Skip to content

Commit 751f7ff

Browse files
corvid-agentclaude
andcommitted
fix: improve header matching, score diagnostics, and frontmatter parsing (#166, #167, #161, #164)
- Accept common header variations in Public API (Exports, Public, Export) instead of requiring exact "Exported" keyword (#166) - Score suggestions now show point impact per category (e.g., "Sections (-6pts): missing ## Invariants, Error Cases") for clear diagnosis (#167) - Add --explain flag to score command for color-coded subscore breakdown - Strip inline YAML comments from frontmatter values (#161) - Handle tab indentation and trailing whitespace in frontmatter - --fix now renames near-miss headers to canonical form before adding exports (#164) Closes #166, closes #167, closes #161, closes #164 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e55718c commit 751f7ff

6 files changed

Lines changed: 305 additions & 34 deletions

File tree

src/cli.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ pub enum Command {
6868
/// Create a specsync.json config file
6969
Init,
7070
/// Score spec quality (0-100) with letter grades and improvement suggestions
71-
Score,
71+
Score {
72+
/// Show detailed per-category breakdown explaining exactly why each spec lost points
73+
#[arg(long)]
74+
explain: bool,
75+
},
7276
/// Watch spec and source files, re-running check on changes
7377
Watch,
7478
/// Run as an MCP (Model Context Protocol) server over stdio

src/commands/check.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,78 @@ fn auto_regen_stale_specs(
372372

373373
// ─── Auto-fix: add undocumented exports to spec ─────────────────────────
374374

375+
/// Normalize near-miss export headers within ## Public API.
376+
/// E.g., "### Exportd Functions" → "### Exported Functions"
377+
/// Returns true if the content was modified.
378+
fn fix_near_miss_headers(content: &mut String) -> bool {
379+
use regex::Regex;
380+
let re = Regex::new(r"(?m)^(### )(.+)$").unwrap();
381+
382+
// Find the Public API section bounds
383+
let api_start = match content.find("## Public API") {
384+
Some(pos) => pos,
385+
None => return false,
386+
};
387+
let after = &content[api_start..];
388+
let api_end = after[1..]
389+
.find("\n## ")
390+
.map(|p| api_start + 1 + p)
391+
.unwrap_or(content.len());
392+
393+
let api_section = content[api_start..api_end].to_string();
394+
let mut modified = false;
395+
396+
// Known canonical headers and their near-miss patterns
397+
let canonical_map: &[(&[&str], &str)] = &[
398+
(
399+
&["exportd function", "exportd func", "exproted function", "expported function"],
400+
"Exported Functions",
401+
),
402+
(
403+
&["exportd type", "exproted type", "expported type"],
404+
"Exported Types",
405+
),
406+
(
407+
&["exportd class", "exproted class"],
408+
"Exported Classes",
409+
),
410+
(
411+
&["exportd constant", "exportd const", "exproted constant"],
412+
"Exported Constants",
413+
),
414+
];
415+
416+
let mut new_section = api_section.clone();
417+
for cap in re.captures_iter(&api_section) {
418+
let header_text = cap.get(2).unwrap().as_str();
419+
let lower = header_text.to_ascii_lowercase();
420+
421+
// Skip headers that already match via is_export_header
422+
if crate::parser::is_export_header(&format!("### {header_text}")) {
423+
continue;
424+
}
425+
426+
// Check for near-miss (Levenshtein distance ≤ 2 from any canonical)
427+
for (patterns, canonical) in canonical_map {
428+
for pattern in *patterns {
429+
if lower.contains(pattern) {
430+
let old = format!("### {header_text}");
431+
let new = format!("### {canonical}");
432+
new_section = new_section.replacen(&old, &new, 1);
433+
modified = true;
434+
break;
435+
}
436+
}
437+
}
438+
}
439+
440+
if modified {
441+
content.replace_range(api_start..api_end, &new_section);
442+
}
443+
444+
modified
445+
}
446+
375447
fn auto_fix_specs(root: &Path, spec_files: &[PathBuf], config: &types::SpecSyncConfig) -> usize {
376448
use crate::exports::get_exported_symbols_with_level;
377449
use crate::parser::{get_spec_symbols, parse_frontmatter};
@@ -384,6 +456,17 @@ fn auto_fix_specs(root: &Path, spec_files: &[PathBuf], config: &types::SpecSyncC
384456
Err(_) => continue,
385457
};
386458

459+
// First pass: fix near-miss headers
460+
let mut content = content;
461+
if fix_near_miss_headers(&mut content) {
462+
let rel = spec_file.strip_prefix(root).unwrap_or(spec_file).display();
463+
println!(
464+
" {} {rel}: renamed near-miss header(s) to canonical form",
465+
"✓".green()
466+
);
467+
let _ = fs::write(spec_file, &content);
468+
}
469+
387470
let parsed = match parse_frontmatter(&content) {
388471
Some(p) => p,
389472
None => continue,

src/commands/score.rs

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::types;
66

77
use super::load_and_discover;
88

9-
pub fn cmd_score(root: &Path, format: types::OutputFormat) {
9+
pub fn cmd_score(root: &Path, format: types::OutputFormat, explain: bool) {
1010
let json = matches!(format, types::OutputFormat::Json);
1111
let (config, spec_files) = load_and_discover(root, false);
1212
let scores: Vec<scoring::SpecScore> = spec_files
@@ -70,10 +70,33 @@ pub fn cmd_score(root: &Path, format: types::OutputFormat) {
7070
grade_colored,
7171
s.total
7272
);
73-
println!(
74-
" Frontmatter: {}/20 Sections: {}/20 API: {}/20 Depth: {}/20 Fresh: {}/20",
75-
s.frontmatter_score, s.sections_score, s.api_score, s.depth_score, s.freshness_score
76-
);
73+
74+
if explain {
75+
// Show color-coded per-category bars
76+
println!(
77+
" {} {}/20 {} {}/20 {} {}/20 {} {}/20 {} {}/20",
78+
"Frontmatter:".dimmed(),
79+
colorize_subscore(s.frontmatter_score),
80+
"Sections:".dimmed(),
81+
colorize_subscore(s.sections_score),
82+
"API:".dimmed(),
83+
colorize_subscore(s.api_score),
84+
"Depth:".dimmed(),
85+
colorize_subscore(s.depth_score),
86+
"Fresh:".dimmed(),
87+
colorize_subscore(s.freshness_score),
88+
);
89+
} else {
90+
println!(
91+
" Frontmatter: {}/20 Sections: {}/20 API: {}/20 Depth: {}/20 Fresh: {}/20",
92+
s.frontmatter_score,
93+
s.sections_score,
94+
s.api_score,
95+
s.depth_score,
96+
s.freshness_score
97+
);
98+
}
99+
77100
if !s.suggestions.is_empty() {
78101
for suggestion in &s.suggestions {
79102
println!(" {} {suggestion}", "->".cyan());
@@ -103,3 +126,13 @@ pub fn cmd_score(root: &Path, format: types::OutputFormat) {
103126
project.grade_distribution[4]
104127
);
105128
}
129+
130+
/// Colorize a subscore (out of 20) — green for 20, yellow for 10-19, red for <10.
131+
fn colorize_subscore(score: u32) -> String {
132+
let s = score.to_string();
133+
match score {
134+
20 => s.green().to_string(),
135+
10..=19 => s.yellow().to_string(),
136+
_ => s.red().to_string(),
137+
}
138+
}

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ fn run() {
104104
format,
105105
provider,
106106
),
107-
Command::Score => commands::score::cmd_score(&root, format),
107+
Command::Score { explain } => commands::score::cmd_score(&root, format, explain),
108108
Command::Watch => watch::run_watch(&root, cli.strict, cli.require_coverage),
109109
Command::Mcp => mcp::run_mcp_server(&root),
110110
Command::AddSpec { name } => commands::scaffold::cmd_add_spec(&root, &name),

src/parser.rs

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ pub fn parse_frontmatter(content: &str) -> Option<ParsedSpec> {
2424
let mut current_list: Vec<String> = Vec::new();
2525

2626
for line in yaml_block.lines() {
27-
// List item: " - value"
27+
// List item: " - value" (supports spaces or tabs for indentation)
2828
if let Some(stripped) = line.trim_start().strip_prefix("- ")
2929
&& current_key.is_some()
3030
{
31-
current_list.push(stripped.trim().to_string());
31+
current_list.push(strip_yaml_comment(stripped.trim()));
3232
continue;
3333
}
3434

@@ -45,13 +45,13 @@ pub fn parse_frontmatter(content: &str) -> Option<ParsedSpec> {
4545
current_list.clear();
4646
}
4747

48-
let value = line[colon_pos + 1..].trim();
48+
let value = strip_yaml_comment(line[colon_pos + 1..].trim());
4949

5050
if value.is_empty() || value == "[]" {
5151
current_key = Some(key.to_string());
5252
current_list.clear();
5353
} else {
54-
set_scalar(&mut fm, key, value);
54+
set_scalar(&mut fm, key, &value);
5555
}
5656
continue;
5757
}
@@ -77,6 +77,25 @@ pub fn parse_frontmatter(content: &str) -> Option<ParsedSpec> {
7777
})
7878
}
7979

80+
/// Strip inline YAML comments from a value.
81+
/// Handles: `value # comment` → `value`
82+
/// Preserves: `value` (no comment), quoted strings with `#` inside.
83+
fn strip_yaml_comment(value: &str) -> String {
84+
// Don't strip from quoted strings or bracket arrays
85+
if value.starts_with('"') || value.starts_with('\'') || value.starts_with('[') {
86+
return value.to_string();
87+
}
88+
// Find ` # ` pattern (space-hash-space) which is a YAML comment
89+
if let Some(pos) = value.find(" #") {
90+
// Verify the # is followed by a space or is at end of string (YAML comment convention)
91+
let after = &value[pos + 2..];
92+
if after.is_empty() || after.starts_with(' ') {
93+
return value[..pos].trim_end().to_string();
94+
}
95+
}
96+
value.to_string()
97+
}
98+
8099
fn set_scalar(fm: &mut Frontmatter, key: &str, value: &str) {
81100
match key {
82101
"module" => fm.module = Some(value.to_string()),
@@ -123,6 +142,22 @@ fn set_field(fm: &mut Frontmatter, key: &str, values: &[String]) {
123142
}
124143
}
125144

145+
/// Check if a ### header describes exported symbols (case-insensitive).
146+
/// Matches headers containing "Exported", "Exports", "Export", or "Public" as keywords.
147+
/// Examples that match:
148+
/// "### Exported Functions", "### TypeScript Exports", "### Exports",
149+
/// "### Public Types", "### Export Functions", "### Exported Symbols"
150+
/// Examples that do NOT match:
151+
/// "### API Endpoints", "### Component API", "### Configuration",
152+
/// "### Internal Functions", "### Route Handlers"
153+
pub fn is_export_header(header: &str) -> bool {
154+
let lower = header.to_ascii_lowercase();
155+
lower.contains("exported")
156+
|| lower.contains("exports")
157+
|| lower.contains("export ")
158+
|| lower.contains("public ")
159+
}
160+
126161
static TABLE_ROW_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\|\s*`(\w+)`").unwrap());
127162

128163
static METHOD_HEADER_RE: LazyLock<Regex> =
@@ -179,12 +214,16 @@ pub fn get_spec_symbols(body: &str) -> Vec<String> {
179214
.find(|l| !l.is_empty())
180215
.unwrap_or("");
181216

182-
// Allowlist: only validate tables under ### headers containing "Exported"
183-
// (e.g., "### Exported Functions", "### Exported Types").
217+
// Allowlist: only validate tables under ### headers that describe exports.
218+
// Accepted patterns (case-insensitive):
219+
// - "### Exported Functions", "### Exported Types" (contains "Exported")
220+
// - "### TypeScript Exports", "### Exports" (contains "Exports")
221+
// - "### Public Functions", "### Public Types" (contains "Public")
222+
// - "### Exported Symbols", "### Export Types" (contains "Export")
184223
// Tables directly under ## Public API (no ### header) are also validated.
185224
// Everything else (### API Endpoints, ### Component API, ### Route Handlers,
186225
// ### Configuration, ### Internal Functions, etc.) is informational only.
187-
if header.starts_with("### ") && !header.contains("Exported") {
226+
if header.starts_with("### ") && !is_export_header(header) {
188227
continue;
189228
}
190229

@@ -246,6 +285,42 @@ mod tests {
246285
assert!(parsed.frontmatter.db_tables.is_empty());
247286
}
248287

288+
#[test]
289+
fn test_strip_yaml_comment() {
290+
assert_eq!(strip_yaml_comment("active"), "active");
291+
assert_eq!(strip_yaml_comment("active # this is the status"), "active");
292+
assert_eq!(strip_yaml_comment("value #no-space-means-not-comment"), "value #no-space-means-not-comment");
293+
assert_eq!(strip_yaml_comment("[42, 57] # issue list"), "[42, 57] # issue list"); // brackets preserved
294+
assert_eq!(strip_yaml_comment("\"quoted # value\""), "\"quoted # value\""); // quotes preserved
295+
assert_eq!(strip_yaml_comment("value #"), "value");
296+
}
297+
298+
#[test]
299+
fn test_parse_frontmatter_inline_comments() {
300+
let content = "---\nmodule: auth # the auth module\nversion: 1 # initial\nstatus: active # current status\nfiles:\n - src/auth.ts # main file\n---\n\n# Auth\n";
301+
let parsed = parse_frontmatter(content).unwrap();
302+
assert_eq!(parsed.frontmatter.module.as_deref(), Some("auth"));
303+
assert_eq!(parsed.frontmatter.version.as_deref(), Some("1"));
304+
assert_eq!(parsed.frontmatter.status.as_deref(), Some("active"));
305+
assert_eq!(parsed.frontmatter.files, vec!["src/auth.ts"]);
306+
}
307+
308+
#[test]
309+
fn test_parse_frontmatter_tabs_and_whitespace() {
310+
// Tabs used for indentation instead of spaces
311+
let content = "---\nmodule: auth\nversion: 1\nstatus: active\nfiles:\n\t- src/auth.ts\n\t- src/auth.utils.ts\n---\n\n# Auth\n";
312+
let parsed = parse_frontmatter(content).unwrap();
313+
assert_eq!(parsed.frontmatter.files, vec!["src/auth.ts", "src/auth.utils.ts"]);
314+
}
315+
316+
#[test]
317+
fn test_parse_frontmatter_trailing_spaces() {
318+
let content = "---\nmodule: auth \nversion: 1 \nstatus: active \nfiles:\n - src/auth.ts \n---\n\n# Auth\n";
319+
let parsed = parse_frontmatter(content).unwrap();
320+
assert_eq!(parsed.frontmatter.module.as_deref(), Some("auth"));
321+
assert_eq!(parsed.frontmatter.files, vec!["src/auth.ts"]);
322+
}
323+
249324
#[test]
250325
fn test_parse_frontmatter_missing() {
251326
let content = "# No frontmatter here\n\nJust markdown.";
@@ -368,6 +443,57 @@ Something
368443
assert!(parsed.frontmatter.tracks.is_empty());
369444
}
370445

446+
#[test]
447+
fn test_is_export_header() {
448+
// Should match
449+
assert!(is_export_header("### Exported Functions"));
450+
assert!(is_export_header("### Exported Types"));
451+
assert!(is_export_header("### TypeScript Exports"));
452+
assert!(is_export_header("### Exports"));
453+
assert!(is_export_header("### Public Functions"));
454+
assert!(is_export_header("### Public Types"));
455+
assert!(is_export_header("### Export Types"));
456+
assert!(is_export_header("### Exported Symbols"));
457+
assert!(is_export_header("### exported functions")); // case-insensitive
458+
459+
// Should NOT match
460+
assert!(!is_export_header("### API Endpoints"));
461+
assert!(!is_export_header("### Component API"));
462+
assert!(!is_export_header("### Route Handlers"));
463+
assert!(!is_export_header("### Configuration"));
464+
assert!(!is_export_header("### Internal Functions"));
465+
}
466+
467+
#[test]
468+
fn test_get_spec_symbols_accepts_header_variations() {
469+
let body = r#"## Public API
470+
471+
### TypeScript Exports
472+
473+
| Function | Description |
474+
|----------|-------------|
475+
| `createAuth` | Creates auth |
476+
| `validateToken` | Validates |
477+
478+
### Public Types
479+
480+
| Type | Description |
481+
|------|-------------|
482+
| `AuthConfig` | Config type |
483+
484+
### API Endpoints
485+
486+
| Endpoint | Method |
487+
|----------|--------|
488+
| `/login` | POST |
489+
490+
## Invariants
491+
"#;
492+
let symbols = get_spec_symbols(body);
493+
// Should extract from "TypeScript Exports" and "Public Types" but not "API Endpoints"
494+
assert_eq!(symbols, vec!["createAuth", "validateToken", "AuthConfig"]);
495+
}
496+
371497
#[test]
372498
fn test_get_spec_symbols_top_level_table() {
373499
// Tables directly under ## Public API (no ### header) should be validated

0 commit comments

Comments
 (0)