Files
fusion/packages/core/src/knowledge-graph/graph-store.ts
gsxdsm 74a0bdb25c 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>
2026-08-13 15:30:53 -07:00

128 lines
9.3 KiB
TypeScript

import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { randomUUID } from "node:crypto";
import { deserializeArtifacts, serializeGraph, serializeManifest } from "./graph-serialization.js";
import { edgeId, fileNodeId, KnowledgeGraphError, moduleCanonicalPath, moduleNodeId, type GraphEdge, type GraphManifest, type GraphNode, type KnowledgeGraph } from "./graph-types.js";
/*
FNXC:KnowledgeGraph 2026-08-10-10:00:
The graph is review-visible and committable, while .fusion is ignored, so artifacts live under
.fusion-knowledge/graph. FR-29/FR-34 capability bundles do not have a format yet and are deferred.
*/
export const DEFAULT_KNOWLEDGE_GRAPH_DIR = ".fusion-knowledge/graph";
export type RecoveryReason="missing-artifact"|"invalid-artifact"|"version-mismatch"|"inconsistent-artifact"|"forced";
export type ArtifactLoadResult={ok:true;graph:KnowledgeGraph;manifest:GraphManifest;recoveryReason:null}|{ok:false;recoveryReason:RecoveryReason};
export function resolveKnowledgeGraphDir(projectRoot:string, configured?:string):string { const root=resolve(projectRoot), target=resolve(root,configured||DEFAULT_KNOWLEDGE_GRAPH_DIR), rel=relative(root,target); if(isAbsolute(rel)||rel===".."||rel.startsWith(`..${sep}`)) throw new KnowledgeGraphError("Knowledge graph directory escapes project root");return target; }
function isMissing(error:unknown){return (error as NodeJS.ErrnoException).code==="ENOENT";}
function consistent(graph: KnowledgeGraph, manifest: GraphManifest): boolean {
const allFileNodes = graph.nodes.filter(node => node.owner === "file" && node.kind === "file");
const fileNodes = new Map(allFileNodes.map(node => [node.ownerPath, node]));
/*
FNXC:KnowledgeGraph 2026-08-10-12:06:
A Map alone hides a second, differently-id'd file root with the same ownership path. Reject it
so a malformed artifact always takes the safe full-rebuild path instead of reusing rogue nodes.
*/
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.
const expectedNodeId = (node: GraphNode): string | undefined => {
switch (node.kind) {
case "file": return `file:${node.ownerPath}`;
case "module": return `module:${node.ownerPath}`;
case "symbol": return node.id.startsWith(`symbol:${node.ownerPath}#`) ? node.id : undefined;
case "doc-concept": return node.id.startsWith(`doc:${node.ownerPath}#`) ? node.id : undefined;
case "rationale": return node.id.startsWith(`rationale:${node.ownerPath}#`) ? node.id : undefined;
}
};
/*
FNXC:KnowledgeGraph 2026-08-10-12:38:
Persisted artifacts are an untrusted incremental cache. Validate ownership classes as well as ids:
only module facts are derived, and derived edges are module-scoped contains edges. Otherwise a
malformed cache can survive an incremental reuse path with a node that pruning can never own.
*/
const moduleFiles = allFileNodes
.map(node => node.ownerPath)
.filter(path => /\.tsx?$/.test(path));
const hasSyntheticMarker = (item: GraphNode | GraphEdge) => "syntheticSource" in item.attributes;
const isSyntheticAnchor = (item: GraphNode | GraphEdge, path: string) => item.attributes.syntheticSource === "true"
&& item.source.path === path && item.source.line === 1 && item.source.column === 1;
/*
FNXC:KnowledgeGraph 2026-08-10-12:48:
A persisted graph is an untrusted incremental cache, so synthetic anchors must be validated before
reuse. File/module facts and module-scoped edges have no parsed text position; accepting a fake
location or a synthetic marker on a real symbol/comment edge would preserve false provenance.
*/
if (graph.nodes.some(node => {
if (expectedNodeId(node) !== node.id) return true;
if (node.kind === "file") return node.owner !== "file" || !isSyntheticAnchor(node, node.ownerPath);
if (node.kind === "module") return node.owner !== "derived"
|| !isSyntheticAnchor(node, moduleCanonicalPath(node.ownerPath, moduleFiles));
return node.owner !== "file" || node.source.path !== node.ownerPath || hasSyntheticMarker(node);
})) return false;
const nodesById = new Map(graph.nodes.map(node => [node.id, node]));
if (graph.edges.some(edge => {
if (edge.id !== edgeId(edge.kind, edge.from, edge.to)) return true;
/*
FNXC:KnowledgeGraphInferredEdges 2026-08-11-10:56:
FN-8933 anchors an inferred LLM relation to its existing `from` node instead of accepting an
LLM-supplied location. It may cross ownership classes, so parser/derived edge restrictions do
not apply, but its persisted owner and source must remain exactly that canonical node anchor.
*/
if (edge.provenance === "inferred") {
const anchor = nodesById.get(edge.from);
return !anchor || !nodesById.has(edge.to) || edge.owner !== anchor.owner || edge.ownerPath !== anchor.ownerPath
|| edge.source.path !== anchor.source.path || edge.source.line !== anchor.source.line || edge.source.column !== anchor.source.column;
}
if (edge.owner === "derived") return edge.kind !== "contains"
|| edge.from !== moduleNodeId(edge.ownerPath)
|| !isSyntheticAnchor(edge, moduleCanonicalPath(edge.ownerPath, moduleFiles));
/*
FNXC:KnowledgeGraph 2026-08-10-12:56:
Documentation hierarchy is file-owned but links a heading to its child heading. Only import
references must originate at the owning file node; rejecting the hierarchy would force a full
rebuild of every otherwise-valid graph artifact.
*/
return edge.source.path !== edge.ownerPath || hasSyntheticMarker(edge)
|| ((edge.kind === "imports" || edge.kind === "re-exports") && edge.from !== fileNodeId(edge.ownerPath));
})) return false;
const ids = new Set<string>();
for (const node of graph.nodes) {
if (ids.has(node.id)) return false;
ids.add(node.id);
}
const edgeIds = new Set<string>();
for (const edge of graph.edges) {
if (edgeIds.has(edge.id)) return false;
edgeIds.add(edge.id);
}
return !graph.edges
.filter(edge => edge.owner !== "derived" && edge.kind !== "imports" && edge.kind !== "re-exports")
.some(edge => !manifest.files[edge.ownerPath] || !ids.has(edge.from) || !ids.has(edge.to));
}
export async function loadArtifacts(graphDir:string, logger:(message:string)=>void=()=>{}):Promise<ArtifactLoadResult>{const paths=["nodes.json","edges.json","manifest.json"].map(x=>resolve(graphDir,x));let text:string[];try{text=await Promise.all(paths.map(p=>readFile(p,"utf8")));}catch(error){if(isMissing(error))return{ok:false,recoveryReason:"missing-artifact"};throw new KnowledgeGraphError(`Unable to read knowledge graph artifacts: ${error instanceof Error?error.message:String(error)}`);}const decoded=deserializeArtifacts(text[0]!,text[1]!,text[2]!);if(!decoded.ok){logger(`Knowledge graph artifact recovery: ${decoded.reason}`);return{ok:false,recoveryReason:decoded.reason};}if(!consistent(decoded.graph,decoded.manifest)){logger("Knowledge graph artifact recovery: inconsistent-artifact");return{ok:false,recoveryReason:"inconsistent-artifact"};}return{ok:true,graph:decoded.graph,manifest:decoded.manifest,recoveryReason:null};}
async function writeIfChanged(path:string, content:string):Promise<boolean>{try{if(await readFile(path,"utf8")===content)return false;}catch(error){if(!isMissing(error))throw error;}await mkdir(dirname(path),{recursive:true});const temp=`${path}.${randomUUID()}.tmp`;try{await writeFile(temp,content,"utf8");await rename(temp,path);return true;}catch(error){await unlink(temp).catch(()=>{});throw error;}}
/** Manifest is last: a torn write can only force a safe full rebuild, never validate unwritten nodes. */
export async function writeArtifacts(graphDir:string,graph:KnowledgeGraph,manifest:GraphManifest):Promise<{nodes:boolean;edges:boolean;manifest:boolean;changed:boolean}>{const bytes=serializeGraph(graph),nodes=await writeIfChanged(resolve(graphDir,"nodes.json"),bytes.nodes),edges=await writeIfChanged(resolve(graphDir,"edges.json"),bytes.edges),manifestChanged=await writeIfChanged(resolve(graphDir,"manifest.json"),serializeManifest(manifest));return{nodes,edges,manifest:manifestChanged,changed:nodes||edges||manifestChanged};}