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,