feat(ci): gates manifest Phase 2 — gen-hooks Rust generator + auto-generated pre-push hook#141
Merged
githubrobbi merged 1 commit intomainfrom May 6, 2026
Merged
Conversation
…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
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 of
docs/architecture/gates-manifest-plan.md. The pre-push hook (scripts/hooks/_lint_pre_push.sh) is now generated from the canonicalscripts/ci/gates.tomlmanifest 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
scripts/ci/gen-hooks/— Rust binarygen-hooksper plan §4.1.manifest.rs— serde model + lightweight invariant validation. Bridges the TOML-sidegate_whento the Rust-sidewhenviaserde(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-linebash -creadingCOMMIT_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 -vfor non-assumed tools,dep_changedinner guard for Bucket 2.templates/preamble.sh+templates/footer.sh— embedded bash scaffolding (colors, change-classification, helpers, bucket reaping, optional-tool hint, failure dump).gate_when⇄whenrename round-trip,consumer_namesper-tier label override, pr-fast-only gates legitimately omittingbucket, every special-case emission pattern, and the §4.4 idempotency contract.hooks-drift— self-referential gate that runsgen-hooks --check. Pairs with Phase 1'sgates-drift: the latter ensures the gate-set matches across consumers; this one ensures the emitted hook matches what the manifest would produce.pr-fast.yml::hooks-driftjob — always-on, cache-shared withsanity, gated onrequired.needs:andnotify-failure.needs:.just gen-hooks+just hooks-driftrecipes.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'snotesfields (single source of truth). Same gate set, same bucket assignment, same fail-fast order, same wall-clock as the pre-Phase-2 hook.Cargo.toml—scripts/ci/gen-hooksadded as workspace member alongsidescripts/ci-pipeline.Lint policy (audited per the no-suppression-hacks rule)
#[allow(...)]directives in the new crate's source.Manifest::header,Classification::patterns,Gate::expected_runtime_secs,Gate::notes) is now genuinely read by the--verbosepath (emit_verbose_dumpinmain.rs) — they stop being dead code by being put to actual work, not by silencing the lint.Gate::gate_when→whenrename eliminatesclippy::struct_field_namesat the root cause (the field name) instead of suppressing it.consumer_namesfield's earlier#[allow(dead_code)]was a leftover from beforeemit::consumer_labelwas wired up; removed.[lints] workspace = true, matching the convention fromscripts/ci-pipeline(operational tools that legitimately useeprintln!/unwrappatterns the workspace stack denies). Thelint-prod/lint-testsflag stack still applies and passes cleanly.Verification
cargo test -p uffs-gen-hookscargo clippy -p uffs-gen-hooks -- -D pedantic -D nursery -D cargo -W unwrap_used -W expect_used -W missing_docs_in_private_items -D warningsbash scripts/ci/check_gates_drift.shjust hooks-driftjust lint-pre-push(full 21-gate sweep)bash -n scripts/hooks/_lint_pre_push.shactionlint .github/workflows/pr-fast.ymlWhat this PR does NOT do (deferred to Phase 3)
_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.ymlper-gate jobs are still hand-maintained. Thegen-workflowgenerator (plan §4.2) is Phase 3.CONTRIBUTING.mdanddocs/architecture/dev-flow.mdare Phase 3.manifest.versionfield is currently informational; Phase 3 wires it into a generator compatibility check.Sequencing
Branched off
mainafter 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.mdfor the full migration trajectory and acceptance criteria.