|
1 | 1 | #!/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. |
3 | 3 |
|
4 | 4 | Smart mode: only runs if files were actually changed during this session. |
5 | 5 | Session fingerprint is captured at session start (check-on-start.py) and |
6 | 6 | compared here. If nothing changed during the session, everything is skipped. |
7 | 7 |
|
| 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 | +
|
8 | 14 | Exit codes: |
9 | | - 0 - All passed or skipped (no changes during session) |
| 15 | + 0 - All passed or skipped |
10 | 16 | 2 - Lint or test failures (stderr fed back to Claude) |
11 | 17 | """ |
12 | 18 |
|
|
21 | 27 | PROJECT_ROOT = SCRIPT_DIR.parent.parent |
22 | 28 | SESSION_FINGERPRINT_FILE = PROJECT_ROOT / ".cache" / "session_fingerprint.json" |
23 | 29 |
|
| 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 | + |
24 | 45 |
|
25 | 46 | def run_cmd(cmd): |
26 | 47 | """Run a command rooted at PROJECT_ROOT.""" |
@@ -87,27 +108,133 @@ def should_skip(): |
87 | 108 | return False |
88 | 109 |
|
89 | 110 |
|
| 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 | + |
90 | 205 | def main(): |
91 | 206 | if should_skip(): |
92 | 207 | print("Skipping stop checks (no changes during this session)", file=sys.stderr) |
93 | 208 | return 0 |
94 | 209 |
|
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) |
96 | 212 |
|
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) |
99 | 229 |
|
100 | | - # Run lint and tests concurrently |
101 | 230 | lint_results = [] |
102 | 231 | test_results = [] |
103 | 232 |
|
104 | 233 | 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)) |
107 | 235 |
|
108 | 236 | 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)) |
111 | 238 |
|
112 | 239 | lint_thread = threading.Thread(target=do_lint) |
113 | 240 | test_thread = threading.Thread(target=do_test) |
|
0 commit comments