Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()`,
Expand Down
178 changes: 178 additions & 0 deletions build/graph_doc.ts
Original file line number Diff line number Diff line change
@@ -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<string, TargetBuilder>,
): 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, TargetBuilder>): string {
const rows = graphDocTargets(targets);
const edgeCount = rows.reduce((n, row) => n + row.deps.length, 0);

const header = [
"# Zuke's build graph",
"",
"<!-- Generated by `./zuke graphDoc` — do not edit by hand. -->",
"",
"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<string, TargetBuilder>,
path: string = GRAPH_DOC_PATH,
): Promise<boolean> {
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<string, TargetBuilder>,
path: string = GRAPH_DOC_PATH,
): Promise<string[]> {
if (!await FileTasks.exists(path)) return [`${path} (missing)`];
const committed = await FileTasks.readText(path);
if (committed !== renderGraphDoc(targets)) return [`${path} (stale)`];
return [];
}
116 changes: 116 additions & 0 deletions docs/graph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Zuke's build graph

<!-- Generated by `./zuke graphDoc` — do not edit by hand. -->

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` |
Loading