1use crate::paths::MissionPaths;
9use crate::types::Plan;
10use serde::Serialize;
11
12pub const DEFAULT_SPEC_CHARS_P90: usize = 1_200;
16pub const DEFAULT_FILE_MENTIONS_P90: usize = 5;
17
18#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct FitAnchor {
22 pub spec_chars_p90: usize,
23 pub file_mentions_p90: usize,
24 pub plans_used: usize,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct FeatureFitWarning {
31 pub feature_id: String,
32 pub title: String,
33 pub spec_chars: usize,
34 pub file_mentions: usize,
35 pub reasons: Vec<String>,
36}
37
38fn count_file_mentions(spec: &str) -> usize {
43 const EXTENSIONS: &[&str] = &[
44 ".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".md", ".json", ".toml", ".yml",
45 ".yaml", ".sh", ".mjs",
46 ];
47 spec.split_whitespace()
48 .filter(|token| {
49 let token = token.trim_matches(|c: char| {
50 matches!(
51 c,
52 '`' | '"' | '\'' | '(' | ')' | '[' | ']' | ',' | ';' | ':'
53 )
54 });
55 !token.is_empty()
56 && (token.contains('/')
57 || token.contains('*')
58 || EXTENSIONS.iter().any(|ext| token.ends_with(ext)))
59 })
60 .count()
61}
62
63fn p90(values: &mut [usize]) -> usize {
65 if values.is_empty() {
66 return 0;
67 }
68 values.sort_unstable();
69 let idx = ((values.len() as f64) * 0.9).ceil() as usize;
70 values[idx.saturating_sub(1).min(values.len() - 1)]
71}
72
73pub fn corpus_fit_anchor(repo_root: &std::path::Path) -> FitAnchor {
77 let mut spec_chars = Vec::new();
78 let mut file_mentions = Vec::new();
79 let mut plans_used = 0usize;
80 for mission_id in MissionPaths::list_missions(repo_root) {
81 let plan_path = MissionPaths::new(repo_root, &mission_id).plan_file();
82 let Ok(text) = std::fs::read_to_string(&plan_path) else {
83 continue;
84 };
85 let Ok(plan) = serde_json::from_str::<Plan>(&text) else {
86 continue;
87 };
88 plans_used += 1;
89 for milestone in &plan.milestones {
90 for feature in &milestone.features {
91 spec_chars.push(feature.spec.chars().count());
92 file_mentions.push(count_file_mentions(&feature.spec));
93 }
94 }
95 }
96 if plans_used == 0 {
97 return FitAnchor {
98 spec_chars_p90: DEFAULT_SPEC_CHARS_P90,
99 file_mentions_p90: DEFAULT_FILE_MENTIONS_P90,
100 plans_used: 0,
101 };
102 }
103 FitAnchor {
104 spec_chars_p90: p90(&mut spec_chars).max(200),
105 file_mentions_p90: p90(&mut file_mentions).max(2),
106 plans_used,
107 }
108}
109
110pub fn feature_fit_warnings(plan: &Plan, anchor: &FitAnchor) -> Vec<FeatureFitWarning> {
113 let mut warnings = Vec::new();
114 for (mi, milestone) in plan.milestones.iter().enumerate() {
115 for (fi, feature) in milestone.features.iter().enumerate() {
116 let chars = feature.spec.chars().count();
117 let mentions = count_file_mentions(&feature.spec);
118 let mut reasons = Vec::new();
119 if chars > anchor.spec_chars_p90 {
120 reasons.push(format!(
121 "spec is {chars} chars (corpus p90: {})",
122 anchor.spec_chars_p90
123 ));
124 }
125 if mentions > anchor.file_mentions_p90 {
126 reasons.push(format!(
127 "spec points at {mentions} files (corpus p90: {})",
128 anchor.file_mentions_p90
129 ));
130 }
131 if !reasons.is_empty() {
132 warnings.push(FeatureFitWarning {
133 feature_id: format!("f-{}-{}", mi + 1, fi + 1),
134 title: feature.title.clone(),
135 spec_chars: chars,
136 file_mentions: mentions,
137 reasons,
138 });
139 }
140 }
141 }
142 warnings
143}
144
145pub fn render_fit_note(warnings: &[FeatureFitWarning], anchor: &FitAnchor) -> String {
148 let provenance = if anchor.plans_used == 0 {
149 format!(
150 "defaults (no plan history; p90 ≈ {} chars / {} files)",
151 anchor.spec_chars_p90, anchor.file_mentions_p90
152 )
153 } else {
154 format!(
155 "corpus p90 over {} plan(s): {} chars / {} files",
156 anchor.plans_used, anchor.spec_chars_p90, anchor.file_mentions_p90
157 )
158 };
159 let mut out = format!("Context-fit check ({provenance}):");
160 for warning in warnings {
161 out.push_str(&format!(
162 "\n- **{}** ({}) — {}",
163 warning.feature_id,
164 warning.title,
165 warning.reasons.join("; ")
166 ));
167 }
168 out
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::types::{PlanFeature, PlanMilestone};
175
176 fn plan_with(features: Vec<PlanFeature>) -> Plan {
177 Plan {
178 goal: "g".into(),
179 validation_contract: vec![],
180 milestones: vec![PlanMilestone {
181 title: "m".into(),
182 features,
183 }],
184 considered_alternatives: None,
185 command_grants: vec![],
186 touch_set: vec![],
187 standards_manifest: None,
188 reviewer_independence: None,
189 }
190 }
191
192 fn feature(title: &str, spec: &str) -> PlanFeature {
193 PlanFeature {
194 title: title.into(),
195 spec: spec.into(),
196 validation_criteria: vec![],
197 }
198 }
199
200 fn anchor() -> FitAnchor {
201 FitAnchor {
202 spec_chars_p90: 100,
203 file_mentions_p90: 3,
204 plans_used: 4,
205 }
206 }
207
208 #[test]
209 fn right_sized_features_stay_quiet() {
210 let plan = plan_with(vec![feature("small", "add a flag to config.rs")]);
211 assert!(feature_fit_warnings(&plan, &anchor()).is_empty());
212 }
213
214 #[test]
215 fn oversized_features_warn_with_reasons() {
216 let big_spec = format!(
217 "{} touch crates/engine/src/orchestrator.rs and crates/cli/src/commands.rs and docs/a.md docs/b.md",
218 "word ".repeat(40)
219 );
220 let plan = plan_with(vec![
221 feature("small", "add a flag"),
222 feature("huge", &big_spec),
223 ]);
224 let warnings = feature_fit_warnings(&plan, &anchor());
225 assert_eq!(warnings.len(), 1);
226 assert_eq!(warnings[0].feature_id, "f-1-2");
227 assert_eq!(warnings[0].title, "huge");
228 assert!(warnings[0]
229 .reasons
230 .iter()
231 .any(|r| r.contains("chars (corpus p90: 100)")));
232 assert!(warnings[0]
233 .reasons
234 .iter()
235 .any(|r| r.contains("files (corpus p90: 3)")));
236 }
237
238 #[test]
239 fn file_mentions_counts_paths_globs_and_source_extensions() {
240 assert_eq!(count_file_mentions("edit `src/a.rs` and docs/b.md"), 2);
241 assert_eq!(count_file_mentions("crates/engine/**/*.rs"), 1);
242 assert_eq!(count_file_mentions("no files here at all"), 0);
243 assert_eq!(count_file_mentions("src/lib.rs:42 has the fn"), 1);
244 }
245
246 #[test]
247 fn render_fit_note_lists_features_and_provenance() {
248 let warning = FeatureFitWarning {
249 feature_id: "f-1-1".into(),
250 title: "big".into(),
251 spec_chars: 500,
252 file_mentions: 8,
253 reasons: vec!["spec is 500 chars (corpus p90: 100)".into()],
254 };
255 let note = render_fit_note(&[warning], &anchor());
256 assert!(note.contains("corpus p90 over 4 plan(s)"));
257 assert!(note.contains("f-1-1"));
258 assert!(note.contains("big"));
259 }
260}