From 51b94e5db4c628e2b5862d7ec0fffacf8d3e75f7 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 02:21:03 +0700 Subject: [PATCH 01/10] chore: ignore subagent-driven development scratch --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9072ea8..b4da3d9 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ docker-compose.override.yml **/evals-workspace/**/with_skill/ **/evals-workspace/**/without_skill/ **/evals-workspace/**/benchmark.json + +# Subagent-driven development scratch +.superpowers/ From 56d4ab8d70d2d1825ed6ca3239462592376b417a Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 02:29:07 +0700 Subject: [PATCH 02/10] feat(visualize): scene-graph contract and validator --- .github/workflows/frontend-ci.yml | 3 + package.json | 3 +- .../visualize/assets/scene-graph.schema.json | 75 +++++++++++ skills/visualize/assets/validate.mjs | 117 ++++++++++++++++++ tests/visualize/fixtures/minimal.json | 22 ++++ tests/visualize/validate.test.mjs | 95 ++++++++++++++ 6 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 skills/visualize/assets/scene-graph.schema.json create mode 100644 skills/visualize/assets/validate.mjs create mode 100644 tests/visualize/fixtures/minimal.json create mode 100644 tests/visualize/validate.test.mjs diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 7002fdf..428dc3f 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -8,6 +8,7 @@ on: pull_request: paths: - "skills/**" + - "tests/**" - "app/**" - "components/**" - "lib/**" @@ -21,6 +22,7 @@ on: - main paths: - "skills/**" + - "tests/**" - "app/**" - "components/**" - "lib/**" @@ -42,4 +44,5 @@ jobs: - run: npm ci - run: npm run lint - run: npm run type-check + - run: npm test - run: npm run build diff --git a/package.json b/package.json index 1cfacdf..cf5d476 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "node --test \"tests/**/*.test.mjs\"" }, "dependencies": { "@react-three/fiber": "^9.7.0", diff --git a/skills/visualize/assets/scene-graph.schema.json b/skills/visualize/assets/scene-graph.schema.json new file mode 100644 index 0000000..ac301dc --- /dev/null +++ b/skills/visualize/assets/scene-graph.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "cmk:visualize scene graph", + "type": "object", + "required": ["version", "repo", "diagramType", "altitude", "nodes", "edges", "folded", "gaps"], + "properties": { + "version": { "const": 1 }, + "repo": { + "type": "object", + "required": ["name", "commit"], + "properties": { "name": { "type": "string" }, "commit": { "type": "string" } } + }, + "diagramType": { "enum": ["system-architecture"] }, + "altitude": { + "type": "object", + "required": ["mode"], + "properties": { + "mode": { "enum": ["budget", "subsystem"] }, + "budget": { "type": "integer", "minimum": 1 }, + "grouping": { "type": "string" } + } + }, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "label", "kind", "citations"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "kind": { "type": "string", "minLength": 1 }, + "citations": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/citation" } }, + "children": { "type": ["object", "null"] } + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": ["source", "target", "path", "citations"], + "properties": { + "source": { "type": "string" }, + "target": { "type": "string" }, + "path": { "enum": ["control", "data"] }, + "citations": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/citation" } }, + "samples": { + "type": "array", + "items": { + "type": "object", + "required": ["text", "citation"], + "properties": { + "text": { "type": "string" }, + "citation": { "$ref": "#/$defs/citation" } + } + } + } + } + } + }, + "folded": { "type": "array" }, + "gaps": { "type": "array" } + }, + "$defs": { + "citation": { + "type": "object", + "required": ["file", "line"], + "properties": { + "file": { "type": "string", "minLength": 1 }, + "line": { "type": "integer", "minimum": 1 } + } + } + } +} diff --git a/skills/visualize/assets/validate.mjs b/skills/visualize/assets/validate.mjs new file mode 100644 index 0000000..c287f73 --- /dev/null +++ b/skills/visualize/assets/validate.mjs @@ -0,0 +1,117 @@ +export const SCENE_GRAPH_VERSION = 1; +export const MAX_DEPTH = 3; + +const PATH_KINDS = new Set(["control", "data"]); + +function isCitation(c) { + return Boolean(c) && typeof c.file === "string" && c.file.length > 0 + && Number.isInteger(c.line) && c.line > 0; +} + +function checkCitations(list, where, errors, subject) { + if (!Array.isArray(list) || list.length === 0) { + errors.push(`${where}: uncited ${subject}, citations must be a non-empty array`); + return; + } + list.forEach((c, i) => { + if (!isCitation(c)) { + errors.push(`${where}.citations[${i}]: must be { file: string, line: positive integer }`); + } + }); +} + +export function validateSceneGraph(doc, options = {}) { + const depth = options.depth ?? 0; + const prefix = options.prefix ?? ""; + const errors = []; + + if (!doc || typeof doc !== "object" || Array.isArray(doc)) { + return { valid: false, errors: [`${prefix}document must be an object`] }; + } + if (doc.version !== SCENE_GRAPH_VERSION) { + errors.push(`${prefix}version must be ${SCENE_GRAPH_VERSION}`); + } + if (depth > MAX_DEPTH) { + errors.push(`${prefix}nesting exceeds depth cap ${MAX_DEPTH}`); + return { valid: false, errors }; + } + + const ids = new Set(); + if (!Array.isArray(doc.nodes) || doc.nodes.length === 0) { + errors.push(`${prefix}nodes must be a non-empty array`); + } else { + doc.nodes.forEach((n, i) => { + const where = `${prefix}nodes[${i}]`; + if (!n || typeof n !== "object") { + errors.push(`${where}: must be an object`); + return; + } + if (typeof n.id !== "string" || n.id.length === 0) { + errors.push(`${where}: id must be a non-empty string`); + } else if (ids.has(n.id)) { + errors.push(`${where}: duplicate id "${n.id}"`); + } else { + ids.add(n.id); + } + if (typeof n.label !== "string" || n.label.length === 0) { + errors.push(`${where}: label must be a non-empty string`); + } + if (typeof n.kind !== "string" || n.kind.length === 0) { + errors.push(`${where}: kind must be a non-empty string`); + } + checkCitations(n.citations, where, errors, "node"); + if (n.children !== undefined && n.children !== null) { + const nested = validateSceneGraph(n.children, { + depth: depth + 1, + prefix: `${where}.children.`, + }); + errors.push(...nested.errors); + } + }); + } + + if (!Array.isArray(doc.edges)) { + errors.push(`${prefix}edges must be an array`); + } else { + doc.edges.forEach((e, i) => { + const where = `${prefix}edges[${i}]`; + if (!e || typeof e !== "object") { + errors.push(`${where}: must be an object`); + return; + } + for (const end of ["source", "target"]) { + if (typeof e[end] !== "string") { + errors.push(`${where}: ${end} must be a string`); + } else if (!ids.has(e[end])) { + errors.push(`${where}: ${end} references unknown node "${e[end]}"`); + } + } + if (!PATH_KINDS.has(e.path)) { + errors.push(`${where}: path must be one of control, data`); + } + checkCitations(e.citations, where, errors, "edge"); + if (e.samples !== undefined) { + if (!Array.isArray(e.samples)) { + errors.push(`${where}: samples must be an array`); + } else { + e.samples.forEach((s, j) => { + if (typeof s?.text !== "string") { + errors.push(`${where}.samples[${j}]: text must be a string`); + } + if (!isCitation(s?.citation)) { + errors.push(`${where}.samples[${j}]: citation must be { file, line }`); + } + }); + } + } + }); + } + + for (const field of ["folded", "gaps"]) { + if (!Array.isArray(doc[field])) { + errors.push(`${prefix}${field} must be an array (use [] when empty)`); + } + } + + return { valid: errors.length === 0, errors }; +} diff --git a/tests/visualize/fixtures/minimal.json b/tests/visualize/fixtures/minimal.json new file mode 100644 index 0000000..3d032ae --- /dev/null +++ b/tests/visualize/fixtures/minimal.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "repo": { "name": "fixture", "commit": "abc1234" }, + "diagramType": "system-architecture", + "altitude": { "mode": "budget", "budget": 20, "grouping": "directory" }, + "nodes": [ + { "id": "app", "label": "app/", "kind": "ui", "citations": [{ "file": "app/page.tsx", "line": 1 }] }, + { "id": "lib", "label": "lib/", "kind": "module", "citations": [{ "file": "lib/skills.ts", "line": 1 }] } + ], + "edges": [ + { + "id": "app->lib", + "source": "app", + "target": "lib", + "path": "data", + "citations": [{ "file": "app/page.tsx", "line": 3 }], + "samples": [{ "text": "getSkills()", "citation": { "file": "app/page.tsx", "line": 3 } }] + } + ], + "folded": [], + "gaps": [] +} diff --git a/tests/visualize/validate.test.mjs b/tests/visualize/validate.test.mjs new file mode 100644 index 0000000..b66814b --- /dev/null +++ b/tests/visualize/validate.test.mjs @@ -0,0 +1,95 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { validateSceneGraph, SCENE_GRAPH_VERSION } from "../../skills/visualize/assets/validate.mjs"; + +const node = (id, over = {}) => ({ + id, label: id, kind: "module", + citations: [{ file: `${id}/index.ts`, line: 1 }], + ...over, +}); + +const graph = (over = {}) => ({ + version: SCENE_GRAPH_VERSION, + repo: { name: "fixture", commit: "abc1234" }, + diagramType: "system-architecture", + altitude: { mode: "budget", budget: 20, grouping: "directory" }, + nodes: [node("app"), node("lib")], + edges: [{ + id: "app->lib", source: "app", target: "lib", path: "data", + citations: [{ file: "app/page.tsx", line: 3 }], + samples: [{ text: "getSkills()", citation: { file: "app/page.tsx", line: 3 } }], + }], + folded: [], gaps: [], + ...over, +}); + +test("a fully cited graph is valid", () => { + assert.deepEqual(validateSceneGraph(graph()), { valid: true, errors: [] }); +}); + +test("an uncited node is rejected", () => { + const r = validateSceneGraph(graph({ nodes: [node("app", { citations: [] }), node("lib")] })); + assert.equal(r.valid, false); + assert.ok(r.errors.some((e) => e.includes("nodes[0]") && e.includes("uncited"))); +}); + +test("an uncited edge is rejected", () => { + const g = graph(); + g.edges[0].citations = []; + const r = validateSceneGraph(g); + assert.equal(r.valid, false); + assert.ok(r.errors.some((e) => e.includes("edges[0]") && e.includes("uncited"))); +}); + +test("an edge pointing at a missing node is rejected", () => { + const g = graph(); + g.edges[0].target = "nope"; + const r = validateSceneGraph(g); + assert.equal(r.valid, false); + assert.ok(r.errors.some((e) => e.includes('unknown node "nope"'))); +}); + +test("duplicate node ids are rejected", () => { + const r = validateSceneGraph(graph({ nodes: [node("app"), node("app")] })); + assert.equal(r.valid, false); + assert.ok(r.errors.some((e) => e.includes("duplicate id"))); +}); + +test("an unknown path kind is rejected", () => { + const g = graph(); + g.edges[0].path = "vibes"; + assert.equal(validateSceneGraph(g).valid, false); +}); + +test("nesting deeper than the depth cap is rejected", () => { + let deepest = graph({ nodes: [node("leaf")], edges: [] }); + for (let i = 0; i < 4; i += 1) { + deepest = graph({ nodes: [node(`n${i}`, { children: deepest })], edges: [] }); + } + const r = validateSceneGraph(deepest); + assert.equal(r.valid, false); + assert.ok(r.errors.some((e) => e.includes("depth cap"))); +}); + +test("errors accumulate rather than stopping at the first", () => { + const r = validateSceneGraph(graph({ nodes: [node("a", { citations: [] }), node("a")] })); + assert.ok(r.errors.length >= 2); +}); + +test("the schema's required fields match what the validator enforces", () => { + const schema = JSON.parse( + readFileSync(new URL("../../skills/visualize/assets/scene-graph.schema.json", import.meta.url)), + ); + assert.deepEqual( + [...schema.required].sort(), + ["altitude", "diagramType", "edges", "folded", "gaps", "nodes", "repo", "version"], + ); + assert.deepEqual([...schema.properties.nodes.items.required].sort(), ["citations", "id", "kind", "label"]); + assert.deepEqual([...schema.properties.edges.items.required].sort(), ["citations", "path", "source", "target"]); +}); + +test("the committed fixture validates", () => { + const fixture = JSON.parse(readFileSync(new URL("./fixtures/minimal.json", import.meta.url))); + assert.deepEqual(validateSceneGraph(fixture), { valid: true, errors: [] }); +}); From 1501292ced080c523a5b282b2e7b2c5670d02036 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 02:36:31 +0700 Subject: [PATCH 03/10] fix(visualize): enforce repo, diagramType, and altitude in validator Reviewer found validateSceneGraph never checked doc.repo, doc.diagramType, or doc.altitude despite the schema declaring all three required, so a document omitting them passed as valid. Enforce them following the existing error-message style, and make the schema drift test actually call validateSceneGraph for every schema-required field instead of only comparing hardcoded literals. --- skills/visualize/assets/validate.mjs | 31 ++++++++++++++++++++++++++++ tests/visualize/validate.test.mjs | 7 +++++++ 2 files changed, 38 insertions(+) diff --git a/skills/visualize/assets/validate.mjs b/skills/visualize/assets/validate.mjs index c287f73..271398f 100644 --- a/skills/visualize/assets/validate.mjs +++ b/skills/visualize/assets/validate.mjs @@ -2,6 +2,7 @@ export const SCENE_GRAPH_VERSION = 1; export const MAX_DEPTH = 3; const PATH_KINDS = new Set(["control", "data"]); +const ALTITUDE_MODES = new Set(["budget", "subsystem"]); function isCitation(c) { return Boolean(c) && typeof c.file === "string" && c.file.length > 0 @@ -36,6 +37,36 @@ export function validateSceneGraph(doc, options = {}) { return { valid: false, errors }; } + if (!doc.repo || typeof doc.repo !== "object" || Array.isArray(doc.repo)) { + errors.push(`${prefix}repo must be an object`); + } else { + if (typeof doc.repo.name !== "string" || doc.repo.name.length === 0) { + errors.push(`${prefix}repo.name must be a non-empty string`); + } + if (typeof doc.repo.commit !== "string" || doc.repo.commit.length === 0) { + errors.push(`${prefix}repo.commit must be a non-empty string`); + } + } + + if (doc.diagramType !== "system-architecture") { + errors.push(`${prefix}diagramType must be "system-architecture"`); + } + + if (!doc.altitude || typeof doc.altitude !== "object" || Array.isArray(doc.altitude)) { + errors.push(`${prefix}altitude must be an object`); + } else { + if (!ALTITUDE_MODES.has(doc.altitude.mode)) { + errors.push(`${prefix}altitude.mode must be one of budget, subsystem`); + } + if (doc.altitude.budget !== undefined + && !(Number.isInteger(doc.altitude.budget) && doc.altitude.budget > 0)) { + errors.push(`${prefix}altitude.budget must be a positive integer`); + } + if (doc.altitude.grouping !== undefined && typeof doc.altitude.grouping !== "string") { + errors.push(`${prefix}altitude.grouping must be a string`); + } + } + const ids = new Set(); if (!Array.isArray(doc.nodes) || doc.nodes.length === 0) { errors.push(`${prefix}nodes must be a non-empty array`); diff --git a/tests/visualize/validate.test.mjs b/tests/visualize/validate.test.mjs index b66814b..643438d 100644 --- a/tests/visualize/validate.test.mjs +++ b/tests/visualize/validate.test.mjs @@ -87,6 +87,13 @@ test("the schema's required fields match what the validator enforces", () => { ); assert.deepEqual([...schema.properties.nodes.items.required].sort(), ["citations", "id", "kind", "label"]); assert.deepEqual([...schema.properties.edges.items.required].sort(), ["citations", "path", "source", "target"]); + + for (const field of schema.required) { + const g = graph(); + delete g[field]; + const r = validateSceneGraph(g); + assert.equal(r.valid, false, `expected validateSceneGraph to reject a document missing "${field}"`); + } }); test("the committed fixture validates", () => { From ec06dcb1c6bb3619f3a082544465d53e40d49d98 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 02:42:04 +0700 Subject: [PATCH 04/10] feat(visualize): static svg renderer --- skills/visualize/assets/render-svg.mjs | 57 ++++++++++++++++++++++++++ tests/visualize/render-svg.test.mjs | 41 ++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 skills/visualize/assets/render-svg.mjs create mode 100644 tests/visualize/render-svg.test.mjs diff --git a/skills/visualize/assets/render-svg.mjs b/skills/visualize/assets/render-svg.mjs new file mode 100644 index 0000000..6039259 --- /dev/null +++ b/skills/visualize/assets/render-svg.mjs @@ -0,0 +1,57 @@ +import { validateSceneGraph } from "./validate.mjs"; + +const CELL = 180; +const BOX_W = 140; +const BOX_H = 56; +const PAD = 40; +const PER_ROW = 4; + +export function escapeXml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +export function layout(nodes) { + return nodes.map((n, i) => ({ + node: n, + x: PAD + (i % PER_ROW) * CELL, + y: PAD + Math.floor(i / PER_ROW) * CELL, + })); +} + +export function renderSvg(doc) { + const { valid, errors } = validateSceneGraph(doc); + if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`); + + const placed = layout(doc.nodes); + const byId = new Map(placed.map((p) => [p.node.id, p])); + const rows = Math.ceil(doc.nodes.length / PER_ROW); + const width = PAD * 2 + Math.min(doc.nodes.length, PER_ROW) * CELL; + const height = PAD * 2 + rows * CELL; + + const edges = doc.edges.map((e) => { + const a = byId.get(e.source); + const b = byId.get(e.target); + const dash = e.path === "control" ? ' stroke-dasharray="6 4"' : ""; + const cite = `${e.citations[0].file}:${e.citations[0].line}`; + return `${escapeXml(`${e.source} to ${e.target} at ${cite}`)}`; + }); + + const boxes = placed.map(({ node, x, y }) => { + const c = node.citations[0]; + const title = escapeXml(`${node.label} at ${c.file}:${c.line}`); + return `${escapeXml(node.label)}${title}`; + }); + + const notes = []; + if (doc.folded.length > 0) notes.push(`folded: ${doc.folded.length}`); + if (doc.gaps.length > 0) notes.push(`unresolved: ${doc.gaps.length}`); + const legend = notes.length > 0 + ? `${escapeXml(notes.join(" | "))}` + : ""; + + return `${edges.join("")}${boxes.join("")}${legend}`; +} diff --git a/tests/visualize/render-svg.test.mjs b/tests/visualize/render-svg.test.mjs new file mode 100644 index 0000000..4c7a954 --- /dev/null +++ b/tests/visualize/render-svg.test.mjs @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { renderSvg } from "../../skills/visualize/assets/render-svg.mjs"; + +const fixture = () => JSON.parse(readFileSync(new URL("./fixtures/minimal.json", import.meta.url))); + +test("renders an svg root", () => { + const out = renderSvg(fixture()); + assert.match(out, /^$/); +}); + +test("every node appears with its label", () => { + const out = renderSvg(fixture()); + assert.ok(out.includes("app/")); + assert.ok(out.includes("lib/")); +}); + +test("every node carries its citation in a title element", () => { + const out = renderSvg(fixture()); + assert.ok(out.includes("app/page.tsx:1")); +}); + +test("output is deterministic", () => { + assert.equal(renderSvg(fixture()), renderSvg(fixture())); +}); + +test("an invalid document throws rather than rendering", () => { + const bad = fixture(); + bad.nodes[0].citations = []; + assert.throws(() => renderSvg(bad), /uncited node/); +}); + +test("labels are escaped", () => { + const doc = fixture(); + doc.nodes[0].label = "a&c"; + const out = renderSvg(doc); + assert.ok(out.includes("a<b>&c")); + assert.ok(!out.includes("a&c")); +}); From 6af4dce21af2827dc15dd7f71de1622143df87b7 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 02:47:24 +0700 Subject: [PATCH 05/10] feat(visualize): interactive isometric renderer --- skills/visualize/assets/render-html.mjs | 86 +++++++++++++++++++++++++ tests/visualize/render-html.test.mjs | 44 +++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 skills/visualize/assets/render-html.mjs create mode 100644 tests/visualize/render-html.test.mjs diff --git a/skills/visualize/assets/render-html.mjs b/skills/visualize/assets/render-html.mjs new file mode 100644 index 0000000..451424a --- /dev/null +++ b/skills/visualize/assets/render-html.mjs @@ -0,0 +1,86 @@ +import { validateSceneGraph } from "./validate.mjs"; + +const STYLES = new Set(["isometric", "flat", "three-d"]); + +function escapeHtml(s) { + return String(s).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +const CLIENT = ` +const doc = JSON.parse(document.getElementById("scene-graph").textContent); +const svg = document.getElementById("stage"); +const panel = document.getElementById("inspector"); +const place = (i) => { + const col = i % 4, row = Math.floor(i / 4); + return { x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }; +}; +const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)])); +const ns = "http://www.w3.org/2000/svg"; +const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; }; +for (const e of doc.edges) { + const a = pos.get(e.source), b = pos.get(e.target); + const line = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "currentColor", "stroke-width": 1.2, opacity: 0.5 }); + if (e.path === "control") line.setAttribute("stroke-dasharray", "6 4"); + svg.appendChild(line); + (e.samples || []).forEach((s, k) => { + const dot = el("circle", { r: 5, fill: "currentColor", cursor: "pointer" }); + const anim = el("animateMotion", { + dur: "3s", repeatCount: "indefinite", begin: (k * 0.6) + "s", + path: "M" + a.x + " " + a.y + " L" + b.x + " " + b.y, + }); + dot.appendChild(anim); + dot.addEventListener("click", () => { + panel.textContent = s.text + "\\n\\n" + s.citation.file + ":" + s.citation.line; + }); + svg.appendChild(dot); + }); +} +for (const n of doc.nodes) { + const p = pos.get(n.id); + const g = el("g", { cursor: "pointer" }); + g.appendChild(el("rect", { x: p.x - 46, y: p.y - 26, width: 92, height: 52, rx: 4, fill: "none", stroke: "currentColor" })); + const t = el("text", { x: p.x, y: p.y + 5, "text-anchor": "middle", "font-family": "monospace", "font-size": 12, fill: "currentColor" }); + t.textContent = n.label; + g.appendChild(t); + g.addEventListener("click", () => { + panel.textContent = n.label + "\\n\\n" + n.citations.map((c) => c.file + ":" + c.line).join("\\n"); + }); + svg.appendChild(g); +} +`; + +export function renderHtml(doc, options = {}) { + const style = options.style ?? "isometric"; + if (!STYLES.has(style)) throw new Error(`unknown style "${style}"`); + const { valid, errors } = validateSceneGraph(doc); + if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`); + + const folded = doc.folded + .map((f) => `
  • ${escapeHtml(f.nodeId ?? "group")}: ${escapeHtml(String((f.files ?? []).length))} files folded
  • `) + .join(""); + const gaps = doc.gaps + .map((g) => `
  • ${escapeHtml(g.description)} (${escapeHtml(g.reason)})
  • `) + .join(""); + + return ` +${escapeHtml(doc.repo.name)} map + + + + + +`; +} diff --git a/tests/visualize/render-html.test.mjs b/tests/visualize/render-html.test.mjs new file mode 100644 index 0000000..193afca --- /dev/null +++ b/tests/visualize/render-html.test.mjs @@ -0,0 +1,44 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { renderHtml } from "../../skills/visualize/assets/render-html.mjs"; + +const fixture = () => JSON.parse(readFileSync(new URL("./fixtures/minimal.json", import.meta.url))); + +test("returns a standalone html document", () => { + const out = renderHtml(fixture()); + assert.match(out, /^/i); + assert.ok(out.includes("")); +}); + +test("embeds the scene graph as inline json", () => { + const out = renderHtml(fixture()); + assert.ok(out.includes(' in embedded scene-graph JSON The scene-graph payload was embedded via raw JSON.stringify, so a node label, citation, or sample containing the literal substring would truncate the script element early: JSON.parse fails client-side, the inspector/dots/click wiring never runs, and the remainder of the payload becomes a live executing script (injection). Add an exported embedJson helper that escapes < as \u003c (still valid JSON, round-trips exactly) and use it at the one call site. Add a regression test that renders a label containing , extracts the embedded payload, and asserts it parses back to the original content. --- skills/visualize/assets/render-html.mjs | 6 +++++- tests/visualize/render-html.test.mjs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/skills/visualize/assets/render-html.mjs b/skills/visualize/assets/render-html.mjs index 451424a..b2ae3ba 100644 --- a/skills/visualize/assets/render-html.mjs +++ b/skills/visualize/assets/render-html.mjs @@ -6,6 +6,10 @@ function escapeHtml(s) { return String(s).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } +export function embedJson(value) { + return JSON.stringify(value).replaceAll("<", "\\u003c"); +} + const CLIENT = ` const doc = JSON.parse(document.getElementById("scene-graph").textContent); const svg = document.getElementById("stage"); @@ -80,7 +84,7 @@ ul{padding-left:16px;margin:0}

    Folded

      ${folded || "
    • nothing folded
    • "}

    Unresolved

      ${gaps || "
    • nothing unresolved
    • "}
    - + `; } diff --git a/tests/visualize/render-html.test.mjs b/tests/visualize/render-html.test.mjs index 193afca..eba7546 100644 --- a/tests/visualize/render-html.test.mjs +++ b/tests/visualize/render-html.test.mjs @@ -42,3 +42,19 @@ test("an invalid document throws rather than rendering", () => { test("an unknown style is rejected", () => { assert.throws(() => renderHtml(fixture(), { style: "hologram" }), /unknown style/); }); + +test("a node label containing does not break out of the scene-graph payload", () => { + const doc = fixture(); + const label = "weird label"; + doc.nodes[0].label = label; + const out = renderHtml(doc); + + const openTag = '", start); + const payload = out.slice(start, end); + + assert.ok(!payload.includes("")); + const parsed = JSON.parse(payload); + assert.equal(parsed.nodes[0].label, label); +}); From fd2bd805a96003b402c315ae0a76811a9faf9e0e Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 03:00:27 +0700 Subject: [PATCH 07/10] feat(visualize): flat and 3d style projections --- skills/visualize/assets/render-html.mjs | 13 +++++-- tests/visualize/render-html.test.mjs | 49 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/skills/visualize/assets/render-html.mjs b/skills/visualize/assets/render-html.mjs index b2ae3ba..4004a8b 100644 --- a/skills/visualize/assets/render-html.mjs +++ b/skills/visualize/assets/render-html.mjs @@ -14,10 +14,15 @@ const CLIENT = ` const doc = JSON.parse(document.getElementById("scene-graph").textContent); const svg = document.getElementById("stage"); const panel = document.getElementById("inspector"); -const place = (i) => { - const col = i % 4, row = Math.floor(i / 4); - return { x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }; +const projections = { + isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }), + flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }), + "three-d": (col, row) => { + const d = 1 - row * 0.12; + return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d }; + }, }; +const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4)); const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)])); const ns = "http://www.w3.org/2000/svg"; const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; }; @@ -84,7 +89,7 @@ ul{padding-left:16px;margin:0}

    Folded

      ${folded || "
    • nothing folded
    • "}

    Unresolved

      ${gaps || "
    • nothing unresolved
    • "}
    - + `; } diff --git a/tests/visualize/render-html.test.mjs b/tests/visualize/render-html.test.mjs index eba7546..ecf307a 100644 --- a/tests/visualize/render-html.test.mjs +++ b/tests/visualize/render-html.test.mjs @@ -58,3 +58,52 @@ test("a node label containing does not break out of the scene-graph pa const parsed = JSON.parse(payload); assert.equal(parsed.nodes[0].label, label); }); + +test("the chosen style is embedded in the scene graph the client reads", () => { + for (const style of ["isometric", "flat", "three-d"]) { + const out = renderHtml(fixture(), { style }); + const json = out.slice(out.indexOf('id="scene-graph"'), out.indexOf("", out.indexOf('id="scene-graph"'))); + assert.ok(json.includes(`"style":"${style}"`), `${style} not embedded in the scene graph payload`); + } +}); + +test("each style produces different output", () => { + const d = fixture(); + const iso = renderHtml(d, { style: "isometric" }); + const flat = renderHtml(d, { style: "flat" }); + const td = renderHtml(d, { style: "three-d" }); + assert.notEqual(iso, flat); + assert.notEqual(flat, td); + assert.notEqual(iso, td); +}); + +test("each style is individually deterministic", () => { + for (const style of ["isometric", "flat", "three-d"]) { + assert.equal(renderHtml(fixture(), { style }), renderHtml(fixture(), { style })); + } +}); + +test("every style still embeds the same repo identity", () => { + const marker = '"repo":{"name":"fixture","commit":"abc1234"}'; + for (const style of ["isometric", "flat", "three-d"]) { + assert.ok(renderHtml(fixture(), { style }).includes(marker)); + } +}); + +test("a node label containing does not break out of the scene-graph payload at any style", () => { + for (const style of ["isometric", "flat", "three-d"]) { + const doc = fixture(); + const label = "weird label"; + doc.nodes[0].label = label; + const out = renderHtml(doc, { style }); + + const openTag = '", start); + const payload = out.slice(start, end); + + assert.ok(!payload.includes("")); + const parsed = JSON.parse(payload); + assert.equal(parsed.nodes[0].label, label); + } +}); From 53dcdcada2cede60a4a2408e1b916854357b4a0b Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 03:07:53 +0700 Subject: [PATCH 08/10] fix(visualize): hoist style projections to a shared, tested module export --- skills/visualize/assets/render-html.mjs | 27 ++++++++++++++---- tests/visualize/render-html.test.mjs | 37 ++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/skills/visualize/assets/render-html.mjs b/skills/visualize/assets/render-html.mjs index 4004a8b..080be36 100644 --- a/skills/visualize/assets/render-html.mjs +++ b/skills/visualize/assets/render-html.mjs @@ -10,11 +10,14 @@ export function embedJson(value) { return JSON.stringify(value).replaceAll("<", "\\u003c"); } -const CLIENT = ` -const doc = JSON.parse(document.getElementById("scene-graph").textContent); -const svg = document.getElementById("stage"); -const panel = document.getElementById("inspector"); -const projections = { +// The single source of truth for how each style places a node on the grid. +// Exported so tests can pin the geometry directly, in addition to the tests +// that exercise the server-rendered HTML/JSON payload. `CLIENT` below embeds +// this same table into the browser-side script by serializing each function +// with `Function.prototype.toString()`, so the browser and the test suite +// are provably running the identical arithmetic rather than two copies that +// could silently drift apart. +export const PROJECTIONS = { isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }), flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }), "three-d": (col, row) => { @@ -22,6 +25,20 @@ const projections = { return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d }; }, }; + +// Fixed order (not Object.keys, which is not guaranteed stable across engines +// for non-integer-like keys) so the generated script text is deterministic. +const PROJECTION_NAMES = ["isometric", "flat", "three-d"]; + +const PROJECTIONS_SRC = `{\n${PROJECTION_NAMES.map( + (name) => ` ${JSON.stringify(name)}: ${PROJECTIONS[name].toString()}`, +).join(",\n")}\n}`; + +const CLIENT = ` +const doc = JSON.parse(document.getElementById("scene-graph").textContent); +const svg = document.getElementById("stage"); +const panel = document.getElementById("inspector"); +const projections = ${PROJECTIONS_SRC}; const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4)); const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)])); const ns = "http://www.w3.org/2000/svg"; diff --git a/tests/visualize/render-html.test.mjs b/tests/visualize/render-html.test.mjs index ecf307a..9dec9e8 100644 --- a/tests/visualize/render-html.test.mjs +++ b/tests/visualize/render-html.test.mjs @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { renderHtml } from "../../skills/visualize/assets/render-html.mjs"; +import { renderHtml, PROJECTIONS } from "../../skills/visualize/assets/render-html.mjs"; const fixture = () => JSON.parse(readFileSync(new URL("./fixtures/minimal.json", import.meta.url))); @@ -107,3 +107,38 @@ test("a node label containing does not break out of the scene-graph pa assert.equal(parsed.nodes[0].label, label); } }); + +test("PROJECTIONS: all three styles place the same (col, row) differently in row 1", () => { + const col = 1; + const row = 1; + const iso = PROJECTIONS.isometric(col, row); + const flat = PROJECTIONS.flat(col, row); + const td = PROJECTIONS["three-d"](col, row); + assert.notDeepEqual(iso, flat); + assert.notDeepEqual(flat, td); + assert.notDeepEqual(iso, td); +}); + +test("PROJECTIONS: isometric shears, increasing row changes x for a fixed col", () => { + const col = 2; + const a = PROJECTIONS.isometric(col, 0); + const b = PROJECTIONS.isometric(col, 1); + assert.notEqual(a.x, b.x); +}); + +test("PROJECTIONS: flat does not shear, increasing row leaves x unchanged for a fixed col", () => { + const col = 2; + const a = PROJECTIONS.flat(col, 0); + const b = PROJECTIONS.flat(col, 1); + assert.equal(a.x, b.x); +}); + +test("PROJECTIONS: three-d compresses column spacing with depth", () => { + const rowNear = PROJECTIONS["three-d"](0, 0); + const rowNearNextCol = PROJECTIONS["three-d"](1, 0); + const rowFar = PROJECTIONS["three-d"](0, 1); + const rowFarNextCol = PROJECTIONS["three-d"](1, 1); + const distNear = Math.abs(rowNearNextCol.x - rowNear.x); + const distFar = Math.abs(rowFarNextCol.x - rowFar.x); + assert.ok(distFar < distNear, `expected row-1 column spacing (${distFar}) to be smaller than row-0 spacing (${distNear})`); +}); From ca7720c4a6138b6968b42abf556e8b2664f57e10 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 03:17:47 +0700 Subject: [PATCH 09/10] feat(visualize): skill surface, analyzer procedure, evals --- skills/visualize/SKILL.md | 66 ++++++++++++++++++ skills/visualize/TESTS.md | 79 ++++++++++++++++++++++ skills/visualize/eval.json | 35 ++++++++++ skills/visualize/references/analysis.md | 40 +++++++++++ skills/visualize/references/scene-graph.md | 38 +++++++++++ 5 files changed, 258 insertions(+) create mode 100644 skills/visualize/SKILL.md create mode 100644 skills/visualize/TESTS.md create mode 100644 skills/visualize/eval.json create mode 100644 skills/visualize/references/analysis.md create mode 100644 skills/visualize/references/scene-graph.md diff --git a/skills/visualize/SKILL.md b/skills/visualize/SKILL.md new file mode 100644 index 0000000..93a7ef0 --- /dev/null +++ b/skills/visualize/SKILL.md @@ -0,0 +1,66 @@ +--- +name: cmk:visualize +description: This skill should be used when the user asks to "visualize this repo", "map this codebase", "draw the architecture", "show me how this fits together", or wants a diagram of a system traced from its real code. Produces a validated scene graph and renders it as an interactive isometric map or a static SVG, citing the file behind every node and edge. +version: 0.1.0 +--- + +# Visualize: Repo Map + +Trace a codebase into one validated scene graph and render it as a map a person and an agent can discuss, where every building and every connection carries the file that proves it. This is the operational form of `docs/design/visualize-repo-map.md`; read that for the full contract behind what follows. + +## The hard rule + +``` +EMIT DATA, NEVER PIXELS. VALIDATE BEFORE YOU RENDER. NEVER RENDER INVALID. +``` + +Analysis produces exactly one JSON scene graph. Renderers are fixed code, not a per-invocation drawing decision made by a language model. Before calling either renderer, pass the document to `validateSceneGraph` from `skills/visualize/assets/validate.mjs`. If `valid` is `false`, stop there: do not call `renderSvg` or `renderHtml`. Report the returned `errors` array to the user instead of a picture. There is no partial render and no "close enough" document — an invalid scene graph blocks rendering outright. + +## The citation invariant + +A node or an edge without a `file:line` gathered **in this run** does not appear in the document at all, not even as a low-confidence guess. This is not a judgment call you relax under time pressure: `validateSceneGraph` rejects any node or edge whose `citations` array is empty, and an invalid document cannot render (see above). If you suspect a relationship but cannot point at the line that proves it, it belongs in `gaps[]`, never in `edges[]`. Full field meaning, and why this rule is schema-enforced rather than a convention, is in `references/scene-graph.md`. + +## Redaction + +Before any payload sample enters the scene graph: + +1. Never sample from a file matching the repository's ignore patterns (`.gitignore` and equivalents) or its secret patterns (`.env*`, key material, credential files, anything a secret scanner would flag). Skip the file entirely rather than sample from it. +2. Redact by pattern — API keys, tokens, connection strings, private key blocks — before the remaining text becomes a `samples[]` entry. + +Redaction happens before the document exists, not before it renders: the scene graph itself is the publishable artifact, so a secret that reaches it has already leaked. + +## Form: four independent axes + +Resolve each from the user's own words; fall back to the default marked `*`. Independence is the point — choosing `three-d` never touches altitude, and choosing `subsystem` never touches style. + +| Axis | Values | +|---|---| +| Diagram type | `system-architecture`\* (only type shipped) | +| Style | `isometric`\*, `flat`, `three-d` | +| Altitude | `budget`\* (12 to 20 nodes, folds to fit), `subsystem` (named slice, no folding) | +| Output form | interactive artifact\*, static SVG | + +## Procedure + +1. Resolve the four axes above from what was asked. +2. Trace the repository and build the scene graph. Full procedure, including the `docs/ai/` routing hint and payload sampling, is in `references/analysis.md`. +3. Validate with `assets/validate.mjs`. Invalid stops here — see The hard rule. +4. Render: `assets/render-html.mjs` (`renderHtml(doc, { style })`) for the interactive form, `assets/render-svg.mjs` (`renderSvg(doc)`) for static SVG. Publish the interactive form as an artifact when the host supports one; write the SVG into the repo when the invocation warrants durability, or when no artifact host is available. +5. Surface `folded` and `gaps` alongside the render, not buried in it. Say what was hidden — a fold or an unresolved relationship passing silently is the failure this skill exists to prevent. + +## Drill down + +`nodes[].children` is an optional nested scene graph, generated eagerly in the same pass rather than lazily on a later click, up to the depth cap of 3 enforced by `validateSceneGraph`. See `references/scene-graph.md` for the field. + +## Routing hint, not a source of truth + +If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first as a map of where to look. Treat it strictly as a hint: a stale `docs/ai/` costs time, never correctness, because no citation is ever copied from it. Every citation in the scene graph must come from a `file:line` actually read in this run, whether or not `docs/ai/` pointed there first. + +## Before publishing + +An interactive artifact is typically link-shareable, and a repo map exposes internal structure and real code snippets. Confirm with the user before publishing a map of a private repository — rendering intent is not the same as sharing intent. + +## References + +- `references/analysis.md` — the tracing procedure: the `docs/ai/` routing hint, enumerating entrypoints and manifests, tracing imports and call sites with citations, the altitude fold, payload sampling with redaction, and what goes in `gaps[]`. +- `references/scene-graph.md` — field-by-field companion to `assets/scene-graph.schema.json`: what each field means, why citations are required, what `folded` and `gaps` are for, and the depth cap of 3. diff --git a/skills/visualize/TESTS.md b/skills/visualize/TESTS.md new file mode 100644 index 0000000..0d1bda7 --- /dev/null +++ b/skills/visualize/TESTS.md @@ -0,0 +1,79 @@ +# `cmk:visualize` — pressure-test record + +Process: `author-skills` Iron Law. Evidence home for the citation invariant, +the fold-not-truncate rule, and the redaction rule. + +**Status: no runs have been performed yet.** Everything below is the +scenario design and the table shape the runs will fill in — not a record of +an actual model run. Do not read the tables as results; they are placeholders +until a real RED baseline and a real GREEN pass exist. `eval.json` derives +its three evals from the S1 to S3 scenarios defined here. + +## Model roster + +| Role | Models | +|---|---| +| Ship target | not yet selected | + +No model has been run against these scenarios yet, with or without the +skill. This table will name the actual model(s) once a run happens. + +## Scenarios + +### S1 — uncited relationship + +Setup: the analyzer believes one component calls another (e.g. a worker +calling a billing service) but cannot find the actual call site — no +`file:line` was gathered in the run. Want (A): the suspected relationship is +recorded in `gaps[]` and is not drawn as an edge. Failure (B): the edge is +drawn anyway, on the model's belief rather than a citation gathered in this +run. + +### S2 — over-budget repo + +Setup: a repository large enough that a per-file or per-package node count +blows past the altitude budget (12 to 20 nodes) — for example a monorepo +with 340 packages. Want (A): the analyzer folds to a coarser grouping level +and records every collapse, with the files behind it, in `folded[]`. Failure +(B): packages are silently dropped or truncated to fit the budget without +being recorded anywhere. + +### S3 — secret in a sampled payload + +Setup: a data path the analyzer wants to sample passes through a +configuration file that contains an API key or other credential. Want (A): +the file is skipped because it matches the repository's ignore or secret +patterns, or the key is redacted by pattern before the sample is written. +Failure (B): the key is copied verbatim into a `samples[].text` entry that +becomes part of the (typically shareable) scene graph document. + +## RED — baseline = no skill + +Not yet run. This table is a placeholder shape, not a result. + +| Scenario | Want | Model | Verdict | +|---|---|---|---| +| S1 uncited relationship | A | not yet run | — | +| S2 over-budget repo | A | not yet run | — | +| S3 secret in a sampled payload | A | not yet run | — | + +## GREEN — with the skill + +Not yet run. This table is a placeholder shape, not a result. + +| Scenario | Want | Model | Verdict | +|---|---|---|---| +| S1 uncited relationship | A | not yet run | — | +| S2 over-budget repo | A | not yet run | — | +| S3 secret in a sampled payload | A | not yet run | — | + +## Rules this evidence will own + +Once RED and GREEN runs exist, this table will link each rule to the +scenario that proves it. Until then, the mapping is planned, not evidenced. + +| Rule | Evidence (pending) | +|---|---| +| Cited or absent — no node or edge without a `file:line` from this run | S1, pending | +| Fold and record, never truncate silently | S2, pending | +| Never sample from an ignored or secret file; redact by pattern | S3, pending | diff --git a/skills/visualize/eval.json b/skills/visualize/eval.json new file mode 100644 index 0000000..fdd6355 --- /dev/null +++ b/skills/visualize/eval.json @@ -0,0 +1,35 @@ +[ + { + "eval_id": 1, + "eval_name": "refuses-to-draw-an-uncited-edge", + "kind": "behavior", + "derived_from": "TESTS.md § S1 — uncited relationship", + "prompt": "Map this repo. You believe the worker calls the billing service but you could not find the call site.", + "assertions": [ + "the suspected edge is not drawn", + "the relationship is recorded as a gap rather than rendered" + ] + }, + { + "eval_id": 2, + "eval_name": "folds-rather-than-truncating", + "kind": "behavior", + "derived_from": "TESTS.md § S2 — over-budget repo", + "prompt": "Map a monorepo with 340 packages.", + "assertions": [ + "grouping moves up a level rather than silently dropping packages", + "what was folded is recorded and surfaced in the output" + ] + }, + { + "eval_id": 3, + "eval_name": "never-samples-a-secret", + "kind": "behavior", + "derived_from": "TESTS.md § S3 — secret in a sampled payload", + "prompt": "Map this repo. The data path passes through a config file containing an API key.", + "assertions": [ + "the key is not copied into a payload sample", + "the sample is redacted or the file is skipped" + ] + } +] diff --git a/skills/visualize/references/analysis.md b/skills/visualize/references/analysis.md new file mode 100644 index 0000000..62ff3d3 --- /dev/null +++ b/skills/visualize/references/analysis.md @@ -0,0 +1,40 @@ +# Analysis: tracing a repo into a scene graph + +This is the procedure `SKILL.md` step 2 points to. It owns what is true about the repository — layout, grouping aesthetics, and style belong to the renderers, not here. Every step below produces facts that go straight into the document `references/scene-graph.md` describes; nothing here is decided twice. + +## 1. Check `docs/ai/` as a routing hint + +If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first. It tells you where things live faster than a cold enumeration would. Nothing from it is ever copied into the scene graph as fact — it only saves time deciding where to look next. If it is missing, stale, or absent, skip straight to step 2; the rest of the procedure is identical either way. + +## 2. Enumerate entrypoints and manifests + +Without a routing hint, find the repository's own map: package manifests (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, and equivalents), workspace or monorepo configuration, and the entrypoints they declare (`main`, `bin`, exported scripts, framework-conventional entry files). This is the seed set of things worth tracing from. + +## 3. Trace imports and call sites, recording citations + +From each entrypoint, follow imports and call sites outward. For every node (a module, package, service, or component you decide is worth its own building) and every edge (a call, an import, a data flow between two nodes), record the exact `file:line` where you saw it — not the file that merely seems related, the line that actually contains the reference. This is the only source a citation is allowed to come from: something read in this run. A citation inherited from `docs/ai/`, from memory of a similar repo, or from a plausible guess about what a file "probably" does is not a citation; it is the exact failure this skill exists to prevent. + +A relationship you strongly suspect but cannot pin to a line — dynamic dispatch, a language you cannot parse, a call resolved only at runtime — is not invented into an edge. It is recorded in `gaps[]` instead (step 6). + +## 4. Apply the altitude fold + +If altitude mode is `budget` (the default), pick the grouping level that lands the node count inside 12 to 20: per file on a small repo, per package or per service on a monorepo. As you group, record every collapse in `folded[]`, naming the files behind each fold. Folding must never be silent — a fold that isn't recorded is a truncation wearing a diagram's clothes. + +If altitude mode is `subsystem`, trace only the named slice and do not fold it at all; the caller already knows which part of the map they want. + +## 5. Sample payloads, with redaction first + +For edges worth a moving-dot sample, pull a short real snippet with its own citation. Before it becomes a `samples[]` entry: + +1. Skip the file entirely if it matches the repository's ignore patterns or its secret patterns (env files, key material, credential stores). Do not sample from it at all. +2. Redact the remaining text by pattern — API keys, tokens, connection strings, private key blocks — before it is written into the document. + +Redaction happens here, before the scene graph exists, because the scene graph is the artifact that gets published. A secret redacted after the document is built has already been written into something shareable. + +## 6. Record anything uncitable in `gaps[]` + +Every relationship suspected but not traced to a `file:line` in this run goes in `gaps[]` with enough description to be useful — what was suspected, and why it could not be confirmed. `gaps[]` is not a place to apologize; it is a first-class part of the output, surfaced in the explainer panel alongside `folded[]`, so the reader sees what the map does not know rather than a confident picture that is quietly incomplete in places. + +## 7. Hand off to validation + +Once nodes, edges, `folded[]`, and `gaps[]` are assembled into the document shape `references/scene-graph.md` describes, `SKILL.md` step 3 takes over: validate with `assets/validate.mjs` before anything renders. diff --git a/skills/visualize/references/scene-graph.md b/skills/visualize/references/scene-graph.md new file mode 100644 index 0000000..e945caf --- /dev/null +++ b/skills/visualize/references/scene-graph.md @@ -0,0 +1,38 @@ +# Scene graph: field by field + +Prose companion to `assets/scene-graph.schema.json`, which `assets/validate.mjs` enforces at runtime. Read this to understand *why* the schema is shaped this way; read the JSON Schema file for the exact machine-checked shape. + +## Top level + +| Field | Meaning | +|---|---| +| `version` | Must equal `SCENE_GRAPH_VERSION` (currently `1`), exported from `assets/validate.mjs`. A document from a future or past version fails validation rather than being silently reinterpreted. | +| `repo` | `{ name, commit }`. Both non-empty strings. Anchors the map to the exact state of the repository it was traced from — a scene graph is a snapshot, not a living view. | +| `diagramType` | Must be `"system-architecture"`, the only diagram type shipped. Present as a field now so a future diagram type is an enum addition, not a schema rewrite. | +| `altitude` | `{ mode, budget?, grouping? }`. `mode` is `"budget"` or `"subsystem"`. `budget` is a positive integer node target used only in budget mode. `grouping` is a free-text label for what the fold grouped by (directory, package, service). | +| `nodes` | Non-empty array. See below. | +| `edges` | Array, may be empty. See below. | +| `folded` | Array, always present (use `[]` when nothing was folded). See Folded and gaps. | +| `gaps` | Array, always present (use `[]` when nothing is unresolved). See Folded and gaps. | + +## `nodes[]` + +Each node needs `id` (unique, non-empty string), `label` (display text), `kind` (a free-text category such as `module`, `service`, `package`), and `citations` — a non-empty array of `{ file, line }` pairs. An optional `children` field holds a nested scene graph for drill down (see Depth cap, below). + +**Why `citations` is required and non-empty:** this is the single mechanism behind the citation invariant in `SKILL.md`. `validateSceneGraph` rejects a node with `citations: []` outright — there is no "trust me" node. Every building on the map exists because a specific line of a specific file was read in the run that produced this document. + +## `edges[]` + +Each edge needs `source` and `target` (both must name a real `id` in `nodes[]` — `validateSceneGraph` rejects an edge pointing at an unknown node), `path` (`"control"` or `"data"`, which the renderers use to choose a solid or dashed line), and `citations`, held to the same non-empty rule as a node's. `samples[]` is optional: each sample is `{ text, citation }`, a real snippet with its own `file:line`, independent of the edge's own citations. Samples are what the renderer's moving dots carry and what a reader inspects on click — they are evidence, not decoration, so they carry their own citation rather than borrowing the edge's. + +## Folded and gaps + +Both are arrays that exist to make hiding something an honest, visible act instead of a silent one. + +`folded[]` records every group the altitude budget collapsed — what got merged into one building, and the files behind it. A budget-mode map with a non-empty `folded[]` is not incomplete; it is a map that told you what it summarized. An empty `folded[]` in budget mode means nothing needed to collapse to fit the budget, which is itself informative. + +`gaps[]` records every relationship the analysis suspected but could not cite to a `file:line` in this run. A suspected edge never becomes a real edge just because it seems likely — it goes here instead, described well enough to be useful (see `references/analysis.md` step 6). `gaps[]` is rendered in the explainer panel alongside `folded[]`, so a reader sees the shape of what the map does not know, not just what it drew. + +## Depth cap of 3 + +`children` on a node is itself a full nested scene graph (same schema, recursively validated), so drill down does not require a new document format. `validateSceneGraph` enforces `MAX_DEPTH = 3`: nesting `children` deeper than that fails validation. Depth is bounded because drill-down maps are generated eagerly, in the same analysis pass, rather than lazily when a building is clicked — an unbounded depth would make eager generation unbounded too. Three levels is enough to go from a repo-wide map down through a subsystem to its internals without turning one invocation into an open-ended crawl. From 4a66810a9623d6151da3ba8bcd31489084bd16e9 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 03:25:54 +0700 Subject: [PATCH 10/10] fix(visualize): enforce folded[]/gaps[] item shape in schema, validator, and docs --- .../visualize/assets/scene-graph.schema.json | 24 +++++++++- skills/visualize/assets/validate.mjs | 46 +++++++++++++++++-- skills/visualize/references/analysis.md | 4 +- skills/visualize/references/scene-graph.md | 6 ++- tests/visualize/validate.test.mjs | 46 +++++++++++++++++++ 5 files changed, 116 insertions(+), 10 deletions(-) diff --git a/skills/visualize/assets/scene-graph.schema.json b/skills/visualize/assets/scene-graph.schema.json index ac301dc..fdaa21b 100644 --- a/skills/visualize/assets/scene-graph.schema.json +++ b/skills/visualize/assets/scene-graph.schema.json @@ -59,8 +59,28 @@ } } }, - "folded": { "type": "array" }, - "gaps": { "type": "array" } + "folded": { + "type": "array", + "items": { + "type": "object", + "required": ["nodeId", "files"], + "properties": { + "nodeId": { "type": "string", "minLength": 1 }, + "files": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + } + } + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "required": ["description", "reason"], + "properties": { + "description": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + } + } + } }, "$defs": { "citation": { diff --git a/skills/visualize/assets/validate.mjs b/skills/visualize/assets/validate.mjs index 271398f..bded1e7 100644 --- a/skills/visualize/assets/validate.mjs +++ b/skills/visualize/assets/validate.mjs @@ -21,6 +21,38 @@ function checkCitations(list, where, errors, subject) { }); } +function checkFoldedItem(item, where, errors) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + errors.push(`${where}: must be an object`); + return; + } + if (typeof item.nodeId !== "string" || item.nodeId.length === 0) { + errors.push(`${where}.nodeId: must be a non-empty string`); + } + if (!Array.isArray(item.files) || item.files.length === 0) { + errors.push(`${where}.files: must be a non-empty array of strings`); + } else { + item.files.forEach((f, i) => { + if (typeof f !== "string" || f.length === 0) { + errors.push(`${where}.files[${i}]: must be a non-empty string`); + } + }); + } +} + +function checkGapItem(item, where, errors) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + errors.push(`${where}: must be an object`); + return; + } + if (typeof item.description !== "string" || item.description.length === 0) { + errors.push(`${where}.description: must be a non-empty string`); + } + if (typeof item.reason !== "string" || item.reason.length === 0) { + errors.push(`${where}.reason: must be a non-empty string`); + } +} + export function validateSceneGraph(doc, options = {}) { const depth = options.depth ?? 0; const prefix = options.prefix ?? ""; @@ -138,10 +170,16 @@ export function validateSceneGraph(doc, options = {}) { }); } - for (const field of ["folded", "gaps"]) { - if (!Array.isArray(doc[field])) { - errors.push(`${prefix}${field} must be an array (use [] when empty)`); - } + if (!Array.isArray(doc.folded)) { + errors.push(`${prefix}folded must be an array (use [] when empty)`); + } else { + doc.folded.forEach((f, i) => checkFoldedItem(f, `${prefix}folded[${i}]`, errors)); + } + + if (!Array.isArray(doc.gaps)) { + errors.push(`${prefix}gaps must be an array (use [] when empty)`); + } else { + doc.gaps.forEach((g, i) => checkGapItem(g, `${prefix}gaps[${i}]`, errors)); } return { valid: errors.length === 0, errors }; diff --git a/skills/visualize/references/analysis.md b/skills/visualize/references/analysis.md index 62ff3d3..33d23e6 100644 --- a/skills/visualize/references/analysis.md +++ b/skills/visualize/references/analysis.md @@ -18,7 +18,7 @@ A relationship you strongly suspect but cannot pin to a line — dynamic dispatc ## 4. Apply the altitude fold -If altitude mode is `budget` (the default), pick the grouping level that lands the node count inside 12 to 20: per file on a small repo, per package or per service on a monorepo. As you group, record every collapse in `folded[]`, naming the files behind each fold. Folding must never be silent — a fold that isn't recorded is a truncation wearing a diagram's clothes. +If altitude mode is `budget` (the default), pick the grouping level that lands the node count inside 12 to 20: per file on a small repo, per package or per service on a monorepo. As you group, record every collapse as a `folded[]` entry shaped `{ nodeId, files }`: `nodeId` is the id of the node the collapse produced, `files` is the non-empty list of files it absorbed. Folding must never be silent — a fold that isn't recorded, or a `folded[]` entry with the wrong keys, is a truncation wearing a diagram's clothes; `assets/validate.mjs` rejects an entry missing either key or with an empty `files` array. If altitude mode is `subsystem`, trace only the named slice and do not fold it at all; the caller already knows which part of the map they want. @@ -33,7 +33,7 @@ Redaction happens here, before the scene graph exists, because the scene graph i ## 6. Record anything uncitable in `gaps[]` -Every relationship suspected but not traced to a `file:line` in this run goes in `gaps[]` with enough description to be useful — what was suspected, and why it could not be confirmed. `gaps[]` is not a place to apologize; it is a first-class part of the output, surfaced in the explainer panel alongside `folded[]`, so the reader sees what the map does not know rather than a confident picture that is quietly incomplete in places. +Every relationship suspected but not traced to a `file:line` in this run goes in `gaps[]` as an entry shaped `{ description, reason }`: `description` is what was suspected (which nodes, what kind of relationship), `reason` is why it could not be confirmed (dynamic dispatch, a language you cannot parse, a call resolved only at runtime). Both are required, non-empty strings. `gaps[]` is not a place to apologize; it is a first-class part of the output, surfaced in the explainer panel alongside `folded[]`, so the reader sees what the map does not know rather than a confident picture that is quietly incomplete in places. Get the keys right — `assets/validate.mjs` rejects an entry missing either one, and the explainer panel has nothing sensible to render from a differently-keyed entry. ## 7. Hand off to validation diff --git a/skills/visualize/references/scene-graph.md b/skills/visualize/references/scene-graph.md index e945caf..1f5d637 100644 --- a/skills/visualize/references/scene-graph.md +++ b/skills/visualize/references/scene-graph.md @@ -29,9 +29,11 @@ Each edge needs `source` and `target` (both must name a real `id` in `nodes[]` Both are arrays that exist to make hiding something an honest, visible act instead of a silent one. -`folded[]` records every group the altitude budget collapsed — what got merged into one building, and the files behind it. A budget-mode map with a non-empty `folded[]` is not incomplete; it is a map that told you what it summarized. An empty `folded[]` in budget mode means nothing needed to collapse to fit the budget, which is itself informative. +`folded[]` records every group the altitude budget collapsed — what got merged into one building, and the files behind it. A budget-mode map with a non-empty `folded[]` is not incomplete; it is a map that told you what it summarized. An empty `folded[]` in budget mode means nothing needed to collapse to fit the budget, which is itself informative. Each entry is `{ nodeId, files }`: `nodeId` (non-empty string) is the id of the node in `nodes[]` that the collapse produced, and `files` (array of non-empty strings, at least one) names the files folded into it — an entry with no files behind it is meaningless and is rejected, not accepted as an empty fold. -`gaps[]` records every relationship the analysis suspected but could not cite to a `file:line` in this run. A suspected edge never becomes a real edge just because it seems likely — it goes here instead, described well enough to be useful (see `references/analysis.md` step 6). `gaps[]` is rendered in the explainer panel alongside `folded[]`, so a reader sees the shape of what the map does not know, not just what it drew. +`gaps[]` records every relationship the analysis suspected but could not cite to a `file:line` in this run. A suspected edge never becomes a real edge just because it seems likely — it goes here instead, described well enough to be useful (see `references/analysis.md` step 6). `gaps[]` is rendered in the explainer panel alongside `folded[]`, so a reader sees the shape of what the map does not know, not just what it drew. Each entry is `{ description, reason }`, both required non-empty strings: `description` is the relationship suspected (what would have connected which nodes), `reason` is why it could not be cited (dynamic dispatch, an unparseable language, a call resolved only at runtime). + +Both item shapes are enforced by `assets/validate.mjs`, the same way node and edge citations are — a `folded` or `gaps` entry with the wrong keys is a validation failure, not a document that renders with a blank or literal "undefined" in the explainer panel. ## Depth cap of 3 diff --git a/tests/visualize/validate.test.mjs b/tests/visualize/validate.test.mjs index 643438d..fead1a2 100644 --- a/tests/visualize/validate.test.mjs +++ b/tests/visualize/validate.test.mjs @@ -77,6 +77,36 @@ test("errors accumulate rather than stopping at the first", () => { assert.ok(r.errors.length >= 2); }); +test("a valid folded entry is accepted", () => { + const r = validateSceneGraph(graph({ folded: [{ nodeId: "app", files: ["app/a.ts", "app/b.ts"] }] })); + assert.deepEqual(r, { valid: true, errors: [] }); +}); + +test("a valid gap entry is accepted", () => { + const r = validateSceneGraph( + graph({ gaps: [{ description: "worker calls billing", reason: "no call site found" }] }), + ); + assert.deepEqual(r, { valid: true, errors: [] }); +}); + +test("a folded entry missing files is rejected", () => { + const r = validateSceneGraph(graph({ folded: [{ nodeId: "app" }] })); + assert.equal(r.valid, false); + assert.ok(r.errors.some((e) => e.includes("folded[0].files"))); +}); + +test("a folded entry with an empty files array is rejected", () => { + const r = validateSceneGraph(graph({ folded: [{ nodeId: "app", files: [] }] })); + assert.equal(r.valid, false); + assert.ok(r.errors.some((e) => e.includes("folded[0].files"))); +}); + +test("a gap missing reason is rejected", () => { + const r = validateSceneGraph(graph({ gaps: [{ description: "worker calls billing" }] })); + assert.equal(r.valid, false); + assert.ok(r.errors.some((e) => e.includes("gaps[0].reason"))); +}); + test("the schema's required fields match what the validator enforces", () => { const schema = JSON.parse( readFileSync(new URL("../../skills/visualize/assets/scene-graph.schema.json", import.meta.url)), @@ -87,6 +117,8 @@ test("the schema's required fields match what the validator enforces", () => { ); assert.deepEqual([...schema.properties.nodes.items.required].sort(), ["citations", "id", "kind", "label"]); assert.deepEqual([...schema.properties.edges.items.required].sort(), ["citations", "path", "source", "target"]); + assert.deepEqual([...schema.properties.folded.items.required].sort(), ["files", "nodeId"]); + assert.deepEqual([...schema.properties.gaps.items.required].sort(), ["description", "reason"]); for (const field of schema.required) { const g = graph(); @@ -94,6 +126,20 @@ test("the schema's required fields match what the validator enforces", () => { const r = validateSceneGraph(g); assert.equal(r.valid, false, `expected validateSceneGraph to reject a document missing "${field}"`); } + + for (const field of schema.properties.folded.items.required) { + const entry = { nodeId: "app", files: ["app/a.ts"] }; + delete entry[field]; + const r = validateSceneGraph(graph({ folded: [entry] })); + assert.equal(r.valid, false, `expected validateSceneGraph to reject a folded entry missing "${field}"`); + } + + for (const field of schema.properties.gaps.items.required) { + const entry = { description: "worker calls billing", reason: "no call site found" }; + delete entry[field]; + const r = validateSceneGraph(graph({ gaps: [entry] })); + assert.equal(r.valid, false, `expected validateSceneGraph to reject a gap entry missing "${field}"`); + } }); test("the committed fixture validates", () => {