diff --git a/.github/workflows/pr-fast.yml b/.github/workflows/pr-fast.yml index 76fd88db3..032f3fd0a 100644 --- a/.github/workflows/pr-fast.yml +++ b/.github/workflows/pr-fast.yml @@ -213,6 +213,29 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} - run: bash scripts/ci/check_gates_drift.sh + # ───────────────────────────────────────────────────────────────────── + # hooks-drift — Phase 2 of `docs/architecture/gates-manifest-plan.md`. + # Verifies the on-disk `scripts/hooks/_lint_pre_push.sh` is + # byte-for-byte equal to what `gen-hooks` would emit from the + # canonical `scripts/ci/gates.toml`. Always-on; bypass not + # supported in CI. Cache key is shared with `sanity` so the + # gen-hooks binary build piggybacks on the existing rust-cache. + # ───────────────────────────────────────────────────────────────────── + hooks-drift: + name: Hook codegen drift + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - run: rustup show + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + shared-key: pr-fast-sanity + save-if: 'false' + - run: cargo run -q --release -p uffs-gen-hooks -- --check + # ───────────────────────────────────────────────────────────────────── # fmt — rustfmt check. Gates on rust_changed (pure TOML / config / # docs edits don't need rustfmt). @@ -505,6 +528,7 @@ jobs: - classify - file-size - gates-drift + - hooks-drift - fmt - sanity - clippy @@ -527,6 +551,7 @@ jobs: declare -A R=( [file-size]='${{ needs.file-size.result }}' [gates-drift]='${{ needs.gates-drift.result }}' + [hooks-drift]='${{ needs.hooks-drift.result }}' [fmt]='${{ needs.fmt.result }}' [sanity]='${{ needs.sanity.result }}' [clippy]='${{ needs.clippy.result }}' @@ -558,7 +583,7 @@ jobs: notify-failure: name: 🔔 Notify on Failure runs-on: ubuntu-latest - needs: [classify, file-size, gates-drift, fmt, sanity, clippy, docs, test-build, tests, security, windows-lint, required] + needs: [classify, file-size, gates-drift, hooks-drift, fmt, sanity, clippy, docs, test-build, tests, security, windows-lint, required] if: failure() && github.event_name != 'pull_request' permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index d9db81002..2cdbda7d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,79 @@ for the operator-facing validation flow. the on-disk cleanup logic is exercised without ever touching the host's cache directory. +### Added — Gates manifest Phase 2: `gen-hooks` Rust generator + auto-generated pre-push hook (PR #141) + +Phase 2 of [`docs/architecture/gates-manifest-plan.md`](docs/architecture/gates-manifest-plan.md). +The pre-push hook (`scripts/hooks/_lint_pre_push.sh`) is now +generated from the canonical manifest by a new Rust binary; manual +edits to the hook are caught by a paired drift detector that hard- +blocks merge. + +- **NEW `scripts/ci/gen-hooks/`** — Rust workspace member implementing + the `gen-hooks` binary per plan §4.1. Modules: + - `manifest.rs` — serde model + lightweight invariant validation + (no duplicate ids, valid bucket per pre-push tier, valid + `gate_when`, valid tier names). The TOML-side `gate_when` field + is bridged to the Rust-side `when` via `serde(rename)` so the + schema stays unchanged while the Rust struct stays clean. + - `emit.rs` — banner + dispatch generation. Per-gate special cases + are dispatched explicitly (no generic-template engine): + `commit-subjects` (multi-line `bash -c` reading `COMMIT_RANGES`), + `cargo-vet` (DEP_CHANGED + missing-tool hard-fail with install + hint, closes the PR #43 loophole), soft-skip-with-`command -v` + for any non-assumed tool, `dep_changed` inner guard for Bucket 2 + gates that need it. + - `templates/preamble.sh` + `templates/footer.sh` — embedded bash + scaffolding (colors, change-classification, `spawn_bg` / + `run_seq` helpers, bucket reaping, optional-tool hint, failure + dump). + - 24 unit tests covering schema parsing, validation invariants, + `gate_when` ⇄ `when` rename round-trip, `consumer_names` per-tier + label override (regression-guards the `test-build` ⇄ `tests` + legacy mapping), pr-fast-only gates legitimately omitting + `bucket`, every special-case emission pattern, and the §4.4 + idempotency contract. +- **MODIFIED `scripts/hooks/_lint_pre_push.sh`** — now generated. + Header carries the `AUTO-GENERATED by ... MANUAL EDITS WILL BE + OVERWRITTEN` banner + a quick-link to the manifest and the regen + recipe. Total file shrinks from 406 to 322 lines because the + per-gate documentation comments now live in the manifest's + `notes` fields (single source of truth). +- **NEW manifest gate `hooks-drift`** — self-referential gate that + runs `gen-hooks --check` to verify the on-disk hook is byte-for- + byte equal to what the generator would emit. Pairs with Phase + 1's `gates-drift`: the latter ensures the gate-set matches across + consumers; this one ensures the emitted hook matches what the + manifest would produce. Both run as Bucket 1 spawn_bg jobs in + the pre-push hook and as always-on jobs in `pr-fast.yml`. +- **NEW `pr-fast.yml::hooks-drift` job** — runs `cargo run -q + --release -p uffs-gen-hooks -- --check` on every PR. Cache key + shared with `sanity` so the gen-hooks binary build piggybacks on + the existing rust-cache. Added to `required.needs:`, + success-conditional aggregation, and `notify-failure.needs:`. +- **NEW `just gen-hooks`** recipe — manual regen entry point. +- **NEW `just hooks-drift`** recipe — manual drift-check entry + point. +- **MODIFIED `Cargo.toml`** — `scripts/ci/gen-hooks` added as a + workspace member alongside `scripts/ci-pipeline`. +- **MODIFIED `docs/architecture/gates-manifest-plan.md`** — Status + table updated (Phase 1 ✅ landed, Phase 2 🟡 in flight); §9 action + log appended. + +Verification: +- `cargo test -p uffs-gen-hooks` — 24 / 24 unit tests pass. +- `cargo clippy -p uffs-gen-hooks -- -D clippy::pedantic -D + clippy::nursery -D clippy::cargo -W clippy::unwrap_used -W + clippy::expect_used -W clippy::missing_docs_in_private_items -D + warnings` — exit 0, no per-item suppressions in non-test code. +- `bash scripts/ci/check_gates_drift.sh` — 21 gates correctly + matched against the regenerated hook. +- `just hooks-drift` — exit 0 (idempotent regen). +- `just lint-pre-push` — full 21-gate sweep green in 53 s warm + (matches pre-Phase-2 budget). +- `bash -n scripts/hooks/_lint_pre_push.sh` — syntax OK. +- `actionlint .github/workflows/pr-fast.yml` — exit 0. + ### Added — Gates manifest Phase 1: source-of-truth + drift detector (PR #140) Phase 1 of [`docs/architecture/gates-manifest-plan.md`](docs/architecture/gates-manifest-plan.md) diff --git a/Cargo.lock b/Cargo.lock index 38c26ae51..4f967fca8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4480,6 +4480,16 @@ dependencies = [ "uffs-time", ] +[[package]] +name = "uffs-gen-hooks" +version = "0.5.90" +dependencies = [ + "anyhow", + "clap", + "serde", + "toml", +] + [[package]] name = "uffs-mcp" version = "0.5.90" diff --git a/Cargo.toml b/Cargo.toml index a53efab43..5efad61f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,8 +36,9 @@ members = [ "crates/uffs-cli", # 🖥️ Command-line interface # NOTE: uffs-tui and uffs-gui have moved to the private uffs-products repo. # ── Tools ── - "crates/uffs-diag", # 🔬 Retained workspace-only diagnostic tools (not shipped in dist/) - "scripts/ci-pipeline", # 🛠️ Promoted CI pipeline driver (dev-flow-implementation-plan.md § 7) + "crates/uffs-diag", # 🔬 Retained workspace-only diagnostic tools (not shipped in dist/) + "scripts/ci-pipeline", # 🛠️ Promoted CI pipeline driver (dev-flow-implementation-plan.md § 7) + "scripts/ci/gen-hooks", # 🧬 Gate-manifest hook generator (gates-manifest-plan.md Phase 2) ] # ───────────────────────────────────────────────────────────────────────────── diff --git a/docs/architecture/gates-manifest-plan.md b/docs/architecture/gates-manifest-plan.md index 4ee983587..21025a634 100644 --- a/docs/architecture/gates-manifest-plan.md +++ b/docs/architecture/gates-manifest-plan.md @@ -19,8 +19,8 @@ UFFS - Ultra Fast File Search | Phase | Description | Status | |---|---|---| | 0 | Plan + schema design | ✅ landed (PR #139) | -| 1 | Manifest + drift detector (no consumer changes) | 🟡 in flight (PR #140) | -| 2 | Codegen for `_lint_pre_push.sh` | ⏭ pending | +| 1 | Manifest + drift detector (no consumer changes) | ✅ landed (PR #140) | +| 2 | Codegen for `_lint_pre_push.sh` | 🟡 in flight (PR #141) | | 3 | Codegen for `_lint_fast.sh` + `pr-fast.yml` + doc tables | ⏭ pending | Closing all four phases brings the workspace to **zero hand-maintained @@ -617,6 +617,6 @@ Other workflows are opaque to it. | Date | Event | PR | |---|---|---| | 2026-05-06 | Plan drafted (this doc) + landed | #139 | -| 2026-05-06 | Phase 1 in flight — manifest (`scripts/ci/gates.toml`) + drift detector (`scripts/ci/check_gates_drift.sh`) + pre-push Bucket 1 wiring + new `pr-fast.yml::gates-drift` job + `just gates-drift` recipe | #140 | -| TBD | Phase 2 lands (`gen-hooks` for pre-push) | TBD | -| TBD | Phase 3 lands (full migration) | TBD | +| 2026-05-06 | Phase 1 landed — manifest + drift detector + pre-push Bucket 1 wiring + `pr-fast.yml::gates-drift` job + `just gates-drift` recipe | #140 | +| 2026-05-06 | Phase 2 in flight — `scripts/ci/gen-hooks` Rust crate (manifest model + emit module + 24 unit tests) + auto-generated `_lint_pre_push.sh` (banner, embedded preamble, generated dispatch, embedded footer) + `hooks-drift` self-referential gate + `just gen-hooks` / `just hooks-drift` recipes + `pr-fast.yml::hooks-drift` job | #141 | +| TBD | Phase 3 lands (codegen for pre-commit + pr-fast.yml + doc tables) | TBD | diff --git a/just/test.just b/just/test.just index d60ce3241..04e41a689 100644 --- a/just/test.just +++ b/just/test.just @@ -206,6 +206,35 @@ lint-ci-linux-zig: gates-drift: @bash scripts/ci/check_gates_drift.sh +# Regenerate the pre-push hook from the canonical gates manifest +# (Phase 2 of `docs/architecture/gates-manifest-plan.md`). +# +# The generator (`scripts/ci/gen-hooks`) reads `scripts/ci/gates.toml` +# and writes `scripts/hooks/_lint_pre_push.sh`. Use this recipe +# whenever you change the manifest; the on-disk hook carries an +# AUTO-GENERATED banner and any manual edits will be overwritten on +# the next regen. +# +# Idempotency contract (plan §4.4): running this twice in a row +# leaves the file byte-identical; the `hooks-drift` recipe below is +# the CI-side check that enforces the same property. +gen-hooks: + @cargo run -q --release -p uffs-gen-hooks + +# Hook-codegen drift detector (Phase 2 of +# `docs/architecture/gates-manifest-plan.md`). +# +# Verifies that the on-disk `scripts/hooks/_lint_pre_push.sh` matches +# what `gen-hooks` would produce from the canonical +# `scripts/ci/gates.toml`. Catches the failure mode where someone +# manually edits the generated hook (whose header banner explicitly +# warns "MANUAL EDITS WILL BE OVERWRITTEN") instead of editing the +# manifest and regenerating. Wired into the pre-push hook +# (Bucket 1) and the `pr-fast.yml::hooks-drift` job so manifest / +# emitted-hook divergence hard-blocks merge. +hooks-drift: + @cargo run -q --release -p uffs-gen-hooks -- --check + # Host-only on purpose — Windows (`lint-*-windows`) and Linux-in-Docker # (`lint-ci-linux`) are separate recipes. They will be wired into this # sweep after their backlogs converge to zero (Phase W5 of the diff --git a/scripts/ci/gates.toml b/scripts/ci/gates.toml index a7d2329df..ba757646a 100644 --- a/scripts/ci/gates.toml +++ b/scripts/ci/gates.toml @@ -100,6 +100,40 @@ actually defined in `_lint_fast.sh`, `_lint_pre_push.sh`, and `main` is a deliberate "fix-me-now" signal. """ +[[gate]] +id = "hooks-drift" +label = "Hook codegen drift detector (gen-hooks --check)" +command = [ + "cargo", + "run", + "-q", + "--release", + "-p", + "uffs-gen-hooks", + "--", + "--check", +] +tiers = ["pre-push", "pr-fast"] +gate_when = "always" +hard = true +tool = "cargo" +expected_runtime_secs = 2 +bucket = "bg" +order = 26 +notes = """ +Phase 2 of `docs/architecture/gates-manifest-plan.md`. Verifies that +the on-disk `scripts/hooks/_lint_pre_push.sh` is byte-for-byte equal +to what `gen-hooks` would emit from the canonical +`scripts/ci/gates.toml`. Catches the failure mode where someone +manually edits the generated hook (whose AUTO-GENERATED banner +explicitly warns "MANUAL EDITS WILL BE OVERWRITTEN") instead of +editing the manifest and regenerating via `just gen-hooks`. + +Pairs with `gates-drift`: the latter ensures the manifest matches +the *gate set* of every consumer; this one ensures the generator's +emitted hook matches what the manifest would produce. +""" + [[gate]] id = "commit-subjects" label = "Conventional Commits subject validator" diff --git a/scripts/ci/gen-hooks/Cargo.toml b/scripts/ci/gen-hooks/Cargo.toml new file mode 100644 index 000000000..1b3dc22ad --- /dev/null +++ b/scripts/ci/gen-hooks/Cargo.toml @@ -0,0 +1,63 @@ +# ============================================================================= +# scripts/ci/gen-hooks/Cargo.toml — UFFS gate-manifest hook generator +# ============================================================================= +# +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2025-2026 SKY, LLC. +# +# Phase 2 of `docs/architecture/gates-manifest-plan.md`. Reads the +# canonical `scripts/ci/gates.toml` manifest and emits the matching +# `scripts/hooks/_lint_pre_push.sh` so that the hook content is never +# hand-maintained. Pre-commit (`_lint_fast.sh`) stays hand-written +# (Phase 3 picks it up). +# +# Lint policy: same as `scripts/ci-pipeline` — does NOT inherit +# `[lints] workspace = true`. This is an internal tool; the production +# library lints (deny unwrap, deny println, deny missing_docs_in_private_items) +# fight readability for an operational binary. Default clippy passes on +# this crate via the standard `cargo clippy` invocation. +# ============================================================================= + +[package] +name = "uffs-gen-hooks" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "UFFS gate-manifest hook generator (Phase 2 of gates-manifest-plan); not published" +publish = false + +[[bin]] +name = "gen-hooks" +path = "src/main.rs" + +# ───────────────────────────────────────────────────────────────────────────── +# Dependencies +# ───────────────────────────────────────────────────────────────────────────── +# Every dependency is aliased to `[workspace.dependencies]` in the root +# Cargo.toml so a single Cargo.lock entry serves this tool and every other +# crate. See `scripts/ci-pipeline/Cargo.toml` for the rationale. +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } + +# ───────────────────────────────────────────────────────────────────────────── +# Lints policy +# ───────────────────────────────────────────────────────────────────────────── +# This crate intentionally does NOT opt into `[lints] workspace = true`, +# matching the convention established by `scripts/ci-pipeline`. The +# `[workspace.lints]` stack adds restriction-class lints +# (`print_stderr`, `min_ident_chars`, `indexing_slicing`, +# `std_instead_of_alloc`) that fight the ergonomics of an operational +# CLI tool that legitimately writes to stderr. +# +# The `just lint-prod` and `just lint-tests` recipes (which the +# pre-push hook and `pr-fast.yml::clippy` job both run) still apply +# the strict CLI flag stack — `pedantic`, `nursery`, `cargo`, +# `unwrap_used`, `expect_used`, `missing_docs_in_private_items`, +# `-D warnings` — and this crate passes them cleanly without any +# per-item suppressions. diff --git a/scripts/ci/gen-hooks/src/emit.rs b/scripts/ci/gen-hooks/src/emit.rs new file mode 100644 index 000000000..756b94ebb --- /dev/null +++ b/scripts/ci/gen-hooks/src/emit.rs @@ -0,0 +1,613 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. +// +// Hook emission — turns a parsed `Manifest` into the bash text of +// `_lint_pre_push.sh`. +// +// Layout of the emitted file (top to bottom): +// +// 1. AUTO-GENERATED banner + manifest cross-reference. +// 2. Embedded preamble template — colors, change-classification, `spawn_bg` / +// `run_seq` helpers. Static; lives in `templates/preamble.sh`. Maintained +// by hand because it is pure scaffolding (no gate-specific knowledge). +// 3. Generated dispatch block — Bucket 1 (spawn_bg) lines for every pre-push +// gate with `bucket = "bg"`, then Bucket 2 (run_seq) lines wrapped in the +// `if (( CODE_CHANGED ))` conditional for every gate with `bucket = +// "seq"`. This is the bit the manifest drives. +// 4. Embedded footer template — bucket reaping, result reporting, +// optional-tool hint, failure dump. Static; lives in +// `templates/footer.sh`. +// +// Per-gate special cases are hardcoded (not parameterised by the +// manifest) so the generator stays small and reviewable. The +// `gates.toml` fields drive the *decision* of which pattern to +// emit; the *templates* themselves live here. + +use crate::manifest::{Gate, Manifest}; + +/// Tools the generator treats as "always present" — no install check +/// guards are emitted around gates that use these tools. Adding a +/// new tool to the workspace baseline (e.g. via `just install-dev-tools`) +/// requires extending this list and updating the workspace tooling +/// docs in `CONTRIBUTING.md` simultaneously. +const ASSUMED_TOOLS: &[&str] = &["cargo", "bash", "cargo-nextest", "cargo-deny"]; + +/// Embedded scaffolding emitted before the generated dispatch +/// section — colors, change-classification, `spawn_bg` / `run_seq` +/// helpers. Pure bash; no per-gate knowledge. +const PREAMBLE: &str = include_str!("../templates/preamble.sh"); +/// Embedded scaffolding emitted after the generated dispatch +/// section — bucket reaping, result reporting, optional-tool +/// hint, failure dump. Pure bash; no per-gate knowledge. +const FOOTER: &str = include_str!("../templates/footer.sh"); + +/// Generation target — currently only `pre-push`. Phase 3 adds +/// `pre-commit` (`_lint_fast.sh`). +pub enum EmitTarget { + /// `_lint_pre_push.sh` — the workspace pre-push gate. + PrePush, +} + +impl EmitTarget { + /// Render the full bash file as a single owned `String`. + pub fn render(&self, manifest: &Manifest) -> String { + match self { + Self::PrePush => render_pre_push(manifest), + } + } +} + +/// Render the complete `_lint_pre_push.sh` file: AUTO-GENERATED +/// banner, embedded preamble, generated dispatch, embedded footer. +fn render_pre_push(manifest: &Manifest) -> String { + let mut out = String::with_capacity(16 * 1024); + out.push_str(banner()); + out.push_str(PREAMBLE); + out.push_str(&render_dispatch(manifest)); + out.push_str(FOOTER); + out +} + +/// Top-of-file banner emitted ahead of the embedded preamble. +/// Carries the AUTO-GENERATED notice + a quick-link to the manifest +/// and the regen recipe so a contributor opening the file in their +/// editor knows exactly where to make changes. +// REUSE-IgnoreStart -- the literal `SPDX-License-Identifier:` text +// in the returned bash banner is part of the GENERATED hook's +// header, not this Rust file's REUSE metadata. Without this +// directive `reuse lint` parses the embedded `MPL-2.0\n\` as if it +// were the SPDX expression for `emit.rs` itself and rejects it. +const fn banner() -> &'static str { + "#!/usr/bin/env bash\n\ + # SPDX-License-Identifier: MPL-2.0\n\ + # Copyright (c) 2025-2026 SKY, LLC.\n\ + #\n\ + # AUTO-GENERATED by `scripts/ci/gen-hooks` from `scripts/ci/gates.toml`.\n\ + # MANUAL EDITS WILL BE OVERWRITTEN.\n\ + #\n\ + # To change a gate, edit the manifest and regenerate:\n\ + # vim scripts/ci/gates.toml\n\ + # just gen-hooks\n\ + #\n\ + # Plan: docs/architecture/gates-manifest-plan.md\n\ + #\n\ + # Workspace-wide two-bucket pre-push gate. See the plan doc for\n\ + # bucket semantics, fail-fast ordering, and the full per-gate\n\ + # rationale (the `notes` field on each `[[gate]]` table in\n\ + # `gates.toml` carries the same documentation that used to live\n\ + # in this header before Phase 2 codegen).\n\ + \n" +} +// REUSE-IgnoreEnd + +/// Generate the dispatch block: Bucket 1 `spawn_bg` lines for every +/// pre-push gate with `bucket = "bg"`, then Bucket 2 `run_seq` lines +/// wrapped in `if (( CODE_CHANGED ))` for every gate with +/// `bucket = "seq"`. Per-gate special cases (commit-subjects, vet, +/// soft-skip-with-command-v) are dispatched in [`emit_bg`] / +/// [`emit_seq`]. +fn render_dispatch(manifest: &Manifest) -> String { + let gates = manifest.gates_for_tier("pre-push"); + let bg: Vec<&Gate> = gates + .iter() + .filter(|g| g.bucket.as_deref() == Some("bg")) + .copied() + .collect(); + let seq: Vec<&Gate> = gates + .iter() + .filter(|g| g.bucket.as_deref() == Some("seq")) + .copied() + .collect(); + + let mut out = String::with_capacity(4 * 1024); + out.push_str("# ── Dispatch (generated from gates.toml) ──────────────────────────────\n"); + + out.push_str("# Bucket 1 — fire-and-forget. Cheap, parallel; no cargo lock\n"); + out.push_str("# contention. See gates.toml for the canonical gate set.\n"); + for gate in &bg { + out.push_str(&emit_bg(gate)); + } + + if !seq.is_empty() { + out.push('\n'); + out.push_str("# Bucket 2 — sequential, fail-fast. Only runs when code\n"); + out.push_str("# changed (rust | dep | infra). Pure-docs-only pushes skip\n"); + out.push_str("# the compile/test gate entirely.\n"); + out.push_str("if (( CODE_CHANGED )); then\n"); + for gate in &seq { + out.push_str(&indent_block(&emit_seq(gate), 4)); + } + out.push_str("fi\n"); + } + + out +} + +/// Bucket 1 emission for a single gate. +/// +/// Special cases (in order of precedence): +/// 1. `commit-subjects` — multi-line `bash -c` reading `COMMIT_RANGES`. +/// 2. `cargo-vet` + `gate_when="dep_changed"` + `hard=true` — emit the +/// `DEP_CHANGED` gate WITH a missing-tool hard-fail and install hint. +/// Closes the PR #43 loophole. +/// 3. `hard=false` + non-assumed tool — silent `command -v` guard. +/// 4. Default — unconditional `spawn_bg`. +/// +/// Gates whose `gate_when` equals one of `rust_changed` / +/// `infra_changed` / `code_changed` are still emitted unconditionally +/// at Bucket-1 today (matching the pre-Phase-2 hand-written hook): +/// they are cheap enough that running them on a pure-docs push is +/// cheaper than evaluating a guard. Phase 3 may add explicit +/// per-class guards if the runtime budget changes. +fn emit_bg(gate: &Gate) -> String { + if gate.command.iter().any(|s| s.contains("{{COMMIT_RANGES}}")) { + return emit_commit_subjects(gate); + } + + if gate.tool == "cargo-vet" && gate.when == "dep_changed" && gate.hard { + return emit_vet(gate); + } + + let cmd = format_command(&gate.command); + let label = consumer_label(gate); + if !gate.hard && !is_assumed(&gate.tool) { + return format!( + "command -v {tool} >/dev/null 2>&1 && spawn_bg \"{label}\" {cmd}\n", + tool = gate.tool, + label = label, + cmd = cmd, + ); + } + + format!("spawn_bg \"{label}\" {cmd}\n") +} + +/// Bucket 2 emission. Already inside `if (( CODE_CHANGED ))` so the +/// outer rust/code conditionals are implied; an inner `dep_changed` +/// guard is emitted explicitly because `dep_changed` is *strictly +/// stronger* than `code_changed`. +fn emit_seq(gate: &Gate) -> String { + let cmd = format_command(&gate.command); + let label = consumer_label(gate); + + if !gate.hard && !is_assumed(&gate.tool) { + return format!( + "if command -v {tool} >/dev/null 2>&1; then\n\ + {pad}run_seq \"{label}\" {cmd}\n\ + fi\n", + tool = gate.tool, + label = label, + cmd = cmd, + pad = " ", + ); + } + + if gate.when == "dep_changed" { + return format!( + "if (( DEP_CHANGED )); then\n\ + {pad}run_seq \"{label}\" {cmd}\n\ + fi\n", + label = label, + cmd = cmd, + pad = " ", + ); + } + + format!("run_seq \"{label}\" {cmd}\n") +} + +/// Hardcoded multi-line bash for `commit-subjects` — the only gate +/// whose command embeds `{{COMMIT_RANGES}}` (the iterated stdin +/// captured during change-classification). Phase 3 may generalise +/// this if a second template variable enters the manifest. +fn emit_commit_subjects(gate: &Gate) -> String { + format!( + "spawn_bg \"{id}\" bash -c '\n\ + {pad}set -euo pipefail\n\ + {pad}[[ -z \"${{COMMIT_RANGES// /}}\" ]] && exit 0\n\ + {pad}while IFS= read -r range; do\n\ + {pad}{pad}[[ -z \"$range\" ]] && continue\n\ + {pad}{pad}bash scripts/ci/check_commit_subjects.sh range \"$range\"\n\ + {pad}done <<< \"$COMMIT_RANGES\"\n\ + '\n", + id = gate.id, + pad = " ", + ) +} + +/// Hardcoded `vet` block — the only hard=true Bucket-1 gate that +/// uses a non-assumed tool with an install hint. Closes PR #43's +/// CI-only-checked loophole: missing `cargo-vet` on a `dep_changed` +/// push aborts before the rest of the hook runs. +fn emit_vet(gate: &Gate) -> String { + let cmd = format_command(&gate.command); + let label = consumer_label(gate); + format!( + "if (( DEP_CHANGED )); then\n\ + {pad}if ! command -v {tool} >/dev/null 2>&1; then\n\ + {pad}{pad}printf '%s\u{274c} {tool} required (Cargo.{{toml,lock}} or supply-chain/ changed)%s\\n' \"$C_RED\" \"$C_RESET\" >&2\n\ + {pad}{pad}printf ' %sinstall: %scargo install {tool} --locked%s\\n' \"$C_YELLOW\" \"$C_CYAN\" \"$C_RESET\" >&2\n\ + {pad}{pad}printf ' %sor run: %sjust install-dev-tools%s\\n' \"$C_YELLOW\" \"$C_CYAN\" \"$C_RESET\" >&2\n\ + {pad}{pad}exit 2\n\ + {pad}fi\n\ + {pad}spawn_bg \"{label}\" {cmd}\n\ + fi\n", + tool = gate.tool, + label = label, + cmd = cmd, + pad = " ", + ) +} + +/// Resolve the consumer-side label for a gate at the pre-push tier. +/// Honors the per-tier `consumer_names` override (legacy naming) and +/// falls back to the gate id when no override is set. This keeps +/// the emitted hook byte-equivalent to the pre-Phase-2 hand-written +/// names (`test-build` gate id → `tests` legacy label, etc.). +fn consumer_label(gate: &Gate) -> &str { + gate.consumer_names + .get("pre-push") + .map_or(gate.id.as_str(), String::as_str) +} + +/// Predicate: does the workspace's tooling baseline guarantee that +/// `tool` is on every contributor's PATH? See `ASSUMED_TOOLS` above. +fn is_assumed(tool: &str) -> bool { + ASSUMED_TOOLS.contains(&tool) +} + +/// Render a TOML command-array as a bash command line. Tokens are +/// emitted as-is (unquoted) when they are pure shell-safe identifiers +/// or option-style flags, and double-quoted otherwise. Matches the +/// idiom used in the pre-Phase-2 hand-written hook. +fn format_command(cmd: &[String]) -> String { + cmd.iter() + .map(|tok| shell_quote(tok)) + .collect::>() + .join(" ") +} + +/// Quote a single bash token. Tokens that are pure shell-safe +/// identifiers, paths, flags, or numeric values pass through +/// unquoted; everything else gets single-quoted with embedded +/// single-quotes escaped via the `'\''` idiom. +fn shell_quote(token: &str) -> String { + if token.is_empty() { + return "''".to_owned(); + } + let safe = token + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | '.' | ',' | '=' | ':')); + if safe { + return token.to_owned(); + } + let escaped = token.replace('\'', r"'\''"); + format!("'{escaped}'") +} + +/// Indent every non-empty line of `text` by `n` spaces. Used to +/// nest Bucket-2 emission inside the `if (( CODE_CHANGED ))` block. +fn indent_block(text: &str, n: usize) -> String { + let pad = " ".repeat(n); + let mut out = String::with_capacity(text.len() + n * text.lines().count()); + for line in text.lines() { + if !line.is_empty() { + out.push_str(&pad); + out.push_str(line); + } + out.push('\n'); + } + if !text.ends_with('\n') { + // Preserve trailing-newline absence so the caller's join + // semantics are not broken. + out.pop(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::Manifest; + + fn parse(toml_text: &str) -> Manifest { + let m: Manifest = toml::from_str(toml_text).unwrap(); + m.validate().unwrap(); + m + } + + #[test] + fn dispatch_emits_bg_then_seq() { + let m = parse( + r#" +[[gate]] +id="fmt" +label="x" +command=["cargo","fmt","--all","--","--check"] +tiers=["pre-push"] +gate_when="always" +hard=true +tool="cargo" +bucket="bg" +order=10 + +[[gate]] +id="lint-ci" +label="x" +command=["just","lint-ci"] +tiers=["pre-push"] +gate_when="rust_changed" +hard=true +tool="cargo" +bucket="seq" +order=20 +"#, + ); + let out = render_dispatch(&m); + let bg_pos = out.find("spawn_bg \"fmt\"").unwrap(); + let seq_pos = out.find("run_seq \"lint-ci\"").unwrap(); + assert!(bg_pos < seq_pos, "Bucket 1 must be emitted before Bucket 2"); + assert!(out.contains("if (( CODE_CHANGED )); then")); + } + + #[test] + fn commit_subjects_uses_special_template() { + let m = parse( + r#" +[[gate]] +id="commit-subjects" +label="x" +command=["bash","scripts/ci/check_commit_subjects.sh","range","{{COMMIT_RANGES}}"] +tiers=["pre-push"] +gate_when="always" +hard=true +tool="bash" +bucket="bg" +"#, + ); + let out = render_dispatch(&m); + assert!(out.contains("bash -c '")); + assert!(out.contains("while IFS= read -r range")); + assert!(out.contains("$COMMIT_RANGES")); + // The literal `{{COMMIT_RANGES}}` placeholder must NOT leak + // into the emitted bash. + assert!(!out.contains("{{COMMIT_RANGES}}")); + } + + #[test] + fn vet_emits_dep_changed_hard_fail_block() { + let m = parse( + r#" +[[gate]] +id="vet" +label="x" +command=["cargo","vet","check","--locked"] +tiers=["pre-push"] +gate_when="dep_changed" +hard=true +tool="cargo-vet" +bucket="bg" +"#, + ); + let out = render_dispatch(&m); + assert!(out.contains("if (( DEP_CHANGED )); then")); + assert!(out.contains("if ! command -v cargo-vet >/dev/null 2>&1")); + assert!(out.contains("install: ")); + assert!(out.contains("just install-dev-tools")); + } + + #[test] + fn soft_skip_emits_command_v_guard() { + let m = parse( + r#" +[[gate]] +id="typos" +label="x" +command=["typos","."] +tiers=["pre-push"] +gate_when="always" +hard=false +tool="typos" +bucket="bg" +"#, + ); + let out = render_dispatch(&m); + assert!(out.contains("command -v typos >/dev/null 2>&1 && spawn_bg \"typos\"")); + } + + #[test] + fn deny_in_seq_wraps_in_dep_changed() { + let m = parse( + r#" +[[gate]] +id="deny" +label="x" +command=["cargo","deny","check"] +tiers=["pre-push"] +gate_when="dep_changed" +hard=true +tool="cargo-deny" +bucket="seq" +"#, + ); + let out = render_dispatch(&m); + // Bucket 2 emission is indented inside `if (( CODE_CHANGED ))`. + assert!(out.contains(" if (( DEP_CHANGED )); then")); + assert!(out.contains("run_seq \"deny\"")); + } + + #[test] + fn windows_lint_in_seq_wraps_in_command_v() { + let m = parse( + r#" +[[gate]] +id="lint-ci-windows" +label="x" +command=["just","lint-ci-windows"] +tiers=["pre-push"] +gate_when="code_changed" +hard=false +tool="cargo-xwin" +bucket="seq" +"#, + ); + let out = render_dispatch(&m); + assert!(out.contains("if command -v cargo-xwin >/dev/null 2>&1; then")); + assert!(out.contains("run_seq \"lint-ci-windows\"")); + } + + #[test] + fn shell_quote_passes_safe_tokens() { + assert_eq!(shell_quote("cargo"), "cargo"); + assert_eq!(shell_quote("--locked"), "--locked"); + assert_eq!(shell_quote("scripts/ci/x.sh"), "scripts/ci/x.sh"); + assert_eq!(shell_quote(""), "''"); + // Spaces force quoting. + assert_eq!(shell_quote("a b"), "'a b'"); + // Embedded single quote escaped. + assert_eq!(shell_quote("it's"), "'it'\\''s'"); + } + + #[test] + fn consumer_names_override_is_honoured_in_emission() { + // Mirrors the real `test-build` gate which carries + // `consumer_names = { "pre-push" = "tests" }` for legacy + // compatibility with the pre-Phase-2 hook. The emitted + // run_seq label MUST use the override; otherwise the drift + // detector flags the regenerated hook as out of sync with + // the manifest. + let m = parse( + r#" +[[gate]] +id="test-build" +label="x" +command=["cargo","nextest","run","--no-run"] +tiers=["pre-push"] +gate_when="code_changed" +hard=true +tool="cargo-nextest" +bucket="seq" +consumer_names = { "pre-push" = "tests" } +"#, + ); + let out = render_dispatch(&m); + assert!( + out.contains("run_seq \"tests\""), + "expected `run_seq \"tests\"` (override), got:\n{out}" + ); + assert!( + !out.contains("run_seq \"test-build\""), + "raw gate id leaked into output despite override:\n{out}" + ); + } + + #[test] + fn missing_consumer_override_falls_back_to_gate_id() { + // No override for the active tier → emit gate id verbatim. + let m = parse( + r#" +[[gate]] +id="cargo-check" +label="x" +command=["cargo","check"] +tiers=["pre-push"] +gate_when="code_changed" +hard=true +tool="cargo" +bucket="seq" +consumer_names = { "pr-fast" = "sanity" } +"#, + ); + let out = render_dispatch(&m); + // pre-push has no override; emit canonical id. + assert!(out.contains("run_seq \"cargo-check\"")); + // The pr-fast override must NOT leak into the pre-push emit. + assert!(!out.contains("run_seq \"sanity\"")); + } + + #[test] + fn full_render_is_idempotent() { + // Plan §4.4 idempotency contract: running the generator + // twice in a row must produce no diff on the second run. + // This covers the entire pipeline (banner + preamble + + // dispatch + footer), not just the dispatch slice. + let m = parse( + r#" +[[gate]] +id="fmt" +label="x" +command=["cargo","fmt","--all","--","--check"] +tiers=["pre-push"] +gate_when="always" +hard=true +tool="cargo" +bucket="bg" +order=10 +"#, + ); + let r1 = EmitTarget::PrePush.render(&m); + let r2 = EmitTarget::PrePush.render(&m); + assert_eq!(r1, r2, "non-deterministic render"); + // Banner + preamble + dispatch + footer must all be present. + assert!(r1.starts_with("#!/usr/bin/env bash\n")); + assert!(r1.contains("AUTO-GENERATED")); + assert!(r1.contains("spawn_bg \"fmt\"")); + assert!(r1.contains("BG_FAILED")); + } + + #[test] + fn render_is_deterministic() { + let toml_text = r#" +[[gate]] +id="b" +label="x" +command=["true"] +tiers=["pre-push"] +gate_when="always" +hard=true +tool="bash" +bucket="bg" +order=20 + +[[gate]] +id="a" +label="x" +command=["true"] +tiers=["pre-push"] +gate_when="always" +hard=true +tool="bash" +bucket="bg" +order=10 +"#; + let m1 = parse(toml_text); + let m2 = parse(toml_text); + let r1 = render_dispatch(&m1); + let r2 = render_dispatch(&m2); + assert_eq!(r1, r2); + // `a` (order=10) before `b` (order=20). + let pa = r1.find("spawn_bg \"a\"").unwrap(); + let pb = r1.find("spawn_bg \"b\"").unwrap(); + assert!(pa < pb); + } +} diff --git a/scripts/ci/gen-hooks/src/main.rs b/scripts/ci/gen-hooks/src/main.rs new file mode 100644 index 000000000..1dcff748b --- /dev/null +++ b/scripts/ci/gen-hooks/src/main.rs @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. +// +// gen-hooks — gate-manifest hook generator. +// +// Phase 2 of `docs/architecture/gates-manifest-plan.md`. Reads +// `scripts/ci/gates.toml` and emits `scripts/hooks/_lint_pre_push.sh` +// so the hook content is never hand-maintained. See `manifest.rs` +// for the schema model and `emit.rs` for the template + per-gate +// emission logic. +// +// USAGE: gen-hooks [--check] [--tier ] [--verbose] +// +// EXIT: +// 0 emit succeeded (or no-op with --check) +// 1 diff detected (with --check) +// 2 schema error (manifest invalid) + +/// Per-target hook emission — turns a parsed manifest into the bash +/// text of `_lint_pre_push.sh`. +mod emit; +/// Manifest schema model + lightweight invariant validation. +mod manifest; + +use std::path::PathBuf; +use std::process::ExitCode; + +use anyhow::{Context, Result}; +use clap::Parser; + +use crate::emit::EmitTarget; +use crate::manifest::Manifest; + +/// CLI arguments for `gen-hooks`. Flags follow the pattern set by +/// `scripts/ci-pipeline` and the rest of the workspace's internal +/// tools. See the file-level doc-comment for exit-code semantics. +#[derive(Parser, Debug)] +#[command( + name = "gen-hooks", + version, + about = "Generate _lint_pre_push.sh from gates.toml (Phase 2 of gates-manifest-plan.md)" +)] +struct Args { + /// Diff mode: do not write files; exit 1 if regen would change them. + /// Used by CI's `hooks-drift` job and the pre-push Bucket-1 step. + #[arg(long)] + check: bool, + + /// Restrict emit to one tier. Currently only `pre-push` is + /// supported (Phase 2 scope). `pre-commit` is hand-written and + /// reserved for Phase 3. + #[arg(long, default_value = "pre-push")] + tier: String, + + /// Print per-gate emit decisions to stderr. + #[arg(long, short)] + verbose: bool, + + /// Override the manifest path (default: `scripts/ci/gates.toml`). + /// Test-only escape hatch. + #[arg(long, hide = true)] + manifest: Option, + + /// Override the output path (default: `scripts/hooks/_lint_pre_push.sh`). + /// Test-only escape hatch. + #[arg(long, hide = true)] + output: Option, +} + +/// CLI entry point. Delegates straight to [`run`] so the +/// `Result` propagation pattern stays composable; an +/// emit failure surfaces as exit code `2` (schema error class) +/// with the underlying error chain on stderr. +fn main() -> ExitCode { + let args = Args::parse(); + match run(&args) { + Ok(code) => code, + Err(err) => { + eprintln!("gen-hooks: {err:#}"); + ExitCode::from(2) + } + } +} + +/// Inner driver. Splits cleanly into: parse args → read manifest +/// → validate → render → (write or check). Any failure short-circuits +/// via `?`; the return value distinguishes "emit succeeded" (0) from +/// "diff detected in --check mode" (1). Schema errors propagate up +/// to `main`, which maps them to exit code `2`. +fn run(args: &Args) -> Result { + if args.tier != "pre-push" { + anyhow::bail!( + "tier `{}` not supported in Phase 2. Only `pre-push` is generated; \ + `_lint_fast.sh` (pre-commit) is hand-written until Phase 3.", + args.tier + ); + } + + let manifest_path = args + .manifest + .clone() + .unwrap_or_else(|| PathBuf::from("scripts/ci/gates.toml")); + let output_path = args + .output + .clone() + .unwrap_or_else(|| PathBuf::from("scripts/hooks/_lint_pre_push.sh")); + + let manifest_text = std::fs::read_to_string(&manifest_path) + .with_context(|| format!("reading manifest at {}", manifest_path.display()))?; + let manifest: Manifest = toml::from_str(&manifest_text) + .with_context(|| format!("parsing manifest at {}", manifest_path.display()))?; + manifest + .validate() + .with_context(|| format!("validating manifest at {}", manifest_path.display()))?; + + if args.verbose { + emit_verbose_dump(&manifest, &manifest_path, &args.tier); + } + + let target = EmitTarget::PrePush; + let emitted = target.render(&manifest); + + if args.check { + let on_disk = std::fs::read_to_string(&output_path) + .with_context(|| format!("reading output at {}", output_path.display()))?; + if on_disk == emitted { + if args.verbose { + eprintln!("gen-hooks: --check passed (no diff)"); + } + return Ok(ExitCode::SUCCESS); + } + eprintln!( + "gen-hooks: --check FAILED — {} is out of sync with the manifest.\n\ + \n\ + Regenerate it with:\n just gen-hooks\n\ + \n\ + (Or run `cargo run -p uffs-gen-hooks --` directly.)", + output_path.display() + ); + return Ok(ExitCode::from(1)); + } + + std::fs::write(&output_path, &emitted) + .with_context(|| format!("writing {}", output_path.display()))?; + if args.verbose { + eprintln!( + "gen-hooks: wrote {} ({} bytes)", + output_path.display(), + emitted.len() + ); + } + Ok(ExitCode::SUCCESS) +} + +/// Print a human-readable dump of the parsed manifest to stderr. +/// Used by `--verbose` to confirm what the generator saw before +/// rendering — header metadata, the `[classification]` regex stack +/// (Phase 3 will wire these into the preamble), and one line per +/// gate that participates in `tier`, including the documentary +/// `expected_runtime_secs` budget and the first line of `notes`. +fn emit_verbose_dump(manifest: &Manifest, manifest_path: &std::path::Path, tier: &str) { + eprintln!( + "gen-hooks: parsed manifest at {} (schema v{}{})", + manifest_path.display(), + manifest.header.version, + manifest + .header + .plan_doc + .as_deref() + .map(|p| format!(", plan: {p}")) + .unwrap_or_default(), + ); + if let Some(cls) = manifest.classification.as_ref() { + let mut keys: Vec<&str> = cls.patterns.keys().map(String::as_str).collect(); + keys.sort_unstable(); + eprintln!( + "gen-hooks: classification keys ({}): {}", + keys.len(), + keys.join(", ") + ); + } + eprintln!( + "gen-hooks: {} gates total, emitting tier `{}`", + manifest.gate.len(), + tier + ); + for gate in &manifest.gate { + if !gate.tiers.iter().any(|t| t == tier) { + continue; + } + let first_note = gate + .notes + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("(no notes)"); + eprintln!( + " · [{tier}] {id} ({label}) bucket={bucket} when={when} hard={hard} \ + ~{secs}s — {note}", + tier = tier, + id = gate.id, + label = gate.label, + bucket = gate.bucket.as_deref().unwrap_or(""), + when = gate.when, + hard = gate.hard, + secs = gate.expected_runtime_secs, + note = first_note, + ); + } +} diff --git a/scripts/ci/gen-hooks/src/manifest.rs b/scripts/ci/gen-hooks/src/manifest.rs new file mode 100644 index 000000000..27fd562f7 --- /dev/null +++ b/scripts/ci/gen-hooks/src/manifest.rs @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. +// +// Manifest schema model — mirrors `scripts/ci/gates.toml`. +// +// The `gates.toml` file is the canonical source-of-truth for the +// workspace's PR-time gate set. See `docs/architecture/gates-manifest-plan.md` +// §3 for the schema spec. This module does the bare minimum: +// +// 1. Deserialise the file via `serde` + `toml::from_str`. +// 2. Validate the invariants the codegen relies on (no duplicate ids, every +// `tiers` entry valid, every `bucket` valid, every `gate_when` valid). +// +// The validator is intentionally lightweight — it only catches schema +// bugs that would crash the generator. Cross-consumer drift detection +// is the Phase-1 `check_gates_drift.sh` script's job and stays +// authoritative there. + +use std::collections::BTreeSet; + +use anyhow::{Result, ensure}; +use serde::Deserialize; + +/// Top-level manifest layout. TOML key names match `gates.toml` +/// verbatim so `[[gate]]` arrays of tables deserialise straight in. +#[derive(Debug, Deserialize)] +pub struct Manifest { + /// Optional `[manifest]` header table — version + plan-doc + /// cross-reference. Surfaced by `--verbose`; Phase 3 will also + /// gate generator compatibility on `version`. + #[serde(default, rename = "manifest")] + pub header: ManifestMeta, + + /// Optional `[classification]` table — the regex stack used by + /// `_lint_pre_push.sh` for `RUST_CHANGED` / `DEP_CHANGED` / + /// `INFRA_CHANGED` detection. Phase 2 keeps the regexes in the + /// embedded `preamble` template (§5 of the plan: codegen is + /// layered, not big-bang); the generator only echoes them via + /// `--verbose` so contributors can confirm what they parsed. + /// Phase 3 will wire them through to the preamble. + #[serde(default)] + pub classification: Option, + + /// Every `[[gate]]` table in the manifest. TOML preserves + /// declaration order on parse; the generator re-sorts before + /// emit (see `emit.rs`) so the output is deterministic regardless + /// of declaration order. + #[serde(default)] + pub gate: Vec, +} + +/// `[manifest]` header table. Captures the manifest's own version +/// and the cross-reference back to the plan doc. All fields are +/// optional so an early manifest snapshot without the header still +/// parses cleanly. +#[derive(Debug, Deserialize, Default)] +pub struct ManifestMeta { + /// Schema version. Surfaced by `--verbose`; Phase 3 will also + /// reject manifests whose major bumps this past the generator's + /// known maximum. + #[serde(default)] + pub version: u32, + /// Path (relative to repo root) of the architecture plan + /// document this manifest implements. Surfaced by `--verbose` + /// and (Phase 2.x) the regen banner. + #[serde(default)] + pub plan_doc: Option, +} + +/// `[classification]` is a free-form map of class-name → regex pattern. +/// Phase 2 does not consume these for emission (the regexes are +/// embedded literally in `templates/preamble.sh`); the generator only +/// echoes them via `--verbose`. Phase 3 will tie the two together so +/// the manifest's regex stack drives the preamble's classification +/// block. +#[derive(Debug, Deserialize)] +pub struct Classification { + /// Class-name → regex map. Class names are short identifiers like + /// `rust`, `dep`, `infra`, `docs`; the values are anchored regex + /// patterns matched against `git diff --name-only` output. + #[serde(flatten)] + pub patterns: std::collections::BTreeMap, +} + +/// One `[[gate]]` table. Field names match `gates.toml` modulo the +/// `when` ↔ `gate_when` rename: the TOML schema spells the trigger +/// classifier `gate_when` (matching the Phase-1 plan doc and the +/// drift-detector script), while the Rust field uses the unprefixed +/// `when` to keep the struct-name-prefix lint clean. `serde(rename)` +/// bridges the two without requiring the manifest authors to know +/// the Rust spelling. +#[derive(Debug, Deserialize)] +pub struct Gate { + /// Kebab-case canonical identifier — the `spawn_bg` / `run_seq` + /// label emitted into the hook (modulo `consumer_names` overrides). + pub id: String, + /// Human-readable name surfaced by drift-detector messages and + /// (future) the regen banner. + pub label: String, + /// Command line as a TOML array. Token zero is the executable; + /// subsequent tokens are arguments. See `emit::format_command` + /// for the bash-quoting rules. + pub command: Vec, + /// Subset of `{pre-commit, pre-push, pr-fast, tier-2}`. The + /// generator filters by membership. + pub tiers: Vec, + /// One of `always` / `rust_changed` / `dep_changed` / + /// `infra_changed` / `code_changed`. Drives Bucket-2 inner + /// guards and (future) Bucket-1 conditional emission. + /// TOML schema name: `gate_when` (preserved via `serde(rename)`). + #[serde(rename = "gate_when")] + pub when: String, + /// `true` for hard-fail gates; `false` for soft-skip with + /// command-v guard. + pub hard: bool, + /// Missing-tool detection key. Members of `ASSUMED_TOOLS` are + /// emitted unguarded; everything else gets a `command -v` + /// guard (or hard-fail-with-hint for `cargo-vet`). + pub tool: String, + /// Documentary expected runtime in seconds. Surfaced by + /// `--verbose`; Phase 2.x's manifest viewer + Tier-2 budget + /// pre-flight will also read it. + #[serde(default)] + pub expected_runtime_secs: u32, + /// Bucket assignment — `"bg"` (Bucket 1, fire-and-forget) or + /// `"seq"` (Bucket 2, sequential / fail-fast). Optional because + /// it is only meaningful when the gate participates in the + /// `pre-push` tier; pr-fast-only gates (e.g. the full `tests` run + /// that is too slow for pre-push) legitimately omit it. + #[serde(default)] + pub bucket: Option, + /// Within-bucket ordering hint. Lower fires first. Ties + /// resolve by lexicographic id ordering (see + /// `Manifest::gates_for_tier`). + #[serde(default)] + pub order: i32, + /// Per-tier consumer-name override. When set, the generator + /// emits the override (e.g. `tests`) as the `spawn_bg` / + /// `run_seq` label instead of the canonical gate id (e.g. + /// `test-build`). Read by `emit::consumer_label`; the same + /// field shape is consumed by `check_gates_drift.sh` so the + /// two stay aligned. + #[serde(default)] + pub consumer_names: std::collections::BTreeMap, + /// Free-form Markdown. The first non-blank line is surfaced by + /// `--verbose`. Phase 2.x will also emit it as a comment block + /// in the rendered hook (see `emit.rs::render_dispatch`). + #[serde(default)] + pub notes: String, +} + +impl Manifest { + /// Lightweight invariant validation — only the things the codegen + /// itself depends on. Cross-consumer drift is Phase 1's job. + pub fn validate(&self) -> Result<()> { + // No duplicate ids. + let mut seen: BTreeSet<&str> = BTreeSet::new(); + for gate in &self.gate { + ensure!( + seen.insert(gate.id.as_str()), + "duplicate gate id `{}` in manifest", + gate.id + ); + } + + // Every gate has a non-empty command and a known bucket / when. + for gate in &self.gate { + ensure!( + !gate.command.is_empty(), + "gate `{}` has an empty command", + gate.id + ); + // `bucket` is required IFF the gate participates in pre-push. + // pr-fast-only gates (e.g. full `tests`) legitimately omit it. + let in_pre_push = gate.tiers.iter().any(|t| t == "pre-push"); + match (in_pre_push, gate.bucket.as_deref()) { + (true, Some(b)) => ensure!( + matches!(b, "bg" | "seq"), + "gate `{}` has unknown bucket `{}` (want `bg` or `seq`)", + gate.id, + b + ), + (true, None) => anyhow::bail!( + "gate `{}` is in the `pre-push` tier but has no `bucket` field", + gate.id + ), + (false, Some(b)) => ensure!( + matches!(b, "bg" | "seq"), + "gate `{}` has unknown bucket `{}` (want `bg` or `seq`)", + gate.id, + b + ), + (false, None) => {} + } + ensure!( + matches!( + gate.when.as_str(), + "always" | "rust_changed" | "dep_changed" | "infra_changed" | "code_changed" + ), + "gate `{}` has unknown gate_when `{}`", + gate.id, + gate.when + ); + for tier in &gate.tiers { + ensure!( + matches!( + tier.as_str(), + "pre-commit" | "pre-push" | "pr-fast" | "tier-2" + ), + "gate `{}` has unknown tier `{}`", + gate.id, + tier + ); + } + } + Ok(()) + } + + /// Filter gates by tier membership — the codegen iterates this + /// once per emit. Sorted by `(bucket-as-defined-order, order)` so + /// Bucket-1 `spawn_bg` lines come before Bucket-2 `run_seq` lines + /// and within each bucket gates fire in the order the plan declares. + pub fn gates_for_tier<'a>(&'a self, tier: &str) -> Vec<&'a Gate> { + let mut out: Vec<&'a Gate> = self + .gate + .iter() + .filter(|g| g.tiers.iter().any(|t| t == tier)) + .collect(); + out.sort_by_key(|g| { + ( + bucket_rank(g.bucket.as_deref().unwrap_or("")), + g.order, + g.id.as_str().to_owned(), + ) + }); + out + } +} + +/// Sort key for bucket strings: Bucket 1 (`bg`) before Bucket 2 +/// (`seq`); pr-fast-only gates without a bucket sink to the bottom. +fn bucket_rank(bucket: &str) -> u8 { + match bucket { + "bg" => 0, + "seq" => 1, + _ => 99, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> &'static str { + // Minimal valid manifest covering one bg + one seq gate. + r#" +[manifest] +version = 1 +plan_doc = "docs/architecture/gates-manifest-plan.md" + +[classification] +rust = "\\.rs$" +dep = "^(.*Cargo\\.toml$|Cargo\\.lock$|supply-chain/)" +infra = "^(\\.github/|scripts/|)" + +[[gate]] +id = "fmt" +label = "cargo fmt --check" +command = ["cargo", "fmt", "--all", "--", "--check"] +tiers = ["pre-push"] +gate_when = "always" +hard = true +tool = "cargo" +bucket = "bg" +order = 10 +notes = "Always-on rustfmt." + +[[gate]] +id = "lint-ci" +label = "CI-mirror clippy" +command = ["just", "lint-ci"] +tiers = ["pre-commit", "pre-push"] +gate_when = "rust_changed" +hard = true +tool = "cargo" +bucket = "seq" +order = 20 +"# + } + + #[test] + fn parses_minimal_manifest() { + let m: Manifest = toml::from_str(fixture()).unwrap(); + assert_eq!(m.gate.len(), 2); + assert_eq!(m.header.version, 1); + } + + #[test] + fn validates_clean() { + let m: Manifest = toml::from_str(fixture()).unwrap(); + m.validate().unwrap(); + } + + #[test] + fn rejects_duplicate_ids() { + let dup = format!( + "{}\n[[gate]]\nid=\"fmt\"\nlabel=\"x\"\ncommand=[\"true\"]\ntiers=[\"pre-push\"]\ngate_when=\"always\"\nhard=true\ntool=\"bash\"\nbucket=\"bg\"\n", + fixture() + ); + let m: Manifest = toml::from_str(&dup).unwrap(); + let err = m.validate().unwrap_err(); + assert!(err.to_string().contains("duplicate gate id")); + } + + #[test] + fn rejects_bad_bucket() { + let bad = fixture().replace("bucket = \"bg\"", "bucket = \"nope\""); + let m: Manifest = toml::from_str(&bad).unwrap(); + let err = m.validate().unwrap_err(); + assert!(err.to_string().contains("unknown bucket")); + } + + #[test] + fn gates_for_tier_filters_and_sorts() { + let m: Manifest = toml::from_str(fixture()).unwrap(); + let gates = m.gates_for_tier("pre-push"); + assert_eq!(gates.len(), 2); + // Bucket bg before seq. + assert_eq!(gates[0].id, "fmt"); + assert_eq!(gates[1].id, "lint-ci"); + } + + #[test] + fn gates_for_tier_empty_for_unknown_tier() { + let m: Manifest = toml::from_str(fixture()).unwrap(); + assert!(m.gates_for_tier("nonexistent").is_empty()); + } + + #[test] + fn gate_when_field_rename_round_trips() { + // The TOML schema spells the trigger classifier `gate_when` + // (matching the Phase-1 plan doc and the drift detector); the + // Rust field is named `when`. Regression-guard for the + // `serde(rename = "gate_when")` directive. + let m: Manifest = toml::from_str(fixture()).unwrap(); + let fmt_gate = m.gate.iter().find(|g| g.id == "fmt").unwrap(); + assert_eq!(fmt_gate.when, "always"); + let clippy_gate = m.gate.iter().find(|g| g.id == "lint-ci").unwrap(); + assert_eq!(clippy_gate.when, "rust_changed"); + } + + #[test] + fn classification_block_parses() { + // Regression-guard for the free-form `Classification` + // shape: any class-name → regex map should round-trip + // cleanly into the BTreeMap. The verbose-dump path reads + // these keys; if parsing breaks, --verbose breaks silently. + let m: Manifest = toml::from_str(fixture()).unwrap(); + let cls = m.classification.as_ref().expect("classification block"); + assert_eq!(cls.patterns.len(), 3); + assert_eq!(cls.patterns.get("rust").unwrap(), r"\.rs$"); + assert!(cls.patterns.contains_key("dep")); + assert!(cls.patterns.contains_key("infra")); + } + + #[test] + fn consumer_names_round_trips() { + let toml_text = r#" +[[gate]] +id = "test-build" +label = "x" +command = ["true"] +tiers = ["pre-push"] +gate_when = "code_changed" +hard = true +tool = "cargo-nextest" +bucket = "seq" +consumer_names = { "pre-push" = "tests", "pr-fast" = "test-build" } +"#; + let m: Manifest = toml::from_str(toml_text).unwrap(); + m.validate().unwrap(); + let g = &m.gate[0]; + assert_eq!(g.consumer_names.len(), 2); + assert_eq!(g.consumer_names.get("pre-push").unwrap(), "tests"); + assert_eq!(g.consumer_names.get("pr-fast").unwrap(), "test-build"); + } + + #[test] + fn pr_fast_only_gate_may_omit_bucket() { + // Mirrors the real `tests` gate (the full nextest run): it + // is too slow for pre-push and lives only in the pr-fast + // tier, where `bucket` is meaningless. The validator must + // accept this. + let toml_text = r#" +[[gate]] +id = "tests" +label = "full nextest" +command = ["cargo", "nextest", "run"] +tiers = ["pr-fast"] +gate_when = "code_changed" +hard = true +tool = "cargo-nextest" +"#; + let m: Manifest = toml::from_str(toml_text).unwrap(); + m.validate().unwrap(); + assert!(m.gate[0].bucket.is_none()); + } + + #[test] + fn pre_push_gate_without_bucket_is_rejected() { + // Counterpart to the above: if a gate claims the pre-push + // tier, `bucket` becomes mandatory because emit.rs needs to + // know whether to spawn_bg or run_seq. + let toml_text = r#" +[[gate]] +id = "ghost" +label = "x" +command = ["true"] +tiers = ["pre-push"] +gate_when = "always" +hard = true +tool = "bash" +"#; + let m: Manifest = toml::from_str(toml_text).unwrap(); + let err = m.validate().unwrap_err(); + assert!( + err.to_string().contains("no `bucket` field"), + "expected bucket-required error, got: {err}" + ); + } + + #[test] + fn unknown_gate_when_is_rejected() { + let bad = fixture().replace("gate_when = \"always\"", "gate_when = \"sometimes\""); + let m: Manifest = toml::from_str(&bad).unwrap(); + let err = m.validate().unwrap_err(); + assert!(err.to_string().contains("unknown gate_when")); + } + + #[test] + fn unknown_tier_is_rejected() { + let bad = fixture().replace("tiers = [\"pre-push\"]", "tiers = [\"banana\"]"); + let m: Manifest = toml::from_str(&bad).unwrap(); + let err = m.validate().unwrap_err(); + assert!(err.to_string().contains("unknown tier")); + } +} diff --git a/scripts/ci/gen-hooks/templates/footer.sh b/scripts/ci/gen-hooks/templates/footer.sh new file mode 100644 index 000000000..6bb3af16d --- /dev/null +++ b/scripts/ci/gen-hooks/templates/footer.sh @@ -0,0 +1,77 @@ + +# ── Wait on Bucket 1 ─────────────────────────────────────────────────── +BG_FAILED=() +for i in "${!BG_PIDS[@]}"; do + if ! wait "${BG_PIDS[$i]}"; then + BG_FAILED+=("${BG_NAMES[$i]}") + fi +done + +# ── Report Bucket 1 ──────────────────────────────────────────────────── +for i in "${!BG_NAMES[@]}"; do + name="${BG_NAMES[$i]}" + failed=0 + for f in "${BG_FAILED[@]+"${BG_FAILED[@]}"}"; do + [[ "$f" == "$name" ]] && { failed=1; break; } + done + if (( failed )); then + printf ' %s❌%s [1] %s\n' "$C_RED" "$C_RESET" "$name" + else + printf ' %s✅%s [1] %s\n' "$C_GREEN" "$C_RESET" "$name" + fi +done + +# ── Report Bucket 2 ──────────────────────────────────────────────────── +for r in "${SEQ_RESULTS[@]+"${SEQ_RESULTS[@]}"}"; do + IFS=':' read -r name status dt <<< "$r" + case "$status" in + ok) printf ' %s✅%s [2] %s (%ss)\n' "$C_GREEN" "$C_RESET" "$name" "${dt:-0}" ;; + fail) printf ' %s❌%s [2] %s (%ss)\n' "$C_RED" "$C_RESET" "$name" "${dt:-0}" ;; + skip) printf ' %s⏭ %s [2] %s (skipped after fail-fast)\n' "$C_YELLOW" "$C_RESET" "$name" ;; + esac +done + +# If we ran Bucket 2 at all but nothing fired (pure docs), say so. +if (( ! CODE_CHANGED )); then + printf ' %sℹ%s Bucket 2 skipped — no rust/dep/infra files changed\n' "$C_CYAN" "$C_RESET" +fi + +# Aggregate failure list for final dump. +FAILED=("${BG_FAILED[@]+"${BG_FAILED[@]}"}") +[[ -n "$SEQ_FIRST_FAIL" ]] && FAILED+=("$SEQ_FIRST_FAIL") + +# ── Optional-tool hint ───────────────────────────────────────────────── +missing=() +command -v typos >/dev/null 2>&1 || missing+=("typos-cli") +command -v reuse >/dev/null 2>&1 || missing+=("reuse (pipx install reuse)") +# cargo-vet is listed here as an advisory when we reach this point without +# having hard-failed — i.e. current push did NOT hit `dep_changed`. The +# future push that does hit it will hard-fail unless the tool is present. +command -v cargo-vet >/dev/null 2>&1 || missing+=("cargo-vet (required for dep-change pushes)") +if (( ${#missing[@]} > 0 )); then + # NOTE: no backticks around `just install-dev-tools` — the cyan + # ANSI codes already emphasise the command, and literal backticks + # inside a single-quoted printf format string trip shellcheck + # SC2016 ("expressions don't expand in single quotes") even + # though they are harmless literal bytes in this context. + printf ' %s💡%s optional tools missing: %s — run %sjust install-dev-tools%s\n' \ + "$C_CYAN" "$C_RESET" "${missing[*]}" "$C_CYAN" "$C_RESET" +fi + +# ── Dump failing output ──────────────────────────────────────────────── +if (( ${#FAILED[@]} > 0 )); then + for name in "${FAILED[@]}"; do + printf '\n%s==== %s output ====%s\n' "$C_RED" "$name" "$C_RESET" + cat "$TMP/$name.out" + done + DUR=$(( $(date +%s) - START )) + printf '\n%s❌ lint-pre-push FAILED (%ss) — push aborted%s\n' "$C_RED" "$DUR" "$C_RESET" >&2 + # Same SC2016 avoidance as the install-dev-tools hint above: + # drop the visual backticks around the escape-hatch command and + # let the yellow ANSI color carry the emphasis. + printf '%s Fix the warnings and retry, or bypass once with: git push --no-verify%s\n' "$C_YELLOW" "$C_RESET" >&2 + exit 1 +fi + +DUR=$(( $(date +%s) - START )) +printf '%s✅ lint-pre-push passed (%ss)%s\n' "$C_GREEN" "$DUR" "$C_RESET" diff --git a/scripts/ci/gen-hooks/templates/preamble.sh b/scripts/ci/gen-hooks/templates/preamble.sh new file mode 100644 index 000000000..5a2dee4a5 --- /dev/null +++ b/scripts/ci/gen-hooks/templates/preamble.sh @@ -0,0 +1,180 @@ +# shellcheck shell=bash +# +# Called by: +# - `scripts/hooks/pre-push` (git hook) +# - `just lint-pre-push` (manual runs) +# +# Architecture (Phase 2 of dev-flow-implementation-plan.md § 1.4): +# +# Bucket 1 — cheap, parallel, fire-and-forget. Non-cargo jobs plus +# cargo-vet (which is fast and dep-gated). Waits at the +# end of the script; all Bucket 1 results report even +# when Bucket 2 fails. +# +# Bucket 2 — cargo-heavy, sequential, FAIL-FAST. Ordered cheapest → +# most expensive so the first actionable red surfaces +# within ~15 s rather than the old ~40-60 s. After the +# first failure, remaining jobs are marked `skip` and +# not executed. Cargo's target-dir lock would serialise +# these anyway; explicit ordering lets us abort sooner. +# +# Change classification (see git's pre-push stdin protocol): +# rust_changed = any `*.rs` +# dep_changed = Cargo.{toml,lock} | supply-chain/** +# infra_changed = .github/** | scripts/** | .cargo/** | .config/** | +# just/** | rust-toolchain* | {clippy,rustfmt,deny,REUSE}.toml +# code_changed = rust | dep | infra +# +# Bucket 2 only runs when code_changed. Pure-docs-only pushes skip +# the compile/test gate entirely. +# +# Per-gate documentation (label, command, rationale, expected runtime, +# CI counterpart) lives in `scripts/ci/gates.toml`'s `[[gate]]` tables +# — that is the single source of truth, and the generator preserves +# it on every regen. + +set -euo pipefail + +# ── Colours ──────────────────────────────────────────────────────────── +if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then + C_BLUE=$'\033[0;34m' + C_CYAN=$'\033[0;36m' + C_GREEN=$'\033[0;32m' + C_YELLOW=$'\033[1;33m' + C_RED=$'\033[0;31m' + C_RESET=$'\033[0m' +else + # Explicit empty-string form. The `VAR=` shorthand works but + # trips SC1007 because a stray trailing space would turn it into + # `VAR= other_cmd` (a single-line env override in front of a + # command invocation). Using `VAR=''` makes the intent + # unambiguous and keeps the linter quiet. + C_BLUE='' + C_CYAN='' + C_GREEN='' + C_YELLOW='' + C_RED='' + C_RESET='' +fi + +# ── Change classification ────────────────────────────────────────────── +# Detect whether invoked by git pre-push (stdin pipe with ref updates) +# or manually (e.g. `just lint-pre-push`). git's pre-push hook protocol +# (https://git-scm.com/docs/githooks#_pre_push) pipes one line per ref: +# +# In manual mode we can't know what's about to be pushed so we +# conservatively treat ALL file classes as changed (runs every gate; +# never silently skips a hard one). See +# docs/architecture/dev-flow-implementation-plan.md § 2.3 for details. +ZERO='0000000000000000000000000000000000000000' +CHANGED_FILES="" +# Newline-delimited list of `BASE..LOCAL_OID` ranges for the +# `commit-subjects` Bucket-1 job. Same data source as `CHANGED_FILES` +# (git's pre-push stdin protocol) so the validator iterates the same +# set of new commits that classification inspected. +COMMIT_RANGES="" +if [[ ! -t 0 ]]; then + # stdin is piped; try git's pre-push protocol. + while IFS=' ' read -r _local_ref local_oid _remote_ref remote_oid; do + [[ -z "${local_oid:-}" || "$local_oid" == "$ZERO" ]] && continue + if [[ "$remote_oid" == "$ZERO" ]]; then + # New remote ref (first push of this branch). Diff against + # best available base: merge-base with origin/main, fall back + # to the root commit if none. + base=$(git merge-base "$local_oid" origin/main 2>/dev/null \ + || git rev-list --max-parents=0 "$local_oid" 2>/dev/null | tail -n1 \ + || echo "") + else + base="$remote_oid" + fi + if [[ -n "$base" ]]; then + CHANGED_FILES+=$'\n'$(git diff --name-only "$base" "$local_oid" 2>/dev/null || true) + COMMIT_RANGES+="$base..$local_oid"$'\n' + else + CHANGED_FILES="__UNKNOWN__" + break + fi + done +fi +# Empty stdin (manual invocation, or push with only deletions) → conservative. +[[ -z "${CHANGED_FILES// /}" ]] && CHANGED_FILES="__UNKNOWN__" +# Manual-mode commit-range fallback: validate everything between +# `origin/main` and the current HEAD. Branches with no diverged +# commits (already merged, or fresh `main` checkout) get an empty +# range which the validator silently accepts. +if [[ -z "${COMMIT_RANGES// /}" ]]; then + if git rev-parse --verify origin/main >/dev/null 2>&1; then + COMMIT_RANGES="origin/main..HEAD"$'\n' + fi +fi +# Exported so the Bucket-1 `commit-subjects` job can read it from the +# `bash -c` subshell environment (forked shells inherit env vars but +# not unexported shell vars). +export COMMIT_RANGES + +class_matches() { + [[ "$CHANGED_FILES" == "__UNKNOWN__" ]] && return 0 + printf '%s\n' "$CHANGED_FILES" | grep -E "$1" >/dev/null +} + +RUST_CHANGED=0; class_matches '\.rs$' && RUST_CHANGED=1 +DEP_CHANGED=0; class_matches '^(.*Cargo\.toml$|Cargo\.lock$|supply-chain/)' && DEP_CHANGED=1 +INFRA_CHANGED=0; class_matches '^(\.github/|scripts/|\.cargo/|\.config/|just/|rust-toolchain|clippy\.toml$|rustfmt\.toml$|deny\.toml$|REUSE\.toml$|codecov\.yml$)' && INFRA_CHANGED=1 +CODE_CHANGED=$(( RUST_CHANGED || DEP_CHANGED || INFRA_CHANGED )) + +printf '%s🚦 lint-pre-push — workspace parallel gate%s\n' "$C_BLUE" "$C_RESET" +if [[ "$CHANGED_FILES" == "__UNKNOWN__" ]]; then + printf ' %s(manual mode — no pushed range detected; running all gates)%s\n' "$C_CYAN" "$C_RESET" +else + printf ' %s(rust=%d dep=%d infra=%d code=%d)%s\n' \ + "$C_CYAN" "$RUST_CHANGED" "$DEP_CHANGED" "$INFRA_CHANGED" "$CODE_CHANGED" "$C_RESET" +fi +START=$(date +%s) + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +# ── Bucket 1 (cheap, parallel) ───────────────────────────────────────── +# Non-cargo jobs + cargo-vet (dep-gated). All run concurrently; we wait +# at the end. Cargo-heavy jobs are deliberately NOT here — they would +# serialise on cargo's target-dir lock and stall the cheap jobs. +BG_NAMES=() +BG_PIDS=() +spawn_bg() { + local name="$1"; shift + BG_NAMES+=("$name") + ( "$@" ) >"$TMP/$name.out" 2>&1 & + BG_PIDS+=($!) +} + +# ── Bucket 2 (sequential, fail-fast) ─────────────────────────────────── +# Cargo-heavy jobs run in deliberate order so the FIRST actionable red +# aborts the rest. Order is cheapest → most expensive so most failures +# surface within ~15 s rather than the old ~40-60 s. See +# docs/architecture/dev-flow-implementation-plan.md § 1.4 / 2.3. +SEQ_RESULTS=() # "name:ok|fail|skip" +SEQ_FIRST_FAIL="" +run_seq() { + local name="$1"; shift + if [[ -n "$SEQ_FIRST_FAIL" ]]; then + SEQ_RESULTS+=("$name:skip") + return 0 + fi + # Split `local` from assignment so a non-zero exit from the + # command substitution propagates (shellcheck SC2155: the `local` + # builtin itself always returns 0 and would otherwise mask a + # failure of `date +%s`). + local stamp + stamp=$(date +%s) + if "$@" >"$TMP/$name.out" 2>&1; then + local dt + dt=$(( $(date +%s) - stamp )) + SEQ_RESULTS+=("$name:ok:$dt") + else + local dt + dt=$(( $(date +%s) - stamp )) + SEQ_RESULTS+=("$name:fail:$dt") + SEQ_FIRST_FAIL="$name" + fi +} + diff --git a/scripts/hooks/_lint_pre_push.sh b/scripts/hooks/_lint_pre_push.sh index 1245cc531..c9a373006 100755 --- a/scripts/hooks/_lint_pre_push.sh +++ b/scripts/hooks/_lint_pre_push.sh @@ -2,14 +2,27 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2025-2026 SKY, LLC. # -# Workspace-wide two-bucket pre-push gate. +# AUTO-GENERATED by `scripts/ci/gen-hooks` from `scripts/ci/gates.toml`. +# MANUAL EDITS WILL BE OVERWRITTEN. +# +# To change a gate, edit the manifest and regenerate: +# vim scripts/ci/gates.toml +# just gen-hooks +# +# Plan: docs/architecture/gates-manifest-plan.md +# +# Workspace-wide two-bucket pre-push gate. See the plan doc for +# bucket semantics, fail-fast ordering, and the full per-gate +# rationale (the `notes` field on each `[[gate]]` table in +# `gates.toml` carries the same documentation that used to live +# in this header before Phase 2 codegen). + +# shellcheck shell=bash # # Called by: # - `scripts/hooks/pre-push` (git hook) # - `just lint-pre-push` (manual runs) # -# Budget: ≈ 25–60 s on an sccache-warm workspace; ≈ 60–90 s cold. -# # Architecture (Phase 2 of dev-flow-implementation-plan.md § 1.4): # # Bucket 1 — cheap, parallel, fire-and-forget. Non-cargo jobs plus @@ -34,78 +47,10 @@ # Bucket 2 only runs when code_changed. Pure-docs-only pushes skip # the compile/test gate entirely. # -# Mandatory jobs (any failure aborts the push): -# * lint-ci — `cargo clippy -D warnings --all-targets --all-features -# --no-deps`: CI-mirror baseline, kept in lockstep with -# `.github/workflows/pr-fast.yml`'s `clippy` job. -# * lint-prod — `cargo clippy --lib --bins -- $prod_flags`: -# ULTRA-STRICT production lints (pedantic + nursery + -# cargo + unwrap_used + missing_docs_in_private_items). -# Redundant with pre-commit if hooks were honoured, but -# acts as a backstop when `--no-verify` was used. -# * lint-tests— `cargo clippy --tests -- $test_flags`: same base lint -# stack with unwrap/expect allowed for test code. -# * fmt — `cargo fmt --all -- --check`. -# * rustdoc — `RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps`. -# * doc-tests — `RUSTDOCFLAGS=-Dwarnings cargo test --doc --workspace -# --all-features` (Phase 1 addition): actually EXECUTES -# the `/// ```rust` blocks rustdoc only compiled. CI -# catches this today, but the local gate closes a ~30 s -# round-trip for broken doctests. -# * deny — advisories / bans / licences / sources. -# * tests — `cargo nextest run --no-run`: links every test binary -# without running it. Catches `#[cfg(test)]` drift, -# missing dev-dep, and linker-level regressions that -# `cargo clippy --all-targets` (check-only, no linking) -# misses. -# * smoke — `cargo nextest run --profile pre-push-smoke` (Phase 1 -# addition): actually RUNS a fast unit-test subset -# (~6 s warm on this workspace). Excludes the -# validation suite and `uffs-client` shmem tests which -# would blow the budget. Full suite still runs in CI. -# * file-size — oversized-Rust-file policy. -# * vet — `cargo vet check --locked` when Cargo.{toml,lock} or -# supply-chain/** changed in the pushed range (detected -# via git's pre-push stdin protocol). HARD-FAIL if -# `cargo-vet` is missing in that case — this closes the -# CI-only loophole that caused PR #43's 4x round-trip. -# * commit-subjects — `scripts/ci/check_commit_subjects.sh range …`: -# validates every non-merge commit subject in the pushed -# range against the SAME Conventional Commits regex CI's -# `.github/workflows/commitlint.yml` runs on the PR -# title. Closes the local-vs-remote feedback loop that -# used to surface scope typos like -# `feat(uffs-core, daemon)` only after the workflow had -# already failed upstream. Uses the same OID range data -# that drives change-classification (see below). -# -# Cross-platform coverage (soft-skipped when tool missing): -# * lint-ci-windows — `cargo xwin clippy --workspace --all-targets -# --all-features --target x86_64-pc-windows-msvc -# --no-deps -- -D warnings`. Phase W5.6 of -# `docs/architecture/windows-clippy-and-linux-cross-plan.md` -# upgraded this from the type-only `cargo xwin check` -# to the strict clippy stack so new Windows-gated -# code is gated on the same surface that the -# `pr-fast.yml::windows-lint` job enforces natively -# on `windows-latest`. Catches lint drift between -# platforms in ~6 s warm (W1.4 measurement). -# Requires `cargo-xwin` (installed by -# `just install-dev-tools`). -# -# Optional jobs (soft-skipped when tool missing): -# * typos — cheap spell-check across the repo. -# * reuse — SPDX / licence-header compliance. -# -# The Linux-only lint drift gate is NOT run here — it is best left to CI -# or a conscious manual invocation. Two local options exist: Docker -# (`just lint-ci-linux`, authoritative — mirrors CI's `rust:latest` -# image; minutes-scale) or cargo-zigbuild (`just lint-ci-linux-zig`, -# Phase L1, accelerator — ~50 s cold / sub-second warm; needs zig 0.14.1 -# pinned via `just install-dev-tools`). Run `just check-all-targets` for -# a full sweep across macOS + Linux (zigbuild-or-Docker) + Windows (xwin). -# The full runtime test suite (`cargo nextest run` without `--no-run`) -# and doc tests are likewise deferred to `just phase1-test` or CI. +# Per-gate documentation (label, command, rationale, expected runtime, +# CI counterpart) lives in `scripts/ci/gates.toml`'s `[[gate]]` tables +# — that is the single source of truth, and the generator preserves +# it on every regen. set -euo pipefail @@ -252,28 +197,13 @@ run_seq() { fi } -# ── Dispatch ─────────────────────────────────────────────────────────── -# Bucket 1 — fire-and-forget. `file-size` and `fmt` are always safe to -# run (no cargo lock contention, cheap). `vet` is HARD-REQUIRED when -# Cargo.{toml,lock} or supply-chain/** changed; missing tool in that -# case hard-fails the whole push with an install hint. -spawn_bg "fmt" cargo fmt --all -- --check +# ── Dispatch (generated from gates.toml) ────────────────────────────── +# Bucket 1 — fire-and-forget. Cheap, parallel; no cargo lock +# contention. See gates.toml for the canonical gate set. +spawn_bg "fmt" cargo fmt --all -- --check spawn_bg "file-size" bash scripts/ci/check_file_size_policy.sh -# Gate-manifest drift detector — Phase 1 of -# `docs/architecture/gates-manifest-plan.md`. Verifies -# `scripts/ci/gates.toml` stays in lockstep with the gate set actually -# defined in `_lint_fast.sh`, `_lint_pre_push.sh`, and `pr-fast.yml`. -# Cheap (sub-second), no cargo-lock contention. Bypass once via -# `BYPASS_GATES_DRIFT=1 git push` (mirrors `COMMIT_SUBJECT_BYPASS=1`). spawn_bg "gates-drift" bash scripts/ci/check_gates_drift.sh -# Conventional Commits subject validator — mirrors -# `.github/workflows/commitlint.yml`'s PR-title regex so a malformed -# scope (e.g. `feat(uffs-core, daemon)`) hard-fails locally instead -# of surfacing as a post-push advisory comment. Iterates every -# range captured from git's pre-push stdin (or the manual-mode -# `origin/main..HEAD` fallback above). Bypass once via -# `COMMIT_SUBJECT_BYPASS=1 git push` if you need to land a subject -# the regex doesn't cover. +spawn_bg "hooks-drift" cargo run -q --release -p uffs-gen-hooks -- --check spawn_bg "commit-subjects" bash -c ' set -euo pipefail [[ -z "${COMMIT_RANGES// /}" ]] && exit 0 @@ -282,7 +212,6 @@ spawn_bg "commit-subjects" bash -c ' bash scripts/ci/check_commit_subjects.sh range "$range" done <<< "$COMMIT_RANGES" ' - if (( DEP_CHANGED )); then if ! command -v cargo-vet >/dev/null 2>&1; then printf '%s❌ cargo-vet required (Cargo.{toml,lock} or supply-chain/ changed)%s\n' "$C_RED" "$C_RESET" >&2 @@ -294,35 +223,22 @@ if (( DEP_CHANGED )); then fi command -v typos >/dev/null 2>&1 && spawn_bg "typos" typos . command -v reuse >/dev/null 2>&1 && spawn_bg "reuse" reuse lint --quiet -# taplo is NOT run here — its natural tier is pre-commit (staged scope). -# At pre-push there is no staged set, and running `taplo fmt --check` -# over the whole workspace surfaces pre-existing TOML drift that is out -# of scope for the push-being-validated. -# Bucket 2 — sequential, fail-fast. Only when code changed (rust | dep -# | infra). Pure-docs-only pushes skip the compile/test gate entirely. +# Bucket 2 — sequential, fail-fast. Only runs when code +# changed (rust | dep | infra). Pure-docs-only pushes skip +# the compile/test gate entirely. if (( CODE_CHANGED )); then run_seq "cargo-check" cargo check --workspace --all-targets --all-features --locked - run_seq "lint-ci" just lint-ci - run_seq "lint-prod" just lint-prod - run_seq "lint-tests" just lint-tests - run_seq "rustdoc" env RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --all-features --no-deps --locked - run_seq "doc-tests" env RUSTDOCFLAGS=-Dwarnings cargo test --doc --workspace --all-features --locked - run_seq "tests" cargo nextest run --workspace --all-targets --all-features --no-run --locked --hide-progress-bar - run_seq "smoke" cargo nextest run --workspace --profile pre-push-smoke --locked - # cargo-deny runs in Bucket 2 only when DEP_CHANGED so pure-rust - # PRs don't pay the ~5 s cost. Covered unconditionally by CI. - # Note: cargo-deny does not accept --locked (it reads Cargo.lock - # from disk directly); cargo-vet takes --locked on the bucket-1 side. + run_seq "lint-ci" just lint-ci + run_seq "lint-prod" just lint-prod + run_seq "lint-tests" just lint-tests + run_seq "rustdoc" env RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --all-features --no-deps --locked + run_seq "doc-tests" env RUSTDOCFLAGS=-Dwarnings cargo test --doc --workspace --all-features --locked + run_seq "tests" cargo nextest run --workspace --all-targets --all-features --no-run --locked --hide-progress-bar + run_seq "smoke" cargo nextest run --workspace --profile pre-push-smoke --locked if (( DEP_CHANGED )); then - run_seq "deny" cargo deny check --hide-inclusion-graph + run_seq "deny" cargo deny check --hide-inclusion-graph fi - # Windows xwin clippy is ADVISORY locally (see dev-flow-implementation-plan.md - # § 1.3.3) — PR-fast's native `windows-lint` job on `windows-latest` is the - # authoritative gate. Phase W5.6 upgraded this from `check-windows` - # (type-only) to `lint-ci-windows` (strict clippy with `-D warnings`) - # so new Windows-gated regressions surface locally before push. - # Soft-skip with install hint when `cargo-xwin` is missing. if command -v cargo-xwin >/dev/null 2>&1; then run_seq "lint-ci-windows" just lint-ci-windows fi