use crate::paths::MissionPaths;
use crate::types::Plan;
use serde::Serialize;
pub const DEFAULT_SPEC_CHARS_P90: usize = 1_200;
pub const DEFAULT_FILE_MENTIONS_P90: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FitAnchor {
pub spec_chars_p90: usize,
pub file_mentions_p90: usize,
pub plans_used: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FeatureFitWarning {
pub feature_id: String,
pub title: String,
pub spec_chars: usize,
pub file_mentions: usize,
pub reasons: Vec<String>,
}
fn count_file_mentions(spec: &str) -> usize {
const EXTENSIONS: &[&str] = &[
".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".md", ".json", ".toml", ".yml",
".yaml", ".sh", ".mjs",
];
spec.split_whitespace()
.filter(|token| {
let token = token.trim_matches(|c: char| {
matches!(
c,
'`' | '"' | '\'' | '(' | ')' | '[' | ']' | ',' | ';' | ':'
)
});
!token.is_empty()
&& (token.contains('/')
|| token.contains('*')
|| EXTENSIONS.iter().any(|ext| token.ends_with(ext)))
})
.count()
}
fn p90(values: &mut [usize]) -> usize {
if values.is_empty() {
return 0;
}
values.sort_unstable();
let idx = ((values.len() as f64) * 0.9).ceil() as usize;
values[idx.saturating_sub(1).min(values.len() - 1)]
}
pub fn corpus_fit_anchor(repo_root: &std::path::Path) -> FitAnchor {
let mut spec_chars = Vec::new();
let mut file_mentions = Vec::new();
let mut plans_used = 0usize;
for mission_id in MissionPaths::list_missions(repo_root) {
let plan_path = MissionPaths::new(repo_root, &mission_id).plan_file();
let Ok(text) = std::fs::read_to_string(&plan_path) else {
continue;
};
let Ok(plan) = serde_json::from_str::<Plan>(&text) else {
continue;
};
plans_used += 1;
for milestone in &plan.milestones {
for feature in &milestone.features {
spec_chars.push(feature.spec.chars().count());
file_mentions.push(count_file_mentions(&feature.spec));
}
}
}
if plans_used == 0 {
return FitAnchor {
spec_chars_p90: DEFAULT_SPEC_CHARS_P90,
file_mentions_p90: DEFAULT_FILE_MENTIONS_P90,
plans_used: 0,
};
}
FitAnchor {
spec_chars_p90: p90(&mut spec_chars).max(200),
file_mentions_p90: p90(&mut file_mentions).max(2),
plans_used,
}
}
pub fn feature_fit_warnings(plan: &Plan, anchor: &FitAnchor) -> Vec<FeatureFitWarning> {
let mut warnings = Vec::new();
for (mi, milestone) in plan.milestones.iter().enumerate() {
for (fi, feature) in milestone.features.iter().enumerate() {
let chars = feature.spec.chars().count();
let mentions = count_file_mentions(&feature.spec);
let mut reasons = Vec::new();
if chars > anchor.spec_chars_p90 {
reasons.push(format!(
"spec is {chars} chars (corpus p90: {})",
anchor.spec_chars_p90
));
}
if mentions > anchor.file_mentions_p90 {
reasons.push(format!(
"spec points at {mentions} files (corpus p90: {})",
anchor.file_mentions_p90
));
}
if !reasons.is_empty() {
warnings.push(FeatureFitWarning {
feature_id: format!("f-{}-{}", mi + 1, fi + 1),
title: feature.title.clone(),
spec_chars: chars,
file_mentions: mentions,
reasons,
});
}
}
}
warnings
}
pub fn render_fit_note(warnings: &[FeatureFitWarning], anchor: &FitAnchor) -> String {
let provenance = if anchor.plans_used == 0 {
format!(
"defaults (no plan history; p90 ≈ {} chars / {} files)",
anchor.spec_chars_p90, anchor.file_mentions_p90
)
} else {
format!(
"corpus p90 over {} plan(s): {} chars / {} files",
anchor.plans_used, anchor.spec_chars_p90, anchor.file_mentions_p90
)
};
let mut out = format!("Context-fit check ({provenance}):");
for warning in warnings {
out.push_str(&format!(
"\n- **{}** ({}) — {}",
warning.feature_id,
warning.title,
warning.reasons.join("; ")
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{PlanFeature, PlanMilestone};
fn plan_with(features: Vec<PlanFeature>) -> Plan {
Plan {
goal: "g".into(),
validation_contract: vec![],
milestones: vec![PlanMilestone {
title: "m".into(),
features,
}],
considered_alternatives: None,
command_grants: vec![],
touch_set: vec![],
standards_manifest: None,
reviewer_independence: None,
}
}
fn feature(title: &str, spec: &str) -> PlanFeature {
PlanFeature {
title: title.into(),
spec: spec.into(),
validation_criteria: vec![],
}
}
fn anchor() -> FitAnchor {
FitAnchor {
spec_chars_p90: 100,
file_mentions_p90: 3,
plans_used: 4,
}
}
#[test]
fn right_sized_features_stay_quiet() {
let plan = plan_with(vec![feature("small", "add a flag to config.rs")]);
assert!(feature_fit_warnings(&plan, &anchor()).is_empty());
}
#[test]
fn oversized_features_warn_with_reasons() {
let big_spec = format!(
"{} touch crates/engine/src/orchestrator.rs and crates/cli/src/commands.rs and docs/a.md docs/b.md",
"word ".repeat(40)
);
let plan = plan_with(vec![
feature("small", "add a flag"),
feature("huge", &big_spec),
]);
let warnings = feature_fit_warnings(&plan, &anchor());
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].feature_id, "f-1-2");
assert_eq!(warnings[0].title, "huge");
assert!(warnings[0]
.reasons
.iter()
.any(|r| r.contains("chars (corpus p90: 100)")));
assert!(warnings[0]
.reasons
.iter()
.any(|r| r.contains("files (corpus p90: 3)")));
}
#[test]
fn file_mentions_counts_paths_globs_and_source_extensions() {
assert_eq!(count_file_mentions("edit `src/a.rs` and docs/b.md"), 2);
assert_eq!(count_file_mentions("crates/engine/**/*.rs"), 1);
assert_eq!(count_file_mentions("no files here at all"), 0);
assert_eq!(count_file_mentions("src/lib.rs:42 has the fn"), 1);
}
#[test]
fn render_fit_note_lists_features_and_provenance() {
let warning = FeatureFitWarning {
feature_id: "f-1-1".into(),
title: "big".into(),
spec_chars: 500,
file_mentions: 8,
reasons: vec!["spec is 500 chars (corpus p90: 100)".into()],
};
let note = render_fit_note(&[warning], &anchor());
assert!(note.contains("corpus p90 over 4 plan(s)"));
assert!(note.contains("f-1-1"));
assert!(note.contains("big"));
}
}