Skip to content

Commit 048896d

Browse files
corvid-agentclaude
andcommitted
feat: add --json CLI flag and CHANGELOG.md
Add a global --json flag that outputs structured JSON for check, coverage, and generate commands, enabling CI/CD and tooling integration. Also add CHANGELOG.md documenting the v1.0.0 release. Fix pre-existing rustfmt issues in if-let chains across multiple files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7950590 commit 048896d

6 files changed

Lines changed: 237 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [1.0.0] - 2026-03-18
9+
10+
### Added
11+
12+
- **Complete rewrite from TypeScript to Rust** for language-agnostic spec validation
13+
with significantly improved performance and a single static binary.
14+
- **9 language backends** for export extraction: TypeScript/JavaScript, Rust, Go,
15+
Python, Swift, Kotlin, Java, C#, and Dart.
16+
- **`check` command** — validates all spec files against source code, checking
17+
frontmatter, file existence, required sections, API surface coverage,
18+
DB table references, and dependency specs.
19+
- **`coverage` command** — reports file and module coverage, listing unspecced
20+
files and modules.
21+
- **`generate` command** — scaffolds spec files for unspecced modules using
22+
a customizable template (`_template.spec.md`).
23+
- **`init` command** — creates a default `specsync.json` configuration file.
24+
- **`--json` flag** — global CLI flag that outputs results as structured JSON
25+
instead of colored terminal text, for CI/CD and tooling integration.
26+
- **`--strict` flag** — treats warnings as errors for CI enforcement.
27+
- **`--require-coverage N` flag** — fails if file coverage percent is below
28+
the given threshold.
29+
- **`--root` flag** — overrides the project root directory.
30+
- **CI/CD workflows** with GitHub Actions for testing, linting, and
31+
multi-platform release binary publishing (Linux x86_64/aarch64,
32+
macOS x86_64/aarch64, Windows x86_64).
33+
- Configurable required sections, exclude patterns, source extensions,
34+
and schema table validation via `specsync.json`.
35+
- YAML frontmatter parsing without external YAML dependencies.
36+
- API surface validation: detects undocumented exports (warnings) and
37+
phantom documentation for non-existent exports (errors).
38+
- Dependency spec cross-referencing and Consumed By section validation.
39+
40+
[1.0.0]: https://github.com/CorvidLabs/spec-sync/releases/tag/v1.0.0

src/exports/kotlin.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,14 @@ pub fn extract_exports(content: &str) -> Vec<String> {
3535
}
3636

3737
if let Some(caps) = KT_DECL.captures(line)
38-
&& let Some(name) = caps.get(1) {
39-
// Skip companion objects (they're not standalone exports)
40-
if trimmed.starts_with("companion") {
41-
continue;
42-
}
43-
symbols.push(name.as_str().to_string());
38+
&& let Some(name) = caps.get(1)
39+
{
40+
// Skip companion objects (they're not standalone exports)
41+
if trimmed.starts_with("companion") {
42+
continue;
4443
}
44+
symbols.push(name.as_str().to_string());
45+
}
4546
}
4647

4748
symbols

