Skip to content

feat(ci): gates manifest Phase 2 — gen-hooks Rust generator + auto-generated pre-push hook#141

Merged
githubrobbi merged 1 commit intomainfrom
feat/gates-manifest-phase-2
May 6, 2026
Merged

feat(ci): gates manifest Phase 2 — gen-hooks Rust generator + auto-generated pre-push hook#141
githubrobbi merged 1 commit intomainfrom
feat/gates-manifest-phase-2

Conversation

@githubrobbi
Copy link
Copy Markdown
Collaborator

Phase 2 of docs/architecture/gates-manifest-plan.md. The pre-push hook (scripts/hooks/_lint_pre_push.sh) is now generated from the canonical scripts/ci/gates.toml manifest by a new Rust workspace binary; manual edits to the hook are caught by a paired drift detector (hooks-drift) that hard-blocks merge.

What lands

  • NEW workspace member scripts/ci/gen-hooks/ — Rust binary gen-hooks per plan §4.1.
    • manifest.rs — serde model + lightweight invariant validation. Bridges the TOML-side gate_when to the Rust-side when via serde(rename) so the schema stays unchanged while the Rust struct stays clean.
    • emit.rs — banner + dispatch generation with explicit per-gate special-case dispatch (no template engine). Special cases: commit-subjects (multi-line bash -c reading COMMIT_RANGES), cargo-vet (DEP_CHANGED guard + missing-tool hard-fail with install hint, closes the PR chore: release v0.5.71 — ship pipeline auto-commit #43 loophole), soft-skip-with-command -v for non-assumed tools, dep_changed inner guard for Bucket 2.
    • templates/preamble.sh + templates/footer.sh — embedded bash scaffolding (colors, change-classification, helpers, bucket reaping, optional-tool hint, failure dump).
    • 24 unit tests covering schema parsing, validation invariants, the gate_whenwhen rename round-trip, consumer_names per-tier label override, pr-fast-only gates legitimately omitting bucket, every special-case emission pattern, and the §4.4 idempotency contract.
  • NEW manifest gate hooks-drift — self-referential gate that runs gen-hooks --check. 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.
  • NEW pr-fast.yml::hooks-drift job — always-on, cache-shared with sanity, gated on required.needs: and notify-failure.needs:.
  • NEW just gen-hooks + just hooks-drift recipes.
  • MODIFIED scripts/hooks/_lint_pre_push.sh — now generated. AUTO-GENERATED banner, file shrinks from 406 → 322 lines because the per-gate documentation comments now live in the manifest's notes fields (single source of truth). Same gate set, same bucket assignment, same fail-fast order, same wall-clock as the pre-Phase-2 hook.
  • MODIFIED Cargo.tomlscripts/ci/gen-hooks added as workspace member alongside scripts/ci-pipeline.

Lint policy (audited per the no-suppression-hacks rule)

  • Zero #[allow(...)] directives in the new crate's source.
  • Every formerly-dead-code field (Manifest::header, Classification::patterns, Gate::expected_runtime_secs, Gate::notes) is now genuinely read by the --verbose path (emit_verbose_dump in main.rs) — they stop being dead code by being put to actual work, not by silencing the lint.
  • The Gate::gate_whenwhen rename eliminates clippy::struct_field_names at the root cause (the field name) instead of suppressing it.
  • The consumer_names field's earlier #[allow(dead_code)] was a leftover from before emit::consumer_label was wired up; removed.
  • Crate intentionally does NOT opt into [lints] workspace = true, matching the convention from scripts/ci-pipeline (operational tools that legitimately use eprintln! / unwrap patterns the workspace stack denies). The lint-prod / lint-tests flag stack still applies and passes cleanly.

Verification

Check Result
cargo test -p uffs-gen-hooks 24 / 24 unit tests pass
cargo clippy -p uffs-gen-hooks -- -D pedantic -D nursery -D cargo -W unwrap_used -W expect_used -W missing_docs_in_private_items -D warnings exit 0, zero per-item suppressions
bash scripts/ci/check_gates_drift.sh 21 gates 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; no regression)
bash -n scripts/hooks/_lint_pre_push.sh syntax OK
actionlint .github/workflows/pr-fast.yml exit 0

