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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.2.0] - 2026-03-25

### Added

- **`--fix` flag for `check` command** — automatically adds undocumented exports as stub rows in the spec's Public API table. Creates a `## Public API` section if one doesn't exist. Works with `--json` for structured output of applied fixes. Turns spec maintenance from manual bookkeeping into a one-command operation.
- **`diff` command** — compares current code exports against a git ref (default: `HEAD`) to show what's been added or removed since a given commit. Human-readable by default, `--json` for structured output. Essential for code review and CI drift detection.
- **Wildcard re-export resolution** — TypeScript/JS barrel files using `export * from './module'` now have their re-exported symbols resolved and validated. Namespace re-exports (`export * as Ns from`) are detected as a single namespace export. Resolution is depth-limited to one level to prevent infinite recursion.

### Changed

- Spec quality scoring now accounts for `--fix` generated stubs (scored lower than hand-written descriptions).
- Expanded integration test suite with 12 new tests covering `--fix`, `diff`, and wildcard re-exports (74 total integration tests, 131 total).
- Updated `cli.spec.md` and `exports.spec.md` to 100% coverage for all new features.

## [2.1.1] - 2026-03-25

### Fixed
Expand Down Expand Up @@ -149,6 +163,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
phantom documentation for non-existent exports (errors).
- Dependency spec cross-referencing and Consumed By section validation.

[2.2.0]: https://github.com/CorvidLabs/spec-sync/releases/tag/v2.2.0
[2.1.1]: https://github.com/CorvidLabs/spec-sync/releases/tag/v2.1.1
[2.1.0]: https://github.com/CorvidLabs/spec-sync/releases/tag/v2.1.0
[2.0.0]: https://github.com/CorvidLabs/spec-sync/releases/tag/v2.0.0
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "specsync"
version = "2.1.1"
version = "2.2.0"
edition = "2024"
description = "Bidirectional spec-to-code validation — language-agnostic, blazing fast"
license = "MIT"
Expand Down
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Auto-detected from file extensions. Same spec format for all.

| Language | Exports Detected | Test Exclusions |
|----------|-----------------|-----------------|
| **TypeScript/JS** | `export function/class/type/const/enum`, re-exports | `.test.ts`, `.spec.ts`, `.d.ts` |
| **TypeScript/JS** | `export function/class/type/const/enum`, re-exports, `export *` wildcard resolution | `.test.ts`, `.spec.ts`, `.d.ts` |
| **Rust** | `pub fn/struct/enum/trait/type/const/static/mod` | `#[cfg(test)]` modules |
| **Go** | Uppercase `func/type/var/const`, methods | `_test.go` |
| **Python** | `__all__`, or top-level `def/class` (no `_` prefix) | `test_*.py`, `*_test.py` |
Expand Down Expand Up @@ -99,6 +99,9 @@ cargo install --git https://github.com/CorvidLabs/spec-sync
```bash
specsync init # Create specsync.json config
specsync check # Validate specs against code
specsync check --fix # Auto-add undocumented exports as stubs
specsync diff # Show exports added/removed since HEAD
specsync diff HEAD~5 # Compare against a specific ref
specsync coverage # Show file/module coverage
specsync generate # Scaffold specs for unspecced modules
specsync generate --provider auto # AI-powered specs (auto-detect provider)
Expand Down Expand Up @@ -254,7 +257,8 @@ specsync [command] [flags]

| Command | Description |
|---------|-------------|
| `check` | Validate all specs against source code **(default)** |
| `check` | Validate all specs against source code **(default)**. `--fix` auto-adds missing exports as stubs |
| `diff` | Show exports added/removed since a git ref (default: `HEAD`) |
| `coverage` | File and module coverage report |
| `generate` | Scaffold specs for modules missing one (`--provider` for AI-powered content) |
| `add-spec <name>` | Scaffold a single spec + companion files (`tasks.md`, `context.md`) |
Expand All @@ -274,6 +278,7 @@ specsync [command] [flags]
| `--require-coverage N` | Fail if file coverage < N% |
| `--root <path>` | Project root (default: cwd) |
| `--provider <name>` | AI provider: `auto`, `anthropic`, `openai`, or `command`. `auto` detects installed provider. Without `--provider`, generate uses templates only. |
| `--fix` | Auto-add undocumented exports as stub rows in spec Public API tables |
| `--json` | Structured JSON output |

### Exit Codes
Expand Down Expand Up @@ -495,6 +500,7 @@ The generate command is the entry point for LLM-powered spec workflows:

```bash
specsync generate --provider auto # AI writes specs from source code
specsync check --fix # auto-add any missing exports as stubs
specsync check --json # validate, get structured feedback
# LLM fixes errors from JSON output # iterate until clean
specsync check --strict --require-coverage 100 # enforce full coverage in CI
Expand All @@ -514,8 +520,36 @@ Every output format is designed for machine consumption:

