Skip to content

Commit bbfbba7

Browse files
zackeesclaude
andauthored
fix(hooks): scope Stop-hook clippy + test to changed crates (#465) (#467)
`check-on-stop.py` previously ran `cargo clippy --workspace --all-targets` and `cargo test --workspace` on every Stop where any file changed. A one-line edit in `fbuild-core` rebuilt + retested every test in every crate — ~5+ min for tests, ~2-3 min for workspace clippy. Now `check-on-stop.py` parses `git status --porcelain -z`, classifies the changed paths into: - `crates/<name>/...` → run `cargo {clippy,test} -p <name>` for each changed crate - root `Cargo.toml`, `Cargo.lock`, `rust-toolchain.toml`, `rustfmt.toml`, `clippy.toml`, anything under `.cargo/` → fall back to `--workspace` (cross-crate deps may have shifted) - per-crate `Cargo.toml` → scopes to that crate, not workspace - non-Rust-only change (markdown, json outside crates/) → skip entirely `rustfmt --all --check` always runs (cheap, catches cross-crate formatting). The acceptance from #465 is preserved: workspace-wide verification still runs when build config changes; per-crate edits get per-crate work. Tests: `ci/hooks/test_check_on_stop.py` covers root vs per-crate `Cargo.toml`, all workspace triggers, Windows backslash path normalization, mixed workspace+per-crate (workspace wins), and the no-Rust-changes skip path. 11 tests, all green. Closes #465. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2333516 commit bbfbba7

2 files changed

Lines changed: 242 additions & 10 deletions

File tree

ci/hooks/check-on-stop.py

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
#!/usr/bin/env python3
2-
"""Stop hook: runs full workspace lint and tests.
2+
"""Stop hook: runs lint and tests scoped to the crates touched this session.
33
44
Smart mode: only runs if files were actually changed during this session.
55
Session fingerprint is captured at session start (check-on-start.py) and
66
compared here. If nothing changed during the session, everything is skipped.
77
8+
Scoping rules (issue #465):
9+
- Cargo.toml / Cargo.lock / rust-toolchain.toml / .cargo/** → workspace-wide
10+
- changes under crates/<name>/ → per-crate clippy + test for each <name>
11+
- changes only outside crates/ (and not workspace-wide) → format check only
12+
- no Rust-relevant changes at all → skip
13+
814
Exit codes:
9-
0 - All passed or skipped (no changes during session)
15+
0 - All passed or skipped
1016
2 - Lint or test failures (stderr fed back to Claude)
1117
"""
1218

@@ -21,6 +27,21 @@
2127
PROJECT_ROOT = SCRIPT_DIR.parent.parent
2228
SESSION_FINGERPRINT_FILE = PROJECT_ROOT / ".cache" / "session_fingerprint.json"
2329

30+
# Repo-root files whose change forces a workspace-wide lint+test —
31+
# they affect every crate (cross-crate deps, toolchain pin, lint config).
32+
WORKSPACE_TRIGGER_FILES = frozenset({
33+
"Cargo.toml",
34+
"Cargo.lock",
35+
"rust-toolchain.toml",
36+
"rustfmt.toml",
37+
"clippy.toml",
38+
})
39+
# Path prefixes that also force workspace-wide (config / shared infra).
40+
WORKSPACE_PREFIXES = (".cargo/",)
41+
# Extensions that participate in the lint/test gate. Anything else
42+
# (e.g. .md, .txt, generated JSON outside crates/) doesn't trigger work.
43+
RUST_EXTENSIONS = (".rs",)
44+
2445

2546
def run_cmd(cmd):
2647
"""Run a command rooted at PROJECT_ROOT."""
@@ -87,27 +108,133 @@ def should_skip():
87108
return False
88109

89110

111+
def get_dirty_files():
112+
"""Return paths of files dirty in the worktree (modified + untracked).
113+
114+
Uses `git status --porcelain -z` so filenames with spaces or unusual
115+
characters are handled correctly. Renames are reported with both
116+
source and destination — we count the destination (the path that
117+
exists on disk and might compile).
118+
"""
119+
result = run_cmd(["git", "status", "--porcelain", "-z"])
120+
if result.returncode != 0 or not result.stdout:
121+
return []
122+
out = []
123+
parts = result.stdout.split("\0")
124+
i = 0
125+
while i < len(parts):
126+
entry = parts[i]
127+
if not entry:
128+
i += 1
129+
continue
130+
# `XY ` prefix + filename, where XY is two status chars + a space.
131+
# For renames ("R "), the destination is on this entry and source
132+
# is the next NUL-separated token; consume both, keep destination.
133+
if len(entry) < 4:
134+
i += 1
135+
continue
136+
status = entry[:2]
137+
path = entry[3:]
138+
if status.startswith("R") or status.startswith("C"):
139+
# Destination is this entry's path; source is next, skip it.
140+
i += 2
141+
else:
142+
i += 1
143+
out.append(path)
144+
return out
145+
146+
147+
def classify_changes(files):
148+
"""Map a list of changed paths to (crates_set, needs_workspace, has_rust).
149+
150+
- crates_set: distinct crate names under `crates/<name>/...` that changed
151+
- needs_workspace: True if any change forces a workspace-wide run
152+
(Cargo.lock, Cargo.toml at root, rust-toolchain.toml, .cargo/**)
153+
- has_rust: True if any `.rs` file changed anywhere (so even non-crate
154+
Rust files in benches or examples still get a workspace check)
155+
"""
156+
crates: set[str] = set()
157+
needs_workspace = False
158+
has_rust = False
159+
for raw in files:
160+
# Normalize Windows separators for matching.
161+
path = raw.replace("\\", "/")
162+
if path in WORKSPACE_TRIGGER_FILES:
163+
needs_workspace = True
164+
if any(path.startswith(p) for p in WORKSPACE_PREFIXES):
165+
needs_workspace = True
166+
if path.startswith("crates/"):
167+
parts = path.split("/")
168+
if len(parts) >= 2 and parts[1]:
169+
crates.add(parts[1])
170+
if path.endswith(RUST_EXTENSIONS):
171+
has_rust = True
172+
return crates, needs_workspace, has_rust
173+
174+
175+
def run_lint(crates, needs_workspace):
176+
"""Run rustfmt --check + clippy, scoped to the changed crates."""
177+
# rustfmt is workspace-cheap and catches cross-crate consistency;
178+
# always run --all. Use --check (no autofix) because Stop is read-only
179+
# from the user's POV — they shouldn't see surprise modifications.
180+
fmt = run_cmd(["soldr", "cargo", "fmt", "--all", "--", "--check"])
181+
if fmt.returncode != 0:
182+
report_failure("Formatting check failed (run `soldr cargo fmt --all` to fix)", fmt)
183+
return fmt
184+
cmd = ["soldr", "cargo", "clippy"]
185+
if needs_workspace or not crates:
186+
cmd += ["--workspace"]
187+
else:
188+
for c in sorted(crates):
189+
cmd += ["-p", c]
190+
cmd += ["--all-targets", "--", "-D", "warnings"]
191+
return run_cmd(cmd)
192+
193+
194+
def run_tests(crates, needs_workspace):
195+
"""Run cargo test, scoped to the changed crates."""
196+
cmd = ["soldr", "cargo", "test"]
197+
if needs_workspace or not crates:
198+
cmd += ["--workspace"]
199+
else:
200+
for c in sorted(crates):
201+
cmd += ["-p", c]
202+
return run_cmd(cmd)
203+
204+
90205
def main():
91206
if should_skip():
92207
print("Skipping stop checks (no changes during this session)", file=sys.stderr)
93208
return 0
94209

95-
print("Running full workspace checks (changes detected)", file=sys.stderr)
210+
dirty = get_dirty_files()
211+
crates, needs_workspace, has_rust = classify_changes(dirty)
96212

97-
lint_script = str(PROJECT_ROOT / "ci" / "lint.py")
98-
test_script = str(PROJECT_ROOT / "ci" / "test.py")
213+
if not has_rust and not needs_workspace:
214+
# No Rust-relevant changes (markdown, json outside crates/, etc.)
215+
print(
216+
f"Skipping stop checks (changes touched no Rust code: {len(dirty)} non-Rust file(s))",
217+
file=sys.stderr,
218+
)
219+
return 0
220+
221+
scope_label = (
222+
"workspace-wide (Cargo.toml/lock/toolchain change)"
223+
if needs_workspace
224+
else f"scoped to {len(crates)} crate(s): {', '.join(sorted(crates))}"
225+
if crates
226+
else "workspace-wide (no per-crate attribution available)"
227+
)
228+
print(f"Running stop checks: {scope_label}", file=sys.stderr)
99229

100-
# Run lint and tests concurrently
101230
lint_results = []
102231
test_results = []
103232

104233
def do_lint():
105-
result = run_cmd(["uv", "run", "--script", lint_script, "--fix"])
106-
lint_results.append(result)
234+
lint_results.append(run_lint(crates, needs_workspace))
107235

108236
def do_test():
109-
result = run_cmd(["uv", "run", "--script", test_script])
110-
test_results.append(result)
237+
test_results.append(run_tests(crates, needs_workspace))
111238

112239
lint_thread = threading.Thread(target=do_lint)
113240
test_thread = threading.Thread(target=do_test)

ci/hooks/test_check_on_stop.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env python3
2+
"""Unit tests for the Stop hook's per-crate scoping classifier (issue #465)."""
3+
4+
import importlib.util
5+
import unittest
6+
from pathlib import Path
7+
8+
# The hook script's filename has a dash, so plain `import` won't work —
9+
# load it through importlib instead.
10+
_SCRIPT = Path(__file__).parent / "check-on-stop.py"
11+
_spec = importlib.util.spec_from_file_location("check_on_stop", _SCRIPT)
12+
assert _spec is not None and _spec.loader is not None
13+
_module = importlib.util.module_from_spec(_spec)
14+
_spec.loader.exec_module(_module)
15+
classify_changes = _module.classify_changes
16+
17+
18+
class ClassifyChangesTests(unittest.TestCase):
19+
def test_root_cargo_toml_triggers_workspace(self):
20+
crates, needs_workspace, has_rust = classify_changes(["Cargo.toml"])
21+
self.assertTrue(needs_workspace)
22+
self.assertFalse(has_rust)
23+
self.assertEqual(crates, set())
24+
25+
def test_cargo_lock_triggers_workspace(self):
26+
_, needs_workspace, _ = classify_changes(["Cargo.lock"])
27+
self.assertTrue(needs_workspace)
28+
29+
def test_toolchain_change_triggers_workspace(self):
30+
_, needs_workspace, _ = classify_changes(["rust-toolchain.toml"])
31+
self.assertTrue(needs_workspace)
32+
33+
def test_dot_cargo_config_triggers_workspace(self):
34+
_, needs_workspace, _ = classify_changes([".cargo/config.toml"])
35+
self.assertTrue(needs_workspace)
36+
37+
def test_crate_change_scopes_to_that_crate(self):
38+
crates, needs_workspace, has_rust = classify_changes(
39+
["crates/fbuild-core/src/lib.rs"]
40+
)
41+
self.assertFalse(needs_workspace)
42+
self.assertTrue(has_rust)
43+
self.assertEqual(crates, {"fbuild-core"})
44+
45+
def test_multiple_crate_changes_collect_all(self):
46+
crates, needs_workspace, _ = classify_changes(
47+
[
48+
"crates/fbuild-core/src/lib.rs",
49+
"crates/fbuild-build/src/symbol_analyzer.rs",
50+
"crates/fbuild-core/src/symbol_analysis/cref.rs",
51+
]
52+
)
53+
self.assertFalse(needs_workspace)
54+
self.assertEqual(crates, {"fbuild-core", "fbuild-build"})
55+
56+
def test_crate_cargo_toml_does_not_force_workspace(self):
57+
# A per-crate Cargo.toml change should only re-test that crate;
58+
# only the ROOT Cargo.toml (which defines workspace members) does.
59+
crates, needs_workspace, has_rust = classify_changes(
60+
["crates/fbuild-core/Cargo.toml"]
61+
)
62+
self.assertFalse(needs_workspace, "per-crate Cargo.toml must NOT trigger workspace")
63+
self.assertEqual(crates, {"fbuild-core"})
64+
self.assertFalse(has_rust)
65+
66+
def test_markdown_only_change_skips(self):
67+
# No Rust files, no workspace triggers — main() should skip,
68+
# but the classifier still reports the facts.
69+
crates, needs_workspace, has_rust = classify_changes(
70+
["docs/CLAUDE.md", "README.md"]
71+
)
72+
self.assertFalse(needs_workspace)
73+
self.assertFalse(has_rust)
74+
self.assertEqual(crates, set())
75+
76+
def test_windows_backslash_paths_are_normalized(self):
77+
# `git status -z` on Windows can emit backslash separators;
78+
# the classifier must treat them as `/`.
79+
crates, _, has_rust = classify_changes(
80+
["crates\\fbuild-core\\src\\lib.rs"]
81+
)
82+
self.assertTrue(has_rust)
83+
self.assertEqual(crates, {"fbuild-core"})
84+
85+
def test_workspace_and_per_crate_change_together_picks_workspace(self):
86+
# Mixed: Cargo.lock change + crate code change. Workspace wins
87+
# because cross-crate deps may have shifted under the per-crate
88+
# code.
89+
crates, needs_workspace, _ = classify_changes(
90+
["Cargo.lock", "crates/fbuild-core/src/lib.rs"]
91+
)
92+
self.assertTrue(needs_workspace)
93+
# crates set is still populated (informational), but the runner
94+
# will use --workspace anyway.
95+
self.assertEqual(crates, {"fbuild-core"})
96+
97+
def test_empty_input_returns_empty(self):
98+
crates, needs_workspace, has_rust = classify_changes([])
99+
self.assertFalse(needs_workspace)
100+
self.assertFalse(has_rust)
101+
self.assertEqual(crates, set())
102+
103+
104+
if __name__ == "__main__":
105+
unittest.main()

0 commit comments

Comments
 (0)