From c03c3abe44cdfaca2dc497f743a4d8a514fd05e1 Mon Sep 17 00:00:00 2001 From: halaprix <6533433+halaprix@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:20:10 +0200 Subject: [PATCH 1/2] feat: render the bead graph mechanically via relay graph Viewing the queue meant either `bd graph --html`, which loads D3 from d3js.org and so needs network access and cannot be archived, or hermes-beads, which reads a .beads/issues.jsonl this Dolt-backed store does not have. Neither is offline or reviewable. `relay graph [bead-id] --out path.html` computes the page from `bd export` instead of asking a model to draw it: layout coordinates come from the script, so identical input yields identical bytes and the output is unit-testable. The page carries no script tag and no external host, which makes it work offline, diff cleanly, and satisfy an Artifact CSP as written. Layering is longest-path over ordering edges only, so layer 0 is startable work; containment is drawn but never pushes a child rightward, because an epic does not block its own children. Kahn's algorithm rather than recursion, so a dependency cycle surfaces as leftover nodes instead of an unbounded stack - it is reported in the page and the beads pinned to layer 0, since bd permits a cycle and refusing to draw would make the store unviewable. relates-to is an annotation and draws nothing. The export is read on stdout and never written to disk. An exported .beads/issues.jsonl carries created_by account names plus every title, description, and comment, it is not covered by .beads/.gitignore, and scan-privacy.mjs skips .beads - two blind guards on a public repository. A gitignore line now covers a hand-run export as well. Titles are truncated before they are escaped, never after: the reverse order would let the character budget cut an entity in half and emit a bare `&am`. A test pins that ordering. Verified: 125 pass / 0 fail, lint and check green. Rendering this repository's own n95 epic produced a 8KB page with zero script tags and zero external hosts, and byte-identical output across runs. The page's visual appearance is unverified - the sandbox blocked both file:// navigation and a local server. --- .gitignore | 6 + README.md | 16 +++ scripts/relay.mjs | 10 +- skills/relay/SKILL.md | 3 +- src/lib/bead-graph-html.mjs | 221 ++++++++++++++++++++++++++++++++++++ src/lib/bead-graph.mjs | 192 +++++++++++++++++++++++++++++++ src/lib/beads.mjs | 23 ++++ src/lib/supervisor.mjs | 30 +++++ test/bead-graph.test.mjs | 162 ++++++++++++++++++++++++++ test/fixtures/fake-bd.mjs | 5 + test/helpers.mjs | 1 + test/supervisor.test.mjs | 54 +++++++++ 12 files changed, 721 insertions(+), 2 deletions(-) create mode 100644 src/lib/bead-graph-html.mjs create mode 100644 src/lib/bead-graph.mjs create mode 100644 test/bead-graph.test.mjs diff --git a/.gitignore b/.gitignore index 2a60bd8..2e16c92 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,12 @@ site/.cache/ /CLAUDE.md /GEMINI.md +# `bd export -o .beads/issues.jsonl` would land an unignored file holding created_by +# account names and every bead title, description, and comment - and scan-privacy.mjs +# skips .beads, so nothing else would catch it. `relay graph` reads the export from +# stdout instead; this line covers a hand-run export. +.beads/issues.jsonl + # Beads / Dolt files (added by bd init) .dolt/ *.db diff --git a/README.md b/README.md index ca15cd8..df3bb46 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Version `0.1.0` ships as a local CLI/plugin package only. A transient per-user r - `relay run ` - `relay resume ` - `relay status [bead-id]` +- `relay graph [bead-id] [--out path.html]` - `relay review ` - `relay gates [gate-name]` - `relay cleanup ` @@ -100,6 +101,21 @@ They stay local and uncommitted on purpose: they name per-machine tooling and mo Adopting a project means filling in the placeholders: the architecture documents worth reading, the real gate commands, the invariants a newcomer would violate, and who merges. The template ships the parts that are true everywhere — the inherited-`BEADS_DIR` trap, verify-don't-trust delegation, no AI attribution, and Beads as the only durable task system. +## Bead graph (`relay graph`) + +`relay graph` renders the dependency graph as one self-contained HTML file: no scripts, no CDN, no network requests. It opens offline, survives in an archive, and can be published as an Artifact as-is. + +The page is computed, not drawn by a model. Identical input produces identical bytes, which is what makes it reviewable and testable — the layout coordinates come from the script, so there is nothing to trust and nothing to drift. + +- Nodes are laid out left to right by longest path over ordering edges, so **layer 0 is startable work**. Containment never pushes a child rightward: an epic does not block its own children. +- Solid arrows are `blocks`; dashed arrows are `parent-child`. `relates-to` is an annotation and draws nothing. +- A bead id restricts the page to that bead and its descendants; without one the whole store is drawn. +- A dependency cycle is reported in the page and the beads pinned to layer 0, rather than throwing — `bd` permits a cycle, so refusing to draw would make the store unviewable. `bd dep cycles` names them. + +The data comes from `bd export --readonly` read on **stdout and never written to disk**. That is deliberate: an exported `.beads/issues.jsonl` carries `created_by` account names plus every title, description, and comment, it is not covered by `.beads/.gitignore`, and the privacy scanner skips `.beads` — two blind guards on a public repository. + +Compared to `bd graph --html`, which loads D3 from `d3js.org` and so needs network access and cannot be archived, this trades interactivity for a file that always works. + ## Beads store The Beads store is project-local. `adapter.beads.requiredDir` defaults to `.beads`, resolved against the project root, which is exactly where `bd init` creates the embedded Dolt database (`.beads/embeddeddolt/`). Nothing depends on a shared or per-user store. diff --git a/scripts/relay.mjs b/scripts/relay.mjs index 8c4e25c..a7f995d 100755 --- a/scripts/relay.mjs +++ b/scripts/relay.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { parseCliArgs } from "../src/lib/cli.mjs"; -import { doctor, setup, plan, run, resume, status, review, gates, cleanup } from "../src/lib/supervisor.mjs"; +import { doctor, setup, plan, run, resume, status, review, gates, cleanup, graph } from "../src/lib/supervisor.mjs"; import { printResult, result } from "../src/lib/output.mjs"; const parsed = parseCliArgs(process.argv.slice(2)); @@ -19,6 +19,13 @@ async function main() { return run({ projectRoot, adapterName, beadId: parsed.positionals[0] }); case "resume": return resume({ projectRoot, adapterName, beadId: parsed.positionals[0] }); + case "graph": + return graph({ + projectRoot, + adapterName, + beadId: parsed.positionals[0] || null, + outPath: typeof parsed.options.out === "string" ? parsed.options.out : null + }); case "status": return status({ projectRoot, beadId: parsed.positionals[0] }); case "review": @@ -39,6 +46,7 @@ async function main() { "relay run ", "relay resume ", "relay status [bead-id]", + "relay graph [bead-id] [--out path.html]", "relay review ", "relay gates [gate-name]", "relay cleanup ", diff --git a/skills/relay/SKILL.md b/skills/relay/SKILL.md index dc7b4b9..0ae4d1d 100644 --- a/skills/relay/SKILL.md +++ b/skills/relay/SKILL.md @@ -14,7 +14,8 @@ Use `relay` when the project wants Claude, Codex, and agy to share the same Bead 3. Plan the target Bead with `relay plan `. 4. Execute with `relay run ` or reattach with `relay resume `. 5. Inspect checkpoints with `relay status [bead-id]`, `relay review `, and `relay gates [gate-name]`. -6. Use `relay cleanup ` only after the human has reviewed the retained worktree state. +6. Render the queue with `relay graph [bead-id] [--out path.html]` — a self-contained page, computed by the script rather than drawn by a model, where layer 0 is startable work. +7. Use `relay cleanup ` only after the human has reviewed the retained worktree state. ## Agent instruction files diff --git a/src/lib/bead-graph-html.mjs b/src/lib/bead-graph-html.mjs new file mode 100644 index 0000000..b765975 --- /dev/null +++ b/src/lib/bead-graph-html.mjs @@ -0,0 +1,221 @@ +import { STATUS_GLYPHS, STATUS_ORDER } from "./bead-graph.mjs"; + +// Fixed geometry: coordinates are computed here rather than by a layout library, +// so the page needs no script tag, no CDN, and no network. That is what makes the +// output diffable, archival, and safe to publish as an Artifact. +const NODE_WIDTH = 260; +const NODE_HEIGHT = 76; +const COLUMN_GAP = 96; +const ROW_GAP = 22; +const PADDING = 32; +// Lane for same-column and backward edges, kept smaller than PADDING so a routed +// edge never leaves the canvas. +const GUTTER_OFFSET = 18; +const HEADER_HEIGHT = 96; + +const STATUS_COLORS = { + blocked: "#c2410c", + in_progress: "#b45309", + open: "#2563eb", + deferred: "#6b7280", + closed: "#15803d" +}; + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// Deterministic truncation: no measurement, just a character budget for the fixed +// node width, so the same title always renders identically. +function truncate(text, budget) { + return text.length <= budget ? text : `${text.slice(0, Math.max(0, budget - 1))}…`; +} + +function layout(graph) { + const positions = new Map(); + graph.layers.forEach((layer, columnIndex) => { + layer.forEach((node, rowIndex) => { + positions.set(node.id, { + x: PADDING + columnIndex * (NODE_WIDTH + COLUMN_GAP), + y: HEADER_HEIGHT + rowIndex * (NODE_HEIGHT + ROW_GAP) + }); + }); + }); + const tallest = Math.max(1, ...graph.layers.map((layer) => layer.length)); + return { + positions, + width: PADDING * 2 + Math.max(1, graph.layers.length) * (NODE_WIDTH + COLUMN_GAP) - COLUMN_GAP, + height: HEADER_HEIGHT + tallest * (NODE_HEIGHT + ROW_GAP) - ROW_GAP + PADDING + }; +} + +function renderEdge(edge, positions) { + const from = positions.get(edge.from); + const to = positions.get(edge.to); + if (!from || !to) { + return ""; + } + const startY = from.y + NODE_HEIGHT / 2; + const endY = to.y + NODE_HEIGHT / 2; + let path; + if (to.x === from.x) { + // Same column - an epic and the children it contains. Run the edge down the + // gutter to the left of the column. Anchoring right-to-left here would cross the + // boxes, and a bezier between the two anchors would leave the canvas entirely. + const gutter = from.x - GUTTER_OFFSET; + path = `M ${from.x} ${startY} H ${gutter} V ${endY} H ${to.x}`; + } else if (to.x > from.x) { + const startX = from.x + NODE_WIDTH; + const midX = (startX + to.x) / 2; + path = `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${to.x} ${endY}`; + } else { + // Backward across columns: drop below the source row and return, staying inside + // the canvas rather than routing through negative coordinates. + const startX = from.x + NODE_WIDTH; + const laneY = Math.max(startY, endY) + NODE_HEIGHT; + path = `M ${startX} ${startY} V ${laneY} H ${to.x - GUTTER_OFFSET} V ${endY} H ${to.x}`; + } + const className = edge.kind === "containment" ? "edge containment" : "edge ordering"; + return ``; +} + +function renderNode(node, position) { + const color = STATUS_COLORS[node.status]; + const priority = node.priority ? ` · ${node.priority}` : ""; + const isEpic = node.type === "epic"; + return [ + ``, + ``, + ``, + `${escapeXml(STATUS_GLYPHS[node.status])} ${escapeXml(node.id)}${escapeXml(priority)}`, + `${escapeXml(truncate(node.title, 34))}`, + isEpic ? `epic` : "", + "" + ] + .filter(Boolean) + .join("\n"); +} + +function renderLegend(graph) { + const present = STATUS_ORDER.filter((status) => graph.counts[status] > 0); + return present + .map( + (status) => + `${escapeXml(STATUS_GLYPHS[status])} ${status.replace("_", " ")} ${graph.counts[status]}` + ) + .join("\n"); +} + +export function renderBeadGraphHtml(graph, { title = "Bead graph", generatedFor = null } = {}) { + const { positions, width, height } = layout(graph); + const heading = graph.rootId ? `${graph.rootId}` : "all beads"; + const rootNode = graph.rootId ? graph.nodes.get(graph.rootId) : null; + const columns = graph.layers + .map( + (layer, index) => + `layer ${index} · ${layer.length}` + ) + .join("\n"); + + return `${escapeXml(title)} + +
+