What this PR does NOT do (deferred to Phase 3)

  • Pre-commit hook (_lint_fast.sh) stays hand-written. Its logic is small (3 clippy spawns + a few bash conditionals); generating it costs more code than the file it replaces.
  • pr-fast.yml per-gate jobs are still hand-maintained. The gen-workflow generator (plan §4.2) is Phase 3.
  • Doc-table generators for CONTRIBUTING.md and docs/architecture/dev-flow.md are Phase 3.
  • manifest.version field is currently informational; Phase 3 wires it into a generator compatibility check.

Sequencing

Branched off main after PR #140 (Phase 1) landed. No dependency on any other in-flight PR. Phase 3 implementation comes after this lands.

Plan reference

§5.Phase 2 of gates-manifest-plan.md for the full migration trajectory and acceptance criteria.

…nerated pre-push hook

Phase 2 of `docs/architecture/gates-manifest-plan.md`.  The pre-push
hook (`scripts/hooks/_lint_pre_push.sh`) is now generated from the
canonical `scripts/ci/gates.toml` manifest by a new Rust workspace
binary; manual edits to the hook are caught by a paired drift
detector (`hooks-drift`) that hard-blocks merge.

## What lands

NEW workspace member `scripts/ci/gen-hooks/`:

  * `Cargo.toml`               — workspace member, no per-crate lint
                                 opt-in (matches the operational-tool
                                 convention from `scripts/ci-pipeline`;
                                 `lint-prod` flag stack still applies)
  * `src/main.rs`              — CLI (`--check`, `--tier`, `--verbose`,
                                 hidden `--manifest` / `--output`
                                 escape hatches for tests)
  * `src/manifest.rs`          — serde model + lightweight invariant
                                 validation (no duplicate ids, valid
                                 bucket per pre-push tier, valid
                                 `gate_when`, valid tier names)
  * `src/emit.rs`              — banner + dispatch generation with
                                 explicit per-gate special-case
                                 dispatch (no template engine)
  * `templates/preamble.sh`    — embedded bash scaffolding (colors,
                                 change-classification, helpers)
  * `templates/footer.sh`      — embedded bash scaffolding (bucket
                                 reaping, reporting, optional-tool
                                 hint, failure dump)

