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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-13 15:30:53 -07:00
parent 958b08e237
commit 74a0bdb25c
8 changed files with 129 additions and 17 deletions

View File

@@ -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.

View File

@@ -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

View File

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

View File

@@ -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<Record<string, unknown>> };
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<string, { importRefs?: unknown[] }> };
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<string, { importRefs?: unknown[] }> }).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 });

View File

@@ -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<Record<string, unknown>> },
edges: JSON.parse(bytes.edges) as { edges: Array<Record<string, unknown>> },
manifest: JSON.parse(serializeManifest(manifest)) as { files: Record<string, Record<string, unknown>> },
});
const cases = [
(value: ReturnType<typeof payload>) => { value.nodes.nodes[0]!.foreign = true; },
(value: ReturnType<typeof payload>) => { value.edges.edges[0]!.foreign = true; },
(value: ReturnType<typeof payload>) => { (value.nodes.nodes[0]!.source as Record<string, unknown>).foreign = true; },
(value: ReturnType<typeof payload>) => { (value.edges.edges[0]!.source as Record<string, unknown>).foreign = true; },
(value: ReturnType<typeof payload>) => { value.manifest.files["src/a.ts"]!.foreign = true; },
(value: ReturnType<typeof payload>) => { ((value.manifest.files["src/a.ts"]!.importRefs as Array<Record<string, unknown>>)[0]!).foreign = true; },
(value: ReturnType<typeof payload>) => { ((value.manifest.files["src/a.ts"]!.importRefs as Array<Record<string, unknown>>)[0]!).candidates = []; },
(value: ReturnType<typeof payload>) => { ((value.manifest.files["src/a.ts"]!.importRefs as Array<Record<string, unknown>>)[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 });
});
});

View File

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

View File

@@ -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;

View File

@@ -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<string>();
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.