From b46f4fcebd06b607183d08f770203b230779a151 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:21:24 +0800 Subject: [PATCH] fix(javascript): preserve loader authority for exact suffixes (#9477) * fix(javascript): require exact loader suffix case * fix(javascript): share exact loader mode across import resolution * test(process): align symlink fixture with filesystem checkpoints * test(javascript): normalize the module resolution oracle path separators * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity * fix(javascript): preserve uncertain loader provenance * test(javascript): cover uncertain require resolution * fix(javascript): retain conditional Electron evidence * fix(javascript): preserve timed semantic provenance * fix(javascript): retain conditional builtin effects * test(javascript): distinguish bundled host authority * fix(javascript): preserve nested loader invocation identity * test(javascript): qualify Electron fixture authority * fix(javascript): preserve loader invocation authority * fix(javascript): respect source order for resolver escapes * fix(javascript): check nested loader callable mutations * fix(javascript): retain conditional constructor requests * fix(reference): retain constructor loader relationships * fix(reference): follow nested loader callable paths * refactor(javascript): share callable mutation interpretation * fix(javascript): require exact package loader suffixes * refactor(javascript): unify conditional relation evidence * refactor(javascript): unify conditional node evidence * fix(javascript): retain unknown authority for mixed case sources * fix(javascript): preserve loader uncertainty for dotfiles * fix(reference): classify javascript suffix dotfiles * test(reference): cover suffix dotfiles through cli * fix(javascript): retain equivalent conditional built-in origins * fix(javascript): preserve wrapped resolver operation * fix(javascript): retain nested conditional built-in receivers * test(javascript): declare CommonJS in native addon fixtures * fix(javascript): retain wrapped resolver candidates * style: format merged native command schema * fix(javascript): preserve uncertain loader authority * test(javascript): require candidate preload evidence * test(javascript): cover reference loader member writes --------- Co-authored-by: ShuaixinHuang <1836656514@qq.com> --- .../javascript/ElectronBoundaryAnalysis.ts | 101 +++- .../ElectronBoundaryGraphCallPaths.ts | 17 + .../ElectronBoundaryGraphContext.ts | 79 ++- .../javascript/ElectronBoundaryGraphIpc.ts | 163 +++-- .../javascript/ElectronBoundaryGraphNative.ts | 85 +-- .../ElectronBoundaryGraphProtocols.ts | 31 +- .../ElectronBoundaryGraphWindows.ts | 127 ++-- .../javascript/JavaScriptArtifactAnalysis.ts | 2 +- .../JavaScriptArtifactGraphBuilder.ts | 6 + ...criptArtifactGraphElectronCoverage.test.ts | 26 + .../JavaScriptArtifactGraphFindings.ts | 105 +++- .../JavaScriptArtifactGraphStructure.test.ts | 26 +- .../JavaScriptModuleRelationships.ts | 137 ++++- .../javascript/JavaScriptPreloadDiscovery.ts | 10 +- .../JavaScriptSemanticGraphAsyncProjection.ts | 55 +- ...riptSemanticGraphChildProcessProjection.ts | 82 ++- .../JavaScriptSemanticGraphConstruction.ts | 5 +- .../JavaScriptSemanticGraphDataProjection.ts | 44 +- ...vaScriptSemanticGraphElectronProjection.ts | 24 +- .../JavaScriptSemanticGraphEvidence.ts | 54 ++ .../JavaScriptSemanticGraphFlowProjection.ts | 9 +- ...vaScriptSemanticGraphResourceProjection.ts | 17 +- .../JavaScriptSemanticGraphResources.test.ts | 30 + .../electronBundledRequireIdentity.test.ts | 14 +- .../javascript/electronModuleInterop.test.ts | 10 +- .../electronNativeLoaderPaths.test.ts | 51 ++ .../javascript/electronStaticAnalysis.ts | 24 +- .../electronStaticAnalysisBrowser.ts | 5 + .../electronStaticAnalysisCandidate.test.ts | 141 +++++ ...nStaticAnalysisComputedPreferences.test.ts | 7 +- .../electronStaticAnalysisIdentity.test.ts | 28 +- .../javascript/electronStaticAnalysisIpc.ts | 4 + .../electronStaticAnalysisNative.ts | 23 +- .../electronStaticAnalysisOptions.test.ts | 48 +- .../electronStaticAnalysisProtocol.ts | 2 + .../electronStaticAnalysisScopedIpc.test.ts | 26 +- .../javascript/electronStaticAnalysisTypes.ts | 18 + .../electronStaticAnalysisValues.ts | 17 +- .../electronStaticReceiverAdmission.test.ts | 11 +- .../javascript/electronStaticRenderer.ts | 31 +- .../electronWrappedCallPaths.test.ts | 42 +- .../javascript/electronWrappedCallPaths.ts | 26 +- .../javascriptBundledRequireOrigin.ts | 38 +- .../javascriptCommonJsAssignAppPath.test.ts | 11 +- .../javascriptCommonJsExportReceivers.ts | 84 ++- ...avascriptFeatureSeed.array_members.test.ts | 4 +- .../javascriptModuleResolutionMode.ts | 35 +- .../javascriptSemanticAmbientWrites.ts | 52 +- ...ticAnalysis.candidate_node_effects.test.ts | 200 +++++++ ...criptSemanticAnalysis.member_depth.test.ts | 1 + ...riptSemanticAnalysis.require_links.test.ts | 44 +- ...scriptSemanticAnalysis.scope_depth.test.ts | 25 +- .../javascript/javascriptSemanticAnalysis.ts | 127 +++- .../javascriptSemanticAnalysisSteps.test.ts | 17 +- .../javascriptSemanticAsyncEffects.ts | 57 +- ...iptSemanticCandidateBuiltinEffects.test.ts | 123 ++++ .../javascriptSemanticChildProcesses.ts | 45 +- .../javascriptSemanticDataEffectHelpers.ts | 395 +++++++++++-- .../javascriptSemanticDataEffects.ts | 73 ++- .../javascriptSemanticFingerprints.ts | 22 +- src/domain/javascript/javascriptSemanticIr.ts | 10 + .../javascriptSemanticMemberMutations.ts | 17 +- .../javascriptSemanticMutationInitializers.ts | 4 +- .../javascriptSemanticProjection.ts | 20 +- .../javascriptSemanticProvenance.test.ts | 18 +- .../javascriptSemanticProvenance.ts | 12 +- ...criptSemanticProvenanceAssignments.test.ts | 7 +- .../javascriptSemanticRequireOrigin.ts | 249 ++++++-- .../javascriptSemanticRequireOriginSlot.ts | 11 +- .../javascript/javascriptSemanticResources.ts | 20 +- .../javascript/javascriptSemanticState.ts | 5 +- .../javascript/javascriptSemanticValues.ts | 41 +- ...vascriptSemanticWrapperAssignments.test.ts | 5 +- .../javascript/javascriptSourceParser.test.ts | 14 +- .../javascript/javascriptSourceParser.ts | 44 +- .../javascriptSourceParserContext.test.ts | 149 +++++ ...scriptStaticAnalysis.sqliteStorage.test.ts | 14 +- .../javascript/javascriptStaticAnalysis.ts | 21 +- ...scriptStaticAnalysisCalleeBoundary.test.ts | 5 +- .../javascriptStaticAnalysisCalls.ts | 132 +++-- ...tStaticAnalysisDelegateEligibility.test.ts | 3 +- ...tStaticAnalysisInvocationIteration.test.ts | 108 +++- ...ptStaticAnalysisInvocationReadSite.test.ts | 36 +- ...iptStaticAnalysisLoaderInvocations.test.ts | 103 +++- ...javascriptStaticAnalysisMainModule.test.ts | 13 + .../javascriptStaticAnalysisState.ts | 15 +- .../javascriptStaticAnalysisTypes.ts | 3 + .../javascriptStaticCoverage.test.ts | 5 +- .../javascriptSymbolicBindings.test.ts | 57 +- .../javascript/javascriptSymbolicBindings.ts | 36 +- .../javascript/javascriptSymbolicValues.ts | 6 +- src/domain/referenceSourceClassification.ts | 21 +- src/domain/referenceSourceImportParsing.ts | 17 +- src/domain/referenceSourceRequireBindings.ts | 99 ++-- src/domain/webBundleAnalyzer.test.ts | 15 +- .../JavaScriptAnalysisWorkerSchemas.test.ts | 71 ++- .../JavaScriptAnalysisWorkerSchemas.ts | 42 +- .../applicationWorkflowCli.test.ts | 68 ++- .../electronDynamicIpcHandlersCli.test.ts | 43 ++ .../javascriptChildProcessStdioCli.test.ts | 4 +- ...avascriptConditionalBuiltinEffects.test.ts | 146 +++++ .../javascriptGraphQueryCoverage.test.ts | 77 +++ ...avascriptModuleResolutionSemantics.test.ts | 38 ++ .../javascriptPackageSuffixCase.test.ts | 106 ++++ .../applications/unboundRequires.test.ts | 555 +++++++++++++++++- .../referenceSourceSuffixDotfileCli.test.ts | 44 ++ .../cli/electronWrappedCallPaths.test.ts | 82 ++- .../javascriptBundlerRuntimeScopes.test.ts | 45 +- .../cli/javascriptRendererEndpoints.test.ts | 2 +- .../electronApplicationAnalysis.test.ts | 4 +- .../referenceSourceLoaderConstructors.test.ts | 161 +++++ .../javascriptArtifactTypeScriptDialect.ts | 14 +- 112 files changed, 5156 insertions(+), 797 deletions(-) create mode 100644 src/domain/javascript/electronStaticAnalysisCandidate.test.ts create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.candidate_node_effects.test.ts create mode 100644 src/domain/javascript/javascriptSemanticCandidateBuiltinEffects.test.ts create mode 100644 tests/acceptance/applications/javascriptConditionalBuiltinEffects.test.ts create mode 100644 tests/acceptance/applications/javascriptPackageSuffixCase.test.ts create mode 100644 tests/acceptance/referenceSourceSuffixDotfileCli.test.ts create mode 100644 tests/boundary/filesystem/referenceSourceLoaderConstructors.test.ts diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index 5d1bc43ca1..0f76b4e2b7 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -90,14 +90,20 @@ export const createElectronIpcContext = ( const uncertain = !coverageComplete || owner == null || + transmission.finding.loader_authority === "candidate" || + matching.some( + ({ finding }) => finding.loader_authority === "candidate", + ) || matching.some((handler) => owners.get(handler.key) == null); return { transmission, candidates, coverageLimitedCandidate: !coverageComplete && + transmission.finding.loader_authority !== "candidate" && owner != null && candidates.length === 1 && + candidates[0]?.finding.loader_authority !== "candidate" && isExactIpcHandler(candidates[0]) && !matching.some((handler) => owners.get(handler.key) == null) ? (candidates[0] ?? null) @@ -143,16 +149,18 @@ export const electronModuleReferenced = ( if ( links !== undefined && links.some( - ({ specifier, kind }) => + ({ specifier, kind, loaderAuthority }) => kind !== "type-import" && + loaderAuthority !== "candidate" && specifier !== null && ELECTRON_MODULE.test(specifier), ) ) return true; return (javascript?.references ?? []).some( - ({ specifier, kind, operation }) => + ({ specifier, kind, operation, loader_authority }) => operation === "load" && + loader_authority !== "candidate" && ELECTRON_REFERENCE_KINDS.has(kind) && specifier !== null && ELECTRON_MODULE.test(specifier), @@ -168,16 +176,48 @@ export const electronBoundaryFactCount = ( count + (javascript === null ? 0 - : javascript.electron.browser_windows.length + - javascript.electron.context_bridge_apis.length + - javascript.electron.ipc.length + - javascript.electron.sender_validations.length + - javascript.electron.utility_processes.length + - javascript.electron.native_addon_bindings.length + - javascript.electron.protocols.length), + : javascript.electron.browser_windows.filter(provenElectronFinding) + .length + + javascript.electron.context_bridge_apis.filter(provenElectronFinding) + .length + + javascript.electron.ipc.filter(provenElectronFinding).length + + javascript.electron.sender_validations.filter(provenElectronFinding) + .length + + javascript.electron.utility_processes.filter(provenElectronFinding) + .length + + javascript.electron.native_addon_bindings.filter( + provenElectronFinding, + ).length + + javascript.electron.protocols.filter(provenElectronFinding).length), 0, ); +const provenElectronFinding = (finding: { + readonly loader_authority?: "candidate" | undefined; +}): boolean => finding.loader_authority !== "candidate"; + +/** Candidate findings remain in the graph, but are not proven summary counts. */ +export const hasCandidateElectronFindings = ( + analysis: JavaScriptArtifactAnalysis, +): boolean => + analysis.files.some( + ({ javascript }) => + javascript !== null && + [ + javascript.electron.browser_windows, + javascript.electron.context_bridge_apis, + javascript.electron.ipc, + javascript.electron.sender_validations, + javascript.electron.utility_processes, + javascript.electron.native_addon_bindings, + javascript.electron.protocols, + javascript.electron.app_path_changes ?? [], + javascript.electron.call_paths ?? [], + ].some((findings) => + findings.some((finding) => !provenElectronFinding(finding)), + ), + ); + /** * Whether the Electron boundary summary came back empty. `undefined` means no * analyzed source names Electron, which is unknown rather than absent: unread @@ -209,36 +249,45 @@ export const summarizeElectronBoundaries = ( const javascript = analysis.files.flatMap(({ javascript: value }) => value === null ? [] : [value], ); - const windows = javascript.flatMap( - ({ electron }) => electron.browser_windows, + const windows = javascript.flatMap(({ electron }) => + electron.browser_windows.filter(provenElectronFinding), ); - const bridges = javascript.flatMap( - ({ electron }) => electron.context_bridge_apis, + const bridges = javascript.flatMap(({ electron }) => + electron.context_bridge_apis.filter(provenElectronFinding), ); - const validations = javascript.flatMap( - ({ electron }) => electron.sender_validations, + const validations = javascript.flatMap(({ electron }) => + electron.sender_validations.filter(provenElectronFinding), ); const utilities = analysis.files.flatMap(({ file, javascript: value }) => - (value?.electron.utility_processes ?? []).map((finding) => ({ - file, - finding, - })), + (value?.electron.utility_processes ?? []) + .filter(provenElectronFinding) + .map((finding) => ({ + file, + finding, + })), ); const nativeBindings = analysis.files.flatMap(({ file, javascript: value }) => - (value?.electron.native_addon_bindings ?? []).map((finding) => ({ - file, - finding, - })), + (value?.electron.native_addon_bindings ?? []) + .filter(provenElectronFinding) + .map((finding) => ({ + file, + finding, + })), + ); + const ipc = ipcContext.records.filter(({ finding }) => + provenElectronFinding(finding), + ); + const pairing = ipcContext.states.filter(({ transmission }) => + provenElectronFinding(transmission.finding), ); - const ipc = ipcContext.records; - const pairing = ipcContext.states; const literalChannels = new Set( ipc.flatMap(({ finding }) => finding.channel === null ? [] : [finding.channel], ), ); const protocolCount = javascript.reduce( - (count, { electron }) => count + electron.protocols.length, + (count, { electron }) => + count + electron.protocols.filter(provenElectronFinding).length, 0, ); // The coverage state is read from the retained analysis so that a rejected diff --git a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts index 59558f0f3b..115d6af39d 100644 --- a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts +++ b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts @@ -10,6 +10,7 @@ import type { } from "./ElectronBoundaryAnalysis.js"; import { addElectronInferenceEdge, + ELECTRON_CANDIDATE_LIMITATION, electronFindingSourceNode, } from "./ElectronBoundaryGraphContext.js"; import { @@ -64,6 +65,9 @@ export const addElectronCallPaths = ( module_runtime: step.module_runtime, conditions: path.conditions, resolution: "candidate", + ...(path.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), }, evidence: staticInferenceEvidence({ sha256: file.sha256, @@ -71,8 +75,14 @@ export const addElectronCallPaths = ( range: step.location, operation: "substitute-electron-wrapper", coverage, + ...(path.loader_authority === "candidate" + ? { confidence: "low" as const } + : {}), limitations: [ "Lexical wrapper substitution is a static candidate; guards, runtime overrides and execution remain unverified.", + ...(path.loader_authority === "candidate" + ? [ELECTRON_CANDIDATE_LIMITATION] + : []), ], }), }, @@ -98,6 +108,7 @@ export const addElectronCallPaths = ( coverage, relation: "contains", operation: "locate-electron-wrapper-path", + loaderAuthority: path.loader_authority, properties: { path_id: pathId, entry: path.entry, @@ -123,6 +134,7 @@ export const addElectronCallPaths = ( ? "maps_to" : "calls", operation: "trace-electron-wrapper-path", + loaderAuthority: path.loader_authority, properties: { path_id: pathId, entry: path.entry, @@ -161,6 +173,11 @@ export const addElectronCallPaths = ( ); for (const from of sources) for (const to of targets) { + if ( + from.path.loader_authority === "candidate" || + to.path.loader_authority === "candidate" + ) + continue; const source = from.nodes.at(-1), target = to.nodes[0]; if (!source || !target) continue; diff --git a/src/application/javascript/ElectronBoundaryGraphContext.ts b/src/application/javascript/ElectronBoundaryGraphContext.ts index f8f96b343a..75385ea46e 100644 --- a/src/application/javascript/ElectronBoundaryGraphContext.ts +++ b/src/application/javascript/ElectronBoundaryGraphContext.ts @@ -33,8 +33,29 @@ export interface ElectronGraphEdgeInput { readonly properties: Readonly>; readonly confidence?: "high" | "medium" | "low"; readonly limitations?: readonly string[]; + readonly loaderAuthority?: "candidate" | undefined; } +/** Candidate syntax does not establish that the receiver is Electron. */ +export const ELECTRON_CANDIDATE_LIMITATION = + "The Electron module loader is unknown, so this relationship is only a candidate."; + +/** Preserve syntax with uncertainty when its Electron receiver is unproven. */ +export const electronFindingEvidence = ( + input: Parameters[0], + loaderAuthority: "candidate" | undefined, +) => + loaderAuthority === "candidate" + ? staticInferenceEvidence({ + ...input, + confidence: "low", + limitations: [ + ...(input.limitations ?? []), + ELECTRON_CANDIDATE_LIMITATION, + ], + }) + : astObservationEvidence(input); + /** Resolve the file or recovered bundle module that owns one finding. */ export const electronFindingSourceNode = ( context: JavaScriptArtifactGraphContext, @@ -69,17 +90,25 @@ export const addElectronAstEdge = ( source_node_id: input.source.node_id, target_node_id: input.target.node_id, relation: input.relation, - properties: input.properties, - evidence: astObservationEvidence({ - sha256: input.file.sha256, - path: input.file.path, - range: input.range, - operation: input.operation, - coverage: input.coverage, - ...(input.limitations === undefined - ? {} - : { limitations: input.limitations }), - }), + properties: { + ...input.properties, + ...(input.loaderAuthority === "candidate" + ? { loader_authority: "candidate" } + : {}), + }, + evidence: electronFindingEvidence( + { + sha256: input.file.sha256, + path: input.file.path, + range: input.range, + operation: input.operation, + coverage: input.coverage, + ...(input.limitations === undefined + ? {} + : { limitations: input.limitations }), + }, + input.loaderAuthority, + ), }); }; @@ -92,19 +121,33 @@ export const addElectronInferenceEdge = ( source_node_id: input.source.node_id, target_node_id: input.target.node_id, relation: input.relation, - properties: input.properties, + properties: { + ...input.properties, + ...(input.loaderAuthority === "candidate" + ? { loader_authority: "candidate" } + : {}), + }, evidence: staticInferenceEvidence({ sha256: input.file.sha256, path: input.file.path, range: input.range, operation: input.operation, coverage: input.coverage, - ...(input.confidence === undefined - ? {} - : { confidence: input.confidence }), - ...(input.limitations === undefined - ? {} - : { limitations: input.limitations }), + ...(input.loaderAuthority === "candidate" + ? { confidence: "low" as const } + : input.confidence === undefined + ? {} + : { confidence: input.confidence }), + ...(input.loaderAuthority === "candidate" + ? { + limitations: [ + ...(input.limitations ?? []), + ELECTRON_CANDIDATE_LIMITATION, + ], + } + : input.limitations === undefined + ? {} + : { limitations: input.limitations }), }), }); }; diff --git a/src/application/javascript/ElectronBoundaryGraphIpc.ts b/src/application/javascript/ElectronBoundaryGraphIpc.ts index cf96a2865b..070259c9a3 100644 --- a/src/application/javascript/ElectronBoundaryGraphIpc.ts +++ b/src/application/javascript/ElectronBoundaryGraphIpc.ts @@ -12,10 +12,7 @@ import { type JavaScriptArtifactGraphContext, type JavaScriptArtifactGraphCoverage, } from "./JavaScriptArtifactGraphContext.js"; -import { - astObservationEvidence, - staticInferenceEvidence, -} from "./JavaScriptArtifactGraphEvidence.js"; +import { staticInferenceEvidence } from "./JavaScriptArtifactGraphEvidence.js"; import type { ElectronIpcContext, ElectronIpcRecord, @@ -23,6 +20,8 @@ import type { import { addElectronAstEdge, addElectronInferenceEdge, + ELECTRON_CANDIDATE_LIMITATION, + electronFindingEvidence, electronFindingSourceNode, electronObservationIdentity, } from "./ElectronBoundaryGraphContext.js"; @@ -111,6 +110,7 @@ const addIpcRecord = ( coverage, relation: record.finding.mode === "invoke" ? "invokes" : "sends", operation: "map-ipc-transmission", + loaderAuthority: record.finding.loader_authority, properties: { side: record.finding.side, operation: record.finding.operation, @@ -150,10 +150,13 @@ const addChannelNode = ( return context.accumulator.addNode({ kind: "ipc-channel", identity: - finding.resolution_basis === "wrapper-substitution" + finding.resolution_basis === "wrapper-substitution" || + finding.loader_authority === "candidate" ? artifactLocalIdentity( file.sha256, - "electron-inferred-ipc-channel", + finding.loader_authority === "candidate" + ? "electron-candidate-ipc-channel" + : "electron-inferred-ipc-channel", identityKey, ) : electronObservationIdentity( @@ -174,36 +177,67 @@ const addChannelNode = ( channel: finding.channel, channel_expression: finding.channel_expression, resolution: finding.channel === null ? "dynamic" : "literal", + ...(finding.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), ...(finding.invocation_location === undefined ? {} : { invocation_location: finding.invocation_location }), }, - evidence: (finding.resolution_basis === "wrapper-substitution" - ? staticInferenceEvidence - : astObservationEvidence)({ - sha256: file.sha256, - path: file.path, - range: finding.location, - operation: "observe-ipc-channel-expression", - coverage, - limitations: [ - ...(finding.receiver === undefined - ? [] - : [ - "The receiver is a source-level allocation, not a runtime frame identity; registration and dispatch remain unverified.", - ]), - ...(finding.resolution_basis !== "wrapper-substitution" - ? [] - : [ - "The channel is inferred through lexical wrapper substitution; runtime registration and dispatch remain unverified.", - ]), - ...(finding.channel !== null - ? [] - : [ - "The channel expression is dynamic; its runtime value and pairings remain unknown.", - ]), - ], - }), + evidence: + finding.resolution_basis !== "wrapper-substitution" + ? electronFindingEvidence( + { + sha256: file.sha256, + path: file.path, + range: finding.location, + operation: "observe-ipc-channel-expression", + coverage, + limitations: [ + ...(finding.receiver === undefined + ? [] + : [ + "The receiver is a source-level allocation, not a runtime frame identity; registration and dispatch remain unverified.", + ]), + ...(finding.channel !== null + ? [] + : [ + "The channel expression is dynamic; its runtime value and pairings remain unknown.", + ]), + ], + }, + finding.loader_authority, + ) + : staticInferenceEvidence({ + sha256: file.sha256, + path: file.path, + range: finding.location, + operation: "observe-ipc-channel-expression", + coverage, + ...(finding.loader_authority === "candidate" + ? { confidence: "low" as const } + : {}), + limitations: [ + ...(finding.loader_authority === "candidate" + ? [ELECTRON_CANDIDATE_LIMITATION] + : []), + ...(finding.receiver === undefined + ? [] + : [ + "The receiver is a source-level allocation, not a runtime frame identity; registration and dispatch remain unverified.", + ]), + ...(finding.resolution_basis !== "wrapper-substitution" + ? [] + : [ + "The channel is inferred through lexical wrapper substitution; runtime registration and dispatch remain unverified.", + ]), + ...(finding.channel !== null + ? [] + : [ + "The channel expression is dynamic; its runtime value and pairings remain unknown.", + ]), + ], + }), }, ], }); @@ -245,19 +279,38 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => { channel_expression: finding.channel_expression, handler_kind: finding.handler_kind, handler_location: finding.handler_location, + ...(finding.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), ...(finding.invocation_location === undefined ? {} : { invocation_location: finding.invocation_location }), }, - evidence: (finding.resolution_basis === "wrapper-substitution" - ? staticInferenceEvidence - : astObservationEvidence)({ - sha256: file.sha256, - path: file.path, - range: handlerRange, - operation: "observe-ipc-handler", - coverage, - }), + evidence: + finding.resolution_basis !== "wrapper-substitution" + ? electronFindingEvidence( + { + sha256: file.sha256, + path: file.path, + range: handlerRange, + operation: "observe-ipc-handler", + coverage, + }, + finding.loader_authority, + ) + : staticInferenceEvidence({ + sha256: file.sha256, + path: file.path, + range: handlerRange, + operation: "observe-ipc-handler", + coverage, + ...(finding.loader_authority === "candidate" + ? { + confidence: "low" as const, + limitations: [ELECTRON_CANDIDATE_LIMITATION], + } + : {}), + }), }, ], }); @@ -270,6 +323,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => { relation: "contains", operation: "locate-ipc-handler", properties: { side: finding.side, operation: finding.operation }, + loaderAuthority: finding.loader_authority, }); addElectronInferenceEdge(context, { source: handler, @@ -279,6 +333,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => { coverage, relation: "handles", operation: "map-ipc-handler-registration", + loaderAuthority: finding.loader_authority, properties: { channel: finding.channel, channel_expression: finding.channel_expression, @@ -362,17 +417,23 @@ const addValidationObservation = (input: ValidationObservationInput): void => { mechanism: validation.mechanism, expected: validation.expected, enforcement: validation.enforcement, + ...(validation.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), }, - evidence: astObservationEvidence({ - sha256: file.sha256, - path: file.path, - range: validation.location, - operation: "observe-ipc-sender-validation-candidate", - coverage, - limitations: [ - "A comparison or string check is visible, but static syntax alone does not prove rejection, ordering, or complete sender validation.", - ], - }), + evidence: electronFindingEvidence( + { + sha256: file.sha256, + path: file.path, + range: validation.location, + operation: "observe-ipc-sender-validation-candidate", + coverage, + limitations: [ + "A comparison or string check is visible, but static syntax alone does not prove rejection, ordering, or complete sender validation.", + ], + }, + validation.loader_authority, + ), }, ], }); diff --git a/src/application/javascript/ElectronBoundaryGraphNative.ts b/src/application/javascript/ElectronBoundaryGraphNative.ts index 1c1ea161f4..08c363abd7 100644 --- a/src/application/javascript/ElectronBoundaryGraphNative.ts +++ b/src/application/javascript/ElectronBoundaryGraphNative.ts @@ -3,15 +3,16 @@ import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSour import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { + artifactLocalIdentity, javascriptAnalysisCoverage, type JavaScriptArtifactGraphContext, type JavaScriptArtifactGraphCoverage, } from "./JavaScriptArtifactGraphContext.js"; -import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js"; import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import { javascriptDisplayText } from "../../domain/javascript/javascriptAstValues.js"; import { addElectronInferenceEdge, + electronFindingEvidence, electronFindingSourceNode, electronObservationIdentity, } from "./ElectronBoundaryGraphContext.js"; @@ -45,15 +46,18 @@ const addNativeBinding = (input: NativeBindingInput): void => { value.module_runtime, ); if (source === undefined) return; - const resolved = resolveArtifactPathByContext({ - declaredPath: value.specifier, - sourcePath: file.path, - context: "module-specifier", - files: context.filesByPath, - jsonDocuments: context.analysis.jsonDocuments, - directoryPaths: context.directoryPaths, - moduleKind: value.module_kind, - }).resolved_path; + const resolved = + value.loader_authority === "candidate" + ? null + : resolveArtifactPathByContext({ + declaredPath: value.specifier, + sourcePath: file.path, + context: "module-specifier", + files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, + directoryPaths: context.directoryPaths, + moduleKind: value.module_kind, + }).resolved_path; // Extension probing can resolve `./addon.node` to `addon.node.js`; only a // native-addon artifact can expose the requested binding. const addon = @@ -61,21 +65,29 @@ const addNativeBinding = (input: NativeBindingInput): void => { context.filesByPath.get(resolved)?.kind !== "native-addon" ? undefined : context.fileNodes.get(resolved); + const bindingKey = compositeKey([ + file.path, + value.specifier, + value.binding_kind, + value.members, + value.namespace_access ?? null, + value.dynamic_member_access ?? null, + javascriptSourceRangeKey(value.location), + ]); const binding = context.accumulator.addNode({ kind: "native-export", - identity: electronObservationIdentity( - context, - "javascript-requested-native-export", - compositeKey([ - file.path, - value.specifier, - value.binding_kind, - value.members, - value.namespace_access ?? null, - value.dynamic_member_access ?? null, - javascriptSourceRangeKey(value.location), - ]), - ), + identity: + value.loader_authority === "candidate" + ? artifactLocalIdentity( + file.sha256, + "javascript-requested-native-export", + bindingKey, + ) + : electronObservationIdentity( + context, + "javascript-requested-native-export", + bindingKey, + ), observations: [ { label: @@ -89,17 +101,23 @@ const addNativeBinding = (input: NativeBindingInput): void => { dynamic_member_access: value.dynamic_member_access ?? null, resolved_path: resolved, native_export_verification: "not-performed", + ...(value.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), }, - evidence: astObservationEvidence({ - sha256: file.sha256, - path: file.path, - range: value.location, - operation: "observe-native-addon-binding", - coverage, - limitations: [ - "JavaScript syntax identifies requested member names; it does not prove that the native binary exports or implements them.", - ], - }), + evidence: electronFindingEvidence( + { + sha256: file.sha256, + path: file.path, + range: value.location, + operation: "observe-native-addon-binding", + coverage, + limitations: [ + "JavaScript syntax identifies requested member names; it does not prove that the native binary exports or implements them.", + ], + }, + value.loader_authority, + ), }, ], }); @@ -111,6 +129,7 @@ const addNativeBinding = (input: NativeBindingInput): void => { coverage, relation: value.binding_kind === "re-export" ? "exposes" : "imports", operation: "map-native-addon-binding", + loaderAuthority: value.loader_authority, properties: { specifier: value.specifier, binding_kind: value.binding_kind, diff --git a/src/application/javascript/ElectronBoundaryGraphProtocols.ts b/src/application/javascript/ElectronBoundaryGraphProtocols.ts index 41b4f2a9c8..c57fc94ff5 100644 --- a/src/application/javascript/ElectronBoundaryGraphProtocols.ts +++ b/src/application/javascript/ElectronBoundaryGraphProtocols.ts @@ -7,9 +7,9 @@ import { type JavaScriptArtifactGraphContext, type JavaScriptArtifactGraphCoverage, } from "./JavaScriptArtifactGraphContext.js"; -import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js"; import { addElectronInferenceEdge, + electronFindingEvidence, electronFindingSourceNode, electronObservationIdentity, } from "./ElectronBoundaryGraphContext.js"; @@ -67,6 +67,9 @@ const addProtocolRegistration = (input: ProtocolInput): void => { role: value.role, session: value.session, enabled_privileges: value.enabled_privileges, + ...(value.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), }; const limitations = scheme !== null @@ -86,10 +89,12 @@ const addProtocolRegistration = (input: ProtocolInput): void => { const node = context.accumulator.addNode({ kind: "url-scheme", identity: - scheme === null + scheme === null || value.loader_authority === "candidate" ? artifactLocalIdentity( file.sha256, - "electron-dynamic-url-scheme", + value.loader_authority === "candidate" + ? "electron-candidate-url-scheme" + : "electron-dynamic-url-scheme", // Identical bytes at two paths are separate occurrences whose // expressions can resolve differently. `${file.path}:${value.api}:${javascriptSourceRangeKey(value.location)}`, @@ -103,14 +108,17 @@ const addProtocolRegistration = (input: ProtocolInput): void => { { label: scheme ?? value.scheme.expression ?? String(value.scheme.value), properties: facts, - evidence: astObservationEvidence({ - sha256: file.sha256, - path: file.path, - range: value.location, - operation: "observe-protocol-registration", - coverage, - limitations, - }), + evidence: electronFindingEvidence( + { + sha256: file.sha256, + path: file.path, + range: value.location, + operation: "observe-protocol-registration", + coverage, + limitations, + }, + value.loader_authority, + ), }, ], }); @@ -123,6 +131,7 @@ const addProtocolRegistration = (input: ProtocolInput): void => { // Granting privileges exposes the scheme; the other roles claim its links. relation: value.role === "privileged-scheme" ? "exposes" : "handles", operation: "map-protocol-registration", + loaderAuthority: value.loader_authority, properties: facts, limitations, }); diff --git a/src/application/javascript/ElectronBoundaryGraphWindows.ts b/src/application/javascript/ElectronBoundaryGraphWindows.ts index 4ed0f48ca0..b0bda84c59 100644 --- a/src/application/javascript/ElectronBoundaryGraphWindows.ts +++ b/src/application/javascript/ElectronBoundaryGraphWindows.ts @@ -14,13 +14,12 @@ import { type JavaScriptArtifactGraphContext, type JavaScriptArtifactGraphCoverage, } from "./JavaScriptArtifactGraphContext.js"; -import { - astObservationEvidence, - staticInferenceEvidence, -} from "./JavaScriptArtifactGraphEvidence.js"; +import { staticInferenceEvidence } from "./JavaScriptArtifactGraphEvidence.js"; import { addElectronAstEdge, addElectronInferenceEdge, + ELECTRON_CANDIDATE_LIMITATION, + electronFindingEvidence, electronFindingSourceNode, } from "./ElectronBoundaryGraphContext.js"; import { @@ -98,6 +97,9 @@ const addBrowserWindow = ( web_preferences: value.web_preferences, preload_path: value.preload_path, preload_resolution_context: value.preload_resolution_context, + ...(value.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), ...(value.resolution_sources === undefined ? {} : { @@ -110,21 +112,40 @@ const addBrowserWindow = ( }), absence_means_default: false, }, - evidence: (value.resolution_sources === undefined - ? astObservationEvidence - : staticInferenceEvidence)({ - sha256: file.sha256, - path: file.path, - range: value.location, - operation: - value.resolution_sources === undefined - ? "observe-browser-window-construction" - : "resolve-browser-window-constant-options", - coverage, - limitations: [ - "Only explicitly present webPreferences are reported; omitted properties are not interpreted as Electron defaults.", - ], - }), + evidence: + value.resolution_sources === undefined + ? electronFindingEvidence( + { + sha256: file.sha256, + path: file.path, + range: value.location, + operation: "observe-browser-window-construction", + coverage, + limitations: [ + "Only explicitly present webPreferences are reported; omitted properties are not interpreted as Electron defaults.", + ], + }, + value.loader_authority, + ) + : staticInferenceEvidence({ + sha256: file.sha256, + path: file.path, + range: value.location, + operation: + value.resolution_sources === undefined + ? "observe-browser-window-construction" + : "resolve-browser-window-constant-options", + coverage, + limitations: [ + "Only explicitly present webPreferences are reported; omitted properties are not interpreted as Electron defaults.", + ...(value.loader_authority === "candidate" + ? [ELECTRON_CANDIDATE_LIMITATION] + : []), + ], + ...(value.loader_authority === "candidate" + ? { confidence: "low" as const } + : {}), + }), }, ], }); @@ -137,6 +158,7 @@ const addBrowserWindow = ( relation: "contains", operation: "locate-browser-window-construction", properties: { source_path: file.path }, + loaderAuthority: value.loader_authority, }); addWindowPreload(input, window); }; @@ -145,7 +167,11 @@ const addWindowPreload = ( input: FindingInput, window: ApplicationNode, ): void => { - if (input.value.preload_path === null) return; + if ( + input.value.preload_path === null || + input.value.loader_authority === "candidate" + ) + return; const { preload_path: declared, preload_resolution_context: resolutionContext, @@ -217,16 +243,35 @@ const addContextBridge = ( members: value.members, unknown_members: value.unknown_members, resolution_basis: value.resolution_basis ?? "direct-syntax", + ...(value.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), }, - evidence: (value.resolution_basis === "wrapper-substitution" - ? staticInferenceEvidence - : astObservationEvidence)({ - sha256: file.sha256, - path: file.path, - range: value.location, - operation: "observe-context-bridge-api", - coverage, - }), + evidence: + value.resolution_basis !== "wrapper-substitution" + ? electronFindingEvidence( + { + sha256: file.sha256, + path: file.path, + range: value.location, + operation: "observe-context-bridge-api", + coverage, + }, + value.loader_authority, + ) + : staticInferenceEvidence({ + sha256: file.sha256, + path: file.path, + range: value.location, + operation: "observe-context-bridge-api", + coverage, + ...(value.loader_authority === "candidate" + ? { + confidence: "low" as const, + limitations: [ELECTRON_CANDIDATE_LIMITATION], + } + : {}), + }), }, ], }); @@ -238,6 +283,7 @@ const addContextBridge = ( coverage, relation: "exposes", operation: "map-context-bridge-exposure", + loaderAuthority: value.loader_authority, properties: { world: value.world, api_key: value.api_key, @@ -251,7 +297,7 @@ const addUtilityProcess = ( ): void => { const { context, file, value, coverage } = input; const resolution = - value.module_path === null + value.module_path === null || value.loader_authority === "candidate" ? undefined : resolveArtifactPathByContext({ declaredPath: value.module_path, @@ -283,14 +329,20 @@ const addUtilityProcess = ( module_resolution_context: value.module_resolution_context, module_expression: value.module_expression, service_name: value.service_name, + ...(value.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), }, - evidence: astObservationEvidence({ - sha256: file.sha256, - path: file.path, - range: value.location, - operation: "observe-utility-process-fork", - coverage, - }), + evidence: electronFindingEvidence( + { + sha256: file.sha256, + path: file.path, + range: value.location, + operation: "observe-utility-process-fork", + coverage, + }, + value.loader_authority, + ), }, ...(resolution === undefined ? [] @@ -318,6 +370,7 @@ const addUtilityProcess = ( coverage, relation: "loads", operation: "map-utility-process-launch", + loaderAuthority: value.loader_authority, limitations: resolution?.limitations ?? [], properties: { declared_path: value.module_path, diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 9c863211aa..4cd483b909 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -129,7 +129,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( signal, onPhase === undefined ? undefined : (phase) => onPhase(file, phase), preloadPaths.has(`${file.container_sha256}:${file.path}`) && - /\.js$/iu.test(file.path) + posix.extname(file.path) === ".js" ? "commonjs" : packageTypeFor(file), ); diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index e57b943528..81bde8333a 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -2,6 +2,7 @@ import type { ElectronBoundarySummary } from "../../domain/javascript/javascript import { electronSemanticsState, createElectronIpcContext, + hasCandidateElectronFindings, summarizeElectronBoundaries, type ElectronIpcContext, } from "./ElectronBoundaryAnalysis.js"; @@ -381,6 +382,11 @@ const graphLimitations = ( "CommonJS and ESM binding relationships were recovered from inert syntax and resolved only within the inventoried artifact container.", "Webpack/Rspack factories were recovered from AST literals; REA did not invoke push handlers or bundle bootstrap code.", "Static imports, entrypoints, workers, endpoints, and storage relationships do not prove runtime execution.", + ...(hasCandidateElectronFindings(context.analysis) + ? [ + "Conditional Electron findings are retained in the graph but excluded from proven boundary summary counts; their loader identity remains unknown.", + ] + : []), ...(context.analysis.files.some(({ javascript }) => javascript?.limitations.includes(ELECTRON_IDENTITY_LIMITATION), ) diff --git a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts index d0a9021658..c48584df8b 100644 --- a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts @@ -118,9 +118,25 @@ const graphFor = async ( }; describe("Electron semantic coverage", () => { + it.each(["main.js", "main.MJS"])( + "does not present candidate Electron boundaries as proven coverage for %s", + async (path) => { + const { limitations, electronSemantics } = await graphFor( + 'const { BrowserWindow } = require("electron"); new BrowserWindow();', + undefined, + path, + ); + expect(electronSemantics).toBeUndefined(); + expect(limitations.join(" ")).toContain("Conditional Electron findings"); + expect(limitations).not.toContain(EMPTY_COVERAGE); + }, + ); + it("reports empty Electron coverage when the module is referenced but nothing is recovered", async () => { const { limitations, electronSemantics } = await graphFor( `const { app } = require("electron");\napp.whenReady();\n`, + undefined, + "main.cjs", ); expect(limitations).toContain(EMPTY_COVERAGE); expect(electronSemantics).toBe("empty"); @@ -129,6 +145,8 @@ describe("Electron semantic coverage", () => { it("reports idle-coverage limits, not empty coverage, once a boundary fact is recovered", async () => { const { limitations, electronSemantics } = await graphFor( `const { ipcMain } = require("electron");\nipcMain.handle("ping", () => 1);\n`, + undefined, + "main.cjs", ); expect(limitations).not.toContain(EMPTY_COVERAGE); expect(limitations).toContainEqual( @@ -171,6 +189,8 @@ describe("Electron semantic coverage", () => { it("matches a supported Electron subpath and ignores an unrelated package", async () => { const subpath = await graphFor( `const { app } = require("electron/main");\napp.whenReady();\n`, + undefined, + "main.cjs", ); expect(subpath.electronSemantics).toBe("empty"); const unrelated = await graphFor( @@ -193,6 +213,7 @@ describe("Electron semantic coverage", () => { limits: null, }, }), + "main.cjs", ); expect(electronSemantics).toBe("empty"); expect(limitations).toContain(EMPTY_COVERAGE); @@ -207,6 +228,7 @@ describe("Electron semantic coverage", () => { ...entry, semantic: null, }), + "main.cjs", ); expect(electronSemantics).toBe("empty"); }); @@ -304,6 +326,8 @@ it("classifies a bundle module by its loader, not by its name", async () => { `100:(e,t,n)=>{"use strict";const s=n(45840);s.ipcMain.handle("x",()=>1)}`, `n(100);`, ), + undefined, + "main.cjs", ); expect(external.electronSemantics).toBe("recovered"); @@ -315,6 +339,8 @@ it("classifies a bundle module by its loader, not by its name", async () => { `100:(e,t,n)=>{"use strict";const s=n("electron");s.ipcMain.handle("x",()=>1)}`, `n(100);`, ), + undefined, + "main.cjs", ); expect(externalUnderKey.electronSemantics).toBe("recovered"); }); diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 49968898b4..8c67d204bd 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -205,7 +205,8 @@ const addReference = ( input.value.module_runtime, ) ?? input.asset; const resolved = - input.value.specifier === null + input.value.specifier === null || + input.value.loader_authority === "candidate" ? null : resolveReference(context, input.file.path, input.value, input.coverage); if ( @@ -319,11 +320,15 @@ const addReference = ( operation: input.value.operation, specifier: input.value.specifier, expression: input.value.expression, + ...(input.value.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), resolution_base_path: input.value.resolution_base_path ?? null, resolution_mode: input.value.kind === "static-import" || input.value.kind === "dynamic-import" || - input.value.kind === "require" + (input.value.kind === "require" && + input.value.loader_authority !== "candidate") ? moduleResolutionModeForJavaScriptReference( input.file.path, input.value.kind, @@ -333,7 +338,18 @@ const addReference = ( resolution_status: resolved?.resolution?.resolution_status ?? null, ...artifactPathResolutionProperties(resolved?.resolution), }, - ...(resolved === null ? {} : { limitations: resolved.limitations }), + ...(input.value.loader_authority === "candidate" + ? { confidence: "low" as const } + : {}), + ...(resolved === null + ? input.value.loader_authority === "candidate" + ? { + limitations: [ + "The module loader is unknown, so this dependency cannot be resolved against the source file.", + ], + } + : {} + : { limitations: resolved.limitations }), }); }; @@ -354,7 +370,7 @@ const addEndpoint = ( const candidateLimitation = input.value.kind === "route" ? "Only the property name suggests a route; the enclosing object has no router fields." - : "The unbound method-like argument is only a candidate; its receiver does not establish a network call."; + : "The receiver or loader identity is unproven; this network endpoint is only a candidate."; const partialLimitation = "The request target is not an exact literal; the endpoint value retains the unresolved source expression."; const label = `${input.value.kind} ${candidate ? "endpoint candidate" : "endpoint"}`; @@ -388,7 +404,10 @@ const addEndpoint = ( operation: "classify-static-endpoint-candidate", coverage: input.coverage, confidence: "low", - limitations: [candidateLimitation], + limitations: [ + candidateLimitation, + ...(partial ? [partialLimitation] : []), + ], }) : astObservationEvidence({ sha256: input.file.sha256, @@ -416,7 +435,10 @@ const addEndpoint = ( ...(candidate ? { confidence: "low" as const, - limitations: [candidateLimitation], + limitations: [ + candidateLimitation, + ...(partial ? [partialLimitation] : []), + ], } : partial ? { limitations: [partialLimitation] } @@ -496,6 +518,10 @@ const addDiscoveredRole = ( input: FindingInput, changedRoots: ReadonlySet | null, ): void => { + if (input.value.loader_authority === "candidate") { + addCandidateRole(context, input); + return; + } const root = applicationRootPath(context, input.file); const resolved = resolveArtifactPathByContext({ declaredPath: input.value.path, @@ -575,6 +601,67 @@ const addDiscoveredRole = ( }); }; +/** Retain the requested path without asserting that an unknown loader creates an Electron role. */ +const addCandidateRole = ( + context: JavaScriptArtifactGraphContext, + input: FindingInput, +): void => { + const limitation = + "The Electron loader is unknown, so the renderer role and target ownership remain unproven."; + const source = + sourceNodeFor( + context, + input.file.path, + input.value.module_key, + input.value.module_runtime, + ) ?? input.asset; + const properties = { + candidate_role: input.value.role, + loader_authority: "candidate", + declared_path: input.value.path, + resolution_status: "unavailable", + resolved_path: null, + mechanism: input.value.mechanism, + ...(input.value.path_base === undefined + ? {} + : { path_base: input.value.path_base }), + }; + const role = context.accumulator.addNode({ + kind: "unknown", + identity: artifactLocalIdentity( + input.file.sha256, + "candidate-electron-role", + JSON.stringify([input.file.path, input.value.path, input.value.location]), + ), + observations: [ + { + label: input.value.path, + properties, + evidence: staticInferenceEvidence({ + sha256: input.file.sha256, + path: input.file.path, + range: input.value.location, + operation: "retain-candidate-electron-role", + coverage: input.coverage, + confidence: "low", + limitations: [limitation], + }), + }, + ], + }); + addStaticInferenceEdge(context, { + source, + target: role, + file: input.file, + range: input.value.location, + coverage: input.coverage, + relation: "loads", + properties, + confidence: "low", + limitations: [limitation], + }); +}; + const addSourceMapEdge = ( context: JavaScriptArtifactGraphContext, input: FindingInput, @@ -925,6 +1012,9 @@ const unresolvedReferenceNode = ( reference_kind: reference.kind, specifier: reference.specifier, expression: reference.expression, + ...(reference.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), resolution: "unresolved", ...(resolution === undefined ? {} @@ -940,6 +1030,9 @@ const unresolvedReferenceNode = ( confidence: "low", limitations: [ "The static reference could not be resolved within the inventoried artifact.", + ...(reference.loader_authority === "candidate" + ? ["The module loader is unknown."] + : []), ...(resolution?.limitations ?? []), ], }), diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts index b7cafd38ed..e802b09016 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts @@ -5,7 +5,11 @@ import { createHash } from "node:crypto"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; -import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; +import { + analyzeJavaScriptStaticSource, + analyzeParsedJavaScriptStaticSource, +} from "../../domain/javascript/javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; import type { AnalyzedJavaScriptArtifactFile, JavaScriptArtifactAnalysis, @@ -25,6 +29,13 @@ const SHA256 = "a".repeat(64); const REASON = "main.js: Resource constraint during analyze_javascript_application: Owned JavaScript worker exhausted its heap while analyzing main.js during static"; +/** Preserve the explicit artifact filename when analyzing owned test fixtures. */ +const analyzeOwnedSource = (source: string, path: string) => { + const parsed = parseJavaScriptSource(source, path); + if (parsed === null) throw new Error(`Unable to parse ${path}`); + return analyzeParsedJavaScriptStaticSource(source, parsed); +}; + const skippedFile = (): JavaScriptArtifactFile => ({ path: "main.js", container_sha256: SHA256, @@ -245,15 +256,17 @@ it.each([false, true])( const analysis = analysisFor([ { file: setter, - javascript: analyzeJavaScriptStaticSource( + javascript: analyzeOwnedSource( 'const { app } = require("electron"); app.setAppPath("/changed");', + setter.path, ), semantic: null, }, { file: window, - javascript: analyzeJavaScriptStaticSource( + javascript: analyzeOwnedSource( 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");', + window.path, ), semantic: null, }, @@ -361,20 +374,21 @@ it.each([ const entries: AnalyzedJavaScriptArtifactFile[] = [ { file: main, - javascript: analyzeJavaScriptStaticSource(statement + window), + javascript: analyzeOwnedSource(statement + window, main.path), semantic: null, }, { file: other, - javascript: analyzeJavaScriptStaticSource(window), + javascript: analyzeOwnedSource(window, other.path), semantic: null, }, ...(gap === "transitive-unselected" ? [ { file: makeFile("a/middle.cjs"), - javascript: analyzeJavaScriptStaticSource( + javascript: analyzeOwnedSource( 'require("./change.cjs");', + "a/middle.cjs", ), semantic: null, }, diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index a59c8c048d..63b3b31bf4 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -19,6 +19,8 @@ import type { JavaScriptSemanticModuleLink, } from "../../domain/javascript/javascriptSemanticIr.js"; import { + explicitJavaScriptLoaderMode, + hasCaseVariantJavaScriptLoaderSuffix, isJsonModuleTarget, moduleResolutionModeForJavaScriptReference, } from "../../domain/javascript/javascriptModuleResolutionMode.js"; @@ -88,8 +90,12 @@ export const addJavaScriptSourceModules = ( if (asset === undefined || semantic === null || program === undefined) continue; const exports = semantic.ir.moduleLinks - .flatMap(({ exportedName, kind }) => - exportedName === null || kind === "type-import" ? [] : [exportedName], + .flatMap(({ exportedName, kind, loaderAuthority }) => + exportedName === null || + kind === "type-import" || + loaderAuthority === "candidate" + ? [] + : [exportedName], ) .sort(compareUnicodeCodePoints); const typeExports = semantic.ir.moduleLinks @@ -97,7 +103,17 @@ export const addJavaScriptSourceModules = ( exportedName === null || kind !== "type-import" ? [] : [exportedName], ) .sort(compareUnicodeCodePoints); - const retainedExports = [...new Set(exports)]; + const retainedExportSet = new Set(exports); + const retainedExports = [...retainedExportSet]; + const candidateExportNames = new Set( + semantic.ir.moduleLinks.flatMap(({ exportedName, loaderAuthority }) => + exportedName !== null && + loaderAuthority === "candidate" && + !retainedExportSet.has(exportedName) + ? [exportedName] + : [], + ), + ); const source = context.accumulator.addNode({ kind: "javascript-module", identity: artifactLocalIdentity( @@ -124,7 +140,7 @@ export const addJavaScriptSourceModules = ( export_relationships: exports.length, export_names: retainedExports, type_export_names: [...new Set(typeExports)], - omitted_export_names: 0, + omitted_export_names: candidateExportNames.size, }, evidence: astObservationEvidence({ sha256: file.sha256, @@ -132,7 +148,14 @@ export const addJavaScriptSourceModules = ( range: program.location, operation: "recover-source-module", coverage: semanticCoverage(semantic), - limitations: semantic.ir.limitations, + limitations: [ + ...semantic.ir.limitations, + ...(candidateExportNames.size === 0 + ? [] + : [ + "Candidate CommonJS export names are not included in the proven export names; the candidate relationships are retained separately.", + ]), + ], }), }, ], @@ -189,12 +212,17 @@ const addExportRelationship = ( const { context, file, semantic, source, link } = input; if (link.exportedName === null) return; const baseCoverage = semanticCoverage(semantic); - const returnShapes = projectJavaScriptExportReturnShapes({ - ir: semantic.ir, - link, - modulePath: file.path, - baseCoverage, - }); + const candidate = link.loaderAuthority === "candidate"; + const candidateLimitation = + "The module wrapper is unknown, so this export is only a candidate."; + const returnShapes = candidate + ? null + : projectJavaScriptExportReturnShapes({ + ir: semantic.ir, + link, + modulePath: file.path, + baseCoverage, + }); const exported = context.accumulator.addNode({ kind: "javascript-module", identity: artifactLocalIdentity( @@ -214,14 +242,20 @@ const addExportRelationship = ( local_name: link.localName, imported_name: link.importedName, declared_specifier: link.specifier, + ...(candidate ? { loader_authority: "candidate" } : {}), }, - evidence: astObservationEvidence({ + evidence: (candidate + ? staticInferenceEvidence + : astObservationEvidence)({ sha256: file.sha256, path: file.path, range: link.location, operation: "recover-module-export", coverage: baseCoverage, - limitations: semantic.ir.limitations, + limitations: candidate + ? [...semantic.ir.limitations, candidateLimitation] + : semantic.ir.limitations, + ...(candidate ? { confidence: "low" as const } : {}), }), }, ...(returnShapes === null @@ -247,7 +281,12 @@ const addExportRelationship = ( target_node_id: exported.node_id, relation: "exposes", properties: relationshipProperties(link, null), - evidence: relationshipEvidence(input, "expose-module-export", []), + evidence: relationshipEvidence( + input, + "expose-module-export", + candidate ? [candidateLimitation] : [], + candidate ? "low" : "high", + ), }); const origin = moduleOriginForExport(semantic.ir, link); if (origin !== null) @@ -260,7 +299,7 @@ const addImportRelationship = ( omissions: RelationshipOmissionCounter, origin: JavaScriptModuleOrigin, ): void => { - const target = resolveModuleTarget(input, origin.specifier); + const target = resolveModuleTarget(input, origin); // A specifier that resolves back to the importing module itself (a literal // self-import, self-require, or artifact-confined path alias) // cannot become an edge: the application graph forbids self-referential @@ -289,8 +328,34 @@ const addImportRelationship = ( const resolveModuleTarget = ( input: RelationshipInput, - specifier: string, + origin: JavaScriptModuleOrigin, ): ResolvedModuleTarget => { + const specifier = origin.specifier; + if ( + input.link.loaderAuthority === "candidate" || + origin.loaderAuthority === "candidate" + ) { + // The syntax retains a possible dependency, but an unproven wrapper + // cannot establish the loader or its lookup base. + const resolution: ArtifactPathResolution = { + declared_path: specifier, + resolution_context: "module-specifier", + resolution_status: "unavailable", + resolved_path: null, + limitations: [ + "The module loader is unknown, so this dependency cannot be resolved against the source file.", + ], + }; + return { + node: unresolvedModuleNode(input, specifier, resolution), + path: null, + file: null, + jsonStatus: null, + status: resolution.resolution_status, + limitations: resolution.limitations, + resolution, + }; + } const resolution = resolveArtifactPathByContext({ declaredPath: specifier, sourcePath: input.file.path, @@ -370,7 +435,10 @@ const unresolvedModuleNode = ( operation: "retain-unresolved-semantic-module", coverage: semanticCoverage(input.semantic), confidence: "low", - limitations: input.semantic.ir.limitations, + limitations: [ + ...input.semantic.ir.limitations, + ...resolution.limitations, + ], }), }, ], @@ -387,6 +455,9 @@ const moduleOriginForExport = ( : { resolutionMode: link.resolutionMode }), specifier: link.specifier, importedPath: link.importedName === null ? [] : [link.importedName], + ...(link.loaderAuthority === "candidate" + ? { loaderAuthority: "candidate" as const } + : {}), }; if (link.localName === null) return null; const program = ir.scopes.find(({ kind }) => kind === "program"); @@ -394,9 +465,14 @@ const moduleOriginForExport = ( ({ name, scopeId }) => name === link.localName && scopeId === program?.scopeId, ); - return binding?.provenance.status === "module" && - binding.provenance.origins.length === 1 - ? (binding.provenance.origins[0] ?? null) + const provenance = binding?.provenance; + if (provenance === undefined || provenance.origins.length !== 1) return null; + const origin = provenance.origins[0]; + return origin !== undefined && + (provenance.status === "module" || + (provenance.status === "ambiguous" && + origin.loaderAuthority === "candidate")) + ? origin : null; }; @@ -406,7 +482,14 @@ const relationshipProperties = ( origin?: JavaScriptModuleOrigin, ) => ({ module_link_kind: link.kind, - ...moduleResolutionModeProperties(link.resolutionMode), + ...(link.loaderAuthority === "candidate" || + origin?.loaderAuthority === "candidate" + ? { loader_authority: "candidate" } + : {}), + ...(link.loaderAuthority === "candidate" || + origin?.loaderAuthority === "candidate" + ? { resolution_mode: null } + : moduleResolutionModeProperties(link.resolutionMode)), specifier: origin?.specifier ?? link.specifier, imported_name: origin?.importedPath.at(-1) ?? link.importedName, imported_path: origin?.importedPath ?? [], @@ -457,15 +540,19 @@ const moduleFormat = ( path: string, ir: JavaScriptModuleSemanticIr, ): "commonjs" | "esm" | "mixed" | "unknown" => { + const loader = explicitJavaScriptLoaderMode(path); + if (loader === "module") return "esm"; + if (loader === "commonjs") return "commonjs"; + if (hasCaseVariantJavaScriptLoaderSuffix(path)) return "unknown"; const extension = posix.extname(path).toLowerCase(); - if (extension === ".mjs" || extension === ".mts") return "esm"; - if (extension === ".cjs" || extension === ".cts") return "commonjs"; if (extension === ".ts" || extension === ".tsx") return "unknown"; const esm = ir.moduleLinks.some(({ kind }) => ["import", "export", "re-export"].includes(kind), ); - const commonJs = ir.moduleLinks.some(({ kind }) => - ["require", "commonjs-export"].includes(kind), + const commonJs = ir.moduleLinks.some( + ({ kind, loaderAuthority }) => + loaderAuthority !== "candidate" && + ["require", "commonjs-export"].includes(kind), ); return esm && commonJs ? "mixed" diff --git a/src/application/javascript/JavaScriptPreloadDiscovery.ts b/src/application/javascript/JavaScriptPreloadDiscovery.ts index ab2ab67425..d45d7cf5f1 100644 --- a/src/application/javascript/JavaScriptPreloadDiscovery.ts +++ b/src/application/javascript/JavaScriptPreloadDiscovery.ts @@ -1,3 +1,5 @@ +import { posix } from "node:path"; + import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { @@ -41,7 +43,7 @@ export const discoverJavaScriptPreloadPaths = async ( (file) => file.kind === "javascript" && file.text.included && - /\.js$/iu.test(file.path) && + posix.extname(file.path) === ".js" && packageTypeFor(file) === "module", ) ) @@ -87,7 +89,11 @@ export const discoverJavaScriptPreloadPaths = async ( break; } for (const window of result.value.javascript.electron.browser_windows) { - if (window.preload_path === null) continue; + if ( + window.preload_path === null || + window.loader_authority === "candidate" + ) + continue; const resolution = resolveArtifactPathByContext({ declaredPath: window.preload_path, sourcePath: file.path, diff --git a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts index 6c8ac206d0..9e597bc565 100644 --- a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts @@ -9,7 +9,11 @@ import { addSemanticGraphRelation, addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js"; -import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + unknownSemanticEvidence, + builtinSemanticNode, + builtinSemanticRelation, +} from "./JavaScriptSemanticGraphEvidence.js"; import { projectSemanticResultBindings, type SemanticFlowProjectionContext, @@ -158,10 +162,10 @@ export const projectSemanticTimers = ( location: operation.location, label: operation.method, functionNodeId: owner?.node_id ?? null, - properties: { + ...builtinSemanticNode(context.file, operation, { delay_milliseconds: operation.delayMilliseconds, method: operation.method, - }, + }), }); return timer === null ? [] : [[operation.timerId, timer] as const]; }), @@ -177,17 +181,39 @@ const projectTimerSchedule = ( operation: JavaScriptSemanticTimerOperation, timerNodes: ReadonlyMap, ): void => { + const authority = builtinSemanticRelation( + context.file, + operation, + "resolved", + ); + const candidateEvidence = authority.evidence; addSemanticGraphRelation(context.state, { source: context.callSiteAt(operation.location), target: timerNodes.get(operation.timerId), relation: "schedules-timer", - resolution: "resolved", + ...authority, + ...(candidateEvidence === undefined + ? {} + : { + properties: { builtin_authority: "candidate" }, + }), }); projectSemanticResultBindings( context, timerNodes.get(operation.timerId), operation.projectedResultBindings, + candidateEvidence, ); + if (candidateEvidence !== undefined) + addSemanticGraphUnknown(context.state, { + node_id: timerNodes.get(operation.timerId)?.node_id ?? null, + family: "timer", + relation_kinds: ["schedules-timer"], + reason: "ambiguous-target", + detail: "The timer identity is conditional on loader or mutation facts.", + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }); }; const projectTimerCancellation = ( @@ -196,6 +222,12 @@ const projectTimerCancellation = ( timerNodes: ReadonlyMap, ): void => { const callSite = context.callSiteAt(operation.location); + const authority = builtinSemanticRelation( + context.file, + operation, + operation.resolution === "complete" ? "resolved" : "candidate", + ); + const candidateEvidence = authority.evidence; const target = operation.linkedTimerId === null ? undefined @@ -204,15 +236,24 @@ const projectTimerCancellation = ( source: callSite, target, relation: "cancels-timer", - resolution: operation.resolution === "complete" ? "resolved" : "candidate", + ...authority, + ...(candidateEvidence === undefined + ? {} + : { + properties: { builtin_authority: "candidate" }, + }), }); - if (operation.resolution === "complete") return; + if (operation.resolution === "complete" && candidateEvidence === undefined) + return; addSemanticGraphUnknown(context.state, { node_id: callSite?.node_id ?? null, family: "timer", relation_kinds: ["cancels-timer"], reason: "ambiguous-target", - detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`, + detail: + candidateEvidence === undefined + ? `Static ${operation.method} handle resolution was ${operation.resolution}.` + : `The ${operation.method} identity is conditional on loader or mutation facts.`, candidate_node_ids: [], evidence: unknownSemanticEvidence(context.file, operation.location), }); diff --git a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts index de01c7af3d..486e76eeba 100644 --- a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts @@ -8,7 +8,11 @@ import { addSemanticGraphRelation, addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js"; -import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + unknownSemanticEvidence, + builtinSemanticNode, + builtinSemanticRelation, +} from "./JavaScriptSemanticGraphEvidence.js"; import { projectSemanticResultBindings, type SemanticFlowProjectionContext, @@ -62,13 +66,13 @@ const addChildProcessNode = ( spawn.ownerCallableId === null ? null : (context.callableNodes.get(spawn.ownerCallableId)?.node_id ?? null), - properties: { + ...builtinSemanticNode(context.file, spawn, { argv_count: spawn.argvCount, command: spawn.command, environment_supplied: spawn.environmentSupplied, method: spawn.method, stdio_mode: spawn.stdioMode, - }, + }), }); const projectSpawn = ( @@ -77,19 +81,38 @@ const projectSpawn = ( child: JavaScriptSemanticGraphNode, ): void => { const callSite = context.callSiteAt(spawn.location); + const authority = builtinSemanticRelation( + context.file, + spawn, + spawn.resolution === "complete" ? "resolved" : "candidate", + ); + const candidateEvidence = authority.evidence; addSemanticGraphRelation(context.state, { source: callSite, target: child, relation: "spawns", - resolution: spawn.resolution === "complete" ? "resolved" : "candidate", + ...authority, + ...(candidateEvidence === undefined + ? {} + : { + properties: { builtin_authority: "candidate" }, + }), }); - projectSemanticResultBindings(context, child, spawn.projectedResultBindings); + projectSemanticResultBindings( + context, + child, + spawn.projectedResultBindings, + candidateEvidence, + ); if (spawn.argvCount !== null) addSemanticGraphRelation(context.state, { source: callSite, target: child, relation: "supplies-argv", - resolution: "resolved", + resolution: candidateEvidence === undefined ? "resolved" : "candidate", + ...(candidateEvidence === undefined + ? {} + : { evidence: candidateEvidence }), properties: { argument_count: spawn.argvCount }, }); if (spawn.environmentSupplied) @@ -97,14 +120,21 @@ const projectSpawn = ( source: callSite, target: child, relation: "supplies-env", - resolution: "resolved", + resolution: candidateEvidence === undefined ? "resolved" : "candidate", + ...(candidateEvidence === undefined + ? {} + : { evidence: candidateEvidence }), }); const stdio = addStdioNode(context, spawn, child); addSemanticGraphRelation(context.state, { source: child, target: stdio, relation: "connects-stdio", - resolution: spawn.stdioMode === "dynamic" ? "candidate" : "resolved", + resolution: + candidateEvidence !== undefined || spawn.stdioMode === "dynamic" + ? "candidate" + : "resolved", + ...(candidateEvidence === undefined ? {} : { evidence: candidateEvidence }), }); if (spawn.stdioMode === "dynamic") addChildUnknown({ @@ -116,6 +146,16 @@ const projectSpawn = ( detail: `Static ${spawn.method} stdio options remain unresolved.`, candidateNodeIds: [], }); + if (candidateEvidence !== undefined) + addChildUnknown({ + context, + node: child, + location: spawn.location, + relationKinds: ["spawns"], + detail: + "The child-process identity is conditional on loader or mutation facts.", + candidateNodeIds: [child.node_id], + }); if (spawn.resolution !== "complete") addChildUnknown({ context, @@ -138,7 +178,9 @@ const addStdioNode = ( location: spawn.location, label: spawn.stdioMode, functionNodeId: child.function_node_id, - properties: { mode: spawn.stdioMode }, + ...builtinSemanticNode(context.file, spawn, { + mode: spawn.stdioMode, + }), }); const projectInteraction = ( @@ -146,6 +188,12 @@ const projectInteraction = ( interaction: JavaScriptSemanticChildProcessInteraction, processNodes: ReadonlyMap, ): void => { + const authority = builtinSemanticRelation( + context.file, + interaction, + interaction.resolution === "complete" ? "resolved" : "candidate", + ); + const candidateEvidence = authority.evidence; const children = interaction.linkedProcessIds.flatMap((identifier) => { const child = processNodes.get(identifier); return child === undefined ? [] : [child]; @@ -164,8 +212,12 @@ const projectInteraction = ( : interaction.eventName === "exit" ? "listens-exit" : "listens-error", - resolution: - interaction.resolution === "complete" ? "resolved" : "candidate", + ...authority, + ...(candidateEvidence === undefined + ? {} + : { + properties: { builtin_authority: "candidate" }, + }), }); if (interaction.resolution !== "complete") addChildUnknown({ @@ -198,10 +250,10 @@ const addChildListenerNode = ( ? null : (context.callableNodes.get(interaction.ownerCallableId)?.node_id ?? null), - properties: { + ...builtinSemanticNode(context.file, interaction, { event_name: interaction.eventName, method: interaction.method, - }, + }), }); const addSignalNode = ( @@ -218,7 +270,9 @@ const addSignalNode = ( ? null : (context.callableNodes.get(interaction.ownerCallableId)?.node_id ?? null), - properties: { signal_name: interaction.signalName }, + ...builtinSemanticNode(context.file, interaction, { + signal_name: interaction.signalName, + }), }); const addChildUnknown = (input: ChildUnknownInput): void => { diff --git a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts index 08ffbd11c6..0377e281a6 100644 --- a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts +++ b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts @@ -64,6 +64,8 @@ export interface SemanticNodeConstructionInput { readonly label: string | null; readonly functionNodeId: string | null; readonly properties?: Readonly>; + /** Evidence for inferred semantic identities; defaults to exact syntax. */ + readonly evidence?: ApplicationGraphEvidence; } /** Input for one directed static semantic relationship. */ @@ -131,7 +133,8 @@ export const constructSemanticGraphNode = ( application_node_ids: [], label: input.label === null ? null : javascriptDisplayText(input.label), properties: input.properties ?? {}, - evidence: observedSemanticEvidence(file, input.location), + evidence: + input.evidence ?? observedSemanticEvidence(file, input.location), }, state.evidenceContexts, ); diff --git a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts index 7489e601e2..69fe3cdf52 100644 --- a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts @@ -9,7 +9,11 @@ import { addSemanticGraphRelation, addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js"; -import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + builtinSemanticNode, + builtinSemanticRelation, + unknownSemanticEvidence, +} from "./JavaScriptSemanticGraphEvidence.js"; import { projectSemanticResultBindings, type SemanticFlowProjectionContext, @@ -38,16 +42,22 @@ export const projectSemanticConfiguration = ( source: config, target, relation: configurationRelation(operation), - resolution: + ...builtinSemanticRelation( + context.file, + operation, operation.resolution === "complete" ? "resolved" : "candidate", + ), }); if (operation.kind === "default" && operation.sourceConfigId !== null) addSemanticGraphRelation(context.state, { source: configNodes.get(operation.sourceConfigId), target: config, relation: "overrides", - resolution: + ...builtinSemanticRelation( + context.file, + operation, operation.resolution === "complete" ? "resolved" : "candidate", + ), }); if (operation.resolution !== "complete") addDataUnknown({ @@ -89,14 +99,14 @@ const addConfigurationNode = ( ? null : (context.callableNodes.get(operation.ownerCallableId)?.node_id ?? null), - properties: { + ...builtinSemanticNode(context.file, operation, { key: operation.key, source_kind: operation.kind, value: operation.value, ...(operation.valueResourceLimit === undefined ? {} : { value_resource_limit: operation.valueResourceLimit }), - }, + }), }); const configurationRelation = ( @@ -163,11 +173,11 @@ const addRequestNode = ( ? null : (context.callableNodes.get(operation.ownerCallableId)?.node_id ?? null), - properties: { + ...builtinSemanticNode(context.file, operation, { endpoint: operation.endpoint, method: operation.method, operation_kind: operation.kind, - }, + }), }); const projectRequestConstruction = ( @@ -176,16 +186,22 @@ const projectRequestConstruction = ( request: JavaScriptSemanticGraphNode, ): void => { const callSite = context.callSiteAt(operation.location); + const authority = builtinSemanticRelation( + context.file, + operation, + operation.resolution === "complete" ? "resolved" : "candidate", + ); addSemanticGraphRelation(context.state, { source: callSite, target: request, relation: "constructs-request", - resolution: operation.resolution === "complete" ? "resolved" : "candidate", + ...authority, }); projectSemanticResultBindings( context, request, operation.projectedResultBindings, + authority.evidence, ); for (const field of operation.fields) addSemanticGraphRelation(context.state, { @@ -195,7 +211,11 @@ const projectRequestConstruction = ( : context.bindingNodes.get(field.sourceBindingId), target: request, relation: "supplies-request-field", - resolution: field.sourceBindingId === null ? "candidate" : "resolved", + ...builtinSemanticRelation( + context.file, + operation, + field.sourceBindingId === null ? "candidate" : "resolved", + ), properties: { field: field.name }, }); }; @@ -209,7 +229,11 @@ const projectResponseConsumer = ( source: response, target: context.callSiteAt(operation.location), relation: "consumed-by", - resolution: operation.resolution === "complete" ? "resolved" : "candidate", + ...builtinSemanticRelation( + context.file, + operation, + operation.resolution === "complete" ? "resolved" : "candidate", + ), properties: { method: operation.method }, }); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts index 4491c4781e..0228ce57cf 100644 --- a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts @@ -7,6 +7,7 @@ import { type SemanticGraphProjectionState, } from "./JavaScriptSemanticGraphConstruction.js"; import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js"; +import { ELECTRON_CANDIDATE_LIMITATION } from "./ElectronBoundaryGraphContext.js"; /** Give discovered process-boundary paths budget before bulk library definitions. */ export function* projectElectronSemanticCallPaths( @@ -20,6 +21,18 @@ export function* projectElectronSemanticCallPaths( let previous: ReturnType = null; let previousKind: ElectronCallPath["steps"][number]["kind"] | undefined; for (const [index, step] of path.steps.entries()) { + const inferred = inferredSemanticEvidenceAt(file, step.location); + const candidateEvidence = + path.loader_authority === "candidate" + ? { + ...inferred, + confidence: "low" as const, + limitations: [ + ...inferred.limitations, + ELECTRON_CANDIDATE_LIMITATION, + ], + } + : undefined; const node = retainSemanticGraphNode(state, file, { kind: step.kind === "function" @@ -31,6 +44,9 @@ export function* projectElectronSemanticCallPaths( location: step.location, label: step.label, functionNodeId: null, + ...(candidateEvidence === undefined + ? {} + : { evidence: candidateEvidence }), properties: { path_id: pathId, entry: path.entry, @@ -39,6 +55,9 @@ export function* projectElectronSemanticCallPaths( : { entry_path: [...path.entry_path] }), channel: path.channel, terminal: path.terminal, + ...(path.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), step_kind: step.kind, resolution: "candidate", conditions: path.conditions.map((c) => ({ @@ -61,7 +80,7 @@ export function* projectElectronSemanticCallPaths( ? "aliases" : "calls", resolution: "candidate", - evidence: inferredSemanticEvidenceAt(file, step.location), + evidence: candidateEvidence ?? inferred, properties: { path_id: pathId, entry: path.entry, @@ -70,6 +89,9 @@ export function* projectElectronSemanticCallPaths( : { entry_path: [...path.entry_path] }), channel: path.channel, basis: "lexical-wrapper-substitution", + ...(path.loader_authority === "candidate" + ? { loader_authority: "candidate" } + : {}), }, }); previous = node; diff --git a/src/application/javascript/JavaScriptSemanticGraphEvidence.ts b/src/application/javascript/JavaScriptSemanticGraphEvidence.ts index d408170f77..ccc5203375 100644 --- a/src/application/javascript/JavaScriptSemanticGraphEvidence.ts +++ b/src/application/javascript/JavaScriptSemanticGraphEvidence.ts @@ -1,6 +1,7 @@ import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; const INFERENCE_LIMITATION = "This relationship is a conservative static candidate; it does not prove runtime execution or causal flow."; @@ -64,6 +65,59 @@ export const inferredSemanticEvidenceAt = ( limitations: [INFERENCE_LIMITATION], }); +/** A built-in operation whose identity is conditional on loader or mutation facts. */ +export const candidateBuiltinSemanticEvidence = ( + file: JavaScriptArtifactFile, + location: JavaScriptSourceRange, +): ApplicationGraphEvidence => { + const evidence = inferredSemanticEvidenceAt(file, location); + return { + ...evidence, + confidence: "low", + limitations: [ + ...evidence.limitations, + "The built-in identity or its mutation state is unproven; this operation is conditional.", + ], + }; +}; + +type SemanticBuiltinOperation = { + readonly builtinAuthority?: "candidate" | undefined; + readonly location: JavaScriptSourceRange; +}; + +/** Keep the candidate marker and its Evidence together on built-in nodes. */ +export const builtinSemanticNode = ( + file: JavaScriptArtifactFile, + operation: SemanticBuiltinOperation, + properties: Readonly>, +): { + readonly properties: Readonly>; + readonly evidence?: ApplicationGraphEvidence; +} => + operation.builtinAuthority === "candidate" + ? { + evidence: candidateBuiltinSemanticEvidence(file, operation.location), + properties: { ...properties, builtin_authority: "candidate" }, + } + : { properties }; + +/** Keep conditional built-in authority and relation resolution together. */ +export const builtinSemanticRelation = ( + file: JavaScriptArtifactFile, + operation: SemanticBuiltinOperation, + resolution: "resolved" | "candidate", +): { + readonly resolution: "resolved" | "candidate"; + readonly evidence?: ApplicationGraphEvidence; +} => + operation.builtinAuthority === "candidate" + ? { + resolution: "candidate", + evidence: candidateBuiltinSemanticEvidence(file, operation.location), + } + : { resolution }; + /** Explicit absence of semantic syntax for an artifact-level fallback root. */ export const unavailableSemanticRootEvidence = ( rootArtifactSha256: string, diff --git a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts index 06fb86bdf1..c77502ff5f 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts @@ -1,5 +1,6 @@ import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; +import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticIr, @@ -45,6 +46,7 @@ export const projectSemanticResultBindings = ( context: Pick, source: JavaScriptSemanticGraphNode | undefined, bindings: readonly JavaScriptSemanticProjectedResultBinding[], + candidateEvidence?: ApplicationGraphEvidence, ): void => { for (const projected of bindings) addSemanticGraphRelation(context.state, { @@ -52,7 +54,12 @@ export const projectSemanticResultBindings = ( target: context.bindingNodes.get(projected.bindingId), relation: "destructures", resolution: - projected.resolution === "complete" ? "resolved" : "candidate", + candidateEvidence === undefined && projected.resolution === "complete" + ? "resolved" + : "candidate", + ...(candidateEvidence === undefined + ? {} + : { evidence: candidateEvidence }), properties: { projection_path: [...projected.projectionPath], projection_resolution: projected.resolution, diff --git a/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts b/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts index 24784f439c..fd0954d4bb 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts @@ -5,7 +5,11 @@ import { addSemanticGraphRelation, addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js"; -import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + builtinSemanticNode, + builtinSemanticRelation, + unknownSemanticEvidence, +} from "./JavaScriptSemanticGraphEvidence.js"; import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; /** Project built-in resource acquisition and exact local release handles. */ @@ -25,7 +29,7 @@ export const projectSemanticResources = ( source: context.callSiteAt(operation.location), target: resourceNodes.get(operation.resourceId), relation: "acquires", - resolution: "resolved", + ...builtinSemanticRelation(context.file, operation, "resolved"), }); else projectRelease(context, operation, resourceNodes); }; @@ -44,7 +48,9 @@ const addResourceNode = ( ? null : (context.callableNodes.get(operation.ownerCallableId)?.node_id ?? null), - properties: { method: operation.method }, + ...builtinSemanticNode(context.file, operation, { + method: operation.method, + }), }); const projectRelease = ( @@ -62,8 +68,11 @@ const projectRelease = ( source: callSite, target: resource, relation: "releases", - resolution: + ...builtinSemanticRelation( + context.file, + operation, operation.resolution === "complete" ? "resolved" : "candidate", + ), }); if (operation.resolution === "complete") return; addSemanticGraphUnknown(context.state, { diff --git a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts index a46a99ceb1..43a713b627 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts @@ -401,3 +401,33 @@ it("uses unavailable evidence when no source produced semantic IR", () => { }, ); }); + +it("retains conditional Node timer and child effects without proving a loader", () => { + const graph = graphFor(` + const { spawn } = require('child_process'); + const { setTimeout } = require('timers'); + const child = spawn('tool', []); child.kill(); + setTimeout(() => {}, 1); + `); + const child = graph.nodes.find((node) => node.kind === "child-process"); + const timer = graph.nodes.find((node) => node.kind === "timer"); + for (const node of [child, timer]) { + expect(node?.properties.builtin_authority).toBe("candidate"); + if (node === undefined) throw new Error("Expected candidate node"); + expect( + resolveJavaScriptSemanticEvidence(graph, node.evidence), + ).toMatchObject({ state: "inferred", confidence: "low" }); + } + for (const kind of [ + "spawns", + "schedules-timer", + "forwards-signal", + ] as const) { + const relation = graph.relations.find((item) => item.relation === kind); + expect(relation).toMatchObject({ resolution: "candidate" }); + if (relation === undefined) throw new Error(`Expected ${kind} relation`); + expect( + resolveJavaScriptSemanticEvidence(graph, relation.evidence), + ).toMatchObject({ state: "inferred", confidence: "low" }); + } +}); diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts index c30a34a2c1..169cd36ea7 100644 --- a/src/domain/javascript/electronBundledRequireIdentity.test.ts +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { runInNewContext } from "node:vm"; -import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeJavaScriptStaticSource as analyzeUnknownHostSource } from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture as analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; // Webpack 5 standalone bootstrap with an Electron external, as emitted for // `target: "electron-main"`. @@ -31,6 +32,17 @@ const expectUnresolved = (source: string) => { ).toEqual([]); }; +it("does not claim Electron host identity from an unbound require in unknown context", () => { + const analysis = analyzeUnknownHostSource(bootstrap(EXTERNAL, CONSUMER)); + expect(analysis.electron.browser_windows).toContainEqual( + expect.objectContaining({ + preload_path: "./preload.js", + loader_authority: "candidate", + }), + ); + expect(analysis.analysis_coverage?.applicability.electron).toBe("unknown"); +}); + describe("Electron identity through Webpack externals", () => { it.each([ ["const key=45840;n.m[key]=local;", 0], diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index 2074f952a4..eda151f4f1 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeJavaScriptStaticSource as analyzeUnknownHostSource } from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture as analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; import { ROLLDOWN_MODULE_INTEROP } from "./javascriptModuleInterop.fixture.js"; const WINDOW = @@ -20,6 +21,13 @@ const BLOCK_MODULE_INTEROP = ROLLDOWN_MODULE_INTEROP.replace( .replace(" });", " });\n }") .replace(" return to;", " }\n return to;"); +it("does not claim Electron interop identity when wrapper authority is unknown", () => { + expect( + analyzeUnknownHostSource(`${ROLLDOWN_MODULE_INTEROP} +${IMPORT} ${WRAP} ${WINDOW}`).electron.browser_windows, + ).toEqual([]); +}); + describe("Electron module interop identity", () => { it("preserves named APIs through the released Rolldown namespace conversion", () => { const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} diff --git a/src/domain/javascript/electronNativeLoaderPaths.test.ts b/src/domain/javascript/electronNativeLoaderPaths.test.ts index 352c640810..f05bc39aff 100644 --- a/src/domain/javascript/electronNativeLoaderPaths.test.ts +++ b/src/domain/javascript/electronNativeLoaderPaths.test.ts @@ -64,6 +64,32 @@ describe("native addon type-only re-exports", () => { }); }); +it("does not claim a native re-export from a candidate wrapper with a proven factory loader", () => { + const analysis = analyzeJavaScriptStaticSource(` + globalThis.webpackChunkApp.push([[1], { + "factory": function (m, e, load) { + const known = load("./known.node"); + module.exports = load("./addon.node"); + } + }]); + `); + expect(analysis.electron.native_addon_bindings).toContainEqual( + expect.objectContaining({ + binding_kind: "require", + specifier: "./known.node", + }), + ); + expect(analysis.electron.native_addon_bindings).not.toContainEqual( + expect.objectContaining({ + binding_kind: "re-export", + specifier: "./addon.node", + }), + ); + expect( + analysis.analysis_coverage?.semantics.unknown_findings, + ).toBeGreaterThan(0); +}); + describe("native addon loader provenance", () => { it("does not give ESM fixtures an ambient CommonJS loader", () => { const analysis = analyzeJavaScriptStaticSource(` @@ -126,3 +152,28 @@ describe("native addon loader provenance", () => { }, ); }); + +it.each(["call", "apply"])( + "does not prove native bindings through a replaced require.%s", + (method) => { + const argument = + method === "call" ? 'null,"./addon.node"' : 'null,["./addon.node"]'; + const replaced = analyzeCommonJsStaticFixture( + `require.${method}=custom;const addon=require.${method}(${argument});`, + ); + expect(replaced.electron.native_addon_bindings).toEqual([]); + const intact = analyzeCommonJsStaticFixture( + `require.other=custom;const addon=require.${method}(${argument});`, + ); + expect(intact.electron.native_addon_bindings).toMatchObject([ + { specifier: "./addon.node", binding_kind: "require" }, + ]); + }, +); + +it("does not prove native bindings through a replaced aliased loader member", () => { + const analysis = analyzeCommonJsStaticFixture( + 'const load=require;load.call=custom;const addon=load.call(null,"./addon.node");', + ); + expect(analysis.electron.native_addon_bindings).toEqual([]); +}); diff --git a/src/domain/javascript/electronStaticAnalysis.ts b/src/domain/javascript/electronStaticAnalysis.ts index f5699d0894..bae0a5c47b 100644 --- a/src/domain/javascript/electronStaticAnalysis.ts +++ b/src/domain/javascript/electronStaticAnalysis.ts @@ -5,7 +5,10 @@ import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js"; import { inspectElectronNativeNode } from "./electronStaticAnalysisNative.js"; import { inspectElectronProtocolNode } from "./electronStaticAnalysisProtocol.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; -import { electronCalleePath } from "./electronStaticAnalysisValues.js"; +import { + electronCandidateAuthority, + electronCalleePath, +} from "./electronStaticAnalysisValues.js"; import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; @@ -22,6 +25,11 @@ export const inspectElectronStaticNode = ( ? (electronCalleePath(node.callee, context) ?? context.electronInvocationPath?.(node)) : undefined; + const provenAppPathChange = + context.electronAppPathChangePossible?.(node) === true; + const candidateAppPathChange = + !provenAppPathChange && + context.electronCandidateAppPathChangePossible?.(node) === true; if ( (path !== undefined && path[0] === "app" && @@ -29,7 +37,8 @@ export const inspectElectronStaticNode = ( path .slice(2) .every((member) => ["call", "apply", "bind"].includes(member))) || - context.electronAppPathChangePossible?.(node) === true + provenAppPathChange || + candidateAppPathChange ) // A bound or exported setter may escape this source before it changes the // shared app path. Keep that possibility without claiming an invocation. @@ -37,7 +46,16 @@ export const inspectElectronStaticNode = ( collection: context.accumulator.appPathChanges, key: "electron:app-path-change", node, - value: { module_key: null, location: range(node) }, + value: { + ...(candidateAppPathChange + ? { loader_authority: "candidate" as const } + : {}), + ...(t.isCallExpression(node) || t.isOptionalCallExpression(node) + ? electronCandidateAuthority(node.callee, context) + : {}), + module_key: null, + location: range(node), + }, }); inspectElectronProtocolNode(node, context); }; diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index a164219f12..568f6d15f5 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -13,6 +13,7 @@ import { contextBridgeExposureArguments } from "./javascriptContextBridgeExposur import { boundedExpression, collectContextBridgeMembers, + electronCandidateAuthority, electronCalleePath, electronStaticValue, } from "./electronStaticAnalysisValues.js"; @@ -139,6 +140,7 @@ const inspectBrowserWindow = ( ); context.accumulator.unknownFindings += collected.unknown; const finding: ElectronBrowserWindowFinding = { + ...electronCandidateAuthority(node.callee, context), options_status: resolvedOptions === undefined ? "missing" @@ -155,6 +157,7 @@ const inspectBrowserWindow = ( location: range(node), }; if ( + finding.loader_authority !== "candidate" && finding.preload_path !== null && finding.preload_resolution_context !== null ) @@ -305,6 +308,7 @@ const inspectContextBridge = ( ? { api_key: key.value, api_key_expression: null } : { api_key: null, api_key_expression: null }; const finding: ElectronContextBridgeFinding = { + ...electronCandidateAuthority(node.callee, context), ...apiKey, world: exposure.world, world_id: worldId, @@ -373,6 +377,7 @@ const inspectUtilityProcess = ( module_expression: null, }; const finding: ElectronUtilityProcessFinding = { + ...electronCandidateAuthority(node.callee, context), ...moduleReference, service_name: serviceName ?? null, module_key: null, diff --git a/src/domain/javascript/electronStaticAnalysisCandidate.test.ts b/src/domain/javascript/electronStaticAnalysisCandidate.test.ts new file mode 100644 index 0000000000..ba364d0222 --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisCandidate.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const analyze = (source: string, path: string) => { + const parsed = parseJavaScriptSource(source, path); + if (parsed === null) throw new Error("fixture did not parse"); + return analyzeParsedJavaScriptStaticSource(source, parsed); +}; + +describe("conditional Electron identities", () => { + const source = ` + const electron = require("electron"); + const { BrowserWindow, protocol } = electron; + new BrowserWindow({ webPreferences: { sandbox: false } }); + protocol.handle("app", handler); + `; + + it("retains alias-derived findings without proving an unknown loader", () => { + const result = analyze(source, "main.MJS"); + expect(result.analysis_coverage?.applicability.electron).toBe("unknown"); + expect(result.electron.browser_windows).toMatchObject([ + { + loader_authority: "candidate", + web_preferences: [ + { name: "sandbox", value: { status: "literal", value: false } }, + ], + }, + ]); + expect(result.electron.protocols).toMatchObject([ + { + loader_authority: "candidate", + api: "handle", + scheme: { status: "literal", value: "app" }, + }, + ]); + }); + + it("retains established CommonJS and ES module observations as proven", () => { + const commonjs = analyze(source, "main.cjs"); + expect(commonjs.analysis_coverage?.applicability.electron).toBe("observed"); + expect( + commonjs.electron.browser_windows[0]?.loader_authority, + ).toBeUndefined(); + const esm = analyze( + 'import { BrowserWindow } from "electron"; new BrowserWindow({});', + "main.mjs", + ); + expect(esm.analysis_coverage?.applicability.electron).toBe("observed"); + expect(esm.electron.browser_windows[0]?.loader_authority).toBeUndefined(); + }); + + it("retains optional loader findings with the host's authority", () => { + const optional = + 'const {app, BrowserWindow} = require?.("electron"); app.setAppPath("/tmp"); new BrowserWindow();'; + const candidate = analyze(optional, "main.MJS"); + expect(candidate.analysis_coverage?.applicability.electron).toBe("unknown"); + expect(candidate.electron.browser_windows).toMatchObject([ + { loader_authority: "candidate" }, + ]); + expect(candidate.electron.app_path_changes).toMatchObject([ + { loader_authority: "candidate" }, + ]); + const commonjs = analyze(optional, "main.cjs"); + expect(commonjs.electron.browser_windows).toHaveLength(1); + expect( + commonjs.electron.browser_windows[0]?.loader_authority, + ).toBeUndefined(); + expect(commonjs.electron.app_path_changes).toHaveLength(1); + expect( + commonjs.electron.app_path_changes?.[0]?.loader_authority, + ).toBeUndefined(); + }); + + it("does not claim conditional identity when initialization is skipped or loader is shadowed", () => { + const skipped = analyze( + 'while (false) { var electron = require("electron"); } new electron.BrowserWindow({});', + "main.MJS", + ); + expect(skipped.electron.browser_windows).toEqual([]); + const shadowed = analyze( + 'const require = () => ({ BrowserWindow: Local }); const { BrowserWindow } = require("electron"); new BrowserWindow({});', + "main.MJS", + ); + expect(shadowed.electron.browser_windows).toEqual([]); + }); + + it("does not use a later candidate initializer at an earlier read", () => { + const early = analyze( + 'new electron.BrowserWindow({}); var electron = require("electron");', + "main.MJS", + ); + expect(early.electron.browser_windows).toEqual([]); + const later = analyze( + 'var electron = require("electron"); new electron.BrowserWindow({});', + "main.MJS", + ); + expect(later.electron.browser_windows).toMatchObject([ + { loader_authority: "candidate" }, + ]); + const aliasCapturedEarly = analyze( + 'var BrowserWindow = electron.BrowserWindow; var electron = require("electron"); new BrowserWindow({});', + "main.MJS", + ); + expect(aliasCapturedEarly.electron.browser_windows).toEqual([]); + const aliasCapturedLater = analyze( + 'var electron = require("electron"); var BrowserWindow = electron.BrowserWindow; new BrowserWindow({});', + "main.MJS", + ); + expect(aliasCapturedLater.electron.browser_windows).toMatchObject([ + { loader_authority: "candidate" }, + ]); + }); + + it("does not use a later proven CommonJS initializer at an earlier read", () => { + const early = analyze( + 'new electron.BrowserWindow({}); var electron = require("electron");', + "main.cjs", + ); + expect(early.electron.browser_windows).toEqual([]); + const later = analyze( + 'var electron = require("electron"); new electron.BrowserWindow({});', + "main.cjs", + ); + expect(later.electron.browser_windows).toHaveLength(1); + expect(later.electron.browser_windows[0]?.loader_authority).toBeUndefined(); + const aliasEarly = analyze( + 'var BrowserWindow = electron.BrowserWindow; var electron = require("electron"); new BrowserWindow({});', + "main.cjs", + ); + expect(aliasEarly.electron.browser_windows).toEqual([]); + const aliasLater = analyze( + 'var electron = require("electron"); var BrowserWindow = electron.BrowserWindow; new BrowserWindow({});', + "main.cjs", + ); + expect(aliasLater.electron.browser_windows).toHaveLength(1); + expect( + aliasLater.electron.browser_windows[0]?.loader_authority, + ).toBeUndefined(); + }); +}); diff --git a/src/domain/javascript/electronStaticAnalysisComputedPreferences.test.ts b/src/domain/javascript/electronStaticAnalysisComputedPreferences.test.ts index 01973ce87e..5fa11139ed 100644 --- a/src/domain/javascript/electronStaticAnalysisComputedPreferences.test.ts +++ b/src/domain/javascript/electronStaticAnalysisComputedPreferences.test.ts @@ -14,8 +14,13 @@ describe("computed Electron web preference keys", () => { } }); `); - expect(analysis.parse_status).toBe("complete"); + expect(analysis.parse_status).toBe("partial"); + expect(analysis.analysis_coverage?.syntax).toEqual({ + status: "complete", + error_count: 0, + }); expect(analysis.electron.browser_windows[0]).toMatchObject({ + loader_authority: "candidate", preload_path: "./preload.js", web_preferences: [ { name: "", value: { status: "literal", value: false } }, diff --git a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts index 4de895ed36..1e8899aa11 100644 --- a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts +++ b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; it.each([ "module.require = replacement;", @@ -20,7 +21,11 @@ it.each([ ); it.each([ - ['const { app } = require("electron"); app.setAppPath("./other");', true], + [ + 'const { app } = require("electron"); app.setAppPath("./other");', + true, + true, + ], [ 'import { app as runtime } from "electron/main"; runtime.setAppPath(path);', true, @@ -32,14 +37,17 @@ it.each([ [ 'const { app } = require("electron"); const runtime = app; runtime.setAppPath("./other");', true, + true, ], [ 'const { app } = require("electron"); const copy = { ...{ app } }; copy.app.setAppPath("./other");', true, + true, ], [ 'const { app } = require("electron"); const change = app.setAppPath; change("./other");', true, + true, ], ['const app = { setAppPath() {} }; app.setAppPath("./other");', false], [ @@ -52,8 +60,10 @@ it.each([ ], ])( "retains Electron application path changes by binding identity: %s", - (source, expected) => { - const analysis = analyzeJavaScriptStaticSource(source); + (source, expected, commonJs = false) => { + const analysis = commonJs + ? analyzeCommonJsStaticFixture(source) + : analyzeJavaScriptStaticSource(source); expect(analysis.parse_error_count).toBe(0); expect(analysis.electron.app_path_changes ?? []).toEqual( expected @@ -112,7 +122,7 @@ it.each([ ])( "retains indirect application path changes by Electron identity: %s", (call, expected) => { - const analysis = analyzeJavaScriptStaticSource(` + const analysis = analyzeCommonJsStaticFixture(` const { app } = require("electron"); ${call} `); @@ -160,7 +170,7 @@ it("recovers APIs through an ES module default import of Electron", () => { }); it("recovers APIs and sender checks written with optional chaining", () => { - const analysis = analyzeJavaScriptStaticSource(` + const analysis = analyzeCommonJsStaticFixture(` const { contextBridge, ipcMain, utilityProcess } = require("electron"); contextBridge?.exposeInMainWorld("api", {}); utilityProcess?.fork("./worker.js"); @@ -202,7 +212,7 @@ it("recovers APIs and sender checks written with optional chaining", () => { }); it("preserves empty and computed literal contextBridge member keys", () => { - const analysis = analyzeJavaScriptStaticSource(` + const analysis = analyzeCommonJsStaticFixture(` const { contextBridge } = require("electron"); const dynamicKey = getKey(); contextBridge.exposeInMainWorld("api", { @@ -232,7 +242,7 @@ it("preserves empty and computed literal contextBridge member keys", () => { }); it("follows bundler namespace interop helpers without accepting other writes", () => { - const analysis = analyzeJavaScriptStaticSource(` + const analysis = analyzeCommonJsStaticFixture(` var __toESM = (mod) => mod; var __importStar = (mod) => mod; let electron = require("electron"); @@ -279,7 +289,7 @@ it("does not trust unbound helper names or non-function helper values", () => { }); it("follows stable declaration aliases but not an assigned default parameter", () => { - const analysis = analyzeJavaScriptStaticSource(` + const analysis = analyzeCommonJsStaticFixture(` const direct = require("electron"); const constAlias = direct; new constAlias.BrowserWindow({webPreferences:{preload:"./const.js"}}); @@ -302,7 +312,7 @@ it("follows stable declaration aliases but not an assigned default parameter", ( }); it("preserves one initializer across variable redeclarations without accepting writes", () => { - const analysis = analyzeJavaScriptStaticSource(` + const analysis = analyzeCommonJsStaticFixture(` var electron; var electron = require("electron"); var electron; diff --git a/src/domain/javascript/electronStaticAnalysisIpc.ts b/src/domain/javascript/electronStaticAnalysisIpc.ts index 43bcdbc383..561ce526c5 100644 --- a/src/domain/javascript/electronStaticAnalysisIpc.ts +++ b/src/domain/javascript/electronStaticAnalysisIpc.ts @@ -8,6 +8,7 @@ import type { } from "./electronStaticAnalysisTypes.js"; import { boundedExpression, + electronCandidateAuthority, electronCalleePath, electronStaticValue, handlerKind, @@ -91,6 +92,7 @@ const inspectIpcCall = ( if (findingState.handler_kind === "missing") context.accumulator.unknownFindings += 1; const finding: ElectronIpcFinding = { + ...(scoped?.authority ?? electronCandidateAuthority(node.callee, context)), ...findingState, ...(scoped === undefined ? {} : { receiver: scoped.receiver }), ...channelReference, @@ -112,6 +114,7 @@ const scopedIpcDescriptor = ( | { readonly descriptor: ElectronIpcDescriptor; readonly receiver: ElectronIpcReceiver; + readonly authority: { readonly loader_authority?: "candidate" }; } | undefined => { if ( @@ -143,6 +146,7 @@ const scopedIpcDescriptor = ( ? undefined : { descriptor, + authority: receiver.authority, receiver: { kind, owner_location: range(receiver.owner) }, }; }; diff --git a/src/domain/javascript/electronStaticAnalysisNative.ts b/src/domain/javascript/electronStaticAnalysisNative.ts index 3c91965901..26306c8a3c 100644 --- a/src/domain/javascript/electronStaticAnalysisNative.ts +++ b/src/domain/javascript/electronStaticAnalysisNative.ts @@ -86,8 +86,8 @@ export function* finishElectronNativeBindingsSteps( : new Map(); for (const node of pending) { if (t.isVariableDeclarator(node)) - inspectRequireBinding(node, context, index.bindings); - else inspectReExport(node, context, index.bindings, receivers.get(node)); + inspectRequireBinding(node, context, index); + else inspectReExport(node, context, index, receivers.get(node)); } pending.length = 0; } @@ -184,9 +184,9 @@ const inspectExportAll = ( const inspectRequireBinding = ( node: t.VariableDeclarator, context: JavaScriptFindingContext, - bindings: JavaScriptSemanticAnalysisState, + index: JavaScriptReceiverIndex, ): void => { - const required = nativeRequire(node.init, context, bindings); + const required = nativeRequire(node.init, context, index); if (required === undefined) return; addBinding({ context, @@ -201,11 +201,15 @@ const inspectRequireBinding = ( const inspectReExport = ( node: t.AssignmentExpression, context: JavaScriptFindingContext, - bindings: JavaScriptSemanticAnalysisState, + index: JavaScriptReceiverIndex, receiver: CommonJsExportReceiverEffect | undefined, ): void => { - const exported = commonJsRuntimeExportValues(node, bindings)[0]; + const exported = commonJsRuntimeExportValues(node, index.bindings)[0]; if (exported === undefined) return; + if (exported.loaderAuthority === "candidate") { + context.accumulator.unknownFindings += 1; + return; + } const applicability = commonJsExportApplicability( receiver, exported.exportedName, @@ -218,7 +222,7 @@ const inspectReExport = ( // The export owner leaves arithmetic/logical stores without a selected RHS. const value = exported.value; if (value == null) return; - const required = nativeRequire(value, context, bindings); + const required = nativeRequire(value, context, index); if (required === undefined) return; addBinding({ context, @@ -274,7 +278,7 @@ const nativeCallReceiver = ( const nativeRequire = ( node: t.Node | null | undefined, context: JavaScriptFindingContext, - bindings: JavaScriptSemanticAnalysisState, + index: JavaScriptReceiverIndex, ): | { readonly specifier: string; @@ -298,8 +302,9 @@ const nativeRequire = ( return undefined; const call = semanticRequireCallAt( node, - bindings, + index.bindings, context.accumulator.factoryRequireBindingIds, + index, ); if ( call === undefined || diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index 60734de318..7c911a6b76 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -200,8 +200,18 @@ describe("Electron option captures and initialization", () => { expect(analysis.electron.browser_windows).toMatchObject([ { options_status: "object-literal", - preload_path: "preload.js", - preload_resolution_context: "filesystem-expression", + preload_path: null, + preload_resolution_context: null, + loader_authority: "candidate", + web_preferences: expect.arrayContaining([ + expect.objectContaining({ + name: "preload", + value: expect.objectContaining({ + status: "dynamic", + expression: expect.stringContaining('require("path").join'), + }), + }), + ]), }, ]); }); @@ -951,7 +961,11 @@ describe("Electron option evidence", () => { utilityProcess.fork("./worker.js", [], { serviceName: "kept", cwd: "/app" }); `); - expect(analysis.parse_status).toBe("complete"); + expect(analysis.parse_status).toBe("partial"); + expect(analysis.analysis_coverage?.syntax).toEqual({ + status: "complete", + error_count: 0, + }); expect(analysis.electron.browser_windows[0]).toMatchObject({ preload_path: "./kept.js", preload_resolution_context: "module-specifier", @@ -996,7 +1010,7 @@ describe("Electron option evidence", () => { }, ); - it("selects proven Electron preloads while ignoring media attributes and unrelated properties", () => { + it("retains conditional Electron preloads while ignoring unrelated properties", () => { const analysis = analyzeJavaScriptStaticSource(` jsx("audio", { controls: true, preload: "metadata", src }); (0, React.createElement)(\`video\`, { preload: "auto" }); @@ -1006,8 +1020,12 @@ describe("Electron option evidence", () => { new BrowserWindow({webPreferences: {preload: "./preload.js"}}); `); - expect(analysis.role_paths.map(({ path }) => path)).toEqual([ - "./preload.js", + expect(analysis.role_paths).toEqual([]); + expect(analysis.electron.browser_windows).toEqual([ + expect.objectContaining({ + preload_path: "./preload.js", + loader_authority: "candidate", + }), ]); }); @@ -1071,7 +1089,11 @@ describe("Electron empty option keys", () => { `); expect(analysis.parse_error_count).toBe(0); - expect(analysis.parse_status).toBe("complete"); + expect(analysis.parse_status).toBe("partial"); + expect(analysis.analysis_coverage?.syntax).toEqual({ + status: "complete", + error_count: 0, + }); expect(analysis.electron.browser_windows).toMatchObject([ { web_preferences_status: "object-literal", @@ -1097,7 +1119,11 @@ describe("Electron empty option keys", () => { `); expect(analysis.parse_error_count).toBe(0); - expect(analysis.parse_status).toBe("complete"); + expect(analysis.parse_status).toBe("partial"); + expect(analysis.analysis_coverage?.syntax).toEqual({ + status: "complete", + error_count: 0, + }); expect(analysis.electron.browser_windows).toMatchObject([ { web_preferences_status: "object-literal", @@ -1124,7 +1150,11 @@ describe("Electron empty option keys", () => { `); expect(analysis.parse_error_count).toBe(0); - expect(analysis.parse_status).toBe("complete"); + expect(analysis.parse_status).toBe("partial"); + expect(analysis.analysis_coverage?.syntax).toEqual({ + status: "complete", + error_count: 0, + }); expect(analysis.electron.utility_processes).toMatchObject([ { module_path: "./worker.js", service_name: "kept" }, ]); diff --git a/src/domain/javascript/electronStaticAnalysisProtocol.ts b/src/domain/javascript/electronStaticAnalysisProtocol.ts index d6887e78ea..8ee3c92682 100644 --- a/src/domain/javascript/electronStaticAnalysisProtocol.ts +++ b/src/domain/javascript/electronStaticAnalysisProtocol.ts @@ -7,6 +7,7 @@ import type { } from "./electronStaticAnalysisTypes.js"; import { boundedExpression, + electronCandidateAuthority, electronCalleePath, electronStaticValue, } from "./electronStaticAnalysisValues.js"; @@ -269,6 +270,7 @@ const addProtocol = ( const scheme = schemeValue(context, input.scheme, located); if (scheme.status === "dynamic") context.accumulator.unknownFindings += 1; const finding: ElectronProtocolFinding = { + ...electronCandidateAuthority(input.node.callee, context), api: input.api, role: input.role, scheme, diff --git a/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts b/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts index 0cefe731c2..f9b9935162 100644 --- a/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts +++ b/src/domain/javascript/electronStaticAnalysisScopedIpc.test.ts @@ -1,6 +1,30 @@ import { expect, it } from "vitest"; -import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeJavaScriptStaticSource as analyzeUnknownHostSource } from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture as analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; + +it("retains an unknown-host Electron request as a candidate without claiming IPC identity", () => { + const analysis = analyzeUnknownHostSource( + 'const { BrowserWindow } = require("electron"); new BrowserWindow().webContents.ipc.handle("x", () => {});', + ); + expect(analysis.electron.ipc).toEqual([ + expect.objectContaining({ + channel: "x", + operation: "handle", + loader_authority: "candidate", + }), + ]); + expect(analysis.analysis_coverage?.applicability.electron).toBe("unknown"); + expect(analysis.references).toContainEqual( + expect.objectContaining({ + specifier: "electron", + loader_authority: "candidate", + }), + ); + expect( + analysis.analysis_coverage?.semantics.unknown_findings, + ).toBeGreaterThan(0); +}); it.each([ ["webContents", "window.webContents.ipc"], diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index ee08b13cb4..a974ebf8e6 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -40,6 +40,8 @@ export type ElectronBrowserWindowFinding = ElectronBrowserWindowPreload & { readonly web_preferences: readonly ElectronWebPreference[]; /** Object syntax supporting constant substitution, separate from construction. */ readonly resolution_sources?: readonly JavaScriptSourceRange[]; + /** Electron identity is conditional on an unproved loader. */ + readonly loader_authority?: "candidate"; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; @@ -60,6 +62,8 @@ export type ElectronContextBridgeFinding = ElectronContextBridgeApiKey & { readonly api_status: "object-literal" | "dynamic" | "missing"; readonly members: readonly string[]; readonly unknown_members: number; + /** Electron identity is conditional on an unproved loader. */ + readonly loader_authority?: "candidate"; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; @@ -124,6 +128,8 @@ interface ElectronIpcFindingContext { readonly receiver?: ElectronIpcReceiver; /** Absent on direct syntax observations; wrappers require inferred evidence. */ readonly resolution_basis?: "wrapper-substitution"; + /** Electron identity is conditional on an unproved loader. */ + readonly loader_authority?: "candidate"; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; @@ -168,6 +174,8 @@ export interface ElectronSenderValidationFinding { readonly mechanism: string; readonly expected: ElectronStaticValue; readonly enforcement: "unknown"; + /** Electron identity is conditional on an unproved loader. */ + readonly loader_authority?: "candidate"; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; @@ -176,6 +184,8 @@ export interface ElectronSenderValidationFinding { interface ElectronUtilityProcessFindingState { readonly service_name: string | null; + /** Electron identity is conditional on an unproved loader. */ + readonly loader_authority?: "candidate"; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; @@ -207,6 +217,8 @@ export interface ElectronNativeAddonBindingFinding { /** Omitted in legacy findings; omission means unknown, not false. */ readonly namespace_access?: boolean; readonly dynamic_member_access?: boolean; + /** Electron identity is conditional on an unproved loader. */ + readonly loader_authority?: "candidate"; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; @@ -237,6 +249,8 @@ export interface ElectronProtocolFinding { readonly session: "default-protocol" | "default-session"; /** Privilege keys with a literal `true`; null unless `role` is `privileged-scheme` with a literal object. */ readonly enabled_privileges: readonly string[] | null; + /** Electron identity is conditional on an unproved loader. */ + readonly loader_authority?: "candidate"; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; @@ -260,6 +274,8 @@ export interface ElectronStaticFindings { /** An Electron application path setter invocation or bound delegate capture. */ export interface ElectronAppPathChangeFinding { + /** Electron identity is conditional on an unproved loader. */ + readonly loader_authority?: "candidate"; readonly module_key: string | null; readonly module_runtime?: string; readonly location: JavaScriptSourceRange; @@ -281,6 +297,8 @@ export interface ElectronCallPathStep { /** A statically substituted candidate, never a runtime reachability claim. */ export interface ElectronCallPath { + /** One or more Electron identities depend on an unproved loader. */ + readonly loader_authority?: "candidate"; readonly entry_kind: "bridge-member" | "ipc-handler"; readonly entry: string; /** Exact bridge API and property segments; `entry` remains display text. */ diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 2b232e8f1e..59b953f42f 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -18,10 +18,25 @@ export const electronCalleePath = ( const { root, members } = electronMemberPath(callee); if (!members.every((member): member is string => member !== null)) return undefined; - const exported = context.electronBindings?.get(root.start ?? -1); + const exported = + context.electronBindings?.get(root.start ?? -1) ?? + context.electronCandidateBindings?.get(root.start ?? -1); return exported === undefined ? undefined : [...exported, ...members]; }; +/** Keep the identity uncertainty attached to each derived finding. */ +export const electronCandidateAuthority = ( + callee: t.Node, + context: JavaScriptFindingContext, +): { readonly loader_authority?: "candidate" } => { + const { root } = electronMemberPath(callee); + const offset = root.start ?? -1; + return context.electronBindings?.has(offset) !== true && + context.electronCandidateBindings?.has(offset) === true + ? { loader_authority: "candidate" } + : {}; +}; + /** Preserve one literal value or the exact inert expression. */ export const electronStaticValue = ( source: string, diff --git a/src/domain/javascript/electronStaticReceiverAdmission.test.ts b/src/domain/javascript/electronStaticReceiverAdmission.test.ts index 5d44d4cc93..cd14f65a5c 100644 --- a/src/domain/javascript/electronStaticReceiverAdmission.test.ts +++ b/src/domain/javascript/electronStaticReceiverAdmission.test.ts @@ -18,8 +18,8 @@ import { type JavaScriptFindingContext, } from "./javascriptStaticAnalysisState.js"; -const inspect = (source: string) => { - const file = parseJavaScriptSource(source); +const inspect = (source: string, path?: string) => { + const file = parseJavaScriptSource(source, path); if (file === null) throw new Error("Fixture must parse"); const index = complete( indexJavaScriptBindingsWithReceiversSteps(file.program), @@ -73,8 +73,11 @@ it.each([ }); it("still checks mutations for a source-proven scoped Electron receiver", () => { - const result = inspect(`const {BrowserWindow} = require("electron"); - const window = new BrowserWindow(); window.webContents.ipc.handle("channel", () => {});`); + const result = inspect( + `const {BrowserWindow} = require("electron"); + const window = new BrowserWindow(); window.webContents.ipc.handle("channel", () => {});`, + "fixture.cts", + ); expect(result.findings).toHaveLength(1); expect(result.mutationIndexCalls).toBeGreaterThan(0); }); diff --git a/src/domain/javascript/electronStaticRenderer.ts b/src/domain/javascript/electronStaticRenderer.ts index d68630c045..9a207ec895 100644 --- a/src/domain/javascript/electronStaticRenderer.ts +++ b/src/domain/javascript/electronStaticRenderer.ts @@ -1,6 +1,9 @@ import * as t from "@babel/types"; -import { electronCalleePath } from "./electronStaticAnalysisValues.js"; +import { + electronCalleePath, + electronCandidateAuthority, +} from "./electronStaticAnalysisValues.js"; import { semanticStaticPropertyKey, unwrapJavaScriptExpression, @@ -14,17 +17,17 @@ import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; /** A renderer load belongs to an intact, source-proven BrowserWindow instance. */ -export const isElectronRendererLoad = ( +export const electronRendererLoadAuthority = ( callee: t.Node, site: t.Node, context: JavaScriptFindingContext, index: JavaScriptBindingIndex, -): boolean => { +): { readonly loader_authority?: "candidate" } | undefined => { callee = unwrapJavaScriptExpression(callee).node; if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) - return false; + return undefined; const method = semanticStaticPropertyKey(callee.property, callee.computed); - if (method !== "loadFile" && method !== "loadURL") return false; + if (method !== "loadFile" && method !== "loadURL") return undefined; const receiver = resolveElectronBrowserWindowReceiver( callee.object, site, @@ -32,11 +35,11 @@ export const isElectronRendererLoad = ( context, index, ); - return ( - receiver !== undefined && + return receiver !== undefined && (receiver.path.length === 1 || (receiver.path.length === 2 && receiver.path[0] === "webContents")) - ); + ? receiver.authority + : undefined; }; /** Source allocation identity, not a claim about runtime window/frame identity. */ @@ -47,7 +50,11 @@ export const resolveElectronBrowserWindowReceiver = ( context: JavaScriptFindingContext, index: JavaScriptBindingIndex, ): - | { readonly owner: t.NewExpression; readonly path: readonly string[] } + | { + readonly owner: t.NewExpression; + readonly path: readonly string[]; + readonly authority: { readonly loader_authority?: "candidate" }; + } | undefined => { let receiver = unwrapJavaScriptExpression(node).node; const path = [...suffix]; @@ -105,7 +112,11 @@ export const resolveElectronBrowserWindowReceiver = ( for (const alias of aliases) if (index.referenceMayHaveChangedAt(site, alias.receiver, alias.path)) return undefined; - return { owner: receiver, path }; + return { + owner: receiver, + path, + authority: electronCandidateAuthority(receiver.callee, context), + }; }; const supportedReceiverPath = (path: readonly string[]): boolean => { diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 429fadf97a..e6e081bd6e 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -147,7 +147,11 @@ it("preserves every direct observation in a long flat sibling block", () => { }`); expect(result.electron.ipc.map(({ channel }) => channel)).toEqual(channels); expect(result.electron.call_paths ?? []).toEqual([]); - expect(result.parse_status).toBe("complete"); + expect(result.parse_status).toBe("partial"); + expect(result.analysis_coverage?.syntax.status).toBe("complete"); + expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan( + 0, + ); }); it("keeps sibling guards, definite exits and nested shadowing separate", () => { @@ -370,7 +374,11 @@ it("preserves an observed empty contextBridge API object", () => { expect(result.electron.context_bridge_apis?.[0]).not.toHaveProperty( "resolution_basis", ); - expect(result.parse_status).toBe("complete"); + expect(result.parse_status).toBe("partial"); + expect(result.analysis_coverage?.syntax.status).toBe("complete"); + expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan( + 0, + ); }); it.each([ @@ -767,7 +775,11 @@ it("resolves a site-determined constant channel in place of its dynamic observat expect.objectContaining({ entry: "desktop.ping", channel: "ping" }), ); // The syntax pass's unknown channel is resolved, not left as partial. - expect(result.parse_status).toBe("complete"); + expect(result.parse_status).toBe("partial"); + expect(result.analysis_coverage?.syntax.status).toBe("complete"); + expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan( + 0, + ); }); it("resolves a site-determined registration whose handler is a named local", () => { const result = analyzeJavaScriptStaticSource(` @@ -784,7 +796,11 @@ it("resolves a site-determined registration whose handler is a named local", () handler_kind: "inline-function", }), ]); - expect(result.parse_status).toBe("complete"); + expect(result.parse_status).toBe("partial"); + expect(result.analysis_coverage?.syntax.status).toBe("complete"); + expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan( + 0, + ); }); it("keeps an argument-supplied channel unresolved beside its resolved candidates", () => { const result = analyzeJavaScriptStaticSource(` @@ -987,7 +1003,11 @@ it.each(["interface Shape { marker(): void }", "type Shape = typeof marker;"])( resolution_basis ?? "direct", ]), ).toEqual([["ch", "wrapper-substitution"]]); - expect(result.parse_status).toBe("complete"); + expect(result.parse_status).toBe("partial"); + expect(result.analysis_coverage?.syntax.status).toBe("complete"); + expect( + result.analysis_coverage?.semantics.unknown_findings, + ).toBeGreaterThan(0); }, ); it("resolves a handler a module-level factory call created once in place", () => { @@ -1005,7 +1025,11 @@ it("resolves a handler a module-level factory call created once in place", () => resolution_basis ?? "direct", ]), ).toEqual([["ch", "wrapper-substitution"]]); - expect(result.parse_status).toBe("complete"); + expect(result.parse_status).toBe("partial"); + expect(result.analysis_coverage?.syntax.status).toBe("complete"); + expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan( + 0, + ); }); it("keeps separate outer calls that pass one shared handler through a wrapper", () => { const result = analyzeJavaScriptStaticSource(` @@ -1063,7 +1087,11 @@ it("resolves a module-level handler in place when the entry binds an unused para resolution_basis: "wrapper-substitution", }), ]); - expect(result.parse_status).toBe("complete"); + expect(result.parse_status).toBe("partial"); + expect(result.analysis_coverage?.syntax.status).toBe("complete"); + expect(result.analysis_coverage?.semantics.unknown_findings).toBeGreaterThan( + 0, + ); }); it.each([ ["a named local", 'const handler = () => tag; register("same", handler);'], diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index 4cc15a028d..17303e2d6d 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -63,6 +63,7 @@ const ipcEndpointKey = (value: ElectronIpcFinding): string => value.side, value.operation, value.channel, + value.loader_authority ?? "proven", javascriptSourceRangeKey(value.location), ]); @@ -205,7 +206,12 @@ interface BridgeExposure { } const bridgeExposureKey = (value: ElectronContextBridgeFinding): string => - JSON.stringify([value.world, value.api_key, value.api_key_expression]); + JSON.stringify([ + value.world, + value.api_key, + value.api_key_expression, + value.loader_authority ?? "proven", + ]); const bridgeContributionKey = (value: ElectronContextBridgeFinding): string => JSON.stringify([value.world_id, value.members, value.unknown_members]); @@ -234,6 +240,7 @@ const mergeBridgeExposures = ( }); interface PathContext { + readonly loader_authority?: "candidate"; readonly entryNode: t.Node; /** Source ranges of the local calls this path made since its entry. */ readonly calls: string[]; @@ -423,11 +430,15 @@ export function* collectElectronWrappedCallPathsSteps( accumulator.unknownFindings += 1; return null; } + const authorityContext = + value.kind === "external" && value.loaderAuthority === "candidate" + ? { ...context, loader_authority: "candidate" as const } + : context; return ( value.conditions?.reduce( (current, branch) => condition(current, branch.node, branch.positive), - context, - ) ?? context + authorityContext, + ) ?? authorityContext ); }; // Duplicate checks consult only facts at the same endpoint and call site. @@ -873,6 +884,9 @@ export function* collectElectronWrappedCallPathsSteps( ): void => { if (context.entryKind === null) return; const path: ElectronCallPath = { + ...(context.loader_authority === "candidate" + ? { loader_authority: "candidate" as const } + : {}), entry_kind: context.entryKind, entry: context.entry, ...(context.entryKind !== "bridge-member" || @@ -958,6 +972,9 @@ export function* collectElectronWrappedCallPathsSteps( ? undefined : range(context.entryNode); const findingContext = { + ...(context.loader_authority === "candidate" + ? { loader_authority: "candidate" as const } + : {}), module_key: null, location, ...(invocationLocation === undefined @@ -1223,6 +1240,9 @@ export function* collectElectronWrappedCallPathsSteps( // call contributes a separate API per key it is invoked with. const inventory = bridgeMemberInventory(object); const finding: ElectronContextBridgeFinding = { + ...(context.loader_authority === "candidate" + ? { loader_authority: "candidate" as const } + : {}), world: exposure.world, world_id: worldId, api_key: key.value, diff --git a/src/domain/javascript/javascriptBundledRequireOrigin.ts b/src/domain/javascript/javascriptBundledRequireOrigin.ts index 9a69f44dfb..9a137d0eb5 100644 --- a/src/domain/javascript/javascriptBundledRequireOrigin.ts +++ b/src/domain/javascript/javascriptBundledRequireOrigin.ts @@ -14,9 +14,10 @@ import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers. import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; import { - semanticRequireOrigin, + semanticRequireOriginCandidate, type JavaScriptRequireOrigin, } from "./javascriptSemanticRequireOrigin.js"; +import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; import { resolveSemanticBindingState, semanticResolutionBlocked, @@ -42,7 +43,7 @@ interface BootstrapExternals { /** Loader bindings with the canonical n.n getter installed before dispatch. */ readonly interopGetterBindingIds: ReadonlySet; /** Table keys whose factory only re-exports one unshadowed literal require; null when a key repeats. */ - readonly externals: ReadonlyMap; + readonly externals: ReadonlyMap; } const proofs = new WeakMap< @@ -62,6 +63,15 @@ const proofs = new WeakMap< export const semanticBundledRequireOrigin = ( node: t.Node | null | undefined, state: JavaScriptSemanticAnalysisState, +): JavaScriptRequireOrigin | undefined => { + const result = semanticBundledRequireOriginCandidate(node, state); + return result?.origin.loaderAuthority === "candidate" ? undefined : result; +}; + +/** Retain conditional externals without treating the bootstrap as host proof. */ +export const semanticBundledRequireOriginCandidate = ( + node: t.Node | null | undefined, + state: JavaScriptSemanticAnalysisState, ): JavaScriptRequireOrigin | undefined => { const chain = semanticMemberChain(node); const current = chain?.root; @@ -80,7 +90,10 @@ export const semanticBundledRequireOrigin = ( // CommonJS external, invoking that getter yields the exact exports object; // bind its identity to the proven registry result while retaining the // nested loader call as the source of provenance. - const nested = semanticBundledRequireOrigin(current.arguments[0], state); + const nested = semanticBundledRequireOriginCandidate( + current.arguments[0], + state, + ); const loaderBinding = resolveSemanticBindingState( state, current.callee.object, @@ -136,11 +149,11 @@ export const semanticBundledRequireOrigin = ( const proof = bootstrapExternals(ancestor, state); if (proof === null || !proof.loaderBindingIds.has(binding.bindingId)) continue; - const specifier = proof.externals.get(key); - return typeof specifier === "string" + const origin = proof.externals.get(key); + return origin !== null && origin !== undefined ? { origin: { - specifier, + ...origin, importedPath: chain.members, resolutionMode: "require", }, @@ -299,7 +312,7 @@ const isInstalledBeforeEntryDispatch = ( }; const bootstrapProofForLoaderCall = ( - call: t.CallExpression, + call: t.CallExpression | t.OptionalCallExpression, loaderBindingId: string, state: JavaScriptSemanticAnalysisState, ): BootstrapExternals | undefined => { @@ -403,7 +416,7 @@ const proveBootstrapExternals = ( state, ); if (integrity === undefined) return null; - const externals = new Map(); + const externals = new Map(); for (const { property, factory } of factories) { const key = modulePropertyName(property); const specifier = integrity.replacedKeys.has(key) @@ -430,7 +443,7 @@ const proveBootstrapExternals = ( const externalSpecifier = ( factory: t.FunctionExpression | t.ArrowFunctionExpression | t.ObjectMethod, state: JavaScriptSemanticAnalysisState, -): string | undefined => { +): JavaScriptModuleOrigin | undefined => { const moduleParameter = javascriptBundlerFactoryRoles( factory, BOOTSTRAP_RUNTIME, @@ -474,9 +487,12 @@ const externalSpecifier = ( target?.bindingId !== moduleBinding.bindingId ) return undefined; - const required = semanticRequireOrigin(expression.right, state)?.origin; + const required = semanticRequireOriginCandidate( + expression.right, + state, + )?.origin; return required !== undefined && required.importedPath.length === 0 - ? required.specifier + ? required : undefined; }; diff --git a/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts b/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts index 8e83747b13..82366cb920 100644 --- a/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts +++ b/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts @@ -3,7 +3,10 @@ import { runInNewContext } from "node:vm"; import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; -import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { + analyzeCommonJsStaticFixture, + analyzeCommonJsSemanticFixture, +} from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; const cases = [ [ @@ -115,7 +118,7 @@ it.each(cases)( module.exports, ); expect(changed).toBe(possible ? 1 : 0); - const analysis = analyzeJavaScriptStaticSource(defining); + const analysis = analyzeCommonJsStaticFixture(defining); expect(analysis.parse_error_count).toBe(0); expect((analysis.electron.app_path_changes ?? []).length > 0).toBe( possible, @@ -140,9 +143,9 @@ it("retains native source uncertainty when an array iterator is overwritten", () // The local change function is harmless, but the published app still permits // a caller to select the native setter. Unknown iteration retains that // possibility without identifying a yielded value or an exact export link. - const analysis = analyzeJavaScriptStaticSource(defining); + const analysis = analyzeCommonJsStaticFixture(defining); expect((analysis.electron.app_path_changes ?? []).length).toBeGreaterThan(0); - const ir = analyzeJavaScriptSemantics(defining); + const ir = analyzeCommonJsSemanticFixture(defining); expect( ir.moduleLinks.filter(({ kind }) => kind === "commonjs-export"), ).toEqual([ diff --git a/src/domain/javascript/javascriptCommonJsExportReceivers.ts b/src/domain/javascript/javascriptCommonJsExportReceivers.ts index 10d4a9b1bf..0fb297c2aa 100644 --- a/src/domain/javascript/javascriptCommonJsExportReceivers.ts +++ b/src/domain/javascript/javascriptCommonJsExportReceivers.ts @@ -8,6 +8,7 @@ import { } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, + semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; @@ -19,8 +20,10 @@ import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropert import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js"; import { isUnshadowedGlobal, + semanticBindingHasPriorValueWrite, semanticEffectTiming, } from "./javascriptSemanticMutationInitializers.js"; +import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; const exportInvocations = new WeakMap< @@ -176,12 +179,37 @@ const copiedOwnFields = ( return { definite: common ?? new Set(), complete }; }; +/** A possible wrapper export remains a candidate when its host is unknown. */ +const isCommonJsExportReference = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, + name: string, +): boolean => { + const context = javaScriptRuntimeContext(state.program); + if (context === "module") return false; + if (isUnshadowedGlobal(node, state, name)) return true; + if ( + context !== "unknown" || + !t.isIdentifier(node, { name }) || + semanticResolutionBlocked(state, node, name) + ) + return false; + const binding = resolveSemanticBindingState(state, node, name); + return ( + binding !== undefined && + state.scopesById.get(binding.scopeId)?.kind === "program" && + binding.declarationKinds.size === 1 && + binding.declarationKinds.has("var") && + !semanticBindingHasPriorValueWrite(node, binding, state) + ); +}; + const isCommonJsModuleExports = ( node: t.Node, state: JavaScriptSemanticAnalysisState, ): boolean => (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && - isUnshadowedGlobal(node.object, state, "module") && + isCommonJsExportReference(node.object, state, "module") && semanticStaticPropertyKey(node.property, node.computed) === "exports"; /** One syntax owner for CJS export names and their receiver identities. */ @@ -193,7 +221,7 @@ export const commonJsExportName = ( return undefined; const key = semanticStaticPropertyKey(node.property, node.computed); if ( - isUnshadowedGlobal(node.object, state, "exports") || + isCommonJsExportReference(node.object, state, "exports") || isCommonJsModuleExports(node.object, state) ) return key === null ? "*" : key; @@ -230,7 +258,7 @@ export const commonJsDefinedExport = ( return undefined; const receiver = unwrapJavaScriptExpression(target).node; if ( - (!isUnshadowedGlobal(receiver, state, "exports") && + (!isCommonJsExportReference(receiver, state, "exports") && !isCommonJsModuleExports(receiver, state)) || !isUnmodifiedGlobalReference(node.callee, state, [ "Object", @@ -272,7 +300,7 @@ const commonJsAssignedExports = ( if (!t.isExpression(target)) return undefined; const receiver = unwrapJavaScriptExpression(target).node; if ( - !isUnshadowedGlobal(receiver, state, "exports") && + !isCommonJsExportReference(receiver, state, "exports") && !isCommonJsModuleExports(receiver, state) ) return undefined; @@ -289,11 +317,8 @@ const commonJsAssignedExports = ( }; }; -/** Runtime values published by the CommonJS syntax owner, before receiver applicability. */ -export const commonJsRuntimeExportValues = ( - node: t.Node, - state: JavaScriptSemanticAnalysisState, -): readonly { +interface CommonJsRuntimeExportValue { + readonly loaderAuthority?: "candidate"; readonly exportedName: string; readonly value: t.Node | null; /** Retained input to unresolved iteration; never a selected yielded value. */ @@ -301,7 +326,13 @@ export const commonJsRuntimeExportValues = ( /** Own enumerable fields copied at this source, after later-source overwrites. */ readonly projection?: JavaScriptSemanticPropertyPath; readonly ownEnumerable?: true; -}[] => { +} + +/** Runtime values published by the CommonJS syntax owner, before receiver applicability. */ +const commonJsExportValues = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): readonly CommonJsRuntimeExportValue[] => { if (t.isAssignmentExpression(node)) { const exportedName = commonJsExportName(node.left, state); return exportedName === undefined @@ -310,8 +341,7 @@ export const commonJsRuntimeExportValues = ( } const assigned = commonJsAssignedExports(node, state); if (assigned !== undefined) { - const exported: ReturnType[number][] = - []; + const exported: CommonJsRuntimeExportValue[] = []; const overwritten = new Set(); for (let index = assigned.sources.length - 1; index >= 0; index--) { const { value, iterationSource } = assigned.sources[index]!; @@ -359,6 +389,20 @@ export const commonJsRuntimeExportValues = ( ]; }; +/** Preserve potential exports without asserting an unobserved CommonJS wrapper. */ +export const commonJsRuntimeExportValues = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): readonly CommonJsRuntimeExportValue[] => { + const values = commonJsExportValues(node, state); + return javaScriptRuntimeContext(state.program) === "unknown" + ? values.map((value) => ({ + ...value, + loaderAuthority: "candidate" as const, + })) + : values; +}; + type Identity = t.Node | null; type Execution = "ordered" | "conditional" | "deferred"; @@ -423,12 +467,12 @@ export function* indexCommonJsExportReceiversSteps( target = unwrapJavaScriptExpression(target).node; const possiblePublicWrite = (t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) && - isUnshadowedGlobal(target.object, state, "module") && + isCommonJsExportReference(target.object, state, "module") && semanticStaticPropertyKey(target.property, target.computed) === null; const publicWrite = isCommonJsModuleExports(target, state) || possiblePublicWrite; - const exportsWrite = isUnshadowedGlobal(target, state, "exports"); - const moduleWrite = isUnshadowedGlobal(target, state, "module"); + const exportsWrite = isCommonJsExportReference(target, state, "exports"); + const moduleWrite = isCommonJsExportReference(target, state, "module"); if (!publicWrite && !exportsWrite && !moduleWrite) return; if (mode === "deferred") { // A source offset cannot order an invocation against module completion. @@ -489,14 +533,14 @@ export function* indexCommonJsExportReceiversSteps( mode !== "ordered" ? null : receiver !== null && - isUnshadowedGlobal(receiver, state, "exports") + isCommonJsExportReference(receiver, state, "exports") ? exportsIdentity : publicIdentity, ); } const descriptor = commonJsDefinedExport(node, state); if (descriptor !== undefined) { - const receiver = isUnshadowedGlobal( + const receiver = isCommonJsExportReference( descriptor.receiver, state, "exports", @@ -513,7 +557,7 @@ export function* indexCommonJsExportReceiversSteps( node, mode !== "ordered" ? null - : isUnshadowedGlobal(defined.receiver, state, "exports") + : isCommonJsExportReference(defined.receiver, state, "exports") ? exportsIdentity : publicIdentity, ); @@ -559,7 +603,7 @@ export function* indexCommonJsExportReceiversSteps( (t.isMemberExpression(node.argument) || t.isOptionalMemberExpression(node.argument)) ) { - const receiver = isUnshadowedGlobal( + const receiver = isCommonJsExportReference( node.argument.object, state, "exports", @@ -575,7 +619,7 @@ export function* indexCommonJsExportReceiversSteps( } } write(node.argument, null, mode); - } else if (isUnshadowedGlobal(node, state, "exports")) { + } else if (isCommonJsExportReference(node, state, "exports")) { values.set(node, exportsIdentity); } else if (isCommonJsModuleExports(node, state)) { values.set(node, publicIdentity); diff --git a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts index dce3de89ec..b56c40004f 100644 --- a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts +++ b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts @@ -15,7 +15,7 @@ import { applicationFeatureSeedSchema, type ApplicationFeatureSeed, } from "./javascriptFeatureTraceSchemas.js"; -import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; import type { JsonValue } from "../jsonValue.js"; const memberFields = [ @@ -455,7 +455,7 @@ const propertyMatch = (node: ApplicationNode, field: string) => ({ }); const contextBridgeGraph = () => { - const analysis = analyzeJavaScriptStaticSource( + const analysis = analyzeCommonJsStaticFixture( "const { contextBridge } = require('electron');\ncontextBridge.exposeInMainWorld('desktopApi', { openProject() {}, closeProject() {} });", ); expect(analysis.parse_status).toBe("complete"); diff --git a/src/domain/javascript/javascriptModuleResolutionMode.ts b/src/domain/javascript/javascriptModuleResolutionMode.ts index 66d130c758..e958649697 100644 --- a/src/domain/javascript/javascriptModuleResolutionMode.ts +++ b/src/domain/javascript/javascriptModuleResolutionMode.ts @@ -6,13 +6,39 @@ export const isJsonModuleTarget = ( path: string, kind: JavaScriptArtifactFileKind | undefined, ): boolean => - path.endsWith(".json") && (kind === "json" || kind === "package-json"); + posix.extname(path) === ".json" && + (kind === "json" || kind === "package-json"); /** Native-addon dispatch likewise needs the exact `.node` loader suffix. */ export const isNativeAddonModuleTarget = ( path: string, kind: JavaScriptArtifactFileKind | undefined, -): boolean => path.endsWith(".node") && kind === "native-addon"; +): boolean => posix.extname(path) === ".node" && kind === "native-addon"; + +/** Exact loader suffixes are separate from case-folded artifact grammar. */ +export const explicitJavaScriptLoaderMode = ( + path?: string, +): "module" | "commonjs" | undefined => { + switch (path === undefined ? undefined : posix.extname(path)) { + case ".mjs": + case ".mts": + return "module"; + case ".cjs": + case ".cts": + return "commonjs"; + default: + return undefined; + } +}; + +/** Case variants are artifact syntax hints, not recognized loader suffixes. */ +export const hasCaseVariantJavaScriptLoaderSuffix = ( + path?: string, +): boolean => { + if (path === undefined) return false; + const suffix = /\.(?:js|ts|mjs|cjs|mts|cts)$/iu.exec(path)?.[0]; + return suffix !== undefined && suffix !== suffix.toLowerCase(); +}; /** Loader evidence retained for a source-level module reference. */ export type JavaScriptModuleResolutionMode = @@ -29,9 +55,10 @@ export const moduleResolutionModeForJavaScriptReference = ( if (explicitMode !== undefined) return explicitMode; if (referenceKind === "require") return "require"; if (referenceKind === "dynamic-import") return "import"; + const loaderMode = explicitJavaScriptLoaderMode(sourcePath); + if (loaderMode !== undefined) + return loaderMode === "commonjs" ? "require" : "import"; const extension = posix.extname(sourcePath).toLowerCase(); - if (extension === ".cjs" || extension === ".cts") return "require"; - if (extension === ".mts") return "import"; if (extension === ".ts" || extension === ".tsx") return "compiler-dependent"; return "import"; }; diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 2c7e3f15dd..0d35023b63 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -62,6 +62,15 @@ const canonicalAmbientPath = ( type AmbientPath = readonly (string | null)[]; +const ambientEffectCovers = ( + effect: JavaScriptSemanticAnalysisState["ambientWriteEffects"][number], + target: readonly string[], +): boolean => + effect.path.length <= target.length && + effect.path.every((key, index) => + semanticPropertyPathKeyMatches(key, target[index]!), + ); + const retainedAmbientEffects = new WeakMap< JavaScriptSemanticAnalysisState, Map> @@ -798,8 +807,8 @@ export function* indexJavaScriptAmbientWritesSteps( } } // Settle other ambient effects first. A syntactic resolver call is safe only - // if no write or opaque escape can replace the native member anywhere in - // this analysis; otherwise retain its receiver escape conservatively. + // if no write or opaque escape can replace its native member before this + // invocation; ambiguous execution order remains conservative. const resolverReceivers: typeof escapes = []; for (const effect of escapes) { const { node, target } = effect; @@ -821,10 +830,32 @@ export function* indexJavaScriptAmbientWritesSteps( } for (const effect of resolverReceivers) { const { node } = effect; + const selected = ["global", "require", "resolve"]; + const mayAffectResolver = ( + effects: typeof state.ambientWriteEffects, + covered: boolean, + ): boolean => { + if (!covered) return false; + const matches = effects.filter((entry) => + ambientEffectCovers(entry, selected), + ); + return ( + matches.length === 0 || + matches.some( + (entry) => semanticEffectTiming(node, entry.node, state) !== "after", + ) + ); + }; if ( (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && - !state.ambientWrites.covers(["global", "require", "resolve"]) && - !state.ambientEscapes.covers(["global", "require", "resolve"]) && + !mayAffectResolver( + state.ambientWriteEffects, + state.ambientWrites.covers(selected), + ) && + !mayAffectResolver( + state.ambientEscapeEffects, + state.ambientEscapes.covers(selected), + ) && readSemanticRequireLoader(node, state, new Set()) === "commonjs-loader" ) continue; @@ -877,14 +908,6 @@ export const createGlobalReferenceReader = ( string | null, typeof state.ambientWriteEffects >(); - const matches = ( - effect: (typeof state.ambientWriteEffects)[number], - target: readonly string[], - ) => - effect.path.length <= target.length && - effect.path.every((key, index) => - semanticPropertyPathKeyMatches(key, target[index]!), - ); return (callee) => { const namespace = globalReferenceNamespace(callee, state, path); if (namespace === undefined) return { status: "unrelated" }; @@ -901,8 +924,9 @@ export const createGlobalReferenceReader = ( if (effects === undefined) { effects = state.ambientWriteEffects.filter( (effect) => - matches(effect, selected) || - (namespace !== null && matches(effect, ["global", namespace])), + ambientEffectCovers(effect, selected) || + (namespace !== null && + ambientEffectCovers(effect, ["global", namespace])), ); byNamespace.set(namespace, effects); } diff --git a/src/domain/javascript/javascriptSemanticAnalysis.candidate_node_effects.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.candidate_node_effects.test.ts new file mode 100644 index 0000000000..f6a80f9b0e --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.candidate_node_effects.test.ts @@ -0,0 +1,200 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { analyzeCommonJsSemanticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; + +const source = `const {spawn} = require('child_process'); +const timers = require('timers'); +const child = spawn('tool', []); child.kill(); +const timer = timers.setTimeout(() => {}, 1); timers.clearTimeout(timer);`; + +it("retains conditional child and timer observations without proving a loader", () => { + const result = analyzeJavaScriptSemantics(source); + expect(result.childProcessSpawns).toMatchObject([ + { command: "tool", builtinAuthority: "candidate" }, + ]); + expect(result.childProcessInteractions).toMatchObject([ + { kind: "signal", builtinAuthority: "candidate", resolution: "partial" }, + ]); + expect(result.timerOperations).toMatchObject([ + { kind: "schedule", builtinAuthority: "candidate" }, + { kind: "cancel", builtinAuthority: "candidate" }, + ]); + const proven = analyzeCommonJsSemanticFixture(source); + expect(proven.childProcessSpawns[0]).not.toHaveProperty("builtinAuthority"); + expect(proven.timerOperations[0]).not.toHaveProperty("builtinAuthority"); +}); + +it("does not treat shadowed or overwritten bindings as candidate builtins", () => { + const result = analyzeJavaScriptSemantics(` + const cp = require('child_process'); cp.spawn = custom; cp.spawn('bad', []); + function local(require) { require('timers').setTimeout(() => {}, 1); } + `); + expect(result.childProcessSpawns).toEqual([]); + expect(result.timerOperations).toEqual([]); +}); + +it("does not promote a builtin binding read before initialization", () => { + const result = analyzeJavaScriptSemantics(` + spawn('early', []); + var { spawn } = require('child_process'); + spawn('late', []); + `); + expect(result.childProcessSpawns).toMatchObject([ + { command: "late", builtinAuthority: "candidate" }, + ]); + expect(result.childProcessSpawns).toHaveLength(1); +}); + +it("tracks conditional builtin slot writes without discarding unrelated or previously captured methods", () => { + const result = analyzeJavaScriptSemantics(` + const saved = require('child_process').spawn; + require('child_process').exec = custom; + require('child_process').spawn('unrelated', []); + require('child_process').spawn = custom; + require('child_process').spawn('replaced', []); + saved('captured', []); + `); + expect( + result.childProcessSpawns.map(({ command, builtinAuthority }) => ({ + command, + builtinAuthority, + })), + ).toEqual([ + { command: "unrelated", builtinAuthority: "candidate" }, + { command: "captured", builtinAuthority: "candidate" }, + ]); +}); + +it("retains a global process read conditionally when a candidate module may have changed it", () => { + const result = analyzeJavaScriptSemantics(` + require('process').env.MODE = custom; + const mode = process.env.MODE; + `); + expect(result.configurationOperations).toMatchObject([ + { key: "MODE", builtinAuthority: "candidate" }, + ]); +}); + +it("does not turn a proven same-slot replacement into a conditional global process read", () => { + const result = analyzeJavaScriptSemantics(` + process.env.MODE = custom; + const mode = process.env.MODE; + `); + expect(result.configurationOperations).toEqual([]); +}); + +it("weakens default-import builtin effects after a conditional same-slot write", () => { + const result = analyzeJavaScriptSemantics(` + import fs from 'fs'; + require('fs').readFileSync = custom; + fs.readFileSync('config.json'); + `); + expect(result.configurationOperations).toMatchObject([ + { key: "config.json", builtinAuthority: "candidate" }, + ]); +}); + +it("keeps named builtin snapshots proven while weakening mutable default namespaces", () => { + const result = analyzeJavaScriptSemantics(` + import {readFileSync} from 'fs'; + import http from 'http'; import timers from 'timers'; import cp from 'child_process'; + require('fs').readFileSync = custom; + require('http').get = custom; + require('timers').setTimeout = custom; + require('child_process').spawn = custom; + readFileSync('named.json'); http.get('http://example.test'); + timers.setTimeout(() => {}, 1); cp.spawn('tool', []); + `); + expect(result.configurationOperations).toMatchObject([{ key: "named.json" }]); + expect(result.configurationOperations[0]).not.toHaveProperty( + "builtinAuthority", + ); + expect(result.requestOperations).toMatchObject([ + { builtinAuthority: "candidate" }, + ]); + expect(result.timerOperations).toMatchObject([ + { builtinAuthority: "candidate" }, + ]); + expect(result.childProcessSpawns).toMatchObject([ + { builtinAuthority: "candidate" }, + ]); +}); + +it("keeps namespace snapshot methods proven while marking the mutable default object conditional", () => { + const result = analyzeJavaScriptSemantics(` + import * as fs from 'fs'; + require('fs').readFileSync = custom; + fs.readFileSync('snapshot.json'); + fs.default.readFileSync('mutable.json'); + `); + const snapshot = result.configurationOperations.find( + ({ key }) => key === "snapshot.json", + ); + const mutable = result.configurationOperations.find( + ({ key }) => key === "mutable.json", + ); + expect(snapshot).toBeDefined(); + expect(snapshot).not.toHaveProperty("builtinAuthority"); + expect(mutable).toMatchObject({ builtinAuthority: "candidate" }); +}); + +it("preserves namespace process env snapshot across object replacement, but not nested mutation", () => { + const replaced = analyzeJavaScriptSemantics(` + import * as p from 'process'; require('process').env = custom; p.env.MODE; + `).configurationOperations.find(({ key }) => key === "MODE"); + expect(replaced).toBeDefined(); + expect(replaced).not.toHaveProperty("builtinAuthority"); + const mutated = analyzeJavaScriptSemantics(` + import * as p from 'process'; require('process').env.MODE = custom; p.env.MODE; + `).configurationOperations.find(({ key }) => key === "MODE"); + expect(mutated).toMatchObject({ builtinAuthority: "candidate" }); +}); + +it("distinguishes proven namespace object replacement from proven slot mutation", () => { + const replaced = analyzeJavaScriptSemantics(` + import * as p from 'process'; process.env = custom; p.env.MODE; + `).configurationOperations.find(({ key }) => key === "MODE"); + expect(replaced).toBeDefined(); + expect(replaced).not.toHaveProperty("builtinAuthority"); + const mutated = analyzeJavaScriptSemantics(` + import * as p from 'process'; process.env.MODE = custom; p.env.MODE; + `).configurationOperations.find(({ key }) => key === "MODE"); + expect(mutated).toBeUndefined(); +}); + +it("keeps function fingerprints partial when built-in effects depend on an unknown loader", () => { + const source = `const { spawn } = require('child_process'); function execute() { spawn('tool', []); } function unrelated() { return 1; }`; + const candidate = analyzeJavaScriptSemantics(source); + const proven = analyzeCommonJsSemanticFixture(source); + const fingerprint = (result: typeof candidate, name: string) => { + const callable = result.callables.find((item) => item.name === name); + return result.functionFingerprints.find( + (item) => item.callableId === callable?.callableId, + ); + }; + expect(fingerprint(candidate, "execute")).toMatchObject({ + status: "partial", + components: { effects: ["child-process"] }, + }); + expect(fingerprint(candidate, "execute")?.limitations.length).toBeGreaterThan( + 1, + ); + expect(fingerprint(candidate, "unrelated")?.status).toBe("complete"); + expect(fingerprint(proven, "execute")?.status).toBe("complete"); +}); + +it("marks an ESM fingerprint partial only when a conditional write affects its builtin effect", () => { + const sample = (write: string) => + analyzeJavaScriptSemantics(` + import http from 'http'; + ${write} + function request() { http.get('http://example.test'); } + `).functionFingerprints[0]; + expect(sample("")?.status).toBe("complete"); + const conditional = sample("require('http').get = custom;"); + expect(conditional).toMatchObject({ + status: "partial", + components: { effects: ["network"] }, + }); + expect(conditional?.limitations.length).toBeGreaterThan(1); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts index 234f05d262..dde90fd528 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts @@ -16,6 +16,7 @@ const ordinaryMemberChain = ".next".repeat(64); it("preserves a 12,000-member native import through both analyzers", () => { const parsed = parseJavaScriptSource( `const value = require("./addon.node")${memberChain}.last; const ordinary = root.next.last;`, + "fixture.cjs", ); if (parsed === null) throw new Error("Expected valid JavaScript"); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts index 79798fa782..fa5b7beb97 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts @@ -277,14 +277,19 @@ describe("CommonJS dependency links", () => { }); it("keeps bindings and re-exports without extra dependency links", () => { - const ir = analyzeJavaScriptSemantics(` + const ir = analyzeParsedJavaScriptSemantics( + parseJavaScriptSource( + ` const bound = (require("bound") as unknown); const { left, right } = require("destructured"); module.exports = require("forwarded"); exports = module.exports; exports.member = require("member").value; exports.first = exports.second = require("chained"); - `); + `, + "entry.cts", + )!, + ); expect( ir.moduleLinks.map(({ kind, specifier, localName, exportedName }) => ({ kind, @@ -342,31 +347,36 @@ describe("CommonJS dependency links", () => { { specifier: "bound", importedPath: [], resolutionMode: "require" }, ]); }); +}); - it.each([ - 'require("outer", require("inner"));', - 'const bound = require("outer", require("inner"));', - 'module.exports = require("outer", require("inner"));', - ])("retains a distinct nested call in %s", (source) => { - const links = analyzeJavaScriptSemantics(source).moduleLinks; - expect(links.map(({ specifier }) => specifier)).toEqual(["outer", "inner"]); - expect(links[1]).toMatchObject({ - kind: "require", - importedName: null, - localName: null, - exportedName: null, - }); +it.each([ + 'require("outer", require("inner"));', + 'const bound = require("outer", require("inner"));', + 'module.exports = require("outer", require("inner"));', +])("retains a distinct nested call in %s", (source) => { + const links = analyzeJavaScriptSemantics(source).moduleLinks; + expect(links.map(({ specifier }) => specifier)).toEqual(["outer", "inner"]); + expect(links[1]).toMatchObject({ + kind: "require", + importedName: null, + localName: null, + exportedName: null, }); }); it("preserves unshadowed require origins through aliases and assignments", () => { - const ir = analyzeJavaScriptSemantics(` + const ir = analyzeParsedJavaScriptSemantics( + parseJavaScriptSource( + ` const { ipcMain: bus } = require("electron"); const forwarded = bus; let assigned; assigned = require("electron").ipcMain; { const require = localLoader; require("./local.js"); } - `); + `, + "entry.cts", + )!, + ); for (const name of ["bus", "forwarded"]) expect(origin(topLevelBinding(ir, name))).toEqual({ specifier: "electron", diff --git a/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts index cf751f11ad..a31b9754a2 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts @@ -227,13 +227,14 @@ it.each([ ); it("keeps established logical operands across a long chain", () => { - // Known-falsy operands select the right branch at every level; the value and - // provenance must still come from the one reachable operand. - const ir = analyzeJavaScriptSemantics( - `const tok = ${'false || null || 0 || "" || '.repeat(10)}require("electron").app;`, - ); - expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 }); - expect(onlyBinding(ir, "tok").provenance).toEqual({ + // Known-falsy operands select the right branch at every level. + const source = `const tok = ${'false || null || 0 || "" || '.repeat(10)}require("electron").app;`; + const parsed = parseJavaScriptSource(source, "main.cjs"); + if (parsed === null) throw new Error("fixture did not parse"); + const proven = analyzeParsedJavaScriptSemantics(parsed); + const candidate = analyzeJavaScriptSemantics(source); + expect(proven.coverage).toEqual({ status: "complete", omittedCount: 0 }); + expect(onlyBinding(proven, "tok").provenance).toEqual({ status: "module", origins: [ { @@ -244,4 +245,14 @@ it("keeps established logical operands across a long chain", () => { ], reason: null, }); + expect(onlyBinding(candidate, "tok").provenance).toMatchObject({ + status: "ambiguous", + origins: [ + { + specifier: "electron", + importedPath: ["app"], + loaderAuthority: "candidate", + }, + ], + }); }); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index fd5e60af1f..8d4015e845 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -67,12 +67,17 @@ import { } from "./javascriptSemanticTraversal.js"; import { semanticRequireOrigin, + semanticRequireOriginCandidate, observePossibleCommonJsDelegateWrite, invalidateSemanticRequireDelegateWrites, } from "./javascriptSemanticRequireOrigin.js"; -import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.js"; +import { + semanticBundledRequireOrigin, + semanticBundledRequireOriginCandidate, +} from "./javascriptBundledRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; import { + semanticBindingAtRead, semanticEffectTiming, semanticEffectTimingWithinActivation, } from "./javascriptSemanticMutationInitializers.js"; @@ -844,12 +849,16 @@ const resolveGlobalAliasFact = ( export interface JavaScriptElectronIdentity { /** Proven export paths keyed by call/construction root offset; absence is unknown. */ readonly bindings: ReadonlyMap; + /** Conditional export paths whose require loader is not established. */ + readonly candidateBindings: ReadonlyMap; readonly intactExport: (path: readonly (string | null)[]) => boolean; readonly exportPath: (root: t.Node) => readonly string[] | undefined; + readonly candidateExportPath: (root: t.Node) => readonly string[] | undefined; readonly invocationPath: ( node: t.CallExpression | t.OptionalCallExpression, ) => readonly string[] | undefined; readonly mayChangeAppPath: (node: t.Node) => boolean; + readonly candidateMayChangeAppPath: (node: t.Node) => boolean; } export function* indexJavaScriptElectronIdentitySteps( @@ -857,15 +866,33 @@ export function* indexJavaScriptElectronIdentitySteps( state: JavaScriptSemanticAnalysisState, ): Generator { const facts = new Map(); + const candidateFacts = new Map(); + let hasCandidateElectronSource = false; const interop = createSemanticModuleInterop(state); const writes = new SemanticPropertyPathCoverage(); let collectedWrites = false; const exportsByBinding = new Map(); + const candidatesByBinding = new Map(); + const originAvailableAtRead = ( + binding: JavaScriptSemanticBindingState, + read: t.Node, + ): boolean => { + if ( + binding.directOrigins.length > 0 || + semanticStableInitializer(binding, state) === undefined + ) + return true; + const access = semanticBindingAtRead(binding, read, state); + // An unresolved activation can still carry a possible identity, while an + // ordered read before its initializer cannot refer to that value. + return access.status !== "ordered" || access.hasDefiniteInitializer; + }; const rootOrigin = (root: t.Node): readonly string[] | undefined => { if (t.isIdentifier(root)) { if (semanticResolutionBlocked(state, root, root.name)) return undefined; const binding = resolveSemanticBindingState(state, root, root.name); - if (binding === undefined) return undefined; + if (binding === undefined || !originAvailableAtRead(binding, root)) + return undefined; let exported = exportsByBinding.get(binding.bindingId); if (exported === undefined) { exported = electronExport(binding, state, interop); @@ -900,13 +927,79 @@ export function* indexJavaScriptElectronIdentitySteps( } return rootOrigin(root); }; + const candidateRootExport = (root: t.Node): readonly string[] | undefined => { + if (t.isIdentifier(root)) { + if (semanticResolutionBlocked(state, root, root.name)) return undefined; + const binding = resolveSemanticBindingState(state, root, root.name); + if ( + binding === undefined || + (collectedWrites && interop.invalidated(binding)) + ) + return undefined; + if (binding.directOrigins.length === 0) { + if (semanticStableInitializer(binding, state) === undefined) + return undefined; + if (!originAvailableAtRead(binding, root)) return undefined; + } + if ( + state.opaqueEffects.byScope.size > 0 && + (semanticOpaqueBindingReadMayHaveChanged(binding, root, state) || + interop.aliasMayHaveChanged(binding)) + ) + return undefined; + let candidate = candidatesByBinding.get(binding.bindingId); + if (candidate === undefined) { + const provenance = evaluateSemanticProvenance(binding, state); + const initializer = semanticStableInitializer(binding, state); + const bundled = + initializer === undefined + ? undefined + : semanticBundledRequireOriginCandidate(initializer.node, state) + ?.origin; + const origin = + bundled?.loaderAuthority === "candidate" && + initializer?.projection.every( + (key): key is string => typeof key === "string", + ) + ? { + ...bundled, + importedPath: [ + ...bundled.importedPath, + ...initializer.projection, + ], + } + : provenance.status === "ambiguous" && + provenance.origins.length === 1 + ? provenance.origins[0] + : undefined; + candidate = + origin?.loaderAuthority === "candidate" && + ELECTRON_MODULE.test(origin.specifier) + ? electronExportPath(origin.importedPath) + : null; + candidatesByBinding.set(binding.bindingId, candidate); + } + return candidate ?? undefined; + } + const origin = ( + semanticRequireOriginCandidate(root, state) ?? + semanticBundledRequireOriginCandidate(root, state) + )?.origin; + return origin?.loaderAuthority === "candidate" && + ELECTRON_MODULE.test(origin.specifier) + ? electronExportPath(origin.importedPath) + : undefined; + }; yield* collectJavaScriptIdentityWritesSteps( program, state.erasedSyntaxNodes, (node, kind) => { if (kind === "opaque-call") return [interop.opaqueMutationPath(node)]; const { root, members } = electronMemberPath(node); - const exported = kind === "binding" ? undefined : rootOrigin(root); + const exported = + kind === "binding" + ? undefined + : (rootOrigin(root) ?? candidateRootExport(root)); const path = exported === undefined ? (interop.intrinsicPath(root) ?? @@ -936,6 +1029,17 @@ export function* indexJavaScriptElectronIdentitySteps( !t.isNewExpression(node) ) return; + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + t.isIdentifier(node.callee, { name: "require" }) + ) { + const origin = semanticRequireOriginCandidate(node, state)?.origin; + if ( + origin?.loaderAuthority === "candidate" && + ELECTRON_MODULE.test(origin.specifier) + ) + hasCandidateElectronSource = true; + } const { root, members } = electronMemberPath(node.callee); const exported = rootExport(root); if ( @@ -943,6 +1047,14 @@ export function* indexJavaScriptElectronIdentitySteps( intactExport(["electron", ...exported, ...members]) ) facts.set(root.start ?? -1, exported); + else { + const candidate = candidateRootExport(root); + if ( + candidate !== undefined && + intactExport(["electron", ...candidate, ...members]) + ) + candidateFacts.set(root.start ?? -1, candidate); + } }, }); const invocationPath = ( @@ -965,14 +1077,23 @@ export function* indexJavaScriptElectronIdentitySteps( }; return { bindings: facts, + candidateBindings: candidateFacts, intactExport, exportPath: rootExport, + candidateExportPath: candidateRootExport, invocationPath, mayChangeAppPath: createSemanticElectronAppPathChangeQuery( state, rootOrigin, interop, ), + candidateMayChangeAppPath: hasCandidateElectronSource + ? createSemanticElectronAppPathChangeQuery( + state, + candidateRootExport, + interop, + ) + : () => false, }; } diff --git a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts index 93a6dc3040..227cbee9e0 100644 --- a/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysisSteps.test.ts @@ -258,7 +258,7 @@ if (globalThis.register) { const conditionalAlias = conditional; conditionalAlias.handle("conditional-alias:ping", () => "local"); `; - const parsed = parseJavaScriptSource(text, "aliases.js"); + const parsed = parseJavaScriptSource(text, "aliases.cjs"); if (parsed === null) throw new Error("Expected parsed source"); const index = completeSemanticSteps( @@ -267,6 +267,21 @@ conditionalAlias.handle("conditional-alias:ping", () => "local"); const electron = completeSemanticSteps( indexJavaScriptElectronIdentitySteps(parsed.program, index.bindings), ); + const unknown = parseJavaScriptSource(text, "aliases.js"); + if (unknown === null) throw new Error("Expected unknown-host source"); + const unknownIndex = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(unknown.program), + ); + const candidates = completeSemanticSteps( + indexJavaScriptElectronIdentitySteps( + unknown.program, + unknownIndex.bindings, + ), + ); + expect(candidates.bindings.size).toBe(0); + expect(candidates.candidateBindings.get(text.indexOf("Win({"))).toEqual([ + "BrowserWindow", + ]); expect([...electron.bindings]).toEqual([ [text.indexOf("Win({"), ["BrowserWindow"]], [text.indexOf("bridge.exposeInMainWorld"), ["contextBridge"]], diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 1033f6e38f..9dc48f6313 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -24,7 +24,7 @@ import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.j import { semanticOpaqueNameMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { - builtinDataEffectMethod, + builtinDataEffectIdentity, dataEffectArgumentBindingId, dataEffectLiteralString, dataEffectMemberCallee, @@ -92,6 +92,7 @@ interface AsyncEffectAnalysis { } interface TimerCandidate { + readonly builtinAuthority?: "candidate"; readonly node: t.CallExpression | t.OptionalCallExpression; readonly method: JavaScriptSemanticTimerOperation["method"]; readonly kind: JavaScriptSemanticTimerOperation["kind"]; @@ -306,20 +307,25 @@ const emitterIdentity = ( }; /** - * A timer proven to come from the Node `timers` module rather than a bare - * global. Browser and Electron renderer sources reach this analysis without a - * Node/browser distinction, and there `setTimeout("refresh()", 0)` is a real + * A timer associated with the Node `timers` module, either proven or + * conditional, rather than a bare global. Browser and Electron renderer sources + * reach this analysis without a Node/browser distinction, and there `setTimeout("refresh()", 0)` is a real * timer: the string is evaluated by the host. Node's callback rule therefore - * applies only when the timer is imported from `node:timers`, where that form - * cannot exist. + * applies when interpreting the call as `node:timers`, where that form + * cannot create a Node timer even if the module identity is conditional. */ const nodeBuiltinTimerMethod = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, -): JavaScriptSemanticTimerOperation["method"] | null => - admittedTimerMethod( - builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "", +): JavaScriptSemanticTimerOperation["method"] | null => { + const identity = builtinDataEffectIdentity(callee, state, [ + "timers", + "node:timers", + ]); + return admittedTimerMethod( + identity?.path.length === 1 ? (identity.path[0] ?? "") : "", ); +}; /** * A schedule imported from Node's `timers` module requires a function in @@ -370,8 +376,9 @@ const schedulesWithNonFunctionCallback = ( const collectTimer = (input: TimerCollectionInput): void => { const { node, ownerCallableId, state, output, ancestors } = input; - const method = timerMethod(node.callee, state); - if (method === null) return; + const identity = timerMethod(node.callee, state); + if (identity === null) return; + const { method, builtinAuthority } = identity; if ( TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) && nodeBuiltinTimerMethod(node.callee, state) !== null && @@ -421,6 +428,7 @@ const collectTimer = (input: TimerCollectionInput): void => { output.push({ node, method, + ...(builtinAuthority === undefined ? {} : { builtinAuthority }), kind, ownerCallableId, handleBindingId: @@ -467,6 +475,10 @@ const resolveTimers = ( candidate.handleRead !== null && candidate.handleRead.origins.length !== linked.length; return { + ...(candidate.builtinAuthority === "candidate" || + linked.some((item) => item.builtinAuthority === "candidate") + ? { builtinAuthority: "candidate" as const } + : {}), timerId: timerId(candidate), kind: candidate.kind, method: candidate.method, @@ -499,7 +511,10 @@ const resolveTimers = ( const timerMethod = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, -): JavaScriptSemanticTimerOperation["method"] | null => { +): { + readonly method: JavaScriptSemanticTimerOperation["method"]; + readonly builtinAuthority?: "candidate"; +} | null => { if (t.isIdentifier(callee)) { const binding = resolveSemanticBindingState(state, callee, callee.name); if (binding === undefined) { @@ -508,12 +523,24 @@ const timerMethod = ( return semanticResolutionBlocked(state, callee, callee.name) || !isUnmodifiedGlobalReference(callee, state, callee.name) ? null - : method; + : { method }; } } - return admittedTimerMethod( - builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "", + const identity = builtinDataEffectIdentity(callee, state, [ + "timers", + "node:timers", + ]); + const method = admittedTimerMethod( + identity?.path.length === 1 ? (identity.path[0] ?? "") : "", ); + return method === null + ? null + : { + method, + ...(identity?.builtinAuthority === undefined + ? {} + : { builtinAuthority: identity.builtinAuthority }), + }; }; const admittedTimerMethod = ( value: string, diff --git a/src/domain/javascript/javascriptSemanticCandidateBuiltinEffects.test.ts b/src/domain/javascript/javascriptSemanticCandidateBuiltinEffects.test.ts new file mode 100644 index 0000000000..34737cf0e8 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticCandidateBuiltinEffects.test.ts @@ -0,0 +1,123 @@ +import { expect, it } from "vitest"; + +import { analyzeCommonJsSemanticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; +import { + analyzeJavaScriptSemantics, + analyzeParsedJavaScriptSemantics, +} from "./javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +const source = ` +const fs = require('fs'); +const process = require('process'); +const http = require('http'); +fs.readFileSync('config.json'); +process.env.MODE; +const stream = fs.createReadStream('data'); +stream.close(); +http.get('http://example.test'); +`; + +it("retains built-in effects as conditional when the loader is unknown", () => { + const candidate = analyzeJavaScriptSemantics(source); + const proven = analyzeCommonJsSemanticFixture(source); + for (const ir of [candidate, proven]) { + expect(ir.configurationOperations.map(({ key }) => key)).toEqual([ + "config.json", + "MODE", + ]); + expect(ir.resourceOperations.map(({ kind }) => kind)).toEqual([ + "acquire", + "release", + ]); + expect(ir.requestOperations.map(({ endpoint }) => endpoint)).toEqual([ + "http://example.test", + ]); + } + for (const operation of [ + ...candidate.configurationOperations, + ...candidate.resourceOperations, + ...candidate.requestOperations, + ]) + expect(operation.builtinAuthority).toBe("candidate"); + for (const operation of [ + ...proven.configurationOperations, + ...proven.resourceOperations, + ...proven.requestOperations, + ]) + expect(operation.builtinAuthority).toBeUndefined(); +}); + +it("does not infer a conditional built-in after the selected method is replaced or the loader is shadowed", () => { + for (const source of [ + `const fs = require('fs'); fs.readFileSync = replacement; fs.readFileSync('config');`, + `function load(require) { const fs = require('fs'); fs.readFileSync('config'); }`, + ]) + expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual( + [], + ); +}); + +it("retains conditional built-in use across imported and unknown-loader alternatives", () => { + for (const choice of ["builtin : alternate", "alternate : builtin"]) { + const source = `import * as builtin from "node:fs"; const alternate = require("fs"); const selected = globalThis.flag ? ${choice}; selected.readFileSync("config.json");`; + const parsed = parseJavaScriptSource(source, "entry.MJS"); + if (parsed === null) throw new Error("fixture did not parse"); + expect( + analyzeParsedJavaScriptSemantics(parsed).configurationOperations, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "file", + key: "config.json", + builtinAuthority: "candidate", + }), + ]), + ); + } +}); + +it("does not equate mixed alternatives with different identities or replaced methods", () => { + for (const source of [ + 'import * as builtin from "node:fs"; const alternate = require("http"); const selected = globalThis.flag ? builtin : alternate; selected.readFileSync("config.json");', + 'import * as builtin from "node:fs"; const alternate = require("fs"); alternate.readFileSync = globalThis.replacement; const selected = globalThis.flag ? builtin : alternate; selected.readFileSync("config.json");', + 'import * as builtin from "node:fs"; const alternate = require("fs"); const selected = globalThis.flag ? builtin : alternate; selected.default.readFileSync("config.json");', + ]) { + const parsed = parseJavaScriptSource(source, "entry.MJS"); + if (parsed === null) throw new Error("fixture did not parse"); + expect( + analyzeParsedJavaScriptSemantics(parsed).configurationOperations, + ).toEqual([]); + } +}); + +it("retains a conditional built-in reached through a nested receiver", () => { + const source = + 'import * as builtin from "node:fs"; const alternate = require("fs"); const selected = globalThis.flag ? builtin : alternate; selected.promises.readFile("config.json");'; + const parsed = parseJavaScriptSource(source, "entry.MJS"); + if (parsed === null) throw new Error("fixture did not parse"); + expect( + analyzeParsedJavaScriptSemantics(parsed).configurationOperations, + ).toContainEqual( + expect.objectContaining({ + kind: "file", + key: "config.json", + builtinAuthority: "candidate", + }), + ); +}); + +it("does not retain a nested built-in after replacing either the object or its method", () => { + for (const mutation of [ + "alternate.promises = globalThis.replacement;", + "alternate.promises.readFile = globalThis.replacement;", + "selected.promises.readFile = globalThis.replacement;", + ]) { + const source = `import * as builtin from "node:fs"; const alternate = require("fs"); const selected = globalThis.flag ? builtin : alternate; ${mutation} selected.promises.readFile("config.json");`; + const parsed = parseJavaScriptSource(source, "entry.MJS"); + if (parsed === null) throw new Error("fixture did not parse"); + expect( + analyzeParsedJavaScriptSemantics(parsed).configurationOperations, + ).toEqual([]); + } +}); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 690329962d..11731e05b1 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -33,7 +33,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { - builtinDataEffectMethod, + builtinDataEffectIdentity, dataEffectLiteralString, dataEffectMemberCallee, assignedSemanticResultBindings, @@ -52,6 +52,7 @@ interface ChildProcessAnalysis { type ChildSpawnContext = JavaScriptExpressionShapeResolver; interface SpawnCandidate { + readonly builtinAuthority?: "candidate"; readonly node: t.CallExpression | t.OptionalCallExpression; readonly method: JavaScriptSemanticChildProcessSpawn["method"]; readonly ownerCallableId: string | null; @@ -106,8 +107,9 @@ const collectSpawns = ( enter: (node, parent) => { callableStack.enter(node, parent); if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { - const method = childProcessMethod(node.callee, state); - if (method !== null) { + const identity = childProcessMethod(node.callee, state); + if (identity !== null) { + const { method, builtinAuthority } = identity; const projectedResultBindings = assignedSemanticResultBindings( node, ancestors, @@ -116,6 +118,7 @@ const collectSpawns = ( output.push({ node, method, + ...(builtinAuthority === undefined ? {} : { builtinAuthority }), ownerCallableId: callableStack.callables.at(-1) ?? null, resultBindingId: scalarAssignedResultBinding( projectedResultBindings, @@ -145,6 +148,9 @@ const immutableSpawn = ( const { options, argvCount } = spawnArguments(candidate, context); const command = dataEffectLiteralString(candidate.node.arguments[0]); return { + ...(candidate.builtinAuthority === undefined + ? {} + : { builtinAuthority: candidate.builtinAuthority }), processId: spawnId(candidate), method: candidate.method, location: range(candidate.node), @@ -225,6 +231,9 @@ const childInteraction = ( const { kind, interactionMethod, eventName } = classification; const listener = node.arguments[1]; return { + ...(linked.some((candidate) => candidate.builtinAuthority === "candidate") + ? { builtinAuthority: "candidate" as const } + : {}), interactionId: `child:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, kind, method: interactionMethod, @@ -239,7 +248,11 @@ const childInteraction = ( kind === "signal" ? killSignalName(node.arguments, state) : null, listenerLocation: kind === "listener" && t.isNode(listener) ? range(listener) : null, - resolution: complete ? "complete" : "partial", + resolution: + complete && + linked.every((candidate) => candidate.builtinAuthority !== "candidate") + ? "complete" + : "partial", }; }; @@ -288,14 +301,28 @@ const classifyInteraction = ( const childProcessMethod = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, -): JavaScriptSemanticChildProcessSpawn["method"] | null => { - const method = builtinDataEffectMethod(callee, state, [ +): { + readonly method: JavaScriptSemanticChildProcessSpawn["method"]; + readonly builtinAuthority?: "candidate"; +} | null => { + const identity = builtinDataEffectIdentity(callee, state, [ "child_process", "node:child_process", ]); - return ( - CHILD_PROCESS_METHODS.find((candidate) => candidate === method) ?? null - ); + const method = + identity?.path.length === 1 + ? CHILD_PROCESS_METHODS.find( + (candidate) => candidate === identity.path[0], + ) + : undefined; + return method === undefined + ? null + : { + method, + ...(identity?.builtinAuthority === undefined + ? {} + : { builtinAuthority: identity.builtinAuthority }), + }; }; const resolveChildCandidates = ( diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index 2b4965eb13..77a932774c 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -4,6 +4,7 @@ import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js" import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; import { semanticRequireOrigin, + semanticRequireOriginCandidate, semanticRequireExportsIdentity, semanticGlobalProcessOrigin, semanticMutableBuiltinOrigin, @@ -22,10 +23,14 @@ import { } from "./javascriptSemanticPropertyPaths.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; -import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticBindingAtRead, + semanticTopLevelEffectFollowsCapture, +} from "./javascriptSemanticMutationInitializers.js"; import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { resolveSemanticBindingState, + semanticStableInitializer, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, type JavaScriptSemanticMemberEffect, @@ -171,17 +176,70 @@ export const builtinDataEffectCallablePath = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, specifiers: readonly string[], +): readonly string[] | null => { + return builtinDataEffectCallablePathWithAuthority( + callee, + state, + specifiers, + false, + ); +}; + +/** Recover a proven built-in or a conditional built-in identity. */ +export const builtinDataEffectIdentity = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + specifiers: readonly string[], +): { + readonly path: readonly string[]; + readonly builtinAuthority?: "candidate"; +} | null => { + const proven = builtinDataEffectCallablePathWithAuthority( + callee, + state, + specifiers, + false, + ); + if (proven !== null) return { path: proven }; + const candidate = builtinDataEffectCallablePathWithAuthority( + callee, + state, + specifiers, + true, + ); + return candidate === null + ? null + : { path: candidate, builtinAuthority: "candidate" }; +}; + +const builtinDataEffectCallablePathWithAuthority = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + specifiers: readonly string[], + allowCandidate: boolean, ): readonly string[] | null => { const expression = unwrapJavaScriptExpression(callee).node; if (expression !== callee) - return builtinDataEffectCallablePath(expression, state, specifiers); + return builtinDataEffectCallablePathWithAuthority( + expression, + state, + specifiers, + allowCandidate, + ); if (t.isSequenceExpression(expression)) { const final = expression.expressions.at(-1); return final === undefined ? null - : builtinDataEffectCallablePath(final, state, specifiers); + : builtinDataEffectCallablePathWithAuthority( + final, + state, + specifiers, + allowCandidate, + ); } - const required = semanticRequireOrigin(callee, state); + const required = ( + allowCandidate ? semanticRequireOriginCandidate : semanticRequireOrigin + )(callee, state); if (required !== undefined) { const origin = builtinNamespaceOrigin(required.origin); return specifiers.some( @@ -193,6 +251,9 @@ export const builtinDataEffectCallablePath = ( callee, state, true, + undefined, + allowCandidate, + allowCandidate && required.origin.loaderAuthority !== "candidate", ) ? origin.importedPath : null; @@ -204,6 +265,7 @@ export const builtinDataEffectCallablePath = ( state, specifiers, callee, + allowCandidate, [], true, ); @@ -216,13 +278,14 @@ export const builtinDataEffectCallablePath = ( const receiver = semanticBoundMemberPath(callee.object, state); if (method !== null && receiver !== undefined) { const provenance = evaluateSemanticProvenance(receiver.binding, state); - if (provenance.status === "module") { + const sourceOrigin = builtinProvenanceOrigin(provenance, allowCandidate, [ + ...receiver.path, + method, + ]); + if (sourceOrigin !== undefined) { const origin = builtinNamespaceOrigin({ - ...provenance.origins[0], - importedPath: [ - ...provenance.origins[0].importedPath, - ...receiver.path, - ], + ...sourceOrigin, + importedPath: [...sourceOrigin.importedPath, ...receiver.path], }); if ( specifiers.some( @@ -235,6 +298,7 @@ export const builtinDataEffectCallablePath = ( state, specifiers, callee, + allowCandidate, [...receiver.path, method], true, ) !== null @@ -242,7 +306,12 @@ export const builtinDataEffectCallablePath = ( : null; } } - const source = semanticExpressionModuleOrigin(callee.object, state); + const source = semanticExpressionModuleOrigin( + callee.object, + state, + new Set(), + allowCandidate, + ); if (method === null || source === undefined) return null; const origin = builtinNamespaceOrigin(source); return specifiers.some( @@ -255,6 +324,8 @@ export const builtinDataEffectCallablePath = ( state, true, callee.object, + allowCandidate, + allowCandidate && source.loaderAuthority !== "candidate", ) ? [...origin.importedPath, method] : null; @@ -271,6 +342,7 @@ export const builtinDataEffectCallablePath = ( state, specifiers, callee, + allowCandidate, [method], true, ); @@ -285,8 +357,11 @@ export const builtinDataEffectCallablePath = ( return null; } const provenance = evaluateSemanticProvenance(binding, state); - if (provenance.status !== "module") return null; - const origin = builtinNamespaceOrigin(provenance.origins[0]); + const sourceOrigin = builtinProvenanceOrigin(provenance, allowCandidate, [ + method, + ]); + if (sourceOrigin === undefined) return null; + const origin = builtinNamespaceOrigin(sourceOrigin); const selected = [...origin.importedPath, method]; return specifiers.some( (specifier) => specifier.replace(/^node:/u, "") === origin.specifier, @@ -296,16 +371,19 @@ export const builtinDataEffectCallablePath = ( state, specifiers, initializer.node, + allowCandidate, [method], true, )?.length === 0 && !requiredModuleChanged( - provenance.origins[0].specifier, + sourceOrigin.specifier, selected, callee, state, true, initializer.node, + allowCandidate, + allowCandidate && sourceOrigin.loaderAuthority !== "candidate", ) ? [method] : null; @@ -337,6 +415,7 @@ const builtinBindingPath = ( state: JavaScriptSemanticAnalysisState, specifiers: readonly string[], capture: t.Node, + allowCandidate: boolean, selectedPath: readonly string[] = [], callableValue = false, visited: ReadonlySet = new Set(), @@ -366,6 +445,7 @@ const builtinBindingPath = ( state, specifiers, capture, + allowCandidate, selectedPath, callableValue, visited, @@ -382,6 +462,7 @@ const computeBuiltinBindingPath = ( state: JavaScriptSemanticAnalysisState, specifiers: readonly string[], capture: t.Node, + allowCandidate: boolean, selectedPath: readonly string[] = [], callableValue = false, visited: ReadonlySet, @@ -390,6 +471,19 @@ const computeBuiltinBindingPath = ( savedObjectCapture?: t.Node, ): readonly string[] | null => { if (binding === undefined || visited.has(binding.bindingId)) return null; + // A source-owned initializer is not available before its ordered execution. + // Hoisted imports and unresolved activation order retain their existing facts. + if ( + binding.directOrigins.length === 0 && + semanticStableInitializer(binding, state) !== undefined + ) { + const read = semanticBindingAtRead( + binding, + provenanceCapture ?? capture, + state, + ); + if (read.status === "ordered" && !read.hasDefiniteInitializer) return null; + } // Provenance identifies the module, independently of whether a selected slot // still refers to its original built-in after member mutation or escape. if ( @@ -413,7 +507,7 @@ const computeBuiltinBindingPath = ( )) ), ) || - builtinEscapes(binding, state).some( + builtinEscapes(binding, state, allowCandidate).some( ({ path, node }) => affectsBuiltinPath(path, selectedPath) && // A function-value escape cannot replace its native [[Call]]. An @@ -431,9 +525,14 @@ const computeBuiltinBindingPath = ( provenanceCapture === undefined ? evaluateSemanticProvenance(binding, state) : evaluateSemanticProvenanceAtRead(binding, provenanceCapture, state); - if (provenance.status !== "module") return null; - const { importedPath } = provenance.origins[0]; - const namespace = builtinNamespaceOrigin(provenance.origins[0]); + const sourceOrigin = builtinProvenanceOrigin( + provenance, + allowCandidate, + selectedPath, + ); + if (sourceOrigin === undefined) return null; + const { importedPath } = sourceOrigin; + const namespace = builtinNamespaceOrigin(sourceOrigin); if ( !specifiers.some((item) => { const name = item.replace(/^node:/u, ""); @@ -457,6 +556,10 @@ const computeBuiltinBindingPath = ( capture, state, callableValue, + undefined, + allowCandidate, + allowCandidate && origin.loaderAuthority !== "candidate", + processSnapshotObjectRoot(origin, selectedPath), ) ) return null; @@ -491,6 +594,7 @@ const computeBuiltinBindingPath = ( state, specifiers, current.capture, + allowCandidate, current.path, callableValue, ancestors, @@ -535,7 +639,11 @@ const computeBuiltinBindingPath = ( { ...current, node: node.right }, ); } else { - const required = semanticRequireOrigin(node, state); + const required = ( + allowCandidate + ? semanticRequireOriginCandidate + : semanticRequireOrigin + )(node, state); if ( required !== undefined && requiredModuleChanged( @@ -545,6 +653,8 @@ const computeBuiltinBindingPath = ( state, callableValue, current.savedObjectCapture, + allowCandidate, + allowCandidate && required.origin.loaderAuthority !== "candidate", ) ) return null; @@ -557,7 +667,7 @@ const computeBuiltinBindingPath = ( semanticBuiltinNamespacePath(importedPath) ? [] : builtinNamespaceOrigin({ - ...provenance.origins[0], + ...sourceOrigin, importedPath: resolvedPath, }).importedPath; }; @@ -580,6 +690,37 @@ const builtinNamespaceOrigin = ( : { ...origin, specifier, importedPath }; }; +const builtinProvenanceOrigin = ( + provenance: ReturnType, + allowCandidate: boolean, + selectedPath: readonly string[], +): JavaScriptModuleOrigin | undefined => { + if (provenance.status === "module") return provenance.origins[0]; + if ( + !allowCandidate || + provenance.status !== "ambiguous" || + !provenance.origins.some((origin) => origin.loaderAuthority === "candidate") + ) + return undefined; + const first = provenance.origins[0]; + if (first === undefined) return undefined; + if (provenance.origins.length === 1) return first; + if (selectedPath[0] === "default") return undefined; + const normalized = builtinNamespaceOrigin(first); + return provenance.origins.every((origin) => { + const other = builtinNamespaceOrigin(origin); + return ( + other.specifier === normalized.specifier && + other.importedPath.length === normalized.importedPath.length && + other.importedPath.every( + (part, index) => part === normalized.importedPath[index], + ) + ); + }) + ? first + : undefined; +}; + const affectsBuiltinPath = ( path: JavaScriptSemanticPropertyPath, selected: readonly string[], @@ -639,6 +780,7 @@ interface BuiltinEffectIndex { readonly JavaScriptSemanticMemberEffect[] >; readonly receivers: WeakMap; + readonly candidateReceivers: WeakMap; } // Derived passes run after mutation collection. These indexes share that state's @@ -663,6 +805,7 @@ const builtinEffectIndex = ( moduleEffects: new Map(), bindingEscapes: new Map(), receivers: new WeakMap(), + candidateReceivers: new WeakMap(), }; builtinEffectIndexes.set(state, index); } @@ -672,14 +815,16 @@ const builtinEffectIndex = ( const builtinEscapes = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, + allowCandidate = false, ): readonly JavaScriptSemanticMemberEffect[] => { const cache = builtinEffectIndex(state).bindingEscapes; - let effects = cache.get(binding.bindingId); + const key = `${binding.bindingId}:${allowCandidate ? "candidate" : "proven"}`; + let effects = cache.get(key); if (effects === undefined) { effects = binding.escapedPaths.filter( - ({ node }) => !originalBuiltinReceiver(node, state), + ({ node }) => !originalBuiltinReceiver(node, state, allowCandidate), ); - cache.set(binding.bindingId, effects); + cache.set(key, effects); } return effects; }; @@ -687,13 +832,16 @@ const builtinEscapes = ( const builtinModuleEffects = ( specifier: string, state: JavaScriptSemanticAnalysisState, + allowCandidate = false, ): readonly JavaScriptSemanticRequiredModuleEffect[] => { const cache = builtinEffectIndex(state).moduleEffects; - let effects = cache.get(specifier); + const key = `${specifier}:${allowCandidate ? "candidate" : "proven"}`; + let effects = cache.get(key); if (effects === undefined) { effects = (state.requiredModuleEffects.get(specifier) ?? []).filter( ({ kind, node }) => - kind === "write" || !originalBuiltinReceiver(node, state), + kind === "write" || + !originalBuiltinReceiver(node, state, allowCandidate), ); if (specifier === "fs/promises") { const sharedFsEscapes = (state.requiredModuleEffects.get("fs") ?? []) @@ -701,7 +849,7 @@ const builtinModuleEffects = ( ({ kind, path, node }) => kind === "escape" && (path.length === 0 || path[0] === "promises") && - !originalBuiltinReceiver(node, state), + !originalBuiltinReceiver(node, state, allowCandidate), ) .map((effect) => ({ ...effect, @@ -710,11 +858,29 @@ const builtinModuleEffects = ( })); effects = [...effects, ...sharedFsEscapes]; } - cache.set(specifier, effects); + cache.set(key, effects); } return effects; }; +/** A named ESM process env/argv value snapshots the object, not later slot replacement. */ +const processSnapshotObjectRoot = ( + origin: JavaScriptModuleOrigin, + selected: readonly string[], +): "env" | "argv" | undefined => { + if ( + origin.resolutionMode === "require" || + origin.specifier.replace(/^node:/u, "") !== "process" + ) + return undefined; + const [root, ...rest] = [...origin.importedPath, ...selected]; + if ((root !== "env" && root !== "argv") || rest.length === 0) + return undefined; + // The default export is the mutable process object, not a snapshot slot. + if (origin.importedPath[0] === "default") return undefined; + return root; +}; + /** Check shared exports at the use or method-capture point, not at every later call. */ const requiredModuleChanged = ( specifier: string, @@ -723,10 +889,20 @@ const requiredModuleChanged = ( state: JavaScriptSemanticAnalysisState, callableValue = false, savedObjectCapture?: t.Node, + allowCandidate = false, + ignoreCandidateEffects = false, + snapshotObjectRoot?: "env" | "argv", ): boolean => { const identity = semanticRequireExportsIdentity(specifier, selected); - return builtinModuleEffects(identity.specifier, state).some( - ({ path, node, kind }) => + return builtinModuleEffects(identity.specifier, state, allowCandidate).some( + ({ path, node, kind, loaderAuthority }) => + !(ignoreCandidateEffects && loaderAuthority === "candidate") && + !( + snapshotObjectRoot !== undefined && + kind === "write" && + path.length === 1 && + path[0] === snapshotObjectRoot + ) && !( savedObjectCapture !== undefined && path.length < identity.path.length && @@ -752,12 +928,14 @@ const requiredModuleChanged = ( const originalBuiltinReceiver = ( node: t.Node | undefined, state: JavaScriptSemanticAnalysisState, + allowCandidate = false, ): boolean => { if (node === undefined) return false; - const receivers = builtinEffectIndex(state).receivers; + const index = builtinEffectIndex(state); + const receivers = allowCandidate ? index.candidateReceivers : index.receivers; const previous = receivers.get(node); if (previous !== undefined) return previous; - const result = computeOriginalBuiltinReceiver(node, state); + const result = computeOriginalBuiltinReceiver(node, state, allowCandidate); receivers.set(node, result); return result; }; @@ -765,25 +943,49 @@ const originalBuiltinReceiver = ( const computeOriginalBuiltinReceiver = ( node: t.Node | undefined, state: JavaScriptSemanticAnalysisState, + allowCandidate: boolean, ): boolean => { if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return false; const callee = unwrapJavaScriptExpression(node.callee).node; if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return false; - const receiver = t.isIdentifier(callee.object) - ? resolveSemanticBindingState(state, callee.object, callee.object.name) - : undefined; + const boundReceiver = semanticBoundMemberPath(callee.object, state); + const receiver = boundReceiver?.binding; + const receiverPath = boundReceiver?.path ?? []; const provenance = receiver === undefined ? undefined : evaluateSemanticProvenance(receiver, state); + const receiverMethod = semanticStaticPropertyKey( + callee.property, + callee.computed, + ); + const boundOrigin = + provenance === undefined || receiverMethod === null + ? undefined + : builtinProvenanceOrigin(provenance, allowCandidate, [ + ...receiverPath, + receiverMethod, + ]); const sourceOrigin = - provenance?.status === "module" - ? provenance.origins[0] - : (semanticExpressionModuleOrigin(callee.object, state) ?? - semanticRequireOrigin(callee.object, state)?.origin ?? - semanticGlobalProcessOrigin(callee.object, state)); + (boundOrigin === undefined + ? undefined + : { + ...boundOrigin, + importedPath: [...boundOrigin.importedPath, ...receiverPath], + }) ?? + semanticExpressionModuleOrigin( + callee.object, + state, + new Set(), + allowCandidate, + ) ?? + (allowCandidate + ? semanticRequireOriginCandidate(callee.object, state) + : semanticRequireOrigin(callee.object, state) + )?.origin ?? + semanticGlobalProcessOrigin(callee.object, state); if (sourceOrigin === undefined) return false; const origin = builtinNamespaceOrigin(sourceOrigin); const posixReceiver = @@ -792,10 +994,15 @@ const computeOriginalBuiltinReceiver = ( origin.importedPath[0] === "posix"; if (origin.importedPath.length !== 0 && !posixReceiver) return false; const method = semanticStaticPropertyKey(callee.property, callee.computed); - const identity = semanticRequireExportsIdentity(origin.specifier, [ - ...origin.importedPath, - method ?? "", - ]); + // ESM namespace/named exports are snapshots, whereas the default object and + // CommonJS exports share mutable slots with require(). + const sources = + provenance === undefined || boundOrigin === undefined + ? [sourceOrigin] + : provenance.origins.map((source) => ({ + ...source, + importedPath: [...source.importedPath, ...receiverPath], + })); if ( method === null || !BUILTIN_RECEIVER_METHODS[ @@ -803,16 +1010,38 @@ const computeOriginalBuiltinReceiver = ( ]?.has(method) || receiver?.memberWrites.some( ({ path, node: write }) => - affectsBuiltinPath(path, [method]) && + affectsBuiltinPath(path, [...receiverPath, method]) && !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), ) || - ( - builtinEffectIndex(state).writesByModule.get(identity.specifier) ?? [] - ).some( - ({ path, node: write }) => - affectsBuiltinPath(path, identity.path) && - !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), - ) + sources.some((source) => { + const shared = + method === null + ? undefined + : semanticMutableBuiltinOrigin(source, [method]); + const identity = + shared === undefined + ? undefined + : semanticRequireExportsIdentity(shared.specifier, shared.path); + return ( + identity !== undefined && + ( + builtinEffectIndex(state).writesByModule.get(identity.specifier) ?? [] + ).some( + ({ path, node: write, loaderAuthority }) => + !( + allowCandidate && + source.loaderAuthority !== "candidate" && + loaderAuthority === "candidate" + ) && + affectsBuiltinPath(path, identity.path) && + !semanticTopLevelEffectFollowsCapture( + node, + write, + state.parentsByNode, + ), + ) + ); + }) ) return false; return !node.arguments.some((argument) => { @@ -821,7 +1050,11 @@ const computeOriginalBuiltinReceiver = ( if (passed === undefined) return false; const passedOrigin = evaluateSemanticProvenance(passed, state); return ( - passedOrigin.status === "module" && + (passedOrigin.status === "module" || + (allowCandidate && + passedOrigin.status === "ambiguous" && + passedOrigin.origins.length === 1 && + passedOrigin.origins[0]?.loaderAuthority === "candidate")) && builtinNamespaceOrigin(passedOrigin.origins[0]).specifier === origin.specifier && builtinNamespaceOrigin(passedOrigin.origins[0]).importedPath.length === 0 @@ -833,19 +1066,33 @@ const semanticExpressionModuleOrigin = ( expression: t.Node, state: JavaScriptSemanticAnalysisState, visited = new Set(), + allowCandidate = false, ): JavaScriptModuleOrigin | undefined => { const node = unwrapJavaScriptExpression(expression).node; - const required = semanticRequireOrigin(node, state); + const required = ( + allowCandidate ? semanticRequireOriginCandidate : semanticRequireOrigin + )(node, state); if (required !== undefined) return required.origin; if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); if (binding === undefined || visited.has(binding.bindingId)) return undefined; const provenance = evaluateSemanticProvenance(binding, state); - return provenance.status === "module" ? provenance.origins[0] : undefined; + return provenance.status === "module" || + (allowCandidate && + provenance.status === "ambiguous" && + provenance.origins.length === 1 && + provenance.origins[0]?.loaderAuthority === "candidate") + ? provenance.origins[0] + : undefined; } if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { - const base = semanticExpressionModuleOrigin(node.object, state, visited); + const base = semanticExpressionModuleOrigin( + node.object, + state, + visited, + allowCandidate, + ); const key = semanticStaticPropertyKey(node.property, node.computed); return base === undefined || key === null ? undefined @@ -875,11 +1122,49 @@ export const isSemanticProcessObject = ( state, ["process", "node:process"], node, + false, selected, )?.length === 0 ); }; +/** Preserve conditional process identity across loader and mutation uncertainty. */ +export const semanticProcessObjectIdentity = ( + node: t.Expression | t.Super, + state: JavaScriptSemanticAnalysisState, + family: "env" | "argv", + key?: string | null, +): { readonly builtinAuthority?: "candidate" } | null => { + if (isSemanticProcessObject(node, state, family, key)) return {}; + if (!t.isIdentifier(node)) return null; + const binding = resolveSemanticBindingState(state, node, node.name); + const selected = key === undefined || key === null ? [family] : [family, key]; + if (binding === undefined) + return semanticGlobalProcessOrigin(node, state) !== undefined && + !requiredModuleChanged( + "process", + selected, + node, + state, + false, + undefined, + false, + true, + ) + ? { builtinAuthority: "candidate" } + : null; + return builtinBindingPath( + binding, + state, + ["process", "node:process"], + node, + true, + selected, + )?.length === 0 + ? { builtinAuthority: "candidate" } + : null; +}; + /** Resolve the nearest direct variable/assignment owner before a callable. */ export const outerDataEffectBinding = ( node: t.Node, diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index 384cf05a2e..0267344c86 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -27,11 +27,11 @@ import { semanticEffectTiming, } from "./javascriptSemanticMutationInitializers.js"; import { - builtinDataEffectMethod as builtinMethod, + builtinDataEffectIdentity, dataEffectArgumentBindingId as argumentBindingId, dataEffectLiteralString as literalString, dataEffectMemberCallee as memberCallee, - isSemanticProcessObject as isProcessObject, + semanticProcessObjectIdentity, outerDataEffectBinding as outerAssignedBinding, assignedSemanticResultBindings, semanticProjectedBindingFacts, @@ -103,6 +103,9 @@ const collectConfigurations = ( key: details.key, sourceConfigId: null, value: null, + ...(details.builtinAuthority === undefined + ? {} + : { builtinAuthority: details.builtinAuthority }), resolution: details.resolution, }; output.push({ node, operation }); @@ -116,6 +119,7 @@ const configurationDetails = ( ): { readonly kind: "environment" | "argv" | "file"; readonly key: string | null; + readonly builtinAuthority?: "candidate"; readonly resolution: "complete" | "partial"; } | null => { if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { @@ -124,19 +128,32 @@ const configurationDetails = ( } if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return null; - const fsMethod = builtinMethod(node.callee, context.state, ["fs", "node:fs"]); + const fs = builtinDataEffectIdentity(node.callee, context.state, [ + "fs", + "node:fs", + ]); + const fsMethod = fs?.path.length === 1 ? fs.path[0] : null; + const promises = + fsMethod === null + ? builtinDataEffectIdentity(node.callee, context.state, [ + "fs/promises", + "node:fs/promises", + ]) + : null; const isFileRead = fsMethod === "readFile" || fsMethod === "readFileSync" || (fsMethod === null && - builtinMethod(node.callee, context.state, [ - "fs/promises", - "node:fs/promises", - ]) === "readFile"); + promises?.path.length === 1 && + promises.path[0] === "readFile"); return isFileRead ? { kind: "file", key: literalString(node.arguments[0]), + ...((fsMethod === null ? promises : fs)?.builtinAuthority === + "candidate" + ? { builtinAuthority: "candidate" as const } + : {}), resolution: literalString(node.arguments[0]) === null ? "partial" : "complete", } @@ -149,6 +166,7 @@ const processConfigurationMember = ( ): { readonly kind: "environment" | "argv"; readonly key: string | null; + readonly builtinAuthority?: "candidate"; readonly resolution: "complete" | "partial"; } | null => { const object = node.object; @@ -159,10 +177,19 @@ const processConfigurationMember = ( // `process.env[""]` names an exact (empty) key; only a dynamic key is unknown. const key = semanticStaticPropertyKey(node.property, node.computed); if (family === "argv" && key !== null && !isArrayIndexKey(key)) return null; - if (!isProcessObject(object.object, state, family, key)) return null; + const processIdentity = semanticProcessObjectIdentity( + object.object, + state, + family, + key, + ); + if (processIdentity === null) return null; return { kind: family === "env" ? "environment" : "argv", key, + ...(processIdentity.builtinAuthority === undefined + ? {} + : { builtinAuthority: processIdentity.builtinAuthority }), resolution: key === null ? "partial" : "complete", }; }; @@ -203,6 +230,9 @@ const collectDefaults = ( ownerCallableId: context.callableStack.at(-1) ?? null, resultBindingId: outerAssignedBinding(node, context), key: candidate.operation.key, + ...(candidate.operation.builtinAuthority === undefined + ? {} + : { builtinAuthority: candidate.operation.builtinAuthority }), sourceConfigId: candidate.operation.configId, value: value.status === "literal" ? value.value : null, ...(value.status === "unknown" && value.resourceLimit !== undefined @@ -226,8 +256,9 @@ const collectRequests = ( !t.isNewExpression(node) ) return; - const method = requestMethod(node, context.state); - if (method === null) return; + const identity = requestMethod(node, context.state); + if (identity === null) return; + const method = identity.method; const endpoint = requestEndpoint(node); // `http.get`/`http.request` parse a string target with `new URL` before the // request exists, so a literal that is not an absolute URL throws @@ -246,6 +277,9 @@ const collectRequests = ( requestId: `request:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, kind: "request", method, + ...(identity.builtinAuthority === undefined + ? {} + : { builtinAuthority: identity.builtinAuthority }), location: range(node), ownerCallableId: context.callableStack.at(-1) ?? null, resultBindingId: outerAssignedBinding(node, context), @@ -478,7 +512,10 @@ const boundaryDetails = ( const requestMethod = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, state: JavaScriptSemanticAnalysisState, -): JavaScriptSemanticRequestOperation["method"] | null => { +): { + readonly method: JavaScriptSemanticRequestOperation["method"]; + readonly builtinAuthority?: "candidate"; +} | null => { if (t.isIdentifier(node.callee)) { const name = node.callee.name; const unbound = @@ -490,15 +527,23 @@ const requestMethod = ( name === global && isUnmodifiedGlobalReference(node.callee, state, name) ) - return global; + return { method: global }; } - const method = builtinMethod(node.callee, state, [ + const identity = builtinDataEffectIdentity(node.callee, state, [ "http", "https", "node:http", "node:https", ]); - return method === "request" || method === "get" ? method : null; + const method = identity?.path.length === 1 ? identity.path[0] : null; + return method === "request" || method === "get" + ? { + method, + ...(identity?.builtinAuthority === undefined + ? {} + : { builtinAuthority: identity.builtinAuthority }), + } + : null; }; const requestFields = ( diff --git a/src/domain/javascript/javascriptSemanticFingerprints.ts b/src/domain/javascript/javascriptSemanticFingerprints.ts index d46305362f..f836094e00 100644 --- a/src/domain/javascript/javascriptSemanticFingerprints.ts +++ b/src/domain/javascript/javascriptSemanticFingerprints.ts @@ -83,10 +83,25 @@ export function* collectJavaScriptSemanticFingerprintsSteps( const networkEffects = networkEffectsByOwner.get(callable.callableId) ?? []; const resourceEffects = resourceEffectsByOwner.get(callable.callableId) ?? []; + const conditionalBuiltinEffects = + timerEffects.some( + ({ builtinAuthority }) => builtinAuthority === "candidate", + ) || + childProcessEffects.some( + ({ builtinAuthority }) => builtinAuthority === "candidate", + ) || + networkEffects.some( + ({ builtinAuthority }) => builtinAuthority === "candidate", + ) || + resourceEffects.some( + ({ builtinAuthority }) => builtinAuthority === "candidate", + ); fingerprints.push({ callableId: callable.callableId, status: - parserPartial || callable.returnCoverage.status !== "complete" + parserPartial || + conditionalBuiltinEffects || + callable.returnCoverage.status !== "complete" ? "partial" : "complete", components: { @@ -121,6 +136,11 @@ export function* collectJavaScriptSemanticFingerprintsSteps( ...(parserPartial ? ["Incomplete semantic recovery makes this fingerprint partial."] : []), + ...(conditionalBuiltinEffects + ? [ + "A built-in effect has conditional loader or mutation identity, so its fingerprint contribution is partial.", + ] + : []), ], }); } diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index a4374f8bed..2fa8e85d7c 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -11,6 +11,8 @@ import type { JavaScriptModuleResolutionMode } from "./javascriptModuleResolutio /** One exact module origin followed through imports, requires, or aliases. */ export interface JavaScriptModuleOrigin { + /** An unbound require whose CommonJS wrapper is not established. */ + readonly loaderAuthority?: "candidate"; readonly resolutionMode?: JavaScriptModuleResolutionMode; readonly specifier: string; readonly importedPath: readonly string[]; @@ -264,6 +266,7 @@ export interface JavaScriptSemanticEventOperation { /** One timer scheduling or cancellation candidate. */ export interface JavaScriptSemanticTimerOperation { + readonly builtinAuthority?: "candidate"; readonly timerId: string; readonly kind: "schedule" | "cancel"; readonly method: @@ -284,6 +287,7 @@ export interface JavaScriptSemanticTimerOperation { /** One explicit asynchronous node:child_process creation candidate. */ export interface JavaScriptSemanticChildProcessSpawn { + readonly builtinAuthority?: "candidate"; readonly processId: string; readonly method: "spawn" | "exec" | "execFile" | "fork"; readonly location: JavaScriptSourceRange; @@ -299,6 +303,7 @@ export interface JavaScriptSemanticChildProcessSpawn { /** One exit/error listener or signal operation linked to child candidates. */ export interface JavaScriptSemanticChildProcessInteraction { + readonly builtinAuthority?: "candidate"; readonly interactionId: string; readonly kind: "listener" | "signal"; readonly method: "on" | "once" | "addListener" | "kill"; @@ -314,6 +319,7 @@ export interface JavaScriptSemanticChildProcessInteraction { /** One environment, argv, file, or default configuration source. */ export interface JavaScriptSemanticConfigurationOperation { + readonly builtinAuthority?: "candidate"; readonly configId: string; readonly kind: "environment" | "argv" | "file" | "default"; readonly location: JavaScriptSourceRange; @@ -328,6 +334,7 @@ export interface JavaScriptSemanticConfigurationOperation { /** One request construction or response-consumer candidate. */ export interface JavaScriptSemanticRequestOperation { + readonly builtinAuthority?: "candidate"; readonly requestId: string; readonly kind: "request" | "response-consumer"; readonly method: @@ -372,6 +379,7 @@ export interface JavaScriptSemanticBoundaryOperation { /** One explicit built-in resource acquisition or release candidate. */ export interface JavaScriptSemanticResourceOperation { + readonly builtinAuthority?: "candidate"; readonly resourceId: string; readonly kind: "acquire" | "release"; readonly method: @@ -418,6 +426,8 @@ export interface JavaScriptSemanticFrontier { /** Static import/export relationship retained for cross-file composition. */ export interface JavaScriptSemanticModuleLink { + /** The literal request is observed, but its CommonJS wrapper is unknown. */ + readonly loaderAuthority?: "candidate"; readonly resolutionMode?: JavaScriptModuleResolutionMode; readonly kind: | "import" diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index a59c798602..e028b4e54f 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -16,7 +16,7 @@ import { semanticOriginalGlobalProcessAt, semanticMutableBuiltinOrigin, semanticRequireExportsIdentity, - semanticRequireOrigin, + semanticRequireOriginCandidate, semanticRequireLoaderAt, hasSemanticCreateRequireFactoryOrigin, } from "./javascriptSemanticRequireOrigin.js"; @@ -483,6 +483,7 @@ export function* collectSemanticMemberMutationsSteps( path: PropertyPath, effect: ValueEffect, node: t.Node | undefined, + loaderAuthority?: "candidate", ): void => { const identity = semanticRequireExportsIdentity(specifier, path); let effectPath = identity.path; @@ -508,6 +509,7 @@ export function* collectSemanticMemberMutationsSteps( "required", identity.specifier, effect, + loaderAuthority, effectPath, node?.start, node?.end, @@ -516,7 +518,12 @@ export function* collectSemanticMemberMutationsSteps( recordedRequiredEffects.add(key); recordedEffectCount += 1; const effects = state.requiredModuleEffects.get(identity.specifier) ?? []; - effects.push({ path: effectPath, node, kind: effect }); + effects.push({ + path: effectPath, + node, + kind: effect, + ...(loaderAuthority === undefined ? {} : { loaderAuthority }), + }); state.requiredModuleEffects.set(identity.specifier, effects); }; // Traversal states that differ only in coverage fields a record never @@ -866,13 +873,14 @@ export function* collectSemanticMemberMutationsSteps( ) continue; const expression = unwrapJavaScriptExpression(current.node).node; - const required = semanticRequireOrigin(expression, state); + const required = semanticRequireOriginCandidate(expression, state, true); if (required !== undefined) { recordRequiredEffect( required.origin.specifier, [...required.origin.importedPath, ...current.path], effect, mutation, + required.origin.loaderAuthority, ); continue; } @@ -1156,6 +1164,7 @@ export function* collectSemanticMemberMutationsSteps( shared.path, effect, mutation, + origin.loaderAuthority, ); } const receiverInitializers = [ @@ -2108,7 +2117,7 @@ const observableMutationPaths = ( // exact member paths instead of widening the first path to a wildcard. const hasParsedRequireOrigin = binding.initializers.some( ({ node }) => - semanticRequireOrigin(node, state) !== undefined || + semanticRequireOriginCandidate(node, state) !== undefined || hasSemanticCreateRequireFactoryOrigin(node, state), ); if ( diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index bce356fd46..cdc464c200 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -1983,8 +1983,8 @@ export const commonJsWrapperBindingDeclaration = ( // The index owns explicit wrapper parameters, including ones with no source // redeclaration. Babel's module classification does not invalidate that fact. if (binding.hostOrigin === "commonjs-wrapper") return binding; - // Without an explicit host fact, preserve the legacy script-var inference. - return state.program.sourceType === "script" && + // A program var shares the wrapper binding only when the host is known. + return javaScriptRuntimeContext(state.program) === "commonjs" && binding.declarationKinds.size > 0 ? binding : undefined; diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index ae201131ab..c4c1eba31f 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -12,7 +12,7 @@ import { type JavaScriptSemanticAnalysisState, type JavaScriptSemanticScopeState, } from "./javascriptSemanticState.js"; -import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; +import { semanticRequireOriginCandidate } from "./javascriptSemanticRequireOrigin.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { completeSemanticSteps, @@ -428,7 +428,7 @@ const collectRequireLink = ( node: t.VariableDeclarator, state: JavaScriptSemanticAnalysisState, ): void => { - const required = semanticRequireOrigin(node.init, state); + const required = semanticRequireOriginCandidate(node.init, state); if (required === undefined) return; const { origin, call } = required; const bindings = requirePatternBindings(node.id, origin.importedPath); @@ -437,6 +437,9 @@ const collectRequireLink = ( state, { kind: "require", + ...(origin.loaderAuthority === "candidate" + ? { loaderAuthority: "candidate" as const } + : {}), specifier: origin.specifier, importedName: binding.importedName, localName: binding.localName, @@ -453,12 +456,15 @@ const collectUnboundRequireLink = ( state: JavaScriptSemanticAnalysisState, ): void => { if (state.linkedRequireCalls.has(node)) return; - const required = semanticRequireOrigin(node, state); + const required = semanticRequireOriginCandidate(node, state); if (required === undefined) return; addModuleLink( state, { kind: "require", + ...(required.origin.loaderAuthority === "candidate" + ? { loaderAuthority: "candidate" as const } + : {}), specifier: required.origin.specifier, importedName: null, localName: null, @@ -515,6 +521,7 @@ const collectCommonJsExport = ( exported.exportedName, applicability === "uncertain" ? null : exported.value, exported.projection, + exported.loaderAuthority, ); } }; @@ -525,13 +532,14 @@ const addCommonJsExport = ( exportedName: string, exported: t.Node | null, projection?: JavaScriptSemanticPropertyPath, + loaderAuthority?: "candidate", ): void => { // Simple assignment chains yield their rightmost assigned value. let value = exported; while (t.isAssignmentExpression(value, { operator: "=" })) value = value.right; const required = - value === null ? undefined : semanticRequireOrigin(value, state); + value === null ? undefined : semanticRequireOriginCandidate(value, state); // Copies retain their module provenance, but do not export the source object // itself. A wildcard with exclusions cannot express one module link. const origin = projection?.some( @@ -545,6 +553,10 @@ const addCommonJsExport = ( state, { kind: "commonjs-export", + ...(origin?.loaderAuthority === "candidate" || + loaderAuthority === "candidate" + ? { loaderAuthority: "candidate" as const } + : {}), specifier: origin?.specifier ?? null, importedName: origin === undefined diff --git a/src/domain/javascript/javascriptSemanticProvenance.test.ts b/src/domain/javascript/javascriptSemanticProvenance.test.ts index 82a77a4499..0d3a0bf2f1 100644 --- a/src/domain/javascript/javascriptSemanticProvenance.test.ts +++ b/src/domain/javascript/javascriptSemanticProvenance.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { + analyzeJavaScriptSemantics, + analyzeParsedJavaScriptSemantics, +} from "./javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { onlyBinding, topLevelBinding, @@ -46,7 +50,7 @@ describe("JavaScript semantic binding provenance", () => { }); it("recognizes explicit local values and preserves exact module aliases", () => { - const ir = analyzeJavaScriptSemantics(` + const source = ` const object = { value: 1 }; const array = [1, 2]; const template = \`feature\`; @@ -60,7 +64,15 @@ describe("JavaScript semantic binding provenance", () => { const alias = imported; const sequence = (0, require("sequence-feature")); const assignment = (slot = require("assignment-feature")); - `); + `; + const parsed = parseJavaScriptSource(source, "main.cjs"); + if (parsed === null) throw new Error("fixture did not parse"); + const ir = analyzeParsedJavaScriptSemantics(parsed); + const candidate = analyzeJavaScriptSemantics(source); + expect(topLevelBinding(candidate, "alias").provenance).toMatchObject({ + status: "ambiguous", + origins: [{ specifier: "feature", loaderAuthority: "candidate" }], + }); for (const name of [ "object", diff --git a/src/domain/javascript/javascriptSemanticProvenance.ts b/src/domain/javascript/javascriptSemanticProvenance.ts index 3272b2ab99..acdee6dd5a 100644 --- a/src/domain/javascript/javascriptSemanticProvenance.ts +++ b/src/domain/javascript/javascriptSemanticProvenance.ts @@ -30,9 +30,16 @@ export const semanticOriginsProvenance = ( ): JavaScriptBindingProvenance => { const unique = uniqueSemanticOrigins(origins); const origin = unique[0]; - return unique.length === 1 && origin !== undefined + return unique.length === 1 && + origin !== undefined && + origin.loaderAuthority !== "candidate" ? { status: "module", origins: [origin], reason: null } - : semanticAmbiguousProvenance(unique, "Multiple module origins."); + : semanticAmbiguousProvenance( + unique, + unique.some(({ loaderAuthority }) => loaderAuthority === "candidate") + ? "CommonJS loader authority is unknown." + : "Multiple module origins.", + ); }; /** Deduplicate and canonically order module origins. */ @@ -52,6 +59,7 @@ const semanticOriginKey = (origin: JavaScriptModuleOrigin): string => origin.specifier, origin.importedPath, origin.resolutionMode ?? "import", + origin.loaderAuthority ?? "proven", ]); /** Canonically distinguish primitive values across type boundaries. */ diff --git a/src/domain/javascript/javascriptSemanticProvenanceAssignments.test.ts b/src/domain/javascript/javascriptSemanticProvenanceAssignments.test.ts index 6a26af7bd5..eeff6ee595 100644 --- a/src/domain/javascript/javascriptSemanticProvenanceAssignments.test.ts +++ b/src/domain/javascript/javascriptSemanticProvenanceAssignments.test.ts @@ -1,10 +1,13 @@ import { expect, it } from "vitest"; -import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { analyzeParsedJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; const provenance = (source: string, name: string) => { - const result = analyzeJavaScriptSemantics(source); + const file = parseJavaScriptSource(source, "fixture.cjs"); + if (file === null) throw new Error("Expected valid CommonJS fixture"); + const result = analyzeParsedJavaScriptSemantics(file); const binding = result.bindings.find((candidate) => candidate.name === name); if (binding === undefined) throw new Error(`Missing binding ${name}`); return binding.provenance; diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 59475e18d6..6190a4a46c 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -17,6 +17,7 @@ import { semanticResolutionBlocked, semanticStableInitializer, type JavaScriptSemanticAnalysisState, + type JavaScriptBindingIndex, } from "./javascriptSemanticState.js"; import { commonJsWrapperBindingDeclaration, @@ -173,7 +174,7 @@ const requireLoaderValueAt = ( if (loader.name !== "require") return "local"; if (binding === undefined) { if ( - javaScriptRuntimeContext(state.program) === "module" || + javaScriptRuntimeContext(state.program) !== "commonjs" || semanticResolutionBlocked(state, loader, "require") ) return "unknown"; @@ -199,7 +200,10 @@ const requireLoaderValueAt = ( t.isNewExpression(node)) && isWrapperRequireInitializerCall(binding, node, state) ) - return commonJsLoaderAt(node, state); + return binding.hostOrigin === "commonjs-wrapper" || + javaScriptRuntimeContext(state.program) === "commonjs" + ? commonJsLoaderAt(node, state) + : "unknown"; const wrapperBinding = commonJsWrapperBindingDeclaration( loader, "require", @@ -376,13 +380,14 @@ const selectedRequireSourcesAt = ( ): | Pick< JavaScriptRequireCallFact, - "loader" | "sourceRelative" | "sourceRelativePath" + "loader" | "sourceRelative" | "sourceRelativePath" | "loaderAuthority" > | undefined => { const pending: ModuleObjectSource[] = [{ node: root, path: [] }]; const seen = new WeakMap>(); let count = 0; let found = false; + let candidate = false; let loaderBinding = false; let selectedLoader: JavaScriptRequireLoaderFact | undefined; let unknown = false; @@ -432,6 +437,10 @@ const selectedRequireSourcesAt = ( ? commonJsLoaderAt(site, state) : captured; found = true; + candidate ||= + invoked === "unknown" && + t.isIdentifier(node, { name: "require" }) && + isConditionalRequireAt(node, node, state); unknown ||= invoked === "unknown" || (selectedLoader !== undefined && selectedLoader !== invoked); @@ -655,6 +664,7 @@ const selectedRequireSourcesAt = ( ? (selectedLoader ?? "unknown") : "local", sourceRelative: found && sourceRelative && !unknown, + ...(candidate ? { loaderAuthority: "candidate" as const } : {}), ...(found && !unknown && sourceRelative && @@ -701,23 +711,41 @@ export interface JavaScriptRequireCallFact { | readonly t.CallExpression["arguments"][number][] | undefined; readonly sourceRelative: boolean; + readonly loaderAuthority?: "candidate"; + readonly memberUnproven?: true; /** A createRequire filename relative to the current source directory, when proven. */ readonly sourceRelativePath?: string; } +/** A child-property write does not replace the selected callable itself. */ +export const semanticSelectedMemberMayHaveChanged = ( + index: JavaScriptBindingIndex, + site: t.Node, + member: t.Node, +): boolean => { + const effects = index.effectsForReferenceAt(site, member); + const targetDepth = semanticMemberChain(member)?.members.length; + return ( + !effects.memberEffectsIndexed || + effects.effects.some( + ({ kind, timing, path }) => + timing !== "after" && + (kind === "escape" || + (kind === "member-write" && + (targetDepth === undefined || + path === undefined || + path.length <= targetDepth))), + ) + ); +}; + /** Classify the selected loader and normalize invocation arguments once. */ export const semanticRequireCallAt = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, state: JavaScriptSemanticAnalysisState, factoryBindingIds: ReadonlySet, + index?: JavaScriptBindingIndex, ): JavaScriptRequireCallFact | undefined => { - if (semanticModuleRequireCallAt(node, state)) - return { - operation: "load", - loader: "unknown", - arguments: undefined, - sourceRelative: false, - }; const callee = unwrapJavaScriptExpression(node.callee).node; if ( (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && @@ -733,6 +761,59 @@ export const semanticRequireCallAt = ( arguments: undefined, sourceRelative: false, }; + if (index !== undefined) { + let member = callee; + while ( + t.isMemberExpression(member) || + t.isOptionalMemberExpression(member) + ) { + const key = semanticStaticPropertyKey(member.property, member.computed); + if (key === "call" || key === "apply" || key === "resolve") { + const loader = requireLoaderValueAt( + member.object, + node, + state, + factoryBindingIds, + ); + const selected = + loader === "local" + ? selectedRequireSourcesAt( + member.object, + node, + state, + factoryBindingIds, + ) + : undefined; + if ( + loader === "local" && + (selected === undefined || selected.loader === "local") + ) { + member = unwrapJavaScriptExpression(member.object).node; + continue; + } + if (semanticSelectedMemberMayHaveChanged(index, node, member)) + return { + operation: "load", + loader: "unknown", + // A changed call/apply member has no trustworthy argument mapping. + arguments: + member === callee && key === "resolve" + ? node.arguments + : undefined, + sourceRelative: false, + memberUnproven: true, + }; + } + member = unwrapJavaScriptExpression(member.object).node; + } + } + if (semanticModuleRequireCallAt(node, state)) + return { + operation: "load", + loader: "unknown", + arguments: undefined, + sourceRelative: false, + }; if ( (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && semanticStaticPropertyKey(callee.property, callee.computed) === "resolve" @@ -743,13 +824,19 @@ export const semanticRequireCallAt = ( state, factoryBindingIds, ); - if (loader === "unknown") + if (loader === "unknown") { + const receiver = unwrapJavaScriptExpression(callee.object).node; + const candidate = + t.isIdentifier(receiver, { name: "require" }) && + isConditionalRequireAt(receiver, node, state); return { - operation: "load", + operation: "resolve", loader, - arguments: undefined, + arguments: candidate ? node.arguments : undefined, sourceRelative: false, + ...(candidate ? { loaderAuthority: "candidate" as const } : {}), }; + } // Bundler factory loaders can share the `require` shape, but their // `.resolve` member is not Node's module resolver. if (loader === "commonjs-loader" || loader === "create-require") { @@ -790,6 +877,9 @@ export const semanticRequireCallAt = ( loader: selected?.loader ?? loader, arguments: node.arguments, sourceRelative: selected?.sourceRelative ?? loader !== "create-require", + ...(selected?.loaderAuthority === "candidate" + ? { loaderAuthority: "candidate" as const } + : {}), ...(selected?.sourceRelativePath === undefined ? {} : { sourceRelativePath: selected.sourceRelativePath }), @@ -804,8 +894,19 @@ export const semanticRequireCallAt = ( state, factoryBindingIds, ); - if (loader !== "local" && loader !== "create-require") - return ["require-loader", loader, "relative"]; + if (loader !== "local" && loader !== "create-require") { + const root = unwrapJavaScriptExpression(callee).node; + const candidate = + loader === "unknown" && + t.isIdentifier(root, { name: "require" }) && + isConditionalRequireAt(root, node, state); + return [ + "require-loader", + loader, + "relative", + ...(candidate ? ["candidate"] : []), + ]; + } const selected = selectedRequireSourcesAt( callee, node, @@ -817,6 +918,7 @@ export const semanticRequireCallAt = ( "require-loader", selected.loader, selected.sourceRelative ? "relative" : "unknown", + ...(selected.loaderAuthority === "candidate" ? ["candidate"] : []), ]; return interop.intrinsicPath(callee); }); @@ -836,6 +938,9 @@ export const semanticRequireCallAt = ( ? "unknown" : (invocation.path[1] as JavaScriptRequireLoaderFact), arguments: invocation.arguments, + ...(invocation.kind === "known" && invocation.path[3] === "candidate" + ? { loaderAuthority: "candidate" as const } + : {}), sourceRelative: invocation.kind === "known" && invocation.path[2] === "relative", ...(selected?.sourceRelativePath === undefined @@ -1267,6 +1372,18 @@ const hasCommonJsModuleSource = ( commonJsWrapperBindingDeclaration(value, "require", state) !== undefined && !read.hasDefiniteInitializer; + // A top-level `var require` may refer to a host wrapper on an unknown + // runtime. Preserve a possible member-loader call without resolving it. + if ( + includeUnknown && + mainModulePath && + javaScriptRuntimeContext(state.program) === "unknown" && + state.scopesById.get(binding.scopeId)?.kind === "program" && + binding.declarationKinds.size === 1 && + binding.declarationKinds.has("var") && + !read.hasDefiniteInitializer + ) + return true; if ( value.name === "module" && copyAdmitsModuleDelegate && @@ -1549,40 +1666,89 @@ export const semanticGlobalProcessPathOrigin = ( }; }; +/** Check one unknown-host wrapper candidate at its source-ordered read. */ +const isConditionalRequireAt = ( + root: t.Identifier, + node: t.Node, + state: JavaScriptSemanticAnalysisState, + allowAmbientModule = false, +): boolean => { + const runtime = javaScriptRuntimeContext(state.program); + if ( + (runtime !== "unknown" && !(allowAmbientModule && runtime === "module")) || + semanticResolutionBlocked(state, root, "require") || + commonJsDelegateChangedAt(node, state) + ) + return false; + const binding = resolveSemanticBindingState(state, root, "require"); + if ( + binding !== undefined && + (binding.hostOrigin === "commonjs-wrapper" || + state.scopesById.get(binding.scopeId)?.kind !== "program" || + binding.declarationKinds.size !== 1 || + !binding.declarationKinds.has("var") || + (semanticBindingHasPriorValueWrite(node, binding, state) && + !( + (t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node)) && + isWrapperRequireInitializerCall(binding, node, state) + ))) + ) + return false; + return !(state.ambientBindingWrites.get("require") ?? []).some( + (write) => + !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), + ); +}; + /** Recover an unshadowed literal require origin and its exact member path. */ -export const semanticRequireOrigin = ( +export const semanticRequireOriginCandidate = ( node: t.Node | null | undefined, state: JavaScriptSemanticAnalysisState, + allowAmbientModule = false, ): JavaScriptRequireOrigin | undefined => { const chain = semanticMemberChain(node); const current = chain?.root; if ( chain === undefined || - !t.isCallExpression(current) || + (!t.isCallExpression(current) && !t.isOptionalCallExpression(current)) || !t.isIdentifier(current.callee, { name: "require" }) ) return undefined; - if (commonJsDelegateChangedAt(current, state)) return undefined; - const requireBinding = resolveSemanticBindingState( - state, - current.callee, - "require", - ); + const binding = resolveSemanticBindingState(state, current.callee, "require"); + const runtime = javaScriptRuntimeContext(state.program); + const candidate = + (runtime === "unknown" || + (allowAmbientModule && runtime === "module" && binding === undefined)) && + binding?.hostOrigin !== "commonjs-wrapper"; if ( - (requireBinding !== undefined && - !isUnmodifiedCommonJsWrapperBinding(current.callee, "require", state) && - !isWrapperRequireInitializerCall(requireBinding, current, state)) || - (requireBinding === undefined && - (javaScriptRuntimeContext(state.program) === "module" || - semanticResolutionBlocked(state, current.callee, "require"))) || - (state.ambientBindingWrites.get("require") ?? []).some( - (write) => - !semanticTopLevelEffectFollowsCapture( + candidate + ? !isConditionalRequireAt( + current.callee, current, - write, - state.parentsByNode, - ), - ) + state, + allowAmbientModule, + ) + : commonJsDelegateChangedAt(current, state) || + (binding !== undefined && + !isUnmodifiedCommonJsWrapperBinding( + current.callee, + "require", + state, + ) && + !isWrapperRequireInitializerCall(binding, current, state)) || + (binding === undefined && + (javaScriptRuntimeContext(state.program) === "module" || + semanticResolutionBlocked(state, current.callee, "require"))) || + (state.ambientBindingWrites.get("require") ?? []).some( + (write) => + !semanticTopLevelEffectFollowsCapture( + current, + write, + state.parentsByNode, + ), + ) ) return undefined; const specifier = stringValue(current.arguments[0]); @@ -1593,11 +1759,21 @@ export const semanticRequireOrigin = ( specifier, importedPath: chain.members, resolutionMode: "require", + ...(candidate ? { loaderAuthority: "candidate" as const } : {}), }, call: current, }; }; +/** Only a proven host loader may establish Node builtin identity. */ +export const semanticRequireOrigin = ( + node: t.Node | null | undefined, + state: JavaScriptSemanticAnalysisState, +): JavaScriptRequireOrigin | undefined => { + const result = semanticRequireOriginCandidate(node, state); + return result?.origin.loaderAuthority === "candidate" ? undefined : result; +}; + /** Node's exported child objects retain their direct builtin-module identity. */ export const semanticRequireExportsIdentity = ( specifier: string, @@ -1656,4 +1832,5 @@ export const semanticMutableBuiltinOrigin = ( }; semanticRequireOriginReaders.origin = semanticRequireOrigin; +semanticRequireOriginReaders.candidate = semanticRequireOriginCandidate; semanticRequireOriginReaders.loader = semanticRequireLoaderAt; diff --git a/src/domain/javascript/javascriptSemanticRequireOriginSlot.ts b/src/domain/javascript/javascriptSemanticRequireOriginSlot.ts index df53f15b56..f001a73e46 100644 --- a/src/domain/javascript/javascriptSemanticRequireOriginSlot.ts +++ b/src/domain/javascript/javascriptSemanticRequireOriginSlot.ts @@ -5,7 +5,7 @@ import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState. /** An unshadowed literal require and the member path selected from its result. */ export interface JavaScriptRequireOrigin { readonly origin: JavaScriptModuleOrigin; - readonly call: t.CallExpression; + readonly call: t.CallExpression | t.OptionalCallExpression; } export type JavaScriptRequireLoaderFact = @@ -33,12 +33,19 @@ type RequireLoaderReader = ( */ export const semanticRequireOriginReaders: { origin: RequireOriginReader | undefined; + candidate: RequireOriginReader | undefined; loader: RequireLoaderReader | undefined; -} = { origin: undefined, loader: undefined }; +} = { origin: undefined, candidate: undefined, loader: undefined }; export const readSemanticRequireOrigin: RequireOriginReader = (node, state) => semanticRequireOriginReaders.origin?.(node, state); +/** Includes conditional requests whose unbound wrapper is not established. */ +export const readSemanticRequireOriginCandidate: RequireOriginReader = ( + node, + state, +) => semanticRequireOriginReaders.candidate?.(node, state); + export const readSemanticRequireLoader: RequireLoaderReader = ( node, state, diff --git a/src/domain/javascript/javascriptSemanticResources.ts b/src/domain/javascript/javascriptSemanticResources.ts index 062136d20e..2ac936911a 100644 --- a/src/domain/javascript/javascriptSemanticResources.ts +++ b/src/domain/javascript/javascriptSemanticResources.ts @@ -20,7 +20,7 @@ import { type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { - builtinDataEffectMethod, + builtinDataEffectIdentity, dataEffectMemberCallee, outerDataEffectBinding, traverseDataEffects, @@ -45,6 +45,7 @@ const releaseMethodsFor = ( }; interface ResourceCandidate { + readonly builtinAuthority?: "candidate"; readonly node: t.CallExpression | t.OptionalCallExpression; readonly specifier: string; readonly method: JavaScriptSemanticResourceOperation["method"]; @@ -339,6 +340,9 @@ const collectAcquisitions = ( node, specifier: reference.specifier, method: reference.method, + ...(reference.builtinAuthority === undefined + ? {} + : { builtinAuthority: reference.builtinAuthority }), ownerCallableId: context.callableStack.at(-1) ?? null, resultBindingId: outerDataEffectBinding(node, context), }); @@ -391,6 +395,9 @@ const collectReleases = ( ownerCallableId: context.callableStack.at(-1) ?? null, resultBindingId: binding.bindingId, linkedResourceIds: linked.map(resourceCandidateId).sort(), + ...(linked.some((value) => value.builtinAuthority === "candidate") + ? { builtinAuthority: "candidate" as const } + : {}), resolution: linked.length === 1 && holdsOnlyAcquisitions(binding, linked, context.state) @@ -440,6 +447,9 @@ const immutableAcquisition = ( resourceId: resourceCandidateId(candidate), kind: "acquire", method: candidate.method, + ...(candidate.builtinAuthority === undefined + ? {} + : { builtinAuthority: candidate.builtinAuthority }), location: range(candidate.node), ownerCallableId: candidate.ownerCallableId, resultBindingId: candidate.resultBindingId, @@ -448,6 +458,7 @@ const immutableAcquisition = ( }); interface AcquisitionReference { + readonly builtinAuthority?: "candidate"; readonly specifier: string; readonly method: JavaScriptSemanticResourceOperation["method"]; } @@ -457,7 +468,9 @@ const acquisitionMethod = ( state: JavaScriptSemanticAnalysisState, ): AcquisitionReference | null => { for (const specifier of BUILTIN_RESOURCE_SPECIFIERS) { - const method = builtinDataEffectMethod(callee, state, [specifier]); + const identity = builtinDataEffectIdentity(callee, state, [specifier]); + const method = + identity?.path.length === 1 ? (identity.path[0] ?? null) : null; if ( method !== null && ( @@ -468,6 +481,9 @@ const acquisitionMethod = ( ) return { specifier, + ...(identity?.builtinAuthority === undefined + ? {} + : { builtinAuthority: identity.builtinAuthority }), method: method as JavaScriptSemanticResourceOperation["method"], }; } diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index fc524fc4bd..408ccc0e21 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -82,6 +82,7 @@ export interface JavaScriptSemanticReferenceEffectsAtSite { export interface JavaScriptSemanticRequiredModuleEffect extends JavaScriptSemanticMemberEffect { readonly kind: "write" | "escape"; + readonly loaderAuthority?: "candidate"; } export interface JavaScriptSemanticBindingState { @@ -192,7 +193,9 @@ export interface JavaScriptSemanticAnalysisState { readonly callableNodesById: Map; readonly moduleLinks: JavaScriptSemanticModuleLink[]; /** Exact calls already represented by binding or dependency links. */ - readonly linkedRequireCalls: WeakSet; + readonly linkedRequireCalls: WeakSet< + t.CallExpression | t.OptionalCallExpression + >; readonly moduleLinkBindings: WeakMap< JavaScriptSemanticModuleLink, JavaScriptSemanticBindingState diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index bc2e07e9f1..8754f5be33 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -42,7 +42,7 @@ import { type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; -import { readSemanticRequireOrigin } from "./javascriptSemanticRequireOriginSlot.js"; +import { readSemanticRequireOriginCandidate } from "./javascriptSemanticRequireOriginSlot.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { readExactJavaScriptLiteral, @@ -1230,17 +1230,18 @@ const provenanceForBindingInitializers = ( } finally { context.bindings.delete(binding.bindingId); } - if (resolved.status !== "module" || initializer.projection.length === 0) + if (resolved.origins.length === 0 || initializer.projection.length === 0) return resolved; - return semanticOriginsProvenance( - resolved.origins.map((origin) => ({ - ...origin, - importedPath: [ - ...origin.importedPath, - ...initializer.projection.map((segment) => String(segment)), - ], - })), - ); + const projected = resolved.origins.map((origin) => ({ + ...origin, + importedPath: [ + ...origin.importedPath, + ...initializer.projection.map((segment) => String(segment)), + ], + })); + return resolved.status === "ambiguous" + ? semanticAmbiguousProvenance(projected, resolved.reason) + : semanticOriginsProvenance(projected); }; const provenanceForExpression = ( @@ -1273,7 +1274,7 @@ const provenanceForExpression = ( expressionDepth: context.expressionDepth + unwrapped.depth, }); } - const required = readSemanticRequireOrigin(node, context.state); + const required = readSemanticRequireOriginCandidate(node, context.state); if (required !== undefined) return semanticOriginsProvenance([required.origin]); if (t.isSequenceExpression(node)) { @@ -1319,14 +1320,14 @@ const provenanceForExpression = ( } const base = provenanceForExpression(current, nestedContext(context)); const path = members.reverse(); - return base.status !== "module" - ? base - : semanticOriginsProvenance( - base.origins.map((origin) => ({ - ...origin, - importedPath: [...origin.importedPath, ...path], - })), - ); + if (base.origins.length === 0) return base; + const projected = base.origins.map((origin) => ({ + ...origin, + importedPath: [...origin.importedPath, ...path], + })); + return base.status === "ambiguous" + ? semanticAmbiguousProvenance(projected, base.reason) + : semanticOriginsProvenance(projected); } if (t.isConditionalExpression(node) || t.isLogicalExpression(node)) { const candidates = semanticSelectedBranches(node, context).map((branch) => diff --git a/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts b/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts index d44bcb4a03..2c886c27c1 100644 --- a/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts +++ b/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts @@ -3,6 +3,7 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; it.each([ 'var require;require("./before.cjs");', @@ -45,7 +46,7 @@ it.each([ "({require}={require:()=>({BrowserWindow:class{}})});", "[require]=[()=>({BrowserWindow:class{}})];", ])("preserves an Electron capture before the projected write: %s", (write) => { - const analysis = analyzeJavaScriptStaticSource( + const analysis = analyzeCommonJsStaticFixture( `var require;const {BrowserWindow}=require("electron");${write}new BrowserWindow({webPreferences:{preload:"./before.cjs"}});`, ); expect(analysis.parse_error_count).toBe(0); @@ -65,7 +66,7 @@ it.each([ ])( "distinguishes definite and conditional wrapper replacement: %s", (write, unknown) => { - const analysis = analyzeJavaScriptStaticSource( + const analysis = analyzeCommonJsStaticFixture( `var module;${write}module.require("./unused.cjs");`, ); expect(analysis.parse_error_count).toBe(0); diff --git a/src/domain/javascript/javascriptSourceParser.test.ts b/src/domain/javascript/javascriptSourceParser.test.ts index 39c3219a19..f94f7f1f45 100644 --- a/src/domain/javascript/javascriptSourceParser.test.ts +++ b/src/domain/javascript/javascriptSourceParser.test.ts @@ -120,9 +120,17 @@ ipcMain.handle("layout:ready",()=>pick);`; for (const path of ["main.js", "main.cjs", "main.mjs", "main.jsx"]) { const result = analyze(source, path); expect(result?.errors).toBe(0); - expect(result?.facts.electron.ipc).toContainEqual( - expect.objectContaining({ channel: "layout:ready", operation: "handle" }), - ); + if (path === "main.mjs") { + expect(result?.facts.electron.ipc).toEqual([]); + } else { + expect(result?.facts.electron.ipc).toContainEqual( + expect.objectContaining({ + channel: "layout:ready", + operation: "handle", + ...(path === "main.cjs" ? {} : { loader_authority: "candidate" }), + }), + ); + } } }); diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index 7f4ef4ce16..13902e897a 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -1,6 +1,11 @@ +import { posix } from "node:path"; import { parse, type ParserOptions, type ParserPlugin } from "@babel/parser"; import * as t from "@babel/types"; import { javaScriptDefinitionExpression } from "./javascriptAstValues.js"; +import { + explicitJavaScriptLoaderMode, + hasCaseVariantJavaScriptLoaderSuffix, +} from "./javascriptModuleResolutionMode.js"; import { hasOnlyInlineTypeExportSpecifiers, hasOnlyInlineTypeImportSpecifiers, @@ -78,27 +83,31 @@ const parseWithRuntimeContext = ( packageContext?: JavaScriptPackageType, ): ParsedJavaScriptSource => { const file = parse(source, options); + const filenameMode = explicitJavaScriptLoaderMode(sourcePath); runtimeContextByProgram.set( file.program, - /\.c[jt]s$/iu.test(sourcePath ?? "") - ? /\.d\.cts$/iu.test(sourcePath ?? "") + filenameMode === "commonjs" + ? sourcePath?.toLowerCase().endsWith(".d.cts") ? "unknown" : "commonjs" - : isExplicitModuleSourcePath(sourcePath) + : filenameMode === "module" ? "module" : packageContext === "module" || packageContext === "commonjs" ? packageContext - : packageContext === "unspecified" && /\.js$/iu.test(sourcePath ?? "") + : packageContext === "unspecified" && + posix.extname(sourcePath ?? "") === ".js" ? file.program.sourceType === "module" ? "module" : "commonjs" - : file.program.body.some((node) => t.isTSExportAssignment(node)) - ? // CommonJS isolation does not establish strict ESM semantics. - "commonjs" - : file.program.sourceType === "module" && - hasRuntimeModuleDeclaration(file.program) - ? "module" - : "unknown", + : hasCaseVariantJavaScriptLoaderSuffix(sourcePath) + ? "unknown" + : file.program.body.some((node) => t.isTSExportAssignment(node)) + ? // CommonJS isolation does not establish strict ESM semantics. + "commonjs" + : file.program.sourceType === "module" && + hasRuntimeModuleDeclaration(file.program) + ? "module" + : "unknown", ); return file; }; @@ -141,16 +150,12 @@ export const parserPluginsForPath = (sourcePath?: string): ParserPlugin[] => { return jsxCapable ? [...syntax, "jsx", typescript] : [...syntax, typescript]; }; -/** `.mjs` and `.mts` explicitly select module mode. */ -export const isExplicitModuleSourcePath = (path?: string): boolean => - /\.m[jt]s$/iu.test(path ?? ""); - const runtimeSourceTypeForPath = ( path?: string, ): "commonjs" | "module" | "unambiguous" => - /\.cjs$/iu.test(path ?? "") + explicitJavaScriptLoaderMode(path) === "commonjs" && path?.endsWith(".cjs") ? "commonjs" - : isExplicitModuleSourcePath(path) + : explicitJavaScriptLoaderMode(path) === "module" ? "module" : "unambiguous"; @@ -162,7 +167,7 @@ const packageContextForPath = ( sourcePath: string | undefined, observedPackageType: JavaScriptPackageType | undefined, ): JavaScriptPackageType | undefined => - /\.(?:js|ts)$/iu.test(sourcePath ?? "") && + [".js", ".ts"].includes(posix.extname(sourcePath ?? "")) && !/\.d\.ts$/iu.test(sourcePath ?? "") ? observedPackageType : undefined; @@ -193,7 +198,8 @@ export const parseRecoveredJavaScript = ( ? filenameType : packageContext === "module" ? "module" - : packageContext === "commonjs" && /\.js$/iu.test(sourcePath ?? "") + : packageContext === "commonjs" && + posix.extname(sourcePath ?? "") === ".js" ? "commonjs" : "unambiguous"; if (sourceType !== "unambiguous") { diff --git a/src/domain/javascript/javascriptSourceParserContext.test.ts b/src/domain/javascript/javascriptSourceParserContext.test.ts index 985d4b798f..bfc85c1e79 100644 --- a/src/domain/javascript/javascriptSourceParserContext.test.ts +++ b/src/domain/javascript/javascriptSourceParserContext.test.ts @@ -1,4 +1,9 @@ import { expect, it } from "vitest"; +import { + isJsonModuleTarget, + isNativeAddonModuleTarget, + moduleResolutionModeForJavaScriptReference, +} from "./javascriptModuleResolutionMode.js"; import * as t from "@babel/types"; import { @@ -21,18 +26,54 @@ import { parserPluginsForPath, } from "./javascriptSourceParser.js"; +it("does not treat suffix-only dotfiles as JSON or native addon loaders", () => { + expect(isJsonModuleTarget(".json", "json")).toBe(false); + expect(isNativeAddonModuleTarget(".node", "native-addon")).toBe(false); + expect(isJsonModuleTarget("value.json", "json")).toBe(true); + expect(isNativeAddonModuleTarget("value.node", "native-addon")).toBe(true); +}); + it.each([ + ["entry.cjs", "require"], + ["entry.cts", "require"], + ["entry.CJS", "import"], + ["entry.CTS", "import"], + ["entry.TS", "compiler-dependent"], +] as const)( + "uses exact loader evidence for static imports from %s", + (path, expected) => { + expect( + moduleResolutionModeForJavaScriptReference(path, "static-import"), + ).toBe(expected); + }, +); + +it.each([ + [".js", "module", "unknown"], + [".ts", "commonjs", "unknown"], + [".mjs", "commonjs", "unknown"], + [".cjs", "module", "unknown"], ["entry.cjs", "module", "commonjs"], ["entry.cts", "module", "commonjs"], ["entry.mjs", "commonjs", "module"], ["entry.mts", "commonjs", "module"], + ["entry.MJS", "commonjs", "unknown"], + ["entry.CJS", "module", "unknown"], + ["entry.MTS", "commonjs", "unknown"], + ["entry.CTS", "module", "unknown"], ["entry.d.cts", "module", "unknown"], + ["entry.D.cts", "module", "unknown"], ["entry.d.ts", "commonjs", "unknown"], ["entry.d.mts", "commonjs", "module"], ["entry.jsx", "commonjs", "unknown"], ["entry.tsx", "commonjs", "unknown"], ["entry.js", "module", "module"], ["entry.ts", "commonjs", "commonjs"], + ["entry.JS", "module", "unknown"], + ["entry.JS", "commonjs", "unknown"], + ["entry.JS", "unspecified", "unknown"], + ["entry.TS", "module", "unknown"], + ["entry.TS", "commonjs", "unknown"], ["entry.js", "unspecified", "commonjs"], ["entry.ts", "unspecified", "unknown"], ["entry.js", "unknown", "unknown"], @@ -64,6 +105,34 @@ it("does not derive factory authority from an ambient require in a proven module expect([...referenceSourceRequireCalls(file, "entry.js")]).toEqual([]); }); +it.each([ + "entry.JS", + "entry.MJS", + "entry.CJS", + "entry.TS", + "entry.MTS", + "entry.CTS", + ".MJS", +])("retains unknown host authority for mixed module syntax in %s", (path) => { + const { file } = parseRecoveredJavaScript( + 'import {join} from "node:path"; require("./dep.cjs");', + path, + { plugins: parserPluginsForPath(path) }, + "module", + ); + expect(javaScriptRuntimeContext(file.program)).toBe("unknown"); + expect(analyzeParsedJavaScriptSemantics(file).moduleLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "import", specifier: "node:path" }), + expect.objectContaining({ + kind: "require", + specifier: "./dep.cjs", + loaderAuthority: "candidate", + }), + ]), + ); +}); + it.each([ ["without source redeclarations", "", "", true], ["with a var redeclaration", "var require;\n", "", true], @@ -112,6 +181,86 @@ it.each([ }, ); +it.each([ + ["unbound", 'require("./dep.cjs");'], + ["var redeclaration", 'var require; require("./dep.cjs");'], + ["var initializer", 'var require; require = require("./dep.cjs");'], +] as const)( + "does not infer a CommonJS wrapper from %s in unknown runtime context", + (_name, source) => { + const { file } = parseRecoveredJavaScript( + source, + "entry.MJS", + {}, + "commonjs", + ); + expect(javaScriptRuntimeContext(file.program)).toBe("unknown"); + const links = analyzeParsedJavaScriptSemantics(file).moduleLinks; + expect(links.filter((link) => link.specifier === "./dep.cjs")).toEqual([ + expect.objectContaining({ + kind: "require", + loaderAuthority: "candidate", + }), + ]); + }, +); + +it.each([ + ["module.exports = 1;", "default"], + ["var module; module.exports = 1;", "default"], + ["var exports; exports.value = 1;", "value"], +] as const)("retains unknown-host export candidates for %s", (source, name) => { + const { file } = parseRecoveredJavaScript( + source, + "entry.MJS", + {}, + "commonjs", + ); + expect(analyzeParsedJavaScriptSemantics(file).moduleLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "commonjs-export", + exportedName: name, + loaderAuthority: "candidate", + }), + ]), + ); +}); + +it("does not claim an unbound module export in a known ES module", () => { + const { file } = parseRecoveredJavaScript( + "module.exports = 1;", + "entry.mjs", + {}, + ); + expect(analyzeParsedJavaScriptSemantics(file).moduleLinks).toEqual([]); +}); + +it("retains conditional require provenance through aliases without claiming a host loader", () => { + const source = + 'const { ipcMain: bus } = require("electron"); const alias = bus;'; + const { file } = parseRecoveredJavaScript( + source, + "entry.MJS", + {}, + "commonjs", + ); + const ir = analyzeParsedJavaScriptSemantics(file); + for (const name of ["bus", "alias"]) { + const binding = ir.bindings.find((item) => item.name === name); + expect(binding?.provenance).toMatchObject({ + status: "ambiguous", + origins: [ + { + specifier: "electron", + importedPath: ["ipcMain"], + loaderAuthority: "candidate", + }, + ], + }); + } +}); + it("detects JavaScript module syntax within an observed unspecified package", () => { const { file } = parseRecoveredJavaScript( "void import.meta.url;", diff --git a/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts b/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts index 7cc7ff78e3..b1a0262943 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.sqliteStorage.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "vitest"; +import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; it.each([ @@ -36,7 +37,6 @@ it.each([ 'import Database from "better-sqlite3"; new Database("known.db");', 'import Database = require("better-sqlite3"); new Database("known.db");', 'import sqlite = require("sqlite3"); new sqlite.Database("known.db");', - 'const Database = require("better-sqlite3"); new Database("known.db");', 'import {Database} from "sqlite3"; new Database("known.db");', 'import * as sqlite from "sqlite3"; new sqlite.Database("known.db");', ])("retains proven SQLite API origins: %s", (source) => { @@ -48,3 +48,15 @@ it.each([ }); expect(analysis.storage[0]).not.toHaveProperty("interpretation"); }); + +it("distinguishes conditional and proven CommonJS SQLite origins", () => { + const source = + 'const Database = require("better-sqlite3"); new Database("known.db");'; + expect(analyzeJavaScriptStaticSource(source).storage).toMatchObject([ + { kind: "sqlite", name: "known.db", interpretation: "candidate" }, + ]); + const proven = analyzeCommonJsStaticFixture(source).storage; + expect(proven).toHaveLength(1); + expect(proven[0]).toMatchObject({ kind: "sqlite", name: "known.db" }); + expect(proven[0]).not.toHaveProperty("interpretation"); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index f8c25f0022..40602371b2 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -17,7 +17,7 @@ import { } from "./javascriptSemanticTraversal.js"; import { - isElectronRendererLoad, + electronRendererLoadAuthority, resolveElectronBrowserWindowReceiver, } from "./electronStaticRenderer.js"; import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js"; @@ -121,7 +121,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( browserWindowReceiver: (node, site, suffix) => resolveElectronBrowserWindowReceiver(node, site, suffix, findings, index), rendererLoad: (callee, site) => - isElectronRendererLoad(callee, site, findings, index), + electronRendererLoadAuthority(callee, site, findings, index), source, accumulator, vitePreloadDependencyTable: indexVitePreloadDependencyTable( @@ -130,9 +130,12 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( ), openReceiverFacts: receivers.openReceivers, electronBindings: electronIdentity.bindings, + electronCandidateBindings: electronIdentity.candidateBindings, electronExportIntact: electronIdentity.intactExport, electronInvocationPath: electronIdentity.invocationPath, electronAppPathChangePossible: electronIdentity.mayChangeAppPath, + electronCandidateAppPathChangePossible: + electronIdentity.candidateMayChangeAppPath, rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index), expressionShapes: createSemanticExpressionShapeResolver(bindings), }; @@ -211,6 +214,7 @@ const finalizeStaticAnalysis = ( value.operation === "load" && ELECTRON_REFERENCE_KINDS.has(value.kind) && ELECTRON_MODULE.test(value.specifier) && + value.loader_authority !== "candidate" && // A `factory-loader` reference names a recovered bundle module, so its // specifier is a module key inside the artifact rather than a package. A // real external load in the same file keeps its `commonjs-loader` @@ -218,6 +222,14 @@ const finalizeStaticAnalysis = ( value.loader !== "factory-loader", ); const resourceLimits = [...bindings.valueResourceLimits].sort(); + const candidateElectron = [ + ...accumulator.browserWindows, + ...accumulator.contextBridgeApis, + ...accumulator.ipc, + ...accumulator.utilityProcesses, + ...accumulator.protocols, + ...accumulator.appPathChanges, + ].some(({ value }) => value.loader_authority === "candidate"); const coverage: JavaScriptStaticCoverage = { syntax: parserErrors === 0 @@ -282,6 +294,11 @@ const finalizeStaticAnalysis = ( ] : []), ...(electronObserved ? [ELECTRON_IDENTITY_LIMITATION] : []), + ...(candidateElectron + ? [ + "Some Electron syntax depends on an unproved CommonJS loader; its Electron identity remains conditional.", + ] + : []), "JavaScript syntax was parsed as data and was never evaluated.", ]; return { diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index dee550ee66..74a290f81b 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js"; const expressionWrappers = [ @@ -408,7 +409,7 @@ it("retains endpoint observations for exact template and TypeScript member keys" it("preserves preload and utility paths through exact computed join members", () => { for (const key of ['"join"', "`join`", '"join" as const']) { - const analysis = analyzeJavaScriptStaticSource(` + const analysis = analyzeCommonJsStaticFixture(` const { BrowserWindow, utilityProcess } = require("electron"); const path = require("node:path"); new BrowserWindow({ webPreferences: { @@ -491,7 +492,7 @@ it("keeps dynamic, interpolated, and invalid template keys unresolved", () => { ]) { const preload = `path[${key}](__dirname, "preload.js")`; const worker = `path[${key}](__dirname, "worker.js")`; - const analysis = analyzeJavaScriptStaticSource(` + const analysis = analyzeCommonJsStaticFixture(` const { BrowserWindow, utilityProcess } = require("electron"); const path = require("node:path"); client[${key}]("/api/items"); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index c9cf1c628c..40c1d20abe 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -86,10 +86,26 @@ export const inspectCall = ( bindings, accumulator.factoryRequireBindingIds, ) !== "local"; + const unwrappedCallee = unwrapJavaScriptExpression(node.callee).node; + const invocationMember = + (t.isMemberExpression(unwrappedCallee) || + t.isOptionalMemberExpression(unwrappedCallee)) && + ["call", "apply"].includes( + semanticStaticPropertyKey( + unwrappedCallee.property, + unwrappedCallee.computed, + ) ?? "", + ); const deferredModuleRequire = semanticModuleRequireReceiver(node) !== undefined || semanticModuleRequireCallAt(node, bindings, false) || - deferredRequireResolve; + deferredRequireResolve || + (invocationMember && + semanticRequireCallAt( + node, + bindings, + accumulator.factoryRequireBindingIds, + ) !== undefined); if (deferredModuleRequire) accumulator.pendingModuleRequireCalls.push(node); const requireCall = deferredModuleRequire ? null @@ -106,6 +122,9 @@ export const inspectCall = ( ...(requireCall.loader === undefined ? {} : { loader: requireCall.loader }), + ...(requireCall.loaderAuthority === undefined + ? {} + : { loaderAuthority: requireCall.loaderAuthority }), }); const chunkLoad = chunkLoadSpecifier(node, bindings, context); if (chunkLoad !== null) @@ -160,9 +179,16 @@ export const inspectCall = ( }); inspectEndpointCall(node, name, first, context, bindings); inspectStorageCall(node, name, first, context, bindings); + const renderer = + name === "loadFile" || + name.endsWith(".loadFile") || + name === "loadURL" || + name.endsWith(".loadURL") + ? context.rendererLoad?.(node.callee, node) + : undefined; if ( (name === "loadFile" || name.endsWith(".loadFile")) && - context.rendererLoad?.(node.callee, node) === true + renderer !== undefined ) { const argument = argumentNode(node.arguments[0]); const filePath = @@ -185,6 +211,7 @@ export const inspectCall = ( : {}), resolution_context: "filesystem-expression", mechanism: `call:${name}`, + ...renderer, module_key: null, location: range(node), }, @@ -192,7 +219,7 @@ export const inspectCall = ( } if ( (name === "loadURL" || name.endsWith(".loadURL")) && - context.rendererLoad?.(node.callee, node) === true + renderer !== undefined ) { if (first === undefined) accumulator.unknownFindings += 1; else @@ -205,6 +232,7 @@ export const inspectCall = ( path: first, resolution_context: "module-specifier", mechanism: `call:${name}`, + ...renderer, module_key: null, location: range(node), }, @@ -279,28 +307,33 @@ const requireCallSpecifier = ( node: JavaScriptCallLike, bindings: JavaScriptSemanticAnalysisState, context: FindingContext, - resolveMemberChanged = false, + index?: JavaScriptBindingIndex, ): { readonly specifier: string | undefined; readonly operation: "load" | "resolve"; readonly resolutionBasePath?: string; readonly loader?: "commonjs-loader" | "factory-loader" | "create-require"; + readonly loaderAuthority?: "candidate"; } | null => { const call = semanticRequireCallAt( node, bindings, context.accumulator.factoryRequireBindingIds, + index, ); if (call === undefined || call.loader === "local") return null; - if ( - resolveMemberChanged || - call.loader === "unknown" || - !call.sourceRelative - ) { + if (call.loader === "unknown" || !call.sourceRelative) { context.accumulator.unknownFindings += 1; + const candidate = + !call.memberUnproven && call.loaderAuthority === "candidate" + ? (argumentValue(call.arguments?.[0]) ?? undefined) + : undefined; return { - specifier: undefined, - operation: resolveMemberChanged ? "load" : call.operation, + specifier: candidate, + operation: call.operation, + ...(candidate === undefined + ? {} + : { loaderAuthority: "candidate" as const }), }; } return { @@ -325,23 +358,7 @@ export const finishModuleRequireCalls = ( index: JavaScriptBindingIndex, ): void => { for (const node of context.accumulator.pendingModuleRequireCalls) { - const resolveMemberChanged = - isRequireResolveCall(node) && - (t.isMemberExpression(node.callee) || - t.isOptionalMemberExpression(node.callee)) && - index - .effectsForReferenceAt(node, node.callee.object, ["resolve"]) - .effects.some( - ({ kind, timing }) => - timing !== "after" && - (kind === "member-write" || kind === "escape"), - ); - const call = requireCallSpecifier( - node, - index.bindings, - context, - resolveMemberChanged, - ); + const call = requireCallSpecifier(node, index.bindings, context, index); if (call !== null) addReference(context, { node, @@ -355,6 +372,9 @@ export const finishModuleRequireCalls = ( // still names a bundle module key, not a package. Dropping the loader here // would read that key as an external specifier. ...(call.loader === undefined ? {} : { loader: call.loader }), + ...(call.loaderAuthority === undefined + ? {} + : { loaderAuthority: call.loaderAuthority }), }); } context.accumulator.pendingModuleRequireCalls.length = 0; @@ -363,14 +383,14 @@ export const finishModuleRequireCalls = ( ); }; -const isRequireResolveCall = ( - node: JavaScriptCallLike, -): node is t.CallExpression | t.OptionalCallExpression => - (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && - (t.isMemberExpression(node.callee) || - t.isOptionalMemberExpression(node.callee)) && - semanticStaticPropertyKey(node.callee.property, node.callee.computed) === - "resolve"; +const isRequireResolveCall = (node: JavaScriptCallLike): boolean => { + if (t.isNewExpression(node)) return false; + const callee = unwrapJavaScriptExpression(node.callee).node; + return ( + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + semanticStaticPropertyKey(callee.property, callee.computed) === "resolve" + ); +}; const chunkLoadSpecifier = ( node: JavaScriptCallLike, @@ -551,7 +571,8 @@ const inspectEndpointCall = ( context: FindingContext, bindings: JavaScriptSemanticAnalysisState, ): void => { - if (isKnownNetworkRequest(node, bindings, context)) { + const networkAuthority = networkRequestAuthority(node, bindings, context); + if (networkAuthority !== undefined) { const target = argumentNode(node.arguments[0]); if (target === undefined) return; const literal = @@ -565,6 +586,9 @@ const inspectEndpointCall = ( kind: "network", value: literal, mechanism: `call:${name}`, + ...(networkAuthority === "candidate" + ? { interpretation: "candidate" } + : {}), }); return; } @@ -578,7 +602,7 @@ const inspectEndpointCall = ( context.accumulator.pendingNetworkCalls.push({ node, name }); return; } - addPartialNetworkEndpoint(context, node, name, target); + addPartialNetworkEndpoint(context, node, name, target, networkAuthority); return; } // A local or shadowed function with a familiar network name is not a known @@ -642,21 +666,26 @@ const isNetworkRequestSpelling = (name: string): boolean => name === "navigator.sendBeacon"; /** Confirm that the call resolves to a built-in network API at this source site. */ -const isKnownNetworkRequest = ( +const networkRequestAuthority = ( node: JavaScriptCallLike, bindings: JavaScriptSemanticAnalysisState, context: FindingContext, -): boolean => { - if (isBrowserNetworkRequest(node, bindings)) return true; +): "proven" | "candidate" | undefined => { + if (isBrowserNetworkRequest(node, bindings)) return "proven"; const callee = unwrapJavaScriptExpression(node.callee).node; if ( (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && - semanticStaticPropertyKey(callee.property, callee.computed) === "loadURL" && - context.rendererLoad?.(node.callee, node) === true - ) - return true; - return false; + semanticStaticPropertyKey(callee.property, callee.computed) === "loadURL" + ) { + const renderer = context.rendererLoad?.(node.callee, node); + return renderer === undefined + ? undefined + : renderer.loader_authority === "candidate" + ? "candidate" + : "proven"; + } + return undefined; }; /** Finish queued targets after shared member-mutation facts are indexed. */ @@ -666,7 +695,8 @@ export const finishPartialNetworkEndpoints = ( index: JavaScriptBindingIndex, ): void => { for (const { node, name } of context.accumulator.pendingNetworkCalls) { - if (!isKnownNetworkRequest(node, bindings, context)) { + const authority = networkRequestAuthority(node, bindings, context); + if (authority === undefined) { if (!isIntrinsicMapLookup(node, bindings, index)) inspectLiteralEndpointCall(node, name, context); continue; @@ -680,8 +710,9 @@ export const finishPartialNetworkEndpoints = ( kind: "network", value: resolved, mechanism: `call:${name}`, + ...(authority === "candidate" ? { interpretation: "candidate" } : {}), }); - else addPartialNetworkEndpoint(context, node, name, target); + else addPartialNetworkEndpoint(context, node, name, target, authority); } context.accumulator.pendingNetworkCalls.length = 0; context.accumulator.endpoints.sort( @@ -694,6 +725,7 @@ const addPartialNetworkEndpoint = ( node: JavaScriptCallLike, name: string, target: t.Node, + authority: "proven" | "candidate", ): void => addEndpoint(context, { node, @@ -701,6 +733,7 @@ const addPartialNetworkEndpoint = ( // Partial values preserve the original expression text, byte for byte. value: sourceSlice(context.source, target), mechanism: `call:${name}`, + ...(authority === "candidate" ? { interpretation: "candidate" } : {}), resolution: "partial", }); @@ -997,6 +1030,9 @@ export const addReference = ( ? {} : { resolution_base_path: input.resolutionBasePath }), ...(input.loader === undefined ? {} : { loader: input.loader }), + ...(input.loaderAuthority === undefined + ? {} + : { loader_authority: input.loaderAuthority }), module_key: null, location: range(input.node), }, diff --git a/src/domain/javascript/javascriptStaticAnalysisDelegateEligibility.test.ts b/src/domain/javascript/javascriptStaticAnalysisDelegateEligibility.test.ts index e022014567..1ea1db913f 100644 --- a/src/domain/javascript/javascriptStaticAnalysisDelegateEligibility.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisDelegateEligibility.test.ts @@ -4,6 +4,7 @@ import { runInNewContext } from "node:vm"; import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; it("keeps repeated Node resolver calls separate from loader delegate eligibility", () => { const expression = "path.resolve(runtimePath,__dirname,'preload.js')"; @@ -33,7 +34,7 @@ it("keeps repeated Node resolver calls separate from loader delegate eligibility "/fixture/app/preload.js", "/fixture/app/preload.js", ]); - const analysis = analyzeJavaScriptStaticSource(source); + const analysis = analyzeCommonJsStaticFixture(source); expect(analysis.parse_status).toBe("complete"); expect(analysis.references).toEqual([ expect.objectContaining({ kind: "require", specifier: "node:path" }), diff --git a/src/domain/javascript/javascriptStaticAnalysisInvocationIteration.test.ts b/src/domain/javascript/javascriptStaticAnalysisInvocationIteration.test.ts index b09fa2a1b0..492a4e93fd 100644 --- a/src/domain/javascript/javascriptStaticAnalysisInvocationIteration.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisInvocationIteration.test.ts @@ -1,7 +1,15 @@ import { runInNewContext } from "node:vm"; +import * as t from "@babel/types"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { semanticRequireCallAt } from "./javascriptSemanticRequireOrigin.js"; import { expect, it } from "vitest"; -import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { + analyzeJavaScriptStaticSource, + analyzeParsedJavaScriptStaticSource, +} from "./javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; const changedIterator = "Array.prototype[Symbol.iterator]=function*(){yield '../shared/window.cjs'};"; @@ -116,7 +124,103 @@ it.each(cases)( const analysis = analyzeJavaScriptStaticSource(source); expect(analysis.parse_error_count).toBe(0); expect(analysis.references).toEqual([ - expect.objectContaining({ kind: "require", specifier }), + expect.objectContaining({ + kind: "require", + specifier, + ...(specifier === null ? {} : { loader_authority: "candidate" }), + }), ]); }, ); + +it.each(["call", "apply"])( + "keeps overwritten require.%s unresolved", + (method) => { + for (const path of ["fixture.MJS", "fixture.cjs"]) { + const argument = + method === "call" ? 'null,"./a.cjs"' : 'null,["./a.cjs"]'; + const source = `require.${method}=custom;require.${method}(${argument});`; + const unrelated = `require.other=custom;require.${method}(${argument});`; + const parse = (text: string) => { + const parsed = parseJavaScriptSource(text, path); + if (parsed === null) throw new Error("fixture did not parse"); + return analyzeParsedJavaScriptStaticSource(text, parsed).references.at( + -1, + ); + }; + expect(parse(source)).toMatchObject({ kind: "require", specifier: null }); + expect(parse(unrelated)).toMatchObject({ + kind: "require", + specifier: "./a.cjs", + ...(path === "fixture.MJS" + ? { loader_authority: "candidate" } + : { loader: "commonjs-loader" }), + }); + } + }, +); + +it("does not prove invocation members before the mutation index is complete", () => { + const source = 'require.call=custom;require?.call(null,"./a.cjs");'; + const parsed = parseJavaScriptSource(source, "fixture.cjs"); + if (parsed === null) throw new Error("fixture did not parse"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + const statement = parsed.program.body[1]; + if ( + !t.isExpressionStatement(statement) || + !t.isOptionalCallExpression(statement.expression) + ) + throw new Error("expected optional call"); + const fact = semanticRequireCallAt( + statement.expression, + index.bindings, + new Set(), + index, + ); + expect(fact).toMatchObject({ loader: "unknown", memberUnproven: true }); +}); + +it("does not turn a mutated local callable into a loader candidate", () => { + const source = + 'const f=()=>0;f.call=()=>0;const addon=f.call(null,"./x.node");'; + const parsed = parseJavaScriptSource(source, "fixture.cjs"); + if (parsed === null) throw new Error("fixture did not parse"); + const result = analyzeParsedJavaScriptStaticSource(source, parsed); + expect(result.references).toEqual([]); + expect(result.electron.native_addon_bindings).toEqual([]); + expect(result.parse_status).toBe("complete"); +}); + +it.each(["fixture.cjs", "fixture.MJS"])( + "does not invent nested loader requests after an inner invocation member is replaced: %s", + (path) => { + const parse = (source: string) => { + const parsed = parseJavaScriptSource(source, path); + if (parsed === null) throw new Error("fixture did not parse"); + return analyzeParsedJavaScriptStaticSource(source, parsed); + }; + const changed = parse( + 'require.call=custom;const addon=require.call.call(require,"./receiver.cjs","./actual.node");', + ); + expect( + changed.references.every( + (ref) => + ref.specifier !== "./actual.node" && + ref.specifier !== "./receiver.cjs", + ), + ).toBe(true); + expect(changed.electron.native_addon_bindings).toEqual([]); + const intact = parse('require.call.call(require,null,"./actual.node");'); + expect(intact.references).toContainEqual( + expect.objectContaining({ + kind: "require", + specifier: "./actual.node", + ...(path.endsWith(".MJS") + ? { loader_authority: "candidate" } + : { loader: "commonjs-loader" }), + }), + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisInvocationReadSite.test.ts b/src/domain/javascript/javascriptStaticAnalysisInvocationReadSite.test.ts index f9e1aaad18..cbbf991632 100644 --- a/src/domain/javascript/javascriptStaticAnalysisInvocationReadSite.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisInvocationReadSite.test.ts @@ -27,7 +27,7 @@ it.each([ expect(result.parse_status).toBe("partial"); }); -it("retains complete coverage for an inline Electron factory export", () => { +it("retains conditional coverage for an inline Electron factory export", () => { const result = analyzeJavaScriptStaticSource(`(() => { const modules={ 5(module,exports){ @@ -46,14 +46,42 @@ it("retains complete coverage for an inline Electron factory export", () => { } load(9); })();`); - expect(result.parse_status).toBe("complete"); + expect(result.parse_status).toBe("partial"); expect(result.references).toContainEqual( - expect.objectContaining({ kind: "require", specifier: "electron" }), + expect.objectContaining({ + kind: "require", + specifier: "electron", + loader_authority: "candidate", + }), ); expect(result.references.every(({ specifier }) => specifier !== null)).toBe( true, ); expect(result.electron.ipc).toContainEqual( - expect.objectContaining({ channel: "test:copy", side: "main" }), + expect.objectContaining({ + channel: "test:copy", + side: "main", + loader_authority: "candidate", + }), ); }); + +it("keeps captured unknown-host loader authority without promoting shadowed values", () => { + const cases = [ + ['const load=require;load("./candidate.cjs");', true], + ['const load=require;require=()=>0;load("./candidate.cjs");', true], + ['const require=()=>0;const load=require;load("./candidate.cjs");', false], + ['require=()=>0;const load=require;load("./candidate.cjs");', false], + ] as const; + for (const [source, candidate] of cases) { + const result = analyzeJavaScriptStaticSource(source); + expect( + result.references.some( + (reference) => + reference.specifier === "./candidate.cjs" && + reference.loader_authority === "candidate", + ), + source, + ).toBe(candidate); + } +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts index 38799b1ed1..8fc8933cc2 100644 --- a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts @@ -3,11 +3,37 @@ import * as path from "node:path"; import { runInNewContext } from "node:vm"; import { expect, it } from "vitest"; -import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { + analyzeJavaScriptStaticSource, + analyzeParsedJavaScriptStaticSource, +} from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { referenceSourceRequireCalls } from "../referenceSourceRequireBindings.js"; import { parseReferenceSourceImports } from "../referenceSourceImportParsing.js"; +it.each([ + '(require.resolve as any)("./dep.cjs");', + '(require as any).resolve("./dep.cjs");', + '(require as any)?.["resolve"]("./dep.cjs");', +])( + "retains an unknown-host resolver through TypeScript wrappers: %s", + (source) => { + const parsed = parseJavaScriptSource(source, "entry.TS"); + if (parsed === null) throw new Error("Expected TypeScript fixture"); + expect( + analyzeParsedJavaScriptStaticSource(source, parsed).references, + ).toContainEqual( + expect.objectContaining({ + kind: "require", + specifier: "./dep.cjs", + operation: "resolve", + loader_authority: "candidate", + }), + ); + }, +); + it.each([ ["require", "load"], ["require?.", "load"], @@ -70,9 +96,60 @@ it.each([ ); const imported = "import {createRequire as make} from 'node:module';"; +it.each([ + ["call", 'null,"./valid.cjs"', 'null,"./invented.cjs"'], + ["apply", 'null,["./valid.cjs"]', 'null,["./invented.cjs"]'], +])( + "distinguishes nested loader-%s replacement from a child property write", + (method, valid, invented) => { + const references = (source: string) => + analyzeCommonJsStaticFixture(source).references.map( + ({ operation, specifier }) => ({ operation, specifier }), + ); + expect( + references( + `const box={load:require};box.load.${method}.extra=1;box.load.${method}(${valid});`, + ), + ).toContainEqual({ operation: "load", specifier: "./valid.cjs" }); + expect( + references( + `const box={load:require};box.load.${method}=custom;box.load.${method}(${invented});`, + ), + ).not.toContainEqual({ operation: "load", specifier: "./invented.cjs" }); + }, +); +it("preserves a native resolver before a later loader receiver escape", () => { + const references = (source: string) => + analyzeCommonJsStaticFixture(source).references.map( + ({ operation, specifier }) => ({ operation, specifier }), + ); + expect( + references( + 'require.resolve.paths=custom;require.resolve("./first.cjs");require.call.extra=1;require.call(null,"./second.cjs");', + ), + ).toEqual([ + { operation: "resolve", specifier: "./first.cjs" }, + { operation: "load", specifier: "./second.cjs" }, + ]); + expect( + references('opaque(require);require.resolve("./unsafe.cjs");').at(-1), + ).toEqual({ + operation: "load", + specifier: null, + }); + for (const source of [ + 'function run(){require.resolve("./unsafe.cjs");opaque(require);}run();run();', + 'function mutate(){opaque(require)}mutate();require.resolve("./unsafe.cjs");', + ]) { + expect(references(source)).not.toContainEqual({ + operation: "resolve", + specifier: "./unsafe.cjs", + }); + } +}); it("retains consecutive native resolver calls while keeping replaced or escaped loaders uncertain", () => { const references = (source: string) => - analyzeJavaScriptStaticSource(source).references.map( + analyzeCommonJsStaticFixture(source).references.map( ({ operation, specifier }) => ({ operation, specifier }), ); expect( @@ -293,7 +370,7 @@ it.each([ require: (request: string) => module.require(request), }); expect(result.join === path.join).toBe(host); - const analysis = analyzeJavaScriptStaticSource(source); + const analysis = analyzeCommonJsStaticFixture(source); expect(analysis.parse_error_count).toBe(0); expect(analysis.references).toEqual( specifier === undefined @@ -493,3 +570,23 @@ it("retains unknown historical .js loader scope until a package manifest establi }), ]); }); + +it.each(["main.cjs", "main.MJS"])( + "does not use an unproven invocation receiver as a dependency: %s", + (path) => { + for (const source of [ + 'require.call=custom;require.call("./receiver.cjs","./actual.cjs");', + 'require.call=custom;require.call.call(require,"./receiver.cjs","./actual.cjs");', + 'require.call=custom;require.call.apply(require,["./receiver.cjs","./actual.cjs"]);', + ]) { + const result = parseReferenceSourceImports( + path, + new TextEncoder().encode(source), + "JavaScript", + ); + expect( + result.relationships.filter(({ kind }) => kind === "requires"), + ).toEqual([]); + } + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisMainModule.test.ts b/src/domain/javascript/javascriptStaticAnalysisMainModule.test.ts index e1c405ca38..e43e4de24a 100644 --- a/src/domain/javascript/javascriptStaticAnalysisMainModule.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisMainModule.test.ts @@ -88,3 +88,16 @@ it.each(cases)( ); }, ); + +it("keeps unknown wrapper eligibility separate from local replacement", () => { + const local = analyzeJavaScriptStaticSource( + 'var require; require={main:{require:()=>0}}; require.main.require("node:path");', + ); + expect(local.references).toEqual([]); + const conditional = analyzeJavaScriptStaticSource( + 'var require; if(flag) require={main:{require:()=>0}}; require.main.require("node:path");', + ); + expect(conditional.references).toMatchObject([ + { kind: "require", specifier: null }, + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 8628fcb931..962a94043c 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -110,10 +110,17 @@ export interface JavaScriptFindingContext { site: t.Node, suffix: readonly string[], ) => - | { readonly owner: t.NewExpression; readonly path: readonly string[] } + | { + readonly owner: t.NewExpression; + readonly path: readonly string[]; + readonly authority: { readonly loader_authority?: "candidate" }; + } | undefined; readonly xhrOpenRequest?: (node: t.Node) => boolean; - readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean; + readonly rendererLoad?: ( + callee: t.Node, + site: t.Node, + ) => { readonly loader_authority?: "candidate" } | undefined; readonly rpcScan: JavaScriptRpcScan; /** Shared bounded argv/nullish argument shapes for optional-slot signatures. */ readonly expressionShapes: JavaScriptExpressionShapeResolver; @@ -123,12 +130,15 @@ export interface JavaScriptFindingContext { readonly openReceiverFacts?: ReadonlyMap; /** Proven Electron export path segments by root offset. */ readonly electronBindings?: ReadonlyMap; + /** Conditional Electron export paths, keyed by call/construction root. */ + readonly electronCandidateBindings?: ReadonlyMap; /** Normalized invocation target from the shared Electron and intrinsic owners. */ readonly electronInvocationPath?: ( node: t.CallExpression | t.OptionalCallExpression, ) => readonly string[] | undefined; /** A possible shared-app mutation does not establish concrete API identity. */ readonly electronAppPathChangePossible?: (node: t.Node) => boolean; + readonly electronCandidateAppPathChangePossible?: (node: t.Node) => boolean; } /** Candidate import, require, worker, or service-worker reference. */ @@ -139,6 +149,7 @@ export interface JavaScriptReferenceInput { readonly specifier: string | undefined; readonly resolutionBasePath?: string; readonly loader?: JavaScriptStaticReference["loader"]; + readonly loaderAuthority?: "candidate"; } /** Candidate route or network endpoint literal. */ diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 489d08dfdc..ac1f559554 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -50,6 +50,8 @@ interface JavaScriptStaticReferenceState { * when it reads like a package name. */ readonly loader?: "commonjs-loader" | "factory-loader" | "create-require"; + /** The literal is observed but the CommonJS loader is not established. */ + readonly loader_authority?: "candidate"; readonly location: JavaScriptSourceRange; } @@ -128,6 +130,7 @@ export interface JavaScriptBundlerRegistration { /** Static Electron role paths discovered from package or JavaScript syntax. */ export interface JavaScriptRolePath { readonly role: "preload" | "renderer"; + readonly loader_authority?: "candidate"; readonly path: string; readonly resolution_context: JavaScriptStaticPathContext; /** Inferred from intact source-directory intrinsics; not a runtime/bundler guarantee. */ diff --git a/src/domain/javascript/javascriptStaticCoverage.test.ts b/src/domain/javascript/javascriptStaticCoverage.test.ts index 2a6df5ae80..002f6d7c2a 100644 --- a/src/domain/javascript/javascriptStaticCoverage.test.ts +++ b/src/domain/javascript/javascriptStaticCoverage.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; import { javaScriptStaticParseStatus, unexaminedJavaScriptStaticCoverage, @@ -7,7 +8,7 @@ import { import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js"; it("separates dynamic semantic uncertainty from completed syntax and examination", () => { - const result = analyzeJavaScriptStaticSource( + const result = analyzeCommonJsStaticFixture( "const {ipcMain}=require('electron'); ipcMain.on(channel, () => {});", ); expect(result.parse_status).toBe("partial"); @@ -46,7 +47,7 @@ it("keeps framework applicability unknown without positive Electron evidence", ( const ordinary = analyzeJavaScriptStaticSource("export const value = 1;"); expect(ordinary.parse_status).toBe("complete"); expect(ordinary.analysis_coverage?.applicability.electron).toBe("unknown"); - const electron = analyzeJavaScriptStaticSource( + const electron = analyzeCommonJsStaticFixture( "const {ipcMain}=require('electron');", ); expect(electron.analysis_coverage?.applicability.electron).toBe("observed"); diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts index 1c9df55055..5f27f27619 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.test.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -82,6 +82,7 @@ it("substitutes numeric modules, named exports and captured wrapper arguments", const env = resolver.bind(fn, []); expect(resolver.evaluate(fn.node.body.callee, env)).toEqual({ kind: "external", + loaderAuthority: "candidate", path: ["electron", "ipcRenderer", "send"], }); expect(resolver.arguments(fn.node.body, env)[0]).toEqual({ @@ -127,7 +128,11 @@ it("checks block lexical initializers against their containing function call", ( ); }, }); - expect(resolved).toEqual({ kind: "external", path: ["electron"] }); + expect(resolved).toEqual({ + kind: "external", + loaderAuthority: "candidate", + path: ["electron"], + }); }); it("keeps same-factory reads before initialization unresolved", () => { @@ -236,8 +241,16 @@ it("keeps function declarations hoisted and checks closures at invocation", () = inspect(localRead()); `); expect(inspected[0]).toMatchObject({ kind: "function" }); - expect(inspected[1]).toEqual({ kind: "external", path: ["electron"] }); - expect(inspected[2]).toEqual({ kind: "external", path: ["electron"] }); + expect(inspected[1]).toEqual({ + kind: "external", + loaderAuthority: "candidate", + path: ["electron"], + }); + expect(inspected[2]).toEqual({ + kind: "external", + loaderAuthority: "candidate", + path: ["electron"], + }); }); it("keeps a synchronous cross-function callback before initialization unresolved", () => { @@ -288,7 +301,11 @@ it("uses completed activation provenance for a returned closure", () => { const value = require("electron"); return () => value; }`); - expect(result).toEqual({ kind: "external", path: ["electron"] }); + expect(result).toEqual({ + kind: "external", + loaderAuthority: "candidate", + path: ["electron"], + }); }); it("does not treat statements after a returned closure as initialized", () => { @@ -898,7 +915,11 @@ it.each([ })();`); expect(value.kind).toBe(kind); if (kind === "external") - expect(value).toEqual({ kind: "external", path: ["electron"] }); + expect(value).toEqual({ + kind: "external", + loaderAuthority: "candidate", + path: ["electron"], + }); }, ); @@ -910,7 +931,11 @@ it("reads a literal prototype only from its actual prototype setter", () => { inspectOne( 'inspect(({__proto__:require("electron"),ordinary:1}).__proto__);', ), - ).toEqual({ kind: "external", path: ["electron"] }); + ).toEqual({ + kind: "external", + loaderAuthority: "candidate", + path: ["electron"], + }); }); it.each(["local", "outer"])( @@ -1031,3 +1056,23 @@ it("keeps repeated cached queries within an independent depth budget", () => { expect(values).toHaveLength(1); expect(resolver.truncated).toBe(false); }); + +it("retains each external selection guard once across deep member reads", () => { + const { parsed, resolver } = resolverFor("if (guard) {};"); + const condition = { node: parsed.program.body[0]!, positive: true as const }; + let value: SymbolicValue = { + kind: "external", + path: ["electron"], + loaderAuthority: "candidate", + conditions: [condition], + }; + for (let index = 0; index < 32; index++) { + value = resolver.property(value, `member${index}`); + } + expect(value).toMatchObject({ + kind: "external", + loaderAuthority: "candidate", + conditions: [condition], + }); + expect(resolver.truncated).toBe(false); +}); diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index 52eb90f5aa..18dc9122c9 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -11,6 +11,8 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; +import { semanticRequireOriginCandidate } from "./javascriptSemanticRequireOrigin.js"; +import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js"; import { javascriptBootstrapAt } from "./javascriptBootstrap.js"; import { ELECTRON_MODULE } from "./javascriptElectronMemberWrites.js"; @@ -358,7 +360,13 @@ export class JavaScriptSymbolicBindings { ) { return symbolicUnknown("overwritten Electron API member"); } - return { kind: "external", path }; + return { + kind: "external", + path, + ...(value.loaderAuthority === "candidate" + ? { loaderAuthority: "candidate" as const } + : {}), + }; } if (value.kind === "record") { const property = value.values.get(key); @@ -765,13 +773,25 @@ export class JavaScriptSymbolicBindings { if (!binding) return !semanticResolutionBlocked(this.state, node, node.name) && ["require", "navigator", "String", "Boolean"].includes(node.name) - ? { kind: "external", path: [node.name] } + ? node.name === "require" && + javaScriptRuntimeContext(this.state.program) === "module" + ? symbolicUnknown("CommonJS require is unavailable in an ES module") + : { + kind: "external", + path: [node.name], + ...(node.name === "require" && + javaScriptRuntimeContext(this.state.program) === "unknown" + ? { loaderAuthority: "candidate" as const } + : {}), + } : symbolicUnknown("unbound identifier"); if (binding.definitions.some((d) => d.kind === "assignment")) return symbolicUnknown("reassigned binding"); // Electron member identity is checked at the selected property. A write // to one method must not erase the module origin or unaffected siblings. - const electronOrigin = this.#electronIdentity.exportPath(node); + const electronOrigin = + this.#electronIdentity.exportPath(node) ?? + this.#electronIdentity.candidateExportPath(node); if (electronOrigin === undefined && this.#mutated.has(binding.bindingId)) return symbolicUnknown("dynamic member writes"); for (let e: SymbolicEnvironment | undefined = env; e; e = e.parent) { @@ -1176,7 +1196,15 @@ export class JavaScriptSymbolicBindings { const spec = args[0]; result = spec?.kind === "literal" && typeof spec.value === "string" - ? { kind: "external", path: [spec.value] } + ? { + kind: "external", + path: [spec.value], + ...(semanticRequireOriginCandidate(node, this.state)?.origin + .loaderAuthority === "candidate" || + value.loaderAuthority === "candidate" + ? { loaderAuthority: "candidate" as const } + : {}), + } : symbolicUnknown("dynamic require"); } else if ( value.kind !== "function" || diff --git a/src/domain/javascript/javascriptSymbolicValues.ts b/src/domain/javascript/javascriptSymbolicValues.ts index 7307575796..9704ddb213 100644 --- a/src/domain/javascript/javascriptSymbolicValues.ts +++ b/src/domain/javascript/javascriptSymbolicValues.ts @@ -47,7 +47,11 @@ export type SymbolicValue = ( readonly kind: "literal"; readonly value: string | number | boolean | null | undefined; } - | { readonly kind: "external"; readonly path: readonly string[] } + | { + readonly kind: "external"; + readonly path: readonly string[]; + readonly loaderAuthority?: "candidate"; + } | { readonly kind: "object"; readonly node: t.ObjectExpression; diff --git a/src/domain/referenceSourceClassification.ts b/src/domain/referenceSourceClassification.ts index fcfb8bd039..9b96bd7f3b 100644 --- a/src/domain/referenceSourceClassification.ts +++ b/src/domain/referenceSourceClassification.ts @@ -1,4 +1,5 @@ import { posix } from "node:path"; +import { isJavaScriptSourcePath } from "./javascript/javascriptArtifactFiles.js"; const SOURCE_CLASSIFICATIONS = [ "source", @@ -315,6 +316,16 @@ const basenameParts = (filename: string): { base: string; ext: string } => { return { base: filename.slice(0, lastDot), ext: filename.slice(lastDot + 1) }; }; +/** A JavaScript suffix may itself be the complete basename, as in `.MJS`. */ +const sourceExtension = (filename: string): string => { + const { ext } = basenameParts(filename); + return ext === "" && + filename.startsWith(".") && + isJavaScriptSourcePath(filename) + ? filename.slice(1) + : ext; +}; + const basenameMatch = (filename: string, set: ReadonlySet): boolean => { const lower = filename.toLowerCase(); return set.has(lower); @@ -344,10 +355,10 @@ export const detectReferenceSourceLanguage = (path: string): string | null => { if (lower.endsWith(".d.mts")) return "TypeScript"; if (lower.endsWith(".d.cts")) return "TypeScript"; - const parts = basenameParts(filename); - if (parts.ext === "C") return "C++"; - if (parts.ext === "M") return "Objective-C++"; - return LANGUAGE_BY_EXTENSION.get(parts.ext.toLowerCase()) ?? null; + const extension = sourceExtension(filename); + if (extension === "C") return "C++"; + if (extension === "M") return "Objective-C++"; + return LANGUAGE_BY_EXTENSION.get(extension.toLowerCase()) ?? null; }; /** Classify a reference source path deterministically. */ @@ -412,7 +423,7 @@ const addFilenameClassifications = ( filename: string, classifications: Set, ): void => { - const extension = basenameParts(filename).ext.toLowerCase(); + const extension = sourceExtension(filename).toLowerCase(); if (filenameIsTest(filename)) classifications.add("test"); if (filenameIsGenerated(filename)) classifications.add("generated"); if (basenameMatch(filename, MANIFEST_FILENAMES)) diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index 6de46d7dc8..caf218003b 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -1,9 +1,4 @@ -import type { - CallExpression, - File, - OptionalCallExpression, - Node, -} from "@babel/types"; +import type { File, Node } from "@babel/types"; import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; import { readExactJavaScriptLiteral } from "./javascript/javascriptAstValues.js"; import { @@ -14,6 +9,7 @@ import { import { referenceSourceRequireCalls, type ReferenceSourceRequireCall, + type ReferenceSourceRequireInvocation, } from "./referenceSourceRequireBindings.js"; import { isRelativeJavaScriptModuleSpecifier } from "./javascript/javascriptModuleSpecifier.js"; import { @@ -22,6 +18,7 @@ import { isExportNamedDeclaration, isImportDeclaration, isImportExpression, + isNewExpression, isOptionalCallExpression, isTSImportType, isStringLiteral, @@ -156,7 +153,7 @@ const extractImports = ( relationships: ReferenceSourceImportRelationship[], limitations: string[], requireCalls: ReadonlyMap< - CallExpression | OptionalCallExpression, + ReferenceSourceRequireInvocation, ReferenceSourceRequireCall >, ): void => { @@ -193,7 +190,11 @@ const extractImports = ( parseState: specifier === undefined ? "partial" : "parsed", ...(specifier === undefined ? { resolution: "unknown" } : {}), }); - } else if (isCallExpression(node) || isOptionalCallExpression(node)) { + } else if ( + isCallExpression(node) || + isOptionalCallExpression(node) || + isNewExpression(node) + ) { const requireCall = requireCalls.get(node); if (requireCall === undefined) return; const specifier = exactStringModuleName(requireCall.arguments?.[0]); diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index b9ce67f493..0151475a4f 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -1,7 +1,11 @@ import * as t from "@babel/types"; import { posix } from "node:path"; -import { semanticStaticPropertyKey } from "./javascript/javascriptAstValues.js"; +import { + semanticMemberChain, + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascript/javascriptAstValues.js"; import { isUnmodifiedGlobalReference } from "./javascript/javascriptSemanticAmbientWrites.js"; import { indexJavaScriptBindingsWithReceiversSteps, @@ -11,6 +15,7 @@ import type { JavaScriptSemanticAnalysisState } from "./javascript/javascriptSem import { semanticRequireCallAt, semanticRequireLoaderAt, + semanticSelectedMemberMayHaveChanged, type JavaScriptRequireCallFact, } from "./javascript/javascriptSemanticRequireOrigin.js"; import { @@ -33,12 +38,17 @@ export interface ReferenceSourceRequireCall { readonly limitation?: string; } +export type ReferenceSourceRequireInvocation = + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression; + /** Identify exact calls through a source-owned Node loader and their resolver base. */ export const referenceSourceRequireCalls = ( file: Pick, path: string, ): ReadonlyMap< - t.CallExpression | t.OptionalCallExpression, + ReferenceSourceRequireInvocation, ReferenceSourceRequireCall > => { // Unmarked paths depend on package scope or compiler options. Preserve their @@ -52,20 +62,30 @@ export const referenceSourceRequireCalls = ( ); const factoryBindings: ReadonlySet = new Set(); const candidates: { - readonly call: t.CallExpression | t.OptionalCallExpression; - readonly identifier: t.Identifier; + readonly call: ReferenceSourceRequireInvocation; readonly arguments: JavaScriptRequireCallFact["arguments"]; readonly resolutionBase: ReferenceSourceRequireResolutionBase; readonly limitation?: string; }[] = []; traverseJavaScriptAst(file.program, { enter: (node) => { - if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + if ( + !t.isCallExpression(node) && + !t.isOptionalCallExpression(node) && + !t.isNewExpression(node) + ) return; - const identifier = callReceiverIdentifier(node.callee); - if (identifier === undefined) return; + if (callReceiverIdentifier(node.callee) === undefined) return; let fact = semanticRequireLoaderAt(node, index.bindings, factoryBindings); - if (fact === "create-require") { + const callee = unwrapJavaScriptExpression(node.callee).node; + if ( + fact === "create-require" || + ((t.isMemberExpression(callee) || + t.isOptionalMemberExpression(callee)) && + ["call", "apply", "resolve"].includes( + semanticStaticPropertyKey(callee.property, callee.computed) ?? "", + )) + ) { // Definitions identify a possible factory. Its mutable member reads // need the shared write index before they can prove an association. completeSemanticSteps(index.indexMemberMutationFactsSteps()); @@ -75,9 +95,16 @@ export const referenceSourceRequireCalls = ( node, index.bindings, factoryBindings, + index, ); if (callFact === undefined || callFact.loader === "local") return; - if (fact !== "create-require" && fact !== "commonjs-loader") return; + if ( + fact !== "create-require" && + fact !== "commonjs-loader" && + callFact.loader !== "create-require" && + callFact.loader !== "commonjs-loader" + ) + return; const sourceRelativePath = callFact.sourceRelativePath; const normalizedSourceRelativePath = sourceRelativePath === undefined @@ -103,8 +130,7 @@ export const referenceSourceRequireCalls = ( kind: "source-relative-file", path: normalizedSourceRelativePath, } - : fact === "commonjs-loader" && - callFact.loader === "commonjs-loader" && + : callFact.loader === "commonjs-loader" && runtimeContext === "commonjs" ? "source-file" : "unknown"; @@ -117,7 +143,6 @@ export const referenceSourceRequireCalls = ( ); candidates.push({ call: node, - identifier, arguments: callFact.arguments, resolutionBase: searchBase, ...(runtimeContext === "unknown" @@ -128,25 +153,24 @@ export const referenceSourceRequireCalls = ( }); }, }); - if (candidates.some(({ call }) => !t.isIdentifier(call.callee))) + if ( + candidates.some( + ({ call }) => + !t.isIdentifier(unwrapJavaScriptExpression(call.callee).node), + ) + ) completeSemanticSteps(index.indexMemberMutationFactsSteps()); return new Map( candidates.map( - ({ - call, - identifier, - arguments: callArguments, - resolutionBase, - limitation, - }) => + ({ call, arguments: callArguments, resolutionBase, limitation }) => [ call, { arguments: callArguments, ...(limitation === undefined ? {} : { limitation }), resolutionBase: - !t.isIdentifier(call.callee) && - hasPriorMemberMutation(index, call, identifier) + !t.isIdentifier(unwrapJavaScriptExpression(call.callee).node) && + hasPriorMemberMutation(index, call) ? "unknown" : resolutionBase, }, @@ -187,33 +211,24 @@ const requireResolveSearchBase = ( const hasPriorMemberMutation = ( index: JavaScriptReceiverIndex, - site: t.CallExpression | t.OptionalCallExpression, - identifier: t.Identifier, + site: ReferenceSourceRequireInvocation, ): boolean => { - const callee = site.callee; - const property = - t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee) - ? semanticStaticPropertyKey(callee.property, callee.computed) - : undefined; - if (property === undefined || property === null) return true; - const facts = index.effectsForReferenceAt(site, identifier, [property]); + const callee = unwrapJavaScriptExpression(site.callee).node; return ( - !facts.memberEffectsIndexed || - facts.effects.some( - ({ kind, timing }) => - (kind === "member-write" || kind === "escape") && timing !== "after", - ) + semanticMemberChain(callee) === undefined || + semanticSelectedMemberMayHaveChanged(index, site, callee) ); }; const callReceiverIdentifier = ( callee: t.Node | null | undefined, ): t.Identifier | undefined => { - if (t.isIdentifier(callee)) return callee; - if ( - (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && - t.isIdentifier(callee.object) + if (!t.isNode(callee)) return undefined; + let expression = unwrapJavaScriptExpression(callee).node; + while ( + t.isMemberExpression(expression) || + t.isOptionalMemberExpression(expression) ) - return callee.object; - return undefined; + expression = unwrapJavaScriptExpression(expression.object).node; + return t.isIdentifier(expression) ? expression : undefined; }; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 208852408e..9bdb8be1ad 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -1099,9 +1099,20 @@ describe("web bundle configuration and navigation parity", () => { endpoints: [], candidates: ["/v1/state", "token", "users"], }; + const conditional = { + endpoints: [], + candidates: [ + "/v1/state", + "closedRepositories", + "lastVersion", + "onboarded", + "token", + "users", + ], + }; expect(analyze(`const vscode = require("vscode");${reads}`)).toEqual({ - web: proven, - application: proven, + web: conditional, + application: conditional, }); expect(analyze(`import * as vscode from "vscode";${reads}`)).toEqual({ web: proven, diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts index 5ae00bb48f..50475dec7c 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts @@ -1,6 +1,16 @@ import { expect, it } from "vitest"; -import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; +import { + analyzeJavaScriptStaticSource, + analyzeParsedJavaScriptStaticSource, +} from "../../domain/javascript/javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; + +const analyzeCommonJsSource = (source: string) => { + const file = parseJavaScriptSource(source, "fixture.cjs"); + if (file === null) throw new Error("Expected valid CommonJS fixture"); + return analyzeParsedJavaScriptStaticSource(source, file); +}; import { javaScriptWorkerModuleHeaderSchema, javaScriptWorkerModuleRecordSchema, @@ -54,7 +64,7 @@ it("transfers bounded alias selection coverage across the worker boundary", () = }); it("retains application path change identity and provenance across the worker boundary", () => { - const facts = analyzeJavaScriptStaticSource(` + const facts = analyzeCommonJsSource(` const { app } = require("electron"); app.setAppPath("./other"); `); @@ -85,6 +95,61 @@ it("transfers getter expansion limits and unknown bindings across the worker bou expect(ir.coverage.resourceLimits).toEqual(["getter-expansion"]); }); +it("retains conditional CommonJS provenance and links across the worker boundary", () => { + const ir = analyzeJavaScriptSemantics( + 'const { readFile } = require("node:fs");', + ); + const binding = ir.bindings.find(({ name }) => name === "readFile"); + const link = ir.moduleLinks.find(({ specifier }) => specifier === "node:fs"); + expect(binding?.provenance).toMatchObject({ + status: "ambiguous", + origins: [{ loaderAuthority: "candidate" }], + }); + expect(link).toMatchObject({ loaderAuthority: "candidate" }); + expect( + javaScriptWorkerModuleRecordSchema.safeParse({ + kind: "binding", + value: { + ...binding, + provenance: { ...binding?.provenance, status: "module", reason: null }, + }, + }).success, + ).toBe(false); + for (const record of [ + { kind: "binding", value: binding }, + { kind: "module-link", value: link }, + ]) { + expect( + javaScriptWorkerModuleRecordSchema.parse( + JSON.parse(JSON.stringify(record)), + ), + ).toEqual(record); + } +}); + +it("transfers conditional require requests without claiming a loader", () => { + const facts = analyzeJavaScriptStaticSource( + 'require("./dep.cjs"); require.resolve("./config.json");', + ); + expect(facts.references).toEqual([ + expect.objectContaining({ + specifier: "./dep.cjs", + operation: "load", + loader_authority: "candidate", + }), + expect.objectContaining({ + specifier: "./config.json", + operation: "resolve", + loader_authority: "candidate", + }), + ]); + expect( + javaScriptWorkerStaticAnalysisSchema.parse( + JSON.parse(JSON.stringify(facts)), + ), + ).toEqual(facts); +}); + it("retains candidate endpoint interpretation across the worker boundary", () => { const facts = analyzeJavaScriptStaticSource(` client.get("settingKey"); @@ -176,7 +241,7 @@ it("preserves partial request targets through the strict worker schema", () => { it("preserves scoped IPC receiver provenance and rejects malformed scope metadata", () => { const facts = - analyzeJavaScriptStaticSource(`const {BrowserWindow} = require("electron"); + analyzeCommonJsSource(`const {BrowserWindow} = require("electron"); const window = new BrowserWindow(); window.webContents.mainFrame.ipc.handle("scoped", () => {});`); expect(facts.electron.ipc).toHaveLength(1); expect(facts.electron.ipc[0]?.receiver?.kind).toBe("main-frame"); diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index a7a6e7d0e3..797f39ee03 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -28,6 +28,10 @@ const findingContext = { module_runtime: z.string().optional(), location: range, }; +const electronFindingContext = { + ...findingContext, + loader_authority: z.literal("candidate").exactOptional(), +}; const withRuntime = < Value extends { module_runtime?: string | undefined; @@ -78,7 +82,7 @@ const ipcReceiver = z const ipcChannel = (shape: Shape) => z.union([ z.strictObject({ - ...findingContext, + ...electronFindingContext, receiver: ipcReceiver, invocation_location: range.optional(), resolution_basis: z.literal("wrapper-substitution").optional(), @@ -87,7 +91,7 @@ const ipcChannel = (shape: Shape) => channel_expression: z.null(), }), z.strictObject({ - ...findingContext, + ...electronFindingContext, receiver: ipcReceiver, invocation_location: range.optional(), resolution_basis: z.literal("wrapper-substitution").optional(), @@ -148,7 +152,7 @@ const ipc = z }; }); const windowShape = { - ...findingContext, + ...electronFindingContext, options_status: z.enum(["object-literal", "dynamic", "missing"]), web_preferences_status: z.enum(["object-literal", "dynamic", "missing"]), web_preferences: z.array( @@ -158,22 +162,26 @@ const windowShape = { }; const bridgeShape = { resolution_basis: z.literal("wrapper-substitution").optional(), - ...findingContext, + ...electronFindingContext, world: z.enum(["main", "isolated"]), world_id: staticValue.nullable(), api_status: z.enum(["object-literal", "dynamic", "missing"]), members: strings, unknown_members: count, }; -const utilityShape = { ...findingContext, service_name: z.string().nullable() }; +const utilityShape = { + ...electronFindingContext, + service_name: z.string().nullable(), +}; const electron = z .strictObject({ app_path_changes: z - .array(z.strictObject(findingContext).transform(withRuntime)) + .array(z.strictObject(electronFindingContext).transform(withRuntime)) .optional(), call_paths: z .array( z.strictObject({ + loader_authority: z.literal("candidate").exactOptional(), entry_kind: z.enum(["bridge-member", "ipc-handler"]), entry: z.string(), entry_path: strings.optional(), @@ -255,7 +263,7 @@ const electron = z ipc: z.array(ipc), sender_validations: z.array( z.strictObject({ - ...findingContext, + ...electronFindingContext, subject: z.enum([ "sender-url", "sender-origin", @@ -291,7 +299,7 @@ const electron = z ), native_addon_bindings: z.array( z.strictObject({ - ...findingContext, + ...electronFindingContext, specifier: z.string(), binding_kind: z.enum(["import", "require", "re-export"]), module_kind: z.enum(["import", "require"]), @@ -303,7 +311,7 @@ const electron = z protocols: z.array( z .strictObject({ - ...findingContext, + ...electronFindingContext, api: z.string(), role: z.enum([ "os-default-client", @@ -327,6 +335,7 @@ const referenceShape = { ...findingContext, operation: z.enum(["load", "resolve"]), resolution_base_path: z.string().exactOptional(), + loader_authority: z.literal("candidate").exactOptional(), // A `factory-loader` specifier is a recovered bundle module key rather than a // package specifier, so the loader identity travels with the reference. loader: z @@ -424,6 +433,7 @@ export const javaScriptWorkerStaticAnalysisSchema = z z.strictObject({ ...findingContext, role: z.enum(["preload", "renderer"]), + loader_authority: z.literal("candidate").exactOptional(), path: z.string(), path_base: z.literal("source-directory").exactOptional(), resolution_context: pathContext, @@ -528,20 +538,26 @@ const semanticValue: z.ZodType = z.lazy(() => ); const origin = z .strictObject({ + loaderAuthority: z.literal("candidate").optional(), specifier: z.string(), importedPath: strings, resolutionMode: z .enum(["import", "require", "compiler-dependent"]) .optional(), }) - .transform(({ resolutionMode, ...rest }) => ({ + .transform(({ resolutionMode, loaderAuthority, ...rest }) => ({ ...rest, ...(resolutionMode === undefined ? {} : { resolutionMode }), + ...(loaderAuthority === undefined ? {} : { loaderAuthority }), })); +const provenOrigin = origin.refine( + (value) => value.loaderAuthority !== "candidate", + "Candidate loader authority cannot establish exact module provenance.", +); const provenance = z.discriminatedUnion("status", [ z.strictObject({ status: z.literal("module"), - origins: z.tuple([origin]), + origins: z.tuple([provenOrigin]), reason: z.null(), }), z.strictObject({ @@ -593,6 +609,7 @@ const coverage = z }); const moduleLink = z .strictObject({ + loaderAuthority: z.literal("candidate").optional(), resolutionMode: z .enum(["import", "require", "compiler-dependent"]) .optional(), @@ -611,9 +628,10 @@ const moduleLink = z callableId: z.string().nullable(), location: range, }) - .transform(({ resolutionMode, ...rest }) => ({ + .transform(({ resolutionMode, loaderAuthority, ...rest }) => ({ ...rest, ...(resolutionMode === undefined ? {} : { resolutionMode }), + ...(loaderAuthority === undefined ? {} : { loaderAuthority }), })); const definitionKind = z.enum([ "import", diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 3379d5ee62..1c1b5d4c1d 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -1851,37 +1851,47 @@ it("retains bundled isolated-world contextBridge APIs through dedicated and gene })();`, ); - for (const arguments_ of [ - ["analyze-javascript-application", root, "--json"], - ["analyze", root, "--format", "json"], - ]) { - const result = javascriptApplicationAnalysisResultSchema.parse( - parseEvidence(await runCli(arguments_)).normalized_result, - ); - expect(result.summary.context_bridge_apis).toBe(1); - const bridge = result.graph.nodes.find( - ({ kind }) => kind === "context-bridge-api", - ); - expect(bridge?.observations).toContainEqual( - expect.objectContaining({ - properties: expect.objectContaining({ - world: "isolated", - world_id: { status: "literal", value: 1001, expression: null }, - api_key: "isolatedApi", - api_status: "object-literal", - members: ["ping"], - unknown_members: 0, - // The proven Webpack bootstrap resolves `load(2)` directly. - resolution_basis: "direct-syntax", - }), - evidence: expect.objectContaining({ - location: expect.objectContaining({ - available: true, - value: expect.objectContaining({ source: "preload.js" }), + for (const loader of ["unknown", "commonjs"] as const) { + if (loader === "commonjs") + await writeFile(join(root, "package.json"), '{"type":"commonjs"}'); + for (const arguments_ of [ + ["analyze-javascript-application", root, "--json"], + ["analyze", root, "--format", "json"], + ]) { + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(await runCli(arguments_)).normalized_result, + ); + expect(result.summary.context_bridge_apis).toBe( + loader === "commonjs" ? 1 : 0, + ); + const bridge = result.graph.nodes.find( + ({ kind }) => kind === "context-bridge-api", + ); + expect(bridge?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + world: "isolated", + world_id: { status: "literal", value: 1001, expression: null }, + api_key: "isolatedApi", + api_status: "object-literal", + members: ["ping"], + unknown_members: 0, + // The proven Webpack bootstrap resolves `load(2)` directly. + resolution_basis: "direct-syntax", + ...(loader === "unknown" ? { loader_authority: "candidate" } : {}), + }), + evidence: expect.objectContaining({ + ...(loader === "unknown" + ? { confidence: "low", state: "inferred" } + : {}), + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ source: "preload.js" }), + }), }), }), - }), - ); + ); + } } }); diff --git a/tests/acceptance/applications/electronDynamicIpcHandlersCli.test.ts b/tests/acceptance/applications/electronDynamicIpcHandlersCli.test.ts index 5d013fa275..da17cb81d2 100644 --- a/tests/acceptance/applications/electronDynamicIpcHandlersCli.test.ts +++ b/tests/acceptance/applications/electronDynamicIpcHandlersCli.test.ts @@ -99,3 +99,46 @@ for (const surface of ["CLI", "stdio MCP"] as const) }); }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps a proven IPC match ambiguous when a candidate Electron handler competes through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-electron-candidate-ipc-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ + name: "candidate-ipc", + main: "main.js", + }), + "main.js": `${window}\nipcMain.handle("app:get", () => 1);`, + "preload.js": + 'const { ipcRenderer } = require("electron"); ipcRenderer.invoke("app:get");', + }); + const known = await analyzeJavaScriptDirectory(surface, cli, root); + expect(known.summary.ipc).toMatchObject({ + paired_renderer_transmissions: 1, + ambiguous_renderer_transmissions: 0, + }); + await writeFixtureFiles(root, { + "candidate.MJS": + 'const { ipcMain } = require("electron"); ipcMain.handle("app:get", () => 2);', + }); + const conditional = await analyzeJavaScriptDirectory(surface, cli, root); + expect(conditional.summary.ipc).toMatchObject({ + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + }); + expect(conditional.graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "ipc-handler", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + loader_authority: "candidate", + }), + evidence: expect.objectContaining({ confidence: "low" }), + }), + ]), + }), + ); + }, +); diff --git a/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts b/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts index 3099f0f209..23fca681d2 100644 --- a/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts +++ b/tests/acceptance/applications/javascriptChildProcessStdioCli.test.ts @@ -352,7 +352,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ); expect(stdio, String(command)).toHaveLength(1); expect(stdio[0]?.resolution).toBe( - mode === "dynamic" ? "candidate" : "resolved", + mode === "dynamic" || child.properties.builtin_authority === "candidate" + ? "candidate" + : "resolved", ); expect(nodes.get(stdio[0]!.target_node_id)).toMatchObject({ kind: "stdio", diff --git a/tests/acceptance/applications/javascriptConditionalBuiltinEffects.test.ts b/tests/acceptance/applications/javascriptConditionalBuiltinEffects.test.ts new file mode 100644 index 0000000000..f02493f407 --- /dev/null +++ b/tests/acceptance/applications/javascriptConditionalBuiltinEffects.test.ts @@ -0,0 +1,146 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains conditional built-in effects through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-conditional-builtins-"); + const source = ` + const fs = require('fs'); + const proc = require('process'); + const http = require('http'); + const { spawn } = require('child_process'); + const { setTimeout: schedule } = require('timers'); + fs.readFileSync('config.json'); + proc.env.MODE; + const stream = fs.createReadStream('data'); + stream.close(); + http.get('http://example.test'); + spawn('tool', []); + schedule(() => {}, 1); + `; + await writeFile(join(root, "candidate.MJS"), source); + await writeFile(join(root, "proven.cjs"), source); + await writeFile( + join(root, "mixed.MJS"), + `import * as builtin from 'node:fs'; const alternate = require('fs'); const selected = globalThis.flag ? builtin : alternate; selected.readFileSync('mixed.json');`, + ); + await writeFile( + join(root, "mixed-nested.MJS"), + `import * as builtin from 'node:fs'; const alternate = require('fs'); const selected = globalThis.flag ? builtin : alternate; selected.promises.readFile('mixed.json');`, + ); + await writeFile( + join(root, "mutation.mjs"), + `import http from 'http'; require('http').get = custom; http.get('http://example.test');`, + ); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + const graph = result.semantic_graph; + for (const [path, candidate] of [ + ["candidate.MJS", true], + ["proven.cjs", false], + ] as const) { + const nodes = graph.nodes.filter( + (node) => + node.identity.module_path === path && + [ + "child-process", + "config-source", + "request", + "resource", + "timer", + ].includes(node.kind), + ); + expect(nodes.map(({ kind }) => kind).sort()).toEqual([ + "child-process", + "config-source", + "config-source", + "request", + "resource", + "timer", + ]); + for (const node of nodes) { + expect(node.properties.builtin_authority).toBe( + candidate ? "candidate" : undefined, + ); + const evidence = graph.evidence_contexts.find( + ({ context_id }) => context_id === node.evidence.context_id, + ); + if (candidate) + expect(evidence).toMatchObject({ + state: "inferred", + confidence: "low", + }); + } + const ids = new Set(nodes.map(({ node_id }) => node_id)); + const relations = graph.relations.filter( + ({ source_node_id, target_node_id }) => + ids.has(source_node_id) || ids.has(target_node_id), + ); + for (const relation of [ + "reads-config", + "reads-environment", + "acquires", + "releases", + "constructs-request", + "spawns", + "schedules-timer", + ]) + expect(relations).toContainEqual( + expect.objectContaining({ + relation, + resolution: candidate ? "candidate" : "resolved", + }), + ); + if (candidate) { + expect( + relations.every(({ resolution }) => resolution !== "resolved"), + ).toBe(true); + for (const relation of relations) { + const evidence = graph.evidence_contexts.find( + ({ context_id }) => context_id === relation.evidence.context_id, + ); + expect(evidence).toMatchObject({ + state: "inferred", + confidence: "low", + }); + } + } + } + const conditionalMutation = graph.nodes.find( + (node) => + node.identity.module_path === "mutation.mjs" && node.kind === "request", + ); + expect(conditionalMutation?.properties.builtin_authority).toBe("candidate"); + const evidence = graph.evidence_contexts.find( + ({ context_id }) => + context_id === conditionalMutation?.evidence.context_id, + ); + expect(evidence).toMatchObject({ state: "inferred", confidence: "low" }); + for (const path of ["mixed.MJS", "mixed-nested.MJS"]) { + const mixed = graph.nodes.find( + (node) => + node.identity.module_path === path && node.kind === "config-source", + ); + expect(mixed?.properties.builtin_authority).toBe("candidate"); + expect( + graph.evidence_contexts.find( + ({ context_id }) => context_id === mixed?.evidence.context_id, + ), + ).toMatchObject({ state: "inferred", confidence: "low" }); + expect( + graph.relations.find( + ({ relation, source_node_id, target_node_id }) => + relation === "reads-config" && + (source_node_id === mixed?.node_id || + target_node_id === mixed?.node_id), + ), + ).toMatchObject({ resolution: "candidate" }); + } + }, +); diff --git a/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts b/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts index 47adda924c..ad077dd4a9 100644 --- a/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts +++ b/tests/acceptance/applications/javascriptGraphQueryCoverage.test.ts @@ -147,3 +147,80 @@ cliTest( expect(mcpTrace.nodes).toContainEqual(read); }, ); + +cliTest( + "retains candidate Electron loader evidence through semantic traces", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-conditional-electron-trace-", + ); + await writeFile( + join(root, "main.MJS"), + 'const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });', + ); + const analysis = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + }); + expect(analysis.exitCode, analysis.stderr).toBe(0); + const application = parseEvidence(analysis.json); + const graph = javascriptApplicationAnalysisResultSchema.parse( + application.normalized_result, + ).semantic_graph; + const seed = graph.nodes.find( + (node) => + node.properties.loader_authority === "candidate" && + node.properties.step_kind === "exposure", + ); + if (seed === undefined) + throw new Error("Expected candidate Electron exposure"); + const query = { + seed: { kind: "semantic-node", node_id: seed.node_id }, + direction: "forward-influence", + include_ambiguous_dynamic_edges: true, + }; + const request = join(root, "trace.json"); + await writeFile(request, JSON.stringify({ application, query })); + const response = await cli.run({ + arguments: ["trace-javascript-semantics", request, "--format", "json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + const mcpCall = await cli.sharedMcpCall?.(); + if (mcpCall === undefined) throw new Error("Expected shared MCP session"); + const mcp = await mcpCall("trace_javascript_semantics", { + application, + query, + }); + expect(mcp.isError, JSON.stringify(mcp.content)).not.toBe(true); + for (const evidence of [ + parseEvidence(response.json), + parseEvidence(mcp.structuredContent), + ]) { + const trace = javaScriptSemanticTraceResultSchema.parse( + evidence.normalized_result, + ); + const node = trace.nodes.find(({ node_id }) => node_id === seed.node_id); + expect(node?.properties.loader_authority).toBe("candidate"); + const context = trace.evidence_contexts.find( + ({ context_id }) => context_id === node?.evidence.context_id, + ); + expect(context).toMatchObject({ state: "inferred", confidence: "low" }); + expect(context?.limitations.join(" ")).toContain("loader is unknown"); + const candidateRelations = trace.relations.filter( + ({ properties }) => properties.loader_authority === "candidate", + ); + expect(candidateRelations.length).toBeGreaterThan(0); + for (const relation of candidateRelations) { + const relationContext = trace.evidence_contexts.find( + ({ context_id }) => context_id === relation.evidence.context_id, + ); + expect(relationContext).toMatchObject({ + state: "inferred", + confidence: "low", + }); + expect(relationContext?.limitations.join(" ")).toContain( + "loader is unknown", + ); + } + } + }, +); diff --git a/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts b/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts index 9091e23e73..c0dbff1052 100644 --- a/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts +++ b/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts @@ -150,3 +150,41 @@ console.log(JSON.stringify({ } }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "selects import exports for uppercase loader suffixes through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-uppercase-import-"); + await writeFixtureFiles(root, { + "uppercase.CJS": 'import "dual-condition";', + "uppercase.CTS": 'import "dual-condition";', + "node_modules/dual-condition/package.json": JSON.stringify({ + exports: { import: "./import.mjs", require: "./require.cjs" }, + }), + "node_modules/dual-condition/import.mjs": "export default 1;", + "node_modules/dual-condition/require.cjs": "module.exports = 2;", + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + const imports = result.graph.edges.filter( + (edge) => + edge.relation === "imports" && + edge.properties.specifier === "dual-condition", + ); + for (const source of ["uppercase.CJS", "uppercase.CTS"]) + expect( + imports.some( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ), + source, + ).toBe(true); + for (const edge of imports) + expect(edge.properties).toMatchObject({ + resolution_status: "resolved", + resolved_path: "node_modules/dual-condition/import.mjs", + }); + }, +); diff --git a/tests/acceptance/applications/javascriptPackageSuffixCase.test.ts b/tests/acceptance/applications/javascriptPackageSuffixCase.test.ts new file mode 100644 index 0000000000..190a6d2d5d --- /dev/null +++ b/tests/acceptance/applications/javascriptPackageSuffixCase.test.ts @@ -0,0 +1,106 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps package context conditional for uppercase source suffixes through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-package-suffix-case-"); + const source = 'require("./dep.cjs");'; + await writeFixtureFiles(root, { + "common/package.json": '{"type":"commonjs"}', + "common/entry.JS": source, + "common/entry.TS": source, + "common/mixed.JS": + 'import {join} from "node:path"; require("./dep.cjs");', + "common/.MJS": 'import {join} from "node:path"; require("./dep.cjs");', + "common/lower.js": source, + "common/dep.cjs": "module.exports = {};", + "module/package.json": '{"type":"module"}', + "module/entry.JS": source, + "module/entry.TS": source, + "module/mixed.JS": + 'import {join} from "node:path"; require("./dep.cjs");', + "lower-module/package.json": '{"type":"module"}', + "lower-common/package.json": '{"type":"commonjs"}', + "lower-module/.cjs": 'require("./dep.cjs");', + "lower-common/.mjs": 'require("./dep.cjs");', + "module/.MJS": 'import {join} from "node:path"; require("./dep.cjs");', + "module/ordinary.js": "console.log('module');", + "module/main.mjs": + 'import {BrowserWindow} from "electron"; import {fileURLToPath} from "node:url"; new BrowserWindow({webPreferences:{sandbox:false,preload:fileURLToPath(new URL("./entry.JS",import.meta.url))}});', + "module/dep.cjs": "module.exports = {};", + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + for (const source of [ + "common/entry.JS", + "common/entry.TS", + "module/entry.JS", + "module/entry.TS", + "common/mixed.JS", + "module/mixed.JS", + "common/.MJS", + "module/.MJS", + "lower-common/.mjs", + "lower-module/.cjs", + ]) { + const edges = result.graph.edges.filter( + ({ relation, properties, evidence }) => + relation === "imports" && + properties.specifier === "./dep.cjs" && + properties.loader_authority === "candidate" && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ); + expect(edges.length, source).toBeGreaterThan(0); + for (const edge of edges) { + expect(edge.properties.resolved_path, source).toBeNull(); + expect(edge.evidence.confidence, source).toBe("low"); + } + } + for (const source of [ + "common/mixed.JS", + "module/mixed.JS", + "common/.MJS", + "module/.MJS", + ]) { + expect( + result.graph.nodes.flatMap(({ observations }) => observations), + ).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + path: source, + module_format: "unknown", + }), + }), + ); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ specifier: "node:path" }), + evidence: expect.objectContaining({ + location: expect.objectContaining({ + value: expect.objectContaining({ source }), + }), + }), + }), + ); + } + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + specifier: "./dep.cjs", + resolved_path: "common/dep.cjs", + }), + evidence: expect.objectContaining({ + location: expect.objectContaining({ + value: expect.objectContaining({ source: "common/lower.js" }), + }), + }), + }), + ); + }, +); diff --git a/tests/acceptance/applications/unboundRequires.test.ts b/tests/acceptance/applications/unboundRequires.test.ts index b5425ba886..21c42c2943 100644 --- a/tests/acceptance/applications/unboundRequires.test.ts +++ b/tests/acceptance/applications/unboundRequires.test.ts @@ -12,9 +12,518 @@ import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/j import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; const execute = promisify(execFile); + +const expectResolverCandidates = ( + graph: ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse + >["graph"], +): void => { + for (const source of [ + "upper-resolve.MJS", + "wrapped-resolve.TS", + "wrapped-receiver.TS", + ]) + expect(graph.edges).toContainEqual( + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + kind: "require", + operation: "resolve", + specifier: "./dep.cjs", + loader_authority: "candidate", + resolved_path: null, + resolution_mode: null, + }), + evidence: expect.objectContaining({ + confidence: "low", + location: expect.objectContaining({ + value: expect.objectContaining({ source }), + }), + }), + }), + ); + for (const source of [ + "upper-shadow.MJS", + "upper-mutated.MJS", + "wrapped-shadow.TS", + "wrapped-mutated.TS", + ]) { + expect( + graph.edges.some( + ({ properties, evidence }) => + properties.loader_authority === "candidate" && + properties.specifier === "./dep.cjs" && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ), + ).toBe(false); + } +}; + +const expectUnknownLoaderCandidates = ( + graph: ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse + >["graph"], +): void => { + const upper = sourceRequireFacts(graph, "upper-module.MJS"); + expect(upper.module_format).toBe("unknown"); + expect(upper.require_targets).toContainEqual([ + "./dep.cjs", + null, + "unavailable", + ]); + const candidate = graph.edges.find( + ({ relation, properties, evidence }) => + relation === "imports" && + properties.specifier === "./dep.cjs" && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === "upper-module.MJS", + ); + expect(candidate?.properties.resolution_mode).toBeNull(); + expect(candidate?.evidence.confidence).toBe("low"); + expect(candidate?.evidence.limitations.join(" ")).toContain( + "loader is unknown", + ); + const staticCandidate = graph.edges.find( + ({ relation, properties, evidence }) => + relation === "imports" && + properties.kind === "require" && + properties.specifier === "./dep.cjs" && + properties.loader_authority === "candidate" && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === "upper-module.MJS", + ); + expect(staticCandidate).toMatchObject({ + properties: { + resolution_mode: null, + resolved_path: null, + resolution_status: null, + }, + evidence: { + confidence: "low", + }, + }); + expect(staticCandidate?.evidence.limitations.join(" ")).toContain( + "loader is unknown", + ); + expectResolverCandidates(graph); + const candidateExports = graph.edges.filter( + ({ relation, properties, evidence }) => + relation === "exposes" && + properties.loader_authority === "candidate" && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === "upper-export.MJS", + ); + expect(candidateExports.length).toBeGreaterThan(0); + expect(candidateExports[0]?.evidence).toMatchObject({ + confidence: "low", + }); + expect(candidateExports[0]?.evidence.limitations.join(" ")).toContain( + "wrapper is unknown", + ); + for (const path of ["upper-alias.MJS", "upper-var-export.MJS"]) { + expect(graph.edges).toContainEqual( + expect.objectContaining({ + relation: "exposes", + properties: expect.objectContaining({ + loader_authority: "candidate", + }), + evidence: expect.objectContaining({ + confidence: "low", + location: expect.objectContaining({ + value: expect.objectContaining({ source: path }), + }), + }), + }), + ); + } + expect(graph.edges).toContainEqual( + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + module_link_kind: "commonjs-export", + loader_authority: "candidate", + specifier: "./dep.cjs", + resolved_path: null, + resolution_status: "unavailable", + }), + evidence: expect.objectContaining({ + confidence: "low", + location: expect.objectContaining({ + value: expect.objectContaining({ source: "upper-alias.MJS" }), + }), + }), + }), + ); + const candidateExportSource = graph.nodes + .flatMap(({ observations }) => observations) + .find( + ({ properties }) => + properties.semantic_role === "source-module" && + properties.path === "upper-export.MJS", + ); + expect( + candidateExportSource?.properties.omitted_export_names, + ).toBeGreaterThan(0); + expect(candidateExportSource?.evidence.limitations.join(" ")).toContain( + "candidate relationships are retained", + ); +}; + +const expectSelectedLoaderEdges = ( + graph: ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse + >["graph"], +): void => { + const loaderEdge = (file: string, specifier: string) => + graph.edges.find( + ({ properties, evidence }) => + properties.specifier === specifier && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === file, + ); + expect + .soft( + loaderEdge("candidate-alias.MJS", "./alias-dep.cjs")?.properties + .loader_authority, + ) + .toBe("candidate"); + for (const source of ["candidate-new.MJS", "candidate-new-alias.MJS"]) { + expect + .soft(loaderEdge(source, "./alias-dep.cjs")?.properties.loader_authority) + .toBe("candidate"); + } + for (const source of [ + "candidate-new-shadow.MJS", + "candidate-new-replaced.MJS", + ]) { + expect.soft(loaderEdge(source, "./alias-dep.cjs")).toBeUndefined(); + } + expect + .soft(loaderEdge("benign-resolve.cjs", "./benign-dep.cjs")) + .toBeDefined(); + expect.soft(loaderEdge("benign-call.cjs", "./call-dep.cjs")).toBeDefined(); + expect + .soft(loaderEdge("consecutive-methods.cjs", "./benign-dep.cjs")) + .toBeDefined(); + expect + .soft(loaderEdge("consecutive-methods.cjs", "./call-dep.cjs")) + .toBeDefined(); + expect + .soft(loaderEdge("nested-loader.cjs", "./nested-false.cjs")) + .toBeUndefined(); + expect + .soft(loaderEdge("nested-child.cjs", "./nested-valid.cjs")) + .toBeDefined(); + expect + .soft(loaderEdge("wrapped-call.cts", "./false-dep.cjs")) + .toBeUndefined(); +}; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "distinguishes exact loader suffixes from uppercase artifact names through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-loader-suffix-case-"); + const source = "console.log(42);"; + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ + type: "commonjs", + main: "upper-module.MJS", + }), + "upper-module.MJS": + 'const value = require("./dep.cjs"); console.log(value);', + "dep.cjs": "module.exports = 42;", + "upper-export.MJS": "module.exports = { value: 42 };", + "upper-resolve.MJS": 'require.resolve("./dep.cjs");', + "wrapped-resolve.TS": '(require.resolve as any)("./dep.cjs");', + "wrapped-receiver.TS": '(require as any).resolve("./dep.cjs");', + "wrapped-shadow.TS": + 'const require = {resolve() {}}; (require as any).resolve("./dep.cjs");', + "wrapped-mutated.TS": + 'require.resolve = custom; (require as any).resolve("./dep.cjs");', + "upper-shadow.MJS": + 'const require = { resolve() {} }; require.resolve("./dep.cjs");', + "upper-mutated.MJS": + 'require.resolve = custom; require.resolve("./dep.cjs");', + "upper-alias.MJS": + 'var module; const dep = require("./dep.cjs"); module.exports = dep;', + "upper-var-export.MJS": "var exports; exports.name = 42;", + "nested-mutated.MJS": + 'require.call=custom;require.call.call(require,"./receiver.cjs","./invented.node");', + "nested-mutated.cjs": + 'require.call=custom;require.call.apply(require,["./receiver.cjs","./invented.node"]);', + "candidate-alias.MJS": 'const load = require; load("./alias-dep.cjs");', + "candidate-new.MJS": 'new require("./alias-dep.cjs");', + "candidate-new-alias.MJS": + 'const load = require; new load("./alias-dep.cjs");', + "candidate-new-shadow.MJS": + 'const require = function() {}; new require("./alias-dep.cjs");', + "candidate-new-replaced.MJS": + 'require = function() {}; const load = require; new load("./alias-dep.cjs");', + "benign-resolve.cjs": + 'require.resolve.paths = custom; require.resolve("./benign-dep.cjs");', + "benign-call.cjs": + 'require.call.extra = 1; require.call(null, "./call-dep.cjs");', + "consecutive-methods.cjs": + 'require.resolve.paths = custom; require.resolve("./benign-dep.cjs"); require.call.extra = 1; require.call(null, "./call-dep.cjs");', + "nested-loader.cjs": + 'const box = {load: require}; box.load.call = () => 0; box.load.call(null, "./nested-false.cjs");', + "nested-child.cjs": + 'const box = {load: require}; box.load.call.extra = 1; box.load.call(null, "./nested-valid.cjs");', + "wrapped-call.cts": + 'require.call = () => 0; (require.call as any)(null, "./false-dep.cjs");', + "alias-dep.cjs": "module.exports = 1;", + "benign-dep.cjs": "module.exports = 2;", + "call-dep.cjs": "module.exports = 3;", + "false-dep.cjs": "module.exports = 4;", + "nested-false.cjs": "module.exports = 5;", + "nested-valid.cjs": "module.exports = 6;", + "upper-wrapper.CJS": source, + "lower-module.mjs": source, + "lower-wrapper.cjs": source, + }); + const control = await execute( + process.execPath, + [join(root, "upper-module.MJS")], + { + cwd: root, + timeout: 5_000, + }, + ); + expect(control.stdout.trim()).toBe("42"); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + expectUnknownLoaderCandidates(result.graph); + for (const path of ["nested-mutated.MJS", "nested-mutated.cjs"]) { + expect( + result.graph.edges.some( + ({ properties, evidence }) => + (properties.specifier === "./receiver.cjs" || + properties.specifier === "./invented.node") && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === path, + ), + ).toBe(false); + } + expectSelectedLoaderEdges(result.graph); + const modules = result.graph.nodes + .filter((node) => node.kind === "javascript-module") + .flatMap((node) => + node.observations.map((observation) => observation.properties), + ); + expect(modules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "upper-module.MJS", + module_format: "unknown", + }), + expect.objectContaining({ + path: "upper-wrapper.CJS", + module_format: "unknown", + }), + expect.objectContaining({ + path: "upper-export.MJS", + module_format: "unknown", + export_names: [], + }), + expect.objectContaining({ + path: "lower-module.mjs", + module_format: "esm", + }), + expect.objectContaining({ + path: "lower-wrapper.cjs", + module_format: "commonjs", + }), + ]), + ); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains conditional Electron boundaries without resolving their roles through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-candidate-electron-"); + await writeFixtureFiles(root, { + "entry.MJS": [ + 'const { BrowserWindow, ipcMain, protocol, contextBridge, ipcRenderer } = require("electron");', + 'const win = new BrowserWindow({ webPreferences: { preload: "./preload.cjs" } });', + 'win.loadFile("./index.html");', + 'win.loadURL("https://candidate.example.test/app");', + 'ipcMain.handle("ping", () => 1);', + 'protocol.handle("demo", () => null);', + 'contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });', + ].join("\n"), + "preload.cjs": "console.log(1);", + "index.html": "", + "dynamic.MJS": [ + 'const name = "electron";', + "const { contextBridge, ipcRenderer } = require(name);", + 'contextBridge.exposeInMainWorld("dynamicApi", { ping: () => ipcRenderer.invoke("dynamicPing") });', + ].join("\n"), + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + for (const kind of ["browser-window", "ipc-handler", "url-scheme"]) { + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + kind, + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + loader_authority: "candidate", + }), + evidence: expect.objectContaining({ + confidence: "low", + }), + }), + ]), + }), + ); + } + expect( + result.graph.nodes.some(({ kind }) => kind === "electron-preload"), + ).toBe(false); + expect( + result.graph.nodes.some(({ kind }) => kind === "electron-renderer"), + ).toBe(false); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "unknown", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + candidate_role: "renderer", + loader_authority: "candidate", + declared_path: "./index.html", + resolution_status: "unavailable", + resolved_path: null, + }), + evidence: expect.objectContaining({ confidence: "low" }), + }), + ]), + }), + ); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "endpoint", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + value: "https://candidate.example.test/app", + interpretation: "candidate", + }), + evidence: expect.objectContaining({ confidence: "low" }), + }), + ]), + }), + ); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "javascript-expression", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + loader_authority: "candidate", + terminal: "electron.ipcRenderer.invoke", + }), + evidence: expect.objectContaining({ + confidence: "low", + }), + }), + ]), + }), + ); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "javascript-expression", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + loader_authority: "candidate", + terminal: "electron.ipcRenderer.invoke", + channel: "dynamicPing", + }), + evidence: expect.objectContaining({ confidence: "low" }), + }), + ]), + }), + ); + expect(result.summary).toMatchObject({ + browser_windows: 0, + preload_entrypoints: 0, + protocol_registrations: 0, + ipc: { operations: 0, main_handlers: 0 }, + }); + expect(result.graph.limitations.join(" ")).toContain( + "Conditional Electron findings", + ); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains loader uncertainty in Electron semantic paths through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory( + "rea-electron-semantic-authority-", + ); + const source = [ + 'const { contextBridge, ipcRenderer } = require("electron");', + 'contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") });', + ].join("\n"); + await writeFixtureFiles(root, { + "candidate.MJS": source, + "proven.cjs": source, + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + const graph = result.semantic_graph; + const pathNodes = graph.nodes.filter((node) => + node.identity.role_key.startsWith("electron-path:"), + ); + const candidate = pathNodes.filter( + (node) => node.identity.module_path === "candidate.MJS", + ); + const proven = pathNodes.filter( + (node) => node.identity.module_path === "proven.cjs", + ); + expect(candidate.length).toBeGreaterThan(0); + expect(proven.length).toBeGreaterThan(0); + const evidenceFor = (contextId: string) => + graph.evidence_contexts.find( + ({ context_id }) => context_id === contextId, + ); + for (const node of candidate) { + expect(node.properties.loader_authority).toBe("candidate"); + const evidence = evidenceFor(node.evidence.context_id); + expect(evidence).toMatchObject({ state: "inferred", confidence: "low" }); + expect(evidence?.limitations.join(" ")).toContain("loader is unknown"); + } + for (const node of proven) { + expect(node.properties.loader_authority).toBeUndefined(); + expect(evidenceFor(node.evidence.context_id)).toMatchObject({ + state: "observed", + confidence: "exact", + }); + } + const relations = graph.relations.filter( + ({ properties }) => properties.loader_authority === "candidate", + ); + expect(relations.length).toBeGreaterThan(0); + for (const relation of relations) { + const evidence = evidenceFor(relation.evidence.context_id); + expect(evidence).toMatchObject({ state: "inferred", confidence: "low" }); + expect(evidence?.limitations.join(" ")).toContain("loader is unknown"); + } + }, +); const compiler = join( dirname(createRequire(import.meta.url).resolve("typescript/package.json")), "bin", @@ -339,6 +848,8 @@ const sourceRequireFacts = ( properties.semantic_role === "source-module" && properties.path === path, )?.properties; + if (module === undefined) + throw new Error(`Source module ${path} is absent from the analysis graph`); const requires = graph.edges.filter( ({ relation, properties, evidence }) => relation === "imports" && @@ -348,8 +859,8 @@ const sourceRequireFacts = ( evidence.location.value.source === path, ); return { - module_format: module?.module_format, - import_relationships: module?.import_relationships, + module_format: module.module_format, + import_relationships: module.import_relationships, require_targets: requires.map(({ properties }) => [ properties.specifier, properties.resolved_path, @@ -486,6 +997,46 @@ cliTest( }, ); +cliTest( + "does not infer preload loader authority from a candidate Electron window", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-candidate-preload-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ type: "module", main: "main.MJS" }), + "main.MJS": + 'const {BrowserWindow}=require("electron"); new BrowserWindow({webPreferences:{preload:"./preload.js"}});', + "preload.js": 'require("./helper.cjs");', + "helper.cjs": "module.exports = 1;", + }); + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "browser-window", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + loader_authority: "candidate", + }), + }), + ]), + }), + ); + expect(sourceRequireFacts(graph, "preload.js").module_format).toBe( + "unknown", + ); + expect( + sourceRequireFacts(graph, "preload.js").require_targets, + ).not.toContainEqual(["./helper.cjs", "helper.cjs", "resolved"]); + }, +); + cliTest( "keeps a declared preload's helper require without renderer API text", async ({ cli }) => { diff --git a/tests/acceptance/referenceSourceSuffixDotfileCli.test.ts b/tests/acceptance/referenceSourceSuffixDotfileCli.test.ts new file mode 100644 index 0000000000..9127f97bc6 --- /dev/null +++ b/tests/acceptance/referenceSourceSuffixDotfileCli.test.ts @@ -0,0 +1,44 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { historicalSourceGraphSchema } from "../../src/domain/referenceSourceGraph.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "retains suffix-only source dotfiles through reference import", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-reference-dotfile-cli-"); + const source = 'import {join} from "node:path"; require("./dep.cjs");'; + await Promise.all([ + writeFile(join(root, ".MJS"), source), + writeFile(join(root, ".TS"), source), + writeFile(join(root, "dep.cjs"), "module.exports = {};"), + ]); + const result = await cli.run({ + arguments: ["import-reference-source", root, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const graph = historicalSourceGraphSchema.parse(result.json); + for (const [path, language] of [ + [".MJS", "JavaScript"], + [".TS", "TypeScript"], + ] as const) { + expect(graph.entries).toContainEqual( + expect.objectContaining({ + path, + language, + classifications: ["source"], + }), + ); + expect(graph.relationships).toContainEqual({ + from_path: path, + to: "./dep.cjs", + kind: "requires", + resolution: "unknown", + parse_state: "partial", + }); + } + }, +); diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts index 6c5c4ba83f..fe131f0fb8 100644 --- a/tests/boundary/cli/electronWrappedCallPaths.test.ts +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -135,10 +135,10 @@ for (const fixture of restArrayPropertyCases) const analyze = async (cli: TestCli, extra = "") => { const root = await createTestTempDirectory("rea-electron-wrapped-paths-"); await writeFixtureFiles(root, { - "preload.js": `const e=require("electron"); + "preload.cjs": `const e=require("electron"); const sender=channel=>(...args)=>e.ipcRenderer.send(channel,...args); e.contextBridge.exposeInMainWorld("desktop",{copy:sender("test:copy"),cut:sender("test:cut")});`, - "main.js": `const e=require("electron"); + "main.cjs": `const e=require("electron"); function register(channel,handler){e.ipcMain.addListener(channel,event=>allowed(event)&&handler(event))} register("test:copy",event=>event.sender.copy()); register("test:cut",event=>event.sender.cut());${extra}`, @@ -351,7 +351,7 @@ cliTest( "rea-electron-early-return-", ); await writeFixtureFiles(root, { - "index.js": `const e=require("electron");function f(x){${fixture.body}}f(globalThis.blocked);`, + "index.cjs": `const e=require("electron");function f(x){${fixture.body}}f(globalThis.blocked);`, }); const application = await analyzeJavaScriptDirectory( surface, @@ -418,11 +418,11 @@ cliTest( async ({ cli }) => { const root = await createTestTempDirectory("rea-electron-nested-bridge-"); await writeFixtureFiles(root, { - "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + "preload.cjs": `const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("api", { nested: { write: (value) => ipcRenderer.invoke("nested:write", value) } });`, - "main.js": `const { ipcMain, clipboard } = require("electron"); + "main.cjs": `const { ipcMain, clipboard } = require("electron"); ipcMain.handle("nested:write", (event, value) => clipboard.writeText(value));`, }); const output = await runFileCli(cli, { @@ -608,7 +608,7 @@ for (const surface of ["CLI", "stdio MCP"] as const) { "rea-electron-spread-bridge-members-", ); await writeFixtureFiles(root, { - "preload.js": `const { contextBridge } = require("electron"); + "preload.cjs": `const { contextBridge } = require("electron"); const extra = getExtra(); contextBridge.exposeInMainWorld("api", ${api});`, }); @@ -658,7 +658,7 @@ for (const surface of ["CLI", "stdio MCP"] as const) { "rea-electron-bridge-helper-calls-", ); await writeFixtureFiles(root, { - "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + "preload.cjs": `const { contextBridge, ipcRenderer } = require("electron"); function expose(key, api) { contextBridge.exposeInMainWorld(key, api); } function relay(key, label, api) { expose(key, api); } function named(label, key) { expose(key, { hidden: () => ipcRenderer.send("hidden") }); } @@ -740,7 +740,7 @@ for (const surface of ["CLI", "stdio MCP"] as const) { async ({ cli }) => { const root = await createTestTempDirectory("rea-electron-branch-paths-"); await writeFixtureFiles(root, { - "preload.js": `const e=require("electron"); + "preload.cjs": `const e=require("electron"); e.contextBridge.exposeInMainWorld("empty", {}); function choose(){ if(flag)return ()=>e.ipcRenderer.send("test:copy"); @@ -754,7 +754,7 @@ for (const surface of ["CLI", "stdio MCP"] as const) { conditional:()=>e.ipcRenderer.send("test:conditional"), fallback:()=>e.ipcRenderer.send("test:fallback"), });`, - "main.js": `const e=require("electron"); + "main.cjs": `const e=require("electron"); const copy=event=>event.sender.copy(), cut=event=>event.sender.cut(); function register(channel,handler){e.ipcMain.on(channel,handler)} register("test:copy",copy);register("test:cut",cut); @@ -846,12 +846,12 @@ cliTest( for (let index = 0; index < 129; index += 1) deepMember = `{ level${index}: ${deepMember} }`; await writeFixtureFiles(root, { - "preload.js": `const e=require("electron"); + "preload.cjs": `const e=require("electron"); e.contextBridge.exposeInMainWorld("desktop", { deep: ${deepMember}, shallow: () => e.ipcRenderer.send("shallow:run") });`, - "main.js": `const e=require("electron"); + "main.cjs": `const e=require("electron"); e.ipcMain.on("shallow:run", event => event.sender.copy()); e.ipcMain.on("deep:run", event => event.sender.copy());`, }); @@ -901,12 +901,12 @@ cliTest( for (let index = 0; index < 129; index += 1) deepMember = `{ level${index}: ${deepMember} }`; await writeFixtureFiles(root, { - "preload.js": `const e=require("electron"); + "preload.cjs": `const e=require("electron"); e.contextBridge.exposeInMainWorld("desktop", { deep: ${deepMember}, shallow: () => e.ipcRenderer.send("shared:run") });`, - "main.js": `const e=require("electron"); + "main.cjs": `const e=require("electron"); e.ipcMain.on("shared:run", event => event.sender.copy());`, }); const output = await runFileCli(cli, { @@ -941,7 +941,7 @@ cliTest( // A known second handler is not merely a hidden-coverage uncertainty. await writeFile( - join(root, "main.js"), + join(root, "main.cjs"), 'const e=require("electron"); e.ipcMain.on("shared:run", () => {}); e.ipcMain.on("shared:run", () => {});', ); const competing = await runFileCli(cli, { @@ -968,11 +968,11 @@ cliTest( "rea-electron-empty-bridge-key-", ); await writeFixtureFiles(root, { - "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + "preload.cjs": `const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("desktop", { "": { nested: { call: () => ipcRenderer.send("empty:key") } } });`, - "main.js": `const { ipcMain } = require("electron"); + "main.cjs": `const { ipcMain } = require("electron"); ipcMain.on("empty:key", event => event.sender.copy());`, }); const output = await runFileCli(cli, { @@ -1008,13 +1008,13 @@ cliTest( async ({ cli }) => { const root = await createTestTempDirectory("rea-electron-join-conditions-"); await writeFixtureFiles(root, { - "preload.js": `const e=require("electron"); + "preload.cjs": `const e=require("electron"); e.contextBridge.exposeInMainWorld("desktop", { choose: flag ? () => e.ipcRenderer.send("test:copy") : () => e.ipcRenderer.send("test:cut") });`, - "main.js": `const e=require("electron"); + "main.cjs": `const e=require("electron"); if(!flag)e.ipcMain.on("test:copy",event=>allowed(event)&&event.sender.copy()); if(flag)e.ipcMain.on("test:cut",event=>allowed(event)&&event.sender.cut());`, }); @@ -1291,3 +1291,49 @@ cliTest.for(["CLI", "stdio MCP"] as const)( }); }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains conditional wrapped Electron paths without exact joins through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-electron-unknown-wrapper-"); + await writeFixtureFiles(root, { + "preload.js": + 'const e=require("electron");e.contextBridge.exposeInMainWorld("api",{send:()=>e.ipcRenderer.send("ready")});', + "main.js": + 'const e=require("electron");e.ipcMain.on("ready",event=>event.sender.copy());', + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.graph.coverage.status).toBe("partial"); + const candidate = result.graph.nodes + .flatMap(({ observations }) => observations) + .filter(({ properties }) => properties.loader_authority === "candidate"); + expect(candidate).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ api_key: "api" }), + }), + expect.objectContaining({ + properties: expect.objectContaining({ + channel: "ready", + side: "renderer", + }), + }), + expect.objectContaining({ + properties: expect.objectContaining({ + channel: "ready", + side: "main", + }), + }), + ]), + ); + expect( + candidate.every(({ evidence }) => evidence.confidence === "low"), + ).toBe(true); + expect( + result.graph.edges.filter( + ({ evidence }) => + evidence.extractor.operation === "join-electron-wrapper-paths", + ), + ).toEqual([]); + }, +); diff --git a/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts b/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts index 0bc0547d3a..49debbb68a 100644 --- a/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts +++ b/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts @@ -174,7 +174,7 @@ for (const scenario of [ `keeps bundled relationships within their runtime for ${scenario.name}`, async ({ cli }) => { const root = await createTestTempDirectory("rea-bundler-runtime-scope-"); - await writeFixtureFiles(root, { "bundle.js": scenario.source }); + await writeFixtureFiles(root, { "bundle.cjs": scenario.source }); const output = await cli.run({ arguments: ["analyze-javascript-application", root, "--json"], }); @@ -249,6 +249,49 @@ for (const scenario of [ ); } +cliTest( + "keeps conditional Electron observations scoped to unknown-host runtimes", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-bundler-unknown-host-"); + await writeFixtureFiles(root, { "bundle.js": appA + appB }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(graph.coverage.status).toBe("partial"); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + for (const runtime of ["webpackChunkAppA", "webpackChunkAppB"]) { + const module = graph.nodes.find( + (node) => + node.identity.strategy === "artifact-local-key" && + node.identity.namespace === `${runtime}:module` && + node.identity.key === "1", + ); + expect(module).toBeDefined(); + const windowEdge = graph.edges.find( + (edge) => + edge.source_node_id === module?.node_id && + edge.relation === "contains" && + nodes.get(edge.target_node_id)?.kind === "browser-window", + ); + expect(windowEdge).toBeDefined(); + expect( + nodes.get(windowEdge!.target_node_id)?.observations, + ).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + loader_authority: "candidate", + }), + evidence: expect.objectContaining({ confidence: "low" }), + }), + ); + } + }, +); + cliTest( "shares module IDs across chunks only within the same runtime", async ({ cli }) => { diff --git a/tests/boundary/cli/javascriptRendererEndpoints.test.ts b/tests/boundary/cli/javascriptRendererEndpoints.test.ts index 6441944e74..53f25b8be4 100644 --- a/tests/boundary/cli/javascriptRendererEndpoints.test.ts +++ b/tests/boundary/cli/javascriptRendererEndpoints.test.ts @@ -39,7 +39,7 @@ cliTest( }); expect(endpoint?.observations[0]?.evidence.limitations).toEqual( expect.arrayContaining([ - expect.stringContaining("receiver does not establish a network call"), + expect.stringContaining("identity is unproven"), ]), ); expect( diff --git a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts index 0d546fd6c8..dea99967f5 100644 --- a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts +++ b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts @@ -177,7 +177,7 @@ describe("native addon association", () => { const root = await createTestTempDirectory("rea-electron-addon-kind-"); await Promise.all([ writeFile( - join(root, "main.js"), + join(root, "main.cjs"), 'const addon = require("./addon.node");\nvoid addon;\n', ), writeFile( @@ -690,7 +690,7 @@ const computedMemberFixtureDirectory = async (): Promise => { await mkdir(join(root, "native")); await Promise.all([ writeFile( - join(root, "main.js"), + join(root, "main.cjs"), String.raw` const member = "read"; const selected = require("./native/addon.node")[member]; diff --git a/tests/boundary/filesystem/referenceSourceLoaderConstructors.test.ts b/tests/boundary/filesystem/referenceSourceLoaderConstructors.test.ts new file mode 100644 index 0000000000..b086193997 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceLoaderConstructors.test.ts @@ -0,0 +1,161 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; + +it("retains constructor loader requests without inventing host authority", async () => { + const root = await createTestTempDirectory("rea-reference-loader-new-"); + await Promise.all([ + writeFile( + join(root, ".MJS"), + 'import {join} from "node:path"; require("./dep.cjs");', + ), + writeFile( + join(root, ".TS"), + 'import {join} from "node:path"; require("./dep.cjs");', + ), + writeFile( + join(root, "child-call.cjs"), + 'require.call.extra=1; require.call(null,"./dep.cjs");', + ), + writeFile( + join(root, "child-apply.cjs"), + 'require.apply.extra=1; require.apply(null,["./dep.cjs"]);', + ), + writeFile( + join(root, "child-resolve.cjs"), + 'require.resolve.extra=1; require.resolve("./dep.cjs");', + ), + writeFile(join(root, "direct.cjs"), 'new require("./dep.cjs");'), + writeFile(join(root, "wrapped.cts"), 'new (require as any)("./dep.cjs");'), + writeFile(join(root, "wrapped-call.cts"), '(require as any)("./dep.cjs");'), + writeFile( + join(root, "alias-direct.cjs"), + 'const box={load:require};box.load("./dep.cjs");', + ), + writeFile( + join(root, "alias-direct-replaced.cjs"), + 'const box={load:require};box.load=()=>0;box.load("./dep.cjs");', + ), + writeFile( + join(root, "alias-direct-deferred.cjs"), + 'const box={load:require};function replace(){box.load=()=>0} replace();box.load("./dep.cjs");', + ), + writeFile( + join(root, "nested-call.cjs"), + 'const box={load:require};box.load.call(null,"./dep.cjs");', + ), + writeFile( + join(root, "nested-mutated.cjs"), + 'const box={load:require};box.load.call=()=>0;box.load.call(null,"./dep.cjs");', + ), + writeFile( + join(root, "nested-parent.cjs"), + 'const box={load:require};box.load=()=>0;box.load.call(null,"./dep.cjs");', + ), + writeFile( + join(root, "nested-child.cjs"), + 'const box={load:require};box.load.call.extra=1;box.load.call(null,"./dep.cjs");', + ), + writeFile( + join(root, "nested-apply.cjs"), + 'const box={load:require};box.load.apply(null,["./dep.cjs"]);', + ), + writeFile( + join(root, "nested-unknown.MJS"), + 'const box={load:require};box.load.call(null,"./dep.cjs");', + ), + writeFile( + join(root, "wrapped-receiver.TS"), + '(require as any).resolve("./dep.cjs");', + ), + writeFile( + join(root, "wrapped-resolve.cts"), + '(require as any).resolve("./dep.cjs");', + ), + writeFile( + join(root, "wrapped-mutated.cts"), + 'require.call=()=>0; (require.call as any)(null,"./dep.cjs");', + ), + writeFile( + join(root, "alias.MJS"), + 'const load=require; new load("./dep.cjs");', + ), + writeFile( + join(root, "shadow.MJS"), + 'const require=function() {}; new require("./dep.cjs");', + ), + writeFile(join(root, "dep.cjs"), "module.exports = {};"), + ]); + const result = await importReferenceSource({ + root, + caller: "reference-loader-constructor-test", + policy: { secretPatterns: [] }, + }); + if (!result.ok) throw result.error; + expect(result.value.parse_failures).toEqual([]); + expect(result.value.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ".MJS", + language: "JavaScript", + classifications: ["source"], + }), + expect.objectContaining({ + path: ".TS", + language: "TypeScript", + classifications: ["source"], + }), + ]), + ); + for (const from_path of [ + "direct.cjs", + "child-call.cjs", + "child-apply.cjs", + "child-resolve.cjs", + "wrapped.cts", + "wrapped-call.cts", + "nested-call.cjs", + "alias-direct.cjs", + "nested-child.cjs", + "nested-apply.cjs", + "wrapped-resolve.cts", + ]) { + expect(result.value.relationships).toContainEqual({ + from_path, + to: "dep.cjs", + kind: "requires", + resolution: "internal", + parse_state: "parsed", + }); + } + for (const from_path of [ + "nested-unknown.MJS", + "alias.MJS", + ".MJS", + ".TS", + "wrapped-receiver.TS", + "alias-direct-replaced.cjs", + "alias-direct-deferred.cjs", + ]) { + expect(result.value.relationships).toContainEqual({ + from_path, + to: "./dep.cjs", + kind: "requires", + resolution: "unknown", + parse_state: "partial", + }); + } + expect( + result.value.relationships.some(({ from_path }) => + [ + "shadow.MJS", + "wrapped-mutated.cts", + "nested-mutated.cjs", + "nested-parent.cjs", + ].includes(from_path), + ), + ).toBe(false); +}); diff --git a/tests/fixtures/javascriptArtifactTypeScriptDialect.ts b/tests/fixtures/javascriptArtifactTypeScriptDialect.ts index f5dd81dcf7..07a257b05a 100644 --- a/tests/fixtures/javascriptArtifactTypeScriptDialect.ts +++ b/tests/fixtures/javascriptArtifactTypeScriptDialect.ts @@ -32,6 +32,11 @@ export const typeScriptDialectCases = [ kind: "require", source: 'const value = require("./dep.js") as unknown;', }, + { + path: "wrapped-as.cts", + kind: "require", + source: 'const value = require("./dep.js") as unknown;', + }, { path: "generic.ts", kind: "static-import", @@ -158,7 +163,14 @@ export const expectTypeScriptDialectReference = ( ); expect(edges).toHaveLength(1); expect(edges[0]).toMatchObject({ - properties: { resolved_path: "dep.js" }, + properties: + fixture.kind === "require" && !fixture.path.endsWith(".cts") + ? { + resolved_path: null, + loader_authority: "candidate", + resolution_mode: null, + } + : { resolved_path: "dep.js" }, evidence: { artifact: { available: true,