Skip to content

Commit d1f0070

Browse files
authored
Merge pull request #9 from Eilodon/claude/ci-friction-analysis-trivjm
fix(mcp,hook,resolver,diff-impact,tools): friction fixes, taxonomy sweep, and tools.rs god-file split
2 parents e69b1cf + 5f38e6f commit d1f0070

21 files changed

Lines changed: 4811 additions & 3425 deletions

.claude/hooks/ci-nudge.sh

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,35 @@ is_code_file() {
7373
esac
7474
}
7575

76+
# Resolve the git repo root that `cmd` will actually operate on, so the
77+
# commit/push gate only fires for *this* project's repo — not an unrelated
78+
# repo the agent is inspecting/debugging elsewhere (e.g. a scratch clone
79+
# under /tmp, or a test fixture repo). PreToolUse hooks always run with cwd
80+
# pinned to the project root regardless of any `cd` inside `cmd` (this
81+
# harness resets the shell's cwd between Bash calls), so neither `pwd` nor
82+
# the hook JSON's `cwd` field can distinguish this — the command text itself
83+
# is the only signal available. Best-effort, not a real shell parser: honors
84+
# the last explicit `git -C <dir>` / `cd <dir> &&`/`;` before the git call;
85+
# anything it can't confidently resolve falls back to "this repo" (fail
86+
# toward enforcing the gate, not silently skipping it).
87+
resolve_git_target_root() {
88+
local cmd="$1" explicit_dir=""
89+
90+
explicit_dir=$(grep -oE 'git[[:space:]]+-C[[:space:]]+[^[:space:]]+' <<<"$cmd" \
91+
| tail -1 | awk '{print $NF}')
92+
93+
if [ -z "$explicit_dir" ]; then
94+
explicit_dir=$(grep -oE 'cd[[:space:]]+[^[:space:]&;]+[[:space:]]*(&&|;)' <<<"$cmd" \
95+
| tail -1 | sed -E 's/^cd[[:space:]]+//; s/[[:space:]]*(&&|;)$//')
96+
fi
97+
98+
if [ -n "$explicit_dir" ]; then
99+
git -C "$explicit_dir" rev-parse --show-toplevel 2>/dev/null
100+
else
101+
git rev-parse --show-toplevel 2>/dev/null
102+
fi
103+
}
104+
76105
# Record mcp__ci__edit_context / mcp__ci__diff_impact calls as they happen —
77106
# recorded on PreToolUse (before the call runs) since attempting the check is
78107
# what matters here, and PreToolUse is all that's needed to observe it.
@@ -103,9 +132,19 @@ case "$tool_name" in
103132
nudge 'MANDATORY per AGENTS.md Stage 5 — call mcp__ci__edit_context(symbol) before this write if it modifies existing code, never skip (especially if is_hub).'
104133
;;
105134
Bash)
106-
if grep -qE '\bgit[[:space:]]+(commit|push)\b' <<<"$command"; then
135+
# Broad on purpose: `git -C <dir> commit` / `git --git-dir=<dir> push` put
136+
# flags between the subcommand and "commit"/"push", so a tight
137+
# `git commit` adjacency check misses them entirely (a false negative —
138+
# worse than the false positive this file otherwise guards against).
139+
# resolve_git_target_root() + the scope check below is what keeps this
140+
# broad match from over-firing on unrelated repos.
141+
if grep -qE '\bgit\b.*\b(commit|push)\b' <<<"$command"; then
107142
if [ "$needs_diff_impact" = "true" ]; then
108-
deny 'MANDATORY per AGENTS.md Stage 7 — call mcp__ci__diff_impact(staged=true) before this commit/push, never skip. Files changed since the last diff_impact check.'
143+
target_root=$(resolve_git_target_root "$command")
144+
project_root=$(git rev-parse --show-toplevel 2>/dev/null)
145+
if [ -z "$target_root" ] || [ "$target_root" = "$project_root" ]; then
146+
deny 'MANDATORY per AGENTS.md Stage 7 — call mcp__ci__diff_impact(staged=true) before this commit/push, never skip. Files changed since the last diff_impact check.'
147+
fi
109148
fi
110149
elif grep -qE '\b(grep|rg|ag)\b' <<<"$command"; then
111150
nudge 'CI available in this repo — prefer mcp__ci__search / mcp__ci__locate instead of grep via Bash (AGENTS.md Stage 2).'
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env bash
2+
# SessionStart hook: pre-build the ci-cli binary synchronously so the "ci"
3+
# stdio MCP server (.mcp.json: `cargo run --quiet -p ci-cli -- serve ...`)
4+
# can finish its handshake inside the MCP client's fixed 30s connection
5+
# timeout.
6+
#
7+
# On a fresh checkout (empty target/), `cargo build -p ci-cli` compiles the
8+
# full dependency tree (tree-sitter grammars, rusqlite bundled, stack-graphs,
9+
# embeddings) — measured ~60s even with a warm crates.io registry cache,
10+
# over 2x the client's timeout, so `cargo run` in .mcp.json reliably times
11+
# out on the very first connection of every fresh session/container.
12+
# Once target/ is warm, `cargo run` reconnects in well under 1s (cargo's own
13+
# freshness check + exec), so paying the compile cost here — before the
14+
# session (and the MCP client's timer) starts — fixes it for the rest of
15+
# the session. Must stay synchronous: async mode would let the MCP connect
16+
# attempt race the build, which is the failure this hook exists to avoid.
17+
set -uo pipefail
18+
19+
if ! command -v cargo >/dev/null 2>&1; then
20+
exit 0
21+
fi
22+
23+
build_output=$(cargo build --quiet -p ci-cli 2>&1)
24+
build_status=$?
25+
26+
if [ "$build_status" -ne 0 ]; then
27+
jq -n --arg msg "ci-cli pre-build failed (exit $build_status) — the ci MCP server will likely fail to connect (30s client timeout). Build output:
28+
$build_output" \
29+
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $msg}}'
30+
fi
31+
exit 0

.claude/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
"type": "command",
99
"command": "bash .claude/hooks/session-start-agents-md.sh",
1010
"timeout": 5
11+
},
12+
{
13+
"type": "command",
14+
"command": "bash .claude/hooks/session-start-build-ci.sh",
15+
"timeout": 300
1116
}
1217
]
1318
}

AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,13 @@ diff_impact(diff="<raw diff text>") # verify without git
153153
diff_impact(commits="HEAD~1..HEAD") # verify already-committed changes
154154
```
155155

156-
**Done when**: `aggregate_risk == "low"` and `unindexed_files == []`. Safe to commit.
156+
**Done when**: `aggregate_risk == "low"` and no `unindexed_files` entry has `reason == "pending_scan"`. Safe to commit.
157157

158158
**Signals**:
159159
- `aggregate_risk == "critical"` or `"high"` → call `callers` on `affected_symbols[0]` to verify manually
160-
- `aggregate_risk == "unknown"` → unindexed files present; wait for index to reach `ready`
161-
- `unindexed_files non-empty` → index incomplete; DO NOT treat diff as safe to push
160+
- `aggregate_risk == "unknown"` → a `pending_scan` file is present; wait for index to reach `ready`, then retry
161+
- `unindexed_files[].reason == "pending_scan"` → that file's index is stale/missing; DO NOT treat diff as safe to push yet
162+
- `unindexed_files[].reason == "out_of_scope"` → not a source file (docs/config/etc.); permanent, harmless, does not affect `aggregate_risk`
162163
- `suggested_reviewers` present → notify these owners before merging
163164

164165
**Rule: Never commit or push** without calling `diff_impact` first. Under Claude Code with this repo's bundled hook (`.claude/hooks/ci-nudge.sh`), this is enforced: `git commit`/`git push` is denied whenever a file was edited since the last `diff_impact` call.

crates/ci-core/src/analysis/dead_code.rs

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,26 @@ pub fn compute_dead_code_confidence(
3535
is_private: bool,
3636
scope_clear: bool,
3737
coverage: &CoverageData,
38+
kind: &str,
3839
) -> (&'static str, &'static str) {
40+
// Type-level definitions (struct/class/...) aren't "called" the way
41+
// functions and methods are — they're referenced via construction
42+
// syntax (`Foo { .. }` in Rust) that the call-graph extractor doesn't
43+
// track as a call at all, so `caller_count` is 0 for essentially every
44+
// one of them regardless of real usage (confirmed: 100% of this repo's
45+
// own `struct` symbols have caller_count=0). "Dead code" isn't a
46+
// well-formed question for a kind that structurally can't accrue
47+
// callers — answering "high confidence dead" here was the single
48+
// largest source of `dead_code_pct` false positives.
49+
if !matches!(kind, "function" | "method") {
50+
let source = if coverage.source != "none" {
51+
"static+coverage"
52+
} else {
53+
"static"
54+
};
55+
return ("none", source);
56+
}
57+
3958
if is_entry_point || is_test || caller_count > 0 {
4059
let source = if coverage.source != "none" {
4160
"static+coverage"
@@ -100,6 +119,7 @@ mod tests {
100119
false,
101120
false,
102121
&no_coverage(),
122+
"function",
103123
);
104124
assert_eq!(conf, "none");
105125
assert_eq!(src, "static");
@@ -117,6 +137,7 @@ mod tests {
117137
false,
118138
false,
119139
&no_coverage(),
140+
"function",
120141
);
121142
assert_eq!(conf, "none");
122143
assert_eq!(src, "static");
@@ -137,6 +158,7 @@ mod tests {
137158
true,
138159
true,
139160
&no_coverage(),
161+
"function",
140162
);
141163
assert_eq!(conf, "none");
142164
assert_eq!(src, "static");
@@ -145,8 +167,9 @@ mod tests {
145167
#[test]
146168
fn test_runtime_covered_returns_low() {
147169
let cov = with_coverage("/f.py", &[5]);
148-
let (conf, src) =
149-
compute_dead_code_confidence("/f.py", 1, 10, 0, false, false, false, false, &cov);
170+
let (conf, src) = compute_dead_code_confidence(
171+
"/f.py", 1, 10, 0, false, false, false, false, &cov, "function",
172+
);
150173
assert_eq!(conf, "low");
151174
assert_eq!(src, "static+coverage");
152175
}
@@ -163,6 +186,7 @@ mod tests {
163186
true,
164187
false,
165188
&no_coverage(),
189+
"function",
166190
);
167191
assert_eq!(conf, "high");
168192
}
@@ -179,6 +203,7 @@ mod tests {
179203
false,
180204
true,
181205
&no_coverage(),
206+
"function",
182207
);
183208
assert_eq!(conf, "medium");
184209
}
@@ -195,6 +220,7 @@ mod tests {
195220
false,
196221
false,
197222
&no_coverage(),
223+
"function",
198224
);
199225
assert_eq!(conf, "low");
200226
}
@@ -211,12 +237,57 @@ mod tests {
211237
false,
212238
false,
213239
&no_coverage(),
240+
"function",
214241
);
215242
assert_eq!(src, "static");
216243

217244
let cov = with_coverage("/other.py", &[1]);
218-
let (_, src) =
219-
compute_dead_code_confidence("/f.py", 1, 10, 5, false, false, false, false, &cov);
245+
let (_, src) = compute_dead_code_confidence(
246+
"/f.py", 1, 10, 5, false, false, false, false, &cov, "function",
247+
);
220248
assert_eq!(src, "static+coverage");
221249
}
250+
251+
/// Regression: a `struct` (or any non-callable kind) must never be
252+
/// scored "high confidence dead" just because it has zero callers — it
253+
/// *always* has zero callers in this codebase's call-graph model (type
254+
/// construction isn't tracked as a call), so the old behavior flagged
255+
/// essentially every private struct in the project.
256+
#[test]
257+
fn test_non_callable_kind_is_never_flagged_dead_even_when_private() {
258+
let (conf, src) = compute_dead_code_confidence(
259+
"/f.rs",
260+
1,
261+
10,
262+
0,
263+
false,
264+
false,
265+
true, // is_private — would return "high" for a function/method
266+
true,
267+
&no_coverage(),
268+
"struct",
269+
);
270+
assert_eq!(conf, "none");
271+
assert_eq!(src, "static");
272+
}
273+
274+
#[test]
275+
fn test_method_kind_is_still_evaluated() {
276+
let (conf, _) = compute_dead_code_confidence(
277+
"/f.rs",
278+
1,
279+
10,
280+
0,
281+
false,
282+
false,
283+
true,
284+
true,
285+
&no_coverage(),
286+
"method",
287+
);
288+
assert_eq!(
289+
conf, "high",
290+
"method is a callable kind — still subject to normal dead-code rules"
291+
);
292+
}
222293
}

0 commit comments

Comments
 (0)