From 37e92e430be5e27013072d8584df02ead9565e35 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:39:17 +0800 Subject: [PATCH] refactor(javascript): centralize package and JSON document facts --- .../javascript/ElectronBoundaryAnalysis.ts | 5 + .../javascript/ElectronBoundaryGraphNative.ts | 20 ++- .../ElectronBoundaryGraphWindows.ts | 2 + ...ScriptArtifactAnalysis.packageMain.test.ts | 44 +++++ .../javascript/JavaScriptArtifactAnalysis.ts | 28 +-- .../JavaScriptArtifactAnalysisTypes.ts | 3 + .../JavaScriptArtifactGraphContext.ts | 18 -- .../JavaScriptArtifactGraphDocuments.ts | 1 + .../JavaScriptArtifactGraphFindings.ts | 4 + .../JavaScriptArtifactGraphStructure.test.ts | 2 + .../JavaScriptArtifactGraphStructure.ts | 2 + ...PathResolution.syntheticRootEscape.test.ts | 4 + .../JavaScriptArtifactPathResolution.test.ts | 6 +- .../JavaScriptArtifactPathResolution.ts | 27 ++- .../JavaScriptHtmlBaseResolution.test.ts | 3 + .../JavaScriptJsonDocuments.test.ts | 111 ++++++++++++ .../javascript/JavaScriptJsonDocuments.ts | 156 ++++++++++++++++ .../javascript/JavaScriptJsonModules.ts | 22 +-- .../JavaScriptModuleRelationships.ts | 26 +-- .../JavaScriptSemanticGraphResources.test.ts | 2 + .../javascriptApplicationService.test.ts | 167 ++++++++++++++++++ .../javascriptModuleSpecifierPaths.test.ts | 18 +- .../javascriptNonStringPackageMain.test.ts | 1 + .../packageConditionalExports.test.ts | 12 +- .../javascript/packageImportAliases.test.ts | 8 +- .../javascript/packageMainHostPaths.test.ts | 4 +- tests/fixtures/javascriptSemanticGraph.ts | 2 + .../javascriptJsonDocumentFailures.test.ts | 149 ++++++++++++++++ .../javascriptArtifactPathResolution.ts | 14 ++ 29 files changed, 748 insertions(+), 113 deletions(-) create mode 100644 src/application/javascript/JavaScriptJsonDocuments.test.ts create mode 100644 src/application/javascript/JavaScriptJsonDocuments.ts create mode 100644 tests/process-global/javascriptJsonDocumentFailures.test.ts create mode 100644 tests/support/javascriptArtifactPathResolution.ts diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index d1cf4b42ad..55b964cbb6 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -1,3 +1,4 @@ +import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; @@ -164,6 +165,7 @@ export const summarizeElectronBoundaries = ( specifier: finding.module_path, context: finding.module_resolution_context, files, + jsonDocuments: analysis.jsonDocuments, directoryPaths, runtimePathKind: "node-entrypoint", }), @@ -175,6 +177,7 @@ export const summarizeElectronBoundaries = ( specifier: finding.specifier, context: "module-specifier", files, + jsonDocuments: analysis.jsonDocuments, directoryPaths, kind: "native-addon", moduleKind: finding.module_kind, @@ -209,6 +212,7 @@ interface ResolveToFileInput { readonly specifier: string; readonly context: "module-specifier" | "filesystem-expression"; readonly files: ReadonlyMap; + readonly jsonDocuments: JavaScriptJsonDocuments; readonly directoryPaths?: ReadonlySet | undefined; readonly kind?: JavaScriptArtifactFile["kind"]; readonly moduleKind?: "import" | "require"; @@ -221,6 +225,7 @@ const resolvesToFile = (input: ResolveToFileInput): boolean => { sourcePath: input.file.path, context: input.context, files: input.files, + jsonDocuments: input.jsonDocuments, ...(input.directoryPaths === undefined ? {} : { directoryPaths: input.directoryPaths }), diff --git a/src/application/javascript/ElectronBoundaryGraphNative.ts b/src/application/javascript/ElectronBoundaryGraphNative.ts index 446ab32338..e15e15d19f 100644 --- a/src/application/javascript/ElectronBoundaryGraphNative.ts +++ b/src/application/javascript/ElectronBoundaryGraphNative.ts @@ -1,9 +1,9 @@ +import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js"; import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { javascriptAnalysisCoverage, - resolveArtifactPath, type JavaScriptArtifactGraphContext, type JavaScriptArtifactGraphCoverage, } from "./JavaScriptArtifactGraphContext.js"; @@ -45,13 +45,17 @@ const addNativeBinding = (input: NativeBindingInput): void => { value.module_runtime, ); if (source === undefined) return; - const resolved = resolveArtifactPath( - value.specifier, - file.path, - context.filesByPath, - value.module_kind, - context.directoryPaths, - ); + const resolved = resolveArtifactPathByContext({ + declaredPath: value.specifier, + sourcePath: file.path, + context: "module-specifier", + files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, + directoryPaths: context.directoryPaths, + ...(value.module_kind === undefined + ? {} + : { moduleKind: value.module_kind }), + }).resolved_path; const addon = resolved === null ? undefined : context.fileNodes.get(resolved); const binding = context.accumulator.addNode({ kind: "native-export", diff --git a/src/application/javascript/ElectronBoundaryGraphWindows.ts b/src/application/javascript/ElectronBoundaryGraphWindows.ts index 7b4c23af2e..4ed0f48ca0 100644 --- a/src/application/javascript/ElectronBoundaryGraphWindows.ts +++ b/src/application/javascript/ElectronBoundaryGraphWindows.ts @@ -155,6 +155,7 @@ const addWindowPreload = ( sourcePath: input.file.path, context: resolutionContext, files: input.context.filesByPath, + jsonDocuments: input.context.analysis.jsonDocuments, directoryPaths: input.context.directoryPaths, unanalyzedFiles: input.context.unanalyzedFiles, runtimePathKind: "exact-file", @@ -257,6 +258,7 @@ const addUtilityProcess = ( sourcePath: file.path, context: value.module_resolution_context, files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, runtimePathKind: "node-entrypoint", diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts index e06f2e0cc7..01f60fa94e 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts @@ -34,6 +34,50 @@ it.each([null, false, 123, {}, []].map((main) => ({ main })))( }, ); +it("keeps valid JSON numeric overflow invalid as a package main", () => { + expect(packageObservation('{"main":1e400}')).toMatchObject({ + status: "included", + main: null, + main_status: "invalid", + main_entrypoint: "", + }); +}); + +it("treats a valid numeric JSON root as a package metadata shape failure", () => { + expect(packageObservation("1e400")).toMatchObject({ + status: "invalid", + main: null, + main_status: "unavailable", + main_entrypoint: null, + }); +}); + +it("retains valid numeric overflow as an ordinary JSON module", () => { + const text = "1e400"; + const analysis = analyzeJavaScriptArtifactFiles({ + files: [ + { + path: "settings.json", + container_sha256: "a".repeat(64), + sha256: "b".repeat(64), + bytes: Buffer.byteLength(text), + inventory_artifact_id: "numeric-json-fixture", + kind: "json", + unpacked: false, + text: { included: true, value: text }, + }, + ], + unavailable_relevant_files: [], + containers: [], + text_bytes_read: Buffer.byteLength(text), + invalid_utf8_files: 0, + }); + + expect(analysis.json_modules).toMatchObject([ + { status: "included", top_level_keys: [] }, + ]); +}); + it.each([ ["{}", "default", ""], ['{"main":""}', "default", ""], diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 7b3f1d9fd1..38836f40fd 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -40,6 +40,10 @@ import type { } from "./JavaScriptArtifactAnalysisTypes.js"; import { javascriptArtifactAnalysisScopes } from "./JavaScriptArtifactAnalysisTypes.js"; import { analyzeJavaScriptJsonModule } from "./JavaScriptJsonModules.js"; +import { + JavaScriptJsonDocuments, + type JavaScriptJsonDocumentFacts, +} from "./JavaScriptJsonDocuments.js"; import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"; import { htmlArtifactReferences } from "../../domain/javascript/htmlArtifactReferences.js"; import type { JavaScriptSourceAnalysisPort } from "../../domain/javascript/javascriptSourceAnalysis.js"; @@ -57,6 +61,7 @@ import { interface MutableArtifactAnalysis< SemanticIr extends JavaScriptModuleSemanticIr, > { + readonly jsonDocuments: JavaScriptJsonDocuments; readonly files: AnalyzedJavaScriptArtifactFile[]; readonly packages: JavaScriptPackageObservation[]; readonly jsonModules: JavaScriptJsonModuleObservation[]; @@ -359,6 +364,7 @@ const finalizeArtifactAnalysis = < state: MutableArtifactAnalysis, ): JavaScriptArtifactAnalysis => { return { + jsonDocuments: state.jsonDocuments, files: state.files, packages: state.packages, json_modules: state.jsonModules, @@ -482,6 +488,7 @@ const addSourceMap = ( const emptyArtifactAnalysis = < SemanticIr extends JavaScriptModuleSemanticIr, >(): MutableArtifactAnalysis => ({ + jsonDocuments: new JavaScriptJsonDocuments(), files: [], packages: [], jsonModules: [], @@ -498,27 +505,25 @@ const addStructuredObservations = ( file: JavaScriptArtifactFile, state: MutableArtifactAnalysis, ): void => { - if (file.kind === "package-json") state.packages.push(parsePackage(file)); + if (file.kind === "package-json") + state.packages.push(parsePackage(file, state.jsonDocuments.read(file))); if (file.kind !== "json") return; - const json = analyzeJavaScriptJsonModule(file); + const json = analyzeJavaScriptJsonModule(file, state.jsonDocuments); state.jsonModules.push(json); if (json.status === "invalid") state.parseFailures += 1; }; const parsePackage = ( file: JavaScriptArtifactFile, + document: JavaScriptJsonDocumentFacts, ): JavaScriptPackageObservation => { - if (!file.text.included) + if (document.status === "unavailable") return unavailablePackage(file, "Package metadata text was unavailable."); - let value: unknown; - try { - value = JSON.parse(file.text.value); - } catch (cause: unknown) { - void cause; + if (document.status === "invalid") return invalidPackage(file, "package.json is not valid JSON."); - } - if (!isRecord(value)) + if (document.packageFields === null) return invalidPackage(file, "package.json root is not an object."); + const value = document.packageFields; return { path: file.path, sha256: file.sha256, @@ -780,6 +785,3 @@ const optionalString = (value: unknown): string | null => const sha256 = (value: string): string => createHash("sha256").update(value).digest("hex"); - -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index 8b420340a1..a395380b6f 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -14,6 +14,7 @@ import type { JavaScriptArtifactFileKind, } from "../../domain/javascript/javascriptArtifactFiles.js"; import type { JsonValue } from "../../domain/jsonValue.js"; +import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; interface JavaScriptStructuredObservation { readonly path: string; @@ -200,6 +201,8 @@ export const isSkippedJavaScriptAnalysis = ( export interface JavaScriptArtifactAnalysis< SemanticIr extends JavaScriptModuleSemanticIr = JavaScriptSemanticIr, > { + /** Internal source-facts owner shared with graph path resolution. */ + readonly jsonDocuments: JavaScriptJsonDocuments; readonly files: readonly AnalyzedJavaScriptArtifactFile[]; readonly packages: readonly JavaScriptPackageObservation[]; readonly json_modules: readonly JavaScriptJsonModuleObservation[]; diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index 7dc8b4b1bb..b724d94747 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -33,7 +33,6 @@ import { } from "./JavaScriptArtifactGraphEvidence.js"; import { artifactPathResolutionProperties, - resolveArtifactPathByContext, type ArtifactPathResolution, } from "./JavaScriptArtifactPathResolution.js"; @@ -513,23 +512,6 @@ export const chunkLookupKey = ( chunkKey: string, ): string => compositeKey([path, runtime, chunkKey]); -/** Resolve a relative static specifier without escaping the artifact root. */ -export const resolveArtifactPath = ( - specifier: string, - sourcePath: string, - files: ReadonlyMap, - moduleKind?: "import" | "require" | "compiler-dependent", - directoryPaths?: ReadonlySet, -): string | null => - resolveArtifactPathByContext({ - declaredPath: specifier, - sourcePath, - context: "module-specifier", - files, - ...(directoryPaths === undefined ? {} : { directoryPaths }), - ...(moduleKind === undefined ? {} : { moduleKind }), - }).resolved_path; - /** Locate the recovered module that owns a static finding. */ export const sourceNodeFor = ( context: JavaScriptArtifactGraphContext, diff --git a/src/application/javascript/JavaScriptArtifactGraphDocuments.ts b/src/application/javascript/JavaScriptArtifactGraphDocuments.ts index 55daf3cbde..7f8e9e7edf 100644 --- a/src/application/javascript/JavaScriptArtifactGraphDocuments.ts +++ b/src/application/javascript/JavaScriptArtifactGraphDocuments.ts @@ -58,6 +58,7 @@ export const addJavaScriptHtmlRoles = ( sourcePath: html.path, context: "html-reference", files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, htmlBaseHref: script.base_href, diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 8ba22c030c..db67c40cbf 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -488,6 +488,7 @@ const addDiscoveredRole = ( sourcePath: input.file.path, context: input.value.resolution_context, files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, ...(input.value.role === "preload" @@ -556,6 +557,7 @@ const addSourceMapEdge = ( sourcePath: input.file.path, context: "url-reference", files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, }); @@ -660,6 +662,7 @@ const resolveReference = ( sourcePath, context: "url-reference", files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, }) @@ -668,6 +671,7 @@ const resolveReference = ( sourcePath, context: "module-specifier", files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, ...("require" === reference.kind || diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts index 224d3631e0..add737714f 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts @@ -17,6 +17,7 @@ import { } from "./JavaScriptArtifactGraphStructure.js"; import { addJavaScriptStaticFindingsSteps } from "./JavaScriptArtifactGraphFindings.js"; import { applicationContainerIdentity } from "./JavaScriptArtifactGraphContext.js"; +import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; const SHA256 = "a".repeat(64); const REASON = @@ -51,6 +52,7 @@ const skippedEntry = ( const analysisFor = ( entries: readonly AnalyzedJavaScriptArtifactFile[], ): JavaScriptModuleArtifactAnalysis => ({ + jsonDocuments: new JavaScriptJsonDocuments(), files: entries, packages: [], json_modules: [], diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index c74ec0618e..68c2e18442 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -562,6 +562,7 @@ export const addJavaScriptPackageNodes = ( context: "package-entrypoint", runtimePathKind: "node-entrypoint", files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, }), @@ -689,6 +690,7 @@ const addPackageRole = ( ? { runtimePathKind: "node-entrypoint" as const } : {}), files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, }); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.syntheticRootEscape.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.syntheticRootEscape.test.ts index ec58e8b1aa..63828ee99c 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.syntheticRootEscape.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.syntheticRootEscape.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; +import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; const files = new Map( @@ -20,6 +21,7 @@ const files = new Map( ], ), ); +const jsonDocuments = new JavaScriptJsonDocuments(); it.each([ "../../rea-artifact/shared/window.cjs", @@ -38,6 +40,7 @@ it.each([ context: "module-specifier", moduleKind: "import", files, + jsonDocuments, }), ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); }, @@ -61,6 +64,7 @@ it.each([ context: "module-specifier", moduleKind: "import", files, + jsonDocuments, }), ).toMatchObject({ resolution_status: "resolved", resolved_path: target }); }, diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 31489dc28e..22adee7d58 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -4,6 +4,7 @@ import { resolveArtifactPathByContext, type ResolveArtifactPathInput, } from "./JavaScriptArtifactPathResolution.js"; +import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js"; import { applicationNodeIdentitySchema } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; @@ -3565,8 +3566,9 @@ describe("module-backed package main host paths", () => { ); }); -const resolve = (input: ResolveArtifactPathInput) => - resolveArtifactPathByContext(input); +const jsonDocuments = new JavaScriptJsonDocuments(); +const resolve = (input: Omit) => + resolveArtifactPathByContext({ ...input, jsonDocuments }); const fileMap = (files: readonly JavaScriptArtifactFile[]) => new Map(files.map((value) => [value.path, value])); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 1642369779..b8d1ac4253 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -1,3 +1,4 @@ +import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import { isBuiltin } from "node:module"; import { posix, win32 } from "node:path"; import { ELECTRON_MODULE } from "../../domain/javascript/javascriptElectronMemberWrites.js"; @@ -79,6 +80,7 @@ export type ArtifactPathResolution = ArtifactPathResolutionBase & /** Values needed to resolve a declaration without filesystem access. */ export interface ResolveArtifactPathInput { + readonly jsonDocuments: JavaScriptJsonDocuments; readonly declaredPath: string; readonly sourcePath: string; readonly context: ArtifactPathResolutionContext; @@ -1449,25 +1451,17 @@ const readPackageMetadata = ( kind: "unavailable", limitation: `Directory package metadata ${path} was inventoried but its text is unavailable: ${file.text.reason}.`, }; - let value: unknown; - try { - value = JSON.parse(file.text.value); - } catch (cause: unknown) { - void cause; + const document = input.jsonDocuments.read(file); + if (document.status !== "included" || document.packageFields === null) return { kind: "unavailable", limitation: `Directory package metadata ${path} is not valid package JSON.`, }; - } - if (typeof value !== "object" || value === null || Array.isArray(value)) - return { - kind: "unavailable", - limitation: `Directory package metadata ${path} is not valid package JSON.`, - }; - const name = Reflect.get(value, "name"); - const exports = Reflect.get(value, "exports"); - const imports = Reflect.get(value, "imports"); - const main = Reflect.get(value, "main"); + const value = document.packageFields; + const name = value.name; + const exports = value.exports; + const imports = value.imports; + const main = value.main; return { kind: "available", path, @@ -1488,7 +1482,7 @@ const readPackageMetadata = ( input.moduleKind !== undefined && typeof main !== "string" ? undefined : main, - module: Reflect.get(value, "module"), + module: value.module, }; }; @@ -1565,6 +1559,7 @@ const resolveDirectory = ( sourcePath: metadata.path, context: "package-entrypoint", files: input.files, + jsonDocuments: input.jsonDocuments, ...(input.directoryPaths === undefined ? {} : { directoryPaths: input.directoryPaths }), diff --git a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts index 245a63d2bd..621e04117e 100644 --- a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts +++ b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; +import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; const paths = [ "renderer/index.html", @@ -24,6 +25,7 @@ const files = new Map( }, ]), ); +const jsonDocuments = new JavaScriptJsonDocuments(); const resolve = (declaredPath: string, htmlBaseHref: string) => resolveArtifactPathByContext({ declaredPath, @@ -31,6 +33,7 @@ const resolve = (declaredPath: string, htmlBaseHref: string) => sourcePath: "renderer/index.html", context: "html-reference", files, + jsonDocuments, }); const documentUrl = "https://artifact.test/renderer/index.html"; describe("HTML base href URL components", () => { diff --git a/src/application/javascript/JavaScriptJsonDocuments.test.ts b/src/application/javascript/JavaScriptJsonDocuments.test.ts new file mode 100644 index 0000000000..27cd10e655 --- /dev/null +++ b/src/application/javascript/JavaScriptJsonDocuments.test.ts @@ -0,0 +1,111 @@ +import { expect, it } from "vitest"; + +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; + +type MutableArtifactFile = { + -readonly [Key in keyof JavaScriptArtifactFile]: JavaScriptArtifactFile[Key]; +}; + +const mutableFile = (): MutableArtifactFile => ({ + path: "package.json", + container_sha256: "container-a", + sha256: "file-a", + bytes: 13, + inventory_artifact_id: "inventory-a", + kind: "package-json", + unpacked: false, + text: { included: true, value: '{"main":"a.cjs"}' }, +}); + +it("reparses changed text and availability on a reused file object", () => { + const documents = new JavaScriptJsonDocuments(); + const file = mutableFile(); + const original = documents.read(file); + expect(original).toMatchObject({ + status: "included", + packageFields: { main: "a.cjs" }, + }); + + file.text = { included: true, value: "{" }; + const invalid = documents.read(file); + expect(invalid.status).toBe("invalid"); + + file.text = { included: false, reason: "invalid-utf8" }; + const unavailable = documents.read(file); + expect(unavailable).toMatchObject({ + status: "unavailable", + reason: "invalid-utf8", + }); + + file.text = { included: true, value: '{"main":"b.cjs"}' }; + const updated = documents.read(file); + expect(updated).toMatchObject({ + status: "included", + packageFields: { main: "b.cjs" }, + }); + expect(original).toMatchObject({ + status: "included", + source: { path: "package.json" }, + packageFields: { main: "a.cjs" }, + }); + expect(invalid.status).toBe("invalid"); + expect(unavailable.status).toBe("unavailable"); +}); + +it("rebuilds source provenance when path and content digests change", () => { + const documents = new JavaScriptJsonDocuments(); + const file = mutableFile(); + const original = documents.read(file); + + file.path = "nested/package.json"; + const moved = documents.read(file); + expect(moved.source.path).toBe("nested/package.json"); + + file.container_sha256 = "container-b"; + const recontained = documents.read(file); + expect(recontained.source).toMatchObject({ + path: "nested/package.json", + container_sha256: "container-b", + sha256: "file-a", + }); + + file.sha256 = "file-b"; + const rehashed = documents.read(file); + expect(rehashed.source).toMatchObject({ + path: "nested/package.json", + container_sha256: "container-b", + sha256: "file-b", + }); + expect(original.source).toEqual({ + path: "package.json", + container_sha256: "container-a", + sha256: "file-a", + }); + expect(original).toMatchObject({ + status: "included", + packageFields: { main: "a.cjs" }, + }); +}); + +it("retains only syntax and keys for ordinary JSON, with package fields scoped to its format", () => { + const documents = new JavaScriptJsonDocuments(); + const file = mutableFile(); + file.kind = "json"; + file.text = { + included: true, + value: '{"exports":{"nested":["large config"]},"main":"entry.cjs"}', + }; + const ordinary = documents.read(file); + expect(ordinary).toMatchObject({ + status: "included", + topLevelKeys: ["exports", "main"], + packageFields: null, + }); + file.kind = "package-json"; + expect(documents.read(file)).toMatchObject({ + status: "included", + packageFields: { main: "entry.cjs", exports: { nested: ["large config"] } }, + }); + expect(ordinary.packageFields).toBeNull(); +}); diff --git a/src/application/javascript/JavaScriptJsonDocuments.ts b/src/application/javascript/JavaScriptJsonDocuments.ts new file mode 100644 index 0000000000..9b79023b82 --- /dev/null +++ b/src/application/javascript/JavaScriptJsonDocuments.ts @@ -0,0 +1,156 @@ +import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; + +interface JavaScriptJsonDocumentSource { + readonly path: string; + readonly container_sha256: string; + readonly sha256: string; +} + +interface JavaScriptJsonDocumentFactBase { + readonly source: JavaScriptJsonDocumentSource; + readonly topLevelKeys: readonly string[]; +} + +export type JavaScriptJsonDocumentFacts = + | (JavaScriptJsonDocumentFactBase & { + readonly status: "included"; + readonly packageFields: Readonly< + Partial> + > | null; + }) + | (JavaScriptJsonDocumentFactBase & { + readonly status: "invalid"; + readonly packageFields: null; + readonly syntaxError: SyntaxError; + }) + | (JavaScriptJsonDocumentFactBase & { + readonly status: "unavailable"; + readonly packageFields: null; + readonly reason: + | "not-applicable" + | "invalid-utf8" + | "outside-analysis-scope"; + }); + +interface SourceSnapshot { + readonly source: JavaScriptJsonDocumentSource; + readonly kind: JavaScriptArtifactFile["kind"]; + readonly text: JavaScriptArtifactFile["text"]; +} + +interface CachedFacts { + readonly snapshot: SourceSnapshot; + readonly facts: JavaScriptJsonDocumentFacts; +} + +const PACKAGE_PROPERTIES = [ + "name", + "version", + "main", + "module", + "renderer", + "browser", + "exports", + "imports", +] as const; + +/** + * Lazy JSON facts shared by one analysis and its graph projection. Entries follow + * the admitted source files' lifetime; changed identity, format or text replaces them. + */ +export class JavaScriptJsonDocuments { + readonly #byFile = new Map(); + + read(file: JavaScriptArtifactFile): JavaScriptJsonDocumentFacts { + const cached = this.#byFile.get(file); + if (cached !== undefined && sameSnapshot(cached.snapshot, file)) + return cached.facts; + + const snapshot = sourceSnapshot(file); + const facts = parseDocument(snapshot); + this.#byFile.set(file, { snapshot, facts }); + return facts; + } +} + +const sourceSnapshot = (file: JavaScriptArtifactFile): SourceSnapshot => ({ + source: Object.freeze({ + path: file.path, + container_sha256: file.container_sha256, + sha256: file.sha256, + }), + kind: file.kind, + text: { ...file.text }, +}); + +const sameSnapshot = ( + left: SourceSnapshot, + right: JavaScriptArtifactFile, +): boolean => + left.source.path === right.path && + left.source.container_sha256 === right.container_sha256 && + left.source.sha256 === right.sha256 && + left.kind === right.kind && + left.text.included === right.text.included && + (left.text.included + ? right.text.included && left.text.value === right.text.value + : !right.text.included && left.text.reason === right.text.reason); + +const parseDocument = ( + snapshot: SourceSnapshot, +): JavaScriptJsonDocumentFacts => { + if (!snapshot.text.included) + return Object.freeze({ + source: snapshot.source, + status: "unavailable", + packageFields: null, + topLevelKeys: Object.freeze([]), + reason: snapshot.text.reason, + }); + + let root: unknown; + try { + root = JSON.parse(snapshot.text.value); + } catch (cause: unknown) { + if (!(cause instanceof SyntaxError)) throw cause; + return Object.freeze({ + source: snapshot.source, + status: "invalid", + packageFields: null, + topLevelKeys: Object.freeze([]), + syntaxError: cause, + }); + } + + if (typeof root !== "object" || root === null || Array.isArray(root)) + return Object.freeze({ + source: snapshot.source, + status: "included", + packageFields: null, + topLevelKeys: Object.freeze([]), + }); + + const record = root as Record; + const topLevelKeys = Object.freeze( + Object.keys(record).sort(compareUnicodeCodePoints), + ); + const packageFields = + snapshot.kind === "package-json" + ? Object.freeze( + Object.fromEntries( + PACKAGE_PROPERTIES.flatMap((key) => + Object.hasOwn(record, key) + ? [[key, Reflect.get(record, key)]] + : [], + ), + ), + ) + : null; + return Object.freeze({ + source: snapshot.source, + status: "included", + packageFields, + topLevelKeys, + }); +}; diff --git a/src/application/javascript/JavaScriptJsonModules.ts b/src/application/javascript/JavaScriptJsonModules.ts index ac18ea3670..ba0be0d44d 100644 --- a/src/application/javascript/JavaScriptJsonModules.ts +++ b/src/application/javascript/JavaScriptJsonModules.ts @@ -1,25 +1,23 @@ -import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import type { JavaScriptJsonModuleObservation } from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; /** Parse one approved JSON module without evaluating JavaScript or resolving imports. */ export const analyzeJavaScriptJsonModule = ( file: JavaScriptArtifactFile, + documents: JavaScriptJsonDocuments, ): JavaScriptJsonModuleObservation => { - if (!file.text.included) + const document = documents.read(file); + if (document.status === "unavailable") return { path: file.path, sha256: file.sha256, status: "unavailable", top_level_keys: [], omitted_top_level_keys: null, - limitation: `JSON module text was unavailable: ${file.text.reason}.`, + limitation: `JSON module text was unavailable: ${document.reason}.`, }; - let value: unknown; - try { - value = JSON.parse(file.text.value); - } catch (cause: unknown) { - void cause; + if (document.status === "invalid") return { path: file.path, sha256: file.sha256, @@ -28,17 +26,11 @@ export const analyzeJavaScriptJsonModule = ( omitted_top_level_keys: 0, limitation: "JSON module is not valid JSON.", }; - } - const keys = - typeof value === "object" && value !== null && !Array.isArray(value) - ? Object.keys(value).sort(compareUnicodeCodePoints) - : []; - const retained = keys; return { path: file.path, sha256: file.sha256, status: "included", - top_level_keys: retained, + top_level_keys: document.topLevelKeys, omitted_top_level_keys: 0, limitation: null, }; diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index 990c1cc893..6676f6323c 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -55,6 +55,7 @@ interface RelationshipInput { type ResolvedModuleTarget = { readonly node: ApplicationNode; readonly limitations: readonly string[]; + readonly jsonStatus: "included" | "invalid" | "unavailable" | null; readonly resolution?: ArtifactPathResolution; } & ( | { @@ -289,6 +290,7 @@ const resolveModuleTarget = ( sourcePath: input.file.path, context: "module-specifier", files: input.context.filesByPath, + jsonDocuments: input.context.analysis.jsonDocuments, directoryPaths: input.context.directoryPaths, unanalyzedFiles: input.context.unanalyzedFiles, moduleKind: moduleResolutionModeForJavaScriptReference( @@ -304,6 +306,7 @@ const resolveModuleTarget = ( node: unresolvedModuleNode(input, specifier, resolution), path: null, file: null, + jsonStatus: null, status: resolution.resolution_status, limitations: resolution.limitations, resolution, @@ -321,6 +324,10 @@ const resolveModuleTarget = ( node, path, file, + jsonStatus: + file.kind === "json" || file.kind === "package-json" + ? input.context.analysis.jsonDocuments.read(file).status + : null, status: "resolved", limitations: resolution.limitations, resolution, @@ -404,10 +411,7 @@ const relationshipProperties = ( resolution_status: target?.status ?? null, ...artifactPathResolutionProperties(target?.resolution), target_file_kind: target?.file?.kind ?? null, - target_json_status: - target?.file?.kind === "json" || target?.file?.kind === "package-json" - ? jsonStatus(target.file) - : null, + target_json_status: target?.jsonStatus ?? null, }); const relationshipEvidence = ( @@ -467,20 +471,6 @@ const moduleFormat = ( : "unknown"; }; -const jsonStatus = ( - file: JavaScriptArtifactFile, -): "included" | "invalid" | "unavailable" => { - if (!file.text.included) return "unavailable"; - try { - JSON.parse(file.text.value); - return "included"; - } catch (cause: unknown) { - // Invalid JSON is classified without further detail. - void cause; - return "invalid"; - } -}; - const isExportLink = (link: JavaScriptSemanticModuleLink): boolean => link.kind === "export" || link.kind === "re-export" || diff --git a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts index 29e2ad64d9..69d73b5a44 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts @@ -5,6 +5,7 @@ import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.j import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; import { resolveJavaScriptSemanticEvidence } from "../../domain/javascript/javascriptSemanticGraph.js"; +import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; const SHA256 = "a".repeat(64); const GRAPH_ID = `jag_${"b".repeat(64)}`; @@ -347,6 +348,7 @@ it("uses unavailable evidence when no source produced semantic IR", () => { }); const emptyAnalysis = (): JavaScriptArtifactAnalysis => ({ + jsonDocuments: new JavaScriptJsonDocuments(), files: [], packages: [], json_modules: [], diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts index 97fc62b313..7bec8204a8 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts @@ -20,6 +20,8 @@ import { createJavaScriptArtifactReader } from "../../../../src/artifacts/javasc import { createJavaScriptSourceAnalysis } from "../../../../src/artifacts/javascript/JavaScriptSourceAnalysis.js"; import { analyzeAndAdoptJavaScriptArtifactFiles } from "../../../../src/application/javascript/JavaScriptArtifactAnalysis.js"; import { createJavaScriptSemanticGraphProjection } from "../../../../src/application/javascript/JavaScriptSemanticGraphBuilder.js"; +import { buildJavaScriptArtifactGraph } from "../../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js"; +import { writeFixtureFiles } from "../../../support/javascriptApplicationFixture.js"; describe("JavaScript source selection cancellation boundary", () => { it("keeps unselected files outside scope when the real worker cancels after the last selected file", async () => { @@ -86,6 +88,171 @@ describe("JavaScript source selection cancellation boundary", () => { }); }); +it("resolves lazy package and JSON facts without completing their skipped observations", async () => { + const root = await createTestTempDirectory("rea-js-lazy-json-facts-"); + await writeFixtureFiles(root, { + "app/first.cjs": 'require("late-pkg"); require("./config.json");\n', + "app/second.cjs": "module.exports = 2;\n", + "app/config.json": "true", + "app/node_modules/late-pkg/package.json": JSON.stringify({ + main: "index.cjs", + }), + "app/node_modules/late-pkg/index.cjs": "module.exports = 1;\n", + "app/vendor/package.json": JSON.stringify({ main: "vendor.cjs" }), + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + const controller = new AbortController(); + const reason = { request: "stop before structured files" }; + const sourcePaths = [ + "app/first.cjs", + "app/second.cjs", + "app/node_modules/late-pkg/package.json", + "app/node_modules/late-pkg/index.cjs", + "app/config.json", + ]; + try { + const files = await readJavaScriptArtifactFiles( + reader, + snapshot, + controller.signal, + sourcePaths, + ); + const worker = await createJavaScriptSourceAnalysis( + { heapMb: 512, timeoutMs: 30_000 }, + 1, + ); + let outcome: Awaited< + ReturnType + >; + let completedWorkers = 0; + const projection = createJavaScriptSemanticGraphProjection(); + try { + const realWorker = worker.analyze.bind(worker); + outcome = await analyzeAndAdoptJavaScriptArtifactFiles( + files, + { + analyze: async (...args) => { + const result = await realWorker(...args); + completedWorkers += 1; + if (completedWorkers === 2) controller.abort(reason); + return result; + }, + }, + projection, + async () => {}, + controller.signal, + ); + } finally { + await worker.close(); + } + + expect(completedWorkers).toBe(2); + expect(outcome.interruption).toBeInstanceOf(AnalysisCancelledError); + expect(outcome.interruption?.cause).toBe(reason); + const analysisByPath = new Map( + outcome.analysis.files.map((entry) => [entry.file.path, entry]), + ); + for (const path of sourcePaths.slice(0, 2)) + expect(analysisByPath.get(path)?.javascript).toMatchObject({ + parse_status: "complete", + }); + for (const path of [ + "app/node_modules/late-pkg/package.json", + "app/config.json", + ]) + expect(analysisByPath.get(path)?.analysis_failure?.status).toBe( + "not-started", + ); + expect( + outcome.analysis.packages.some( + ({ path }) => path === "app/node_modules/late-pkg/package.json", + ), + ).toBe(false); + expect( + outcome.analysis.json_modules.some( + ({ path }) => path === "app/config.json", + ), + ).toBe(false); + + const graph = buildJavaScriptArtifactGraph( + snapshot, + files, + outcome.analysis, + ); + expect(graph.coverage.status).toBe("partial"); + expect( + graph.edges.find( + ({ relation, properties }) => + relation === "imports" && properties.specifier === "late-pkg", + )?.properties, + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/node_modules/late-pkg/index.cjs", + }); + expect( + graph.edges.find( + ({ relation, properties }) => + relation === "imports" && properties.specifier === "./config.json", + )?.properties, + ).toMatchObject({ + target_file_kind: "json", + target_json_status: "included", + }); + for (const [path, operation] of [ + ["app/node_modules/late-pkg/package.json", "parse-package-json"], + ["app/config.json", "parse-json-module"], + ] as const) + expectSkippedStructuredSource(graph, path, operation); + const excluded = analysisByPath.get("app/vendor/package.json"); + expect(excluded?.file.text).toMatchObject({ + included: false, + reason: "outside-analysis-scope", + }); + expect(excluded?.analysis_failure).toBeUndefined(); + expect( + unknownScopeObservation(graph, "app/vendor/package.json"), + ).toMatchObject({ + properties: { operation: "select-javascript-sources" }, + evidence: { confidence: "unknown", state: "unavailable" }, + }); + } finally { + await reader.close(); + } +}); + +const unknownScopeObservation = ( + graph: ReturnType, + path: string, +) => + graph.nodes + .filter(({ kind }) => kind === "unknown") + .flatMap(({ observations }) => observations) + .find(({ properties }) => properties.path === path); + +const expectSkippedStructuredSource = ( + graph: ReturnType, + path: string, + operation: string, +): void => { + expect( + graph.nodes.some( + ({ kind, observations }) => + kind === "package" && + observations.some(({ properties }) => properties.path === path), + ), + ).toBe(false); + expect( + graph.nodes + .filter(({ kind }) => kind === "artifact") + .flatMap(({ observations }) => observations) + .find(({ properties }) => properties.path === path), + ).toMatchObject({ properties: { json_parse_status: null } }); + expect(unknownScopeObservation(graph, path)).toMatchObject({ + properties: { operation, analysis_status: "not-started" }, + }); +}; + describe("Selected JavaScript source failure diagnostics", () => { it("analyzes selected files in first-request order and reports only selected progress", async () => { const root = await createTestTempDirectory("rea-js-selected-priority-"); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts index d8a86f0da6..c19baab615 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts @@ -6,7 +6,7 @@ import { promisify } from "node:util"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import type { JavaScriptArtifactFile } from "../../../../src/domain/javascript/javascriptArtifactFiles.js"; -import { resolveArtifactPathByContext } from "../../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; +import { resolveStandaloneArtifactPath } from "../../../support/javascriptArtifactPathResolution.js"; import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; @@ -99,7 +99,7 @@ describe("module specifier punctuation", () => { (punctuation) => { const literal = `app/dep.cjs${punctuation}literal.cjs`; const stripped = "app/dep.cjs"; - const commonJs = resolveArtifactPathByContext({ + const commonJs = resolveStandaloneArtifactPath({ declaredPath: `./dep.cjs${punctuation}literal.cjs`, sourcePath: "app/main.js", context: "module-specifier", @@ -111,7 +111,7 @@ describe("module specifier punctuation", () => { resolved_path: literal, }); - const esm = resolveArtifactPathByContext({ + const esm = resolveStandaloneArtifactPath({ declaredPath: `./dep.cjs${punctuation}literal.cjs`, sourcePath: "app/main.js", context: "module-specifier", @@ -123,7 +123,7 @@ describe("module specifier punctuation", () => { resolved_path: stripped, }); - const noStrippedTarget = resolveArtifactPathByContext({ + const noStrippedTarget = resolveStandaloneArtifactPath({ declaredPath: `./dep.cjs${punctuation}literal.cjs`, sourcePath: "app/main.js", context: "module-specifier", @@ -141,7 +141,7 @@ describe("module specifier punctuation", () => { "keeps HTML URL suffix handling for %s", (punctuation) => { expect( - resolveArtifactPathByContext({ + resolveStandaloneArtifactPath({ declaredPath: `./dep.cjs${punctuation}literal.cjs`, sourcePath: "app/main.js", context: "html-reference", @@ -216,7 +216,7 @@ describe("ESM module URL decoding", () => { it("preserves CommonJS literal percent names", () => { expect( - resolveArtifactPathByContext({ + resolveStandaloneArtifactPath({ declaredPath: "./space%20name.cjs", sourcePath: "app/main.js", context: "module-specifier", @@ -233,7 +233,7 @@ describe("ESM module URL decoding", () => { "rejects malformed or NUL URL %s", (declaredPath) => { expect( - resolveArtifactPathByContext({ + resolveStandaloneArtifactPath({ declaredPath, sourcePath: "app/main.js", context: "module-specifier", @@ -365,7 +365,7 @@ describe("package exports URL path rejections", () => { }, }); expect( - resolveArtifactPathByContext({ + resolveStandaloneArtifactPath({ declaredPath: "fixture", sourcePath: "app/main.js", context: "module-specifier", @@ -399,7 +399,7 @@ describe("package exports URL path rejections", () => { container_sha256: "c".repeat(64), }); const resolveTarget = () => - resolveArtifactPathByContext({ + resolveStandaloneArtifactPath({ declaredPath: "fixture", sourcePath: "app/main.js", context: "module-specifier", diff --git a/tests/boundary/filesystem/javascript-applications/javascriptNonStringPackageMain.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptNonStringPackageMain.test.ts index cbf0fb1f21..e3d6dec1f2 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptNonStringPackageMain.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptNonStringPackageMain.test.ts @@ -117,6 +117,7 @@ it.each( context: "module-specifier", moduleKind: "require", files: new Map(files.files.map((file) => [file.path, file])), + jsonDocuments: analysis.jsonDocuments, }), ).toMatchObject({ resolution_status: valid ? "resolved" : "unavailable", diff --git a/tests/boundary/javascript/packageConditionalExports.test.ts b/tests/boundary/javascript/packageConditionalExports.test.ts index e7512b847c..4d54ef1a7a 100644 --- a/tests/boundary/javascript/packageConditionalExports.test.ts +++ b/tests/boundary/javascript/packageConditionalExports.test.ts @@ -4,12 +4,10 @@ import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { - resolveArtifactPathByContext, - type ArtifactPathResolution, -} from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; +import type { ArtifactPathResolution } from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { resolveStandaloneArtifactPath } from "../../support/javascriptArtifactPathResolution.js"; const execute = promisify(execFile); const TARGETS = [ "default.cjs", @@ -138,7 +136,7 @@ const compareWithNode = async ( node: node.target, node_error: node.error_code, files, - outcome: resolveArtifactPathByContext({ + outcome: resolveStandaloneArtifactPath({ declaredPath: specifier, sourcePath, context: "module-specifier", @@ -338,7 +336,7 @@ describe("package exports before sibling files", () => { }); // The Node control has one runtime directory. A different inventoried // container without its loader mapping cannot prove the metadata absent. - const outcome = resolveArtifactPathByContext({ + const outcome = resolveStandaloneArtifactPath({ declaredPath: "example", sourcePath: "main.js", context: "module-specifier", @@ -618,7 +616,7 @@ describe("package self-reference scope boundaries", () => { resolved_path: "node_modules/example/specific.cjs", resolution_status: "resolved", }); - const outcome = resolveArtifactPathByContext({ + const outcome = resolveStandaloneArtifactPath({ declaredPath: "example", sourcePath: "node_modules/example/main.js", context: "module-specifier", diff --git a/tests/boundary/javascript/packageImportAliases.test.ts b/tests/boundary/javascript/packageImportAliases.test.ts index 3743c4f3ec..c4978e69f8 100644 --- a/tests/boundary/javascript/packageImportAliases.test.ts +++ b/tests/boundary/javascript/packageImportAliases.test.ts @@ -7,13 +7,11 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { - resolveArtifactPathByContext, - type ArtifactPathResolution, -} from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; +import type { ArtifactPathResolution } from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { writeFixtureFiles } from "../../support/workspace/writeFixtureFiles.js"; +import { resolveStandaloneArtifactPath } from "../../support/javascriptArtifactPathResolution.js"; const execute = promisify(execFile); const nodeOutcomeSchema = z.strictObject({ @@ -101,7 +99,7 @@ const resolveAlias = ( sourcePath: string, moduleKind: "import" | "require", ): ArtifactPathResolution => - resolveArtifactPathByContext({ + resolveStandaloneArtifactPath({ declaredPath, sourcePath, context: "module-specifier", diff --git a/tests/boundary/javascript/packageMainHostPaths.test.ts b/tests/boundary/javascript/packageMainHostPaths.test.ts index 5f5577e4a9..ed65d5d859 100644 --- a/tests/boundary/javascript/packageMainHostPaths.test.ts +++ b/tests/boundary/javascript/packageMainHostPaths.test.ts @@ -2,7 +2,7 @@ import { createRequire } from "node:module"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { resolveArtifactPathByContext } from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; +import { resolveStandaloneArtifactPath } from "../../support/javascriptArtifactPathResolution.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { writeFixtureFiles } from "../../support/workspace/writeFixtureFiles.js"; @@ -55,7 +55,7 @@ it.each(["absolute-host", "relative-package"] as const)( ]), ); expect( - resolveArtifactPathByContext({ + resolveStandaloneArtifactPath({ declaredPath: "fixture", sourcePath: "app/main.cjs", context: "module-specifier", diff --git a/tests/fixtures/javascriptSemanticGraph.ts b/tests/fixtures/javascriptSemanticGraph.ts index 04e7e009e5..6ac66416ae 100644 --- a/tests/fixtures/javascriptSemanticGraph.ts +++ b/tests/fixtures/javascriptSemanticGraph.ts @@ -1,3 +1,4 @@ +import { JavaScriptJsonDocuments } from "../../src/application/javascript/JavaScriptJsonDocuments.js"; import { buildJavaScriptSemanticGraph } from "../../src/application/javascript/JavaScriptSemanticGraphBuilder.js"; import type { JavaScriptArtifactAnalysis } from "../../src/application/javascript/JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../src/domain/javascript/javascriptArtifactFiles.js"; @@ -22,6 +23,7 @@ export const graphForJavaScript = ( text: { included: true, value: source }, }; const analysis: JavaScriptArtifactAnalysis = { + jsonDocuments: new JavaScriptJsonDocuments(), files: [{ file, javascript: null, semantic: { ir } }], packages: [], json_modules: [], diff --git a/tests/process-global/javascriptJsonDocumentFailures.test.ts b/tests/process-global/javascriptJsonDocumentFailures.test.ts new file mode 100644 index 0000000000..44a99abca9 --- /dev/null +++ b/tests/process-global/javascriptJsonDocumentFailures.test.ts @@ -0,0 +1,149 @@ +import { expect, it, onTestFinished, vi } from "vitest"; + +import { createJavaScriptArtifactReader } from "../../src/artifacts/javascript/JavaScriptArtifactReader.js"; +import { analyzeJavaScriptArtifactFiles } from "../../src/application/javascript/JavaScriptArtifactAnalysis.js"; +import { buildJavaScriptArtifactGraph } from "../../src/application/javascript/JavaScriptArtifactGraphBuilder.js"; +import type { + JavaScriptArtifactFile, + JavaScriptArtifactFileSet, +} from "../../src/domain/javascript/javascriptArtifactFiles.js"; +import { scanCanonicalArtifactInventory } from "../fixtures/artifactInventory.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { writeFixtureFiles } from "../support/javascriptApplicationFixture.js"; +import { readJavaScriptArtifactFiles } from "../support/javascriptApplicationScope.js"; + +const packageJsonText = '{"name":"fault-package"}'; +const jsonText = '{"kind":"fault-json"}'; + +const artifactFile = ( + path: string, + kind: "package-json" | "json", + text: string, +): JavaScriptArtifactFile => ({ + path, + container_sha256: "container-digest", + sha256: `digest-${path}`, + bytes: Buffer.byteLength(text), + inventory_artifact_id: `artifact-${path}`, + kind, + unpacked: false, + text: { included: true, value: text }, +}); + +const fileSet = (): JavaScriptArtifactFileSet => ({ + files: [ + artifactFile("package.json", "package-json", packageJsonText), + artifactFile("settings.json", "json", jsonText), + ], + unavailable_relevant_files: [], + containers: [], + text_bytes_read: + Buffer.byteLength(packageJsonText) + Buffer.byteLength(jsonText), + invalid_utf8_files: 0, +}); + +it.each([ + { + document: "package.json", + text: packageJsonText, + create: () => new TypeError("parser fault"), + }, + { + document: "package.json", + text: packageJsonText, + create: () => new Error("parser fault"), + }, + { + document: "ordinary JSON", + text: jsonText, + create: () => new TypeError("parser fault"), + }, + { + document: "ordinary JSON", + text: jsonText, + create: () => new Error("parser fault"), + }, +])( + "propagates unexpected JSON.parse faults from $document", + ({ text, create }) => { + const files = fileSet(); + const originalParse = JSON.parse; + const thrown = create(); + vi.spyOn(JSON, "parse").mockImplementation((input) => { + if (input === text) throw thrown; + return originalParse(input); + }); + onTestFinished(() => { + vi.restoreAllMocks(); + }); + + let caught: unknown; + try { + analyzeJavaScriptArtifactFiles(files); + } catch (cause: unknown) { + caught = cause; + } + expect(caught).toBe(thrown); + }, +); + +it("keeps malformed JSON as an invalid observation", () => { + const malformedPackage = artifactFile("package.json", "package-json", "{"); + const malformedJson = artifactFile("settings.json", "json", "{"); + const analysis = analyzeJavaScriptArtifactFiles({ + ...fileSet(), + files: [malformedPackage, malformedJson], + }); + + expect(analysis.packages).toMatchObject([{ status: "invalid" }]); + expect(analysis.json_modules).toMatchObject([{ status: "invalid" }]); +}); + +it("reuses parsed package and JSON facts while building the public graph", async () => { + const root = await createTestTempDirectory("rea-json-document-graph-"); + await writeFixtureFiles(root, { + "main.cjs": 'require("./dep"); require("./config.json");', + "dep/package.json": '{"main":"index.cjs"}', + "dep/index.cjs": "module.exports = 1;", + "config.json": '{"enabled":true}', + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const analysis = analyzeJavaScriptArtifactFiles(files); + const depPackageText = '{"main":"index.cjs"}'; + const configText = '{"enabled":true}'; + const originalParse = JSON.parse; + vi.spyOn(JSON, "parse").mockImplementation((text) => { + if (text === depPackageText || text === configText) + throw new TypeError("unexpected JSON reparse"); + return originalParse(text); + }); + + const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis); + expect( + graph.edges.find( + ({ relation, properties }) => + relation === "imports" && properties.specifier === "./dep", + )?.properties, + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "dep/index.cjs", + }); + expect( + graph.edges.find( + ({ relation, properties }) => + relation === "imports" && + properties.specifier === "./config.json" && + Object.hasOwn(properties, "target_json_status"), + )?.properties, + ).toMatchObject({ + target_file_kind: "json", + target_json_status: "included", + }); + } finally { + vi.restoreAllMocks(); + await reader.close(); + } +}); diff --git a/tests/support/javascriptArtifactPathResolution.ts b/tests/support/javascriptArtifactPathResolution.ts new file mode 100644 index 0000000000..4848bb42ec --- /dev/null +++ b/tests/support/javascriptArtifactPathResolution.ts @@ -0,0 +1,14 @@ +import { JavaScriptJsonDocuments } from "../../src/application/javascript/JavaScriptJsonDocuments.js"; +import { + resolveArtifactPathByContext, + type ResolveArtifactPathInput, +} from "../../src/application/javascript/JavaScriptArtifactPathResolution.js"; + +/** Resolve one standalone test request with its own JSON facts owner. */ +export const resolveStandaloneArtifactPath = ( + input: Omit, +) => + resolveArtifactPathByContext({ + ...input, + jsonDocuments: new JavaScriptJsonDocuments(), + });