Add a committable, provenance-tagged knowledge graph layer with CLI generation and query support. - Extract TypeScript, Markdown, and FNXC knowledge into deterministic graph nodes and edges. - Persist recoverable graph artifacts outside ignored Fusion state and expose build/query CLI commands. - Document configuration and add core and CLI coverage for graph structure, serialization, and recovery. Files changed: .changeset/fn-8921-knowledge-graph.md | 7 + .gitattributes | 3 + AGENTS.md | 1 + docs/README.md | 2 + docs/cli-reference.md | 4 + docs/knowledge-graph.md | 37 +++++ docs/settings-reference.md | 4 + docs/storage.md | 2 + packages/cli/package.json | 3 +- .../__tests__/knowledge-graph-bundle-shape.test.ts | 4 + .../src/__tests__/knowledge-graph-command.test.ts | 115 ++++++++++++++ packages/cli/src/bin.ts | 19 +++ packages/cli/src/commands/knowledge-graph.ts | 79 ++++++++++ packages/cli/tsup.config.ts | 2 + packages/core/package.json | 4 +- packages/core/src/config/settings-schema.ts | 2 + packages/core/src/index.ts | 1 + .../__tests__/derive-modules.test.ts | 11 ++ .../__tests__/extract-file-composition.test.ts | 20 +++ .../knowledge-graph/__tests__/extract-fnxc.test.ts | 33 ++++ .../__tests__/extract-markdown.test.ts | 21 +++ .../__tests__/extract-typescript.test.ts | 30 ++++ .../__tests__/file-discovery.test.ts | 26 ++++ .../graph-artifact-not-gitignored.test.ts | 15 ++ .../__tests__/graph-builder-equivalence.test.ts | 76 ++++++++++ .../__tests__/graph-builder-incremental.test.ts | 52 +++++++ .../__tests__/graph-identity.test.ts | 11 ++ .../knowledge-graph/__tests__/graph-query.test.ts | 13 ++ .../__tests__/graph-serialization.test.ts | 29 ++++ .../__tests__/graph-store-recovery.test.ts | 106 +++++++++++++ .../__tests__/resolve-imports.test.ts | 10 ++ .../core/src/knowledge-graph/derive-modules.ts | 4 + packages/core/src/knowledge-graph/extract-file.ts | 6 + packages/core/src/knowledge-graph/extract-fnxc.ts | 168 +++++++++++++++++++++ .../core/src/knowledge-graph/extract-markdown.ts | 9 ++ .../core/src/knowledge-graph/extract-typescript.ts | 107 +++++++++++++ .../core/src/knowledge-graph/file-discovery.ts | 85 +++++++++++ packages/core/src/knowledge-graph/graph-builder.ts | 141 +++++++++++++++++ .../core/src/knowledge-graph/graph-manifest.ts | 4 + packages/core/src/knowledge-graph/graph-query.ts | 126 ++++++++++++++++ .../src/knowledge-graph/graph-serialization.ts | 134 ++++++++++++++++ packages/core/src/knowledge-graph/graph-store.ts | 97 ++++++++++++ packages/core/src/knowledge-graph/graph-types.ts | 54 +++++++ packages/core/src/knowledge-graph/index.ts | 14 ++ .../core/src/knowledge-graph/resolve-imports.ts | 4 + packages/core/src/types/settings/settings-scope.ts | 2 + pnpm-lock.yaml | 12 +- 47 files changed, 1700 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-8921 Fusion-Task-Lineage: 7014d0f1-fc47-454b-afe5-5f0d9b229f33 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const build = vi.hoisted(() => vi.fn().mockResolvedValue({
|
|
graph: { nodes: [], edges: [] },
|
|
changed: false,
|
|
stats: {
|
|
parsedFiles: 1,
|
|
reusedFiles: 0,
|
|
prunedFiles: 0,
|
|
addedFiles: 1,
|
|
deletedFiles: 0,
|
|
synthesizedImportEdges: 2,
|
|
derivedModuleCount: 0,
|
|
recoveryReason: null,
|
|
},
|
|
}));
|
|
const close = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
const settings = vi.hoisted(() => vi.fn().mockResolvedValue({ knowledgeGraphDir: ".graph" }));
|
|
const resolveProject = vi.hoisted(() => vi.fn().mockResolvedValue({
|
|
projectPath: "/project",
|
|
store: { getSettings: settings },
|
|
}));
|
|
|
|
class TestLockRetryExhaustedError extends Error {}
|
|
|
|
vi.mock("@fusion/core", () => ({
|
|
buildKnowledgeGraph: build,
|
|
resolveKnowledgeGraphDir: (root: string, dir?: string) =>
|
|
`${root}/${dir ?? ".fusion-knowledge/graph"}`,
|
|
}));
|
|
vi.mock("../project-context.js", () => ({
|
|
resolveProject,
|
|
closeProjectStore: close,
|
|
createLocalStore: vi.fn(),
|
|
asLocalProjectContext: vi.fn(),
|
|
}));
|
|
vi.mock("../lock-retry.js", () => ({
|
|
retryOnLock: (run: () => unknown) => run(),
|
|
LockRetryExhaustedError: TestLockRetryExhaustedError,
|
|
}));
|
|
|
|
const { runKnowledgeGraphBuild } = await import("../commands/knowledge-graph.js");
|
|
|
|
describe("knowledge graph CLI command", () => {
|
|
beforeEach(() => {
|
|
build.mockReset().mockResolvedValue({
|
|
graph: { nodes: [], edges: [] },
|
|
changed: false,
|
|
stats: {
|
|
parsedFiles: 1,
|
|
reusedFiles: 0,
|
|
prunedFiles: 0,
|
|
addedFiles: 1,
|
|
deletedFiles: 0,
|
|
synthesizedImportEdges: 2,
|
|
derivedModuleCount: 0,
|
|
recoveryReason: null,
|
|
},
|
|
});
|
|
close.mockClear();
|
|
settings.mockReset().mockResolvedValue({ knowledgeGraphDir: ".graph" });
|
|
resolveProject.mockClear();
|
|
});
|
|
|
|
it("uses an explicit directory over settings and emits complete JSON", async () => {
|
|
const output = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
|
|
await runKnowledgeGraphBuild({ dir: ".custom", json: true });
|
|
|
|
expect(build).toHaveBeenCalledWith(expect.objectContaining({ graphDir: "/project/.custom" }));
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(output.mock.calls[0]![0] as string)).toMatchObject({
|
|
recoveryReason: null,
|
|
synthesizedImportEdges: 2,
|
|
changed: false,
|
|
});
|
|
output.mockRestore();
|
|
});
|
|
|
|
it("uses the setting before the default directory", async () => {
|
|
await runKnowledgeGraphBuild();
|
|
expect(build).toHaveBeenCalledWith(expect.objectContaining({ graphDir: "/project/.graph" }));
|
|
});
|
|
|
|
it("reports all operator-facing build counters in human output", async () => {
|
|
const output = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
build.mockResolvedValueOnce({
|
|
graph: { nodes: [{}, {}], edges: [{}] },
|
|
changed: true,
|
|
stats: { parsedFiles: 2, reusedFiles: 3, prunedFiles: 4, addedFiles: 2, deletedFiles: 4, synthesizedImportEdges: 5, derivedModuleCount: 6, recoveryReason: "forced" },
|
|
});
|
|
|
|
await runKnowledgeGraphBuild();
|
|
|
|
expect(output).toHaveBeenCalledWith(expect.stringContaining("pruned 4"));
|
|
expect(output).toHaveBeenCalledWith(expect.stringContaining("synthesized imports 5"));
|
|
expect(output).toHaveBeenCalledWith(expect.stringContaining("derived modules 6"));
|
|
expect(output).toHaveBeenCalledWith(expect.stringContaining("recovery=forced"));
|
|
output.mockRestore();
|
|
});
|
|
|
|
it("closes the store when the settings retry is exhausted", async () => {
|
|
settings.mockRejectedValueOnce(new TestLockRetryExhaustedError("locked"));
|
|
|
|
await expect(runKnowledgeGraphBuild()).rejects.toThrow("locked");
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("closes the store when building fails", async () => {
|
|
build.mockRejectedValueOnce(new Error("failed"));
|
|
|
|
await expect(runKnowledgeGraphBuild()).rejects.toThrow("failed");
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|