src/exports/python.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@ static QUOTED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"["'](\w+)["']"#)
1818
pub fn extract_exports(content: &str) -> Vec<String> {
1919
// Check for __all__ first
2020
if let Some(caps) = ALL_DECL.captures(content)
21-
&& let Some(list) = caps.get(1) {
22-
let mut symbols = Vec::new();
23-
for name_cap in QUOTED.captures_iter(list.as_str()) {
24-
if let Some(name) = name_cap.get(1) {
25-
symbols.push(name.as_str().to_string());
26-
}
21+
&& let Some(list) = caps.get(1)
22+
{
23+
let mut symbols = Vec::new();
24+
for name_cap in QUOTED.captures_iter(list.as_str()) {
25+
if let Some(name) = name_cap.get(1) {
26+
symbols.push(name.as_str().to_string());
2727
}
28-
return symbols;
2928
}
29+
return symbols;
30+
}
3031

3132
// Fallback: top-level def/class that don't start with _
3233
let mut symbols = Vec::new();

src/generator.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,50 @@ pub fn generate_specs_for_unspecced_modules(
212212

213213
generated
214214
}
215+
216+
/// Generate spec files for all unspecced modules, returning the paths of generated files.
217+
pub fn generate_specs_for_unspecced_modules_paths(
218+
root: &Path,
219+
report: &CoverageReport,
220+
config: &SpecSyncConfig,
221+
) -> Vec<String> {
222+
let specs_dir = root.join(&config.specs_dir);
223+
let mut generated_paths = Vec::new();
224+
225+
for module_name in &report.unspecced_modules {
226+
let spec_dir = specs_dir.join(module_name);
227+
let spec_file = spec_dir.join(format!("{module_name}.spec.md"));
228+
229+
if spec_file.exists() {
230+
continue;
231+
}
232+
233+
let mut module_files = Vec::new();
234+
for src_dir in &config.source_dirs {
235+
let module_dir = root.join(src_dir).join(module_name);
236+
let files = find_module_source_files(&module_dir, config);
237+
module_files.extend(files);
238+
}
239+
240+
if module_files.is_empty() {
241+
continue;
242+
}
243+
244+
if fs::create_dir_all(&spec_dir).is_err() {
245+
continue;
246+
}
247+
248+
let spec_content = generate_spec(module_name, &module_files, root, &specs_dir);
249+
250+
if fs::write(&spec_file, &spec_content).is_ok() {
251+
let rel = spec_file
252+
.strip_prefix(root)
253+
.unwrap_or(&spec_file)
254+
.to_string_lossy()
255+
.to_string();
256+
generated_paths.push(rel);
257+
}
258+
}
259+
260+
generated_paths
261+
}

src/main.rs

Lines changed: 125 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::path::{Path, PathBuf};
1212
use std::process;
1313

1414
use config::load_config;
15-
use generator::generate_specs_for_unspecced_modules;
15+
use generator::{generate_specs_for_unspecced_modules, generate_specs_for_unspecced_modules_paths};
1616
use validator::{compute_coverage, find_spec_files, get_schema_table_names, validate_spec};
1717

1818
#[derive(Parser)]
@@ -36,6 +36,10 @@ struct Cli {
3636
/// Project root directory (default: cwd)
3737
#[arg(long, global = true)]
3838
root: Option<PathBuf>,
39+
40+
/// Output results as JSON instead of colored text
41+
#[arg(long, global = true)]
42+
json: bool,
3943
}
4044

4145
#[derive(Subcommand)]
@@ -61,9 +65,9 @@ fn main() {
6165

6266
match command {
6367
Command::Init => cmd_init(&root),
64-
Command::Check => cmd_check(&root, cli.strict, cli.require_coverage),
65-
Command::Coverage => cmd_coverage(&root, cli.strict, cli.require_coverage),
66-
Command::Generate => cmd_generate(&root, cli.strict, cli.require_coverage),
68+
Command::Check => cmd_check(&root, cli.strict, cli.require_coverage, cli.json),
69+
Command::Coverage => cmd_coverage(&root, cli.strict, cli.require_coverage, cli.json),
70+
Command::Generate => cmd_generate(&root, cli.strict, cli.require_coverage, cli.json),
6771
}
6872
}
6973

@@ -95,13 +99,31 @@ fn cmd_init(root: &Path) {
9599
println!("{} Created specsync.json", "✓".green());
96100
}
97101

98-
fn cmd_check(root: &Path, strict: bool, require_coverage: Option<usize>) {
102+
fn cmd_check(root: &Path, strict: bool, require_coverage: Option<usize>, json: bool) {
99103
let (config, spec_files) = load_and_discover(root);
100104
let schema_tables = get_schema_table_names(root, &config);
101-
let (total_errors, total_warnings, passed, total) =
102-
run_validation(root, &spec_files, &schema_tables, &config);
105+
let (total_errors, total_warnings, passed, total, all_errors, all_warnings) =
106+
run_validation(root, &spec_files, &schema_tables, &config, json);
103107
let coverage = compute_coverage(root, &spec_files, &config);
104108

109+
if json {
110+
let exit_code = compute_exit_code(
111+
total_errors,
112+
total_warnings,
113+
strict,
114+
&coverage,
115+
require_coverage,
116+
);
117+
let output = serde_json::json!({
118+
"passed": exit_code == 0,
119+
"errors": all_errors,
120+
"warnings": all_warnings,
121+
"specs_checked": total,
122+
});
123+
println!("{}", serde_json::to_string_pretty(&output).unwrap());
124+
process::exit(exit_code);
125+
}
126+
105127
print_summary(total, passed, total_warnings, total_errors);
106128
print_coverage_line(&coverage);
107129
exit_with_status(
@@ -113,13 +135,36 @@ fn cmd_check(root: &Path, strict: bool, require_coverage: Option<usize>) {
113135
);
114136
}
115137

116-
fn cmd_coverage(root: &Path, strict: bool, require_coverage: Option<usize>) {
138+
fn cmd_coverage(root: &Path, strict: bool, require_coverage: Option<usize>, json: bool) {
117139
let (config, spec_files) = load_and_discover(root);
118140
let schema_tables = get_schema_table_names(root, &config);
119-
let (total_errors, total_warnings, passed, total) =
120-
run_validation(root, &spec_files, &schema_tables, &config);
141+
let (total_errors, total_warnings, passed, total, _all_errors, _all_warnings) =
142+
run_validation(root, &spec_files, &schema_tables, &config, json);
121143
let coverage = compute_coverage(root, &spec_files, &config);
122144

145+
if json {
146+
let file_coverage = if coverage.total_source_files == 0 {
147+
100.0
148+
} else {
149+
(coverage.specced_file_count as f64 / coverage.total_source_files as f64) * 100.0
150+
};
151+
152+
let modules: Vec<serde_json::Value> = coverage
153+
.unspecced_modules
154+
.iter()
155+
.map(|m| serde_json::json!({ "name": m, "has_spec": false }))
156+
.collect();
157+
158+
let output = serde_json::json!({
159+
"file_coverage": (file_coverage * 100.0).round() / 100.0,
160+
"files_covered": coverage.specced_file_count,
161+
"files_total": coverage.total_source_files,
162+
"modules": modules,
163+
});
164+
println!("{}", serde_json::to_string_pretty(&output).unwrap());
165+
process::exit(0);
166+
}
167+
123168
print_coverage_report(&coverage);
124169
print_summary(total, passed, total_warnings, total_errors);
125170
print_coverage_line(&coverage);
@@ -132,13 +177,22 @@ fn cmd_coverage(root: &Path, strict: bool, require_coverage: Option<usize>) {
132177
);
133178
}
134179

135-
fn cmd_generate(root: &Path, strict: bool, require_coverage: Option<usize>) {
180+
fn cmd_generate(root: &Path, strict: bool, require_coverage: Option<usize>, json: bool) {
136181
let (config, spec_files) = load_and_discover(root);
137182
let schema_tables = get_schema_table_names(root, &config);
138-
let (total_errors, total_warnings, passed, total) =
139-
run_validation(root, &spec_files, &schema_tables, &config);
183+
let (total_errors, total_warnings, passed, total, _all_errors, _all_warnings) =
184+
run_validation(root, &spec_files, &schema_tables, &config, json);
140185
let coverage = compute_coverage(root, &spec_files, &config);
141186

187+
if json {
188+
let generated_paths = generate_specs_for_unspecced_modules_paths(root, &coverage, &config);
189+
let output = serde_json::json!({
190+
"generated": generated_paths,
191+
});
192+
println!("{}", serde_json::to_string_pretty(&output).unwrap());
193+
process::exit(0);
194+
}
195+
142196
print_coverage_report(&coverage);
143197

144198
println!(
@@ -192,19 +246,34 @@ fn load_and_discover(root: &Path) -> (types::SpecSyncConfig, Vec<PathBuf>) {
192246
(config, spec_files)
193247
}
194248

249+
/// Run validation, returning counts and collected error/warning strings.
195250
fn run_validation(
196251
root: &Path,
197252
spec_files: &[PathBuf],
198253
schema_tables: &std::collections::HashSet<String>,
199254
config: &types::SpecSyncConfig,
200-
) -> (usize, usize, usize, usize) {
255+
json: bool,
256+
) -> (usize, usize, usize, usize, Vec<String>, Vec<String>) {
201257
let mut total_errors = 0;
202258
let mut total_warnings = 0;
203259
let mut passed = 0;
260+
let mut all_errors: Vec<String> = Vec::new();
261+
let mut all_warnings: Vec<String> = Vec::new();
204262

205263
for spec_file in spec_files {
206264
let result = validate_spec(spec_file, root, schema_tables, config);
207265

266+
if json {
267+
all_errors.extend(result.errors.iter().cloned());
268+
all_warnings.extend(result.warnings.iter().cloned());
269+
total_errors += result.errors.len();
270+
total_warnings += result.warnings.len();
271+
if result.errors.is_empty() {
272+
passed += 1;
273+
}
274+
continue;
275+
}
276+
208277
println!("\n{}", result.spec_path.bold());
209278

210279
// Frontmatter check
@@ -330,7 +399,36 @@ fn run_validation(
330399
}
331400
}
332401

333-
(total_errors, total_warnings, passed, spec_files.len())
402+
(
403+
total_errors,
404+
total_warnings,
405+
passed,
406+
spec_files.len(),
407+
all_errors,
408+
all_warnings,
409+
)
410+
}
411+
412+
/// Compute exit code without printing or exiting.
413+
fn compute_exit_code(
414+
total_errors: usize,
415+
total_warnings: usize,
416+
strict: bool,
417+
coverage: &types::CoverageReport,
418+
require_coverage: Option<usize>,
419+
) -> i32 {
420+
if total_errors > 0 {
421+
return 1;
422+
}
423+
if strict && total_warnings > 0 {
424+
return 1;
425+
}
426+
if let Some(req) = require_coverage
427+
&& coverage.coverage_percent < req
428+
{
429+
return 1;
430+
}
431+
0
334432
}
335433

336434
fn print_summary(total: usize, passed: usize, warnings: usize, _errors: usize) {
@@ -418,16 +516,17 @@ fn exit_with_status(
418516
}
419517

420518
if let Some(req) = require_coverage
421-
&& coverage.coverage_percent < req {
422-
println!(
423-
"\n{} {req}%: actual coverage is {}% ({} file(s) missing specs)",
424-
"--require-coverage".red(),
425-
coverage.coverage_percent,
426-
coverage.unspecced_files.len()
427-
);
428-
for f in &coverage.unspecced_files {
429-
println!(" {} {f}", "✗".red());
430-
}
431-
process::exit(1);
519+
&& coverage.coverage_percent < req
520+
{
521+
println!(
522+
"\n{} {req}%: actual coverage is {}% ({} file(s) missing specs)",
523+
"--require-coverage".red(),
524+
coverage.coverage_percent,
525+
coverage.unspecced_files.len()
526+
);
527+
for f in &coverage.unspecced_files {
528+
println!(" {} {f}", "✗".red());
432529
}
530+
process::exit(1);
531+
}
433532
}

0 commit comments

Comments
 (0)