// specsync coverage --json
{ "file_coverage": 85.33, "files_covered": 23, "files_total": 27, "loc_coverage": 79.12, "loc_covered": 4200, "loc_total": 5308, "modules": [...] }

// specsync diff HEAD~3 --json
{ "added": ["newFunction", "NewType"], "removed": ["oldHelper"], "spec": "specs/auth/auth.spec.md" }
```

---

## Auto-Fix & Diff

### `--fix`: Keep specs in sync automatically

```bash
specsync check --fix # Add undocumented exports as stub rows
specsync check --fix --json # Same, with structured JSON output
```

When `--fix` is used, any export found in code but missing from the spec gets appended as a stub row (`| \`name\` | | | *TODO* |`) to the Public API table. If no `## Public API` section exists, one is created. Already-documented exports are never duplicated.

This turns spec maintenance from manual table editing into a review-and-refine workflow — run `--fix`, then fill in the descriptions.

### `diff`: Track API changes across commits

```bash
specsync diff # Changes since HEAD (staged + unstaged)
specsync diff HEAD~5 # Changes since 5 commits ago
specsync diff v2.1.0 # Changes since a tag
```

Shows exports added and removed per spec file since the given git ref. Useful for code review, release notes, and CI drift detection.

---

## Architecture
Expand Down
48 changes: 46 additions & 2 deletions specs/cli/cli.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Three Clap derive structs define the CLI: Cli (root parser with global flags), C

| Command | Description | Key Flags |
|---------|-------------|-----------|
| check | Validate all specs against source code (default when no subcommand given) | --strict, --require-coverage N, --json |
| check | Validate all specs against source code (default when no subcommand given) | --strict, --require-coverage N, --json, --fix |
| coverage | Show file and module coverage report | --strict, --require-coverage N, --json |
| generate | Scaffold spec files for unspecced modules | --provider PROVIDER (AI mode: auto/claude/anthropic/openai/ollama/copilot) |
| init | Create a specsync.json config file with auto-detected source dirs | — |
Expand All @@ -48,6 +48,7 @@ Three Clap derive structs define the CLI: Cli (root parser with global flags), C
| add-spec | Scaffold a new spec with companion files (tasks.md, context.md) | name positional arg |
| init-registry | Generate a specsync-registry.toml for cross-project references | --name |
| resolve | Resolve cross-project spec references in depends_on | --remote (enables network fetches) |
| diff | Show export changes since a git ref (useful for CI/PR comments) | --base REF (default: HEAD), --json |
| hooks install | Install agent instructions and/or git hooks | --claude, --cursor, --copilot, --precommit, --claude-code-hook |
| hooks uninstall | Remove previously installed hooks | --claude, --cursor, --copilot, --precommit, --claude-code-hook |
| hooks status | Show installation status of all hooks | — |
Expand Down Expand Up @@ -75,6 +76,8 @@ All functions in main.rs are private (no pub keyword). Key internal functions:
- **cmd_init_registry** — Generate specsync-registry.toml from existing specs
- **cmd_resolve** — Resolve local and cross-project depends_on references
- **cmd_hooks** — Dispatch to hooks install/uninstall/status
- **cmd_diff** — Compare exports across git refs, show new/removed exports per spec
- **auto_fix_specs** — Scan source files for undocumented exports and auto-add stubs to spec Public API tables
- **collect_hook_targets** — Convert boolean flags to Vec of HookTarget
- **load_and_discover** — Load config and find all spec files (filtering _-prefixed templates)
- **run_validation** — Validate all specs, return counts and collected error/warning strings
Expand All @@ -99,6 +102,11 @@ All functions in main.rs are private (no pub keyword). Key internal functions:
11. `load_and_discover` filters out spec files starting with `_` (template files)
12. Exit codes: 0 = success, 1 = errors (or warnings in strict mode, or coverage below threshold)
13. `collect_hook_targets` with no flags set returns an empty vec, meaning "all targets"
14. `--fix` only adds exports not already documented in the spec (no duplicates)
15. `--fix` modifies spec files on disk — validation runs after fix so the fixed specs are re-checked
16. `--fix` with `--json` suppresses the human-readable fix summary but still writes the fix
17. `cmd_diff` shells out to `git diff --name-only <base>` to detect changed files
18. `cmd_diff` only reports specs whose `files:` frontmatter list intersects the changed file set