${escapeXml(heading)}${rootNode ? ` — ${escapeXml(rootNode.title)}` : ""}

+

${graph.nodes.size} beads · ${graph.edges.length} edges · layer 0 is startable work

+
+${renderLegend(graph)} + ── blocks + ┄┄ parent-child +
+
+
+ + + + + + + + + +${columns} +${graph.edges.map((edge) => renderEdge(edge, positions)).join("\n")} +${[...graph.nodes.values()] + .filter((node) => positions.has(node.id)) + .map((node) => renderNode(node, positions.get(node.id))) + .join("\n")} + +
+${ + graph.cycleEdges.length > 0 + ? `
Dependency cycle: ${escapeXml(graph.cycleEdges.join(", "))}. Those beads are pinned to layer 0 because no ordering exists. Run bd dep cycles.
` + : "" +} +
Generated by relay graph${generatedFor ? ` for ${escapeXml(generatedFor)}` : ""}. Self-contained: no scripts, no external requests.
+`; +} diff --git a/src/lib/bead-graph.mjs b/src/lib/bead-graph.mjs new file mode 100644 index 0000000..08d464e --- /dev/null +++ b/src/lib/bead-graph.mjs @@ -0,0 +1,192 @@ +import { epicIdFor } from "./beads.mjs"; + +// Dependency semantics, as bd exports them: each entry lives on the dependent +// issue, so `depends_on_id` is the thing that comes first. For `parent-child` +// that makes it the parent; for `blocks` it is the blocker. +const CONTAINMENT_TYPE = "parent-child"; +const ORDERING_TYPES = new Set(["blocks", "depends-on"]); + +const STATUS_GLYPHS = { + open: "○", + in_progress: "◐", + blocked: "●", + closed: "✓", + deferred: "❄" +}; + +const STATUS_ORDER = ["blocked", "in_progress", "open", "deferred", "closed"]; + +function normalizeStatus(raw) { + const value = String(raw || "open").toLowerCase(); + return STATUS_GLYPHS[value] ? value : "open"; +} + +function normalizePriority(raw) { + if (typeof raw === "number" && Number.isFinite(raw)) { + return `P${Math.trunc(raw)}`; + } + const value = String(raw ?? "").trim(); + return /^P\d$/i.test(value) ? value.toUpperCase() : ""; +} + +// Records and their dependency arrays arrive in whatever order the store returns. +// Sorting by id everywhere is what makes the rendered bytes reproducible. +function byId(a, b) { + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +export function buildBeadGraph(records, { rootId = null } = {}) { + const nodes = new Map(); + for (const record of records) { + nodes.set(record.id, { + id: record.id, + title: String(record.title || "").trim(), + status: normalizeStatus(record.status), + priority: normalizePriority(record.priority), + type: String(record.issue_type || "task"), + parent: null + }); + } + + const edges = []; + for (const record of [...records].sort(byId)) { + const dependencies = Array.isArray(record.dependencies) ? record.dependencies : []; + for (const dependency of dependencies) { + const from = dependency?.depends_on_id; + const to = dependency?.issue_id || record.id; + const type = String(dependency?.type || ""); + if (!from || !to || !nodes.has(from) || !nodes.has(to)) { + continue; + } + if (type === CONTAINMENT_TYPE) { + nodes.get(to).parent = from; + edges.push({ from, to, kind: "containment" }); + continue; + } + if (ORDERING_TYPES.has(type)) { + edges.push({ from, to, kind: "ordering" }); + } + // Anything else (relates-to and friends) is an annotation, not structure. + } + } + + const selected = selectSubgraph(nodes, rootId); + const selectedEdges = edges + .filter((edge) => selected.has(edge.from) && selected.has(edge.to)) + .sort((a, b) => byId(a, b) || byId({ id: a.from }, { id: b.from }) || a.kind.localeCompare(b.kind)); + + const { layers, cycleEdges } = layerNodes(selected, selectedEdges); + return { + rootId, + nodes: selected, + edges: selectedEdges, + layers, + cycleEdges, + counts: countByStatus(selected) + }; +} + +// A bead id selects that bead plus everything beneath it. Falls back to the whole +// store when no id is given. +function selectSubgraph(nodes, rootId) { + if (!rootId) { + return new Map([...nodes.entries()].sort((a, b) => byId(a[1], b[1]))); + } + if (!nodes.has(rootId)) { + throw new Error(`bead ${rootId} is not in the exported store`); + } + const selected = new Map(); + const queue = [rootId]; + while (queue.length > 0) { + const current = queue.shift(); + if (selected.has(current)) { + continue; + } + selected.set(current, nodes.get(current)); + for (const [id, node] of nodes) { + if (node.parent === current) { + queue.push(id); + } + } + } + return new Map([...selected.entries()].sort((a, b) => byId(a[1], b[1]))); +} + +// Longest-path layering over ordering edges only: a node sits one layer right of +// its latest blocker, so layer 0 is startable work. Containment is drawn but never +// pushes a child rightward - an epic is not a blocker of its own children. +// +// Kahn's algorithm rather than recursion, so a dependency cycle surfaces as +// leftover nodes instead of an unbounded stack. The leftovers are reported, pinned +// to layer 0, and the offending edges named; bd allows a cycle to exist, so a +// renderer that threw on one could not draw the store at all. +function layerNodes(nodes, edges) { + const ordering = edges.filter((edge) => edge.kind === "ordering"); + const indegree = new Map([...nodes.keys()].map((id) => [id, 0])); + const outgoing = new Map([...nodes.keys()].map((id) => [id, []])); + for (const edge of ordering) { + indegree.set(edge.to, indegree.get(edge.to) + 1); + outgoing.get(edge.from).push(edge.to); + } + + const depth = new Map([...nodes.keys()].map((id) => [id, 0])); + const ready = [...indegree.entries()] + .filter(([, count]) => count === 0) + .map(([id]) => id) + .sort(); + const settled = new Set(); + while (ready.length > 0) { + const current = ready.shift(); + settled.add(current); + for (const next of outgoing.get(current).sort()) { + depth.set(next, Math.max(depth.get(next), depth.get(current) + 1)); + indegree.set(next, indegree.get(next) - 1); + if (indegree.get(next) === 0) { + ready.push(next); + ready.sort(); + } + } + } + + const cycleEdges = ordering + .filter((edge) => !settled.has(edge.from) || !settled.has(edge.to)) + .map((edge) => `${edge.from} -> ${edge.to}`); + for (const id of nodes.keys()) { + if (!settled.has(id)) { + depth.set(id, 0); + } + } + + const layers = []; + for (const [id, node] of nodes) { + const index = depth.get(id); + layers[index] ||= []; + layers[index].push(node); + } + // Order within a column by epic, then by id. Since containment is deliberately + // flat, an epic and its children share a column, and grouping them puts a parent + // directly above its own children - which keeps every containment edge a short + // hop instead of a curve sweeping the height of the canvas. Status is already + // carried by the glyph and the stripe, so it does not need to drive order too. + for (const layer of layers) { + layer.sort((a, b) => { + const epicA = epicIdFor(a.id); + const epicB = epicIdFor(b.id); + return epicA < epicB ? -1 : epicA > epicB ? 1 : byId(a, b); + }); + } + return { layers: layers.map((layer) => layer || []), cycleEdges }; +} + +function countByStatus(nodes) { + const counts = {}; + for (const status of STATUS_ORDER) { + counts[status] = 0; + } + for (const node of nodes.values()) { + counts[node.status] += 1; + } + return counts; +} + +export { STATUS_GLYPHS, STATUS_ORDER, epicIdFor }; diff --git a/src/lib/beads.mjs b/src/lib/beads.mjs index 8c4f972..717f801 100644 --- a/src/lib/beads.mjs +++ b/src/lib/beads.mjs @@ -85,6 +85,29 @@ export function listBeadChildren({ env, beadId, beadsDir = null }) { } } +// Exports every issue as JSONL on stdout. Deliberately never writes the file: an +// exported `.beads/issues.jsonl` carries `created_by` identities plus every title, +// description, and comment, it is not gitignored, and the privacy scanner skips +// `.beads` - so a file on disk is two blind guards away from a public commit. +export function exportBeadRecords({ env, beadsDir = null }) { + const response = runBd(["export", "--readonly"], beadsDir ? beadsEnv(env, beadsDir) : env); + if (response.status !== 0) { + throw new Error(`bd export failed: ${response.stderr || response.stdout}`); + } + const records = []; + for (const line of response.stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const parsed = JSON.parse(trimmed); + if (parsed && parsed.id) { + records.push(parsed); + } + } + return records; +} + export function storePathMatches(resolvedPath, beadsDir) { const normalizedStore = path.resolve(beadsDir); const normalizedResolved = path.resolve(resolvedPath); diff --git a/src/lib/supervisor.mjs b/src/lib/supervisor.mjs index 2e9890e..99c4d18 100644 --- a/src/lib/supervisor.mjs +++ b/src/lib/supervisor.mjs @@ -13,6 +13,7 @@ import { appendBeadComment, approvalForSpecHash, epicIdFor, + exportBeadRecords, listBeadChildren, rebuildStateFromBeadComments, verifyBeadsStore @@ -47,6 +48,8 @@ import { prepareIsolatedProviderRun } from "./containment.mjs"; import { classifyProviderFailure, parseWorkerReport, runProviderCommand, providerCommandFromConfig, providerVendor, providerStrength, validateRuntimeProviderConfig } from "./provider.mjs"; import { assertTeamFacingTextClean, sanitizeIssueForPrompt, sanitizePromptText, sanitizeTeamFacingText, slugifyTitle } from "./sanitize.mjs"; import { syncRoleBundles } from "./roles.mjs"; +import { buildBeadGraph } from "./bead-graph.mjs"; +import { renderBeadGraphHtml } from "./bead-graph-html.mjs"; import { agentInstructionExcludeMarkers, agentInstructionStatus, @@ -1762,6 +1765,33 @@ async function planUnlocked({ projectRoot, adapterName, beadId, env = process.en return ensurePlanReady({ projectRoot, beadId, state, config, adapter, env, action: "plan" }); } +// Renders the dependency graph from `bd export` output. The export is read from +// stdout and never written to disk: an `.beads/issues.jsonl` file carries +// `created_by` identities and is neither gitignored nor covered by the privacy +// scanner, so keeping it in memory removes the hazard rather than managing it. +export async function graph({ projectRoot, adapterName, beadId = null, outPath = null, env = process.env }) { + const { adapter } = await loadAdapter(adapterName); + const beadsDir = resolveBeadsDir(adapter, projectRoot); + const records = exportBeadRecords({ env, beadsDir }); + const model = buildBeadGraph(records, { rootId: beadId }); + const html = renderBeadGraphHtml(model, { + title: beadId ? `${beadId} — bead graph` : "Bead graph", + generatedFor: path.basename(projectRoot) + }); + const destination = path.resolve(projectRoot, outPath || path.join(".agents", "agent-relay", "artifacts", beadId ? `graph-${beadId}.html` : "graph.html")); + await ensureDir(path.dirname(destination)); + await writeFile(destination, html, "utf8"); + return ok("graph", { + beadId, + outPath: destination, + beads: model.nodes.size, + edges: model.edges.length, + layers: model.layers.map((layer) => layer.length), + counts: model.counts, + cycleEdges: model.cycleEdges + }); +} + export async function status({ projectRoot, beadId }) { if (!beadId) { const stateDir = path.join(projectStateRoot(projectRoot), "state"); diff --git a/test/bead-graph.test.mjs b/test/bead-graph.test.mjs new file mode 100644 index 0000000..ea86388 --- /dev/null +++ b/test/bead-graph.test.mjs @@ -0,0 +1,162 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildBeadGraph } from "../src/lib/bead-graph.mjs"; +import { renderBeadGraphHtml } from "../src/lib/bead-graph-html.mjs"; + +// Dependency entries live on the dependent issue, so `depends_on_id` is whatever +// comes first: the parent for parent-child, the blocker for blocks. +function record(id, overrides = {}) { + return { + id, + title: `Title for ${id}`, + status: "open", + priority: 1, + issue_type: "task", + dependencies: [], + ...overrides + }; +} + +function dependency(issueId, dependsOnId, type) { + return { issue_id: issueId, depends_on_id: dependsOnId, type }; +} + +const EPIC_FIXTURE = [ + record("proj-1", { issue_type: "epic", title: "The epic", priority: 2 }), + record("proj-1.1", { dependencies: [dependency("proj-1.1", "proj-1", "parent-child")] }), + record("proj-1.2", { + status: "blocked", + priority: 0, + dependencies: [ + dependency("proj-1.2", "proj-1", "parent-child"), + dependency("proj-1.2", "proj-1.1", "blocks") + ] + }), + record("proj-1.3", { + status: "closed", + dependencies: [ + dependency("proj-1.3", "proj-1", "parent-child"), + dependency("proj-1.3", "proj-1.2", "blocks"), + // relates-to is an annotation, not structure: it must not create an edge. + dependency("proj-1.3", "proj-2", "relates-to") + ] + }), + record("proj-2", { title: "Unrelated bead" }) +]; + +test("buildBeadGraph layers ordering edges by longest path and keeps containment flat", () => { + const graph = buildBeadGraph(EPIC_FIXTURE); + const layerOf = (id) => graph.layers.findIndex((layer) => layer.some((node) => node.id === id)); + + // Containment must not push a child rightward: an epic does not block its children. + assert.equal(layerOf("proj-1"), 0); + assert.equal(layerOf("proj-1.1"), 0); + assert.equal(layerOf("proj-1.2"), 1); + assert.equal(layerOf("proj-1.3"), 2); + + const kinds = graph.edges.map((edge) => `${edge.from}->${edge.to}:${edge.kind}`); + assert.equal(kinds.includes("proj-1->proj-1.1:containment"), true); + assert.equal(kinds.includes("proj-1.1->proj-1.2:ordering"), true); + // relates-to produced nothing at all. + assert.equal( + kinds.some((kind) => kind.startsWith("proj-2->proj-1.3")), + false + ); + assert.deepEqual(graph.cycleEdges, []); + assert.equal(graph.counts.blocked, 1); + assert.equal(graph.counts.closed, 1); +}); + +test("buildBeadGraph restricted to a bead id keeps only that bead and its descendants", () => { + const graph = buildBeadGraph(EPIC_FIXTURE, { rootId: "proj-1" }); + assert.deepEqual([...graph.nodes.keys()], ["proj-1", "proj-1.1", "proj-1.2", "proj-1.3"]); + // The edge to the excluded bead is dropped rather than dangling. + assert.equal( + graph.edges.every((edge) => graph.nodes.has(edge.from) && graph.nodes.has(edge.to)), + true + ); + assert.throws(() => buildBeadGraph(EPIC_FIXTURE, { rootId: "proj-nope" }), /not in the exported store/); +}); + +test("buildBeadGraph reports a dependency cycle instead of looping forever", () => { + const cyclic = [ + record("cyc-1", { dependencies: [dependency("cyc-1", "cyc-2", "blocks")] }), + record("cyc-2", { dependencies: [dependency("cyc-2", "cyc-1", "blocks")] }) + ]; + const graph = buildBeadGraph(cyclic); + assert.equal(graph.cycleEdges.length, 2); + // Both are pinned to layer 0: no ordering exists, but the store must still draw. + assert.equal(graph.layers.length, 1); + assert.equal(graph.layers[0].length, 2); +}); + +test("buildBeadGraph is order-independent, so the same store always renders the same bytes", () => { + const forward = renderBeadGraphHtml(buildBeadGraph(EPIC_FIXTURE)); + const reversed = renderBeadGraphHtml(buildBeadGraph([...EPIC_FIXTURE].reverse())); + assert.equal(forward, reversed); +}); + +test("renderBeadGraphHtml emits a self-contained page with no scripts and no external hosts", () => { + const html = renderBeadGraphHtml(buildBeadGraph(EPIC_FIXTURE, { rootId: "proj-1" }), { + title: "proj-1 — bead graph" + }); + assert.doesNotMatch(html, /