Skip to content

feat(ci): gates manifest Phase 3a — _lint_fast.sh codegen + fast-drift gate#144

Merged
githubrobbi merged 1 commit intomainfrom
feat/gates-manifest-phase-3a-fast-drift
May 7, 2026
Merged

feat(ci): gates manifest Phase 3a — _lint_fast.sh codegen + fast-drift gate#144
githubrobbi merged 1 commit intomainfrom
feat/gates-manifest-phase-3a-fast-drift

Conversation

@githubrobbi
Copy link
Copy Markdown
Collaborator

feat(ci): gates manifest Phase 3a — _lint_fast.sh codegen + fast-drift 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 taploif 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-fmtif 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
    fmtfmt-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.

§2.7 closure

Updates the stale 2026-05-06 note in
docs/architecture/dev-flow-implementation-plan.md §2.7 (which
still 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 main post-#143. No dependency on any other
in-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.

…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.
@githubrobbi githubrobbi enabled auto-merge (squash) May 7, 2026 12:07
@githubrobbi githubrobbi merged commit fb814bb into main May 7, 2026
20 checks passed
@githubrobbi githubrobbi deleted the feat/gates-manifest-phase-3a-fast-drift branch May 7, 2026 12:21
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