diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index e6cf1d5302..69b3bb9678 100644 --- a/src/domain/javascript/javascriptSemanticGraph.ts +++ b/src/domain/javascript/javascriptSemanticGraph.ts @@ -56,6 +56,9 @@ const evidenceContextId = (context: ApplicationGraphEvidenceContext): string => /** Graph-scoped owner for canonical provenance shared by semantic facts. */ export class JavaScriptSemanticEvidenceContextRegistry { readonly #contexts = new Map(); + // Facts share a few contexts, so key them by canonical text and hash each + // distinct context once instead of on every fact. + readonly #idsByCanonicalContext = new Map(); /** Intern normalized non-location provenance at fact construction. */ intern( @@ -63,18 +66,21 @@ export class JavaScriptSemanticEvidenceContextRegistry { ): JavaScriptSemanticEvidenceReference { const normalized = normalizeEvidence(evidence); const { location, ...context } = normalized; + const canonicalContext = canonicalJson( + context, + "JavaScript semantic graph", + ); + const internedId = this.#idsByCanonicalContext.get(canonicalContext); + if (internedId !== undefined) return { context_id: internedId, location }; const contextId = evidenceContextId(context); - const row = { ...context, context_id: contextId }; - const existing = this.#contexts.get(contextId); - if ( - existing !== undefined && - canonicalJson(existing, "JavaScript semantic graph") !== - canonicalJson(row, "JavaScript semantic graph") - ) + // This canonical context is new, so a row already stored under its + // digest holds different provenance. + if (this.#contexts.has(contextId)) throw new TypeError( "JavaScript semantic evidence context digest collision", ); - this.#contexts.set(contextId, row); + this.#contexts.set(contextId, { ...context, context_id: contextId }); + this.#idsByCanonicalContext.set(canonicalContext, contextId); return { context_id: contextId, location }; } @@ -101,6 +107,7 @@ export class JavaScriptSemanticEvidenceContextRegistry { /** Release interned contexts after the owning graph is finalized. */ clear(): void { this.#contexts.clear(); + this.#idsByCanonicalContext.clear(); } } diff --git a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts index ddf3015f08..363cf29ad2 100644 --- a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import { z } from "zod"; +import { digestCanonicalValue } from "../canonicalDigest.js"; import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; import { JAVASCRIPT_SEMANTIC_NODE_KINDS, @@ -615,3 +616,42 @@ it.each(invalidGraphCases)( expect(issues(() => complete(steps))).toEqual(expected); }, ); + +it("interns each distinct evidence context once under its content digest", () => { + const registry = new JavaScriptSemanticEvidenceContextRegistry(); + const elsewhere: ApplicationGraphEvidence = { + ...evidence(), + location: { + available: true, + value: { + kind: "source-range", + source: "other.js", + start: { line: 7, column: 2 }, + end: { line: 7, column: 9 }, + }, + }, + }; + const first = registry.intern(evidence()); + const repeated = registry.intern(elsewhere); + const reordered = registry.intern({ + ...evidence("inferred"), + limitations: [ + "Static reachability does not prove runtime execution.", + "Static reachability does not prove runtime execution.", + ], + }); + const inferred = registry.intern(evidence("inferred")); + const { location: _location, ...context } = evidence(); + + expect(repeated.context_id).toBe(first.context_id); + expect(repeated.location).toEqual(elsewhere.location); + expect(reordered.context_id).toBe(inferred.context_id); + expect(inferred.context_id).not.toBe(first.context_id); + expect(first.context_id).toBe( + `jsrg_evidence_${digestCanonicalValue(context, "test")}`, + ); + expect(registry.contexts.map(({ context_id }) => context_id)).toEqual( + [first.context_id, inferred.context_id].toSorted(), + ); + expect(registry.resolve(repeated)).toEqual(elsewhere); +});