## Behavioral Examples

Expand Down Expand Up @@ -144,6 +152,42 @@ All functions in main.rs are private (no pub keyword). Key internal functions:
- **When** `specsync resolve` is run (without `--remote`)
- **Then** lists the refs but does not verify them against remote registries

### Scenario: Fix auto-adds undocumented exports

- **Given** a spec's source files have exports not documented in the Public API section
- **When** `specsync check --fix` is run
- **Then** stub rows (`| \`name\` | <!-- TODO: describe --> |`) are appended to the Public API section and the spec file is written to disk

### Scenario: Fix does not duplicate already-documented exports

- **Given** a spec already documents `login` but not `logout`
- **When** `specsync check --fix` is run
- **Then** only `logout` is added; `login` is not duplicated

### Scenario: Fix creates Public API section when missing

- **Given** a spec has no `## Public API` section
- **When** `specsync check --fix` is run
- **Then** a new `## Public API` section with a table header and stub rows is appended to the spec

### Scenario: Diff shows new exports

- **Given** a source file has a new export added since the base ref
- **When** `specsync diff --base HEAD` is run
- **Then** the new export appears in `new_exports` for the affected spec

### Scenario: Diff shows removed exports

- **Given** a source file has an export removed since the base ref but the spec still documents it
- **When** `specsync diff --base HEAD` is run
- **Then** the removed export appears in `removed_exports` for the affected spec

### Scenario: Diff with no changes

- **Given** no source files have changed since the base ref
- **When** `specsync diff --base HEAD` is run
- **Then** output is empty (`{"changes":[]}` in JSON mode)

### Scenario: Hooks install with no flags

- **Given** no specific hook flags are passed
Expand Down Expand Up @@ -171,7 +215,7 @@ All functions in main.rs are private (no pub keyword). Key internal functions:
| config | `load_config`, `detect_source_dirs` |
| parser | `parse_frontmatter` |
| validator | `validate_spec`, `find_spec_files`, `compute_coverage`, `get_schema_table_names`, `is_cross_project_ref`, `parse_cross_project_ref` |
| exports | `has_extension` |
| exports | `has_extension`, `get_exported_symbols` (used by auto_fix_specs and cmd_diff) |
| generator | `generate_specs_for_unspecced_modules`, `generate_specs_for_unspecced_modules_paths`, `generate_companion_files_for_spec` |
| ai | `resolve_ai_provider` |
| scoring | `score_spec`, `compute_project_score`, `SpecScore` |
Expand Down
33 changes: 31 additions & 2 deletions specs/exports/exports.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ Language-aware export extraction from source files. Auto-detects the programming
| `is_source_file` | `file_path: &Path` | `bool` | Check if a file extension belongs to a supported source language |
| `has_extension` | `file_path: &Path, extensions: &[String]` | `bool` | Check if file matches specific extensions, or any supported language if extensions is empty |
| `extract_exports` | `content: &str` | `Vec<String>` | Per-language backend function that parses source text and returns exported symbol names (one per backend file) |
| `extract_exports_with_resolver` | `content: &str, resolver: Option<&ImportResolver>` | `Vec<String>` | TypeScript-specific: extract exports with optional wildcard re-export resolution via file resolver callback |

### Language Backend Functions

Each language backend exposes a single `extract_exports(content: &str) -> Vec<String>` function that parses source code and returns exported symbol names. These are internal to the exports module (not re-exported) and called by `get_exported_symbols`.

