diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.sourceMaps.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.sourceMaps.test.ts index cbd9c745cf..c60358dfb6 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.sourceMaps.test.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.sourceMaps.test.ts @@ -28,6 +28,7 @@ it("retains batched TraceMap-resolved paths and null source positions", () => { text: { included: true, value: sourceMapText }, }, ], + unavailable_relevant_files: [], containers: [], text_bytes_read: Buffer.byteLength(sourceMapText), invalid_utf8_files: 0, diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 54e652e75a..352838948a 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -178,6 +178,7 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => { ); const unknownGap = (context.fileSet.source_selection?.unselected_files ?? 0) > 0 || + context.fileSet.unavailable_relevant_files.length > 0 || context.analysis.files.some( ({ analysis_failure, application_projection_failure }) => analysis_failure !== undefined || diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index a6c5b3941a..e96344f073 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -1,7 +1,10 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; import { htmlInlineScriptLimitation } from "./JavaScriptArtifactAnalysis.js"; -import { completeApplicationCoverage } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; +import { + completeApplicationCoverage, + partialApplicationCoverage, +} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { freezeJsonSnapshot } from "../../domain/immutableJson.js"; @@ -300,8 +303,133 @@ export const addJavaScriptArtifactFiles = ( }, }); } + addUnavailableAsarEntries(context); }; +const addUnavailableAsarEntries = ( + context: JavaScriptArtifactGraphContext, +): void => { + for (const file of context.fileSet.unavailable_relevant_files) { + if (!isAsarContainer(context, file.container_sha256)) continue; + const limitations = [ + ...(file.limitations ?? []), + `The inventoried ${file.kind ?? "relevant"} entry has unavailable bytes; no content artifact identity can be established.`, + ]; + const entry = context.accumulator.addNode({ + kind: "asar-entry", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: file.container_sha256, + path: file.path, + }, + observations: [ + { + label: file.path, + properties: { + entry_sha256: file.sha256, + bytes: file.bytes, + inventory_artifact_id: file.inventory_artifact_id, + ...(file.inventory_occurrence_id === undefined + ? {} + : { inventory_occurrence_id: file.inventory_occurrence_id }), + hash_status: file.hash_status, + artifact_kind: file.artifact_kind, + ...(file.kind === undefined ? {} : { kind: file.kind }), + content_available: false, + }, + evidence: artifactObservationEvidence({ + sha256: file.container_sha256, + path: file.path, + operation: "inventory-asar-unavailable-entry", + coverage: partialApplicationCoverage([], null), + limitations, + }), + }, + ], + }); + const parent = + context.containerNodes.get(file.container_sha256) ?? context.root; + context.accumulator.addEdge({ + source_node_id: parent.node_id, + target_node_id: entry.node_id, + relation: "contains", + properties: { + path: file.path, + content_available: false, + hash_status: file.hash_status, + }, + evidence: artifactObservationEvidence({ + sha256: file.container_sha256, + path: file.path, + operation: "inventory-asar-unavailable-entry", + coverage: partialApplicationCoverage([], null), + limitations, + }), + }); + if (file.kind === "native-addon" || file.artifact_kind === "native-addon") { + const addon = context.accumulator.addNode({ + kind: "native-addon", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: file.container_sha256, + path: file.path, + }, + observations: [ + { + label: file.path, + properties: { + availability: "unavailable", + ...(file.hash_status === undefined + ? {} + : { hash_status: file.hash_status }), + ...(file.artifact_kind === undefined + ? {} + : { artifact_kind: file.artifact_kind }), + bytes: file.bytes, + inventory_artifact_id: file.inventory_artifact_id, + ...(file.inventory_occurrence_id === undefined + ? {} + : { inventory_occurrence_id: file.inventory_occurrence_id }), + }, + evidence: artifactObservationEvidence({ + sha256: file.container_sha256, + path: file.path, + operation: "classify-unavailable-native-addon", + coverage: partialApplicationCoverage([], null), + limitations, + }), + }, + ], + }); + context.accumulator.addEdge({ + source_node_id: entry.node_id, + target_node_id: addon.node_id, + relation: "contains", + properties: { + classification: "native-addon", + content_available: false, + }, + evidence: artifactObservationEvidence({ + sha256: file.container_sha256, + path: file.path, + operation: "classify-unavailable-native-addon", + coverage: partialApplicationCoverage([], null), + limitations, + }), + }); + } + } +}; + +const isAsarContainer = ( + context: JavaScriptArtifactGraphContext, + containerSha256: string, +): boolean => + context.snapshot.manifest.root_format === "asar" || + containerSha256 !== context.snapshot.manifest.root_sha256; + const sourceFileRange = ( file: JavaScriptArtifactFile, ): JavaScriptSourceRange => { @@ -464,10 +592,7 @@ const createAsarEntry = ( context: JavaScriptArtifactGraphContext, file: JavaScriptArtifactFile, ): ApplicationNode | undefined => { - const isAsar = - context.snapshot.manifest.root_format === "asar" || - file.container_sha256 !== context.snapshot.manifest.root_sha256; - if (!isAsar) return undefined; + if (!isAsarContainer(context, file.container_sha256)) return undefined; return context.accumulator.addNode({ kind: "asar-entry", identity: { diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index c82c589bc2..2a2ebef9d5 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -68,6 +68,56 @@ describe("artifact-local graph identity", () => { }); describe("loader-owned path lookup", () => { + it("applies URL whitespace handling only to ESM lookup and retains declarations", () => { + const tab = "\t"; + const files = fileMap([ + file("app/entry.mjs", "root"), + file("app/feature.js", "root"), + file(`app/tab${tab}name.js`, "root"), + ]); + for (const whitespace of ["\t", "\n", "\r"]) { + const raw = `./fea${whitespace}ture.js`; + expect( + resolve({ + declaredPath: raw, + sourcePath: "app/entry.mjs", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ declared_path: raw, resolved_path: "app/feature.js" }); + } + expect( + resolve({ + declaredPath: `./tab${tab}name.js`, + sourcePath: "app/entry.mjs", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ resolved_path: null }); + expect( + resolve({ + declaredPath: `./tab${tab}name.js`, + sourcePath: "app/entry.cjs", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ resolved_path: "app/tab\tname.js" }); + expect( + resolve({ + declaredPath: "./tab%09name.js", + sourcePath: "app/entry.mjs", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ resolved_path: "app/tab\tname.js" }); + }); +}); + +describe("loader-specific module suffix lookup", () => { it("keeps relative ESM imports exact", () => { const files = fileMap([ file("app/entry.mjs", "root"), @@ -186,6 +236,42 @@ describe("loader-owned path lookup", () => { }); }); +describe("unavailable inventoried native add-ons", () => { + it("retains an inventoried native add-on whose bytes are unavailable", () => { + const unavailable = { + path: "app/addon.node", + container_sha256: file("app/entry.cjs", "root").container_sha256, + sha256: null, + bytes: 8192, + inventory_artifact_id: null, + inventory_occurrence_id: "missing-addon-occurrence", + kind: "native-addon" as const, + hash_status: "unavailable" as const, + limitations: ["unpacked companion is missing"], + }; + const result = resolve({ + declaredPath: "./addon.node", + sourcePath: "app/entry.cjs", + context: "module-specifier", + moduleKind: "require", + files: fileMap([file("app/entry.cjs", "root")]), + unanalyzedFiles: { + get: (path) => (path === unavailable.path ? unavailable : undefined), + hasDirectory: () => false, + }, + }); + expect(result).toMatchObject({ + declared_path: "./addon.node", + resolution_status: "unavailable", + candidate_path: "app/addon.node", + limitations: [ + "unpacked companion is missing", + expect.stringContaining("inventoried native-addon file"), + ], + }); + }); +}); + describe("bare package root file lookup", () => { it("uses loader-specific bare package roots when only a sibling .js file exists", () => { const absentDirectory = fileMap([ diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index f884237a8d..b056ce959f 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -499,6 +499,7 @@ const contextualCandidate = ( } const hasUrlPathSemantics = context === "module-specifier" && input.moduleKind === "import"; + if (hasUrlPathSemantics) declared = htmlUrlText(declared); const rejected = rejectDeclaration( declared, declaresLiteralPercent(input) || hasUrlPathSemantics, @@ -1400,7 +1401,9 @@ const findFileCandidate = ( candidatePath: path, limitations: [ ...(unanalyzed.limitations ?? []), - `${path} exists in the inventoried artifact container (sha256 ${unanalyzed.sha256 ?? "unknown"}, ${unanalyzed.bytes === null ? "unknown size" : `${String(unanalyzed.bytes)} bytes`}) but is not a JavaScript, JSON, HTML, source map, or native add-on file; its contents were not analyzed.`, + unanalyzed.kind === undefined + ? `${path} exists in the inventoried artifact container (sha256 ${unanalyzed.sha256 ?? "unknown"}, ${unanalyzed.bytes === null ? "unknown size" : `${String(unanalyzed.bytes)} bytes`}) but is not a JavaScript, JSON, HTML, source map, or native add-on file; its contents were not analyzed.` + : `${path} is an inventoried ${unanalyzed.kind} file, but its bytes are unavailable (hash status ${unanalyzed.hash_status ?? "unknown"}); resolution cannot be confirmed.`, ], }; return null; diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 0fb95d6a5b..39eb165c77 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -261,7 +261,8 @@ export const reconstructJavaScriptArtifact = async ( ), ), statistics: { - relevant_files: files.files.length, + relevant_files: + files.files.length + files.unavailable_relevant_files.length, nested_asar_containers: files.containers.length, text_bytes_read: files.text_bytes_read, invalid_utf8_files: files.invalid_utf8_files, diff --git a/src/artifacts/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts index d4845feacb..d1149836b3 100644 --- a/src/artifacts/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -140,13 +140,21 @@ export const readJavaScriptArtifactFiles = async ( if (leftPriority !== rightPriority) return leftPriority - rightPriority; return compareUnicodeCodePoints(left.path, right.path); }); + const unanalyzedFiles = createUnanalyzedArtifactLookup( + snapshot, + context.containers, + ); + const unavailableRelevantFiles = [...inventory.unavailableFiles] + .sort(compareUnicodeCodePoints) + .flatMap((path) => { + const unavailable = unanalyzedFiles.get(path); + return unavailable === undefined ? [] : [unavailable]; + }); assertExpectedFilesWereVisited(expected, files); return { files, - unanalyzed_files: createUnanalyzedArtifactLookup( - snapshot, - context.containers, - ), + unanalyzed_files: unanalyzedFiles, + unavailable_relevant_files: unavailableRelevantFiles, containers: context.containers.sort((left, right) => compareUnicodeCodePoints(left.path, right.path), ), @@ -488,6 +496,10 @@ const createUnanalyzedArtifactLookup = ( const isOpaque = (occurrence: ArtifactOccurrence): boolean => occurrence.entry_kind === "file" && relevantKind(occurrence.logical_path) === undefined; + const isUnavailableRelevant = (occurrence: ArtifactOccurrence): boolean => + occurrence.entry_kind === "file" && + occurrence.artifact_id === null && + relevantKind(occurrence.logical_path) !== undefined; return { get(path) { if (requested.has(path)) return requested.get(path); @@ -495,8 +507,10 @@ const createUnanalyzedArtifactLookup = ( snapshot.occurrences[ lowerBound(snapshot.occurrences, path, (entry) => entry.logical_path) ]; + const kind = relevantKind(path); const id = - occurrence?.logical_path === path && isOpaque(occurrence) + occurrence?.logical_path === path && + (isOpaque(occurrence) || isUnavailableRelevant(occurrence)) ? occurrence.artifact_id : undefined; const candidate = @@ -507,7 +521,8 @@ const createUnanalyzedArtifactLookup = ( ]; const node = candidate?.artifact_id === id ? candidate : undefined; const file = - occurrence?.logical_path !== path || !isOpaque(occurrence) + occurrence?.logical_path !== path || + (!isOpaque(occurrence) && !isUnavailableRelevant(occurrence)) ? undefined : { path, @@ -515,6 +530,10 @@ const createUnanalyzedArtifactLookup = ( sha256: node?.sha256 ?? null, bytes: node?.size ?? occurrence.declared_size, inventory_artifact_id: occurrence.artifact_id, + inventory_occurrence_id: occurrence.occurrence_id, + ...(kind === undefined ? {} : { kind }), + artifact_kind: occurrence.artifact_kind, + hash_status: occurrence.hash_status, limitations: occurrence.limitations, }; requested.set(path, file); @@ -539,7 +558,7 @@ const createUnanalyzedArtifactLookup = ( if (entry === undefined || !entry.logical_path.startsWith(prefix)) break; if ( - isOpaque(entry) && + (isOpaque(entry) || isUnavailableRelevant(entry)) && (containerSha256 === undefined || containerFor(entry.logical_path) === containerSha256) ) { diff --git a/src/domain/javascript/javascriptArtifactFiles.ts b/src/domain/javascript/javascriptArtifactFiles.ts index e5c7e05fa2..c7013bc331 100644 --- a/src/domain/javascript/javascriptArtifactFiles.ts +++ b/src/domain/javascript/javascriptArtifactFiles.ts @@ -1,4 +1,5 @@ import type { JavaScriptSourceSelection } from "./javascriptSourceSelection.js"; +import type { ArtifactOccurrence } from "../artifactGraph.js"; /** Whether an inventoried path has a source extension read by the analyzer. */ export const isJavaScriptSourcePath = (path: string): boolean => @@ -34,9 +35,8 @@ export interface JavaScriptArtifactFile { } /** - * One inventoried regular file outside the JavaScript-relevant kinds. Its presence - * was inventoried, including unavailable or unhashed members. The source projection - * does not read its bytes; references remain present-but-unanalyzed rather than absent. + * One inventoried regular file not backed by analyzed source bytes. Its inventory + * occurrence remains available even when content is opaque or unavailable. */ export interface JavaScriptUnanalyzedArtifactFile { readonly path: string; @@ -44,6 +44,14 @@ export interface JavaScriptUnanalyzedArtifactFile { readonly sha256: string | null; readonly bytes: number | null; readonly inventory_artifact_id: string | null; + readonly inventory_occurrence_id?: string; + readonly kind?: JavaScriptArtifactFileKind; + readonly artifact_kind?: ArtifactOccurrence["artifact_kind"]; + readonly hash_status?: + | "verified" + | "mismatched" + | "not-hashed" + | "unavailable"; readonly limitations?: readonly string[]; } @@ -66,6 +74,8 @@ export interface JavaScriptArtifactFileSet { readonly files: readonly JavaScriptArtifactFile[]; /** Borrowed inventory lookup for regular files the source projection does not read. */ readonly unanalyzed_files?: JavaScriptUnanalyzedArtifactLookup; + /** Relevant inventoried files whose bytes were unavailable. */ + readonly unavailable_relevant_files: readonly JavaScriptUnanalyzedArtifactFile[]; readonly containers: readonly JavaScriptArtifactContainer[]; readonly text_bytes_read: number; readonly invalid_utf8_files: number; diff --git a/src/domain/javascript/javascriptModuleResolutionMode.ts b/src/domain/javascript/javascriptModuleResolutionMode.ts index d14b91bd96..ee2f6edaaf 100644 --- a/src/domain/javascript/javascriptModuleResolutionMode.ts +++ b/src/domain/javascript/javascriptModuleResolutionMode.ts @@ -16,7 +16,7 @@ export const moduleResolutionModeForJavaScriptReference = ( if (referenceKind === "require") return "require"; if (referenceKind === "dynamic-import") return "import"; const extension = posix.extname(sourcePath).toLowerCase(); - if (extension === ".cts") return "require"; + 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/javascriptSemanticAnalysis.require_links.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts index a0d93c3c31..831c9ebc41 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { + analyzeJavaScriptSemantics, + analyzeParsedJavaScriptSemantics, +} from "./javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { moduleResolutionModeForJavaScriptReference } from "./javascriptModuleResolutionMode.js"; import { origin, topLevelBinding, @@ -56,6 +61,74 @@ describe("TypeScript import-equals links", () => { }); }); +describe("CommonJS wrapper parameter redeclarations", () => { + it("retains ambient exports and require through uninitialized top-level vars", () => { + const path = "app/main.cjs"; + const parsed = parseJavaScriptSource( + 'var module, exports, require; module.exports = require("./dependency.cjs");', + path, + ); + expect(parsed).not.toBeNull(); + const ir = analyzeParsedJavaScriptSemantics( + parsed!, + moduleResolutionModeForJavaScriptReference(path, "static-import"), + ); + expect(ir.moduleLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "commonjs-export", + specifier: "./dependency.cjs", + localName: null, + exportedName: "default", + }), + ]), + ); + }); + + it("retains the Node loader call on an uninitialized wrapper var assignment RHS", () => { + const path = "app/main.cjs"; + const parsed = parseJavaScriptSource( + 'var require; require = require("./bootstrap.cjs");', + path, + ); + expect(parsed).not.toBeNull(); + const ir = analyzeParsedJavaScriptSemantics( + parsed!, + moduleResolutionModeForJavaScriptReference(path, "static-import"), + ); + expect(ir.moduleLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "require", + specifier: "./bootstrap.cjs", + }), + ]), + ); + }); + + it.each([ + 'var require = local; require("./dependency.cjs");', + 'var require; require = replacement; require("./dependency.cjs");', + "var module; module = replacement; module.exports = 1;", + "var exports; exports = replacement; exports.value = 1;", + 'let require; require("./dependency.cjs");', + 'function nested() { var require; require("./dependency.cjs"); }', + ])("keeps non-wrapper declarations as shadows: %s", (source) => { + const path = "app/main.cjs"; + const parsed = parseJavaScriptSource(source, path); + expect(parsed).not.toBeNull(); + const ir = analyzeParsedJavaScriptSemantics( + parsed!, + moduleResolutionModeForJavaScriptReference(path, "static-import"), + ); + expect( + ir.moduleLinks.filter( + ({ kind }) => kind === "require" || kind === "commonjs-export", + ), + ).toEqual([]); + }); +}); + describe("TypeScript type-only module links", () => { it("retains declarations without runtime bindings or callable claims", () => { const ir = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 1612a797c6..d903020207 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -5,6 +5,7 @@ import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js" import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; import { resolveSemanticBindingState, + isUnmodifiedCommonJsWrapperBinding, semanticResolutionBlocked, semanticStableInitializer, type JavaScriptSemanticAnalysisState, @@ -46,13 +47,7 @@ export const semanticRequireLoaderAt = ( return binding.initializers.length === 0 ? "factory-loader" : "unknown"; if (isWrapperRequireInitializerCall(binding, node, state)) return "commonjs-loader"; - const scope = state.scopesById.get(binding.scopeId); - if ( - scope?.kind === "program" && - binding.declarationKinds.size > 0 && - [...binding.declarationKinds].every((kind) => kind === "var") && - binding.initializers.length === 0 - ) + if (isUnmodifiedCommonJsWrapperBinding(node.callee, "require", state)) return "commonjs-loader"; if (isSemanticCreateRequireBinding(binding, state)) return "create-require"; const initializer = semanticStableInitializer(binding, state)?.node; @@ -214,10 +209,9 @@ const writesModuleRequire = ( const value = unwrapJavaScriptExpression(node).node; if (!t.isIdentifier(value)) return false; if (value.name === "module") - return ( - resolveSemanticBindingState(state, value, "module") === undefined && - !semanticResolutionBlocked(state, value, "module") - ); + return resolveSemanticBindingState(state, value, "module") === undefined + ? !semanticResolutionBlocked(state, value, "module") + : isUnmodifiedCommonJsWrapperBinding(value, "module", state); const binding = resolveSemanticBindingState(state, value, value.name); if (binding === undefined || visited.has(binding.bindingId)) return false; const next = new Set(visited).add(binding.bindingId); @@ -453,10 +447,17 @@ export const semanticRequireOrigin = ( ) ) return undefined; + const requireBinding = resolveSemanticBindingState( + state, + current.callee, + "require", + ); if ( - resolveSemanticBindingState(state, current.callee, "require") !== - undefined || - semanticResolutionBlocked(state, current.callee, "require") || + (requireBinding !== undefined && + !isUnmodifiedCommonJsWrapperBinding(current.callee, "require", state) && + !isWrapperRequireInitializerCall(requireBinding, current, state)) || + (requireBinding === undefined && + semanticResolutionBlocked(state, current.callee, "require")) || (state.ambientBindingWrites.get("require") ?? []).some( (write) => !semanticTopLevelEffectFollowsCapture( diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index ca9a745239..2d38c45964 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -253,5 +253,28 @@ export const isUnshadowedGlobal = ( name: string, ): boolean => t.isIdentifier(node, { name }) && - resolveSemanticBindingState(state, node, name) === undefined && - !semanticResolutionBlocked(state, node, name); + (resolveSemanticBindingState(state, node, name) === undefined + ? !semanticResolutionBlocked(state, node, name) + : isUnmodifiedCommonJsWrapperBinding(node, name, state)); + +/** A program-level `var` redeclaration still denotes its untouched CJS wrapper parameter. */ +export const isUnmodifiedCommonJsWrapperBinding = ( + node: t.Node, + name: string, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if ( + state.staticImportResolutionMode !== "require" || + !["module", "exports", "require"].includes(name) || + !t.isIdentifier(node, { name }) + ) + return false; + const binding = resolveSemanticBindingState(state, node, name); + if (binding === undefined || binding.initializers.length !== 0) return false; + const scope = state.scopesById.get(binding.scopeId); + return ( + scope?.kind === "program" && + binding.declarationKinds.size > 0 && + [...binding.declarationKinds].every((kind) => kind === "var") + ); +}; diff --git a/tests/boundary/cli/javascriptSelfImportRelationships.test.ts b/tests/boundary/cli/javascriptSelfImportRelationships.test.ts index 44b741d4cd..27c4a9ed4d 100644 --- a/tests/boundary/cli/javascriptSelfImportRelationships.test.ts +++ b/tests/boundary/cli/javascriptSelfImportRelationships.test.ts @@ -1,8 +1,12 @@ +import { rm } from "node:fs/promises"; +import { join } from "node:path"; + import { expect } from "vitest"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js"; import { cliTest } from "../../support/cli/cliFixture.js"; import { findExportNode, @@ -11,6 +15,75 @@ import { writeFixtureFiles, } from "../../support/javascriptApplicationFixture.js"; +cliTest( + "resolves ESM specifiers containing whitespace using URL text semantics", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-esm-whitespace-path-"); + const rawSpecifier = "./feature\tname.js"; + const encodedSpecifier = "./feature%09name.js"; + await writeFixtureFiles(root, { + "main.mjs": `import ${JSON.stringify(rawSpecifier)};\nimport ${JSON.stringify(encodedSpecifier)};\n`, + "featurename.js": "export const plain = 1;\n", + "feature\tname.js": "export const value = 1;\n", + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect( + findGraphEdge( + result.graph, + findSourceModule(result.graph, "main.mjs"), + findSourceModule(result.graph, "featurename.js"), + "imports", + )?.properties, + ).toMatchObject({ + specifier: rawSpecifier, + resolved_path: "featurename.js", + }); + expect( + findGraphEdge( + result.graph, + findSourceModule(result.graph, "main.mjs"), + findSourceModule(result.graph, "feature\tname.js"), + "imports", + )?.properties, + ).toMatchObject({ + specifier: encodedSpecifier, + resolved_path: "feature\tname.js", + }); + }, +); + +cliTest( + "retains an inventoried native addon when its ASAR companion is missing", + async ({ cli }) => { + const { archive, addon } = await createStrippedAsarAddon(); + await rm(join(`${archive}.unpacked`, addon)); + const output = await cli.run({ + arguments: ["analyze-javascript-application", archive, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.graph.coverage.status).toBe("partial"); + expect( + result.graph.nodes.some( + (node) => + node.kind === "native-addon" && + node.observations.some( + ({ label, properties }) => + label === addon && properties.availability === "unavailable", + ), + ), + ).toBe(true); + }, +); + cliTest( "resolves emitted module imports without substituting Electron runtime paths", async ({ cli }) => { diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts index c35b8aef11..47c6a93c6a 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -502,10 +502,72 @@ it("keeps ASAR analysis usable when unpacked native companion bytes are absent", "ASAR unpacked companion bytes were unavailable; no content hash or child artifact was produced.", ]), }); + if (missingNative === undefined) + throw new Error("Inventory omitted the unavailable native companion"); - await reconstructAsarArchive(archive, [ + const result = await reconstructAsarArchive(archive, [ expect.objectContaining({ kind: "javascript-asset" }), ]); + const missingEntry = result.graph.nodes.find( + ({ kind, observations }) => + kind === "asar-entry" && + observations.some(({ label }) => label === missingNative.logical_path), + ); + expect(missingEntry).toMatchObject({ + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: snapshot.manifest.root_sha256, + path: missingNative.logical_path, + }, + observations: [ + expect.objectContaining({ + label: missingNative.logical_path, + properties: expect.objectContaining({ + entry_sha256: null, + bytes: missingNative.declared_size, + inventory_artifact_id: null, + inventory_occurrence_id: missingNative.occurrence_id, + hash_status: "unavailable", + artifact_kind: "native-addon", + kind: "native-addon", + content_available: false, + }), + evidence: expect.objectContaining({ + limitations: expect.arrayContaining([ + "ASAR unpacked companion bytes were unavailable; no content hash or child artifact was produced.", + "The inventoried native-addon entry has unavailable bytes; no content artifact identity can be established.", + ]), + }), + }), + ], + }); + const unavailableAddon = result.graph.nodes.find( + ({ kind, observations }) => + kind === "native-addon" && + observations.some(({ label }) => label === missingNative.logical_path), + ); + expect(unavailableAddon).toMatchObject({ + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: snapshot.manifest.root_sha256, + path: missingNative.logical_path, + }, + observations: [ + expect.objectContaining({ + label: missingNative.logical_path, + properties: expect.objectContaining({ + availability: "unavailable", + hash_status: "unavailable", + artifact_kind: "native-addon", + bytes: missingNative.declared_size, + inventory_artifact_id: null, + inventory_occurrence_id: missingNative.occurrence_id, + }), + }), + ], + }); }); it("recurses into filesystem-backed ASAR containers without losing container-relative paths", async () => { diff --git a/tests/boundary/mcp/javascriptSourceSelection.test.ts b/tests/boundary/mcp/javascriptSourceSelection.test.ts index fe1420cb14..609cbbe607 100644 --- a/tests/boundary/mcp/javascriptSourceSelection.test.ts +++ b/tests/boundary/mcp/javascriptSourceSelection.test.ts @@ -10,7 +10,9 @@ import { z } from "zod"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { writeJavaScriptSourceSelectionFixture } from "../../fixtures/javascriptSourceSelection.js"; +import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; const createSelectionClient = () => { const client = new Client({ @@ -30,6 +32,79 @@ const createSelectionClient = () => { return { client, transport }; }; +it("resolves ESM whitespace specifiers through the public application tool", async () => { + const root = await createTestTempDirectory("rea-esm-whitespace-mcp-"); + const rawSpecifier = "./feature\tname.js"; + const encodedSpecifier = "./feature%09name.js"; + await writeFixtureFiles(root, { + "main.mjs": `import ${JSON.stringify(rawSpecifier)};\nimport ${JSON.stringify(encodedSpecifier)};\n`, + "featurename.js": "export const plain = 1;\n", + "feature\tname.js": "export const value = 1;\n", + }); + const { client, transport } = createSelectionClient(); + try { + await client.connect(transport); + const output = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root }, + }); + expect(output.isError, JSON.stringify(output.content)).not.toBe(true); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.structuredContent).normalized_result, + ); + expect(result.graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + specifier: rawSpecifier, + resolved_path: "featurename.js", + }), + }), + expect.objectContaining({ + properties: expect.objectContaining({ + specifier: encodedSpecifier, + resolved_path: "feature\tname.js", + }), + }), + ]), + ); + } finally { + await client.close(); + await transport.close(); + } +}, 60_000); + +it("retains missing ASAR native-addon inventory through the public application tool", async () => { + const { archive, addon } = await createStrippedAsarAddon(); + await rm(join(`${archive}.unpacked`, addon)); + const { client, transport } = createSelectionClient(); + try { + await client.connect(transport); + const output = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: archive }, + }); + expect(output.isError, JSON.stringify(output.content)).not.toBe(true); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.structuredContent).normalized_result, + ); + expect(result.graph.coverage.status).toBe("partial"); + expect( + result.graph.nodes.some( + (node) => + node.kind === "native-addon" && + node.observations.some( + ({ label, properties }) => + label === addon && properties.availability === "unavailable", + ), + ), + ).toBe(true); + } finally { + await client.close(); + await transport.close(); + } +}, 60_000); + it("advertises exact source selection and retains its scope through summary, Evidence retrieval and invalid-input recovery", async () => { const root = await createTestTempDirectory("rea-js-selected-mcp-"); await writeJavaScriptSourceSelectionFixture(root);