From 74a0bdb25c0facd575ea9c39959b25e29f685545 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 13 Aug 2026 15:30:53 -0700 Subject: [PATCH] FN-9032: harden knowledge-graph artifact validation Reject malformed or contradictory graph cache artifacts and rebuild them as a complete set. - Require exact persisted record shapes and valid import references before cache reuse - Detect invalid import ownership and duplicate extractor identities during consistency checks - Cover cache recovery cases and document the all-or-nothing artifact policy - Add a patch changeset for the published CLI package Files changed: .../fn-9032-knowledge-graph-artifact-validation.md | 7 +++++ docs/knowledge-graph.md | 2 +- .../graph-artifact-not-gitignored.test.ts | 13 ++++++--- .../__tests__/graph-builder-incremental.test.ts | 29 +++++++++++++++++++- .../__tests__/graph-serialization.test.ts | 31 +++++++++++++++++++++ .../__tests__/graph-store-recovery.test.ts | 14 ++++++++++ .../src/knowledge-graph/graph-serialization.ts | 32 ++++++++++++++-------- packages/core/src/knowledge-graph/graph-store.ts | 18 ++++++++++++ 8 files changed, 129 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-9032 Fusion-Task-Lineage: d53934f6-2735-42ab-a41e-4421890277be Co-authored-by: Fusion (runfusion.ai) --- ...032-knowledge-graph-artifact-validation.md | 7 ++++ docs/knowledge-graph.md | 2 +- .../graph-artifact-not-gitignored.test.ts | 13 +++++--- .../graph-builder-incremental.test.ts | 29 ++++++++++++++++- .../__tests__/graph-serialization.test.ts | 31 ++++++++++++++++++ .../__tests__/graph-store-recovery.test.ts | 14 ++++++++ .../knowledge-graph/graph-serialization.ts | 32 ++++++++++++------- .../core/src/knowledge-graph/graph-store.ts | 18 +++++++++++ 8 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 .changeset/fn-9032-knowledge-graph-artifact-validation.md diff --git a/.changeset/fn-9032-knowledge-graph-artifact-validation.md b/.changeset/fn-9032-knowledge-graph-artifact-validation.md new file mode 100644 index 0000000000..84bf2d3b9e --- /dev/null +++ b/.changeset/fn-9032-knowledge-graph-artifact-validation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Rebuild corrupted knowledge-graph caches without retaining foreign artifact data. +category: fix +dev: Validates exact persisted record shapes and reusable import references before graph cache reuse. diff --git a/docs/knowledge-graph.md b/docs/knowledge-graph.md index b5022a1eb2..0f4a7b9c8e 100644 --- a/docs/knowledge-graph.md +++ b/docs/knowledge-graph.md @@ -28,7 +28,7 @@ The dispatcher creates exactly one file node per discovered file. TypeScript and File-owned facts are replaced only when that file hash changes. Module nodes and module containment are derived from the final file set every build. Import references are persisted in the manifest and import/re-export edges are synthesized every build, so adding or deleting a target never requires parsing an unchanged importer. Relative resolution tries `.ts`, `.tsx`, then index candidates; it intentionally does not resolve aliases, packages, or expand `export *` names. -Every real source position is recorded as a repository-relative path, line, and column. File and module-derived items use a `syntheticSource: "true"` attribute and a 1:1 anchor. Artifact payloads contain only content-derived values (including source FNXC stamps and hashes), never build time, host, or process metadata. Missing, malformed, mismatched, or inconsistent artifact files trigger a safe full rebuild; artifacts are written nodes, edges, then manifest so a torn write cannot validate incomplete output. +Every real source position is recorded as a repository-relative path, line, and column. File and module-derived items use a `syntheticSource: "true"` attribute and a 1:1 anchor. Artifact payloads contain only content-derived values (including source FNXC stamps and hashes), never build time, host, or process metadata. Before reuse, persisted nodes, edges, source locations, manifest entries, and import references must be complete, exactly-shaped records; an unknown key is rejected rather than retained. Import references must also be usable extractor output: only TypeScript/TSX owners may carry them, candidates are non-empty and unique, and references cannot duplicate an extractor identity. Shape violations report `invalid-artifact`; cross-record contradictions report `inconsistent-artifact`. Either recovery reason discards the entire artifact set and triggers a complete rebuild, never partial trust. Artifacts are written nodes, edges, then manifest so a torn write cannot validate incomplete output. ## FNXC and query behavior diff --git a/packages/core/src/knowledge-graph/__tests__/graph-artifact-not-gitignored.test.ts b/packages/core/src/knowledge-graph/__tests__/graph-artifact-not-gitignored.test.ts index 1b01bd5a17..cd7c26d590 100644 --- a/packages/core/src/knowledge-graph/__tests__/graph-artifact-not-gitignored.test.ts +++ b/packages/core/src/knowledge-graph/__tests__/graph-artifact-not-gitignored.test.ts @@ -4,12 +4,17 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -describe("tracked knowledge graph artifact", () => { - it("is not covered by the repository ignore rules", async () => { +/* +FNXC:KnowledgeGraph 2026-08-12-14:26: +Knowledge-graph artifacts are regenerable, hand-editable cache files. Keep them ignored so recovery +validation, rather than source control, determines whether a local artifact is reused or rebuilt. +*/ +describe("ignored knowledge graph artifact", () => { + it("is covered by the repository ignore rules", async () => { const root = fileURLToPath(new URL("../../../../../", import.meta.url)); const ignored = await readFile(join(root, ".gitignore"), "utf8"); - expect(ignored).not.toContain(".fusion-knowledge"); - expect(() => execFileSync("git", ["check-ignore", "-q", ".fusion-knowledge/graph/nodes.json"], { cwd: root, stdio: "ignore" })).toThrow(); + expect(ignored).toContain(".fusion-knowledge"); + expect(() => execFileSync("git", ["check-ignore", "-q", ".fusion-knowledge/graph/nodes.json"], { cwd: root, stdio: "ignore" })).not.toThrow(); expect(() => execFileSync("git", ["check-ignore", "-q", ".fusion/x.json"], { cwd: root, stdio: "ignore" })).not.toThrow(); }); }); diff --git a/packages/core/src/knowledge-graph/__tests__/graph-builder-incremental.test.ts b/packages/core/src/knowledge-graph/__tests__/graph-builder-incremental.test.ts index e281066c6f..b36cc80176 100644 --- a/packages/core/src/knowledge-graph/__tests__/graph-builder-incremental.test.ts +++ b/packages/core/src/knowledge-graph/__tests__/graph-builder-incremental.test.ts @@ -18,7 +18,7 @@ describe("incremental graph builder", () => { const before=await Promise.all(["nodes.json","edges.json","manifest.json"].map(file=>readFile(join(dir,file),"utf8"))); const typeScript=vi.fn(realTypeScript), extract=vi.fn(realExtractFile); const noChange=await buildKnowledgeGraph({projectRoot:root,graphDir:dir,extractFile:extract,deps:{typescript:typeScript},discovery}); - expect(noChange.changed).toBe(false); expect(extract).not.toHaveBeenCalled(); + expect(noChange.changed).toBe(false); expect(noChange.stats).toMatchObject({ recoveryReason: null, reusedFiles: 2 }); expect(extract).not.toHaveBeenCalled(); await writeFile(join(root,"src","b.ts"),"export const b = 2;"); const changed=await buildKnowledgeGraph({projectRoot:root,graphDir:dir,extractFile:extract,deps:{typescript:typeScript},discovery}); expect(changed.stats).toMatchObject({parsedFiles:1,reusedFiles:1}); expect(extract).toHaveBeenCalledTimes(1); expect(typeScript).toHaveBeenCalledTimes(1); @@ -63,6 +63,33 @@ describe("incremental graph builder", () => { expect(inferred.ownerPath).toBe(currentAnchor.ownerPath); }); + it("fully rebuilds artifacts after exact-shape and import-reference cache corruption", async () => { + const root = await fixture(), dir = join(root, ".fusion-knowledge/graph"); + await mkdir(join(root, "docs")); + await writeFile(join(root, "docs", "guide.md"), "# Guide\n"); + const graphDiscovery = { sourceRoots: ["src"], markdownRoots: ["docs"] }; + const artifactBytes = () => Promise.all(["nodes.json", "edges.json", "manifest.json"].map(file => readFile(join(dir, file), "utf8"))); + await buildKnowledgeGraph({ projectRoot: root, graphDir: dir, discovery: graphDiscovery }); + + const nodesPath = join(dir, "nodes.json"); + const nodes = JSON.parse(await readFile(nodesPath, "utf8")) as { nodes: Array> }; + nodes.nodes[0]!.foreign = "tampered"; + await writeFile(nodesPath, `${JSON.stringify(nodes)}\n`); + const shapeRecovery = await buildKnowledgeGraph({ projectRoot: root, graphDir: dir, discovery: graphDiscovery }); + expect(shapeRecovery.stats.recoveryReason).toBe("invalid-artifact"); + expect(await artifactBytes()).toEqual(await buildKnowledgeGraph({ projectRoot: root, graphDir: dir, discovery: graphDiscovery, force: true }).then(artifactBytes)); + expect((await artifactBytes()).join("")).not.toContain("tampered"); + + const manifestPath = join(dir, "manifest.json"); + const artifactManifest = JSON.parse(await readFile(manifestPath, "utf8")) as { files: Record }; + artifactManifest.files["docs/guide.md"]!.importRefs = [{ kind: "imports", specifier: "./a", candidates: ["src/a.ts"], line: 1, column: 1, typeOnly: false }]; + await writeFile(manifestPath, `${JSON.stringify(artifactManifest)}\n`); + const consistencyRecovery = await buildKnowledgeGraph({ projectRoot: root, graphDir: dir, discovery: graphDiscovery }); + expect(consistencyRecovery.stats.recoveryReason).toBe("inconsistent-artifact"); + expect(await artifactBytes()).toEqual(await buildKnowledgeGraph({ projectRoot: root, graphDir: dir, discovery: graphDiscovery, force: true }).then(artifactBytes)); + expect((JSON.parse(await readFile(manifestPath, "utf8")) as { files: Record }).files["docs/guide.md"]!.importRefs).toBeUndefined(); + }); + it("rebuilds rather than reusing a cache with forged synthetic file provenance", async () => { const root = await fixture(), dir = join(root, ".fusion-knowledge/graph"); await buildKnowledgeGraph({ projectRoot: root, graphDir: dir, discovery }); diff --git a/packages/core/src/knowledge-graph/__tests__/graph-serialization.test.ts b/packages/core/src/knowledge-graph/__tests__/graph-serialization.test.ts index 07b1acf35d..407d18e0cb 100644 --- a/packages/core/src/knowledge-graph/__tests__/graph-serialization.test.ts +++ b/packages/core/src/knowledge-graph/__tests__/graph-serialization.test.ts @@ -26,4 +26,35 @@ describe("knowledge graph serialization", () => { expect(bytes.edges).toContain('"id": "contains|file:src/a.ts|symbol:src/a.ts#a"'); expect(deserializeArtifacts(bytes.nodes.replace("\"schemaVersion\": 2", "\"schemaVersion\": 3"), bytes.edges, serializeManifest(manifest))).toEqual({ ok: false, reason: "version-mismatch" }); }); + + it("rejects extra keys and impossible import candidate shapes instead of retaining foreign artifact state", () => { + const bytes = serializeGraph(graph); + const payload = () => ({ + nodes: JSON.parse(bytes.nodes) as { nodes: Array> }, + edges: JSON.parse(bytes.edges) as { edges: Array> }, + manifest: JSON.parse(serializeManifest(manifest)) as { files: Record> }, + }); + const cases = [ + (value: ReturnType) => { value.nodes.nodes[0]!.foreign = true; }, + (value: ReturnType) => { value.edges.edges[0]!.foreign = true; }, + (value: ReturnType) => { (value.nodes.nodes[0]!.source as Record).foreign = true; }, + (value: ReturnType) => { (value.edges.edges[0]!.source as Record).foreign = true; }, + (value: ReturnType) => { value.manifest.files["src/a.ts"]!.foreign = true; }, + (value: ReturnType) => { ((value.manifest.files["src/a.ts"]!.importRefs as Array>)[0]!).foreign = true; }, + (value: ReturnType) => { ((value.manifest.files["src/a.ts"]!.importRefs as Array>)[0]!).candidates = []; }, + (value: ReturnType) => { ((value.manifest.files["src/a.ts"]!.importRefs as Array>)[0]!).candidates = ["src/b.ts", "src/b.ts"]; }, + ]; + for (const tamper of cases) { + const value = payload(); + tamper(value); + expect(deserializeArtifacts(JSON.stringify(value.nodes), JSON.stringify(value.edges), JSON.stringify(value.manifest))).toEqual({ ok: false, reason: "invalid-artifact" }); + } + }); + + it("accepts complete manifest entries with and without optional import references", () => { + const bytes = serializeGraph(graph); + const withoutRefs: GraphManifest = { schemaVersion: 2, extractorVersion: 1, files: { "src/a.ts": { hash: "a".repeat(64) } } }; + expect(deserializeArtifacts(bytes.nodes, bytes.edges, serializeManifest(manifest))).toMatchObject({ ok: true }); + expect(deserializeArtifacts(bytes.nodes, bytes.edges, serializeManifest(withoutRefs))).toMatchObject({ ok: true }); + }); }); diff --git a/packages/core/src/knowledge-graph/__tests__/graph-store-recovery.test.ts b/packages/core/src/knowledge-graph/__tests__/graph-store-recovery.test.ts index 6a6a0bf3cc..e017e00752 100644 --- a/packages/core/src/knowledge-graph/__tests__/graph-store-recovery.test.ts +++ b/packages/core/src/knowledge-graph/__tests__/graph-store-recovery.test.ts @@ -20,6 +20,20 @@ describe("knowledge graph artifact recovery",()=>{ await writeFile(join(dir, "nodes.json"), JSON.stringify({ nodes: [] })); expect(await loadArtifacts(dir)).toMatchObject({ ok: false, recoveryReason: "invalid-artifact" }); }); + it("rejects import references that contradict extractor ownership or uniqueness", async () => { + const dir = await mkdtemp(join(tmpdir(), "kg-store-")); + dirs.push(dir); + const fileGraph: KnowledgeGraph = { schemaVersion: 2, nodes: [{ id: "file:src/a.ts", kind: "file", name: "a", owner: "file", ownerPath: "src/a.ts", source: { path: "src/a.ts", line: 1, column: 1 }, attributes: { syntheticSource: "true" } }], edges: [] }; + const reference = { kind: "imports" as const, specifier: "./b", candidates: ["src/b.ts"], line: 1, column: 1, typeOnly: false }; + await writeArtifacts(dir, fileGraph, { schemaVersion: 2, extractorVersion: 1, files: { "src/a.ts": { hash: "a".repeat(64), importRefs: [reference] } } }); + expect(await loadArtifacts(dir)).toMatchObject({ ok: true }); + + await writeArtifacts(dir, { ...fileGraph, nodes: [{ ...fileGraph.nodes[0]!, id: "file:docs/a.md", ownerPath: "docs/a.md", source: { path: "docs/a.md", line: 1, column: 1 } }] }, { schemaVersion: 2, extractorVersion: 1, files: { "docs/a.md": { hash: "a".repeat(64), importRefs: [reference] } } }); + expect(await loadArtifacts(dir)).toMatchObject({ ok: false, recoveryReason: "inconsistent-artifact" }); + + await writeArtifacts(dir, fileGraph, { schemaVersion: 2, extractorVersion: 1, files: { "src/a.ts": { hash: "a".repeat(64), importRefs: [reference, { ...reference, typeOnly: true }] } } }); + expect(await loadArtifacts(dir)).toMatchObject({ ok: false, recoveryReason: "inconsistent-artifact" }); + }); it("rejects a retained edge whose file owner is absent from the manifest", async () => { const dir = await mkdtemp(join(tmpdir(), "kg-store-")); dirs.push(dir); diff --git a/packages/core/src/knowledge-graph/graph-serialization.ts b/packages/core/src/knowledge-graph/graph-serialization.ts index 87b06c9383..00cf4d634f 100644 --- a/packages/core/src/knowledge-graph/graph-serialization.ts +++ b/packages/core/src/knowledge-graph/graph-serialization.ts @@ -62,17 +62,31 @@ const validPath = (value: unknown) => { return false; } }; -const validSource = (value: unknown) => !!value && typeof value === "object" && validPath((value as GraphNode["source"]).path) && Number.isInteger((value as GraphNode["source"]).line) && (value as GraphNode["source"]).line >= 1 && Number.isInteger((value as GraphNode["source"]).column) && (value as GraphNode["source"]).column >= 1; +function hasExactKeys(value: object, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +/* +FNXC:KnowledgeGraph 2026-08-12-14:26: +A persisted artifact is untrusted input re-serialized verbatim by the next incremental build. Unknown +record keys are durable foreign graph state, not harmless extras, so reject the whole cache rather than +stripping keys and partially trusting it. +*/ +const validSource = (value: unknown) => !!value && typeof value === "object" && hasExactKeys(value, ["path", "line", "column"]) + && validPath((value as GraphNode["source"]).path) && Number.isInteger((value as GraphNode["source"]).line) && (value as GraphNode["source"]).line >= 1 + && Number.isInteger((value as GraphNode["source"]).column) && (value as GraphNode["source"]).column >= 1; function validNode(value: unknown): value is GraphNode { - if (!value || typeof value !== "object") return false; + if (!value || typeof value !== "object" || !hasExactKeys(value, ["id", "kind", "name", "owner", "ownerPath", "source", "attributes"])) return false; const node = value as GraphNode; return typeof node.id === "string" && node.id.length > 0 && nodeKinds.has(node.kind) && typeof node.name === "string" && owners.has(node.owner) && validPath(node.ownerPath) && validSource(node.source) && hasStringMap(node.attributes); } function validEdge(value: unknown): value is GraphEdge { - if (!value || typeof value !== "object") return false; + if (!value || typeof value !== "object" || !hasExactKeys(value, ["id", "kind", "from", "to", "provenance", "owner", "ownerPath", "source", "attributes"])) return false; const edge = value as GraphEdge; return typeof edge.id === "string" && edge.id.length > 0 && edgeKinds.has(edge.kind) && typeof edge.from === "string" && typeof edge.to === "string" && (edge.provenance === "extracted" || edge.provenance === "inferred") && owners.has(edge.owner) && validPath(edge.ownerPath) @@ -80,24 +94,20 @@ function validEdge(value: unknown): value is GraphEdge { } function validImportRef(value: unknown): value is ImportRef { - if (!value || typeof value !== "object") return false; + if (!value || typeof value !== "object" || !hasExactKeys(value, ["kind", "specifier", "candidates", "line", "column", "typeOnly"])) return false; const ref = value as ImportRef; return (ref.kind === "imports" || ref.kind === "re-exports") && typeof ref.specifier === "string" && Array.isArray(ref.candidates) - && ref.candidates.every(validPath) && Number.isInteger(ref.line) && ref.line >= 1 && Number.isInteger(ref.column) && ref.column >= 1 && typeof ref.typeOnly === "boolean"; + && ref.candidates.length > 0 && ref.candidates.every(validPath) && new Set(ref.candidates).size === ref.candidates.length + && Number.isInteger(ref.line) && ref.line >= 1 && Number.isInteger(ref.column) && ref.column >= 1 && typeof ref.typeOnly === "boolean"; } function validManifestEntry(value: unknown): value is GraphManifestEntry { - if (!value || typeof value !== "object") return false; + if (!value || typeof value !== "object" || !(hasExactKeys(value, ["hash"]) || hasExactKeys(value, ["hash", "importRefs"]))) return false; const entry = value as GraphManifestEntry; return typeof entry.hash === "string" && /^[a-f0-9]{64}$/.test(entry.hash) && (entry.importRefs === undefined || (Array.isArray(entry.importRefs) && entry.importRefs.every(validImportRef))); } -function hasExactKeys(value: object, keys: readonly string[]): boolean { - const actual = Object.keys(value).sort(); - return actual.length === keys.length && actual.every((key, index) => key === [...keys].sort()[index]); -} - function validManifest(value: unknown): value is GraphManifest { if (!value || typeof value !== "object") return false; const manifest = value as GraphManifest; diff --git a/packages/core/src/knowledge-graph/graph-store.ts b/packages/core/src/knowledge-graph/graph-store.ts index 03ee8988e0..a95491eb78 100644 --- a/packages/core/src/knowledge-graph/graph-store.ts +++ b/packages/core/src/knowledge-graph/graph-store.ts @@ -25,6 +25,24 @@ function consistent(graph: KnowledgeGraph, manifest: GraphManifest): boolean { if (allFileNodes.length !== fileNodes.size || fileNodes.size !== Object.keys(manifest.files).length) return false; if ([...fileNodes].some(([path, node]) => node.id !== `file:${path}` || !manifest.files[path])) return false; if (graph.nodes.some(node => node.owner === "file" && !manifest.files[node.ownerPath])) return false; + /* + FNXC:KnowledgeGraph 2026-08-12-14:26: + buildKnowledgeGraph synthesizes imports and re-exports from persisted references for every hash-reused + file. Validate the extractor's owner and uniqueness invariants first, or a fabricated relation survives + indefinitely without a re-parse to correct it. + */ + if (Object.entries(manifest.files).some(([path, entry]) => { + const refs = entry.importRefs; + if (!refs) return false; + if (!/\.tsx?$/.test(path)) return true; + const identities = new Set(); + return refs.some(ref => { + const identity = `${ref.kind}\0${ref.specifier}\0${ref.line}\0${ref.column}`; + if (identities.has(identity)) return true; + identities.add(identity); + return false; + }); + })) return false; // Artifact JSON is an untrusted incremental cache. Checking only ownerPath permits a corrupt // symbol/doc/rationale id (or a cross-file source anchor) to survive every no-op rebuild.