Files
fusion/packages/core/src/knowledge-graph/graph-serialization.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

145 lines
8.6 KiB
TypeScript

import {
KNOWLEDGE_GRAPH_EXTRACTOR_VERSION,
KNOWLEDGE_GRAPH_SCHEMA_VERSION,
type EdgeKind,
type GraphEdge,
type GraphManifest,
type GraphManifestEntry,
type GraphNode,
type GraphNodeKind,
type GraphOwner,
type ImportRef,
type KnowledgeGraph,
normalizeRelPath,
} from "./graph-types.js";
function ordered(value: unknown): unknown {
if (Array.isArray(value)) return value.map(ordered);
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right));
/*
FNXC:KnowledgeGraph 2026-08-10-11:38:
Artifact roots are self-describing, so schemaVersion remains the first serialized key while
nested maps stay recursively sorted for byte-stable commits.
*/
const schema = entries.find(([key]) => key === "schemaVersion");
const rootOrdered = schema ? [schema, ...entries.filter(([key]) => key !== "schemaVersion")] : entries;
return Object.fromEntries(rootOrdered.map(([key, entry]) => [key, ordered(entry)]));
}
return value;
}
export const canonicalJson = (value: unknown) => `${JSON.stringify(ordered(value), null, 2)}\n`;
export function serializeGraph(graph: KnowledgeGraph): { nodes: string; edges: string } {
return {
nodes: canonicalJson({ schemaVersion: KNOWLEDGE_GRAPH_SCHEMA_VERSION, nodes: [...graph.nodes].sort((a, b) => a.id.localeCompare(b.id)) }),
edges: canonicalJson({ schemaVersion: KNOWLEDGE_GRAPH_SCHEMA_VERSION, edges: [...graph.edges].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id)) }),
};
}
export const serializeManifest = (manifest: GraphManifest) => canonicalJson({
schemaVersion: KNOWLEDGE_GRAPH_SCHEMA_VERSION,
extractorVersion: KNOWLEDGE_GRAPH_EXTRACTOR_VERSION,
files: manifest.files,
});
const owners = new Set<GraphOwner>(["file", "derived"]);
const nodeKinds = new Set<GraphNodeKind>(["file", "module", "symbol", "doc-concept", "rationale"]);
const edgeKinds = new Set<EdgeKind>(["contains", "imports", "re-exports", "relates-to", "rationale-supports"]);
const hasStringMap = (value: unknown): value is Record<string, string> => !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every(entry => typeof entry === "string");
const validPath = (value: unknown) => {
if (typeof value !== "string" || value.length === 0) return false;
try {
/*
FNXC:KnowledgeGraph 2026-08-10-12:06:
Persisted identities must already be canonical; normalizing on load could silently reuse a
foreign owner rather than triggering the full-rebuild recovery path.
*/
return normalizeRelPath(value) === value;
} catch {
return false;
}
};
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" || !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" || !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)
&& validSource(edge.source) && hasStringMap(edge.attributes);
}
function validImportRef(value: unknown): value is ImportRef {
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.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" || !(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 validManifest(value: unknown): value is GraphManifest {
if (!value || typeof value !== "object") return false;
const manifest = value as GraphManifest;
return hasExactKeys(manifest, ["schemaVersion", "extractorVersion", "files"])
&& manifest.schemaVersion === KNOWLEDGE_GRAPH_SCHEMA_VERSION && manifest.extractorVersion === KNOWLEDGE_GRAPH_EXTRACTOR_VERSION
&& !!manifest.files && typeof manifest.files === "object" && !Array.isArray(manifest.files)
&& Object.entries(manifest.files).every(([path, entry]) => validPath(path) && validManifestEntry(entry));
}
/** Invalid persisted shapes return a recovery result; corrupted artifacts never become trusted graph state. */
export function deserializeArtifacts(nodesText: string, edgesText: string, manifestText: string): { ok: true; graph: KnowledgeGraph; manifest: GraphManifest } | { ok: false; reason: "invalid-artifact" | "version-mismatch" } {
try {
const nodesPayload: unknown = JSON.parse(nodesText);
const edgesPayload: unknown = JSON.parse(edgesText);
const manifestPayload: unknown = JSON.parse(manifestText);
// A missing version is a malformed payload, not an older compatible artifact. Reserve the
// version-mismatch recovery reason for otherwise-shaped payloads that explicitly declare a
// different version, so operators can distinguish corruption from an intentional upgrade.
const versionMismatch = [nodesPayload, edgesPayload, manifestPayload].some(value => !!value && typeof value === "object"
&& "schemaVersion" in value && (value as { schemaVersion?: unknown }).schemaVersion !== KNOWLEDGE_GRAPH_SCHEMA_VERSION)
|| !!manifestPayload && typeof manifestPayload === "object" && "extractorVersion" in manifestPayload
&& (manifestPayload as { extractorVersion?: unknown }).extractorVersion !== KNOWLEDGE_GRAPH_EXTRACTOR_VERSION;
if (versionMismatch) return { ok: false, reason: "version-mismatch" };
if (!nodesPayload || typeof nodesPayload !== "object" || !edgesPayload || typeof edgesPayload !== "object"
|| !hasExactKeys(nodesPayload, ["schemaVersion", "nodes"]) || !hasExactKeys(edgesPayload, ["schemaVersion", "edges"])
|| !Array.isArray((nodesPayload as { nodes?: unknown }).nodes) || !Array.isArray((edgesPayload as { edges?: unknown }).edges)
|| !(nodesPayload as { nodes: unknown[] }).nodes.every(validNode) || !(edgesPayload as { edges: unknown[] }).edges.every(validEdge) || !validManifest(manifestPayload)) {
return { ok: false, reason: "invalid-artifact" };
}
return { ok: true, graph: { schemaVersion: KNOWLEDGE_GRAPH_SCHEMA_VERSION, nodes: (nodesPayload as { nodes: GraphNode[] }).nodes, edges: (edgesPayload as { edges: GraphEdge[] }).edges }, manifest: manifestPayload };
} catch {
return { ok: false, reason: "invalid-artifact" };
}
}