Skip to content

Commit 966f740

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 beadd7e commit 966f740

3 files changed

Lines changed: 200 additions & 15 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/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: 113 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
1313
use std::process;
1414

1515
use config::load_config;
16-
use generator::generate_specs_for_unspecced_modules;
16+
use generator::{generate_specs_for_unspecced_modules, generate_specs_for_unspecced_modules_paths};
1717
use validator::{compute_coverage, find_spec_files, get_schema_table_names, validate_spec};
1818

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

4246
#[derive(Subcommand)]
@@ -64,9 +68,9 @@ fn main() {
6468

6569
match command {
6670
Command::Init => cmd_init(&root),
67-
Command::Check => cmd_check(&root, cli.strict, cli.require_coverage),
68-
Command::Coverage => cmd_coverage(&root, cli.strict, cli.require_coverage),
69-
Command::Generate => cmd_generate(&root, cli.strict, cli.require_coverage),
71+
Command::Check => cmd_check(&root, cli.strict, cli.require_coverage, cli.json),
72+
Command::Coverage => cmd_coverage(&root, cli.strict, cli.require_coverage, cli.json),
73+
Command::Generate => cmd_generate(&root, cli.strict, cli.require_coverage, cli.json),
7074
Command::Watch => watch::run_watch(&root, cli.strict, cli.require_coverage),
7175
}
7276
}
@@ -99,13 +103,31 @@ fn cmd_init(root: &Path) {
99103
println!("{} Created specsync.json", "✓".green());
100104
}
101105

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

113+
if json {
114+
let exit_code = compute_exit_code(
115+
total_errors,
116+
total_warnings,
117+
strict,
118+
&coverage,
119+
require_coverage,
120+
);
121+
let output = serde_json::json!({
122+
"passed": exit_code == 0,
123+
"errors": all_errors,
124+
"warnings": all_warnings,
125+
"specs_checked": total,
126+
});
127+
println!("{}", serde_json::to_string_pretty(&output).unwrap());
128+
process::exit(exit_code);
129+
}
130+
109131
print_summary(total, passed, total_warnings, total_errors);
110132
print_coverage_line(&coverage);
111133
exit_with_status(
@@ -117,13 +139,36 @@ fn cmd_check(root: &Path, strict: bool, require_coverage: Option<usize>) {
117139
);
118140
}
119141

120-
fn cmd_coverage(root: &Path, strict: bool, require_coverage: Option<usize>) {
142+
fn cmd_coverage(root: &Path, strict: bool, require_coverage: Option<usize>, json: bool) {
121143
let (config, spec_files) = load_and_discover(root);
122144
let schema_tables = get_schema_table_names(root, &config);
123-
let (total_errors, total_warnings, passed, total) =
124-
run_validation(root, &spec_files, &schema_tables, &config);
145+
let (total_errors, total_warnings, passed, total, _all_errors, _all_warnings) =
146+
run_validation(root, &spec_files, &schema_tables, &config, json);
125147
let coverage = compute_coverage(root, &spec_files, &config);
126148

149+
if json {
150+
let file_coverage = if coverage.total_source_files == 0 {
151+
100.0
152+
} else {
153+
(coverage.specced_file_count as f64 / coverage.total_source_files as f64) * 100.0
154+
};
155+
156+
let modules: Vec<serde_json::Value> = coverage
157+
.unspecced_modules
158+
.iter()
159+
.map(|m| serde_json::json!({ "name": m, "has_spec": false }))
160+
.collect();
161+
162+
let output = serde_json::json!({
163+
"file_coverage": (file_coverage * 100.0).round() / 100.0,
164+
"files_covered": coverage.specced_file_count,
165+
"files_total": coverage.total_source_files,
166+
"modules": modules,
167+
});
168+
println!("{}", serde_json::to_string_pretty(&output).unwrap());
169+
process::exit(0);
170+
}
171+
127172
print_coverage_report(&coverage);
128173
print_summary(total, passed, total_warnings, total_errors);
129174
print_coverage_line(&coverage);
@@ -136,13 +181,22 @@ fn cmd_coverage(root: &Path, strict: bool, require_coverage: Option<usize>) {
136181
);
137182
}
138183

139-
fn cmd_generate(root: &Path, strict: bool, require_coverage: Option<usize>) {
184+
fn cmd_generate(root: &Path, strict: bool, require_coverage: Option<usize>, json: bool) {
140185
let (config, spec_files) = load_and_discover(root);
141186
let schema_tables = get_schema_table_names(root, &config);
142-
let (total_errors, total_warnings, passed, total) =
143-
run_validation(root, &spec_files, &schema_tables, &config);
187+
let (total_errors, total_warnings, passed, total, _all_errors, _all_warnings) =
188+
run_validation(root, &spec_files, &schema_tables, &config, json);
144189
let coverage = compute_coverage(root, &spec_files, &config);
145190

191+
if json {
192+
let generated_paths = generate_specs_for_unspecced_modules_paths(root, &coverage, &config);
193+
let output = serde_json::json!({
194+
"generated": generated_paths,
195+
});
196+
println!("{}", serde_json::to_string_pretty(&output).unwrap());
197+
process::exit(0);
198+
}
199+
146200
print_coverage_report(&coverage);
147201

148202
println!(
@@ -196,19 +250,34 @@ fn load_and_discover(root: &Path) -> (types::SpecSyncConfig, Vec<PathBuf>) {
196250
(config, spec_files)
197251
}
198252

253+
/// Run validation, returning counts and collected error/warning strings.
199254
fn run_validation(
200255
root: &Path,
201256
spec_files: &[PathBuf],
202257
schema_tables: &std::collections::HashSet<String>,
203258
config: &types::SpecSyncConfig,
204-
) -> (usize, usize, usize, usize) {
259+
json: bool,
260+
) -> (usize, usize, usize, usize, Vec<String>, Vec<String>) {
205261
let mut total_errors = 0;
206262
let mut total_warnings = 0;
207263
let mut passed = 0;
264+
let mut all_errors: Vec<String> = Vec::new();
265+
let mut all_warnings: Vec<String> = Vec::new();
208266

209267
for spec_file in spec_files {
210268
let result = validate_spec(spec_file, root, schema_tables, config);
211269

270+
if json {
271+
all_errors.extend(result.errors.iter().cloned());
272+
all_warnings.extend(result.warnings.iter().cloned());
273+
total_errors += result.errors.len();
274+
total_warnings += result.warnings.len();
275+
if result.errors.is_empty() {
276+
passed += 1;
277+
}
278+
continue;
279+
}
280+
212281
println!("\n{}", result.spec_path.bold());
213282

214283
// Frontmatter check
@@ -334,7 +403,36 @@ fn run_validation(
334403
}
335404
}
336405

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

340438
fn print_summary(total: usize, passed: usize, warnings: usize, _errors: usize) {

0 commit comments

Comments
 (0)