Skip to content

Commit 3fd4b87

Browse files
authored
Merge pull request #13 from Eilodon/claude/ci-mcp-connection-check-1x10o2
Claude/ci mcp connection check 1x10o2
2 parents 0ef7314 + d93fdad commit 3fd4b87

14 files changed

Lines changed: 1449 additions & 15 deletions

File tree

crates/ci-cli/src/main.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ async fn main() -> Result<()> {
157157

158158
let thresholds = ci_core::fitness::load_thresholds(config.as_deref())?;
159159
let boundary_rules = ci_core::fitness::load_boundary_rules(config.as_deref())?;
160+
let config_drift_doc_paths =
161+
ci_core::fitness::load_config_drift_doc_paths(config.as_deref())?;
160162

161163
let conn = rusqlite::Connection::open(&db_path)
162164
.unwrap_or_else(|_| rusqlite::Connection::open_in_memory().expect("in-memory DB"));
@@ -169,6 +171,7 @@ async fn main() -> Result<()> {
169171
&root,
170172
&coverage,
171173
&boundary_rules,
174+
&config_drift_doc_paths,
172175
)?;
173176

174177
// Record today's metrics for later trend comparison (edit_context's
@@ -208,6 +211,13 @@ async fn main() -> Result<()> {
208211
);
209212
}
210213
}
214+
if !result.config_drift.is_empty() {
215+
println!();
216+
println!("Config drift (doc references to files that no longer exist):");
217+
for f in &result.config_drift {
218+
println!(" {}: references \"{}\"", f.doc_path, f.reference);
219+
}
220+
}
211221
println!();
212222
if result.passed {
213223
println!("All checks passed.");
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
use std::collections::HashSet;
2+
use std::path::Path;
3+
4+
use serde::Serialize;
5+
6+
use super::doc_refs::extract_path_refs;
7+
8+
#[derive(Debug, Clone, Serialize, PartialEq)]
9+
pub struct ConfigDriftFinding {
10+
/// Repo-relative path of the doc that made the reference (as declared in
11+
/// `[config_drift].doc_paths`).
12+
pub doc_path: String,
13+
/// The file-path-like token found in that doc that doesn't resolve to
14+
/// any real file in the project tree.
15+
pub reference: String,
16+
}
17+
18+
/// Scans each declared doc file for file-path-like references (`server.py`,
19+
/// `tools/search.py`, `crates/ci-core/src/fitness.rs`) and reports any that
20+
/// don't resolve to a real file on disk — the same signal that would catch a
21+
/// `CONTRACTS.md` still describing a pre-rewrite Python layout days after the
22+
/// codebase moved to Rust.
23+
///
24+
/// Existence is checked against the real project tree (gitignore-aware
25+
/// walk), not the symbol index — a reference to `Cargo.toml` or `README.md`
26+
/// is legitimate even though neither is a parsed source file the indexer
27+
/// puts in `file_index`. A reference resolves if it exactly matches a
28+
/// repo-relative path, or matches the tail of one after a `/` boundary (so a
29+
/// doc can write the short form `fitness.rs` instead of the full
30+
/// `crates/ci-core/src/fitness.rs`).
31+
///
32+
/// Returns an empty vec (not an error) when `doc_paths` is empty or a
33+
/// declared doc doesn't exist — this check only judges references *inside*
34+
/// docs that are present, mirroring `check_boundaries`' "no rules declared"
35+
/// pass-by-default behavior.
36+
/// Every repo-relative file path under `project_root`, gitignore-aware —
37+
/// shared groundwork for both `check_config_drift` (does this reference
38+
/// resolve at all?) and `crate::memory`'s ref-capture (which real path did a
39+
/// short-form reference resolve to?), so both pay the walk cost once and
40+
/// agree on what "real" means.
41+
pub fn build_real_path_index(project_root: &Path, ignore_patterns: &[String]) -> HashSet<String> {
42+
let mut real_paths = HashSet::new();
43+
for entry in crate::walk::build_walker(project_root, ignore_patterns) {
44+
let Ok(entry) = entry else { continue };
45+
if !entry.file_type().is_some_and(|t| t.is_file()) {
46+
continue;
47+
}
48+
let path = entry.into_path();
49+
let rel = path
50+
.strip_prefix(project_root)
51+
.unwrap_or(&path)
52+
.to_string_lossy()
53+
.replace('\\', "/");
54+
real_paths.insert(rel);
55+
}
56+
real_paths
57+
}
58+
59+
/// Resolves a file-path-like reference to the repo-relative path it names,
60+
/// or `None` if it doesn't correspond to any real file. Tries, in order: (1)
61+
/// a direct filesystem check — covers dot-directories
62+
/// (`.github/workflows/x.yml`, `.claude/hooks/y.sh`) that `real_paths`
63+
/// deliberately excludes, same exclusion `search`'s grep walker relies on to
64+
/// skip `.git`; (2) exact match in `real_paths`; (3) suffix match after a
65+
/// `/` boundary, so a doc can write the short form `fitness.rs` instead of
66+
/// the full `crates/ci-core/src/fitness.rs`.
67+
pub fn resolve_reference(
68+
project_root: &Path,
69+
real_paths: &HashSet<String>,
70+
reference: &str,
71+
) -> Option<String> {
72+
if project_root.join(reference).exists() {
73+
return Some(reference.to_string());
74+
}
75+
if real_paths.contains(reference) {
76+
return Some(reference.to_string());
77+
}
78+
real_paths
79+
.iter()
80+
.find(|p| p.ends_with(&format!("/{reference}")))
81+
.cloned()
82+
}
83+
84+
pub fn check_config_drift(
85+
project_root: &Path,
86+
doc_paths: &[String],
87+
ignore_patterns: &[String],
88+
) -> Vec<ConfigDriftFinding> {
89+
if doc_paths.is_empty() {
90+
return Vec::new();
91+
}
92+
93+
let real_paths = build_real_path_index(project_root, ignore_patterns);
94+
95+
let mut findings = Vec::new();
96+
for doc_path in doc_paths {
97+
let full = project_root.join(doc_path);
98+
let Ok(text) = std::fs::read_to_string(&full) else {
99+
continue;
100+
};
101+
let mut refs = extract_path_refs(&text);
102+
refs.sort();
103+
refs.dedup();
104+
for r in refs {
105+
if resolve_reference(project_root, &real_paths, &r).is_none() {
106+
findings.push(ConfigDriftFinding {
107+
doc_path: doc_path.clone(),
108+
reference: r,
109+
});
110+
}
111+
}
112+
}
113+
findings.sort_by(|a, b| a.doc_path.cmp(&b.doc_path).then(a.reference.cmp(&b.reference)));
114+
findings
115+
}
116+
117+
#[cfg(test)]
118+
mod tests {
119+
use super::*;
120+
121+
fn write(dir: &Path, rel: &str, content: &str) {
122+
let full = dir.join(rel);
123+
if let Some(parent) = full.parent() {
124+
std::fs::create_dir_all(parent).unwrap();
125+
}
126+
std::fs::write(full, content).unwrap();
127+
}
128+
129+
fn temp_project(name: &str) -> std::path::PathBuf {
130+
let dir = std::env::temp_dir().join(format!("ci_config_drift_test_{name}_{}", std::process::id()));
131+
let _ = std::fs::remove_dir_all(&dir);
132+
std::fs::create_dir_all(&dir).unwrap();
133+
dir
134+
}
135+
136+
#[test]
137+
fn empty_doc_paths_returns_no_findings() {
138+
let dir = temp_project("empty");
139+
let findings = check_config_drift(&dir, &[], &[]);
140+
assert!(findings.is_empty());
141+
}
142+
143+
#[test]
144+
fn missing_doc_file_is_skipped_not_errored() {
145+
let dir = temp_project("missing_doc");
146+
let findings = check_config_drift(&dir, &["NOPE.md".into()], &[]);
147+
assert!(findings.is_empty());
148+
}
149+
150+
#[test]
151+
fn flags_reference_to_nonexistent_file() {
152+
let dir = temp_project("flags_missing");
153+
write(&dir, "CONTRACTS.md", "> **Owner:** server.py\n");
154+
let findings = check_config_drift(&dir, &["CONTRACTS.md".into()], &[]);
155+
assert_eq!(findings.len(), 1);
156+
assert_eq!(findings[0].doc_path, "CONTRACTS.md");
157+
assert_eq!(findings[0].reference, "server.py");
158+
}
159+
160+
#[test]
161+
fn does_not_flag_reference_to_real_file_exact_path() {
162+
let dir = temp_project("real_exact");
163+
write(&dir, "Cargo.toml", "[package]\n");
164+
write(&dir, "README.md", "See `Cargo.toml` for deps.\n");
165+
let findings = check_config_drift(&dir, &["README.md".into()], &[]);
166+
assert!(findings.is_empty(), "got {findings:?}");
167+
}
168+
169+
#[test]
170+
fn does_not_flag_reference_to_real_file_short_suffix_form() {
171+
let dir = temp_project("real_suffix");
172+
write(&dir, "crates/ci-core/src/fitness.rs", "// stub\n");
173+
write(&dir, "AGENTS.md", "See `fitness.rs` for the fitness gate.\n");
174+
let findings = check_config_drift(&dir, &["AGENTS.md".into()], &[]);
175+
assert!(findings.is_empty(), "got {findings:?}");
176+
}
177+
178+
#[test]
179+
fn does_not_flag_reference_to_real_file_in_dot_directory() {
180+
let dir = temp_project("dotdir");
181+
write(&dir, ".github/workflows/release.yml", "name: Release\n");
182+
write(
183+
&dir,
184+
"README.md",
185+
"see `.github/workflows/release.yml` for the release matrix\n",
186+
);
187+
let findings = check_config_drift(&dir, &["README.md".into()], &[]);
188+
assert!(findings.is_empty(), "got {findings:?}");
189+
}
190+
191+
#[test]
192+
fn dedups_repeated_reference_within_one_doc() {
193+
let dir = temp_project("dedup");
194+
write(
195+
&dir,
196+
"CONTRACTS.md",
197+
"server.py owns this.\nAlso see server.py again.\n",
198+
);
199+
let findings = check_config_drift(&dir, &["CONTRACTS.md".into()], &[]);
200+
assert_eq!(findings.len(), 1);
201+
}
202+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
use std::sync::OnceLock;
2+
3+
use regex::Regex;
4+
5+
/// File extensions considered a "real reference" worth checking — kept to
6+
/// source/config/doc extensions actually used in this repo (and most repos)
7+
/// so the regex doesn't fire on version strings (`v2.7.2`), abbreviations
8+
/// (`e.g.`), or plain decimals.
9+
fn path_ref_regex() -> &'static Regex {
10+
static RE: OnceLock<Regex> = OnceLock::new();
11+
RE.get_or_init(|| {
12+
Regex::new(
13+
r"\.?[A-Za-z0-9_][A-Za-z0-9_/.-]*\.(?:rs|py|ts|tsx|js|jsx|go|rb|java|kt|swift|toml|json|ya?ml|sh|md)\b",
14+
)
15+
.unwrap()
16+
})
17+
}
18+
19+
/// Strips fenced (```) code blocks — illustrative tool-call examples like
20+
/// `file_overview("src/auth/login.ts")` inside a how-to guide aren't claims
21+
/// that a file exists, and are the single biggest source of false-positive
22+
/// references in docs that teach by example.
23+
fn strip_fenced_code_blocks(text: &str) -> String {
24+
let mut out = String::with_capacity(text.len());
25+
let mut in_fence = false;
26+
for line in text.lines() {
27+
if line.trim_start().starts_with("```") {
28+
in_fence = !in_fence;
29+
} else if !in_fence {
30+
out.push_str(line);
31+
}
32+
out.push('\n');
33+
}
34+
out
35+
}
36+
37+
/// Extracts file-path-like tokens from free-form doc text — e.g. `server.py`,
38+
/// `tools/search.py`, `crates/ci-core/src/fitness.rs`, `.github/workflows/x.yml`
39+
/// — whether or not they're backtick-wrapped, since real-world drift (a
40+
/// stale `CONTRACTS.md` section) shows up as plain prose ("Owner:
41+
/// server.py"), not just inline code spans. Skips matches immediately
42+
/// preceded by `://` so URLs (`example.com/foo.py`) aren't treated as repo
43+
/// file references, and skips fenced code blocks (see
44+
/// `strip_fenced_code_blocks`). Does not dedup — callers that need unique
45+
/// tokens should sort+dedup the result.
46+
pub fn extract_path_refs(text: &str) -> Vec<String> {
47+
let text = strip_fenced_code_blocks(text);
48+
let re = path_ref_regex();
49+
let mut out = Vec::new();
50+
for m in re.find_iter(&text) {
51+
let start = m.start();
52+
// `start - 8` can land inside a multi-byte UTF-8 char (e.g. Vietnamese
53+
// prose) — walk back to the nearest real char boundary before slicing.
54+
let mut lookback = start.saturating_sub(8);
55+
while lookback > 0 && !text.is_char_boundary(lookback) {
56+
lookback -= 1;
57+
}
58+
let preceding = &text[lookback..start];
59+
if preceding.contains("://") {
60+
continue;
61+
}
62+
out.push(m.as_str().trim_start_matches("./").to_string());
63+
}
64+
out
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use super::*;
70+
71+
#[test]
72+
fn extracts_bare_filename() {
73+
let refs = extract_path_refs("> **Owner:** server.py");
74+
assert_eq!(refs, vec!["server.py"]);
75+
}
76+
77+
#[test]
78+
fn extracts_nested_path() {
79+
let refs = extract_path_refs("Owner: tools/search.py (_resolve_symbol)");
80+
assert_eq!(refs, vec!["tools/search.py"]);
81+
}
82+
83+
#[test]
84+
fn extracts_deep_relative_path() {
85+
let refs = extract_path_refs("see `crates/ci-core/src/fitness.rs` for details");
86+
assert_eq!(refs, vec!["crates/ci-core/src/fitness.rs"]);
87+
}
88+
89+
#[test]
90+
fn ignores_version_strings_and_decimals() {
91+
let refs = extract_path_refs("v2.7.2 compatible, e.g. 3.14 is not a path");
92+
assert!(refs.is_empty(), "got {refs:?}");
93+
}
94+
95+
#[test]
96+
fn skips_urls() {
97+
let refs = extract_path_refs("see https://example.com/foo.py for reference");
98+
assert!(refs.is_empty(), "got {refs:?}");
99+
}
100+
101+
#[test]
102+
fn extracts_multiple_and_preserves_order() {
103+
let refs = extract_path_refs(
104+
"db/schema.py (CREATE), indexer/indexer.py (WRITE), tools/* (READ)",
105+
);
106+
assert_eq!(refs, vec!["db/schema.py", "indexer/indexer.py"]);
107+
}
108+
109+
#[test]
110+
fn strips_leading_dot_slash() {
111+
let refs = extract_path_refs("run `./scripts/build.sh` first");
112+
assert_eq!(refs, vec!["scripts/build.sh"]);
113+
}
114+
115+
/// Regression: a multi-byte UTF-8 char (e.g. Vietnamese "Nguyên") landing
116+
/// within 8 bytes before a match used to panic by slicing mid-character —
117+
/// `start.saturating_sub(8)` must walk back to a real char boundary.
118+
#[test]
119+
fn does_not_panic_on_multibyte_utf8_before_match() {
120+
let refs = extract_path_refs("Nguyên tắc: xem server.py để biết chi tiết");
121+
assert_eq!(refs, vec!["server.py"]);
122+
}
123+
124+
#[test]
125+
fn captures_leading_dot_for_dotfiles_and_dotdirs() {
126+
let refs = extract_path_refs(
127+
"Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`), see `.github/workflows/release.yml`",
128+
);
129+
assert_eq!(
130+
refs,
131+
vec![".mcp.json", ".cursor/mcp.json", ".github/workflows/release.yml"]
132+
);
133+
}
134+
135+
#[test]
136+
fn skips_references_inside_fenced_code_blocks() {
137+
let refs = extract_path_refs(
138+
"prose mentions server.py\n\
139+
```\n\
140+
file_overview(\"src/auth/login.ts\")\n\
141+
```\n\
142+
more prose mentions client.py",
143+
);
144+
assert_eq!(refs, vec!["server.py", "client.py"]);
145+
}
146+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
pub mod boundaries;
22
pub mod cochange;
33
pub mod codeowners;
4+
pub mod config_drift;
45
pub mod coverage;
56
pub mod dead_code;
67
pub mod diff_impact;
8+
pub mod doc_refs;
79
pub mod git_log;
810
pub mod hotspot;

0 commit comments

Comments
 (0)