Skip to content

Commit 18bc194

Browse files
corvid-agentclaude
andauthored
fix: high-priority DX improvements from dogfood feedback (#168)
* 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> * fix: apply cargo fmt formatting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add stub detection, source-attributed export warnings, and requirements companion validation (#162, #165, #163) - Detect stub/placeholder sections (TBD, N/A, TODO, Coming soon, etc.) that previously inflated content depth scores to undeserved A grades - Show source file attribution for undocumented export warnings (e.g. "Undocumented export 'foo' from src/bar.ts") - Warn when requirements appear inline in spec body instead of companion requirements.md, and when requirements.md is missing - Shared stub detection in parser.rs used by both scoring and validator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: apply cargo fmt formatting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version to v3.5.0 and update CHANGELOG Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a51e0c2 commit 18bc194

12 files changed

Lines changed: 717 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [3.5.0] - 2026-04-08
11+
12+
### Added
13+
14+
- **Stub/placeholder detection** — sections containing only "TBD", "N/A", "TODO", "Coming soon", or similar placeholders are now flagged as warnings and no longer inflate quality scores (#162).
15+
- **Source-attributed export warnings** — undocumented export warnings now show which source file the export comes from, making them actionable in large codebases (#165).
16+
- **Requirements companion validation** — warns when specs contain inline requirements sections (should be in `requirements.md`) and when companion files are missing (#163).
17+
- **Score diagnostics**`specsync score` now shows per-category breakdowns (completeness, structure, cross-references) with actionable improvement suggestions (#167).
18+
19+
### Fixed
20+
21+
- **Header matching flexibility** — fuzzy matching for common header variations like "Public API" → "Exports", "Tech Stack" → "Dependencies", reducing false negatives (#166).
22+
- **Frontmatter parser edge cases** — correctly handles tabs, trailing whitespace, and inline YAML comments in spec frontmatter (#161).
23+
- **`--fix` header renaming** — near-miss headers are now renamed in-place instead of duplicating the section (#164).
24+
1025
## [3.4.1] - 2026-04-07
1126

1227
### Fixed
@@ -290,6 +305,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
290305
phantom documentation for non-existent exports (errors).
291306
- Dependency spec cross-referencing and Consumed By section validation.
292307

308+
[3.4.1]: https://github.com/CorvidLabs/spec-sync/releases/tag/v3.4.1
309+
[3.5.0]: https://github.com/CorvidLabs/spec-sync/releases/tag/v3.5.0
293310
[3.4.1]: https://github.com/CorvidLabs/spec-sync/releases/tag/v3.4.1
294311
[3.4.0]: https://github.com/CorvidLabs/spec-sync/releases/tag/v3.4.0
295312
[3.1.0]: https://github.com/CorvidLabs/spec-sync/releases/tag/v3.1.0

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "specsync"
3-
version = "3.4.1"
3+
version = "3.5.0"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "Bidirectional spec-to-code validation with schema column checking — 11 languages, single binary"

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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,80 @@ 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+
&[
400+
"exportd function",
401+
"exportd func",
402+
"exproted function",
403+
"expported function",
404+
],
405+
"Exported Functions",
406+
),
407+
(
408+
&["exportd type", "exproted type", "expported type"],
409+
"Exported Types",
410+
),
411+
(&["exportd class", "exproted class"], "Exported Classes"),
412+
(
413+
&["exportd constant", "exportd const", "exproted constant"],
414+
"Exported Constants",
415+
),
416+
];
417+
418+
let mut new_section = api_section.clone();
419+
for cap in re.captures_iter(&api_section) {
420+
let header_text = cap.get(2).unwrap().as_str();
421+
let lower = header_text.to_ascii_lowercase();
422+
423+
// Skip headers that already match via is_export_header
424+
if crate::parser::is_export_header(&format!("### {header_text}")) {
425+
continue;
426+
}
427+
428+
// Check for near-miss (Levenshtein distance ≤ 2 from any canonical)
429+
for (patterns, canonical) in canonical_map {
430+
for pattern in *patterns {
431+
if lower.contains(pattern) {
432+
let old = format!("### {header_text}");
433+
let new = format!("### {canonical}");
434+
new_section = new_section.replacen(&old, &new, 1);
435+
modified = true;
436+
break;
437+
}
438+
}
439+
}
440+
}
441+
442+
if modified {
443+
content.replace_range(api_start..api_end, &new_section);
444+
}
445+
446+
modified
447+
}
448+
375449
fn auto_fix_specs(root: &Path, spec_files: &[PathBuf], config: &types::SpecSyncConfig) -> usize {
376450
use crate::exports::get_exported_symbols_with_level;
377451
use crate::parser::{get_spec_symbols, parse_frontmatter};
@@ -384,6 +458,17 @@ fn auto_fix_specs(root: &Path, spec_files: &[PathBuf], config: &types::SpecSyncC
384458
Err(_) => continue,
385459
};
386460

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

src/commands/mod.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ pub fn run_validation(
202202
let undocumented: Vec<&str> = result
203203
.warnings
204204
.iter()
205-
.filter(|w| w.starts_with("Export '"))
205+
.filter(|w| w.starts_with("Export '") || w.starts_with("Undocumented export '"))
206206
.map(|s| s.as_str())
207207
.collect();
208208
for w in &undocumented {
@@ -233,8 +233,26 @@ pub fn run_validation(
233233
println!(" {} {w}", "⚠".yellow());
234234
}
235235

236-
// Show fix suggestions when there are errors
237-
if !result.fixes.is_empty() && !result.errors.is_empty() {
236+
// Stub section warnings
237+
for w in result
238+
.warnings
239+
.iter()
240+
.filter(|w| w.starts_with("Section ##") && w.contains("stub"))
241+
{
242+
println!(" {} {w}", "⚠".yellow());
243+
}
244+
245+
// Requirements companion file warnings
246+
for w in result
247+
.warnings
248+
.iter()
249+
.filter(|w| w.contains("requirements"))
250+
{
251+
println!(" {} {w}", "⚠".yellow());
252+
}
253+
254+
// Show fix suggestions when there are errors or warnings with fixes
255+
if !result.fixes.is_empty() && (!result.errors.is_empty() || !result.warnings.is_empty()) {
238256
println!(" {}", "Suggested fixes:".cyan());
239257
for fix in &result.fixes {
240258
println!(" {} {fix}", "->".cyan());

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),

0 commit comments

Comments
 (0)