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
@@ -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,
});
};