Schema fidelity:

  * The TOML field `gate_when` is bridged to the Rust field `when`
    via `serde(rename = "gate_when")` so the manifest schema is
    unchanged while the Rust struct stays clean (no
    `clippy::struct_field_names` suppression needed).
  * `bucket` is `Option<String>`: required for pre-push gates (where
    Bucket-1 vs Bucket-2 dispatch matters), optional for pr-fast-only
    gates (e.g. the full `tests` run that's too slow for pre-push).
  * `consumer_names` is honored by the emitter — the legacy
    `test-build` gate id renders as `run_seq "tests"` in the pre-push
    hook (matching the pre-Phase-2 hook's label so the byte-for-byte
    drift check stays green).

Special-case emission patterns (hardcoded in `emit.rs`, not
parameterised — keeps the generator small and reviewable):

  * `commit-subjects` — multi-line `bash -c` reading `COMMIT_RANGES`
  * `cargo-vet` — DEP_CHANGED guard + missing-tool hard-fail with
    install hint (closes the PR #43 loophole)
  * `hard=false` + non-assumed tool — silent `command -v` guard
  * `gate_when="dep_changed"` in Bucket 2 — inner `if (( DEP_CHANGED ))`
  * default — unconditional `spawn_bg` / `run_seq`

NEW manifest gate `hooks-drift`:

  * `tiers = ["pre-push", "pr-fast"]`, `bucket = "bg"`,
    `gate_when = "always"`, `hard = true`
  * Self-referential — 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

NEW `pr-fast.yml::hooks-drift` job:

  * Always-on (no classify gating)
  * 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` recipes:

  * `just gen-hooks`   — regenerate the hook from the manifest
  * `just hooks-drift` — verify on-disk == generated

MODIFIED `scripts/hooks/_lint_pre_push.sh`:

  * Now generated.  Header carries the AUTO-GENERATED banner with
    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).
  * Functional behaviour is preserved — same gate set, same
    bucket assignment, same fail-fast order, same wall-clock
    runtime.

## Lint policy (audited per the user's no-suppression-hacks rule)

  * Zero `#[allow(...)]` directives in the new crate's source.
  * Every formerly-dead-code field (`Manifest::header`,
    `Classification::patterns`, `Gate::expected_runtime_secs`,
    `Gate::notes`) is now genuinely read by the `--verbose` path
    (`emit_verbose_dump` in `main.rs`) — they stop being dead code
    by being put to actual work, not by silencing the lint.
  * The `Gate::gate_when` → `when` rename eliminates the
    `clippy::struct_field_names` lint at the root cause (the field
    name) instead of suppressing it.
  * The `consumer_names` field's `#[allow(dead_code)]` was a
    leftover from before `emit::consumer_label` was wired up;
    removed and replaced with documentation that reflects the
    actual usage.

## Tests (24 / 24 pass)

`manifest.rs::tests`:

  * `parses_minimal_manifest`              — fixture round-trip
  * `validates_clean`                       — happy-path validation
  * `rejects_duplicate_ids`                 — error path
  * `rejects_bad_bucket`                    — error path
  * `gates_for_tier_filters_and_sorts`      — bg before seq, ordered
  * `gates_for_tier_empty_for_unknown_tier` — graceful empty
  * `gate_when_field_rename_round_trips`    — regression-guards the
                                              `serde(rename)` directive
  * `classification_block_parses`           — free-form map shape
  * `consumer_names_round_trips`            — BTreeMap round-trip
  * `pr_fast_only_gate_may_omit_bucket`     — pr-fast-only legitimate
                                              omission
  * `pre_push_gate_without_bucket_is_rejected` — the counterpart
                                              error path
  * `unknown_gate_when_is_rejected`         — error path
  * `unknown_tier_is_rejected`              — error path

`emit.rs::tests`:

  * `dispatch_emits_bg_then_seq`            — bucket order
  * `commit_subjects_uses_special_template` — multi-line bash -c
  * `vet_emits_dep_changed_hard_fail_block` — install hint emission
  * `soft_skip_emits_command_v_guard`       — typos / reuse pattern
  * `deny_in_seq_wraps_in_dep_changed`      — Bucket-2 inner guard
  * `windows_lint_in_seq_wraps_in_command_v` — Bucket-2 soft-skip
  * `shell_quote_passes_safe_tokens`        — quoting edge cases
  * `consumer_names_override_is_honoured_in_emission` — the
                                              `test-build` ⇄ `tests`
                                              regression guard
  * `missing_consumer_override_falls_back_to_gate_id` — pre-push
                                              vs pr-fast isolation
  * `full_render_is_idempotent`             — plan §4.4 contract
  * `render_is_deterministic`               — same input → same output

Every fixture is a literal TOML string; no randomness, no time, no
filesystem dependency in any test.

## Verification

  * `cargo test -p uffs-gen-hooks` — 24 / 24 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, zero per-item suppressions
  * `bash scripts/ci/check_gates_drift.sh` — 21 gates matched
    against the regenerated hook (Phase 1 detector unchanged)
  * `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; no regression)
  * `bash -n scripts/hooks/_lint_pre_push.sh` — syntax OK
  * `actionlint .github/workflows/pr-fast.yml` — exit 0

## What this PR does NOT do (deferred to Phase 3)

  * Pre-commit hook (`_lint_fast.sh`) stays hand-written.  Its
    logic is small (3 clippy spawns + a few bash conditionals);
    generating it costs more code than the file it replaces.
  * `pr-fast.yml` per-gate jobs are still hand-maintained.  The
    `gen-workflow` generator (plan §4.2) is Phase 3.
  * Doc-table generators for `CONTRIBUTING.md` and
    `docs/architecture/dev-flow.md` are Phase 3.
  * `manifest.version` field is currently informational; Phase 3
    wires it into a generator compatibility check.
@githubrobbi githubrobbi enabled auto-merge (squash) May 6, 2026 23:00
@githubrobbi githubrobbi merged commit 1b97be3 into main May 6, 2026
19 checks passed
@githubrobbi githubrobbi deleted the feat/gates-manifest-phase-2 branch May 6, 2026 23:15
githubrobbi added a commit that referenced this pull request May 6, 2026
…142)

Updates `docs/architecture/gates-manifest-plan.md` to reflect a
design-time decision made during Phase 3 prep.  No code changes;
the implementation PR follows once this lands.

## Pivot rationale

The original §4.2 specified that `gen-workflow` would own the
per-gate job blocks in `.github/workflows/pr-fast.yml` between
`# >>> generated:gates-manifest <<<` markers, with the
hand-written `classify` / `required` / `notify-failure` jobs
staying outside.

Investigation during Phase 3 prep (after Phase 2 landed in PR #141)
showed every per-gate job in `pr-fast.yml` is bespoke:

  * gates-drift, file-size:    bash only, no rust-cache, ~5 lines
  * hooks-drift:               cargo run, cache shared with sanity
  * fmt:                       tiny, no cache, `if: rust`
  * sanity:                    free-disk + cache + fetch + check +
                               conditional cargo-vet install AND run
  * clippy, docs:              shared-cache from sanity, no free-disk
  * test-build:                distinct cache key, free-disk
  * tests:                     shared-cache from test-build
  * security:                  distinct cache key, deny + vet
  * windows-lint:              windows-latest runner

That's eleven distinct shapes for ~thirteen pr-fast-tier gates.
Encoding all of that in TOML so the generator can emit it back is
a YAML-in-TOML translation problem with no real upside, AND it
stakes branch protection on a hand-rolled YAML emitter.

## Revised design — structural validator (`--check` only)

`gen-workflow` becomes a read-only validator that enforces five
structural properties:

1. Job presence — every manifest gate with `tier="pr-fast"` has a
   job whose key matches the gate id (or per-tier `consumer_names`
   override).
2. `if:` predicate alignment — `gate_when=rust_changed` →
   `needs.classify.outputs.rust == 'true'`, etc.
3. Aggregator coverage — every gate id is in `required.needs:`,
   the `declare -A R=(...)` aggregator, AND `notify-failure.needs:`.
   This is the exact rename-bookkeeping failure mode that motivated
   the whole plan.
4. Branch-protection guard — `required.name:` is exactly
   `PR Fast CI / required` (the literal string in the repo's
   branch-protection ruleset).
5. Naming convention — every per-gate job's `name:` matches the
   manifest's `label`.

Per-step contents (run commands, runner selection, cache strategy)
are explicitly NOT validated — they're per-job craft.  Phase 1's
`gates-drift` covers gate-set mismatch; structural validator
covers rename-bookkeeping; per-step correctness stays in code
review's hands.

The structural-validator design retains every drift-protection
guarantee the original codegen design promised, while reducing the
blast radius to "same as Phase 1's gates-drift" — the tool only
reads files; it cannot break the workflow.

## What this PR contains

  * §4.2 rewritten — pivot rationale + 5 enforced properties +
    explicit list of what's NOT validated.
  * §4.3 (gen-docs) marked deferred — investigation showed every
    existing gate-matrix table in the repo is prose-laden (cells
    like `✅ if xwin (advisory; W5.6 upgraded from check to
    clippy)`); encoding the prose in TOML is strictly worse than
    leaving it in markdown.
  * §Phase 3 migration section rewritten — narrowed scope to just
    the workflow-drift validator, explicitly carved out the
    deferred sub-phases (3a `_lint_fast.sh` codegen and 3c
    `gen-docs`).
  * §Status table updated — Phase 2 ✅ landed, Phase 3 🟡 plan
    revision in flight, 3a/3c deferred.
  * §9 action log appended with the pivot entry and the
    rationale.

## What this PR does NOT contain

No code changes.  The actual `gen-workflow` Rust crate, the new
`workflow-drift` manifest gate, the `pr-fast.yml::workflow-drift`
job, and the `just workflow-drift` recipe all land in the
follow-up implementation PR (Phase 3 proper).

## Verification

  * `reuse lint`             — exit 0
  * `taplo fmt --check`      — n/a (markdown-only PR)
  * `actionlint`             — n/a (markdown-only PR)
  * `bash -n` syntax checks  — n/a (markdown-only PR)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant