Skip to content

Commit 9648c12

Browse files
committed
tools: add preflight_drift.rs — board-claim vs cargo-reality reconciliation
Single-file zero-deps Rust port of WoA/.claude/v0.2/tools/preflight_drift.py. Compares numeric claims in CLAUDE.md and .claude/board/LATEST_STATE.md against actual Cargo.toml workspace members/exclude counts and PR-table row counts. Exits 1 when drift exceeds the configured threshold so a sprint spawn can refuse-to-start instead of building on stale claims. Currently surfaces 9-crate drift in the workspace_members metric (claim 7, real 16) and 10-crate drift in workspace_total (claim 22, real 32) — the existing CLAUDE.md "22 crates, 7 in workspace, 15 excluded" line predates the post-#372 expansions and needs an update independent of this PR. Build: rustc .claude/tools/preflight_drift.rs -o preflight_drift Run: ./preflight_drift [--threshold N] [--metric KEY]
1 parent 0bc33de commit 9648c12

1 file changed

Lines changed: 274 additions & 0 deletions

File tree

.claude/tools/preflight_drift.rs

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
//! preflight_drift — board-claim vs. cargo-reality reconciliation.
2+
//!
3+
//! Compares numeric claims in `.claude/board/LATEST_STATE.md` and `CLAUDE.md`
4+
//! against actual workspace state (Cargo.toml members/exclude, PR table rows).
5+
//! Refuses sprint spawn when drift exceeds the threshold.
6+
//!
7+
//! Port of `WoA/.claude/v0.2/tools/preflight_drift.py` (Python AST) to the
8+
//! Rust ecosystem. Zero dependencies — stdlib only, single-file. Build with
9+
//! rustc preflight_drift.rs -o preflight_drift
10+
//! Run from repo root:
11+
//! ./preflight_drift # default threshold = 2
12+
//! ./preflight_drift --threshold 0 # zero-drift mode
13+
//! ./preflight_drift --metric crates # only check one metric
14+
//!
15+
//! Exit codes:
16+
//! 0 no drift above threshold — sprint spawn OK
17+
//! 1 drift above threshold — refuse to spawn
18+
//! 2 parse error — warn, do not block
19+
20+
use std::env;
21+
use std::fs;
22+
use std::path::{Path, PathBuf};
23+
use std::process::ExitCode;
24+
25+
fn main() -> ExitCode {
26+
let args: Vec<String> = env::args().collect();
27+
let mut threshold: i64 = 2;
28+
let mut only: Option<String> = None;
29+
let mut i = 1;
30+
while i < args.len() {
31+
match args[i].as_str() {
32+
"--threshold" => {
33+
threshold = args.get(i + 1).and_then(|s| s.parse().ok()).unwrap_or(2);
34+
i += 2;
35+
}
36+
"--metric" => {
37+
only = args.get(i + 1).cloned();
38+
i += 2;
39+
}
40+
"-h" | "--help" => {
41+
eprintln!("{}", HELP);
42+
return ExitCode::from(0);
43+
}
44+
_ => i += 1,
45+
}
46+
}
47+
48+
let repo = match find_repo_root() {
49+
Some(p) => p,
50+
None => {
51+
eprintln!("ERR: no Cargo.toml found walking up from cwd");
52+
return ExitCode::from(2);
53+
}
54+
};
55+
56+
let cargo_toml = repo.join("Cargo.toml");
57+
let claude_md = repo.join("CLAUDE.md");
58+
let latest_state = repo.join(".claude/board/LATEST_STATE.md");
59+
60+
let real = match real_metrics(&cargo_toml) {
61+
Ok(m) => m,
62+
Err(e) => {
63+
eprintln!("ERR: cannot read {}: {e}", cargo_toml.display());
64+
return ExitCode::from(2);
65+
}
66+
};
67+
68+
let claimed = claimed_metrics(&claude_md, &latest_state);
69+
70+
let mut drift_rows: Vec<DriftRow> = Vec::new();
71+
for (key, real_val) in &real {
72+
if let Some(only_key) = &only {
73+
if only_key != key {
74+
continue;
75+
}
76+
}
77+
if let Some(claim_val) = claimed.get(key) {
78+
let diff = (*real_val - *claim_val).abs();
79+
drift_rows.push(DriftRow {
80+
key: key.clone(),
81+
claimed: *claim_val,
82+
real: *real_val,
83+
drift: diff,
84+
});
85+
} else {
86+
drift_rows.push(DriftRow {
87+
key: key.clone(),
88+
claimed: -1,
89+
real: *real_val,
90+
drift: -1, // unknown — claim missing
91+
});
92+
}
93+
}
94+
95+
print_report(&drift_rows, threshold);
96+
if drift_rows.iter().any(|r| r.drift > threshold) {
97+
ExitCode::from(1)
98+
} else {
99+
ExitCode::from(0)
100+
}
101+
}
102+
103+
const HELP: &str = "preflight_drift — board-claim vs. cargo-reality
104+
--threshold N max acceptable drift per metric (default 2)
105+
--metric KEY check only this metric (workspace_members|excluded|prs_in_table)
106+
-h, --help this help";
107+
108+
struct DriftRow {
109+
key: String,
110+
claimed: i64,
111+
real: i64,
112+
drift: i64,
113+
}
114+
115+
fn find_repo_root() -> Option<PathBuf> {
116+
let mut p = env::current_dir().ok()?;
117+
loop {
118+
if p.join("Cargo.toml").exists() {
119+
return Some(p);
120+
}
121+
if !p.pop() {
122+
return None;
123+
}
124+
}
125+
}
126+
127+
fn real_metrics(cargo_toml: &Path) -> std::io::Result<Vec<(String, i64)>> {
128+
let text = fs::read_to_string(cargo_toml)?;
129+
let members = count_array_entries(&text, "members");
130+
let excluded = count_array_entries(&text, "exclude");
131+
Ok(vec![
132+
("workspace_members".into(), members),
133+
("excluded".into(), excluded),
134+
("workspace_total".into(), members + excluded),
135+
])
136+
}
137+
138+
// Counts non-comment quoted strings inside the `<name> = [ ... ]` block.
139+
// Tolerant of trailing commas, mixed indentation, and `#`-prefixed comment
140+
// lines. Stops at the matching ']'.
141+
fn count_array_entries(toml: &str, name: &str) -> i64 {
142+
let mut count: i64 = 0;
143+
let mut in_block = false;
144+
let mut depth: i32 = 0;
145+
for raw in toml.lines() {
146+
let line = raw.trim_start();
147+
if !in_block {
148+
if line.starts_with(&format!("{name} =")) || line.starts_with(&format!("{name}=")) {
149+
in_block = true;
150+
depth = bracket_delta(line);
151+
count += count_quoted(strip_comment(line));
152+
if depth == 0 && line.contains(']') {
153+
in_block = false;
154+
}
155+
}
156+
continue;
157+
}
158+
let body = strip_comment(line);
159+
count += count_quoted(body);
160+
depth += bracket_delta(body);
161+
if depth <= 0 {
162+
in_block = false;
163+
}
164+
}
165+
count
166+
}
167+
168+
fn strip_comment(line: &str) -> &str {
169+
// Naive: split on first '#' outside quotes. Sufficient for our Cargo.toml
170+
// shape (no `#` characters inside the quoted crate paths).
171+
let mut in_quote = false;
172+
for (i, c) in line.char_indices() {
173+
match c {
174+
'"' => in_quote = !in_quote,
175+
'#' if !in_quote => return &line[..i],
176+
_ => {}
177+
}
178+
}
179+
line
180+
}
181+
182+
fn count_quoted(line: &str) -> i64 {
183+
// Even-numbered quote-count → quoted-string count is quotes/2.
184+
let q = line.bytes().filter(|&b| b == b'"').count() as i64;
185+
q / 2
186+
}
187+
188+
fn bracket_delta(line: &str) -> i32 {
189+
let mut d = 0;
190+
let mut in_quote = false;
191+
for c in line.chars() {
192+
match c {
193+
'"' => in_quote = !in_quote,
194+
'[' if !in_quote => d += 1,
195+
']' if !in_quote => d -= 1,
196+
_ => {}
197+
}
198+
}
199+
d
200+
}
201+
202+
fn claimed_metrics(
203+
claude_md: &Path,
204+
latest_state: &Path,
205+
) -> std::collections::HashMap<String, i64> {
206+
let mut out = std::collections::HashMap::new();
207+
if let Ok(text) = fs::read_to_string(claude_md) {
208+
// CLAUDE.md canonical phrase: "N crates, M in workspace, K excluded".
209+
// Match the first occurrence.
210+
if let Some(caps) = find_crates_phrase(&text) {
211+
out.insert("workspace_total".into(), caps.0);
212+
out.insert("workspace_members".into(), caps.1);
213+
out.insert("excluded".into(), caps.2);
214+
}
215+
}
216+
if let Ok(text) = fs::read_to_string(latest_state) {
217+
let prs = count_pr_rows(&text);
218+
if prs > 0 {
219+
out.insert("prs_in_table".into(), prs);
220+
}
221+
}
222+
out
223+
}
224+
225+
fn find_crates_phrase(text: &str) -> Option<(i64, i64, i64)> {
226+
// Look for: "N crates, M in workspace, K excluded" in any line.
227+
// Tolerant of whitespace and surrounding markdown.
228+
for line in text.lines() {
229+
let l = line.to_ascii_lowercase();
230+
if !l.contains("crates") || !l.contains("in workspace") || !l.contains("excluded") {
231+
continue;
232+
}
233+
let nums: Vec<i64> = l
234+
.split(|c: char| !c.is_ascii_digit())
235+
.filter_map(|s| s.parse().ok())
236+
.collect();
237+
if nums.len() >= 3 {
238+
return Some((nums[0], nums[1], nums[2]));
239+
}
240+
}
241+
None
242+
}
243+
244+
fn count_pr_rows(text: &str) -> i64 {
245+
// PR table rows in LATEST_STATE.md look like:
246+
// | **#389** | 2026-05-16 | ...
247+
// Count lines starting with `| **#` and a digit.
248+
text.lines()
249+
.filter(|l| {
250+
let t = l.trim_start();
251+
t.starts_with("| **#")
252+
&& t.bytes()
253+
.nth(5)
254+
.map(|b| b.is_ascii_digit())
255+
.unwrap_or(false)
256+
})
257+
.count() as i64
258+
}
259+
260+
fn print_report(rows: &[DriftRow], threshold: i64) {
261+
let any_over = rows.iter().any(|r| r.drift > threshold);
262+
let banner = if any_over { "DRIFT — refuse to spawn" } else { "OK — within threshold" };
263+
println!("preflight_drift: {banner} (threshold={threshold})");
264+
println!(" {:<22} {:>10} {:>10} {:>10}", "metric", "claimed", "real", "drift");
265+
for r in rows {
266+
let claimed = if r.claimed < 0 { "—".to_string() } else { r.claimed.to_string() };
267+
let drift = if r.drift < 0 { "?".to_string() } else { r.drift.to_string() };
268+
let marker = if r.drift > threshold { " <— OVER" } else { "" };
269+
println!(
270+
" {:<22} {:>10} {:>10} {:>10}{}",
271+
r.key, claimed, r.real, drift, marker
272+
);
273+
}
274+
}

0 commit comments

Comments
 (0)