From c6ba7ef4ac49b0ec906f7d7566c8f27fcfbe5720 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 10:43:30 +0000 Subject: [PATCH] docs: publish the build graph as a gate-checked Mermaid page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/graph.md — Zuke's own dependency graph rendered as a Mermaid diagram GitHub draws natively, plus a target reference table — generated from the discovered build with the same generate-then-verify pattern as the workflows and plugin skills: a graphDoc target regenerates the page and a graphDocCheck target in the ci gate fails when it drifts. Link the page from the README intro and Documentation list, and record the new gate dependency in AGENTS.md. The generator (build/graph_doc.ts) is pure apart from the two file operations, mirrors the data zuke graph prints (hard dependsOn edges between discovered targets, declaration order), and uses synthetic Mermaid node ids so a target name can never collide with a Mermaid keyword. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0187myXRLofTjVoCkzN51tG8 --- AGENTS.md | 8 +- README.md | 6 +- build/graph_doc.ts | 178 ++++++++++++++++++++++++++++ docs/graph.md | 116 ++++++++++++++++++ tests/graph_doc_test.ts | 126 ++++++++++++++++++++ tests/integration/graph_doc_test.ts | 91 ++++++++++++++ zuke.ts | 33 ++++++ 7 files changed, 553 insertions(+), 5 deletions(-) create mode 100644 build/graph_doc.ts create mode 100644 docs/graph.md create mode 100644 tests/graph_doc_test.ts create mode 100644 tests/integration/graph_doc_test.ts diff --git a/AGENTS.md b/AGENTS.md index 6d163820..820fd7cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -308,10 +308,10 @@ can drift from it. `zuke.ts`'s `ci` target depends on: `format` (`deno fmt --check`), `lint` (`deno lint`), `spell` (cspell), `coverage` (type-check, then the test suite with the 95% coverage gate), `coverageUpload` (skips locally without a `CODECOV_TOKEN`), `apiDocsCheck`, `docLint`, -`snippetsCheck`, `hclSyncCheck`, `pluginSyncCheck`, `pluginVersionCheck`, -`prBodyLint`, `actionPinCheck`, `security`, and `lockCheck`. Read `zuke.ts`'s -`ci` target for the current, authoritative list — this is a snapshot, not a -second source of truth. +`snippetsCheck`, `hclSyncCheck`, `pluginSyncCheck`, `graphDocCheck`, +`pluginVersionCheck`, `prBodyLint`, `actionPinCheck`, `security`, and +`lockCheck`. Read `zuke.ts`'s `ci` target for the current, authoritative list — +this is a snapshot, not a second source of truth. **The lock is part of the gate.** Every entrypoint that loads `zuke.ts` — both launchers and the root tasks — passes `--frozen`, so a run cannot quietly heal a diff --git a/README.md b/README.md index 996582b4..6069c9f5 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ Zuke lets you define builds as a **TypeScript class**. Each target is a class field declared with a fluent API; targets reference each other by `this.x` (not strings), forming a dependency graph that Zuke resolves and runs in topological -order. Inspired by [NUKE](https://nuke.build/) for .NET. +order. Inspired by [NUKE](https://nuke.build/) for .NET. Zuke builds itself +this way — see [its own build graph](./docs/graph.md), regenerated straight +from `zuke.ts` and verified in CI. - **Runtime:** Deno - **Packages:** `jsr:@zuke/core` plus 50+ typed tool wrappers and plugins and a @@ -351,6 +353,8 @@ Full documentation lives in [`docs/`](./docs/): launcher, and a first build. - [Core concepts](./docs/concepts.md) — the build/target/graph model and execution semantics. +- [Zuke's build graph](./docs/graph.md) — the live dependency graph of + `zuke.ts` itself, generated by `./zuke graphDoc` and gate-checked in CI. - [Parameters](./docs/parameters.md) — typed build inputs from flags and env vars (`parameter()`, `this.x.value`). - [Authoring API](./docs/authoring.md) — `target()`, `Build`, `run()`, diff --git a/build/graph_doc.ts b/build/graph_doc.ts new file mode 100644 index 00000000..b5b03b07 --- /dev/null +++ b/build/graph_doc.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 the Zuke contributors +// SPDX-License-Identifier: MIT + +/** + * Renders the build's dependency graph as a committed Markdown page + * (`docs/graph.md`) whose Mermaid diagram GitHub draws natively — the same + * nodes and edges `./zuke graph` prints as text, kept current by the same + * generate-then-verify pattern as the workflows and the plugin skills: + * `graphDoc` regenerates the page, `graphDocCheck` fails the gate on drift. + * + * Everything except the two file operations is pure: {@link graphDocTargets} + * extracts the rows from the discovered targets and {@link renderGraphDoc} + * renders the whole page as a string, so the output is unit-testable without + * touching disk. + * + * @module + */ + +import { FileTasks, type TargetBuilder } from "@zuke/core"; + +/** Where the generated graph page is committed. */ +export const GRAPH_DOC_PATH = "docs/graph.md"; + +/** One target extracted from the discovered build. */ +export interface GraphDocTarget { + /** The target's name (nested fields are dotted, e.g. `workflows.ci`). */ + name: string; + /** The target's description, or `""` if none. */ + description: string; + /** Names of the hard `dependsOn` references, in declaration order. */ + deps: string[]; +} + +/** + * Extract the graph rows from discovered targets: one row per target, with + * edges for hard `dependsOn` references between known targets only — the same + * data `zuke graph` prints. Declaration order is preserved so the rendered + * page is deterministic. + */ +export function graphDocTargets( + targets: Map, +): GraphDocTarget[] { + const rows: GraphDocTarget[] = []; + for (const [name, t] of targets) { + const deps: string[] = []; + for (const dep of t.dependsOn_) { + const depName = dep?.name_; + if (depName !== undefined && targets.has(depName)) deps.push(depName); + } + rows.push({ name, description: t.description_ ?? "", deps }); + } + return rows; +} + +/** + * Escape a target name for use inside a quoted Mermaid node label. Names are + * TS identifiers (or dotted paths for nested fields), so this is defensive: + * Mermaid reads `"` as the label delimiter and `#…;` as an entity. + */ +function mermaidLabel(name: string): string { + return name.replaceAll("#", "#35;").replaceAll('"', "#quot;"); +} + +/** Escape a string for use inside a Markdown table cell. */ +function tableCell(text: string): string { + return text.replaceAll("|", "\\|").replaceAll("\n", " "); +} + +/** + * Render the Mermaid `flowchart` for the graph: every target as a node with a + * synthetic id (`t0`, `t1`, … — so a name like `end`, a Mermaid keyword, can + * never break the diagram) and an arrow from each dependency to its dependent. + */ +export function renderMermaid(rows: GraphDocTarget[]): string { + const ids = new Map(rows.map((row, i) => [row.name, `t${i}`])); + const lines = ["flowchart TD"]; + for (const row of rows) { + lines.push(` ${ids.get(row.name)}["${mermaidLabel(row.name)}"]`); + } + for (const row of rows) { + for (const dep of row.deps) { + const from = ids.get(dep); + // A dep naming no row would render a broken `undefined -->` edge; + // graphDocTargets never produces one, but callers of this exported + // function could. + if (from !== undefined) lines.push(` ${from} --> ${ids.get(row.name)}`); + } + } + return lines.join("\n"); +} + +/** + * Render the whole committed page: title, provenance note, the Mermaid + * diagram, and a target reference table. Pure — the input fully determines + * the output, so the drift check is a string comparison. + */ +export function renderGraphDoc(targets: Map): string { + const rows = graphDocTargets(targets); + const edgeCount = rows.reduce((n, row) => n + row.deps.length, 0); + + const header = [ + "# Zuke's build graph", + "", + "", + "", + "The dependency graph of [`zuke.ts`](../zuke.ts) — Zuke building itself. " + + "An arrow points from a dependency to the target that depends on it, so " + + "a target runs after everything that points at it. This is the same " + + "graph `./zuke graph` prints as text and `./zuke graph --output=html` " + + "renders interactively.", + "", + `${rows.length} target(s), ${edgeCount} dependency edge(s). Regenerate ` + + "with `./zuke graphDoc`; the `graphDocCheck` target in the CI gate " + + "fails when this page drifts from the build.", + "", + ]; + + if (rows.length === 0) { + return [...header, "No targets defined.", ""].join("\n"); + } + + const table = [ + "## Targets", + "", + "| Target | Description | Depends on |", + "| --- | --- | --- |", + ...rows.map((row) => { + const deps = row.deps.length === 0 + ? "—" + : row.deps.map((dep) => `\`${dep}\``).join(", "); + return `| \`${row.name}\` | ${tableCell(row.description)} | ${ + tableCell(deps) + } |`; + }), + ]; + + return [ + ...header, + "```mermaid", + renderMermaid(rows), + "```", + "", + ...table, + "", + ].join("\n"); +} + +/** + * Render and write the graph page. Returns `true` when the file changed (or + * was created), `false` when it was already current — so the target can say + * which happened. + */ +export async function writeGraphDoc( + targets: Map, + path: string = GRAPH_DOC_PATH, +): Promise { + const content = renderGraphDoc(targets); + if (await FileTasks.exists(path)) { + if (await FileTasks.readText(path) === content) return false; + } + await FileTasks.writeText(path, content); + return true; +} + +/** + * The ways the committed page has drifted from the build: missing, or its + * content no longer matches what {@link renderGraphDoc} produces. Empty means + * the page is current. + */ +export async function checkGraphDoc( + targets: Map, + path: string = GRAPH_DOC_PATH, +): Promise { + if (!await FileTasks.exists(path)) return [`${path} (missing)`]; + const committed = await FileTasks.readText(path); + if (committed !== renderGraphDoc(targets)) return [`${path} (stale)`]; + return []; +} diff --git a/docs/graph.md b/docs/graph.md new file mode 100644 index 00000000..b00c6f36 --- /dev/null +++ b/docs/graph.md @@ -0,0 +1,116 @@ +# Zuke's build graph + + + +The dependency graph of [`zuke.ts`](../zuke.ts) — Zuke building itself. An arrow points from a dependency to the target that depends on it, so a target runs after everything that points at it. This is the same graph `./zuke graph` prints as text and `./zuke graph --output=html` renders interactively. + +38 target(s), 24 dependency edge(s). Regenerate with `./zuke graphDoc`; the `graphDocCheck` target in the CI gate fails when this page drifts from the build. + +```mermaid +flowchart TD + t0["clean"] + t1["restore"] + t2["format"] + t3["lint"] + t4["spell"] + t5["check"] + t6["test"] + t7["integration"] + t8["coverage"] + t9["coverageUpload"] + t10["apiDocs"] + t11["apiDocsCheck"] + t12["apiReference"] + t13["syncWebsite"] + t14["docLint"] + t15["snippetsCheck"] + t16["hclGen"] + t17["hclSyncCheck"] + t18["pluginSync"] + t19["pluginSyncCheck"] + t20["graphDoc"] + t21["graphDocCheck"] + t22["pluginVersionCheck"] + t23["prBodyLint"] + t24["coreFloorCheck"] + t25["lockCheck"] + t26["security"] + t27["actionPinCheck"] + t28["ci"] + t29["scorecardSarif"] + t30["codeql"] + t31["reviewBase"] + t32["review"] + t33["release"] + t34["actionRelease"] + t35["publishJsr"] + t36["publish"] + t37["default"] + t1 --> t5 + t5 --> t6 + t6 --> t8 + t8 --> t9 + t2 --> t28 + t3 --> t28 + t4 --> t28 + t8 --> t28 + t9 --> t28 + t11 --> t28 + t14 --> t28 + t15 --> t28 + t17 --> t28 + t19 --> t28 + t21 --> t28 + t22 --> t28 + t23 --> t28 + t27 --> t28 + t26 --> t28 + t25 --> t28 + t31 --> t32 + t33 --> t36 + t35 --> t36 + t28 --> t37 +``` + +## Targets + +| Target | Description | Depends on | +| --- | --- | --- | +| `clean` | Remove build artifacts | — | +| `restore` | Warm the module cache | — | +| `format` | Check formatting (deno fmt --check) | — | +| `lint` | Lint the workspace (deno lint) | — | +| `spell` | Spell-check the repository (cspell) | — | +| `check` | Type-check the whole workspace | `restore` | +| `test` | Run the test suite with coverage | `check` | +| `integration` | Run the subprocess e2e suite (real processes, OS matrix) | — | +| `coverage` | Enforce the 95% coverage gate | `test` | +| `coverageUpload` | Upload the coverage report to Codecov | `coverage` | +| `apiDocs` | Generate agent-readable API docs (llms.txt, llms-full.txt, READMEs) | — | +| `apiDocsCheck` | Verify the generated API docs are current | — | +| `apiReference` | Generate the structured API reference (dist/api.json) for the website | — | +| `syncWebsite` | Open and merge a website PR with refreshed llms.txt + api.json | — | +| `docLint` | Fail on missing JSDoc or first-party private-type refs (deno doc --lint) | — | +| `snippetsCheck` | Type-check the marked ts snippets in docs and skills | — | +| `hclGen` | Regenerate the Terraform/OpenTofu wrappers from one template | — | +| `hclSyncCheck` | Verify the Terraform/OpenTofu wrappers match their template | — | +| `pluginSync` | Sync plugins/zuke/skills/ from skills/ (real copies, not a symlink) | — | +| `pluginSyncCheck` | Verify plugins/zuke/skills/ matches skills/ (no drift) | — | +| `graphDoc` | Regenerate docs/graph.md — this build's graph as a Mermaid page | — | +| `graphDocCheck` | Verify docs/graph.md matches the current build graph | — | +| `pluginVersionCheck` | Verify a skills change also bumped the plugin version | — | +| `prBodyLint` | Fail if the PR body has code release-please's parser can't handle | — | +| `coreFloorCheck` | Type-check every package against the @zuke/core version it declares | — | +| `lockCheck` | Verify the run did not rewrite deno.lock | — | +| `security` | Run supply-chain security scanners (zuke/security) | — | +| `actionPinCheck` | Verify the workflows only use inputs the released action has | — | +| `ci` | Full pre-commit / CI gate | `format`, `lint`, `spell`, `coverage`, `coverageUpload`, `apiDocsCheck`, `docLint`, `snippetsCheck`, `hclSyncCheck`, `pluginSyncCheck`, `graphDocCheck`, `pluginVersionCheck`, `prBodyLint`, `actionPinCheck`, `security`, `lockCheck` | +| `scorecardSarif` | Upload the Scorecard SARIF to GitHub code scanning | — | +| `codeql` | CodeQL static analysis (runs in CI via codeql.yml) | — | +| `reviewBase` | Fetch the base branch the AI review diffs against | — | +| `review` | AI review of the diff (security + code quality) | `reviewBase` | +| `release` | Maintain release PRs and GitHub releases (release-please) | — | +| `actionRelease` | Tag a new version of the Marketplace action when it changed | — | +| `publishJsr` | Publish new package versions to JSR, core first | — | +| `publish` | Release then publish new versions to JSR | `release`, `publishJsr` | +| `default` | Default: run the full CI gate | `ci` | diff --git a/tests/graph_doc_test.ts b/tests/graph_doc_test.ts new file mode 100644 index 00000000..22405a22 --- /dev/null +++ b/tests/graph_doc_test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 the Zuke contributors +// SPDX-License-Identifier: MIT + +/** + * Unit tests for `build/graph_doc.ts` — the generator behind `./zuke graphDoc` + * and the `graphDocCheck` gate target. Exercises the pure rendering (rows, + * Mermaid diagram, table, escaping, determinism) against small fixture builds, + * without touching the real `docs/graph.md`. + */ + +import { Build, discoverTargets, target } from "@zuke/core"; +import { + assertEquals, + assertStringIncludes, +} from "../packages/core/tests/_assert.ts"; +import { + checkGraphDoc, + graphDocTargets, + renderGraphDoc, + renderMermaid, + writeGraphDoc, +} from "../build/graph_doc.ts"; + +class Demo extends Build { + lint = target().description("Lint the workspace").executes(() => {}); + test = target() + .description("Run the test suite") + .dependsOn(this.lint) + .executes(() => {}); + ci = target() + .description("The gate | with a pipe") + .dependsOn(this.lint, this.test) + .executes(() => {}); +} + +Deno.test("graphDocTargets extracts rows in declaration order with deps", () => { + const rows = graphDocTargets(discoverTargets(new Demo())); + assertEquals(rows.map((r) => r.name), ["lint", "test", "ci"]); + assertEquals(rows[0].deps, []); + assertEquals(rows[1].deps, ["lint"]); + assertEquals(rows[2].deps, ["lint", "test"]); + assertEquals(rows[1].description, "Run the test suite"); +}); + +Deno.test("renderMermaid declares every node and edge with synthetic ids", () => { + const mermaid = renderMermaid(graphDocTargets(discoverTargets(new Demo()))); + assertStringIncludes(mermaid, "flowchart TD"); + assertStringIncludes(mermaid, 't0["lint"]'); + assertStringIncludes(mermaid, 't1["test"]'); + assertStringIncludes(mermaid, 't2["ci"]'); + assertStringIncludes(mermaid, "t0 --> t1"); + assertStringIncludes(mermaid, "t0 --> t2"); + assertStringIncludes(mermaid, "t1 --> t2"); +}); + +Deno.test("renderMermaid escapes Mermaid-reserved label characters", () => { + const mermaid = renderMermaid([ + { name: 'a"b#c', description: "", deps: [] }, + ]); + assertStringIncludes(mermaid, 't0["a#quot;b#35;c"]'); +}); + +Deno.test("renderMermaid skips an edge whose dependency names no row", () => { + const mermaid = renderMermaid([ + { name: "known", description: "", deps: ["missing"] }, + ]); + assertEquals(mermaid.includes("-->"), false); + assertEquals(mermaid.includes("undefined"), false); +}); + +Deno.test("renderGraphDoc renders the page with marker, diagram, and table", () => { + const page = renderGraphDoc(discoverTargets(new Demo())); + assertStringIncludes(page, "# Zuke's build graph"); + assertStringIncludes( + page, + "", + ); + assertStringIncludes(page, "3 target(s), 3 dependency edge(s)."); + assertStringIncludes(page, "```mermaid"); + assertStringIncludes(page, "| Target | Description | Depends on |"); + assertStringIncludes(page, "| `lint` | Lint the workspace | — |"); + assertStringIncludes(page, "| `test` | Run the test suite | `lint` |"); + // A `|` in a description must not break the table. + assertStringIncludes( + page, + "| `ci` | The gate \\| with a pipe | `lint`, `test` |", + ); +}); + +Deno.test("renderGraphDoc is deterministic", () => { + assertEquals( + renderGraphDoc(discoverTargets(new Demo())), + renderGraphDoc(discoverTargets(new Demo())), + ); +}); + +Deno.test("renderGraphDoc handles a build with no targets", () => { + const page = renderGraphDoc(new Map()); + assertStringIncludes(page, "0 target(s), 0 dependency edge(s)."); + assertStringIncludes(page, "No targets defined."); + assertEquals(page.includes("```mermaid"), false); +}); + +Deno.test("writeGraphDoc writes, reports no-op, and checkGraphDoc sees drift", async () => { + const dir = await Deno.makeTempDir({ prefix: "zuke-graph-doc-" }); + const path = `${dir}/graph.md`; + try { + const targets = discoverTargets(new Demo()); + + // Missing file: check reports it, write creates it. + assertEquals(await checkGraphDoc(targets, path), [`${path} (missing)`]); + assertEquals(await writeGraphDoc(targets, path), true); + assertEquals(await checkGraphDoc(targets, path), []); + + // Unchanged build: writing again is a no-op. + assertEquals(await writeGraphDoc(targets, path), false); + + // Hand-edited page: check reports it stale, write repairs it. + await Deno.writeTextFile(path, "tampered"); + assertEquals(await checkGraphDoc(targets, path), [`${path} (stale)`]); + assertEquals(await writeGraphDoc(targets, path), true); + assertEquals(await checkGraphDoc(targets, path), []); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); diff --git a/tests/integration/graph_doc_test.ts b/tests/integration/graph_doc_test.ts new file mode 100644 index 00000000..110cdb19 --- /dev/null +++ b/tests/integration/graph_doc_test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 the Zuke contributors +// SPDX-License-Identifier: MIT + +/** + * Integration: the graph-page generate-then-verify flow, driven through the + * real CLI `main()` — a fixture build whose targets call `writeGraphDoc` / + * `checkGraphDoc` exactly the way `zuke.ts`'s `graphDoc` / `graphDocCheck` + * targets do, proving the pattern works end-to-end (target execution, the + * discovered graph feeding the generator, and the failing-check exit code). + */ + +import { Build, discoverTargets, target } from "../../packages/core/mod.ts"; +import { + assertEquals, + assertStringIncludes, +} from "../../packages/core/tests/_assert.ts"; +import { checkGraphDoc, writeGraphDoc } from "../../build/graph_doc.ts"; +import { runCli } from "./_harness.ts"; + +/** A fixture mirroring zuke.ts's graphDoc/graphDocCheck wiring. */ +function fixture(path: string): new () => Build { + return class GraphDocDemo extends Build { + lint = target().description("Lint").executes(() => {}); + test = target().description("Test").dependsOn(this.lint).executes( + () => {}, + ); + graphDoc = target() + .description("Regenerate the graph page") + .executes(async () => { + await writeGraphDoc(discoverTargets(this), path); + }); + graphDocCheck = target() + .description("Verify the graph page is current") + .executes(async () => { + const stale = await checkGraphDoc(discoverTargets(this), path); + if (stale.length > 0) { + throw new Error( + `The build-graph page is out of date:\n ${stale.join("\n ")}`, + ); + } + }); + }; +} + +Deno.test("graphDoc target writes the page and graphDocCheck then passes", async () => { + const dir = await Deno.makeTempDir({ prefix: "zuke-it-graph-doc-" }); + const path = `${dir}/graph.md`; + try { + const Demo = fixture(path); + + // The check fails before the page exists… + const before = await runCli(Demo, ["graphDocCheck"]); + assertEquals(before.code, 1); + assertStringIncludes(before.err, "out of date"); + assertStringIncludes(before.err, "(missing)"); + + // …the generator target writes it… + const generate = await runCli(Demo, ["graphDoc"]); + assertEquals(generate.code, 0); + const page = await Deno.readTextFile(path); + assertStringIncludes(page, "```mermaid"); + assertStringIncludes(page, '"graphDocCheck"'); + assertStringIncludes(page, "| `test` | Test | `lint` |"); + + // …and the check passes against the written page. + const after = await runCli(Demo, ["graphDocCheck"]); + assertEquals(after.code, 0); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("graphDocCheck fails when the build gains a target after generation", async () => { + const dir = await Deno.makeTempDir({ prefix: "zuke-it-graph-drift-" }); + const path = `${dir}/graph.md`; + try { + const generate = await runCli(fixture(path), ["graphDoc"]); + assertEquals(generate.code, 0); + + // The same build plus one target: the committed page is now stale. + const Base = fixture(path); + class Grown extends Base { + extra = target().description("New work").executes(() => {}); + } + const check = await runCli(Grown, ["graphDocCheck"]); + assertEquals(check.code, 1); + assertStringIncludes(check.err, "(stale)"); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); diff --git a/zuke.ts b/zuke.ts index 87b5e3bb..9154a095 100644 --- a/zuke.ts +++ b/zuke.ts @@ -22,6 +22,7 @@ import { type AbsolutePath, appendJobSummary, Build, + discoverTargets, FileTasks, glob, isCI, @@ -88,6 +89,11 @@ import { } from "./build/gitleaks_report.ts"; import { checkSnippets, formatSnippetFailures } from "./build/snippets.ts"; import { checkHclWrappers, generateHclWrappers } from "./build/hcl_gen.ts"; +import { + checkGraphDoc, + GRAPH_DOC_PATH, + writeGraphDoc, +} from "./build/graph_doc.ts"; import { lintPrBody } from "./build/pr_body_lint.ts"; import { assertLockUnchanged } from "./build/lock_check.ts"; import { checkCoreFloors, formatFloorFailures } from "./build/core_floor.ts"; @@ -467,6 +473,32 @@ class ZukeBuild extends Build { ConsoleTasks.info("plugins/zuke/skills/ is in sync with skills/."); }); + graphDoc = target() + .description( + "Regenerate docs/graph.md — this build's graph as a Mermaid page", + ) + .executes(async () => { + const changed = await writeGraphDoc(discoverTargets(this)); + ConsoleTasks.info( + changed + ? `Regenerated ${GRAPH_DOC_PATH}.` + : `${GRAPH_DOC_PATH} already up to date.`, + ); + }); + + graphDocCheck = target() + .description("Verify docs/graph.md matches the current build graph") + .executes(async () => { + const stale = await checkGraphDoc(discoverTargets(this)); + if (stale.length > 0) { + throw new Error( + `The build-graph page is out of date:\n ${stale.join("\n ")}\n` + + "Run `./zuke graphDoc` and commit the result.", + ); + } + ConsoleTasks.info(`${GRAPH_DOC_PATH} matches the build graph.`); + }); + pluginVersionCheck = target() .description("Verify a skills change also bumped the plugin version") .executes(async () => { @@ -711,6 +743,7 @@ class ZukeBuild extends Build { this.snippetsCheck, this.hclSyncCheck, this.pluginSyncCheck, + this.graphDocCheck, this.pluginVersionCheck, this.prBodyLint, this.actionPinCheck,