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
14 changes: 7 additions & 7 deletions Cargo.lock

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

12 changes: 6 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ members = [
]

[workspace.package]
version = "0.8.0"
version = "1.6.1"
edition = "2021"
rust-version = "1.75"
license = "MIT"
Expand All @@ -37,11 +37,11 @@ clap = { version = "4", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Internal crates
specforge-core = { path = "crates/specforge-core", version = "0.8.0" }
specforge-ts = { path = "crates/specforge-ts", version = "0.8.0" }
specforge-go = { path = "crates/specforge-go", version = "0.8.0" }
specforge-rust = { path = "crates/specforge-rust", version = "0.8.0" }
specforge-plugin = { path = "crates/specforge-plugin", version = "0.8.0" }
specforge-core = { path = "crates/specforge-core", version = "1.6.1" }
specforge-ts = { path = "crates/specforge-ts", version = "1.6.1" }
specforge-go = { path = "crates/specforge-go", version = "1.6.1" }
specforge-rust = { path = "crates/specforge-rust", version = "1.6.1" }
specforge-plugin = { path = "crates/specforge-plugin", version = "1.6.1" }
tempfile = "3"
# Benchmarks
criterion = { version = "0.5", features = ["html_reports"] }
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Every generated SDK is a **complete, production-ready client** — not just type
| **Idempotency keys** | Auto-generated `Idempotency-Key` on unsafe methods | Safe retries on POST/PUT/PATCH/DELETE without duplicate side effects |
| **SSE streaming** | Server-Sent Event parser with proper error handling | Real-time data streams without manual parsing |
| **Runtime validation** | Validate request/response bodies against the spec | Catch API contract violations in dev and tests, not production |
| **oneOf type guards** | `isPetCreated()`, `narrowPetEvent()` (TS); `is_pet_created()`, `discriminant()` (Rust) | Safely narrow union types at runtime |
| **oneOf type guards** | `isPetEventPetCreated()`, `narrowPetEvent()` (TS); `is_pet_created()`, `discriminant()` (Rust) | Safely narrow union types at runtime (TS guards are union-scoped to avoid collisions when two unions share an arm) |
| **Response caching** | ETag-based caching with TTL expiry and 304 handling | Avoid re-fetching unchanged data |
| **Rate limiting** | Token bucket and sliding window rate limiters | Prevent overwhelming APIs |
| **Logging** | Pluggable `Logger` interface (ConsoleLogger, NoopLogger) | Structured request/response logging |
Expand Down Expand Up @@ -787,10 +787,10 @@ Streaming calls **do not retry** (bodies are not replayable).
### oneOf narrowing (TypeScript)

```ts
import { isPetCreated, narrowPetEvent, type PetEvent } from "./models/PetEvent";
import { isPetEventPetCreated, narrowPetEvent, type PetEvent } from "./models/PetEvent";

function handle(event: PetEvent) {
if (isPetCreated(event)) {
if (isPetEventPetCreated(event)) {
console.log(event.pet.name); // narrowed
return;
}
Expand All @@ -801,6 +801,8 @@ function handle(event: PetEvent) {
}
```

> TS guard names are union-scoped (`is{Union}{Arm}`) so two unions sharing an arm type don't collide. `narrow{Union}()` is the ergonomic entry point.

---

## Examples
Expand Down
107 changes: 107 additions & 0 deletions crates/specforge-cli/tests/regression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,16 @@ fn stripe_generates_and_compiles_rust() {
generate_and_compile_rust(&FIXTURES[2], "stripe_sdk");
}

#[test]
fn github_generates_and_typechecks_ts() {
generate_and_compile_ts(&FIXTURES[1], "@test/github-ts-sdk");
}

#[test]
fn stripe_generates_and_typechecks_ts() {
generate_and_compile_ts(&FIXTURES[2], "@test/stripe-ts-sdk");
}

fn generate_and_compile_go(f: &Fixture, module: &str) {
let path = match spec_path(f) {
Ok(p) => p,
Expand Down Expand Up @@ -373,6 +383,55 @@ fn generate_and_compile_rust(f: &Fixture, crate_name: &str) {
}
}

fn generate_and_compile_ts(f: &Fixture, package_name: &str) {
let path = match spec_path(f) {
Ok(p) => p,
Err(e) => {
eprintln!("skip {} ts gate: {e}", f.name);
return;
}
};
let spec = match specforge_core::parse_file(&path) {
Ok(s) => s,
Err(e) => panic!("{} ts: parse failed: {e}", f.name),
};
let doc = match specforge_core::resolve(&spec) {
Ok(d) => d,
Err(e) => panic!("{} ts: resolve failed: {e}", f.name),
};

let out = std::env::temp_dir().join(format!("specforge-gate-{}-ts", f.name));
let _ = std::fs::remove_dir_all(&out);
let opts = specforge_ts::GeneratorOptions {
out_dir: out.clone(),
package_name: Some(package_name.into()),
i18n: None,
};
let written = specforge_ts::generate(&doc, &opts)
.unwrap_or_else(|e| panic!("{} ts: emit failed: {e}", f.name));
assert!(
written.iter().any(|p| p == "package.json"),
"{} ts: missing package.json",
f.name
);
assert!(
written.len() >= 5,
"{} ts: expected several files, got {}",
f.name,
written.len()
);

match assert_tsc(&out) {
Ok(()) => eprintln!(
"{} ts: generate + tsc --noEmit OK ({} files)",
f.name,
written.len()
),
Err(e) if e.contains("tsc not available") => eprintln!("skip {} ts check: {e}", f.name),
Err(e) => panic!("{} ts typecheck failed: {e}", f.name),
}
}

/// Run `go build ./...` in `dir`. Returns Err("go not found") when the toolchain
/// is missing so callers can skip; any other failure is a real compile error.
fn assert_go_build(dir: &Path) -> Result<(), String> {
Expand Down Expand Up @@ -435,6 +494,54 @@ fn assert_cargo_check(dir: &Path) -> Result<(), String> {
}
}

/// Run `npm install` then `tsc --noEmit` in `dir`. Returns Err("tsc not
/// available") when node/npm are missing so callers can skip; any other failure
/// is a real type error.
fn assert_tsc(dir: &Path) -> Result<(), String> {
// Resolve node/npm — skip gracefully if absent (TS gate is optional).
for tool in ["node", "npm"] {
let ok = std::process::Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
return Err(format!("{tool} not found — tsc not available"));
}
}

// Install devDependencies (typescript) quietly.
let install = std::process::Command::new("npm")
.args(["install", "--no-audit", "--no-fund", "--loglevel=error"])
.current_dir(dir)
.output()
.map_err(|e| format!("spawn npm install: {e}"))?;
if !install.status.success() {
return Err(format!(
"npm install failed (exit {:?}):\n{}",
install.status.code(),
String::from_utf8_lossy(&install.stderr)
));
}
Comment on lines +513 to +525

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Ts gate needs network 🐞 Bug ☼ Reliability

The new TS regression gate always runs npm install in a fresh temp directory, which introduces a
network/package-registry dependency and can make CI slow or flaky even when the generator output is
correct.
Agent Prompt
## Issue description
`assert_tsc()` unconditionally executes `npm install` in the generated temp package directory. This may require registry access and can fail due to transient network/registry issues, turning the regression suite into a flaky, externally-dependent test.

## Issue Context
This is a regression test, so failures should ideally reflect generator correctness rather than external package-manager availability.

## Fix Focus Areas
- crates/specforge-cli/tests/regression.rs[497-525]

### Possible approaches (pick one)
- Add `--prefer-offline`/`--no-progress` and configure an explicit npm cache directory to reduce network reliance.
- Make the TS gate opt-in via env var (e.g. `SPECFORGE_ENABLE_TSC_GATE=1`) so default CI remains hermetic.
- Vendor a minimal toolchain approach (e.g., run `tsc` from a repo-managed dependency/tooling) so the test doesn’t need to install from the network each time.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


// Typecheck. The generated package.json has a `typecheck` script
// (`tsc --noEmit`); invoke it directly so we exercise the shipped config.
let tsc = std::process::Command::new("npm")
.args(["run", "--silent", "typecheck"])
.current_dir(dir)
.output()
.map_err(|e| format!("spawn tsc: {e}"))?;
if tsc.status.success() {
Ok(())
} else {
Err(format!(
"tsc --noEmit failed (exit {:?}):\n{}",
tsc.status.code(),
String::from_utf8_lossy(&tsc.stdout),
))
Comment on lines +537 to +541

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

3. Tsc stderr not reported 🐞 Bug ◔ Observability

On TypeScript typecheck failure, assert_tsc() only returns tsc.stdout and drops tsc.stderr,
which can hide important diagnostics and makes failures harder to debug.
Agent Prompt
## Issue description
When `npm run typecheck` fails, the error string only includes stdout. If stderr contains useful output (npm wrapper errors, node runtime errors, or diagnostics), it will be lost.

## Issue Context
Other compile gates in this file include stderr in failure messages (e.g., cargo check); TS should match for debuggability.

## Fix Focus Areas
- crates/specforge-cli/tests/regression.rs[527-542]

### Suggested change
Include both stdout and stderr in the returned error message, similar to `assert_cargo_check()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
}

/// Helper: collect the relative file paths produced by a generate run.
fn generated_files(dir: &Path) -> BTreeSet<String> {
fn walk(dir: &Path, base: &Path, out: &mut BTreeSet<String>) {
Expand Down
6 changes: 6 additions & 0 deletions crates/specforge-core/src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ pub struct EnumModel { pub name: String, pub description: Option<String>, pub va
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnumVariant { pub value: String, pub description: Option<String> }

// The ObjectModel variant (~336 bytes) is much larger than EnumModel (~72),
// which trips clippy::large_enum_variant. Boxing ObjectModel would force a
// deref at all 70+ match sites across the emitters and core for no real win —
// Model values are not stack-hot (they live in the resolved Document, accessed
// by reference). Suppressing rather than paying the ergonomic cost everywhere.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, serde::Serialize)]
pub enum Model { Object(ObjectModel), Enum(EnumModel) }
impl Model { pub fn name(&self) -> &str { match self { Model::Object(o) => &o.name, Model::Enum(e) => &e.name } } }
Expand Down
2 changes: 1 addition & 1 deletion crates/specforge-core/src/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ mod tests {
"info": { "title": "A", "version": "1.0" },
"paths": { "/a": {} }
});
let result = merge_specs(&[spec.clone()]).unwrap();
let result = merge_specs(std::slice::from_ref(&spec)).unwrap();
assert_eq!(result, spec);
}

Expand Down
15 changes: 11 additions & 4 deletions crates/specforge-go/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ fn emit_enum(e: &EnumModel) -> String {
out.push_str(&go_doc(d, ""));
if d.to_lowercase().contains("deprecated") {
if let Some(alt) = go_schema_deprecation_alternative(d) {
out.push_str(&format!("// Deprecated: Use {alt} instead.\n"));
out.push_str(&format!("// Deprecated: Use {} instead.\n", go_inline(&alt)));
} else {
out.push_str(&format!("// Deprecated: {name} is deprecated.\n"));
}
Expand All @@ -411,7 +411,7 @@ fn emit_object(o: &ObjectModel, doc: &Document) -> String {
out.push_str(&go_doc(d, ""));
if d.to_lowercase().contains("deprecated") {
if let Some(alt) = go_schema_deprecation_alternative(d) {
out.push_str(&format!("// Deprecated: Use {alt} instead.\n"));
out.push_str(&format!("// Deprecated: Use {} instead.\n", go_inline(&alt)));
} else {
out.push_str(&format!("// Deprecated: {name} is deprecated.\n"));
}
Expand Down Expand Up @@ -2661,7 +2661,7 @@ fn emit_method(op: &Operation) -> String {
if !error_responses.is_empty() {
for r in &error_responses {
if let Some(desc) = &r.description {
body.push_str(&format!("// Returns {} for {}.\n", desc, r.status));
body.push_str(&format!("// Returns {} for {}.\n", go_inline(desc), r.status));
} else {
body.push_str(&format!("// Returns an error for {}.\n", r.status));
}
Expand All @@ -2670,7 +2670,7 @@ fn emit_method(op: &Operation) -> String {
// Deprecation notice.
if is_operation_deprecated(op) {
if let Some(alt) = go_deprecation_alternative(op) {
body.push_str(&format!("// Deprecated: Use {alt} instead.\n"));
body.push_str(&format!("// Deprecated: Use {} instead.\n", go_inline(&alt)));
} else {
body.push_str(&format!("// Deprecated: {name} is deprecated.\n"));
}
Expand Down Expand Up @@ -3219,6 +3219,13 @@ fn go_doc_label(text: &str, label: &str) -> String {
out
}

/// Sanitize a spec-derived string for safe inlining into a single Go `//`
/// comment line: escape `*/` (closes block comments) and collapse embedded
/// newlines to spaces so the comment stays on one line.
fn go_inline(text: &str) -> String {
text.replace("*/", "*&#47;").replace(['\n', '\r'], " ")
}

fn escape_go_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
Expand Down
10 changes: 8 additions & 2 deletions crates/specforge-ts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ pub fn generate(doc: &Document, opts: &GeneratorOptions) -> std::io::Result<Vec<
let models_dir = opts.out_dir.join("src").join("models");
std::fs::create_dir_all(&models_dir)?;
for (_, model) in doc.schemas.iter() {
let name = name::pascal(model.name());
let name = name::safe_model_name(&name::pascal(model.name()));
let path = models_dir.join(format!("{name}.ts"));
let rel = path_str(&path, &opts.out_dir);
let content = models::emit_model_file_with_registry(model, Some(&doc.schemas));
Expand Down Expand Up @@ -277,6 +277,12 @@ fn collect_index(doc: &Document, out_dir: &Path, has_i18n: bool) -> std::io::Res
body.push_str("export * from \"./auth\";\n");
body.push_str("export * from \"./retry\";\n");
body.push_str("export * from \"./paginate\";\n");
body.push_str("export * from \"./concurrency\";\n");
body.push_str("export * from \"./dedup\";\n");
body.push_str("export * from \"./idempotency\";\n");
body.push_str("export * from \"./middleware\";\n");
body.push_str("export * from \"./streaming\";\n");
body.push_str("export * from \"./cache\";\n");
body.push_str("export * from \"./validate\";\n");
body.push_str("export * from \"./validation-middleware\";\n");
body.push_str("export * from \"./ratelimit\";\n");
Expand All @@ -286,7 +292,7 @@ fn collect_index(doc: &Document, out_dir: &Path, has_i18n: bool) -> std::io::Res

// Models.
for (_, model) in doc.schemas.iter() {
let name = name::pascal(model.name());
let name = name::safe_model_name(&name::pascal(model.name()));
body.push_str(&format!("export * from \"./models/{name}\";\n"));
}
// Webhooks.
Expand Down
Loading
Loading