refactor(javascript): centralize package and JSON document facts

This commit is contained in:
morluto
2026-10-23 14:39:17 +08:00
parent 4cfc61147b
commit 37e92e430b
29 changed files with 748 additions and 113 deletions
@@ -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<string, JavaScriptArtifactFile>;
readonly jsonDocuments: JavaScriptJsonDocuments;
readonly directoryPaths?: ReadonlySet<string> | 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 }),
@@ -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",
@@ -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",
@@ -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", ""],
@@ -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<SemanticIr>[];
readonly packages: JavaScriptPackageObservation[];
readonly jsonModules: JavaScriptJsonModuleObservation[];
@@ -359,6 +364,7 @@ const finalizeArtifactAnalysis = <
state: MutableArtifactAnalysis<SemanticIr>,
): JavaScriptArtifactAnalysis<SemanticIr> => {
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<SemanticIr> => ({
jsonDocuments: new JavaScriptJsonDocuments(),
files: [],
packages: [],
jsonModules: [],
@@ -498,27 +505,25 @@ const addStructuredObservations = (
file: JavaScriptArtifactFile,
state: MutableArtifactAnalysis<JavaScriptModuleSemanticIr>,
): 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<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -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<SemanticIr>[];
readonly packages: readonly JavaScriptPackageObservation[];
readonly json_modules: readonly JavaScriptJsonModuleObservation[];
@@ -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<string, JavaScriptArtifactFile>,
moduleKind?: "import" | "require" | "compiler-dependent",
directoryPaths?: ReadonlySet<string>,
): 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,
@@ -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,
@@ -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 ||
@@ -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: [],
@@ -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,
});
@@ -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 });
},
@@ -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<ResolveArtifactPathInput, "jsonDocuments">) =>
resolveArtifactPathByContext({ ...input, jsonDocuments });
const fileMap = (files: readonly JavaScriptArtifactFile[]) =>
new Map(files.map((value) => [value.path, value]));
@@ -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 }),
@@ -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<string, JavaScriptArtifactFile>(
},
]),
);
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", () => {
@@ -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();
});
@@ -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<Record<(typeof PACKAGE_PROPERTIES)[number], unknown>>
> | 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<JavaScriptArtifactFile, CachedFacts>();
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<string, unknown>;
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,
});
};
@@ -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,
};
@@ -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" ||
@@ -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: [],
@@ -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<typeof analyzeAndAdoptJavaScriptArtifactFiles>
>;
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<typeof buildJavaScriptArtifactGraph>,
path: string,
) =>
graph.nodes
.filter(({ kind }) => kind === "unknown")
.flatMap(({ observations }) => observations)
.find(({ properties }) => properties.path === path);
const expectSkippedStructuredSource = (
graph: ReturnType<typeof buildJavaScriptArtifactGraph>,
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-");
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
+2
View File
@@ -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: [],
@@ -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();
}
});
@@ -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<ResolveArtifactPathInput, "jsonDocuments">,
) =>
resolveArtifactPathByContext({
...input,
jsonDocuments: new JavaScriptJsonDocuments(),
});