From 5b974b4b61aff2269ab69c25660fde6755f07bec Mon Sep 17 00:00:00 2001 From: serenakeyitan Date: Tue, 12 May 2026 15:46:22 -0700 Subject: [PATCH] feat(cli): add `tree export-graph` for mindmap data export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements milestone M1 of the first-tree live mindmap (ST-6). Adds a new `first-tree tree export-graph` subcommand that walks a Context Tree repo, parses markdown frontmatter (title, owners, tags, kind, links), and emits a graph JSON consumable by the upcoming `TreeMindmap` component. The schema matches §2.1 of the ST-4 spec: versioned object with `nodes` (id, title, kind, owner, tags, summary, size, updated_at, depth) and `edges` (`parent` for folder hierarchy, `link` for explicit cross-edges declared via frontmatter `links:`). Hidden dirs, `node_modules`, and `__pycache__` are skipped. Output goes to stdout by default, or to a file with `--out `. Refs ST-6 Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/commands/tree/export-graph.ts | Bin 0 -> 11730 bytes apps/cli/src/commands/tree/index.ts | 2 + apps/cli/tests/export-graph.test.ts | 201 +++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 apps/cli/src/commands/tree/export-graph.ts create mode 100644 apps/cli/tests/export-graph.test.ts 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 0000000000000000000000000000000000000000..b0a884b3cdd9723a21fe6bb904ed962c98054ecf GIT binary patch literal 11730 zcmb_i>r&jv70z!zMO$%gG~O9uZ{?eIz=@Lp#$wbnXwgVhy1=rSsys~| zEKic}oYURv)(m8;68|t7sr&S~e&-_Pi)B@}>PG3G^QLVMugmFJEiSUWmdjeFS@7dc zUTAsLv}r3>S9RWM`#{~QSzRquR92bZoHfz22e~~2l*)9W$LgmlFUP9ZMcU?<_+9I! zDlT=ncbT^5*loAB*GmmXUsa1mT4rwPRDSFF&ZV!aveiGgV|Cb_nbmt$))oD6NwPuI zo8%{A_rbI(n^wI!c)$N~@8!pjI|ru+J8Da9e4b1u$Iav8GEN?EkB&?Gb9T zZ+AbOzTQ1J-G8~abBJA~zEX#}jgMF%rwcy0iN-2AJzZW;)9JZBJ*A(?Ja6f521C%_ zvo0^P=w#$5|7CCQ<-y-i_g?<(^wpnU9uV>Ge*eVr`*#2Rr~T8nyZf)hV;6dH2JL_% zS%go})4a*6lCD|SI?YQtyISCq4}GA2X4hb~@9^~xb-Fy?2a&fq46vmlLt1?`wCN(V z#Oa4eou3JLuzU}35Wab!RGw|BrmgdG{tUm`ye+hU;{q&fsx^Gk>guZ0^|rsfP3MjM zeRM)=nht(cU)!5}EB^&&lpX1des{|(ZFP2|}Foq-mrnQDONMA)>Uce#mIl_>?)HNA&Q#~cVYrU*|j^`TcSR)Df1GF+wqNqoM z_wX4>L(hmCc8U*a-RSI1jqqBeZL4d;4BINuKHFoU|5b~S{J}O7KiK7t{*)FMI2u@F z&P>k$AYH3J05g2iLoZrmx>;9FTc{37OS6xFiNkcJV}c$_W@Im5k*>`2EH8D&F51@D z{FgM|cD2y3ILI^*U|l<#{2@XVD?rvXZKvnq9!HW_Jh*-Ez_A2RX!JY4!JAwcSzM=A zmKDd-(Fs!%#6q+z7T|45VQE6YVm>#Ysn|SA3SG|IbG5a#rJjsJPQ>tF0Kw<&0M6HK z^C@r7J4XBwlAV&X^rpTu{emYi@rz3Z7S>$aTjU#twkuKvH z-d|sj)Yc2dS4NNoUR|W1vGMsR`rXM_`tQ-kyn~8ywk0{s3pnB`4I}mo2rYWX24l1% zE6sfekl%ZjAz)hmxvN^;#FuH&xyW{gVbz)wc*U;~gAXy#7W=|(;90-)*=UY1qiRw10V~eA{dWO#!5n$u1ti&3n?_kZyxeF18)%G zPW5u)Sy4?dz#VTmdI}vYRp?etyP6>IdwTp<1ELuS8-_%I622rh=)#L25SUf9ij73+ z)jI^Ms%DChN*Z>!jpKUzxZLQR$I5ERLTG}}p-sKJ9XH=T+7LHm8AVE3y*8Mj`jCCl z^f;7oz=>^McA6vxE|BW1$(YmKRxkF9?f4|I-Nt7ygA(Lh6d)ro#nuyk2HOy%LpB~F zv|2R_UVAjOy+*%C@VA z(cS<5SuSY5lFp2@620CXIQm87u|&!Vyevps95E{v1bNXuWm^{7YSVcomKaY=l4Ou# zMXye=zbcl+g9oSmcay-#9QUqtAoN|E0D#?fI&BZ7U>(B0)X3~K;&E5V)K%Ad83P98 z1Yi?fm;yL~lG0>5BfyfYh*e7LnLRXq{_VE_qvSF2@InxBu;BT_$>iv`IX*mjygiw` zu<3fR3N+-2GL?WIB;*B_CkwdGx76XV8?td@^l7({V7{KeJi4)uOil6xjp2}(ny9BE z=3=MJ;?eED|M{;kVFfTP(x%C0*KZLpabIPQsrj|_Fg>%0+Rsxc5(YIltz;9Dv;zui zEpUsm%Bo(Z1=Tq>uLgzK;Pe z{NYos3~m5wMRs_RFJI?%Ok*Ug6ok`RRTVldohHI5jJP>=I0rYZwY8LBs@g{oes@Vg?Pv< z##4BLz4V8MtTEAYTZdc@%V)={U?ves@&<9Er)^bV$1V;?EKaa1!4)i7rB5f&X8I@g zA_k42)VIi;x!nF~F?Ch(N z-BFs7n?a0;GQeMSj1K_C28d3Ufb#BH*x`HIIl5ygenl?!@&j*xS8Avgp5@sCv2*}MK2cI0jxa>mdgq&%t54qigH&Jk%nXm)P z!*rNn{A9S2T*%LYbB$7hfi-jBA+N!45r1IzDzJOu57C^0g&`iA5}$4w^!>Z6~ytdTgqjTSJLZmjZ!w zZrqnHC9kUiN3+VV(#| zSt0xdcMD?cBNHPYB*=XEf>EK5+k}T-79p%@`u6)BW@C`uZ0$3LfTfhdsh|#vVN(B6Zg)N0i70MT)hXV0-9vy`ntBSf7#& z@Ga7*^{n0?bcIt%ghp!rI324qPPi$uZGzVhX`%6*0eMV(<`S?Z0qz!+9uJQ9sZq(1N28VwRD<3Zj+dxY)t=qPh4dez{}o<|w&S7$$= z(WH~z21D3eN$M)8LH1TfU0p;fFD=8~a&MJ6oEjjK*jvOJ1P!M@lw}Ei3tL7Zh9_$w zPx^Z)8V44Fp>l@p_{N5srI4&Wud8l;&Ih{2(P)~AI{2ySiMYKw=t{3_4qL40m765f z*_iJK4HxMkxB=(E?>joMHK!f$%U^iq4XEQ=u=W+;vH!?LsX2Z76Vz>nk7d_VWO=8r z>-FkDw%B!dYh?y9g!c`1#G46DGa>`Sw!^c195m%cF)5~Bbg&+;T(`--ES6@r@eJ`o3xjT(Di9h&ysVHq>0`Y-fouEc|Z zp2SF4!yBjuv*k@p!8&~DW;O<&>c#!0F(qh5B=K{+*@#(s$j5`7??$)BcuXinIxo;O7|XM#5O zFin1jlxLEnqOb3=42Ta{b7!uSgxh*7Qhri3jWEDhGsR>(#)n5K-Vio)$GlxsS5=Mb zVouW`ne2@ygF)eGgPCF9y9>(vKq35+zjmSzBq(V)MZS{X(B}tyyqP!pIgMCfFdP^k zUE=I5ia&mrXtYg=Zl2@w5Fd~@FnT~xkj?#4w>(*}flOla45mrwGRUE*8ol7=1tS!R z%etEChJTtF1x4D!{sYz+lw9d&N^M9hC348D+@r$T#@hJ%k z(oOZ~1~)wKwyjgOTeihDW0hp&)ajdCZ=db%s9VMwkAk3QpxQ+bYQ zLVR=eD_u!SgmU;2$hTBPl__3-rWlAt{KGZq(T@3g6;W<{TYaRULWz)iai8!ubOKX+ z&_y%NO~#LiUQuwk+E5FL;BBp5 { + 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); + }); +});