|
| 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 | +} |
0 commit comments