Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **`specsync new`/`scaffold` auto-detect the source in single-source-file projects** — when no directory or file matches the module name but the project has exactly one non-test source file (the README quickstart's fresh cargo crate with only `src/lib.rs`), that file is used, so `new greeter` produces a spec with real `files:` and pre-populated exports instead of an empty `files: []` that immediately fails validation. When nothing can be detected, `new` prints a warning explaining that the `files:` list must be filled in instead of silently writing a spec that fails `check`.
- **`check --fix` is never a silent no-op** — `--fix` now bypasses the hash cache's unchanged-skip (like `--strict` and `--force` already did). Previously a spec that failed `check --strict` was still recorded in the cache, so a follow-up `check --fix` printed "All specs unchanged", fixed nothing, and exited 0.
- **`check --fix` no longer appends a duplicate export table for symbols a human already documented** — bare API-kind headings under `## Public API` (`### Functions`, `### Methods`, `### Types`, …) are promoted to `### Exported <Kind>` during `--fix` so the existing rows become the recognized export table, and `--fix` skips any symbol that already appears in *any* table within the Public API section.
- **Warning count matches the warnings shown** — the partial "N/M exports documented" summary line is counted as a warning, so it now prints with ⚠ instead of a green ✓ (previously the summary could say "2 warning(s)" while only one ⚠ line was visible).
- **Fresh `specsync init` now creates the v4 layout** — init writes `.specsync/config.toml`, a `4.0.0` version stamp, `.specsync/.gitignore`, and the `lifecycle/`/`changes/`/`archive/` directories (what `specsync migrate` produces) instead of a legacy root-level `specsync.json`, so a brand-new project no longer sees the "Legacy 3.x layout detected" migration nag on its first `check`.
- **`init-registry` respects the v4 layout** — the registry is written to `.specsync/registry.toml` in v4 projects instead of recreating a root-level `specsync-registry.toml` (which re-triggered the legacy nag after migration). Un-migrated 3.x projects keep the legacy path. `load_registry`/`register_module` now resolve the same location via the new `registry::local_registry_path`.
- **Draft specs no longer pass validation silently** — when a draft skips section/export checks (by design), `check` now prints explicit "Section validation skipped (status: draft)" / "Export validation skipped (status: draft)" notices instead of misleading "✓ All required sections present" lines, plus a summary hint: "N draft spec(s) skipped section and export validation — set `status: active` to enable full checks".
Expand Down
8 changes: 5 additions & 3 deletions specs/cmd_check/cmd_check.spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
module: cmd_check
version: 3
version: 4
status: stable
files:
- src/commands/check.rs
Expand Down Expand Up @@ -36,8 +36,9 @@ Implements the `specsync check` command — the primary validation entry point.
## Invariants

1. When `--fix` is passed, auto-fix runs in two phases: (a) add undocumented exports to spec markdown tables with generated review prompts — type exports are routed to the "… Types" table and functions/values to the "… Functions"/"… Methods" table (falling back to the last export subsection), with rows padded to the target table's column count, (b) AI-regenerate specs whose requirements have drifted
2. Near-miss header correction (e.g., "Exported Functions" → "### Exported Functions") runs as part of auto-fix
3. Hash cache is consulted before validation unless `--force` is set — unchanged specs are skipped
2. Near-miss header correction runs as part of auto-fix — Levenshtein-close typos are renamed to canonical export headers, and bare API-kind headings under `## Public API` (e.g. `### Functions`, `### Methods`, `### Types`) are promoted to `### Exported <Kind>` so hand-written tables become the export table instead of being duplicated
3. Hash cache is consulted before validation unless `--force`, `--strict`, `--fix`, or a spec filter is set — an explicit `--fix` is never silently skipped because a previous failing/warning run recorded the hashes
3a. `--fix` never adds a symbol that already appears in any table within `## Public API` (including informational subsections)
4. After auto-fix, validation is re-run to verify fixes resolved the issues
5. JSON output mode collects all errors/warnings into a structured object instead of printing inline
6. `--create-issues` groups errors by spec path and creates one GitHub issue per affected spec
Expand Down Expand Up @@ -100,6 +101,7 @@ Implements the `specsync check` command — the primary validation entry point.

| Date | Change |
|------|--------|
| 2026-06-11 | v4: `--fix` bypasses the hash cache (no more silent no-op after a cached warning run); bare API-kind headings are promoted to export headers and symbols already documented in any Public API table are not re-added; partial export-coverage summary prints as ⚠ so the warning count matches printed warnings |
| 2026-06-11 | v3: `--fix` routes exports to the matching table by kind; unmatched spec filters exit 1 without contradictory output |
| 2026-06-07 | Document generated review prompts for `--fix` export rows |
| 2026-04-09 | Initial spec |
7 changes: 4 additions & 3 deletions specs/cmd_new/cmd_new.spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
module: cmd_new
version: 2
version: 3
status: stable
files:
- src/commands/new.rs
Expand Down Expand Up @@ -28,7 +28,7 @@ Implements the `specsync new` command. Quick-creates a minimal spec with auto-de

## Invariants

1. Auto-detects source files by scanning source dirs for module name matches
1. Auto-detects source files by scanning source dirs for module name matches; when nothing matches and the project has exactly one non-test source file (e.g. only `src/lib.rs`), that file is used as the module's source
2. Extracts exports to pre-populate Public API tables
3. `--full` generates companion files (tasks.md, context.md, requirements.md, testing.md) via `generator::generate_companion_files_for_spec()`; design.md is included only when `companions.design` is enabled in config
4. Includes custom `chrono_lite_today()` for dates without chrono dependency
Expand All @@ -53,7 +53,7 @@ Implements the `specsync new` command. Quick-creates a minimal spec with auto-de
| Condition | Behavior |
|-----------|----------|
| Spec already exists | Exits 1 |
| No source files found | Creates spec with empty `files:` |
| No source files found | Creates spec with empty `files:` and prints a ⚠ explaining that the `files:` list must be filled in before `check` passes |
| Dir creation fails | Exits 1 |

## Dependencies
Expand All @@ -76,6 +76,7 @@ Implements the `specsync new` command. Quick-creates a minimal spec with auto-de

| Date | Change |
|------|--------|
| 2026-06-11 | Fall back to the project's single source file when no name match exists (README quickstart flow); warn instead of silently writing an empty `files:` list |
| 2026-06-07 | Replace unfinished-marker generated rows with review prompts |
| 2026-04-09 | Initial spec |
| 2026-04-13 | Document testing.md and conditional design.md in companion generation |
5 changes: 3 additions & 2 deletions specs/cmd_scaffold/cmd_scaffold.spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
module: cmd_scaffold
version: 2
version: 3
status: stable
files:
- src/commands/scaffold.rs
Expand Down Expand Up @@ -30,7 +30,7 @@ Implements `specsync add-spec` and `specsync scaffold` commands. Creates new spe

## Invariants

1. Both scan source dirs for module name matches
1. Both scan source dirs for module name matches; `cmd_scaffold` falls back to the project's single non-test source file (e.g. only `src/lib.rs`) when no name match exists
2. `cmd_scaffold` supports custom templates and auto-appends to registry
3. Neither overwrites existing specs
4. Companion files (tasks.md, context.md, requirements.md, testing.md) are always generated with guided starter content; design.md is generated only when `companions.design` is enabled in config
Expand Down Expand Up @@ -72,6 +72,7 @@ Implements `specsync add-spec` and `specsync scaffold` commands. Creates new spe

| Date | Change |
|------|--------|
| 2026-06-11 | `cmd_scaffold` falls back to the project's single source file when no module name match exists |
| 2026-06-07 | Document guided starter content in generated companions |
| 2026-04-09 | Initial spec |
| 2026-04-13 | Document companions.design flag for conditional design.md generation |
3 changes: 2 additions & 1 deletion specs/commands/commands.spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
module: commands
version: 2
version: 3
status: stable
files:
- src/commands/mod.rs
Expand Down Expand Up @@ -143,6 +143,7 @@ Shared command infrastructure used by all CLI subcommands. Provides config loadi

| Date | Change |
|------|--------|
| 2026-06-11 | v3: Partial export-coverage summary ("N/M exports documented") prints as ⚠ — it is counted as a warning, so the summary's warning count now matches the printed ⚠ lines |
| 2026-06-11 | v2: Draft specs report skipped section/export validation explicitly; failing frontmatter renders a negated label |
| 2026-04-09 | Initial spec |
| 2026-04-11 | Add lifecycle submodule and filter_by_status function |
4 changes: 3 additions & 1 deletion specs/generator/generator.spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
module: generator
version: 3
version: 4
status: stable
files:
- src/generator.rs
Expand Down Expand Up @@ -28,6 +28,7 @@ Scaffolds spec files and companion files (tasks.md, context.md, requirements.md,
| `generate_specs_for_unspecced_modules_paths` | `root, report, config, provider` | `GenerationOutcome` | Generate specs for all unspecced modules without per-file progress output (JSON/MCP callers), returning the generation outcome |
| `generate_companion_files_for_spec` | `spec_dir, module_name, design_enabled` | `()` | Generate companion files (tasks.md, context.md, requirements.md, testing.md, and design.md if enabled) alongside a spec |
| `find_files_for_module` | `root, module_name, config` | `Vec<String>` | Find source files for a module by checking config definitions, subdirectories, then flat files |
| `find_single_source_fallback` | `root, config` | `Option<String>` | Root-relative path of the project's only non-test source file (e.g. `src/lib.rs`), or `None` when there are zero or multiple candidates — fallback for `new`/`scaffold` when no name match exists |
| `generate_spec` | `module_name, source_files, root, specs_dir` | `String` | Generate a spec from a template (custom or language-aware default) |
| `generate_spec_from_custom_template` | `template_dir, module_name, source_files, root` | `String` | Generate a spec using files from a custom template directory |
| `generate_companion_files_from_template` | `spec_dir, module_name, template_dir, design_enabled` | `()` | Generate companion files from a custom template directory with fallback to defaults; creates design.md only when `design_enabled` is true |
Expand Down Expand Up @@ -118,3 +119,4 @@ Scaffolds spec files and companion files (tasks.md, context.md, requirements.md,
| 2026-04-13 | Fix generate_companion_files_from_template signature to include design_enabled; update scenario for conditional design.md |
| 2026-06-07 | Replace unfinished-marker built-in template content with guided starter content |
| 2026-06-11 | Return `GenerationOutcome` (count, paths, AI errors) from both generation entry points so AI failures surface with a non-zero exit |
| 2026-06-11 | Add `find_single_source_fallback` so `new`/`scaffold` auto-detect the source in single-source-file projects (e.g. a fresh cargo crate with only `src/lib.rs`) |
4 changes: 3 additions & 1 deletion specs/parser/parser.spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
module: parser
version: 1
version: 2
status: stable
files:
- src/parser.rs
Expand Down Expand Up @@ -37,6 +37,7 @@ Parses spec markdown files — extracts YAML frontmatter into structured data, e
| `find_section_offset` | `body: &str, section: &str` | `Option<usize>` | Returns byte offset of the `## Section` heading line, using anchored regex with trailing-whitespace tolerance |
| `body_has_section` | `body: &str, section: &str` | `bool` | Returns true if the spec body contains an exact `## Section` heading (delegates to `find_section_offset`) |
| `get_near_miss_sections` | `body: &str, required_sections: &[String]` | `Vec<(String, String)>` | For each missing required section, returns `(canonical_name, found_heading)` pairs where a `## Heading` exists within Levenshtein distance ≤ 2 — used to detect typos and suggest `--fix` |
| `get_all_api_table_symbols` | `body: &str` | `Vec<String>` | Extract the first backtick-quoted symbol from every table row in `## Public API`, including informational subsections that `get_spec_symbols` skips — used by `check --fix` to avoid appending duplicate rows |

## Invariants

Expand Down Expand Up @@ -101,3 +102,4 @@ Parses spec markdown files — extracts YAML frontmatter into structured data, e
| Date | Change |
|------|--------|
| 2026-03-25 | Initial spec |
| 2026-06-11 | Add `get_all_api_table_symbols` so `check --fix` treats symbols documented under any Public API table (e.g. a bare `### Functions` heading) as already documented |
52 changes: 45 additions & 7 deletions src/commands/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ pub fn cmd_check(
let (specs_to_validate, change_classifications) = if force || strict || !spec_filters.is_empty()
{
(spec_files.clone(), Vec::new())
} else if fix {
// --fix bypasses the unchanged-skip: an explicit fix request must
// never be a silent no-op because a previous (failing or warning) run
// recorded the hashes. Classifications are still computed so
// requirements-drift regeneration keeps working.
let classifications = hash_cache::classify_all_changes(root, &spec_files, &cache);
(spec_files.clone(), classifications)
} else {
let classifications = hash_cache::classify_all_changes(root, &spec_files, &cache);
let changed: Vec<PathBuf> = classifications
Expand Down Expand Up @@ -410,7 +417,9 @@ pub fn cmd_check(
let coverage = compute_coverage(root, &spec_files, &config);

// Update hash cache after validation (only when no errors).
// Specs with warnings are still cached — --strict forces re-validation separately.
// Specs with warnings are still cached, which is why --fix, --strict, and
// --force all bypass the unchanged-skip above — an explicit fix or strict
// run must never trust hashes recorded by a run that had findings.
if total_errors == 0 {
hash_cache::update_cache(root, &specs_to_validate, &mut cache);
let _ = cache.save(root);
Expand Down Expand Up @@ -697,6 +706,32 @@ fn fix_near_miss_headers(content: &mut String) -> bool {
let new = format!("### {canonical}");
new_section = new_section.replacen(&old, &new, 1);
modified = true;
continue;
}

// Bare API-kind headings ("### Functions", "### Methods", "### Types", …)
// describe export tables but fail is_export_header, so their rows are
// informational-only and --fix would append a duplicate export table
// for the same symbols. Promote them to "### Exported <Kind>".
let bare_kinds: &[&str] = &[
"functions",
"methods",
"types",
"classes",
"constants",
"components",
"hooks",
"interfaces",
"enums",
"structs",
"traits",
"protocols",
];
if bare_kinds.contains(&lower.trim()) {
let old = format!("### {header_text}");
let new = format!("### Exported {}", header_text.trim());
new_section = new_section.replacen(&old, &new, 1);
modified = true;
}
}

Expand Down Expand Up @@ -735,7 +770,7 @@ fn auto_fix_specs(
dry_run: bool,
) -> usize {
use crate::exports::get_exported_symbols_full;
use crate::parser::{get_spec_symbols, parse_frontmatter};
use crate::parser::{get_all_api_table_symbols, get_spec_symbols, parse_frontmatter};

let mut fixed_count = 0;
let sub_re = regex::Regex::new(r"(?m)^### ").unwrap();
Expand Down Expand Up @@ -795,14 +830,17 @@ fn auto_fix_specs(
let mut seen = std::collections::HashSet::new();
all_exports.retain(|s| seen.insert(s.clone()));

// Find which exports are already documented
let spec_symbols = get_spec_symbols(&parsed.body);
let spec_set: std::collections::HashSet<&str> =
spec_symbols.iter().map(|s| s.as_str()).collect();
// Find which exports are already documented — in recognized export
// tables AND in any other table within ## Public API. A symbol that a
// human already documented under an informational heading must not be
// appended a second time.
let mut documented: std::collections::HashSet<String> =
get_spec_symbols(&parsed.body).into_iter().collect();
documented.extend(get_all_api_table_symbols(&parsed.body));

let undocumented: Vec<&str> = all_exports
.iter()
.filter(|s| !spec_set.contains(s.as_str()))
.filter(|s| !documented.contains(s.as_str()))
.map(|s| s.as_str())
.collect();

Expand Down
5 changes: 4 additions & 1 deletion src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,10 @@ pub fn run_validation(
"⊘".yellow()
);
} else if let Some(line) = api_line {
println!(" {} {line}", "✓".green());
// The partial-coverage summary is recorded (and counted) as a
// warning — print it as one so the summary's warning count matches
// the number of ⚠ lines shown.
println!(" {} {line}", "⚠".yellow());
} else if let Some(ref summary) = result.export_summary {
println!(" {} {summary}", "✓".green());
}
Expand Down
20 changes: 20 additions & 0 deletions src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ pub fn cmd_new(root: &Path, module_name: &str, full: bool) {

// Auto-detect source files for this module
let source_files = detect_module_sources(root, module_name, &config);
if source_files.is_empty() {
eprintln!(
"{} No source files matched module '{module_name}' — the spec is created with an empty `files:` list.",
"⚠".yellow()
);
eprintln!(
" Add the module's source path(s) to the `files:` list in the spec frontmatter,"
);
eprintln!(
" or define the module in your config — `specsync check` fails on empty `files:`."
);
}
let files_yaml = if source_files.is_empty() {
"files: []\n".to_string()
} else {
Expand Down Expand Up @@ -179,6 +191,14 @@ fn detect_module_sources(
}
}

// Fallback: a single-source-file project (e.g. only src/lib.rs) has exactly
// one possible source — use it even though the name doesn't match.
if files.is_empty()
&& let Some(single) = generator::find_single_source_fallback(root, config)
{
files.push(single);
}

files.sort();
files
}
Expand Down
10 changes: 8 additions & 2 deletions src/commands/scaffold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,14 @@ pub fn cmd_scaffold(
process::exit(1);
}

// Auto-detect source files matching the module name
let module_files = generator::find_files_for_module(root, module_name, &config);
// Auto-detect source files matching the module name; for single-source-file
// projects (e.g. only src/lib.rs) fall back to that file.
let mut module_files = generator::find_files_for_module(root, module_name, &config);
if module_files.is_empty()
&& let Some(single) = generator::find_single_source_fallback(root, &config)
{
module_files.push(single);
}

// Generate spec content
let spec_content = if let Some(ref tpl_dir) = template {
Expand Down
Loading
Loading