feat(ci): gates manifest Phase 3a — _lint_fast.sh codegen + fast-drift gate#144
Merged
githubrobbi merged 1 commit intomainfrom May 7, 2026
Merged
Conversation
…t gate
Phase 3a of `docs/architecture/gates-manifest-plan.md`. The
pre-commit hook (`scripts/hooks/_lint_fast.sh`) is now generated
from the canonical manifest by the same `gen-hooks` binary that
already owned `_lint_pre_push.sh`; manual edits to the hook are
caught by a paired drift detector (`fast-drift`) that hard-blocks
merge.
## Why now
Phase 3a was originally deferred from the plan as "marginal value"
(the pre-commit hook is a smaller cousin of the pre-push hook).
Rebasing on top of Phase 3 (`workflow-drift`) showed the value is
actually meaningful: with `_lint_pre_push.sh` already auto-
generated, the asymmetry between pre-push and pre-commit was a
recurring footgun (two hooks to keep in lockstep, one with a
generator and a drift detector, one without). Closing it brings
the workspace to four orthogonal drift detectors covering four
orthogonal drift axes:
* `gates-drift` — gate-set mismatch (Phase 1)
* `hooks-drift` — pre-push hook content (Phase 2)
* `workflow-drift` — pr-fast.yml structural (Phase 3)
* `fast-drift` — pre-commit hook content (Phase 3a)
## What lands
EXTENDED `scripts/ci/gen-hooks/`:
* `EmitTarget::PreCommit` variant added alongside the existing
`PrePush`. One binary now owns both hook files; the dispatch
is `match self { PrePush => render_pre_push(...), PreCommit =>
render_pre_commit(...) }`. Phase 2 anticipated the variant —
the comment "Phase 3 adds `pre-commit`" was already in
`EmitTarget`'s doc.
* `render_dispatch_fast()` — pre-commit-specific dispatch
generator with four per-gate emit shapes:
1. `always-on hard` (file-size) — unconditional `spawn`.
2. `always-on soft-skip` (typos, reuse) — `if command -v
$tool >/dev/null 2>&1; then spawn ...`.
3. `rust-or-no-staged` (fmt) — special predicate `if
has_staged_rs || ! has_any_staged; then` so manual `just
lint-fast` runs on a clean worktree are still useful as
a sanity pass.
4. `rust-staged group` (lint-prod / lint-tests / lint-ci)
— collapsed into a single `if has_staged_rs; then ... fi`
block (3 spawns instead of 3 separate guard blocks).
5. `bespoke taplo` — `if has_staged_toml_nonvet && command
-v taplo; then`, with a `bash -c` invocation rewritten
from the manifest's `{{STAGED_TOML}}` placeholder into a
literal command-substitution over `$STAGED_TOML_NONVET`.
6. `bespoke vet-fmt` — `if has_staged_vet && command -v
cargo-vet; then` (the `command -v cargo-vet` guard is at
the dispatch level because at pre-commit a missing
`cargo-vet` is a soft-skip; the upstream pre-push `vet`
gate is the hard backstop).
* `--target {pre-push,pre-commit}` CLI flag (default `pre-push`
for back-compat with the existing `hooks-drift` gate).
`--check` failure messages name the right `just` recipe per
target.
* `EmitTarget::default_output_path` + `EmitTarget::tier` —
target-aware accessors so `main.rs` stays target-agnostic.
* Two new templates: `templates/preamble_fast.sh` (colors,
staged-file inventory, `has_staged_*` helpers, `spawn`
helper) and `templates/footer_fast.sh` (wait loop, per-job
report, optional-tool hint, failure dump). Both pure bash;
no per-gate knowledge. Embedded via `include_str!`.
NEW manifest gate `fast-drift`:
* `tiers = ["pre-push", "pr-fast"]`, `bucket = "bg"`,
`gate_when = "always"`, `hard = true`, order 28 (next to
`workflow-drift`'s 27 in Bucket 1).
* Self-referential — runs `gen-hooks --target pre-commit
--check` to verify `_lint_fast.sh` against the manifest.
* NOT in the pre-commit tier itself: running the validator in
pre-commit would compile the gen-hooks binary on every
commit and break the T1 sub-2 s budget; pre-push is the
right place to catch the drift before it reaches the remote.
NEW `pr-fast.yml::fast-drift` job:
* Mirror of `hooks-drift`'s shape (cache shared with `sanity`
so the gen-hooks binary build piggybacks on the existing
rust-cache).
* Wired into `required.needs:`, the bash R=() aggregator, AND
`notify-failure.needs:` (otherwise it would itself fail
Property 3 of `workflow-drift` on first run — same recursive
consistency check Phase 3 introduced).
NEW `just` recipes:
* `just gen-fast` — manual run of the pre-commit hook
generator
* `just fast-drift` — alias of `gen-hooks --target pre-commit
--check`, named for parity with
`gates-drift` / `hooks-drift` /
`workflow-drift`
REGENERATED `scripts/hooks/_lint_fast.sh`:
* First-time `gen-hooks --target pre-commit` output.
AUTO-GENERATED banner + embedded preamble + manifest-driven
dispatch + embedded footer.
* The legacy hand-written inline dispatch comments are now
stored in `gates.toml` `notes` (single source of truth,
surfaced by `gen-hooks --verbose`).
* Behavior is preserved at the spawn level: every gate fires
on the same predicate as before, in the same parallel-fan-out
shape. Cosmetic ordering shifts slightly (typos / reuse
moved up to match manifest order); all jobs run in parallel
so this is purely visual.
REGENERATED `scripts/hooks/_lint_pre_push.sh`:
* Picks up the new `fast-drift` gate as a Bucket-1 entry
alongside `gates-drift`, `hooks-drift`, `workflow-drift`.
MODIFIED `docs/architecture/gates-manifest-plan.md`:
* Status table updated (Phase 3a ✅ landed).
* §9 action log entry appended.
## What is explicitly NOT done
* Phase 3c (`gen-docs`) remains deferred — see plan §4.3.
* No new manifest schema fields (no `staged_when`, no
`requires_command`). Routing is driven entirely by existing
fields (`id` + `gate_when` + `hard` + `tool`) plus three
hardcoded special cases (fmt, taplo, vet-fmt) that mirror
the gen-hooks Phase-2 precedent of `commit-subjects` and
`vet`. Surgical, idiomatic, no schema bloat.
* No structural changes to gen-workflow or the manifest
classification block. Phase 3a is purely additive on the
gen-hooks side + one new gate + one new pr-fast job.
## Lint policy (audited per the no-suppression-hacks rule)
* Zero `#[allow(...)]` directives in non-test code.
* `expect()` / `unwrap()` only in `#[cfg(test)]` blocks
(allowed by `clippy.toml`'s `allow-*-in-tests = true`).
* One `let _ = writeln!(out, ...)` discard pattern in
`emit_fast_rust_staged_block`: writes to `String` are
infallible; `expect("...")` would be ceremony for a panic
that can never fire. The `let _ =` is documented inline
explaining the choice. This satisfies `clippy::format_push_string`
at the root cause (writer-style append vs. `format!()`+`push_str`
allocation) without the `#[allow]` suppression clippy
suggested.
* Per-item doc comments on `TARGET_PRE_PUSH` /
`TARGET_PRE_COMMIT` constants to satisfy
`clippy::missing_docs_in_private_items` at root cause.
## Tests (32 / 32 pass; was 22 before this PR)
10 new tests in `gen-hooks` covering every emit_fast shape +
edge cases:
* `fast_dispatch_emits_all_six_shapes` — exhaustive single-
fixture assertion that all 9 manifest gates render correctly
+ the `{{STAGED_TOML}}` placeholder is rewritten + the
rust-staged block is sorted by manifest order.
* `fast_dispatch_emits_exactly_one_rust_staged_block` —
regression-guard for the "emit once at first member" flag.
* `fast_dispatch_honours_pre_commit_consumer_override` — the
`fmt` → `fmt-check` legacy rename is preserved at emit time.
* `fast_emit_fmt_spans_no_staged_branch` — the `|| !
has_any_staged` clause survives any future refactor of the
fmt special case.
* `fast_emit_rust_staged_block_is_empty_when_no_rust_gates`
— edge case: a manifest with `fmt` but no other
`rust_changed` gates must NOT emit a dangling guard block.
* `pre_commit_render_is_idempotent` — plan §4.4 contract.
* `pre_commit_and_pre_push_render_distinct_files` — sanity:
same manifest emits two materially different bash files; no
template leak between targets.
* `emit_target_default_output_paths_are_distinct` — guards
against a refactor that aliases the two targets' output
paths or tier names.
Every fixture is a literal string; no randomness, no time, no
filesystem dependency in any test.
## Verification
* `cargo test -p uffs-gen-hooks` — 32 / 32 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 in non-test code.
* `cargo run -q --release -p uffs-gen-hooks -- --target
pre-commit --check` — exit 0.
* `cargo run -q --release -p uffs-gen-hooks -- --check` —
exit 0.
* `bash scripts/ci/check_gates_drift.sh` — 23 gates matched.
* `cargo run -q --release -p uffs-gen-workflow -- --check` —
exit 0 (workflow-drift sees the new fast-drift job).
* `actionlint .github/workflows/pr-fast.yml` — exit 0.
* `bash -n scripts/hooks/_lint_fast.sh` + `shellcheck
scripts/hooks/_lint_fast.sh` — both exit 0.
* `just lint-pre-push` — full 23-gate sweep green in 57 s
warm.
## Sequencing
Branched off `main` post-#143. No dependency on any other
in-flight PR. Closes Phase 3a of the gates manifest plan; Phase
3c (gen-docs) remains deferred per plan §4.3.
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.
feat(ci): gates manifest Phase 3a — _lint_fast.sh codegen + fast-drift gate
Phase 3a of
docs/architecture/gates-manifest-plan.md. Thepre-commit hook (
scripts/hooks/_lint_fast.sh) is now generatedfrom the canonical manifest by the same
gen-hooksbinary thatalready owned
_lint_pre_push.sh; manual edits to the hook arecaught by a paired drift detector (
fast-drift) that hard-blocksmerge.
Why now
Phase 3a was originally deferred from the plan as "marginal value"
(the pre-commit hook is a smaller cousin of the pre-push hook).
Rebasing on top of Phase 3 (
workflow-drift) showed the value isactually meaningful: with
_lint_pre_push.shalready auto-generated, the asymmetry between pre-push and pre-commit was a
recurring footgun (two hooks to keep in lockstep, one with a
generator and a drift detector, one without). Closing it brings
the workspace to four orthogonal drift detectors covering four
orthogonal drift axes:
gates-drift— gate-set mismatch (Phase 1)hooks-drift— pre-push hook content (Phase 2)workflow-drift— pr-fast.yml structural (Phase 3)fast-drift— pre-commit hook content (Phase 3a)What lands
EXTENDED
scripts/ci/gen-hooks/:EmitTarget::PreCommitvariant added alongside the existingPrePush. One binary now owns both hook files; the dispatchis
match self { PrePush => render_pre_push(...), PreCommit => render_pre_commit(...) }. Phase 2 anticipated the variant —the comment "Phase 3 adds
pre-commit" was already inEmitTarget's doc.render_dispatch_fast()— pre-commit-specific dispatchgenerator with four per-gate emit shapes:
always-on hard(file-size) — unconditionalspawn.always-on soft-skip(typos, reuse) —if command -v $tool >/dev/null 2>&1; then spawn ....rust-or-no-staged(fmt) — special predicateif has_staged_rs || ! has_any_staged; thenso manualjust lint-fastruns on a clean worktree are still useful asa sanity pass.
rust-staged group(lint-prod / lint-tests / lint-ci)— collapsed into a single
if has_staged_rs; then ... fiblock (3 spawns instead of 3 separate guard blocks).
bespoke taplo—if has_staged_toml_nonvet && command -v taplo; then, with abash -cinvocation rewrittenfrom the manifest's
{{STAGED_TOML}}placeholder into aliteral command-substitution over
$STAGED_TOML_NONVET.bespoke vet-fmt—if has_staged_vet && command -v cargo-vet; then(thecommand -v cargo-vetguard is atthe dispatch level because at pre-commit a missing
cargo-vetis a soft-skip; the upstream pre-pushvetgate is the hard backstop).
--target {pre-push,pre-commit}CLI flag (defaultpre-pushfor back-compat with the existing
hooks-driftgate).--checkfailure messages name the rightjustrecipe pertarget.
EmitTarget::default_output_path+EmitTarget::tier—target-aware accessors so
main.rsstays target-agnostic.templates/preamble_fast.sh(colors,staged-file inventory,
has_staged_*helpers,spawnhelper) and
templates/footer_fast.sh(wait loop, per-jobreport, optional-tool hint, failure dump). Both pure bash;
no per-gate knowledge. Embedded via
include_str!.NEW manifest gate
fast-drift:tiers = ["pre-push", "pr-fast"],bucket = "bg",gate_when = "always",hard = true, order 28 (next toworkflow-drift's 27 in Bucket 1).gen-hooks --target pre-commit --checkto verify_lint_fast.shagainst the manifest.pre-commit would compile the gen-hooks binary on every
commit and break the T1 sub-2 s budget; pre-push is the
right place to catch the drift before it reaches the remote.
NEW
pr-fast.yml::fast-driftjob:hooks-drift's shape (cache shared withsanityso the gen-hooks binary build piggybacks on the existing
rust-cache).
required.needs:, the bash R=() aggregator, ANDnotify-failure.needs:(otherwise it would itself failProperty 3 of
workflow-drifton first run — same recursiveconsistency check Phase 3 introduced).
NEW
justrecipes:just gen-fast— manual run of the pre-commit hookgenerator
just fast-drift— alias ofgen-hooks --target pre-commit --check, named for parity withgates-drift/hooks-drift/workflow-driftREGENERATED
scripts/hooks/_lint_fast.sh:gen-hooks --target pre-commitoutput.AUTO-GENERATED banner + embedded preamble + manifest-driven
dispatch + embedded footer.
stored in
gates.tomlnotes(single source of truth,surfaced by
gen-hooks --verbose).on the same predicate as before, in the same parallel-fan-out
shape. Cosmetic ordering shifts slightly (typos / reuse
moved up to match manifest order); all jobs run in parallel
so this is purely visual.
REGENERATED
scripts/hooks/_lint_pre_push.sh:fast-driftgate as a Bucket-1 entryalongside
gates-drift,hooks-drift,workflow-drift.MODIFIED
docs/architecture/gates-manifest-plan.md:What is explicitly NOT done
gen-docs) remains deferred — see plan §4.3.staged_when, norequires_command). Routing is driven entirely by existingfields (
id+gate_when+hard+tool) plus threehardcoded special cases (fmt, taplo, vet-fmt) that mirror
the gen-hooks Phase-2 precedent of
commit-subjectsandvet. Surgical, idiomatic, no schema bloat.classification block. Phase 3a is purely additive on the
gen-hooks side + one new gate + one new pr-fast job.
Lint policy (audited per the no-suppression-hacks rule)
#[allow(...)]directives in non-test code.expect()/unwrap()only in#[cfg(test)]blocks(allowed by
clippy.toml'sallow-*-in-tests = true).let _ = writeln!(out, ...)discard pattern inemit_fast_rust_staged_block: writes toStringareinfallible;
expect("...")would be ceremony for a panicthat can never fire. The
let _ =is documented inlineexplaining the choice. This satisfies
clippy::format_push_stringat the root cause (writer-style append vs.
format!()+push_strallocation) without the
#[allow]suppression clippysuggested.
TARGET_PRE_PUSH/TARGET_PRE_COMMITconstants to satisfyclippy::missing_docs_in_private_itemsat root cause.Tests (32 / 32 pass; was 22 before this PR)
10 new tests in
gen-hookscovering every emit_fast shape +edge cases:
fast_dispatch_emits_all_six_shapes— exhaustive single-fixture assertion that all 9 manifest gates render correctly
{{STAGED_TOML}}placeholder is rewritten + therust-staged block is sorted by manifest order.
fast_dispatch_emits_exactly_one_rust_staged_block—regression-guard for the "emit once at first member" flag.
fast_dispatch_honours_pre_commit_consumer_override— thefmt→fmt-checklegacy rename is preserved at emit time.fast_emit_fmt_spans_no_staged_branch— the|| ! has_any_stagedclause survives any future refactor of thefmt special case.
fast_emit_rust_staged_block_is_empty_when_no_rust_gates— edge case: a manifest with
fmtbut no otherrust_changedgates must NOT emit a dangling guard block.pre_commit_render_is_idempotent— plan §4.4 contract.pre_commit_and_pre_push_render_distinct_files— sanity:same manifest emits two materially different bash files; no
template leak between targets.
emit_target_default_output_paths_are_distinct— guardsagainst a refactor that aliases the two targets' output
paths or tier names.
Every fixture is a literal string; no randomness, no time, no
filesystem dependency in any test.
Verification
cargo test -p uffs-gen-hooks— 32 / 32 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, zeroper-item suppressions in non-test code.
cargo run -q --release -p uffs-gen-hooks -- --target pre-commit --check— exit 0.cargo run -q --release -p uffs-gen-hooks -- --check—exit 0.
bash scripts/ci/check_gates_drift.sh— 23 gates matched.cargo run -q --release -p uffs-gen-workflow -- --check—exit 0 (workflow-drift sees the new fast-drift job).
actionlint .github/workflows/pr-fast.yml— exit 0.bash -n scripts/hooks/_lint_fast.sh+shellcheck scripts/hooks/_lint_fast.sh— both exit 0.just lint-pre-push— full 23-gate sweep green in 57 swarm.
§2.7 closure
Updates the stale 2026-05-06 note in
docs/architecture/dev-flow-implementation-plan.md§2.7 (whichstill claimed "Phase 1 is the first concrete step") with a
2026-05-07 closure summary table enumerating the five landed
phases (#139 / #140 / #141 / #142 / #143 / this PR) and the four
drift detectors covering four orthogonal drift axes. Phase 3c
(gen-docs, prose tables) remains explicitly deferred per plan
§4.3 rationale; §2.7's executable contract is now shipped.
Sequencing
Branched off
mainpost-#143. No dependency on any otherin-flight PR. Closes Phase 3a of the gates manifest plan + the
executable portion of
dev-flow-implementation-plan.md§2.7;Phase 3c (gen-docs) remains deferred per plan §4.3.