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>
145 lines
8.6 KiB
TypeScript
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" };
|
|
}
|
|
}
|