| Backend | File | Extraction Strategy |
|---------|------|-------------------|
| TypeScript/JS | `typescript.rs` | `export function/class/interface/type/const/enum`, re-exports (`export { }`, `export type { }`) with `as` alias support; strips `//` and `/* */` comments |
| TypeScript/JS | `typescript.rs` | `export function/class/interface/type/const/enum`, re-exports (`export { }`, `export type { }`), wildcard re-exports (`export * from`, `export * as Ns from`), default exports (`export default class/function`); with `as` alias support; strips `//` and `/* */` comments |
| Python | `python.rs` | Uses `__all__` list if present; otherwise top-level `def`/`class`/`async def` not prefixed with `_` |
| Rust | `rust_lang.rs` | `pub fn/struct/enum/trait/type/const/static/mod` including `pub(crate)` and `pub async/unsafe`; strips comments |
| Go | `go.rs` | Top-level `func/type/var/const` starting with uppercase letter; also exported methods `func (receiver) Name()`; strips comments |
Expand All @@ -60,7 +61,11 @@ Each language backend exposes a single `extract_exports(content: &str) -> Vec<St
4. `has_extension` with an empty extensions list delegates to `is_source_file` (matches all supported languages)
5. Test file detection uses language-specific patterns (e.g. `.test.ts`, `_test.go`, `Test.java`)
6. Each language backend uses `LazyLock<Regex>` for compiled patterns — compiled once, reused across calls
7. TypeScript backend handles `export function/class/type/const/enum/interface` and re-exports
7. TypeScript backend handles `export function/class/type/const/enum/interface`, re-exports, wildcard re-exports (`export * from`), namespace re-exports (`export * as Ns from`), and default exports
7a. Wildcard `export * from './module'` is resolved via `resolve_ts_import` which tries .ts/.tsx/.js/.jsx/.mts/.cts extensions and /index.ts etc.
7b. Wildcard resolution is one level deep — resolved modules are parsed without a resolver to avoid infinite loops
7c. `export * as Ns from './module'` emits the namespace name (Ns) as the export, not the individual symbols
7d. Without a resolver (e.g. in unit tests), wildcard `export *` lines are silently skipped
8. Rust backend extracts `pub fn/struct/enum/trait/type/const/static/mod` items
9. Go backend extracts uppercase (exported) identifiers and methods
10. Python backend uses `__all__` if present, otherwise top-level non-underscore `def/class`
Expand Down Expand Up @@ -115,6 +120,30 @@ Each language backend exposes a single `extract_exports(content: &str) -> Vec<St
- **When** `get_exported_symbols(path)` is called
- **Then** includes "Bar" (the alias), not "Foo"

### Scenario: Wildcard re-export from barrel file

- **Given** a `.ts` barrel file containing `export * from './helpers'` and `helpers.ts` exports `helperA` and `helperB`
- **When** `get_exported_symbols(barrel_path)` is called
- **Then** includes "helperA" and "helperB" (resolved via `resolve_ts_import`)

### Scenario: Namespace re-export

- **Given** a `.ts` file containing `export * as Utils from './utils'`
- **When** `get_exported_symbols(path)` is called
- **Then** includes "Utils" (the namespace name), not the individual exports from `./utils`

### Scenario: Default export

- **Given** a `.ts` file containing `export default class MyApp {}`
- **When** `get_exported_symbols(path)` is called
- **Then** includes "MyApp"

### Scenario: Wildcard resolution is one level deep

- **Given** `top.ts` has `export * from './middle'` and `middle.ts` has `export * from './bottom'`
- **When** `get_exported_symbols(top_path)` is called
- **Then** includes symbols directly exported by `middle.ts` but NOT symbols from `bottom.ts` (no recursive resolution)

### Scenario: Comments are stripped before extraction

- **Given** a `.ts` file with `// export function notExported()` inside a comment
Expand Down
33 changes: 32 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,41 @@ pub fn load_config(root: &Path) -> SpecSyncConfig {
}
}

/// Known config keys in specsync.json (camelCase).
const KNOWN_JSON_KEYS: &[&str] = &[
"specsDir",
"sourceDirs",
"schemaDir",
"schemaPattern",
"requiredSections",
"excludeDirs",
"excludePatterns",
"sourceExtensions",
"aiProvider",
"aiModel",
"aiCommand",
"aiApiKey",
"aiBaseUrl",
"aiTimeout",
];

fn load_json_config(config_path: &Path, root: &Path) -> SpecSyncConfig {
let content = match fs::read_to_string(config_path) {
Ok(c) => c,
Err(_) => return SpecSyncConfig::default(),
};

// Warn about unknown keys
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&content)
&& let Some(obj) = raw.as_object()
{
for key in obj.keys() {
if !KNOWN_JSON_KEYS.contains(&key.as_str()) {
eprintln!("Warning: unknown key \"{key}\" in specsync.json (ignored)");
}
}
}

match serde_json::from_str::<SpecSyncConfig>(&content) {
Ok(config) => {
if !content.contains("\"sourceDirs\"") {
Expand Down Expand Up @@ -223,7 +252,9 @@ fn load_toml_config(config_path: &Path, root: &Path) -> SpecSyncConfig {
"required_sections" => {
config.required_sections = parse_toml_string_array(value);
}
_ => {} // Ignore unknown keys
_ => {
eprintln!("Warning: unknown key \"{key}\" in .specsync.toml (ignored)");
}
}
}
}
Expand Down
Loading
Loading