Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion .github/workflows/pr-fast.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -505,6 +528,7 @@ jobs:
- classify
- file-size
- gates-drift
- hooks-drift
- fmt
- sanity
- clippy
Expand All @@ -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 }}'
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
]

# ─────────────────────────────────────────────────────────────────────────────
Expand Down
10 changes: 5 additions & 5 deletions docs/architecture/gates-manifest-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
29 changes: 29 additions & 0 deletions just/test.just
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions scripts/ci/gates.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
63 changes: 63 additions & 0 deletions scripts/ci/gen-hooks/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading