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>
128 lines
9.3 KiB
TypeScript
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};}
|