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: [],