diff --git a/apps/cli/src/commands/tree/export-graph.ts b/apps/cli/src/commands/tree/export-graph.ts new file mode 100644 index 0000000..b0a884b Binary files /dev/null and b/apps/cli/src/commands/tree/export-graph.ts differ diff --git a/apps/cli/src/commands/tree/index.ts b/apps/cli/src/commands/tree/index.ts index ee38fb0..057290e 100644 --- a/apps/cli/src/commands/tree/index.ts +++ b/apps/cli/src/commands/tree/index.ts @@ -4,6 +4,7 @@ import type { CommandModule, SubcommandModule } from "../types.js"; import { registerCommandGroup, registerSubcommands } from "../groups.js"; import { bindCommand } from "./bind.js"; import { bootstrapCommand } from "./bootstrap.js"; +import { exportGraphCommand } from "./export-graph.js"; import { generateCodeownersCommand } from "./generate-codeowners.js"; import { inspectCommand } from "./inspect.js"; import { injectContextCommand } from "./inject-context.js"; @@ -43,6 +44,7 @@ const treeSubcommands: SubcommandModule[] = [ verifyCommand, upgradeCommand, publishCommand, + exportGraphCommand, generateCodeownersCommand, installClaudeCodeHookCommand, injectContextCommand, diff --git a/apps/cli/tests/export-graph.test.ts b/apps/cli/tests/export-graph.test.ts new file mode 100644 index 0000000..e1ef6a5 --- /dev/null +++ b/apps/cli/tests/export-graph.test.ts @@ -0,0 +1,201 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + buildGraph, + extractSummary, + parseFrontmatter, + runExportGraph, +} from "../src/commands/tree/export-graph.js"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function write(root: string, relPath: string, content: string): void { + const full = join(root, relPath); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("parseFrontmatter", () => { + it("parses inline-list owners and tags", () => { + const result = parseFrontmatter( + `---\ntitle: Alice\nowners: [alice, "bob"]\ntags: [pm, design]\n---\n\nbody\n`, + ); + + expect(result.title).toBe("Alice"); + expect(result.owners).toEqual(["alice", "bob"]); + expect(result.tags).toEqual(["pm", "design"]); + expect(result.links).toEqual([]); + }); + + it("parses block-style lists", () => { + const result = parseFrontmatter( + `---\ntitle: Node\nowners:\n - alice\n - bob\nlinks:\n - "domains/design"\n---\n`, + ); + + expect(result.owners).toEqual(["alice", "bob"]); + expect(result.links).toEqual(["domains/design"]); + }); + + it("returns empty defaults on missing frontmatter", () => { + const result = parseFrontmatter("just markdown, no frontmatter\n"); + expect(result.title).toBeUndefined(); + expect(result.owners).toEqual([]); + expect(result.tags).toEqual([]); + expect(result.links).toEqual([]); + }); +}); + +describe("extractSummary", () => { + it("strips frontmatter and headings and collapses whitespace", () => { + const summary = extractSummary( + `---\ntitle: X\n---\n\n# Heading\n\nThis is the body of the doc.\n\nSecond paragraph.\n`, + ); + expect(summary).toBe("This is the body of the doc. Second paragraph."); + }); + + it("truncates long summaries with an ellipsis", () => { + const body = "word ".repeat(200).trim(); + const summary = extractSummary(body); + expect(summary.endsWith("…")).toBe(true); + expect(summary.length).toBeLessThanOrEqual(240); + }); +}); + +describe("buildGraph", () => { + it("emits nodes for every markdown file with parent edges", () => { + const root = makeTempDir("export-graph-"); + write(root, "NODE.md", "---\ntitle: Root\nowners: [serena]\n---\n\nroot body\n"); + write( + root, + "members/serena.md", + "---\ntitle: Serena Tan\nowners: [serena]\ntags: [pm]\n---\n\nfounder bio\n", + ); + write(root, "members/NODE.md", "---\ntitle: Members\nowners: [serena]\n---\n\n"); + write(root, "domains/design/NODE.md", "---\ntitle: Design\nowners: [serena]\n---\n\n"); + + const graph = buildGraph(root, { + repo: "agent-team-foundation/demo-tree", + now: () => new Date("2026-05-12T22:45:00Z"), + }); + + expect(graph.version).toBe(1); + expect(graph.repo).toBe("agent-team-foundation/demo-tree"); + expect(graph.generated_at).toBe("2026-05-12T22:45:00.000Z"); + + const ids = graph.nodes.map((node) => node.id).sort(); + expect(ids).toEqual(["NODE", "domains/design/NODE", "members/NODE", "members/serena"]); + + const serena = graph.nodes.find((node) => node.id === "members/serena"); + expect(serena).toBeDefined(); + expect(serena?.kind).toBe("member"); + expect(serena?.owner).toBe("serena"); + expect(serena?.tags).toEqual(["pm"]); + expect(serena?.size).toBeGreaterThan(0); + + const parentEdges = graph.edges.filter((edge) => edge.kind === "parent"); + const edgeKeys = parentEdges.map((edge) => `${edge.source}->${edge.target}`).sort(); + expect(edgeKeys).toContain("members/NODE->members/serena"); + }); + + it("emits link edges only when the target node exists", () => { + const root = makeTempDir("export-graph-"); + write(root, "NODE.md", "---\ntitle: Root\nowners: [serena]\n---\n"); + write( + root, + "members/serena.md", + `---\ntitle: Serena\nowners: [serena]\nlinks:\n - domains/design\n - domains/nonexistent\n---\n`, + ); + write(root, "domains/design/NODE.md", "---\ntitle: Design\nowners: [serena]\n---\n"); + + const graph = buildGraph(root); + const linkEdges = graph.edges.filter((edge) => edge.kind === "link"); + + expect(linkEdges).toHaveLength(1); + expect(linkEdges[0]).toMatchObject({ + source: "members/serena", + target: "domains/design/NODE", + kind: "link", + }); + }); + + it("skips hidden directories and node_modules", () => { + const root = makeTempDir("export-graph-"); + write(root, "NODE.md", "---\ntitle: Root\nowners: [serena]\n---\n"); + write(root, ".hidden/secret.md", "---\ntitle: secret\n---\n"); + write(root, "node_modules/pkg/README.md", "noise\n"); + write(root, "domains/design/NODE.md", "---\ntitle: Design\nowners: [serena]\n---\n"); + + const graph = buildGraph(root); + const ids = graph.nodes.map((node) => node.id); + + expect(ids).toContain("NODE"); + expect(ids).toContain("domains/design/NODE"); + expect(ids.some((id) => id.startsWith(".hidden"))).toBe(false); + expect(ids.some((id) => id.startsWith("node_modules"))).toBe(false); + }); + + it("classifies node kinds from top-level folder when frontmatter is silent", () => { + const root = makeTempDir("export-graph-"); + write(root, "NODE.md", "---\ntitle: Root\nowners: [serena]\n---\n"); + write(root, "members/alice.md", "---\ntitle: Alice\nowners: [alice]\n---\n"); + write(root, "decisions/2026-04-pivot.md", "---\ntitle: Pivot\nowners: [serena]\n---\n"); + write(root, "constraints/legal.md", "---\ntitle: Legal\nowners: [serena]\n---\n"); + write(root, "domains/design/NODE.md", "---\ntitle: Design\nowners: [serena]\n---\n"); + write(root, "freeform/notes.md", "---\ntitle: Notes\nowners: [serena]\n---\n"); + + const graph = buildGraph(root); + const kindById = Object.fromEntries(graph.nodes.map((node) => [node.id, node.kind])); + + expect(kindById["members/alice"]).toBe("member"); + expect(kindById["decisions/2026-04-pivot"]).toBe("decision"); + expect(kindById["constraints/legal"]).toBe("constraint"); + expect(kindById["domains/design/NODE"]).toBe("domain"); + expect(kindById["freeform/notes"]).toBe("node"); + }); +}); + +describe("runExportGraph", () => { + it("writes the graph to disk when --out is given", () => { + const root = makeTempDir("export-graph-"); + write(root, "NODE.md", "---\ntitle: Root\nowners: [serena]\n---\n"); + write(root, "members/serena.md", "---\ntitle: Serena\nowners: [serena]\n---\n"); + + const outPath = join(root, "graph.json"); + const result = runExportGraph({ + cwd: root, + out: "graph.json", + repo: "demo/tree", + now: () => new Date("2026-05-12T22:45:00Z"), + }); + + expect(result.exitCode).toBe(0); + expect(result.outputPath).toBe(outPath); + + const written = JSON.parse(readFileSync(outPath, "utf-8")) as { + repo: string; + nodes: unknown[]; + }; + expect(written.repo).toBe("demo/tree"); + expect(Array.isArray(written.nodes)).toBe(true); + expect(written.nodes.length).toBeGreaterThan(0); + }); +});