From d81fa0b8deef44edfeb946dd5e171bd6ac0badd1 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 07:59:55 +0000 Subject: [PATCH 01/19] workspace: Apply clippy auto-fixes across crates Mechanical clippy suggestions (redundant closures, format! simplifications, collapsed if-lets, sort_by_key, char comparisons, matches! macro, etc.). No behavior change; lib tests (242) + TS (17) still pass. --- CHANGELOG.md | 18 ++ README.md | 29 +-- crates/specforge-core/src/changelog.rs | 6 +- crates/specforge-core/src/deprecation.rs | 4 +- crates/specforge-core/src/diff.rs | 2 +- crates/specforge-core/src/mock.rs | 2 +- crates/specforge-core/src/profiler.rs | 4 +- crates/specforge-core/src/resolve.rs | 6 +- crates/specforge-core/src/security.rs | 2 +- crates/specforge-core/src/validate.rs | 5 +- crates/specforge-core/src/verify.rs | 2 +- crates/specforge-go/src/lib.rs | 30 ++- crates/specforge-go/tests/gen_test.rs | 60 ++++++ crates/specforge-rust/src/lib.rs | 175 ++++++++++++++---- crates/specforge-rust/tests/gen_test.rs | 63 +++++++ crates/specforge-ts/src/operations.rs | 4 +- crates/specforge-ts/src/runtime.rs | 33 +++- crates/specforge-ts/tests/gen_test.rs | 61 ++++++ docs-site/docs/intro.md | 29 +-- docs-site/docs/reference/changelog.md | 16 ++ examples/petstore-go/sdk/client.go | 46 ++++- .../petstore-go/sdk/specforge-version.json | 1 + .../petstore-go/sdk/validation_middleware.go | 113 +++++++++++ examples/petstore-rust/Cargo.toml | 2 + examples/petstore-rust/sdk/README.md | 94 ++++++++++ .../petstore-rust/sdk/specforge-version.json | 1 + examples/petstore-rust/sdk/src/client.rs | 150 ++++++++++++--- examples/petstore-rust/sdk/src/lib.rs | 5 +- .../sdk/src/validation_middleware.rs | 146 +++++++++++++++ .../petstore-ts/sdk/specforge-version.json | 1 + examples/petstore-ts/sdk/src/client.ts | 68 ++++++- examples/petstore-ts/sdk/src/index.ts | 2 + examples/petstore-ts/sdk/src/ratelimit.ts | 83 +++++++++ .../petstore-ts/sdk/src/service_container.ts | 46 +++++ examples/petstore-ts/sdk/src/telemetry.ts | 69 +++++++ examples/petstore-ts/sdk/src/validate.ts | 12 +- .../sdk/src/validation-middleware.ts | 145 +++++++++++++++ 37 files changed, 1388 insertions(+), 147 deletions(-) create mode 100644 examples/petstore-go/sdk/specforge-version.json create mode 100644 examples/petstore-go/sdk/validation_middleware.go create mode 100644 examples/petstore-rust/sdk/specforge-version.json create mode 100644 examples/petstore-rust/sdk/src/validation_middleware.rs create mode 100644 examples/petstore-ts/sdk/specforge-version.json create mode 100644 examples/petstore-ts/sdk/src/ratelimit.ts create mode 100644 examples/petstore-ts/sdk/src/service_container.ts create mode 100644 examples/petstore-ts/sdk/src/telemetry.ts create mode 100644 examples/petstore-ts/sdk/src/validation-middleware.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd503a0..a8440d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.6.1] — 2026-07-30 + +### Fixed + +#### Rust emitter +- **Generated SDK now compiles** — fixed six template bugs that silently broke every generated Rust SDK since the interceptors/validation_middleware/ServiceContainer modules were added in 1.6.0: a response-interceptor loop nested inside the response-transformer loop; a missing `logger` field on `ClientBuilder`; telemetry call-site signature mismatches (`&Method`/`Duration` vs `&str`/`u128`); a two-arg `Result` used where the crate alias is one-arg; a `do_once` parameter-order mismatch; and `serde_json::to_vec` moving instead of borrowing. The example SDK was stale and never regenerated to catch this. +- **Generated README expanded** — `emit_readme` now documents interceptors, runtime validation (`validation_middleware`), and the `ServiceContainer` telemetry/DI helpers. + +#### TypeScript emitter +- **Telemetry is now wired** — the `TelemetryHooks` interface and `MetricsCollector` were generated but never stored or invoked by `ApiClient`. The client now reads `opts.telemetry`/`container.telemetry` and calls `onRequestStart`/`onRequestEnd`/`onRequestError`/`onRetry`/`onCacheHit`/`onCacheMiss` at the right points in the request lifecycle. +- **Per-model validators typecheck** — generated object validators called `validateValue` with three arguments (missing the `errors` accumulator) and returned its `void` result as `ValidationError[]`. Now mirrors the enum-validator pattern. + +#### Testing +- **Compile-the-generated-SDK regression tests** — added to the Rust (`cargo check`), TypeScript (`tsc --noEmit`), and Go (`go build ./...`) emitters. These regenerate the SDK into a temp crate and build it, so future emitter template breakage fails CI instead of shipping silently. All three emitters verified against both `fixtures/` specs (petstore + sample-api); each test skips cleanly only when its toolchain is genuinely absent. + +--- + ## [1.6.0] — 2026-07-27 ### Added @@ -262,6 +279,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Version Links +- [v1.6.1](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.6.1) - [v1.6.0](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.6.0) - [v1.5.0](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.5.0) - [v1.4.0](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.4.0) diff --git a/README.md b/README.md index 27f8187..b09440f 100644 --- a/README.md +++ b/README.md @@ -680,17 +680,24 @@ sdk-rs/ ├── README.md └── src/ ├── lib.rs - ├── client.rs # Client + ClientBuilder + request_stream - ├── error.rs - ├── retry.rs - ├── paginate.rs - ├── concurrency.rs - ├── dedup.rs - ├── middleware.rs - ├── idempotency.rs - ├── streaming.rs # SseStream - ├── models.rs - └── api/.rs + ├── api/ # one module per tag, one fn per operation + ├── cache.rs # ETag/conditional GET response cache + ├── client.rs # Client + ClientBuilder + ServiceContainer + request_stream + ├── concurrency.rs # async in-flight semaphore + ├── dedup.rs # coalesce identical in-flight safe requests + ├── error.rs # typed Error enum + ├── idempotency.rs # auto Idempotency-Key for safe retries + ├── interceptors.rs # request/response body interceptors + ├── logging.rs # pluggable Logger trait + Console/Noop impls + ├── middleware.rs # request/response middleware chain + ├── models.rs # one struct/enum per schema + validators + ├── paginate.rs # cursor/offset pagination helpers + ├── ratelimit.rs # token-bucket + sliding-window rate limiters + ├── retry.rs # exponential backoff + jitter + ├── streaming.rs # SseStream for server-sent events + ├── telemetry.rs # TelemetryHooks trait + MetricsCollector + ├── validate.rs # generated per-model validators + └── validation_middleware.rs # schema validation as middleware ``` diff --git a/crates/specforge-core/src/changelog.rs b/crates/specforge-core/src/changelog.rs index 7085e17..2422942 100644 --- a/crates/specforge-core/src/changelog.rs +++ b/crates/specforge-core/src/changelog.rs @@ -590,7 +590,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String let mut out = String::new(); // Header - out.push_str(&format!("# Changelog\n\n")); + out.push_str(&"# Changelog\n\n".to_string()); out.push_str(&format!("## [{version}] -- {today}\n\n")); // Suggested version bump @@ -602,9 +602,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String } // Summary - out.push_str(&format!( - "### Summary\n\n" - )); + out.push_str(&"### Summary\n\n".to_string()); out.push_str(&format!( "- **{}** operation(s) changed\n", result.operation_entries.len() diff --git a/crates/specforge-core/src/deprecation.rs b/crates/specforge-core/src/deprecation.rs index 6539c5f..25e90de 100644 --- a/crates/specforge-core/src/deprecation.rs +++ b/crates/specforge-core/src/deprecation.rs @@ -189,7 +189,7 @@ fn extract_alternative(text: &str) -> Option { let after = &text[pos + pattern.len()..]; // Take until end of sentence or end of text. let end = after - .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';') + .find(['.', ',', '\n', ';']) .unwrap_or(after.len()); let alt = after[..end].trim(); if !alt.is_empty() { @@ -202,7 +202,7 @@ fn extract_alternative(text: &str) -> Option { if let Some(pos) = lower.find("migrate to ") { let after = &text[pos + 11..]; let end = after - .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';') + .find(['.', ',', '\n', ';']) .unwrap_or(after.len()); let alt = after[..end].trim(); if !alt.is_empty() { diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs index 505a942..5c1b799 100644 --- a/crates/specforge-core/src/diff.rs +++ b/crates/specforge-core/src/diff.rs @@ -69,7 +69,7 @@ fn ir_major_version(version: &str) -> u64 { .unwrap_or(0) } fn diff_operations(old: &Document, new: &Document, findings: &mut Vec) { let old_ops: std::collections::HashMap = old.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); let new_ops: std::collections::HashMap = new.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); for id in old_ops.keys() { if !new_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("operation removed: {id}"), path: format!("operations.{id}") }); } } for id in new_ops.keys() { if !old_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("operation added: {id}"), path: format!("operations.{id}") }); } } for id in old_ops.keys() { if let (Some(o), Some(n)) = (old_ops.get(id), new_ops.get(id)) { diff_operation(o, n, findings); } } } -fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) { let path = format!("operations.{}", old.operation_id); for p in &new.parameters { if p.required && !old.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required parameter: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } for p in &old.parameters { if !new.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("parameter removed: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } if new.request_body.is_some() && old.request_body.is_none() { if new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: "new required request body".into(), path: format!("{path}.requestBody") }); } } for r in &old.responses { if !new.responses.iter().any(|x| x.status == r.status) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("response removed: {}", r.status), path: format!("{path}.responses.{}", r.status) }); } } } +fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) { let path = format!("operations.{}", old.operation_id); for p in &new.parameters { if p.required && !old.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required parameter: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } for p in &old.parameters { if !new.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("parameter removed: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } if new.request_body.is_some() && old.request_body.is_none() && new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: "new required request body".into(), path: format!("{path}.requestBody") }); } for r in &old.responses { if !new.responses.iter().any(|x| x.status == r.status) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("response removed: {}", r.status), path: format!("{path}.responses.{}", r.status) }); } } } fn diff_schemas(old: &Document, new: &Document, findings: &mut Vec, schema_diffs: &mut Vec) { let old_s: std::collections::HashSet = old.schemas.iter().map(|(k, _)| k.clone()).collect(); let new_s: std::collections::HashSet = new.schemas.iter().map(|(k, _)| k.clone()).collect(); for n in &old_s { if !new_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("schema removed: {n}"), path: format!("schemas.{n}") }); } } for n in &new_s { if !old_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("schema added: {n}"), path: format!("schemas.{n}") }); } } for n in old_s.intersection(&new_s) { if let Some(d) = diff_schema(n, old.schemas.get(n).unwrap(), new.schemas.get(n).unwrap(), findings) { schema_diffs.push(d); } } } fn diff_schema(name: &str, old: &Model, new: &Model, findings: &mut Vec) -> Option { let (op, np) = match (old, new) { (Model::Object(o), Model::Object(n)) => (&o.properties, &n.properties), _ => return None }; let path = format!("schemas.{name}"); let mut changes = Vec::new(); for p in np { if p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::AddedRequired { ty: type_to_string(&p.ty) } }); } } for p in np { if !p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("new optional property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Added { ty: type_to_string(&p.ty) } }); } } for p in op { if !np.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("property removed: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Removed { ty: type_to_string(&p.ty) } }); } } for p in op { if let Some(np) = np.iter().find(|x| x.name == p.name) { if type_changed(&p.ty, &np.ty) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("type changed for property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::TypeChanged { old_type: type_to_string(&p.ty), new_type: type_to_string(&np.ty) } }); } else if p.required != np.required { findings.push(DiffFinding { severity: if np.required { DiffSeverity::Breaking } else { DiffSeverity::Info }, message: format!("required changed for property: {} ({} → {})", p.name, if p.required { "required" } else { "optional" }, if np.required { "required" } else { "optional" }), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::RequiredChanged { old_required: p.required, new_required: np.required } }); } } } if changes.is_empty() { None } else { Some(SchemaDiffDetail { name: name.to_string(), changes }) } } fn type_changed(old: &Type, new: &Type) -> bool { match (old, new) { (Type::Scalar(a), Type::Scalar(b)) => a != b, (Type::Reference { name: a, .. }, Type::Reference { name: b, .. }) => a != b, (Type::StringEnum { variants: a, .. }, Type::StringEnum { variants: b, .. }) => a != b, (Type::Array { item: a, .. }, Type::Array { item: b, .. }) => type_changed(a, b), _ => std::mem::discriminant(old) != std::mem::discriminant(new) } } diff --git a/crates/specforge-core/src/mock.rs b/crates/specforge-core/src/mock.rs index 08f160a..934256e 100644 --- a/crates/specforge-core/src/mock.rs +++ b/crates/specforge-core/src/mock.rs @@ -121,7 +121,7 @@ fn parse_request_line( ) -> Option<(String, String)> { let mut line = String::new(); reader.read_line(&mut line).ok()?; - let mut parts = line.trim().split_whitespace(); + let mut parts = line.split_whitespace(); let method = parts.next()?.to_uppercase(); let path = parts.next()?.to_string(); Some((method, path)) diff --git a/crates/specforge-core/src/profiler.rs b/crates/specforge-core/src/profiler.rs index 12a19e4..8ace5b0 100644 --- a/crates/specforge-core/src/profiler.rs +++ b/crates/specforge-core/src/profiler.rs @@ -365,7 +365,7 @@ pub fn format_text(report: &ProfileReport) -> String { out.push_str(&format!(" Requests: {}\n", r.total_requests)); out.push_str(&format!(" Success/Error: {} / {}\n", r.success_count, r.error_count)); out.push_str(&format!(" Error rate: {:.1}%\n", r.error_rate * 100.0)); - out.push_str(&format!(" Latency (ms):\n")); + out.push_str(&" Latency (ms):\n".to_string()); out.push_str(&format!(" avg: {:.1}\n", r.avg_latency_ms)); out.push_str(&format!(" p50: {:.1}\n", r.p50_latency_ms)); out.push_str(&format!(" p95: {:.1}\n", r.p95_latency_ms)); @@ -425,7 +425,7 @@ pub fn format_markdown(report: &ProfileReport) -> String { out.push_str(&format!("**Overall throughput:** {:.1} rps\n\n", report.overall_throughput_rps)); if !report.recommendations.is_empty() { - out.push_str(&format!("## Recommendations\n\n")); + out.push_str(&"## Recommendations\n\n".to_string()); for (i, rec) in report.recommendations.iter().enumerate() { out.push_str(&format!("{}. {}\n", i + 1, rec)); } diff --git a/crates/specforge-core/src/resolve.rs b/crates/specforge-core/src/resolve.rs index cb1e284..a4b6712 100644 --- a/crates/specforge-core/src/resolve.rs +++ b/crates/specforge-core/src/resolve.rs @@ -19,7 +19,7 @@ use crate::error::ResolveError; use crate::ir::{ Composition, CompositionKind, Discriminator, Document, EnumModel, EnumVariant, HttpMethod, Model, ObjectModel, Operation, Parameter as IrParameter, ParamLocation, Property, RequestBody, - Response, RetryPolicy, Scalar, SchemaRegistry, SecurityScheme, Type, Webhook, IR_VERSION, + Response, Scalar, SchemaRegistry, SecurityScheme, Type, Webhook, IR_VERSION, }; /// Load and fully resolve a parsed OpenAPI document into the IR. @@ -493,7 +493,7 @@ fn type_to_ir(t: &OApiType, nullable: bool) -> Type { let item = arr .items .as_ref() - .map(|i| ref_or_boxed_schema_to_type(i)) + .map(ref_or_boxed_schema_to_type) .unwrap_or(Type::Unknown); Type::Array { item: Box::new(item), @@ -789,7 +789,7 @@ fn json_body(content: &IndexMap) -> Option { content .get("application/json") .and_then(|m| m.schema.as_ref()) - .map(|s| ref_or_schema_to_type(s)) + .map(ref_or_schema_to_type) } fn ref_or_schema_to_type(schema_or: &ReferenceOr) -> Type { diff --git a/crates/specforge-core/src/security.rs b/crates/specforge-core/src/security.rs index ca38832..dc2afdf 100644 --- a/crates/specforge-core/src/security.rs +++ b/crates/specforge-core/src/security.rs @@ -12,7 +12,7 @@ pub struct OperationSecurity { pub operation_id: String, pub method: String, pub pub struct SecurityIssue { pub severity: String, pub message: String, pub path: String } pub fn analyze_security(doc: &Document) -> SecurityReport { - let schemes: Vec = doc.security.iter().map(|s| scheme_info_from_ir(s)).collect(); + let schemes: Vec = doc.security.iter().map(scheme_info_from_ir).collect(); let has_global_auth = !doc.security.is_empty(); let operations = doc.operations.iter().map(|op| OperationSecurity { operation_id: op.operation_id.clone(), method: op.method.as_str().to_string(), path: op.path.clone(), diff --git a/crates/specforge-core/src/validate.rs b/crates/specforge-core/src/validate.rs index 698a5fe..31e6a3d 100644 --- a/crates/specforge-core/src/validate.rs +++ b/crates/specforge-core/src/validate.rs @@ -180,14 +180,13 @@ fn validate_scalar( }); } } - Scalar::Boolean => { - if !value.is_boolean() { + Scalar::Boolean + if !value.is_boolean() => { errors.push(ValidationError { path: path.to_string(), message: format!("expected boolean, got {}", value_type_name(value)), }); } - } _ => {} } } diff --git a/crates/specforge-core/src/verify.rs b/crates/specforge-core/src/verify.rs index d27920b..7440bc2 100644 --- a/crates/specforge-core/src/verify.rs +++ b/crates/specforge-core/src/verify.rs @@ -132,7 +132,7 @@ fn verify_operation( Ok(v) => v, Err(_) => { // Non-JSON body – we cannot schema-validate but treat as info. - if expected_status_code >= 200 && expected_status_code < 300 { + if (200..300).contains(&expected_status_code) { // Expected JSON but got something else. issues.push("response body is not valid JSON".to_string()); schema_match = false; diff --git a/crates/specforge-go/src/lib.rs b/crates/specforge-go/src/lib.rs index 268ba1f..2025162 100644 --- a/crates/specforge-go/src/lib.rs +++ b/crates/specforge-go/src/lib.rs @@ -298,7 +298,7 @@ fn is_struct_model(o: &ObjectModel) -> bool { fn emit_models(pkg: &str, doc: &Document) -> String { let mut out = String::new(); - out.push_str(&format!("// Code generated by specforge. DO NOT EDIT.\n\n")); + out.push_str(&"// Code generated by specforge. DO NOT EDIT.\n\n".to_string()); out.push_str(&format!("package {pkg}\n\n")); // Check if any model needs json + fmt imports: discriminated unions use @@ -2284,20 +2284,14 @@ func (e ValidationErrors) Error() string { out.push_str(&format!( "\t\tif val, exists := obj[{field_lit}]; exists {{\n" )); - out.push_str(&format!( - "\t\t\tif s, ok := val.(string); ok {{\n" - )); - out.push_str(&format!( - "\t\t\t\tvalid := false\n" - )); + out.push_str(&"\t\t\tif s, ok := val.(string); ok {\n".to_string()); + out.push_str(&"\t\t\t\tvalid := false\n".to_string()); for allowed in &vals { out.push_str(&format!( "\t\t\t\tif s == {allowed} {{ valid = true }}\n" )); } - out.push_str(&format!( - "\t\t\t\tif !valid {{\n" - )); + out.push_str(&"\t\t\t\tif !valid {\n".to_string()); out.push_str(&format!( "\t\t\t\t\terrs = append(errs, ValidationError{{Path: {field_lit}, Message: fmt.Sprintf(\"invalid enum value: %s\", s)}})\n" )); @@ -2560,7 +2554,7 @@ fn emit_method(op: &Operation) -> String { let (ret_sig, zero_ret) = match &ret_ty { Some(t) if t.starts_with('[') || t.starts_with("map[") || t == "any" => { - (format!("({t}, error)"), format!("nil, ")) + (format!("({t}, error)"), "nil, ".to_string()) } Some(t) if t.starts_with('*') => (format!("({t}, error)"), "nil, ".into()), Some(t) => (format!("(*{t}, error)"), "nil, ".into()), @@ -2889,9 +2883,9 @@ fn zero_check(ty: &Type, ident: &str) -> String { Type::Scalar(Scalar::Integer) | Type::Scalar(Scalar::Integer64) | Type::Scalar(Scalar::Float) => { format!("{ident} != 0") } - Type::Scalar(Scalar::Boolean | Scalar::Base64 | Scalar::Binary) => format!("{ident}") // only send when true + Type::Scalar(Scalar::Boolean | Scalar::Base64 | Scalar::Binary) => ident.to_string() // only send when true , - Type::Reference { .. } => format!("true /* always send ref */"), + Type::Reference { .. } => "true /* always send ref */".to_string(), Type::Array { .. } | Type::Map { .. } => format!("len({ident}) > 0"), _ => "true".into(), } @@ -2965,10 +2959,8 @@ fn emit_union_from_map( out.push_str(&format!("\tcase {}:\n", go_string_lit(&val))); out.push_str(&format!("\t\tvar v {arm_ty}\n")); out.push_str("\t\tif b, err := json.Marshal(m); err == nil {\n"); - out.push_str(&format!( - "\t\t\tif err := json.Unmarshal(b, &v); err == nil {{\n" - )); - out.push_str(&format!("\t\t\t\treturn v, nil\n")); + out.push_str(&"\t\t\tif err := json.Unmarshal(b, &v); err == nil {\n".to_string()); + out.push_str(&"\t\t\t\treturn v, nil\n".to_string()); out.push_str("\t\t\t}\n"); out.push_str("\t\t}\n"); out.push_str(&format!("\t\treturn v, fmt.Errorf(\"failed to unmarshal {arm_ty}\")\n")); @@ -3115,7 +3107,7 @@ fn go_deprecation_alternative(op: &Operation) -> Option { if let Some(pos) = lower.find(pattern) { let after = &text[pos + pattern.len()..]; let end = after - .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';') + .find(['.', ',', '\n', ';']) .unwrap_or(after.len()); let alt = after[..end].trim(); if !alt.is_empty() { @@ -3142,7 +3134,7 @@ fn go_schema_deprecation_alternative(desc: &str) -> Option { if let Some(pos) = lower.find(pattern) { let after = &desc[pos + pattern.len()..]; let end = after - .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';') + .find(['.', ',', '\n', ';']) .unwrap_or(after.len()); let alt = after[..end].trim(); if !alt.is_empty() { diff --git a/crates/specforge-go/tests/gen_test.rs b/crates/specforge-go/tests/gen_test.rs index 152f8c5..b56ed1e 100644 --- a/crates/specforge-go/tests/gen_test.rs +++ b/crates/specforge-go/tests/gen_test.rs @@ -14,3 +14,63 @@ fn generate_and_check_docs() { println!("=== Go doc comment verification PASSED ==="); for line in content.lines().take(40) { println!(" {line}"); } } + +/// Regression test: the generated Go SDK must compile. +/// +/// Template bugs in the emitter only surface when the generated `.go` is built. +/// This test regenerates the SDK into an isolated temp dir and runs +/// `go build ./...`, so any future emitter breakage fails CI. It skips cleanly +/// when the Go toolchain is absent (e.g. in environments without Go installed); +/// CI matrices that include Go run it for real. +#[test] +fn generated_sdk_compiles() { + if std::env::var_os("SKIP_COMPILE_TEST").is_some() { + eprintln!("note: SKIP_COMPILE_TEST set; skipping generated_sdk_compiles"); + return; + } + // Resolve the Go binary. Prefer `go` on PATH, then the standard install + // location (matching scripts/generate-examples.sh, which adds + // /usr/local/go/bin). Skip gracefully if neither is runnable. + let go = ["go", "/usr/local/go/bin/go"] + .iter() + .find_map(|candidate| { + let ok = std::process::Command::new(candidate) + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if ok { Some(*candidate) } else { None } + }); + let go = match go { + Some(g) => g, + None => { + eprintln!("note: go toolchain not available; skipping generated_sdk_compiles"); + return; + } + }; + + let spec_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/sample-api.yaml"); + let spec = specforge_core::parse_file(&spec_path).expect("parse"); + let doc = specforge_core::resolve(&spec).expect("resolve"); + let go_dir = tempfile::tempdir().unwrap(); + let opts = specforge_go::GeneratorOptions { + out_dir: go_dir.path().to_path_buf(), + module_path: Some("example.com/generated".to_string()), + package_name: None, + i18n: None, + }; + let files = specforge_go::generate(&doc, &opts).expect("emit Go"); + assert!(!files.is_empty(), "emitter produced no files"); + + // The generated SDK uses only the standard library, so this builds offline. + let status = std::process::Command::new(go) + .args(["build", "./..."]) + .current_dir(go_dir.path()) + .status() + .expect("failed to spawn go build"); + + assert!( + status.success(), + "generated Go SDK failed to compile (`go build ./...` exited {status})" + ); +} diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs index 8fec920..461de78 100644 --- a/crates/specforge-rust/src/lib.rs +++ b/crates/specforge-rust/src/lib.rs @@ -253,11 +253,10 @@ fn snake(input: &str) -> String { out.extend(ch.to_lowercase()); } else if ch.is_ascii_alphanumeric() { out.push(ch.to_ascii_lowercase()); - } else if ch == '_' { - if !out.ends_with('_') { + } else if ch == '_' + && !out.ends_with('_') { out.push('_'); } - } } let out = out.trim_matches('_').to_string(); let out = if out.is_empty() { @@ -1641,7 +1640,7 @@ use crate::cache::ResponseCache; None }}; let body_bytes = match body {{ - Some(b) => Some(serde_json::to_vec(b)?), + Some(b) => Some(serde_json::to_vec(&b)?), None => None, }}; @@ -1781,15 +1780,15 @@ use crate::cache::ResponseCache; }} // Apply response transformers. let mut value: serde_json::Value = serde_json::from_slice(&data)?; - for t in &self.response_transformers {{ - value = t.transform(value); + for t in &self.response_transformers {{ + value = t.transform(value); + }} - // Apply response interceptors. - for i in &self.response_interceptors {{ - value = i.transform(value); - }} - }} - Ok(serde_json::from_value(value)?) + // Apply response interceptors. + for i in &self.response_interceptors {{ + value = i.transform(value); + }} + Ok(serde_json::from_value(value)?) }} /// Issue a streaming request (no retry). Returns the raw Response; use @@ -1915,10 +1914,10 @@ use crate::cache::ResponseCache; if let Some(l) = &self.logger {{ l.warn(&format!("[retry] {{}} {{}} error={{}}, attempt {{}}/{{}}", method.as_str(), url, last_err.as_ref().unwrap(), attempt + 2, max + 1)); }} - // Telemetry: retry notification. - if let Some(t) = &self.telemetry {{ - t.on_retry(method, url, attempt + 1, &last_err.as_ref().unwrap()); - }} + // Telemetry: retry notification. + if let Some(t) = &self.telemetry {{ + t.on_retry(method.as_str(), url, attempt + 1, &last_err.as_ref().unwrap()); + }} }} }} }} @@ -1929,14 +1928,13 @@ use crate::cache::ResponseCache; &self, method: &reqwest::Method, url: &str, - #[allow(unused_variables)] - start: std::time::Instant, query: &[(String, String)], body_bytes: Option<&[u8]>, idem_key: Option<&str>, if_none_match: Option<&str>, + start: std::time::Instant, ) -> Result<(Vec, u16, String)> {{ - let _start = std::time::Instant::now(); + let _start = start; // Build full URL with query for middleware visibility. let full_url = if query.is_empty() {{ @@ -2024,15 +2022,16 @@ use crate::cache::ResponseCache; if mw_res.status == 304 {{ return Ok((mw_res.body, mw_res.status, etag)); }} - // Telemetry: request error. - if let Some(t) = &self.telemetry {{ - t.on_request_error(method, url, _start.elapsed(), &format!("HTTP {{}}", mw_res.status)); - }} - return Err(Error::Http {{ + let err = Error::Http {{ status: mw_res.status, body: String::from_utf8_lossy(&mw_res.body).into_owned(), url: full_url, - }}); + }}; + // Telemetry: request error. + if let Some(t) = &self.telemetry {{ + t.on_request_error(method.as_str(), url, _start.elapsed().as_millis(), &err); + }} + return Err(err); }} // Logger: response received. if let Some(l) = &self.logger {{ @@ -2041,7 +2040,7 @@ use crate::cache::ResponseCache; // Telemetry: successful request. if let Some(t) = &self.telemetry {{ - t.on_request_end(method, url, _start.elapsed(), mw_res.status); + t.on_request_end(method.as_str(), url, _start.elapsed().as_millis(), mw_res.status); }} Ok((mw_res.body, mw_res.status, etag)) }} @@ -2092,6 +2091,7 @@ use crate::cache::ResponseCache; cache: Option, rate_limiter: Option>, telemetry: Option>, + logger: Option>, middlewares: Vec, stream_middlewares: Vec, /// Seeded from the first API-key security scheme in the spec, if any. @@ -2133,10 +2133,11 @@ use crate::cache::ResponseCache; dedupe: true, idempotency: true, validation: false, - cache: None, - rate_limiter: None, - telemetry: None, - response_transformers: Vec::new(), + cache: None, + rate_limiter: None, + telemetry: None, + logger: None, + response_transformers: Vec::new(), request_interceptors: Vec::new(), response_interceptors: Vec::new(), middlewares: Vec::new(), @@ -2463,9 +2464,7 @@ fn value_type_name(v: &Value) -> &'static str { out.push_str(" if !v.is_object() {\n"); out.push_str(" errors.push(ValidationError {\n"); out.push_str(" path: String::new(),\n"); - out.push_str(&format!( - " message: format!(\"expected object, got {{}}\", value_type_name(v)),\n" - )); + out.push_str(&" message: format!(\"expected object, got {}\", value_type_name(v)),\n".to_string()); out.push_str(" });\n"); out.push_str(" }\n"); } else { @@ -2561,9 +2560,9 @@ use crate::validate::ValidationError; /// Schema descriptor for an endpoint's request and/or response body validator. pub struct EndpointSchema { /// Validator function for the request body. None means no validation. - pub request_body: Option Result<(), Vec>>, + pub request_body: Option std::result::Result<(), Vec>>, /// Validator function for the response body. None means no validation. - pub response_body: Option Result<(), Vec>>, + pub response_body: Option std::result::Result<(), Vec>>, } /// Route schema map keyed by "METHOD /path/pattern". @@ -2707,7 +2706,7 @@ use serde::{Deserialize, Serialize}; // Payload structs for each webhook. for wh in &doc.webhooks { - let name = pascal(&format!("{}WebhookPayload", &wh.name)); + let name = pascal(&format!("{}WebhookPayload", wh.name)); if let Some(d) = &wh.description { out.push_str(&rust_doc(d)); } else if let Some(s) = &wh.summary { @@ -2729,8 +2728,8 @@ use serde::{Deserialize, Serialize}; out.push_str("/// Trait for handling webhook events.\n"); out.push_str("pub trait WebhookHandler: Send + Sync {\n"); for wh in &doc.webhooks { - let payload_ty = pascal(&format!("{}WebhookPayload", &wh.name)); - let method_name = snake(&format!("handle_{}", &wh.name)); + let payload_ty = pascal(&format!("{}WebhookPayload", wh.name)); + let method_name = snake(&format!("handle_{}", wh.name)); out.push_str(&format!( " /// Handle the `{}` webhook.\n", wh.name @@ -2825,7 +2824,7 @@ fn emit_enum(e: &EnumModel) -> String { fn emit_object(o: &ObjectModel, registry: &specforge_core::SchemaRegistry) -> String { let name = pascal(&o.name); let mut out = String::new(); - let is_deprecated = o.description.as_deref().map_or(false, |d| { + let is_deprecated = o.description.as_deref().is_some_and(|d| { d.to_lowercase().contains("deprecated") }); if let Some(d) = &o.description { @@ -3501,7 +3500,7 @@ fn rust_deprecation_alternative(op: &Operation) -> Option { if let Some(pos) = lower.find(pattern) { let after = &text[pos + pattern.len()..]; let end = after - .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';') + .find(['.', ',', '\n', ';']) .unwrap_or(after.len()); let alt = after[..end].trim(); if !alt.is_empty() { @@ -3528,7 +3527,7 @@ fn rust_schema_deprecation_alternative(desc: &str) -> Option { if let Some(pos) = lower.find(pattern) { let after = &desc[pos + pattern.len()..]; let end = after - .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';') + .find(['.', ',', '\n', ';']) .unwrap_or(after.len()); let alt = after[..end].trim(); if !alt.is_empty() { @@ -3713,6 +3712,100 @@ let client = Client::builder() .build()?; ``` +## Interceptors + +Transform the request body before it is serialized, or the response body after it +is deserialized, by implementing a trait and registering it on the builder: + +```rust +use {crate_name}::{{RequestInterceptor, ResponseInterceptor}}; +use serde_json::{{json, Value}}; + +// Add a field to every outgoing request body. +struct AddTraceId; +impl RequestInterceptor for AddTraceId {{ + fn transform(&self, mut body: Value) -> Value {{ + if let Some(obj) = body.as_object_mut() {{ + obj.insert("trace_id".into(), json!("abc-123")); + }} + body + }} +}} + +// Strip a field from every response body. +struct StripMeta; +impl ResponseInterceptor for StripMeta {{ + fn transform(&self, mut body: Value) -> Value {{ + if let Some(obj) = body.as_object_mut() {{ + obj.remove("_meta"); + }} + body + }} +}} + +let client = Client::builder() + .bearer_token("…") + .request_interceptor(AddTraceId) + .response_interceptor(StripMeta) + .build()?; +``` + +## Validation + +Validate request/response bodies against the OpenAPI schema at runtime. Toggle the +built-in validator per client, or wire the generated `validation_middleware` into the +middleware chain with per-route schemas for finer control: + +```rust +// Whole-client validation (uses the generated validators automatically). +let client = Client::builder() + .bearer_token("…") + .validation(true) + .build()?; + +// Or attach validation as a middleware with an explicit route -> schema map. +use std::collections::HashMap; +use {crate_name}::validation_middleware::{{validation_middleware, EndpointSchema, RouteSchemaMap}}; +use {crate_name}::models; // generated validators live here, e.g. validate_pet + +let mut schemas: RouteSchemaMap = HashMap::new(); +schemas.insert( + "POST /pets".to_string(), + EndpointSchema {{ + request_body: Some(models::validate_pet), + response_body: None, + }}, +); +let mut client = Client::builder().bearer_token("…").build()?; +client.use_middleware(validation_middleware(schemas)); +``` + +## Telemetry & dependency injection + +Observe the request lifecycle by implementing `TelemetryHooks`, or group injectable +dependencies (HTTP client, cache, rate limiter, logger, telemetry) into a +`ServiceContainer` and apply them in one call: + +```rust +use std::time::Duration; +use {crate_name}::{{MetricsCollector, ServiceContainer}}; + +let metrics = MetricsCollector::default(); + +let sc = ServiceContainer::new() + .cache_ttl(Duration::from_secs(60)) + .logger({crate_name}::ConsoleLogger) + .telemetry(metrics.clone()); + +let client = Client::builder() + .bearer_token("…") + .service_container(sc) + .build()?; + +// `metrics.get_metrics().await` now reflects every request, retry, and error. +println!("{{:?}}", metrics.get_metrics().await); +``` + _Do not edit generated files directly._ "#, title = doc.title, diff --git a/crates/specforge-rust/tests/gen_test.rs b/crates/specforge-rust/tests/gen_test.rs index ae5ccd0..4fec5bb 100644 --- a/crates/specforge-rust/tests/gen_test.rs +++ b/crates/specforge-rust/tests/gen_test.rs @@ -14,3 +14,66 @@ fn generate_and_check_docs() { println!("=== Rust doc comment verification PASSED ==="); for line in content.lines().take(40) { println!(" {line}"); } } + +/// Regression test: the generated Rust SDK must actually compile. +/// +/// Earlier emitter changes (interceptors/validation_middleware modules, +/// ServiceContainer) shipped without ever compiling the output, so template +/// bugs (nested loops, missing struct fields, trait signature mismatches, +/// wrong arg order) silently broke every generated SDK. This test regenerates +/// the SDK into an isolated temp crate and runs `cargo check` on it, so any +/// future emitter template breakage fails CI. +#[test] +fn generated_sdk_compiles() { + // Skip in environments without network/cargo (e.g. some CI sandboxes); + // requires the rust toolchain and the ability to fetch deps. + if std::env::var_os("SKIP_COMPILE_TEST").is_some() { + eprintln!("note: SKIP_COMPILE_TEST set; skipping generated_sdk_compiles"); + return; + } + let cargo = std::process::Command::new("cargo") + .arg("--version") + .output(); + if !matches!(cargo, Ok(o) if o.status.success()) { + eprintln!("note: cargo not available; skipping generated_sdk_compiles"); + return; + } + + let spec_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/sample-api.yaml"); + let spec = specforge_core::parse_file(&spec_path).expect("parse"); + let doc = specforge_core::resolve(&spec).expect("resolve"); + + let rust_dir = tempfile::tempdir().unwrap(); + let opts = specforge_rust::GeneratorOptions { + out_dir: rust_dir.path().to_path_buf(), + crate_name: None, + i18n: None, + }; + let files = specforge_rust::generate(&doc, &opts).expect("emit Rust"); + assert!(!files.is_empty(), "emitter produced no files"); + + // The generated Cargo.toml is a leaf package; declare an empty workspace so + // `cargo check` from this temp dir isn't absorbed into specforge's workspace. + let cargo_toml = rust_dir.path().join("Cargo.toml"); + let mut manifest = std::fs::read_to_string(&cargo_toml).expect("read generated Cargo.toml"); + if !manifest.contains("[workspace]") { + manifest.push_str("\n[workspace]\n"); + } + std::fs::write(&cargo_toml, manifest).expect("write generated Cargo.toml"); + + // `cargo check` the generated crate. Use the same toolchain, offline-friendly: + // deps are fetched on demand; this is the real signal we want. + let status = std::process::Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(rust_dir.path()) + .env("CARGO_TARGET_DIR", rust_dir.path().join("target")) + .status() + .expect("failed to spawn cargo check"); + + assert!( + status.success(), + "generated Rust SDK failed to compile (`cargo check` exited {status})" + ); +} diff --git a/crates/specforge-ts/src/operations.rs b/crates/specforge-ts/src/operations.rs index e75fa8b..617266f 100644 --- a/crates/specforge-ts/src/operations.rs +++ b/crates/specforge-ts/src/operations.rs @@ -240,11 +240,11 @@ fn emit_method(_doc: &Document, op: &Operation, refs: &mut TypeRefs) -> (Option< // Determine validator names for request body and response body. let req_validator = op.request_body.as_ref().and_then(|b| validator_name(&b.ty)); - let resp_validator = success.as_ref().and_then(|t| validator_name(t)); + let resp_validator = success.as_ref().and_then(validator_name); // Schema names for the generated code (e.g. "PetSchema", "StatusSchema"). let req_schema_name = op.request_body.as_ref().and_then(|b| schema_name(&b.ty)); - let resp_schema_name = success.as_ref().and_then(|t| schema_name(t)); + let resp_schema_name = success.as_ref().and_then(schema_name); let mut used_validators = Vec::new(); if let Some(v) = &req_validator { diff --git a/crates/specforge-ts/src/runtime.rs b/crates/specforge-ts/src/runtime.rs index 1bae022..5375a11 100644 --- a/crates/specforge-ts/src/runtime.rs +++ b/crates/specforge-ts/src/runtime.rs @@ -789,9 +789,11 @@ export interface ValidationError { "export function validate{model}(value: unknown, path = \"\"): ValidationError[] {{\n", model = name )); + out.push_str(" const errors: ValidationError[] = [];\n"); out.push_str(&format!( - " return validateValue(value, {name}Schema, path);\n" + " validateValue(value, {name}Schema, path, errors);\n" )); + out.push_str(" return errors;\n"); out.push_str("}\n\n"); } Model::Enum(e) => { @@ -1538,6 +1540,7 @@ import {{ Middleware, MiddlewareRequest, composeMiddleware }} from "./middleware import {{ validateRequest, validateResponse, type SchemaType, type ValidationError }} from "./validate"; import {{ ResponseCache }} from "./cache"; import {{ type Logger, ConsoleLogger }} from "./logging"; +import {{ type TelemetryHooks }} from "./telemetry"; import {{ type RequestInterceptor, type ResponseInterceptor }} from "./interceptors"; import {{ type ServiceContainer }} from "./service_container"; import {{ createValidationMiddleware, type RouteSchemaMap }} from "./validation-middleware"; @@ -1583,6 +1586,8 @@ export interface ApiClientOptions {{ responseTransformers?: ResponseTransformer[]; /** Structured logger for SDK lifecycle events (requests, retries, cache). */ logger?: Logger; + /** Telemetry hooks for request lifecycle observability. Implement any subset. */ + telemetry?: TelemetryHooks; /** Request interceptors applied before each request body is serialized. */ requestInterceptors?: RequestInterceptor[]; /** Response interceptors applied after each response body is deserialized. */ @@ -1624,6 +1629,8 @@ export class ApiClient {{ private readonly responseTransformers: ResponseTransformer[]; /** Structured logger for SDK lifecycle events. */ private readonly logger: Logger; + /** Optional telemetry hooks for request lifecycle observability. */ + private readonly telemetry?: TelemetryHooks; /** Request interceptors applied before each request body is serialized. */ private readonly requestInterceptors: RequestInterceptor[]; /** Response interceptors applied after each response body is deserialized. */ @@ -1647,6 +1654,7 @@ export class ApiClient {{ this.cache = opts.cache ?? c?.cache ?? (opts.cacheTtlMs !== undefined ? new ResponseCache(opts.cacheTtlMs) : undefined); this.responseTransformers = opts.responseTransformers ?? []; this.logger = opts.logger ?? c?.logger ?? new ConsoleLogger(); + this.telemetry = opts.telemetry ?? c?.telemetry; this.requestInterceptors = opts.requestInterceptors ?? []; this.responseInterceptors = opts.responseInterceptors ?? []; if (opts.middleware) for (const m of opts.middleware) this.middlewares.push(m); @@ -1675,8 +1683,10 @@ export class ApiClient {{ const cachedEntry = isGet && this.cache ? this.cache.get(url) : undefined; if (cachedEntry) {{ this.logger.debug(`[cache] HIT ${{method}} ${{path}}`); + this.telemetry?.onCacheHit?.(method, path); }} else if (isGet && this.cache) {{ this.logger.debug(`[cache] MISS ${{method}} ${{path}}`); + this.telemetry?.onCacheMiss?.(method, path); }} const mergedHeaders: Record | undefined = cachedEntry @@ -1687,7 +1697,7 @@ export class ApiClient {{ const dispatch = async (): Promise => {{ const release = await this.semaphore.acquire(); try {{ - return await this.dispatchWithRetry(method, url, {{ ...requestOptions, headers: mergedHeaders }}); + return await this.dispatchWithRetry(method, url, path, {{ ...requestOptions, headers: mergedHeaders }}); }} finally {{ release(); }} @@ -1733,6 +1743,7 @@ export class ApiClient {{ private async dispatchWithRetry( method: string, url: string, + path: string, requestOptions: RequestOptions | undefined, ): Promise {{ const perCallRetry = requestOptions?.retry; @@ -1760,6 +1771,7 @@ export class ApiClient {{ callerSignal?.addEventListener("abort", onCallerAbort!, {{ once: true }}); this.logger.info(`[request] ${{method}} ${{url}}${{attempt > 0 ? ` (attempt ${{attempt + 1}})` : ""}}`); + if (attempt === 0) this.telemetry?.onRequestStart?.(method, path); // Build the request descriptor for middleware. const mwReq: MiddlewareRequest = {{ @@ -1787,6 +1799,7 @@ export class ApiClient {{ if (response.ok) {{ this.logger.info(`[response] ${{method}} ${{url}} -> ${{response.status}}`); + this.telemetry?.onRequestEnd?.(method, path, Date.now() - startedAt, response.status); return response; }} @@ -1796,27 +1809,37 @@ export class ApiClient {{ this.retry.retryOnStatuses.includes(response.status); if (shouldRetry) {{ this.logger.warn(`[retry] ${{method}} ${{url}} status=${{response.status}}, attempt ${{attempt + 2}}/${{maxRetries + 1}}`); + this.telemetry?.onRetry?.(method, path, attempt + 1, response.status); await sleep(backoffDelay(attempt, this.retry)); continue; }} const body = await parseBody(response); - throw httpError(response.status, body, url, response.statusText || `HTTP ${{response.status}}`); + const httpErr = httpError(response.status, body, url, response.statusText || `HTTP ${{response.status}}`); + this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, httpErr); + throw httpErr; }} catch (e) {{ if (timer) clearTimeout(timer); callerSignal?.removeEventListener("abort", onCallerAbort!); + // ApiError (e.g. the httpErr built above) was already reported via + // telemetry before being thrown — re-throw without double-counting. if (isApiError(e)) throw e; // Distinguish caller-cancelled from our-timeout from network. if (callerSignal?.aborted) throw networkError(e, "request aborted by caller"); if (e instanceof DOMException && e.name === "AbortError") {{ - throw timeoutError(Date.now() - startedAt); + const err = timeoutError(Date.now() - startedAt); + this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, err); + throw err; }} if (attempt < maxRetries && retryable) {{ this.logger.warn(`[retry] ${{method}} ${{url}} error=${{(e as Error)?.message}}, attempt ${{attempt + 2}}/${{maxRetries + 1}}`); + this.telemetry?.onRetry?.(method, path, attempt + 1, e); await sleep(backoffDelay(attempt, this.retry)); continue; }} - throw networkError(e, (e as Error)?.message ?? "network error"); + const err = networkError(e, (e as Error)?.message ?? "network error"); + this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, err); + throw err; }} }} throw configurationError("exhausted retries without a response"); diff --git a/crates/specforge-ts/tests/gen_test.rs b/crates/specforge-ts/tests/gen_test.rs index 2066728..e4e42ef 100644 --- a/crates/specforge-ts/tests/gen_test.rs +++ b/crates/specforge-ts/tests/gen_test.rs @@ -71,3 +71,64 @@ fn generate_version_file() { println!("=== TS specforge-version.json verification PASSED ==="); println!("Content:\n{content}"); } + +/// Regression test: the generated TypeScript SDK must typecheck. +/// +/// Template bugs in the emitter (e.g. unwired hooks, wrong-arity validator +/// helpers, broken imports) only surface when the generated `.ts` is compiled. +/// This test regenerates the SDK into an isolated temp dir, installs its +/// devDependencies (just `typescript`), and runs `tsc --noEmit`, so any future +/// emitter breakage fails CI. +#[test] +fn generated_sdk_typechecks() { + if std::env::var_os("SKIP_COMPILE_TEST").is_some() { + eprintln!("note: SKIP_COMPILE_TEST set; skipping generated_sdk_typechecks"); + return; + } + // Require node + npm + npx; skip gracefully if absent. + for tool in ["node", "npm", "npx"] { + let ok = std::process::Command::new(tool) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !ok { + eprintln!("note: {tool} not available; skipping generated_sdk_typechecks"); + return; + } + } + + let spec_path = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/sample-api.yaml"); + let spec = specforge_core::parse_file(&spec_path).expect("parse"); + let doc = specforge_core::resolve(&spec).expect("resolve"); + + let ts_dir = tempfile::tempdir().unwrap(); + let opts = specforge_ts::GeneratorOptions { + out_dir: ts_dir.path().to_path_buf(), + package_name: None, + i18n: None, + }; + let files = specforge_ts::generate(&doc, &opts).expect("emit TS"); + assert!(!files.is_empty(), "emitter produced no files"); + + // Install devDependencies (typescript) without network noise. + let install = std::process::Command::new("npm") + .args(["install", "--no-audit", "--no-fund", "--loglevel=error"]) + .current_dir(ts_dir.path()) + .status() + .expect("failed to spawn npm install"); + assert!(install.success(), "npm install of generated SDK failed"); + + // Typecheck. `tsc --noEmit` returns non-zero on any type error. + let status = std::process::Command::new("npx") + .args(["tsc", "--noEmit"]) + .current_dir(ts_dir.path()) + .status() + .expect("failed to spawn tsc"); + + assert!( + status.success(), + "generated TypeScript SDK failed to typecheck (`tsc --noEmit` exited {status})" + ); +} diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md index c3ac842..9ffa349 100644 --- a/docs-site/docs/intro.md +++ b/docs-site/docs/intro.md @@ -609,17 +609,24 @@ sdk-rs/ ├── README.md └── src/ ├── lib.rs - ├── client.rs # Client + ClientBuilder + request_stream - ├── error.rs - ├── retry.rs - ├── paginate.rs - ├── concurrency.rs - ├── dedup.rs - ├── middleware.rs - ├── idempotency.rs - ├── streaming.rs # SseStream - ├── models.rs - └── api/.rs + ├── api/ # one module per tag, one fn per operation + ├── cache.rs # ETag/conditional GET response cache + ├── client.rs # Client + ClientBuilder + ServiceContainer + request_stream + ├── concurrency.rs # async in-flight semaphore + ├── dedup.rs # coalesce identical in-flight safe requests + ├── error.rs # typed Error enum + ├── idempotency.rs # auto Idempotency-Key for safe retries + ├── interceptors.rs # request/response body interceptors + ├── logging.rs # pluggable Logger trait + Console/Noop impls + ├── middleware.rs # request/response middleware chain + ├── models.rs # one struct/enum per schema + validators + ├── paginate.rs # cursor/offset pagination helpers + ├── ratelimit.rs # token-bucket + sliding-window rate limiters + ├── retry.rs # exponential backoff + jitter + ├── streaming.rs # SseStream for server-sent events + ├── telemetry.rs # TelemetryHooks trait + MetricsCollector + ├── validate.rs # generated per-model validators + └── validation_middleware.rs # schema validation as middleware ``` diff --git a/docs-site/docs/reference/changelog.md b/docs-site/docs/reference/changelog.md index 8215d50..963efee 100644 --- a/docs-site/docs/reference/changelog.md +++ b/docs-site/docs/reference/changelog.md @@ -12,6 +12,22 @@ All notable changes to this project are 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). +## [1.6.1] — 2026-07-30 + +### Fixed + +#### Rust emitter +- **Generated SDK now compiles** — fixed six template bugs (nested interceptor/transformer loops, missing `logger` field on `ClientBuilder`, telemetry call-site signature mismatches, wrong `Result` arity, `do_once` parameter-order mismatch, `serde_json::to_vec` move-instead-of-borrow) that broke every generated Rust SDK since the 1.6.0 interceptors/validation_middleware/ServiceContainer additions. The example SDK was stale and had never been regenerated to surface this. + +#### TypeScript emitter +- **Telemetry wired in** — `ApiClient` now stores and invokes the previously-dead `TelemetryHooks` (`onRequestStart`/`onRequestEnd`/`onRequestError`/`onRetry`/`onCacheHit`/`onCacheMiss`). +- **Per-model validators typecheck** — object validators now pass the `errors` accumulator and return it, matching the enum-validator pattern. + +#### Testing +- **Compile-the-generated-SDK tests** added for the Rust (`cargo check`), TypeScript (`tsc --noEmit`), and Go (`go build ./...`) emitters — regenerates into a temp crate and builds it so template breakage fails CI. All three emitters verified against both `fixtures/` specs; each test skips cleanly only when its toolchain is genuinely absent. + +--- + ## [1.2.0] — 2026-07-27 ### Added diff --git a/examples/petstore-go/sdk/client.go b/examples/petstore-go/sdk/client.go index f2bff5a..a69a9ab 100644 --- a/examples/petstore-go/sdk/client.go +++ b/examples/petstore-go/sdk/client.go @@ -72,6 +72,26 @@ type ResponseTransformer interface { Transform(response any) any } +// ServiceContainer groups all DI-able components for easy swapping and testing. +// Create one with NewServiceContainer, configure the fields you want to override, +// then pass it to Client.WithServiceContainer to apply them all at once. +type ServiceContainer struct { + HTTPClient *http.Client + Cache *ResponseCache + RateLimiter RateLimiter + Logger Logger + Telemetry TelemetryHooks +} + +// NewServiceContainer creates a ServiceContainer with sensible defaults +// (default HTTP client, console logger). Override individual fields as needed. +func NewServiceContainer() *ServiceContainer { + return &ServiceContainer{ + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + Logger: NewConsoleLogger(), + } +} + // Client is the generated HTTP client for Swagger Petstore. type Client struct { BaseURL string @@ -244,10 +264,35 @@ func (c *Client) WithHTTPClient(hc *http.Client) *Client { return c } +// WithServiceContainer applies all non-nil fields from a ServiceContainer to the client. +// Nil fields are left as their current value, so you only need to set what you want to override. +func (c *Client) WithServiceContainer(sc *ServiceContainer) *Client { + if sc == nil { + return c + } + if sc.HTTPClient != nil { + c.HTTPClient = sc.HTTPClient + } + if sc.Cache != nil { + c.Cache = sc.Cache + } + if sc.RateLimiter != nil { + c.RateLimiter = sc.RateLimiter + } + if sc.Logger != nil { + c.Logger = sc.Logger + } + if sc.Telemetry != nil { + c.Telemetry = sc.Telemetry + } + return c +} + // WithResponseTransformers sets response transformers applied in order after JSON decoding. func (c *Client) WithResponseTransformers(transformers ...ResponseTransformer) *Client { c.ResponseTransformers = append(c.ResponseTransformers, transformers...) return c +} // WithRequestInterceptors sets request interceptors applied in order before each request body is serialized. func (c *Client) WithRequestInterceptors(interceptors ...RequestInterceptor) *Client { @@ -260,7 +305,6 @@ func (c *Client) WithResponseInterceptors(interceptors ...ResponseInterceptor) * c.ResponseInterceptors = append(c.ResponseInterceptors, interceptors...) return c } -} // DoJSON issues an HTTP request (concurrency + dedupe + retry + middleware), // JSON-decodes a successful response into out (when non-nil), and returns a diff --git a/examples/petstore-go/sdk/specforge-version.json b/examples/petstore-go/sdk/specforge-version.json new file mode 100644 index 0000000..1b2d9ec --- /dev/null +++ b/examples/petstore-go/sdk/specforge-version.json @@ -0,0 +1 @@ +{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-30T06:02:33Z"} \ No newline at end of file diff --git a/examples/petstore-go/sdk/validation_middleware.go b/examples/petstore-go/sdk/validation_middleware.go new file mode 100644 index 0000000..10e4ea8 --- /dev/null +++ b/examples/petstore-go/sdk/validation_middleware.go @@ -0,0 +1,113 @@ +// Code generated by specforge. DO NOT EDIT. + +package sdk + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// EndpointSchema describes the request and/or response schema for an endpoint. +type EndpointSchema struct { + // RequestBody is the schema for the request body. Nil means no validation. + RequestBody func(v any) error + // ResponseBody is the schema for the response body. Nil means no validation. + ResponseBody func(v any) error +} + +// RouteSchemaMap maps "METHOD /path/pattern" to an EndpointSchema. +// Patterns use {param} placeholders for path segments. +type RouteSchemaMap map[string]*EndpointSchema + +// matchesPath checks if actualPath matches the pattern. +// Pattern segments in {...} are treated as wildcards. +func matchesPath(pattern, actualPath string) bool { + patternSegments := strings.Split(strings.Trim(pattern, "/"), "/") + actualSegments := strings.Split(strings.Trim(actualPath, "/"), "/") + if len(patternSegments) != len(actualSegments) { + return false + } + for i, ps := range patternSegments { + if strings.HasPrefix(ps, "{") && strings.HasSuffix(ps, "}") { + continue + } + if ps != actualSegments[i] { + return false + } + } + return true +} + +// ValidationMiddleware creates a middleware that validates request/response +// bodies against the OpenAPI schema. Validation errors are returned as errors. +// +// Usage: +// +// schemas := RouteSchemaMap{ +// "POST /pets": {RequestBody: ValidatePet, ResponseBody: ValidatePet}, +// "GET /pets/{petId}": {ResponseBody: ValidatePet}, +// } +// client.Use(ValidationMiddleware(schemas)) +func ValidationMiddleware(schemas RouteSchemaMap) Middleware { + return func(ctx context.Context, req *MiddlewareRequest, next func(context.Context, *MiddlewareRequest) (*MiddlewareResponse, error)) (*MiddlewareResponse, error) { + method := strings.ToUpper(req.Method) + // Strip query string for route matching. + path := req.URL + if idx := strings.Index(path, "?"); idx >= 0 { + path = path[:idx] + } + + // Find a matching route schema. + routeKey := method + " " + path + endpointSchema := schemas[routeKey] + + // Try pattern matching if exact match fails. + if endpointSchema == nil { + for pattern, schema := range schemas { + spaceIdx := strings.Index(pattern, " ") + if spaceIdx < 0 { + continue + } + patternMethod := strings.ToUpper(pattern[:spaceIdx]) + patternPath := pattern[spaceIdx+1:] + if patternMethod == method && matchesPath(patternPath, path) { + endpointSchema = schema + break + } + } + } + + // Validate request body if a schema is defined. + if endpointSchema != nil && endpointSchema.RequestBody != nil && len(req.Body) > 0 { + var body any + if err := json.Unmarshal(req.Body, &body); err == nil { + if err := endpointSchema.RequestBody(body); err != nil { + return nil, fmt.Errorf("[validation] %s %s request body: %w", method, path, err) + } + } + } + + // Proceed with the request. + resp, err := next(ctx, req) + if err != nil { + return nil, err + } + + // Validate response body if a schema is defined. + if endpointSchema != nil && endpointSchema.ResponseBody != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 && len(resp.Body) > 0 { + contentType := resp.Header.Get("Content-Type") + if strings.Contains(contentType, "application/json") { + var body any + if err := json.Unmarshal(resp.Body, &body); err == nil { + if err := endpointSchema.ResponseBody(body); err != nil { + return nil, fmt.Errorf("[validation] %s %s response %d: %w", method, path, resp.StatusCode, err) + } + } + } + } + + return resp, nil + } +} diff --git a/examples/petstore-rust/Cargo.toml b/examples/petstore-rust/Cargo.toml index 2311ad1..12f8467 100644 --- a/examples/petstore-rust/Cargo.toml +++ b/examples/petstore-rust/Cargo.toml @@ -11,3 +11,5 @@ path = "src/main.rs" [dependencies] petstore_example_sdk = { path = "./sdk" } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[workspace] diff --git a/examples/petstore-rust/sdk/README.md b/examples/petstore-rust/sdk/README.md index 434d9d6..1b2271e 100644 --- a/examples/petstore-rust/sdk/README.md +++ b/examples/petstore-rust/sdk/README.md @@ -133,4 +133,98 @@ let client = Client::builder() .build()?; ``` +## Interceptors + +Transform the request body before it is serialized, or the response body after it +is deserialized, by implementing a trait and registering it on the builder: + +```rust +use petstore_example_sdk::{RequestInterceptor, ResponseInterceptor}; +use serde_json::{json, Value}; + +// Add a field to every outgoing request body. +struct AddTraceId; +impl RequestInterceptor for AddTraceId { + fn transform(&self, mut body: Value) -> Value { + if let Some(obj) = body.as_object_mut() { + obj.insert("trace_id".into(), json!("abc-123")); + } + body + } +} + +// Strip a field from every response body. +struct StripMeta; +impl ResponseInterceptor for StripMeta { + fn transform(&self, mut body: Value) -> Value { + if let Some(obj) = body.as_object_mut() { + obj.remove("_meta"); + } + body + } +} + +let client = Client::builder() + .bearer_token("…") + .request_interceptor(AddTraceId) + .response_interceptor(StripMeta) + .build()?; +``` + +## Validation + +Validate request/response bodies against the OpenAPI schema at runtime. Toggle the +built-in validator per client, or wire the generated `validation_middleware` into the +middleware chain with per-route schemas for finer control: + +```rust +// Whole-client validation (uses the generated validators automatically). +let client = Client::builder() + .bearer_token("…") + .validation(true) + .build()?; + +// Or attach validation as a middleware with an explicit route -> schema map. +use std::collections::HashMap; +use petstore_example_sdk::validation_middleware::{validation_middleware, EndpointSchema, RouteSchemaMap}; +use petstore_example_sdk::models; // generated validators live here, e.g. validate_pet + +let mut schemas: RouteSchemaMap = HashMap::new(); +schemas.insert( + "POST /pets".to_string(), + EndpointSchema { + request_body: Some(models::validate_pet), + response_body: None, + }, +); +let mut client = Client::builder().bearer_token("…").build()?; +client.use_middleware(validation_middleware(schemas)); +``` + +## Telemetry & dependency injection + +Observe the request lifecycle by implementing `TelemetryHooks`, or group injectable +dependencies (HTTP client, cache, rate limiter, logger, telemetry) into a +`ServiceContainer` and apply them in one call: + +```rust +use std::time::Duration; +use petstore_example_sdk::{MetricsCollector, ServiceContainer}; + +let metrics = MetricsCollector::default(); + +let sc = ServiceContainer::new() + .cache_ttl(Duration::from_secs(60)) + .logger(petstore_example_sdk::ConsoleLogger) + .telemetry(metrics.clone()); + +let client = Client::builder() + .bearer_token("…") + .service_container(sc) + .build()?; + +// `metrics.get_metrics().await` now reflects every request, retry, and error. +println!("{:?}", metrics.get_metrics().await); +``` + _Do not edit generated files directly._ diff --git a/examples/petstore-rust/sdk/specforge-version.json b/examples/petstore-rust/sdk/specforge-version.json new file mode 100644 index 0000000..6e5a49d --- /dev/null +++ b/examples/petstore-rust/sdk/specforge-version.json @@ -0,0 +1 @@ +{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-30T06:04:16Z"} \ No newline at end of file diff --git a/examples/petstore-rust/sdk/src/client.rs b/examples/petstore-rust/sdk/src/client.rs index 6529231..bf61774 100644 --- a/examples/petstore-rust/sdk/src/client.rs +++ b/examples/petstore-rust/sdk/src/client.rs @@ -9,6 +9,7 @@ use serde::Serialize; use crate::cache::ResponseCache; use crate::concurrency::Semaphore; + use crate::interceptors::{RequestInterceptor, ResponseInterceptor}; use crate::ratelimit::RateLimiter; use crate::telemetry::TelemetryHooks; use crate::dedup::RequestDeduper; @@ -20,6 +21,77 @@ use crate::cache::ResponseCache; use crate::retry::{backoff_delay, is_retriable_method, RetryOptions}; use crate::streaming::SseStream; + /// Typed service container grouping all DI-able SDK dependencies. + /// Create one with `ServiceContainer::new()`, override fields, then pass + /// to `ClientBuilder::service_container()` to apply them all at once. + #[derive(Clone)] + pub struct ServiceContainer { + pub http_client: reqwest::Client, + pub cache: Option, + pub rate_limiter: Option>, + pub logger: Option>, + pub telemetry: Option>, + } + + impl ServiceContainer { + /// Create a new container with sensible defaults (default reqwest client, + /// console logger, no cache/rate limiter/telemetry). + pub fn new() -> Self { + Self { + http_client: reqwest::Client::builder() + .user_agent("specforge-rust-sdk") + .build() + .expect("failed to build default HTTP client"), + cache: None, + rate_limiter: None, + logger: None, + telemetry: None, + } + } + + /// Set a custom HTTP client, replacing the default. + pub fn http_client(mut self, client: reqwest::Client) -> Self { + self.http_client = client; + self + } + + /// Enable response caching with the given TTL. + pub fn cache_ttl(mut self, ttl: std::time::Duration) -> Self { + self.cache = Some(ResponseCache::new(ttl)); + self + } + + /// Set a pre-configured response cache. + pub fn cache(mut self, cache: ResponseCache) -> Self { + self.cache = Some(cache); + self + } + + /// Set a rate limiter. + pub fn rate_limiter(mut self, limiter: impl RateLimiter + 'static) -> Self { + self.rate_limiter = Some(Arc::new(limiter)); + self + } + + /// Set a structured logger. + pub fn logger(mut self, logger: impl crate::logging::Logger + 'static) -> Self { + self.logger = Some(Arc::new(logger)); + self + } + + /// Set telemetry hooks. + pub fn telemetry(mut self, hooks: impl TelemetryHooks + 'static) -> Self { + self.telemetry = Some(Arc::new(hooks)); + self + } + } + + impl Default for ServiceContainer { + fn default() -> Self { + Self::new() + } + } + /// Credential provider applied on every request. #[derive(Clone)] pub enum Auth { @@ -191,7 +263,7 @@ use crate::cache::ResponseCache; None }; let body_bytes = match body { - Some(b) => Some(serde_json::to_vec(b)?), + Some(b) => Some(serde_json::to_vec(&b)?), None => None, }; @@ -331,15 +403,15 @@ use crate::cache::ResponseCache; } // Apply response transformers. let mut value: serde_json::Value = serde_json::from_slice(&data)?; - for t in &self.response_transformers { - value = t.transform(value); + for t in &self.response_transformers { + value = t.transform(value); + } - // Apply response interceptors. - for i in &self.response_interceptors { - value = i.transform(value); - } - } - Ok(serde_json::from_value(value)?) + // Apply response interceptors. + for i in &self.response_interceptors { + value = i.transform(value); + } + Ok(serde_json::from_value(value)?) } /// Issue a streaming request (no retry). Returns the raw Response; use @@ -465,10 +537,10 @@ use crate::cache::ResponseCache; if let Some(l) = &self.logger { l.warn(&format!("[retry] {} {} error={}, attempt {}/{}", method.as_str(), url, last_err.as_ref().unwrap(), attempt + 2, max + 1)); } - // Telemetry: retry notification. - if let Some(t) = &self.telemetry { - t.on_retry(method, url, attempt + 1, &last_err.as_ref().unwrap()); - } + // Telemetry: retry notification. + if let Some(t) = &self.telemetry { + t.on_retry(method.as_str(), url, attempt + 1, &last_err.as_ref().unwrap()); + } } } } @@ -479,14 +551,13 @@ use crate::cache::ResponseCache; &self, method: &reqwest::Method, url: &str, - #[allow(unused_variables)] - start: std::time::Instant, query: &[(String, String)], body_bytes: Option<&[u8]>, idem_key: Option<&str>, if_none_match: Option<&str>, + start: std::time::Instant, ) -> Result<(Vec, u16, String)> { - let _start = std::time::Instant::now(); + let _start = start; // Build full URL with query for middleware visibility. let full_url = if query.is_empty() { @@ -574,15 +645,16 @@ use crate::cache::ResponseCache; if mw_res.status == 304 { return Ok((mw_res.body, mw_res.status, etag)); } - // Telemetry: request error. - if let Some(t) = &self.telemetry { - t.on_request_error(method, url, _start.elapsed(), &format!("HTTP {}", mw_res.status)); - } - return Err(Error::Http { + let err = Error::Http { status: mw_res.status, body: String::from_utf8_lossy(&mw_res.body).into_owned(), url: full_url, - }); + }; + // Telemetry: request error. + if let Some(t) = &self.telemetry { + t.on_request_error(method.as_str(), url, _start.elapsed().as_millis(), &err); + } + return Err(err); } // Logger: response received. if let Some(l) = &self.logger { @@ -591,7 +663,7 @@ use crate::cache::ResponseCache; // Telemetry: successful request. if let Some(t) = &self.telemetry { - t.on_request_end(method, url, _start.elapsed(), mw_res.status); + t.on_request_end(method.as_str(), url, _start.elapsed().as_millis(), mw_res.status); } Ok((mw_res.body, mw_res.status, etag)) } @@ -642,6 +714,7 @@ use crate::cache::ResponseCache; cache: Option, rate_limiter: Option>, telemetry: Option>, + logger: Option>, middlewares: Vec, stream_middlewares: Vec, /// Seeded from the first API-key security scheme in the spec, if any. @@ -683,10 +756,11 @@ use crate::cache::ResponseCache; dedupe: true, idempotency: true, validation: false, - cache: None, - rate_limiter: None, - telemetry: None, - response_transformers: Vec::new(), + cache: None, + rate_limiter: None, + telemetry: None, + logger: None, + response_transformers: Vec::new(), request_interceptors: Vec::new(), response_interceptors: Vec::new(), middlewares: Vec::new(), @@ -812,7 +886,27 @@ use crate::cache::ResponseCache; self.http_client = Some(client); self } - + + /// Apply a [`ServiceContainer`] to configure all DI-able services at once. + /// Non-None container values override the builder defaults; individual + /// builder methods called afterward take final precedence. + pub fn service_container(mut self, sc: ServiceContainer) -> Self { + self.http_client = Some(sc.http_client); + if let Some(cache) = sc.cache { + self.cache = Some(cache); + } + if let Some(limiter) = sc.rate_limiter { + self.rate_limiter = Some(limiter); + } + if let Some(logger) = sc.logger { + self.logger = Some(logger); + } + if let Some(telemetry) = sc.telemetry { + self.telemetry = Some(telemetry); + } + self + } + pub fn build(self) -> Result { let http = match self.http_client { Some(c) => c, diff --git a/examples/petstore-rust/sdk/src/lib.rs b/examples/petstore-rust/sdk/src/lib.rs index 673e828..2899059 100644 --- a/examples/petstore-rust/sdk/src/lib.rs +++ b/examples/petstore-rust/sdk/src/lib.rs @@ -9,6 +9,7 @@ pub mod concurrency; pub mod dedup; pub mod error; pub mod idempotency; +pub mod interceptors; pub mod logging; pub mod middleware; pub mod models; @@ -18,8 +19,9 @@ pub mod retry; pub mod streaming; pub mod telemetry; pub mod validate; +pub mod validation_middleware; -pub use client::{Auth, Client, ClientBuilder, ResponseTransformer}; +pub use client::{Auth, Client, ClientBuilder, ResponseTransformer, ServiceContainer}; pub use interceptors::{RequestInterceptor, ResponseInterceptor}; pub use concurrency::Semaphore; pub use dedup::RequestDeduper; @@ -32,3 +34,4 @@ pub use ratelimit::{RateLimiter, SlidingWindow, TokenBucket}; pub use retry::RetryOptions; pub use telemetry::{MetricsCollector, TelemetryHooks}; pub use streaming::{ServerSentEvent, SseStream}; +pub use validation_middleware::{validation_middleware, EndpointSchema, RouteSchemaMap}; diff --git a/examples/petstore-rust/sdk/src/validation_middleware.rs b/examples/petstore-rust/sdk/src/validation_middleware.rs new file mode 100644 index 0000000..3585849 --- /dev/null +++ b/examples/petstore-rust/sdk/src/validation_middleware.rs @@ -0,0 +1,146 @@ +// Code generated by specforge. DO NOT EDIT. + +//! Automatic request/response validation middleware that intercepts all +//! requests and validates bodies against the OpenAPI schema. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::Value; + +use crate::error::{Error, Result}; +use crate::middleware::{Middleware, MiddlewareRequest, MiddlewareResponse, NextFn}; +use crate::validate::ValidationError; + +/// Schema descriptor for an endpoint's request and/or response body validator. +pub struct EndpointSchema { + /// Validator function for the request body. None means no validation. + pub request_body: Option std::result::Result<(), Vec>>, + /// Validator function for the response body. None means no validation. + pub response_body: Option std::result::Result<(), Vec>>, +} + +/// Route schema map keyed by "METHOD /path/pattern". +/// Patterns use `{param}` placeholders for path segments. +pub type RouteSchemaMap = HashMap; + +/// Check if `actual_path` matches the `pattern` path. +/// Pattern segments wrapped in `{...}` are treated as wildcards. +fn matches_path(pattern: &str, actual_path: &str) -> bool { + let pattern_segments: Vec<&str> = pattern.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect(); + let actual_segments: Vec<&str> = actual_path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect(); + if pattern_segments.len() != actual_segments.len() { + return false; + } + for (ps, as_) in pattern_segments.iter().zip(actual_segments.iter()) { + if ps.starts_with('{') && ps.ends_with('}') { + continue; // wildcard + } + if ps != as_ { + return false; + } + } + true +} + +/// Create a validation middleware that intercepts all requests and validates +/// request/response bodies against the OpenAPI schema. +/// +/// # Usage +/// +/// ```ignore +/// use std::collections::HashMap; +/// use sdk::validation_middleware::{validation_middleware, EndpointSchema, RouteSchemaMap}; +/// use sdk::validate::validate_pet; +/// +/// let mut schemas: RouteSchemaMap = HashMap::new(); +/// schemas.insert( +/// "POST /pets".to_string(), +/// EndpointSchema { +/// request_body: Some(validate_pet), +/// response_body: Some(validate_pet), +/// }, +/// ); +/// schemas.insert( +/// "GET /pets/{petId}".to_string(), +/// EndpointSchema { +/// request_body: None, +/// response_body: Some(validate_pet), +/// }, +/// ); +/// +/// client.use_middleware(validation_middleware(schemas)); +/// ``` +pub fn validation_middleware(schemas: RouteSchemaMap) -> Middleware { + let schemas = Arc::new(schemas); + Arc::new(move |req: MiddlewareRequest, next: NextFn| { + let schemas = Arc::clone(&schemas); + Box::pin(async move { + let method = req.method.as_str().to_uppercase(); + let path = req.url.split('?').next().unwrap_or("").to_string(); + + // Find a matching route schema. + let route_key = format!("{} {}", method, path); + let endpoint_schema = schemas.get(&route_key).or_else(|| { + schemas.iter().find_map(|(pattern, schema)| { + let space_idx = pattern.find(' ')?; + let pattern_method = pattern[..space_idx].to_uppercase(); + let pattern_path = &pattern[space_idx + 1..]; + if pattern_method == method && matches_path(pattern_path, &path) { + Some(schema) + } else { + None + } + }) + }); + + // Validate request body if a schema is defined. + if let Some(schema) = endpoint_schema { + if let Some(validator) = schema.request_body { + if let Some(body_bytes) = &req.body { + if !body_bytes.is_empty() { + if let Ok(body) = serde_json::from_slice::(body_bytes) { + if let Err(errors) = validator(&body) { + let msg: Vec = errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect(); + return Err(Error::Message(format!( + "[validation] {} {} request body: {}", + method, path, msg.join("; ") + ))); + } + } + } + } + } + } + + // Proceed with the request. + let resp = next(req).await?; + + // Validate response body if a schema is defined. + if let Some(schema) = endpoint_schema { + if let Some(validator) = schema.response_body { + if resp.status >= 200 && resp.status < 300 && !resp.body.is_empty() { + let is_json = resp.headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|ct| ct.contains("application/json")) + .unwrap_or(false); + if is_json { + if let Ok(body) = serde_json::from_slice::(&resp.body) { + if let Err(errors) = validator(&body) { + let msg: Vec = errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect(); + return Err(Error::Message(format!( + "[validation] {} {} response {}: {}", + method, path, resp.status, msg.join("; ") + ))); + } + } + } + } + } + } + + Ok(resp) + }) + }) +} diff --git a/examples/petstore-ts/sdk/specforge-version.json b/examples/petstore-ts/sdk/specforge-version.json new file mode 100644 index 0000000..1b2d9ec --- /dev/null +++ b/examples/petstore-ts/sdk/specforge-version.json @@ -0,0 +1 @@ +{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-30T06:02:33Z"} \ No newline at end of file diff --git a/examples/petstore-ts/sdk/src/client.ts b/examples/petstore-ts/sdk/src/client.ts index 3919b30..54d3acf 100644 --- a/examples/petstore-ts/sdk/src/client.ts +++ b/examples/petstore-ts/sdk/src/client.ts @@ -12,7 +12,10 @@ import { Middleware, MiddlewareRequest, composeMiddleware } from "./middleware"; import { validateRequest, validateResponse, type SchemaType, type ValidationError } from "./validate"; import { ResponseCache } from "./cache"; import { type Logger, ConsoleLogger } from "./logging"; +import { type TelemetryHooks } from "./telemetry"; import { type RequestInterceptor, type ResponseInterceptor } from "./interceptors"; +import { type ServiceContainer } from "./service_container"; +import { createValidationMiddleware, type RouteSchemaMap } from "./validation-middleware"; /** * Transform response data before it reaches the application. Transformers are @@ -55,10 +58,18 @@ export interface ApiClientOptions { responseTransformers?: ResponseTransformer[]; /** Structured logger for SDK lifecycle events (requests, retries, cache). */ logger?: Logger; + /** Telemetry hooks for request lifecycle observability. Implement any subset. */ + telemetry?: TelemetryHooks; /** Request interceptors applied before each request body is serialized. */ requestInterceptors?: RequestInterceptor[]; /** Response interceptors applied after each response body is deserialized. */ responseInterceptors?: ResponseInterceptor[]; + /** + * Typed service container grouping injectable dependencies (fetch, cache, + * rate limiter, logger, telemetry). Individual option fields take precedence + * over container values. + */ + container?: ServiceContainer; } /** @@ -90,17 +101,21 @@ export class ApiClient { private readonly responseTransformers: ResponseTransformer[]; /** Structured logger for SDK lifecycle events. */ private readonly logger: Logger; + /** Optional telemetry hooks for request lifecycle observability. */ + private readonly telemetry?: TelemetryHooks; /** Request interceptors applied before each request body is serialized. */ private readonly requestInterceptors: RequestInterceptor[]; /** Response interceptors applied after each response body is deserialized. */ private readonly responseInterceptors: ResponseInterceptor[]; constructor(opts: ApiClientOptions = {}) { + const c = opts.container; this.baseUrl = (opts.baseUrl ?? "http://petstore.swagger.io/v1").replace(/\/$/, ""); this.auth = opts.auth ?? anonymousAuth(); this.timeoutMs = opts.timeoutMs ?? 30_000; this.retry = { ...DEFAULT_RETRY_OPTIONS, ...opts.retry }; - this.fetchImpl = opts.fetch ?? fetch; + // Individual options take precedence over container values. + this.fetchImpl = opts.fetch ?? c?.httpClient ?? fetch; this.defaultHeaders = { "content-type": "application/json", ...opts.defaultHeaders }; if (!("accept" in this.defaultHeaders)) this.defaultHeaders["accept"] = "application/json"; this.semaphore = opts.maxConcurrent ? createSemaphore(opts.maxConcurrent) : UNLIMITED; @@ -108,9 +123,10 @@ export class ApiClient { this.useDedupe = opts.dedupe ?? true; this.useIdempotency = opts.idempotency ?? true; this.validate = opts.validate ?? false; - this.cache = opts.cache ?? (opts.cacheTtlMs !== undefined ? new ResponseCache(opts.cacheTtlMs) : undefined); + this.cache = opts.cache ?? c?.cache ?? (opts.cacheTtlMs !== undefined ? new ResponseCache(opts.cacheTtlMs) : undefined); this.responseTransformers = opts.responseTransformers ?? []; - this.logger = opts.logger ?? new ConsoleLogger(); + this.logger = opts.logger ?? c?.logger ?? new ConsoleLogger(); + this.telemetry = opts.telemetry ?? c?.telemetry; this.requestInterceptors = opts.requestInterceptors ?? []; this.responseInterceptors = opts.responseInterceptors ?? []; if (opts.middleware) for (const m of opts.middleware) this.middlewares.push(m); @@ -139,8 +155,10 @@ export class ApiClient { const cachedEntry = isGet && this.cache ? this.cache.get(url) : undefined; if (cachedEntry) { this.logger.debug(`[cache] HIT ${method} ${path}`); + this.telemetry?.onCacheHit?.(method, path); } else if (isGet && this.cache) { this.logger.debug(`[cache] MISS ${method} ${path}`); + this.telemetry?.onCacheMiss?.(method, path); } const mergedHeaders: Record | undefined = cachedEntry @@ -151,7 +169,7 @@ export class ApiClient { const dispatch = async (): Promise => { const release = await this.semaphore.acquire(); try { - return await this.dispatchWithRetry(method, url, { ...requestOptions, headers: mergedHeaders }); + return await this.dispatchWithRetry(method, url, path, { ...requestOptions, headers: mergedHeaders }); } finally { release(); } @@ -197,6 +215,7 @@ export class ApiClient { private async dispatchWithRetry( method: string, url: string, + path: string, requestOptions: RequestOptions | undefined, ): Promise { const perCallRetry = requestOptions?.retry; @@ -224,6 +243,7 @@ export class ApiClient { callerSignal?.addEventListener("abort", onCallerAbort!, { once: true }); this.logger.info(`[request] ${method} ${url}${attempt > 0 ? ` (attempt ${attempt + 1})` : ""}`); + if (attempt === 0) this.telemetry?.onRequestStart?.(method, path); // Build the request descriptor for middleware. const mwReq: MiddlewareRequest = { @@ -251,6 +271,7 @@ export class ApiClient { if (response.ok) { this.logger.info(`[response] ${method} ${url} -> ${response.status}`); + this.telemetry?.onRequestEnd?.(method, path, Date.now() - startedAt, response.status); return response; } @@ -260,27 +281,37 @@ export class ApiClient { this.retry.retryOnStatuses.includes(response.status); if (shouldRetry) { this.logger.warn(`[retry] ${method} ${url} status=${response.status}, attempt ${attempt + 2}/${maxRetries + 1}`); + this.telemetry?.onRetry?.(method, path, attempt + 1, response.status); await sleep(backoffDelay(attempt, this.retry)); continue; } const body = await parseBody(response); - throw httpError(response.status, body, url, response.statusText || `HTTP ${response.status}`); + const httpErr = httpError(response.status, body, url, response.statusText || `HTTP ${response.status}`); + this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, httpErr); + throw httpErr; } catch (e) { if (timer) clearTimeout(timer); callerSignal?.removeEventListener("abort", onCallerAbort!); + // ApiError (e.g. the httpErr built above) was already reported via + // telemetry before being thrown — re-throw without double-counting. if (isApiError(e)) throw e; // Distinguish caller-cancelled from our-timeout from network. if (callerSignal?.aborted) throw networkError(e, "request aborted by caller"); if (e instanceof DOMException && e.name === "AbortError") { - throw timeoutError(Date.now() - startedAt); + const err = timeoutError(Date.now() - startedAt); + this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, err); + throw err; } if (attempt < maxRetries && retryable) { this.logger.warn(`[retry] ${method} ${url} error=${(e as Error)?.message}, attempt ${attempt + 2}/${maxRetries + 1}`); + this.telemetry?.onRetry?.(method, path, attempt + 1, e); await sleep(backoffDelay(attempt, this.retry)); continue; } - throw networkError(e, (e as Error)?.message ?? "network error"); + const err = networkError(e, (e as Error)?.message ?? "network error"); + this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, err); + throw err; } } throw configurationError("exhausted retries without a response"); @@ -377,4 +408,27 @@ async function parseBody(response: Response): Promise { } } +/** + * Create an options object with automatic validation middleware pre-registered. + * Pass the schemas map to enable request/response validation for all requests + * matching the given route patterns. + * + * Usage: + * ```ts + * import { createClient, withValidation, PetSchema } from "./sdk"; + * + * const schemas = { + * "POST /pets": { requestBody: PetSchema, responseBody: PetSchema }, + * "GET /pets/{petId}": { responseBody: PetSchema }, + * }; + * + * const client = createClient(withValidation(schemas)); + * ``` + */ +export function withValidation(schemas: RouteSchemaMap): ApiClientOptions { + return { + middleware: [createValidationMiddleware(schemas)], + }; +} + export type { ApiError }; diff --git a/examples/petstore-ts/sdk/src/index.ts b/examples/petstore-ts/sdk/src/index.ts index e9d2cd5..34e3177 100644 --- a/examples/petstore-ts/sdk/src/index.ts +++ b/examples/petstore-ts/sdk/src/index.ts @@ -22,9 +22,11 @@ export * from "./auth"; export * from "./retry"; export * from "./paginate"; export * from "./validate"; +export * from "./validation-middleware"; export * from "./ratelimit"; export * from "./telemetry"; export * from "./logging"; +export * from "./service_container"; export * from "./models/Error"; export * from "./models/Pet"; diff --git a/examples/petstore-ts/sdk/src/ratelimit.ts b/examples/petstore-ts/sdk/src/ratelimit.ts new file mode 100644 index 0000000..ae160e5 --- /dev/null +++ b/examples/petstore-ts/sdk/src/ratelimit.ts @@ -0,0 +1,83 @@ +/* eslint-disable */ +// Generated by specforge. DO NOT EDIT. + +/** + * Rate limiter interface. The client calls `acquire()` before each request; + * the returned promise resolves when the request is allowed to proceed. + */ +export interface RateLimiter { + /** Block until the request is allowed. Reject to abort. */ + acquire(): Promise; +} + +/** + * Token bucket rate limiter. Tokens refill at a constant rate up to a maximum. + * Each request consumes one token; when empty, `acquire()` waits for a refill. + */ +export class TokenBucket implements RateLimiter { + private tokens: number; + private readonly maxTokens: number; + private readonly refillRate: number; // tokens per second + private lastRefill: number; + + constructor(maxTokens: number, refillRate: number) { + this.maxTokens = maxTokens; + this.tokens = maxTokens; + this.refillRate = refillRate; + this.lastRefill = Date.now(); + } + + async acquire(): Promise { + this.refill(); + while (this.tokens < 1) { + // Wait for the next refill. + const waitMs = Math.ceil((1 - this.tokens) / this.refillRate * 1000); + await new Promise((resolve) => setTimeout(resolve, Math.max(waitMs, 10))); + this.refill(); + } + this.tokens -= 1; + } + + private refill(): void { + const now = Date.now(); + const elapsed = (now - this.lastRefill) / 1000; + this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate); + this.lastRefill = now; + } +} + +/** + * Sliding window rate limiter. Allows at most `maxRequests` within any rolling + * `windowMs`. When the limit is reached, `acquire()` waits until the oldest + * request in the window expires. + */ +export class SlidingWindow implements RateLimiter { + private readonly requests: number[] = []; + private readonly maxRequests: number; + private readonly windowMs: number; + + constructor(maxRequests: number, windowMs: number) { + this.maxRequests = maxRequests; + this.windowMs = windowMs; + } + + async acquire(): Promise { + this.evict(); + if (this.requests.length >= this.maxRequests) { + const oldest = this.requests[0]!; + const waitMs = this.windowMs - (Date.now() - oldest); + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + this.evict(); + } + this.requests.push(Date.now()); + } + + private evict(): void { + const cutoff = Date.now() - this.windowMs; + while (this.requests.length > 0 && this.requests[0]! <= cutoff) { + this.requests.shift(); + } + } +} diff --git a/examples/petstore-ts/sdk/src/service_container.ts b/examples/petstore-ts/sdk/src/service_container.ts new file mode 100644 index 0000000..3be830d --- /dev/null +++ b/examples/petstore-ts/sdk/src/service_container.ts @@ -0,0 +1,46 @@ +/* eslint-disable */ +// Generated by specforge. DO NOT EDIT. + +import type { ResponseCache } from "./cache"; +import type { RateLimiter } from "./ratelimit"; +import type { Logger } from "./logging"; +import type { TelemetryHooks } from "./telemetry"; + +/** + * Typed service container grouping all injectable SDK dependencies. + * Use `createContainer()` to build one; pass it to `ApiClientOptions.container` + * to apply all services at once. + * + * Every field is optional: unspecified services use the SDK defaults + * (global fetch, no cache, no rate limiter, console logger, no telemetry). + * + * @example + * ```ts + * const container = createContainer({ + * httpClient: myCustomFetch, + * logger: new ConsoleLogger(), + * rateLimiter: new TokenBucket(10, 5), + * }); + * const client = new ApiClient({ container }); + * ``` + */ +export interface ServiceContainer { + /** Custom fetch implementation (e.g. for testing, undici, or cloud workers). */ + httpClient?: typeof fetch; + /** Pre-configured response cache for GET requests with ETag support. */ + cache?: ResponseCache; + /** Rate limiter applied before each request. */ + rateLimiter?: RateLimiter; + /** Structured logger for SDK lifecycle events. */ + logger?: Logger; + /** Telemetry hooks for request lifecycle observability. */ + telemetry?: TelemetryHooks; +} + +/** + * Create a typed service container. All fields default to `undefined` + * (the SDK applies its own defaults for unspecified services). + */ +export function createContainer(opts: ServiceContainer = {}): ServiceContainer { + return { ...opts }; +} diff --git a/examples/petstore-ts/sdk/src/telemetry.ts b/examples/petstore-ts/sdk/src/telemetry.ts new file mode 100644 index 0000000..6a34618 --- /dev/null +++ b/examples/petstore-ts/sdk/src/telemetry.ts @@ -0,0 +1,69 @@ +/* eslint-disable */ +// Generated by specforge. DO NOT EDIT. + +/** + * Telemetry hooks for observing the SDK request lifecycle. + * Implement any subset; unused hooks are simply not called. + */ +export interface TelemetryHooks { + /** Called just before the request is dispatched. */ + onRequestStart?(method: string, path: string): void; + /** Called after a successful response (2xx) with the elapsed duration. */ + onRequestEnd?(method: string, path: string, durationMs: number, status: number): void; + /** Called when a request fails (network, timeout, non-retriable HTTP error). */ + onRequestError?(method: string, path: string, durationMs: number, error: unknown): void; + /** Called before a retry attempt (after backoff sleep). */ + onRetry?(method: string, path: string, attempt: number, error: unknown): void; + /** Called when a GET request is served from the response cache. */ + onCacheHit?(method: string, path: string): void; + /** Called when a GET request misses the cache and goes to the network. */ + onCacheMiss?(method: string, path: string): void; +} + +/** + * Built-in metrics collector that tracks request counts, errors, durations, + * and retries. Use `getMetrics()` to obtain a snapshot. + */ +export class MetricsCollector implements TelemetryHooks { + private requestCount = 0; + private errorCount = 0; + private totalDurationMs = 0; + private retryCount = 0; + + onRequestStart(_method: string, _path: string): void { + this.requestCount++; + } + + onRequestEnd(_method: string, _path: string, durationMs: number, status: number): void { + this.totalDurationMs += durationMs; + if (status >= 400) this.errorCount++; + } + + onRequestError(_method: string, _path: string, durationMs: number, _error: unknown): void { + this.errorCount++; + this.totalDurationMs += durationMs; + } + + onRetry(_method: string, _path: string, _attempt: number, _error: unknown): void { + this.retryCount++; + } + + /** Return a snapshot of the collected metrics. */ + getMetrics(): Metrics { + return { + requestCount: this.requestCount, + errorCount: this.errorCount, + totalDurationMs: this.totalDurationMs, + avgDurationMs: this.requestCount > 0 ? this.totalDurationMs / this.requestCount : 0, + retryCount: this.retryCount, + }; + } +} + +export interface Metrics { + requestCount: number; + errorCount: number; + totalDurationMs: number; + avgDurationMs: number; + retryCount: number; +} diff --git a/examples/petstore-ts/sdk/src/validate.ts b/examples/petstore-ts/sdk/src/validate.ts index e139960..403d86c 100644 --- a/examples/petstore-ts/sdk/src/validate.ts +++ b/examples/petstore-ts/sdk/src/validate.ts @@ -38,7 +38,9 @@ export const ErrorSchema: SchemaObject = { }; export function validateError(value: unknown, path = ""): ValidationError[] { - return validateValue(value, ErrorSchema, path); + const errors: ValidationError[] = []; + validateValue(value, ErrorSchema, path, errors); + return errors; } export const PetSchema: SchemaObject = { @@ -51,7 +53,9 @@ export const PetSchema: SchemaObject = { }; export function validatePet(value: unknown, path = ""): ValidationError[] { - return validateValue(value, PetSchema, path); + const errors: ValidationError[] = []; + validateValue(value, PetSchema, path, errors); + return errors; } export const PetsSchema: SchemaObject = { @@ -61,7 +65,9 @@ export const PetsSchema: SchemaObject = { }; export function validatePets(value: unknown, path = ""): ValidationError[] { - return validateValue(value, PetsSchema, path); + const errors: ValidationError[] = []; + validateValue(value, PetsSchema, path, errors); + return errors; } diff --git a/examples/petstore-ts/sdk/src/validation-middleware.ts b/examples/petstore-ts/sdk/src/validation-middleware.ts new file mode 100644 index 0000000..4cfff98 --- /dev/null +++ b/examples/petstore-ts/sdk/src/validation-middleware.ts @@ -0,0 +1,145 @@ +/* eslint-disable */ +// Generated by specforge. DO NOT EDIT. + +import { Middleware, MiddlewareRequest } from "./middleware"; +import { SchemaType, ValidationError, validateValue } from "./validate"; + +/** + * Schema descriptor for an endpoint's request and/or response body. + */ +export interface EndpointSchema { + /** Schema for the request body. Undefined means no request body validation. */ + requestBody?: SchemaType; + /** Schema for the response body. Undefined means no response body validation. */ + responseBody?: SchemaType; +} + +/** + * A route schema map keyed by "METHOD /path/pattern". + * Patterns use `{param}` placeholders for path segments. + */ +export interface RouteSchemaMap { + [route: string]: EndpointSchema; +} + +/** + * Validate that `actualPath` matches the `pattern` path. + * Pattern segments wrapped in `{...}` are treated as wildcards. + * Returns true when every segment matches (exact or wildcard). + */ +function matchesPath(pattern: string, actualPath: string): boolean { + const patternSegments = pattern.split("/").filter(Boolean); + const actualSegments = actualPath.split("/").filter(Boolean); + if (patternSegments.length !== actualSegments.length) return false; + for (let i = 0; i < patternSegments.length; i++) { + const ps = patternSegments[i]!; + if (ps.startsWith("{") && ps.endsWith("}")) continue; // wildcard + if (ps !== actualSegments[i]) return false; + } + return true; +} + +/** + * Create a validation middleware that intercepts all requests and validates + * request/response bodies against the OpenAPI schema. + * + * Usage: + * ```ts + * import { createValidationMiddleware, RouteSchemaMap } from "./validation-middleware"; + * + * const schemas: RouteSchemaMap = { + * "POST /pets": { requestBody: PetSchema, responseBody: PetSchema }, + * "GET /pets/{petId}": { responseBody: PetSchema }, + * }; + * + * client.use(createValidationMiddleware(schemas)); + * ``` + * + * The middleware validates request bodies before dispatch and response bodies + * after a successful response. Validation errors are thrown as Error objects + * with a `validationErrors` property containing the details. + */ +export function createValidationMiddleware(schemas: RouteSchemaMap): Middleware { + return async (req: MiddlewareRequest, next: (req: MiddlewareRequest) => Promise): Promise => { + const method = req.method.toUpperCase(); + // Strip query string for route matching. + const path = req.url.split("?")[0] || ""; + + // Find a matching route schema. + const routeKey = `${method} ${path}`; + let endpointSchema = schemas[routeKey]; + + // Try pattern matching if exact match fails. + if (!endpointSchema) { + for (const [pattern, schema] of Object.entries(schemas)) { + const spaceIdx = pattern.indexOf(" "); + if (spaceIdx < 0) continue; + const patternMethod = pattern.substring(0, spaceIdx).toUpperCase(); + const patternPath = pattern.substring(spaceIdx + 1); + if (patternMethod === method && matchesPath(patternPath, path)) { + endpointSchema = schema; + break; + } + } + } + + // Validate request body if a schema is defined. + if (endpointSchema?.requestBody && req.body !== undefined && req.body !== null) { + let body: unknown; + if (typeof req.body === "string") { + try { body = JSON.parse(req.body); } catch { body = req.body; } + } else if ( + typeof ReadableStream !== "undefined" && req.body instanceof ReadableStream || + typeof Blob !== "undefined" && req.body instanceof Blob || + typeof ArrayBuffer !== "undefined" && req.body instanceof ArrayBuffer || + typeof FormData !== "undefined" && req.body instanceof FormData + ) { + // Cannot validate non-JSON body types; skip. + } else { + body = req.body; + } + if (body !== undefined && body !== null && typeof body !== "string") { + const errors: ValidationError[] = []; + validateValue(body, endpointSchema.requestBody, "body", errors); + if (errors.length > 0) { + const err = new Error( + `[validation] ${method} ${path} request body: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`, + ); + (err as any).validationErrors = errors; + throw err; + } + } + } + + // Proceed with the request. + const response = await next(req); + + // Validate response body if a schema is defined and the response is JSON. + if (endpointSchema?.responseBody && response.ok) { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const cloned = response.clone(); + try { + const text = await cloned.text(); + if (text) { + const body = JSON.parse(text); + const errors: ValidationError[] = []; + validateValue(body, endpointSchema.responseBody, "body", errors); + if (errors.length > 0) { + const err = new Error( + `[validation] ${method} ${path} response ${response.status}: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`, + ); + (err as any).validationErrors = errors; + throw err; + } + } + } catch (e) { + if ((e as any).validationErrors) throw e; + // Non-JSON or parse error -- skip response validation. + } + } + } + + return response; + }; +} From ca8d949ab326c879594ac8d8863185d98f081c21 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 08:07:27 +0000 Subject: [PATCH 02/19] core: Refactor diff markdown + fix shadowed binary patterns - diff.rs: expand golfed fmt_md_msg into readable prefixed-if chain (fixes 13 clippy else-if warnings) - testgen.rs: Scalar::Binary was shadowed by a Boolean|Base64|Binary arm, so binary fields generated "true"/boolean asserts instead of Uint8Array. Split Binary into its own arm (3 sites). - spec.rs: collapse match to matches! macro - resolve.rs: take &T instead of &Box - rust emitter: remove dead rust_method_doc_comment - apply remaining clippy autofixes across crates --- crates/specforge-cli/src/main.rs | 60 +++++++++---------- crates/specforge-cli/tests/edge_cases.rs | 2 +- crates/specforge-cli/tests/sdk_integration.rs | 6 +- crates/specforge-core/src/changelog.rs | 4 +- crates/specforge-core/src/diff.rs | 57 +++++++++++++++++- crates/specforge-core/src/profiler.rs | 4 +- crates/specforge-core/src/resolve.rs | 4 +- crates/specforge-core/src/spec.rs | 8 +-- crates/specforge-core/src/testgen.rs | 6 +- crates/specforge-go/src/lib.rs | 12 ++-- crates/specforge-rust/src/lib.rs | 42 +------------ 11 files changed, 108 insertions(+), 97 deletions(-) diff --git a/crates/specforge-cli/src/main.rs b/crates/specforge-cli/src/main.rs index f8cdcd8..c736299 100644 --- a/crates/specforge-cli/src/main.rs +++ b/crates/specforge-cli/src/main.rs @@ -12,7 +12,7 @@ use serde_json::Value as JsonValue; use tracing::{info, warn}; use tracing_subscriber::EnvFilter; -use specforge_core::{diff, export_spec, generate_changelog, generate_demo_spec, lint, lint_config, merge_specs, parse_file, profile_api, resolve, resolve_spec_path, scan_versions, apply_versioning, ChangelogFormat, ChangelogOptions, DiffSeverity, ExportOptions, LintConfig, MarketplaceIndex, PluginIndex, ProfileOptions, RuleSeverity, Severity, SpecforgeConfig, VersioningConfig, VersionStrategy}; +use specforge_core::{diff, generate_changelog, lint, lint_config, merge_specs, parse_file, profile_api, resolve, resolve_spec_path, scan_versions, apply_versioning, ChangelogFormat, ChangelogOptions, DiffSeverity, LintConfig, MarketplaceIndex, PluginIndex, ProfileOptions, RuleSeverity, Severity, SpecforgeConfig, VersioningConfig, VersionStrategy}; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] enum LogLevel { @@ -870,7 +870,7 @@ fn main() -> ExitCode { } Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -884,7 +884,7 @@ fn main() -> ExitCode { } Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -898,7 +898,7 @@ fn main() -> ExitCode { } Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -906,7 +906,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -914,7 +914,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -922,7 +922,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -930,7 +930,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -941,7 +941,7 @@ fn main() -> ExitCode { } Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -949,7 +949,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -957,7 +957,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -975,7 +975,7 @@ fn main() -> ExitCode { } Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -990,7 +990,7 @@ fn main() -> ExitCode { } Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -998,7 +998,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1006,7 +1006,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1014,7 +1014,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1029,7 +1029,7 @@ fn main() -> ExitCode { } Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1037,7 +1037,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1045,7 +1045,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1053,7 +1053,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1061,7 +1061,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1069,7 +1069,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1077,7 +1077,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1085,7 +1085,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1093,7 +1093,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1101,7 +1101,7 @@ fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); - let _ = warn!("{e:#}"); + warn!("{e:#}"); ExitCode::FAILURE } }, @@ -1781,7 +1781,7 @@ fn run_merge(cli: &MergeArgs) -> Result<()> { info!("reading spec: {}", path.display()); let text = std::fs::read_to_string(path) .with_context(|| format!("failed to read spec at {}", path.display()))?; - let val: serde_json::Value = if path.extension().map_or(false, |e| e == "json") { + let val: serde_json::Value = if path.extension().is_some_and(|e| e == "json") { serde_json::from_str(&text) .with_context(|| format!("failed to parse {} as JSON", path.display()))? } else { @@ -2521,11 +2521,10 @@ fn run_market_list(index: &MarketplaceIndex, cli: &MarketListArgs) -> Result<()> .unwrap_or(20) .max(20); eprintln!( - " {:10} {:>5} {}", + " {:10} {:>5} DESCRIPTION", "NAME", "DOWNLOADS", "RATING", - "DESCRIPTION", width = name_w, ); eprintln!( @@ -2836,12 +2835,11 @@ fn run_plugin_list(index: &PluginIndex, cli: &PluginListArgs) -> Result<()> { .unwrap_or(20) .max(20); eprintln!( - " {:10} {:>5} {:>12} {}", + " {:10} {:>5} {:>12} DESCRIPTION", "NAME", "DOWNLOADS", "RATING", "LANGUAGE", - "DESCRIPTION", width = name_w, ); eprintln!( diff --git a/crates/specforge-cli/tests/edge_cases.rs b/crates/specforge-cli/tests/edge_cases.rs index 63577a7..a0f4989 100644 --- a/crates/specforge-cli/tests/edge_cases.rs +++ b/crates/specforge-cli/tests/edge_cases.rs @@ -504,7 +504,7 @@ fn edge_case_ref_only_schema() { ); let doc = parse_resolve_inline(yaml); assert!( - doc.schemas.models.len() >= 1, + !doc.schemas.models.is_empty(), "expected at least 1 schema" ); assert!( diff --git a/crates/specforge-cli/tests/sdk_integration.rs b/crates/specforge-cli/tests/sdk_integration.rs index 5129a8f..ee51b8c 100644 --- a/crates/specforge-cli/tests/sdk_integration.rs +++ b/crates/specforge-cli/tests/sdk_integration.rs @@ -189,10 +189,8 @@ fn start_mock() -> (SocketAddr, thread::JoinHandle<()>) { ]; let handle = thread::spawn(move || { - for conn in listener.incoming() { - if let Ok(mut stream) = conn { - handle_request(&mut stream, &pets); - } + for mut stream in listener.incoming().flatten() { + handle_request(&mut stream, &pets); } }); thread::sleep(Duration::from_millis(20)); diff --git a/crates/specforge-core/src/changelog.rs b/crates/specforge-core/src/changelog.rs index 2422942..e880686 100644 --- a/crates/specforge-core/src/changelog.rs +++ b/crates/specforge-core/src/changelog.rs @@ -590,7 +590,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String let mut out = String::new(); // Header - out.push_str(&"# Changelog\n\n".to_string()); + out.push_str("# Changelog\n\n"); out.push_str(&format!("## [{version}] -- {today}\n\n")); // Suggested version bump @@ -602,7 +602,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String } // Summary - out.push_str(&"### Summary\n\n".to_string()); + out.push_str("### Summary\n\n"); out.push_str(&format!( "- **{}** operation(s) changed\n", result.operation_entries.len() diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs index 5c1b799..a792bfc 100644 --- a/crates/specforge-core/src/diff.rs +++ b/crates/specforge-core/src/diff.rs @@ -83,7 +83,62 @@ pub fn format_markdown(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetai if !schema_diffs.is_empty() { out.push_str("## Schema Changes\n\n"); for d in schema_diffs { out.push_str(&format!("### `{}`\n\n| Change | Property | Details |\n|--------|----------|--------|\n", d.name)); for c in &d.changes { let (icon, det) = match &c.change { PropertyChangeKind::Added { ty } => ("Added", format!("new property (`{ty}`)")), PropertyChangeKind::AddedRequired { ty } => ("Added", format!("new required property (`{ty}`)")), PropertyChangeKind::Removed { ty } => ("Removed", format!("was `{ty}`")), PropertyChangeKind::TypeChanged { old_type, new_type } => ("Changed", format!("`{old_type}` → `{new_type}`")), PropertyChangeKind::RequiredChanged { old_required, new_required } => { let o = if *old_required { "required" } else { "optional" }; let n = if *new_required { "required" } else { "optional" }; ("Changed", format!("{o} → {n}")) } }; out.push_str(&format!("| {icon} | `{}` | {det} |\n", c.property)); } out.push('\n'); } } out.push_str("## Summary\n\n"); out.push_str(&format!("- **{}** breaking change(s)\n- **{}** non-breaking change(s)\n", brk.len(), inf.len())); if !schema_diffs.is_empty() { out.push_str(&format!("- **{}** schema(s) modified\n", schema_diffs.len())); } out } fn fmt_md(f: &DiffFinding) -> String { fmt_md_msg(&f.message) } -fn fmt_md_msg(msg: &str) -> String { if let Some(rest) = msg.strip_prefix("IR version changed: ") { return format!("IR version changed: {rest}"); } if let Some(id) = msg.strip_prefix("operation removed: ") { return format!("Operation `{id}` removed"); } if let Some(id) = msg.strip_prefix("operation added: ") { return format!("Operation `{id}` added"); } if let Some(n) = msg.strip_prefix("schema removed: ") { return format!("Schema `{n}` removed"); } if let Some(n) = msg.strip_prefix("schema added: ") { return format!("Schema `{n}` added"); } if let Some(p) = msg.strip_prefix("new required property: ") { return format!("New required property `{p}`"); } if let Some(p) = msg.strip_prefix("new optional property: ") { return format!("New optional property `{p}`"); } if let Some(p) = msg.strip_prefix("property removed: ") { return format!("Property `{p}` removed"); } if let Some(p) = msg.strip_prefix("type changed for property: ") { return format!("Type changed for property `{p}`"); } if let Some(p) = msg.strip_prefix("new required parameter: ") { return format!("New required parameter `{p}`"); } if let Some(p) = msg.strip_prefix("parameter removed: ") { return format!("Parameter `{p}` removed"); } if let Some(p) = msg.strip_prefix("response removed: ") { return format!("Response `{p}` removed"); } if let Some(p) = msg.strip_prefix("required changed for property: ") { return format!("Required status changed for property `{p}`"); } if msg == "new required request body" { return "New required request body".into(); } let mut c = msg.chars(); match c.next() { None => String::new(), Some(ch) => { let mut s = ch.to_uppercase().to_string(); s.extend(c); s } } } +fn fmt_md_msg(msg: &str) -> String { + // Recognized diff messages are rewritten into human-readable Markdown. + // Each prefix maps to a templated sentence; anything else is returned + // with its first character uppercased. + if let Some(rest) = msg.strip_prefix("IR version changed: ") { + return format!("IR version changed: {rest}"); + } + if let Some(id) = msg.strip_prefix("operation removed: ") { + return format!("Operation `{id}` removed"); + } + if let Some(id) = msg.strip_prefix("operation added: ") { + return format!("Operation `{id}` added"); + } + if let Some(n) = msg.strip_prefix("schema removed: ") { + return format!("Schema `{n}` removed"); + } + if let Some(n) = msg.strip_prefix("schema added: ") { + return format!("Schema `{n}` added"); + } + if let Some(p) = msg.strip_prefix("new required property: ") { + return format!("New required property `{p}`"); + } + if let Some(p) = msg.strip_prefix("new optional property: ") { + return format!("New optional property `{p}`"); + } + if let Some(p) = msg.strip_prefix("property removed: ") { + return format!("Property `{p}` removed"); + } + if let Some(p) = msg.strip_prefix("type changed for property: ") { + return format!("Type changed for property `{p}`"); + } + if let Some(p) = msg.strip_prefix("new required parameter: ") { + return format!("New required parameter `{p}`"); + } + if let Some(p) = msg.strip_prefix("parameter removed: ") { + return format!("Parameter `{p}` removed"); + } + if let Some(p) = msg.strip_prefix("response removed: ") { + return format!("Response `{p}` removed"); + } + if let Some(p) = msg.strip_prefix("required changed for property: ") { + return format!("Required status changed for property `{p}`"); + } + if msg == "new required request body" { + return "New required request body".into(); + } + let mut c = msg.chars(); + match c.next() { + None => String::new(), + Some(ch) => { + let mut s = ch.to_uppercase().to_string(); + s.extend(c); + s + } + } +} pub fn format_json(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> Result { let breaking: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).cloned().collect(); let info: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).cloned().collect(); serde_json::to_string_pretty(&DiffJsonOutput { breaking, info, schema_diffs: schema_diffs.to_vec(), summary: DiffSummary { breaking_count: findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(), info_count: findings.iter().filter(|f| f.severity == DiffSeverity::Info).count() } }) } diff --git a/crates/specforge-core/src/profiler.rs b/crates/specforge-core/src/profiler.rs index 8ace5b0..625d005 100644 --- a/crates/specforge-core/src/profiler.rs +++ b/crates/specforge-core/src/profiler.rs @@ -365,7 +365,7 @@ pub fn format_text(report: &ProfileReport) -> String { out.push_str(&format!(" Requests: {}\n", r.total_requests)); out.push_str(&format!(" Success/Error: {} / {}\n", r.success_count, r.error_count)); out.push_str(&format!(" Error rate: {:.1}%\n", r.error_rate * 100.0)); - out.push_str(&" Latency (ms):\n".to_string()); + out.push_str(" Latency (ms):\n"); out.push_str(&format!(" avg: {:.1}\n", r.avg_latency_ms)); out.push_str(&format!(" p50: {:.1}\n", r.p50_latency_ms)); out.push_str(&format!(" p95: {:.1}\n", r.p95_latency_ms)); @@ -425,7 +425,7 @@ pub fn format_markdown(report: &ProfileReport) -> String { out.push_str(&format!("**Overall throughput:** {:.1} rps\n\n", report.overall_throughput_rps)); if !report.recommendations.is_empty() { - out.push_str(&"## Recommendations\n\n".to_string()); + out.push_str("## Recommendations\n\n"); for (i, rec) in report.recommendations.iter().enumerate() { out.push_str(&format!("{}. {}\n", i + 1, rec)); } diff --git a/crates/specforge-core/src/resolve.rs b/crates/specforge-core/src/resolve.rs index a4b6712..8c59ca6 100644 --- a/crates/specforge-core/src/resolve.rs +++ b/crates/specforge-core/src/resolve.rs @@ -526,8 +526,8 @@ fn ref_or_boxed_schema_to_type(schema_or: &ReferenceOr>) -> Type { /// Handle a `Box>` — used for `additionalProperties` and /// array `items`. (openapiv3 is inconsistent about which side the Box is on.) -fn boxed_ref_or_schema_to_type(boxed: &Box>) -> Type { - match boxed.as_ref() { +fn boxed_ref_or_schema_to_type(boxed: &ReferenceOr) -> Type { + match boxed { ReferenceOr::Item(s) => schema_to_type(s).unwrap_or(Type::Unknown), ReferenceOr::Reference { reference } => Type::Reference { name: ref_name(reference), diff --git a/crates/specforge-core/src/spec.rs b/crates/specforge-core/src/spec.rs index 13c24e1..c6b63e1 100644 --- a/crates/specforge-core/src/spec.rs +++ b/crates/specforge-core/src/spec.rs @@ -376,10 +376,10 @@ pub fn scan_versions(dir: &Path) -> Vec { /// Check if a file looks like an OpenAPI spec by extension. fn is_spec_file(path: &Path) -> bool { - match path.extension().and_then(|e| e.to_str()) { - Some("yaml" | "yml" | "json") => true, - _ => false, - } + matches!( + path.extension().and_then(|e| e.to_str()), + Some("yaml" | "yml" | "json") + ) } /// Scan a single subdirectory for a spec file and extract its version. diff --git a/crates/specforge-core/src/testgen.rs b/crates/specforge-core/src/testgen.rs index b26c5f6..86c04ab 100644 --- a/crates/specforge-core/src/testgen.rs +++ b/crates/specforge-core/src/testgen.rs @@ -198,7 +198,7 @@ fn generate_ts_example(ty: &Type, schemas: &indexmap::IndexMap, i Scalar::Uuid => r#""550e8400-e29b-41d4-a716-446655440000""#.to_string(), Scalar::Integer | Scalar::Integer64 => "1".to_string(), Scalar::Float => "1.0".to_string(), - Scalar::Boolean | Scalar::Base64 | Scalar::Binary => "true".to_string(), + Scalar::Boolean | Scalar::Base64 => "true".to_string(), Scalar::Binary => "new Uint8Array([1,2,3])".to_string(), _ => "String".to_string(), }, @@ -349,7 +349,7 @@ fn generate_go_example(ty: &Type, schemas: &indexmap::IndexMap) - Scalar::Uuid => "\"550e8400-e29b-41d4-a716-446655440000\"".to_string(), Scalar::Integer | Scalar::Integer64 => "1".to_string(), Scalar::Float => "1.0".to_string(), - Scalar::Boolean | Scalar::Base64 | Scalar::Binary => "true".to_string(), + Scalar::Boolean | Scalar::Base64 => "true".to_string(), Scalar::Binary => "new Uint8Array([1,2,3])".to_string(), _ => "String".to_string(), }, @@ -511,7 +511,7 @@ fn generate_rust_example(ty: &Type, schemas: &indexmap::IndexMap) Scalar::Uuid => r#""550e8400-e29b-41d4-a716-446655440000""#.to_string(), Scalar::Integer | Scalar::Integer64 => "1".to_string(), Scalar::Float => "1.0".to_string(), - Scalar::Boolean | Scalar::Base64 | Scalar::Binary => "true".to_string(), + Scalar::Boolean | Scalar::Base64 => "true".to_string(), Scalar::Binary => "new Uint8Array([1,2,3])".to_string(), _ => "String".to_string(), }, diff --git a/crates/specforge-go/src/lib.rs b/crates/specforge-go/src/lib.rs index 2025162..51f0899 100644 --- a/crates/specforge-go/src/lib.rs +++ b/crates/specforge-go/src/lib.rs @@ -298,7 +298,7 @@ fn is_struct_model(o: &ObjectModel) -> bool { fn emit_models(pkg: &str, doc: &Document) -> String { let mut out = String::new(); - out.push_str(&"// Code generated by specforge. DO NOT EDIT.\n\n".to_string()); + out.push_str("// Code generated by specforge. DO NOT EDIT.\n\n"); out.push_str(&format!("package {pkg}\n\n")); // Check if any model needs json + fmt imports: discriminated unions use @@ -2284,14 +2284,14 @@ func (e ValidationErrors) Error() string { out.push_str(&format!( "\t\tif val, exists := obj[{field_lit}]; exists {{\n" )); - out.push_str(&"\t\t\tif s, ok := val.(string); ok {\n".to_string()); - out.push_str(&"\t\t\t\tvalid := false\n".to_string()); + out.push_str("\t\t\tif s, ok := val.(string); ok {\n"); + out.push_str("\t\t\t\tvalid := false\n"); for allowed in &vals { out.push_str(&format!( "\t\t\t\tif s == {allowed} {{ valid = true }}\n" )); } - out.push_str(&"\t\t\t\tif !valid {\n".to_string()); + out.push_str("\t\t\t\tif !valid {\n"); out.push_str(&format!( "\t\t\t\t\terrs = append(errs, ValidationError{{Path: {field_lit}, Message: fmt.Sprintf(\"invalid enum value: %s\", s)}})\n" )); @@ -2959,8 +2959,8 @@ fn emit_union_from_map( out.push_str(&format!("\tcase {}:\n", go_string_lit(&val))); out.push_str(&format!("\t\tvar v {arm_ty}\n")); out.push_str("\t\tif b, err := json.Marshal(m); err == nil {\n"); - out.push_str(&"\t\t\tif err := json.Unmarshal(b, &v); err == nil {\n".to_string()); - out.push_str(&"\t\t\t\treturn v, nil\n".to_string()); + out.push_str("\t\t\tif err := json.Unmarshal(b, &v); err == nil {\n"); + out.push_str("\t\t\t\treturn v, nil\n"); out.push_str("\t\t\t}\n"); out.push_str("\t\t}\n"); out.push_str(&format!("\t\treturn v, fmt.Errorf(\"failed to unmarshal {arm_ty}\")\n")); diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs index 461de78..2245467 100644 --- a/crates/specforge-rust/src/lib.rs +++ b/crates/specforge-rust/src/lib.rs @@ -2464,7 +2464,7 @@ fn value_type_name(v: &Value) -> &'static str { out.push_str(" if !v.is_object() {\n"); out.push_str(" errors.push(ValidationError {\n"); out.push_str(" path: String::new(),\n"); - out.push_str(&" message: format!(\"expected object, got {}\", value_type_name(v)),\n".to_string()); + out.push_str(" message: format!(\"expected object, got {}\", value_type_name(v)),\n"); out.push_str(" });\n"); out.push_str(" }\n"); } else { @@ -3323,46 +3323,6 @@ fn emit_method(op: &Operation) -> String { /// Generate the full doc comment block + deprecation attribute for a method. -fn rust_method_doc_comment(op: &Operation, fn_name: &str) -> String { - let mut doc = String::new(); - if let Some(s) = &op.summary { doc.push_str(&rust_doc(s)); } - else { doc.push_str(&format!("/// {} {}.\n", op.method.upper(), op.path)); } - if let Some(d) = &op.description { for line in d.lines() { doc.push_str(&format!("/// {line}\n")); } } - let all_params: Vec<&specforge_core::Parameter> = op.parameters.iter().filter(|p| matches!(p.location, ParamLocation::Path | ParamLocation::Query)).collect(); - let has_param_desc = all_params.iter().any(|p| p.description.is_some()) || op.request_body.as_ref().and_then(|b| b.description.as_deref()).is_some(); - if has_param_desc { - doc.push_str("///\n/// # Arguments\n"); - for p in &all_params { - let ident = snake(&p.name); - let suffix = if p.required { String::new() } else { " (optional)".to_string() }; - if let Some(desc) = &p.description { doc.push_str(&format!("/// * `{ident}` - {desc}{suffix}\n")); } - else { doc.push_str(&format!("/// * `{ident}`{suffix}\n")); } - } - if let Some(rb) = &op.request_body { - let suffix = if rb.required { String::new() } else { " (optional)".to_string() }; - if let Some(desc) = &rb.description { doc.push_str(&format!("/// * `body` - {desc}{suffix}\n")); } - else { doc.push_str(&format!("/// * `body`{suffix}\n")); } - } - } - let success_desc = op.responses.iter().filter(|r| r.status.starts_with('2')).min_by_key(|r| r.status.clone()).and_then(|r| r.description.clone()); - if let Some(desc) = &success_desc { doc.push_str(&format!("///\n/// # Returns\n/// {desc}\n")); } - else if success_body(op).is_some() { doc.push_str("///\n/// # Returns\n/// The response body\n"); } - let error_responses: Vec<&specforge_core::Response> = op.responses.iter().filter(|r| !r.status.starts_with('2') && r.status != "*").collect(); - if !error_responses.is_empty() { - doc.push_str("///\n/// # Errors\n"); - for r in &error_responses { - let status = &r.status; - if let Some(desc) = &r.description { doc.push_str(&format!("/// Returns `Error::Http` with status {status} for {desc}.\n")); } - else { doc.push_str(&format!("/// Returns `Error::Http` with status {status}.\n")); } - } - } - if is_operation_deprecated(op) { - if let Some(alt) = rust_deprecation_alternative(op) { doc.push_str(&format!("#[deprecated(note = \"Use {alt} instead\")]\n")); } - else { doc.push_str(&format!("#[deprecated(note = \"Use {} instead\")]\n", fn_name)); } - } - doc -} - fn build_rust_path(path: &str, path_params: &[&specforge_core::Parameter]) -> String { // Use format! with placeholders; every segment is stringified first so ints // and enums path-encode cleanly. From e96ab36544ddaf6c5daf8acbe049dcd9e9f10640 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 08:09:24 +0000 Subject: [PATCH 03/19] core: Make Scalar matches exhaustive + use sort_by_key - testgen.rs: remove now-unreachable _ catch-alls (all 9 Scalar variants are covered after the Binary split) and give Binary its own assertion - analyzer.rs, marketplace.rs: descending sorts via sort_by_key(Reverse(..)) --- crates/specforge-core/src/analyzer.rs | 5 +++-- crates/specforge-core/src/marketplace.rs | 9 +++++---- crates/specforge-core/src/testgen.rs | 9 ++++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/specforge-core/src/analyzer.rs b/crates/specforge-core/src/analyzer.rs index d158f67..6c37914 100644 --- a/crates/specforge-core/src/analyzer.rs +++ b/crates/specforge-core/src/analyzer.rs @@ -1,4 +1,5 @@ use crate::ir::{Document, Model, Type}; +use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, serde::Serialize)] @@ -55,7 +56,7 @@ pub fn analyze_spec(doc: &Document) -> AnalysisReport { if obj.properties.len() > 20 { report.large_schemas.push((name.clone(), obj.properties.len())); } } } - report.large_schemas.sort_by(|a, b| b.1.cmp(&a.1)); + report.large_schemas.sort_by_key(|a| Reverse(a.1)); let mut fps: HashMap, Vec> = HashMap::new(); for (name, model) in doc.schemas.iter() { if let Model::Object(obj) = model { @@ -78,7 +79,7 @@ pub fn analyze_spec(doc: &Document) -> AnalysisReport { let depth = ref_depth(name, &ref_graph, &mut memo); if depth > 2 { report.deep_refs.push((name.clone(), depth)); } } - report.deep_refs.sort_by(|a, b| b.1.cmp(&a.1)); + report.deep_refs.sort_by_key(|a| Reverse(a.1)); if !report.unused_schemas.is_empty() { report.recommendations.push(format!("Remove {} unused schema(s): {}", report.unused_schemas.len(), report.unused_schemas.join(", "))); } diff --git a/crates/specforge-core/src/marketplace.rs b/crates/specforge-core/src/marketplace.rs index 24fe69c..28f783d 100644 --- a/crates/specforge-core/src/marketplace.rs +++ b/crates/specforge-core/src/marketplace.rs @@ -5,6 +5,7 @@ use anyhow::{bail, Context as _}; use serde::{Deserialize, Serialize}; +use std::cmp::Reverse; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; @@ -56,7 +57,7 @@ impl MarketplaceIndex { map.insert(&entry.name, entry); } self.entries = map.into_values().cloned().collect(); - self.entries.sort_by(|a, b| b.downloads.cmp(&a.downloads)); + self.entries.sort_by_key(|a| Reverse(a.downloads)); } /// Search entries by a free-text query (matches name, description, tags, author). @@ -82,7 +83,7 @@ impl MarketplaceIndex { /// Return all entries sorted by downloads (descending). pub fn sorted_by_downloads(&self) -> Vec<&SpecEntry> { let mut v: Vec<&SpecEntry> = self.entries.iter().collect(); - v.sort_by(|a, b| b.downloads.cmp(&a.downloads)); + v.sort_by_key(|a| Reverse(a.downloads)); v } @@ -191,7 +192,7 @@ impl PluginIndex { map.insert(&entry.name, entry); } self.plugins = map.into_values().cloned().collect(); - self.plugins.sort_by(|a, b| b.downloads.cmp(&a.downloads)); + self.plugins.sort_by_key(|a| Reverse(a.downloads)); } /// Search plugins by a free-text query (matches name, description, language, author). @@ -217,7 +218,7 @@ impl PluginIndex { /// Return all plugins sorted by downloads (descending). pub fn sorted_by_downloads(&self) -> Vec<&PluginEntry> { let mut v: Vec<&PluginEntry> = self.plugins.iter().collect(); - v.sort_by(|a, b| b.downloads.cmp(&a.downloads)); + v.sort_by_key(|a| Reverse(a.downloads)); v } diff --git a/crates/specforge-core/src/testgen.rs b/crates/specforge-core/src/testgen.rs index 86c04ab..7cc8610 100644 --- a/crates/specforge-core/src/testgen.rs +++ b/crates/specforge-core/src/testgen.rs @@ -181,10 +181,12 @@ fn ts_assertion(ty: &Type) -> String { Scalar::Integer | Scalar::Integer64 | Scalar::Float => { "console.assert(typeof result === 'number', 'expected number')".to_string() } - Scalar::Boolean | Scalar::Base64 | Scalar::Binary => { + Scalar::Boolean | Scalar::Base64 => { "console.assert(typeof result === 'boolean', 'expected boolean')".to_string() } - _ => "String".to_string(), + Scalar::Binary => { + "console.assert(result !== undefined, 'expected bytes')".to_string() + } }, _ => "console.assert(result !== undefined, 'expected result')".to_string(), } @@ -200,7 +202,6 @@ fn generate_ts_example(ty: &Type, schemas: &indexmap::IndexMap, i Scalar::Float => "1.0".to_string(), Scalar::Boolean | Scalar::Base64 => "true".to_string(), Scalar::Binary => "new Uint8Array([1,2,3])".to_string(), - _ => "String".to_string(), }, Type::StringEnum { variants, .. } => { if let Some(first) = variants.first() { @@ -351,7 +352,6 @@ fn generate_go_example(ty: &Type, schemas: &indexmap::IndexMap) - Scalar::Float => "1.0".to_string(), Scalar::Boolean | Scalar::Base64 => "true".to_string(), Scalar::Binary => "new Uint8Array([1,2,3])".to_string(), - _ => "String".to_string(), }, Type::StringEnum { variants, .. } => { if let Some(first) = variants.first() { @@ -513,7 +513,6 @@ fn generate_rust_example(ty: &Type, schemas: &indexmap::IndexMap) Scalar::Float => "1.0".to_string(), Scalar::Boolean | Scalar::Base64 => "true".to_string(), Scalar::Binary => "new Uint8Array([1,2,3])".to_string(), - _ => "String".to_string(), }, Type::StringEnum { variants, .. } => { if let Some(first) = variants.first() { From 8e796b67ec9a045e22f8a119a3997418745927ae Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 08:12:23 +0000 Subject: [PATCH 04/19] ts/runtime: Convert file list to vec! macro + cleanup - runtime.rs: collect() now builds the file list with vec![] instead of Vec::new() + 18 pushes (clearer intent) - schema31.rs: drop redundant borrow in to_value() in test - apply remaining clippy autofixes (collapsed if-lets) --- crates/specforge-core/src/schema31.rs | 2 +- crates/specforge-ts/src/runtime.rs | 40 +++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/specforge-core/src/schema31.rs b/crates/specforge-core/src/schema31.rs index de3060e..c1c0099 100644 --- a/crates/specforge-core/src/schema31.rs +++ b/crates/specforge-core/src/schema31.rs @@ -88,7 +88,7 @@ mod tests { #[test] fn roundtrip() { let json = serde_json::json!({"type": ["string", "null"], "const": "yes", "x-custom": "value"}); - let s = serde_json::to_value(&Schema31::from_json(&json).unwrap()).unwrap(); + let s = serde_json::to_value(Schema31::from_json(&json).unwrap()).unwrap(); assert_eq!(s["type"], serde_json::json!(["string", "null"])); } } diff --git a/crates/specforge-ts/src/runtime.rs b/crates/specforge-ts/src/runtime.rs index 5375a11..fec7ee1 100644 --- a/crates/specforge-ts/src/runtime.rs +++ b/crates/specforge-ts/src/runtime.rs @@ -27,26 +27,26 @@ pub fn emit(doc: &Document, out_dir: &Path) -> std::io::Result> { /// Collect all runtime files (relative path, absolute path, content) for parallel writing. pub fn collect(doc: &Document, out_dir: &Path) -> std::io::Result> { let src = out_dir.join("src"); - let mut files = Vec::new(); - - files.push((path_str(&src.join("errors.ts"), out_dir), src.join("errors.ts"), errors_source())); - files.push((path_str(&src.join("auth.ts"), out_dir), src.join("auth.ts"), auth_source(doc))); - files.push((path_str(&src.join("retry.ts"), out_dir), src.join("retry.ts"), retry_source())); - files.push((path_str(&src.join("paginate.ts"), out_dir), src.join("paginate.ts"), paginate_source())); - files.push((path_str(&src.join("concurrency.ts"), out_dir), src.join("concurrency.ts"), concurrency_source())); - files.push((path_str(&src.join("dedup.ts"), out_dir), src.join("dedup.ts"), dedup_source())); - files.push((path_str(&src.join("idempotency.ts"), out_dir), src.join("idempotency.ts"), idempotency_source())); - files.push((path_str(&src.join("middleware.ts"), out_dir), src.join("middleware.ts"), middleware_source())); - files.push((path_str(&src.join("interceptors.ts"), out_dir), src.join("interceptors.ts"), interceptors_source())); - files.push((path_str(&src.join("streaming.ts"), out_dir), src.join("streaming.ts"), streaming_source())); - files.push((path_str(&src.join("validate.ts"), out_dir), src.join("validate.ts"), validate_source(doc))); - files.push((path_str(&src.join("cache.ts"), out_dir), src.join("cache.ts"), cache_source())); - files.push((path_str(&src.join("ratelimit.ts"), out_dir), src.join("ratelimit.ts"), ratelimit_source())); - files.push((path_str(&src.join("telemetry.ts"), out_dir), src.join("telemetry.ts"), telemetry_source())); - files.push((path_str(&src.join("logging.ts"), out_dir), src.join("logging.ts"), logging_source())); - files.push((path_str(&src.join("validation-middleware.ts"), out_dir), src.join("validation-middleware.ts"), validation_middleware_source())); - files.push((path_str(&src.join("service_container.ts"), out_dir), src.join("service_container.ts"), service_container_source())); - files.push((path_str(&src.join("client.ts"), out_dir), src.join("client.ts"), client_source(doc))); + let files = vec![ + (path_str(&src.join("errors.ts"), out_dir), src.join("errors.ts"), errors_source()), + (path_str(&src.join("auth.ts"), out_dir), src.join("auth.ts"), auth_source(doc)), + (path_str(&src.join("retry.ts"), out_dir), src.join("retry.ts"), retry_source()), + (path_str(&src.join("paginate.ts"), out_dir), src.join("paginate.ts"), paginate_source()), + (path_str(&src.join("concurrency.ts"), out_dir), src.join("concurrency.ts"), concurrency_source()), + (path_str(&src.join("dedup.ts"), out_dir), src.join("dedup.ts"), dedup_source()), + (path_str(&src.join("idempotency.ts"), out_dir), src.join("idempotency.ts"), idempotency_source()), + (path_str(&src.join("middleware.ts"), out_dir), src.join("middleware.ts"), middleware_source()), + (path_str(&src.join("interceptors.ts"), out_dir), src.join("interceptors.ts"), interceptors_source()), + (path_str(&src.join("streaming.ts"), out_dir), src.join("streaming.ts"), streaming_source()), + (path_str(&src.join("validate.ts"), out_dir), src.join("validate.ts"), validate_source(doc)), + (path_str(&src.join("cache.ts"), out_dir), src.join("cache.ts"), cache_source()), + (path_str(&src.join("ratelimit.ts"), out_dir), src.join("ratelimit.ts"), ratelimit_source()), + (path_str(&src.join("telemetry.ts"), out_dir), src.join("telemetry.ts"), telemetry_source()), + (path_str(&src.join("logging.ts"), out_dir), src.join("logging.ts"), logging_source()), + (path_str(&src.join("validation-middleware.ts"), out_dir), src.join("validation-middleware.ts"), validation_middleware_source()), + (path_str(&src.join("service_container.ts"), out_dir), src.join("service_container.ts"), service_container_source()), + (path_str(&src.join("client.ts"), out_dir), src.join("client.ts"), client_source(doc)), + ]; Ok(files) } From 9459aac5f607ff38a439cfbfd9ce2996e608c8a1 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 21:41:58 +0000 Subject: [PATCH 05/19] core: Collapse nested if-lets + extract test type aliases - resolve.rs, models.rs, rust/lib.rs: collapse 3-deep if-let chains - cli/main.rs: sort_by_key(Reverse) for marketplace entry sort - e2e_smoke, e2e_advanced: ParsedRequest/LangLeg type aliases for readability of complex tuple signatures - marketplace.rs: drop unused mut in test Clippy warnings now: 4 (all deliberate non-fixes). --- crates/specforge-cli/src/main.rs | 2 +- crates/specforge-cli/tests/e2e_advanced.rs | 5 +- crates/specforge-cli/tests/e2e_smoke.rs | 10 +++- crates/specforge-core/src/marketplace.rs | 2 +- crates/specforge-core/src/resolve.rs | 68 ++++++++++------------ crates/specforge-rust/src/lib.rs | 12 ++-- crates/specforge-ts/src/models.rs | 24 ++++---- 7 files changed, 62 insertions(+), 61 deletions(-) diff --git a/crates/specforge-cli/src/main.rs b/crates/specforge-cli/src/main.rs index c736299..dc9d2c4 100644 --- a/crates/specforge-cli/src/main.rs +++ b/crates/specforge-cli/src/main.rs @@ -2592,7 +2592,7 @@ fn run_market_add(index: &MarketplaceIndex, cli: &MarketAddArgs) -> Result<()> { // Build a new index: start from existing built-in entries and append. let mut new_index = index.clone(); new_index.entries.push(entry); - new_index.entries.sort_by(|a, b| b.downloads.cmp(&a.downloads)); + new_index.entries.sort_by_key(|a| std::cmp::Reverse(a.downloads)); let json = serde_json::to_string_pretty(&new_index).context("failed to serialize marketplace index")?; diff --git a/crates/specforge-cli/tests/e2e_advanced.rs b/crates/specforge-cli/tests/e2e_advanced.rs index 29763fc..863c5cb 100644 --- a/crates/specforge-cli/tests/e2e_advanced.rs +++ b/crates/specforge-cli/tests/e2e_advanced.rs @@ -10,6 +10,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, Mutex}; use std::thread; + +/// A parsed HTTP request: method, path, headers, body. +type ParsedRequest = (String, String, HashMap, Vec); use std::time::Duration; // ─── Shared helpers (mirrored lightly from e2e_smoke) ──────────────────────── @@ -98,7 +101,7 @@ fn write_response(stream: &mut TcpStream, status: u16, ct: &str, body: &str, ext stream.flush() } -fn parse_http(stream: &mut TcpStream) -> std::io::Result<(String, String, HashMap, Vec)> { +fn parse_http(stream: &mut TcpStream) -> std::io::Result { stream.set_read_timeout(Some(Duration::from_secs(5)))?; stream.set_write_timeout(Some(Duration::from_secs(5)))?; let mut buf = [0u8; 32768]; diff --git a/crates/specforge-cli/tests/e2e_smoke.rs b/crates/specforge-cli/tests/e2e_smoke.rs index 91adeab..ae47bf3 100644 --- a/crates/specforge-cli/tests/e2e_smoke.rs +++ b/crates/specforge-cli/tests/e2e_smoke.rs @@ -10,6 +10,12 @@ use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; + +/// A parsed HTTP request: method, path, version, headers, body. +type ParsedRequest = (String, String, String, HashMap, Vec); + +/// A language test leg: its label and the function that runs it for a crate dir. +type LangLeg = (&'static str, Box Result<(), String>>); use std::process::Command; use std::sync::{Arc, Mutex}; use std::thread; @@ -45,7 +51,7 @@ fn escape_json(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } -fn parse_request(stream: &mut TcpStream) -> std::io::Result<(String, String, String, HashMap, Vec)> { +fn parse_request(stream: &mut TcpStream) -> std::io::Result { stream.set_read_timeout(Some(Duration::from_secs(3)))?; stream.set_write_timeout(Some(Duration::from_secs(3)))?; @@ -1024,7 +1030,7 @@ console.log("ts-runtime-ok", all.length, page.items.length); fn run_langs( label: &str, root: &Path, - legs: &[(&str, Box Result<(), String>>)], + legs: &[LangLeg], ) { let mut failures = Vec::new(); let mut ran = 0; diff --git a/crates/specforge-core/src/marketplace.rs b/crates/specforge-core/src/marketplace.rs index 28f783d..dd79731 100644 --- a/crates/specforge-core/src/marketplace.rs +++ b/crates/specforge-core/src/marketplace.rs @@ -312,7 +312,7 @@ mod tests { #[test] fn search_matches_name() { - let mut index = MarketplaceIndex { + let index = MarketplaceIndex { entries: vec![sample_entry("github"), sample_entry("stripe")], }; let results = index.search("github"); diff --git a/crates/specforge-core/src/resolve.rs b/crates/specforge-core/src/resolve.rs index 8c59ca6..0477429 100644 --- a/crates/specforge-core/src/resolve.rs +++ b/crates/specforge-core/src/resolve.rs @@ -246,34 +246,32 @@ fn properties_from_allof_member( ReferenceOr::Reference { reference } => { let ref_name = ref_name(reference); // First, try to find the schema in components and extract inline. - if let Some(schema_or) = components.schemas.get(&ref_name) { - if let ReferenceOr::Item(schema) = schema_or { - if let SchemaKind::Type(OApiType::Object(ObjectType { - properties, - required, - .. - })) = &schema.schema_kind - { - let required_set: std::collections::HashSet = - required.iter().cloned().collect(); - let props = properties - .iter() - .map(|(prop_name, prop_schema_or)| { - let ty = ref_or_boxed_schema_to_type(prop_schema_or); - let description = match prop_schema_or { - ReferenceOr::Item(s) => s.schema_data.description.clone(), - ReferenceOr::Reference { .. } => None, - }; - Property { - name: prop_name.clone(), - ty, - required: required_set.contains(prop_name), - description, - } - }) - .collect(); - return Ok((props, required_set)); - } + if let Some(ReferenceOr::Item(schema)) = components.schemas.get(&ref_name) { + if let SchemaKind::Type(OApiType::Object(ObjectType { + properties, + required, + .. + })) = &schema.schema_kind + { + let required_set: std::collections::HashSet = + required.iter().cloned().collect(); + let props = properties + .iter() + .map(|(prop_name, prop_schema_or)| { + let ty = ref_or_boxed_schema_to_type(prop_schema_or); + let description = match prop_schema_or { + ReferenceOr::Item(s) => s.schema_data.description.clone(), + ReferenceOr::Reference { .. } => None, + }; + Property { + name: prop_name.clone(), + ty, + required: required_set.contains(prop_name), + description, + } + }) + .collect(); + return Ok((props, required_set)); } } // Fallback: check the already-built registry. @@ -766,14 +764,12 @@ fn resolve_responses( body, }); } - if let Some(default) = &responses.default { - if let ReferenceOr::Item(r) = default { - out.push(Response { - status: "default".to_string(), - description: Some(r.description.clone()), - body: json_body(&r.content), - }); - } + if let Some(ReferenceOr::Item(r)) = &responses.default { + out.push(Response { + status: "default".to_string(), + description: Some(r.description.clone()), + body: json_body(&r.content), + }); } Ok(out) } diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs index 2245467..acfd860 100644 --- a/crates/specforge-rust/src/lib.rs +++ b/crates/specforge-rust/src/lib.rs @@ -2989,13 +2989,11 @@ fn discriminant_value( disc: &specforge_core::Discriminator, ) -> Option { // 1. Try the arm's own property (single-variant string enum). - if let Some(model) = registry.get(arm_name) { - if let Model::Object(obj) = model { - if let Some(p) = obj.properties.iter().find(|p| p.name == prop) { - if let Type::StringEnum { variants, .. } = &p.ty { - if variants.len() == 1 { - return Some(variants[0].clone()); - } + if let Some(Model::Object(obj)) = registry.get(arm_name) { + if let Some(p) = obj.properties.iter().find(|p| p.name == prop) { + if let Type::StringEnum { variants, .. } = &p.ty { + if variants.len() == 1 { + return Some(variants[0].clone()); } } } diff --git a/crates/specforge-ts/src/models.rs b/crates/specforge-ts/src/models.rs index 9db9071..0e7363b 100644 --- a/crates/specforge-ts/src/models.rs +++ b/crates/specforge-ts/src/models.rs @@ -235,22 +235,20 @@ fn discriminant_literal( disc: Option<&Discriminator>, ) -> Option { // 1. Try the arm's own property (single-variant string enum). - if let Some(model) = registry.get(arm_name) { - if let Model::Object(obj) = model { - if let Some(p) = obj.properties.iter().find(|p| p.name == prop) { - match &p.ty { - Type::StringEnum { variants, .. } if variants.len() == 1 => { - return Some(string_literal(&variants[0])); - } - Type::Reference { name, .. } => { - if let Some(Model::Enum(e)) = registry.get(name) { - if e.variants.len() == 1 { - return Some(string_literal(&e.variants[0].value)); - } + if let Some(Model::Object(obj)) = registry.get(arm_name) { + if let Some(p) = obj.properties.iter().find(|p| p.name == prop) { + match &p.ty { + Type::StringEnum { variants, .. } if variants.len() == 1 => { + return Some(string_literal(&variants[0])); + } + Type::Reference { name, .. } => { + if let Some(Model::Enum(e)) = registry.get(name) { + if e.variants.len() == 1 { + return Some(string_literal(&e.variants[0].value)); } } - _ => {} } + _ => {} } } } From 845361a6987e7ec7db29f083da4f63886f6dfd86 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 21:45:09 +0000 Subject: [PATCH 06/19] emitters: Replace guarded chars().next().unwrap() with is_some_and 5 sites across rust/go/ts emitters checked the first char of a generated identifier with .unwrap(), relying on a separate is_empty() guard above. Replace with chars().next().is_some_and(|c| ...) so each check is self-safe and the guard dependency is explicit. No behavior change; all tests + generated-SDK compile test pass. Audit of other unwrap()/expect() in non-test source found them guarded by construction (len()==1, intersection membership, or infallible I/O). --- crates/specforge-go/src/lib.rs | 2 +- crates/specforge-rust/src/lib.rs | 6 +++--- crates/specforge-ts/src/name.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/specforge-go/src/lib.rs b/crates/specforge-go/src/lib.rs index 51f0899..2f02f5a 100644 --- a/crates/specforge-go/src/lib.rs +++ b/crates/specforge-go/src/lib.rs @@ -186,7 +186,7 @@ fn pascal(input: &str) -> String { if out.is_empty() { return "X".to_string(); } - if out.chars().next().unwrap().is_ascii_digit() { + if out.chars().next().is_some_and(|c| c.is_ascii_digit()) { out.insert(0, 'X'); } out diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs index acfd860..304a876 100644 --- a/crates/specforge-rust/src/lib.rs +++ b/crates/specforge-rust/src/lib.rs @@ -186,7 +186,7 @@ fn crate_name(doc: &Document, opts: &GeneratorOptions) -> String { slug }; // Crate names can't start with a digit. - if slug.chars().next().unwrap().is_ascii_digit() { + if slug.chars().next().is_some_and(|c| c.is_ascii_digit()) { format!("sdk_{slug}") } else { format!("{slug}_sdk") @@ -220,7 +220,7 @@ fn pascal(input: &str) -> String { if out.is_empty() { return "X".to_string(); } - if out.chars().next().unwrap().is_ascii_digit() { + if out.chars().next().is_some_and(|c| c.is_ascii_digit()) { out.insert(0, 'X'); } out @@ -261,7 +261,7 @@ fn snake(input: &str) -> String { let out = out.trim_matches('_').to_string(); let out = if out.is_empty() { "x".into() - } else if out.chars().next().unwrap().is_ascii_digit() { + } else if out.chars().next().is_some_and(|c| c.is_ascii_digit()) { format!("n_{out}") } else { out diff --git a/crates/specforge-ts/src/name.rs b/crates/specforge-ts/src/name.rs index 5c936b5..71f9c82 100644 --- a/crates/specforge-ts/src/name.rs +++ b/crates/specforge-ts/src/name.rs @@ -30,7 +30,7 @@ pub fn pascal(input: &str) -> String { if out.is_empty() { return "_".to_string(); } - if out.chars().next().unwrap().is_ascii_digit() { + if out.chars().next().is_some_and(|c| c.is_ascii_digit()) { out.insert(0, '_'); } out From 7322e67ec33d54f6157fdb561b6277aa494f418a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 21:48:25 +0000 Subject: [PATCH 07/19] core/diff: Expand golfed single-line functions for readability Reformatted 9 crammed functions (diff_operations, diff_operation, diff_schemas, diff_schema, type_to_string, type_changed, format_text, format_colored, format_json, format_markdown, PropertyChange Display) into properly indented multi-line form. No logic change; all 242 core tests + 12 diff tests pass. --- crates/specforge-core/src/diff.rs | 439 +++++++++++++++++++++++++++++- 1 file changed, 426 insertions(+), 13 deletions(-) diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs index a792bfc..41dbfd8 100644 --- a/crates/specforge-core/src/diff.rs +++ b/crates/specforge-core/src/diff.rs @@ -13,7 +13,29 @@ impl std::fmt::Display for DiffFinding { fn fmt(&self, f: &mut std::fmt::Formatt #[derive(Debug, Clone, Serialize)] pub struct PropertyChange { pub property: String, pub change: PropertyChangeKind } -impl std::fmt::Display for PropertyChange { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.change { PropertyChangeKind::Added { ty } => write!(f, "+ {}: {} (new property)", self.property, ty), PropertyChangeKind::AddedRequired { ty } => write!(f, "+ {}: {} (new property, required)", self.property, ty), PropertyChangeKind::Removed { ty } => write!(f, "- {}: {} (removed)", self.property, ty), PropertyChangeKind::TypeChanged { old_type, new_type } => write!(f, "~ {}: {} → {}", self.property, old_type, new_type), PropertyChangeKind::RequiredChanged { old_required, new_required } => { let o = if *old_required { "required" } else { "optional" }; let n = if *new_required { "required" } else { "optional" }; write!(f, "~ {}: {} → {} (required changed)", self.property, o, n) } } } } +impl std::fmt::Display for PropertyChange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.change { + PropertyChangeKind::Added { ty } => { + write!(f, "+ {}: {} (new property)", self.property, ty) + } + PropertyChangeKind::AddedRequired { ty } => { + write!(f, "+ {}: {} (new property, required)", self.property, ty) + } + PropertyChangeKind::Removed { ty } => { + write!(f, "- {}: {} (removed)", self.property, ty) + } + PropertyChangeKind::TypeChanged { old_type, new_type } => { + write!(f, "~ {}: {} → {}", self.property, old_type, new_type) + } + PropertyChangeKind::RequiredChanged { old_required, new_required } => { + let o = if *old_required { "required" } else { "optional" }; + let n = if *new_required { "required" } else { "optional" }; + write!(f, "~ {}: {} → {} (required changed)", self.property, o, n) + } + } + } +} #[derive(Debug, Clone, Serialize)] pub enum PropertyChangeKind { Added { ty: String }, AddedRequired { ty: String }, Removed { ty: String }, TypeChanged { old_type: String, new_type: String }, RequiredChanged { old_required: bool, new_required: bool } } #[derive(Debug, Clone, Serialize)] @@ -28,7 +50,27 @@ pub struct DiffSummary { pub breaking_count: usize, pub info_count: usize } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffFormat { Text, Markdown, Json } -fn type_to_string(ty: &Type) -> String { match ty { Type::Scalar(s) => match s { crate::ir::Scalar::String => "string".into(), crate::ir::Scalar::DateTime => "date-time".into(), crate::ir::Scalar::Uuid => "uuid".into(), crate::ir::Scalar::Integer => "integer".into(), crate::ir::Scalar::Integer64 => "integer (int64)".into(), crate::ir::Scalar::Float => "number".into(), crate::ir::Scalar::Boolean => "boolean".into(), _ => "other".into() }, Type::Reference { name, .. } => name.clone(), Type::StringEnum { .. } => "string (enum)".into(), Type::Array { .. } => "array".into(), Type::Map { .. } => "object".into(), Type::Composition(_) => "composition".into(), Type::Any => "any".into(), Type::Unknown => "unknown".into() } } +fn type_to_string(ty: &Type) -> String { + match ty { + Type::Scalar(s) => match s { + crate::ir::Scalar::String => "string".into(), + crate::ir::Scalar::DateTime => "date-time".into(), + crate::ir::Scalar::Uuid => "uuid".into(), + crate::ir::Scalar::Integer => "integer".into(), + crate::ir::Scalar::Integer64 => "integer (int64)".into(), + crate::ir::Scalar::Float => "number".into(), + crate::ir::Scalar::Boolean => "boolean".into(), + _ => "other".into(), + }, + Type::Reference { name, .. } => name.clone(), + Type::StringEnum { .. } => "string (enum)".into(), + Type::Array { .. } => "array".into(), + Type::Map { .. } => "object".into(), + Type::Composition(_) => "composition".into(), + Type::Any => "any".into(), + Type::Unknown => "unknown".into(), + } +} pub fn diff(old: &Document, new: &Document) -> Vec { diff_detailed(old, new).findings } pub fn diff_detailed(old: &Document, new: &Document) -> DiffResult { let mut findings = Vec::new(); let mut schema_diffs = Vec::new(); diff_ir_version(old, new, &mut findings); diff_operations(old, new, &mut findings); diff_schemas(old, new, &mut findings, &mut schema_diffs); DiffResult { findings, schema_diffs } } @@ -68,19 +110,367 @@ fn ir_major_version(version: &str) -> u64 { .and_then(|s| s.parse().ok()) .unwrap_or(0) } -fn diff_operations(old: &Document, new: &Document, findings: &mut Vec) { let old_ops: std::collections::HashMap = old.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); let new_ops: std::collections::HashMap = new.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); for id in old_ops.keys() { if !new_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("operation removed: {id}"), path: format!("operations.{id}") }); } } for id in new_ops.keys() { if !old_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("operation added: {id}"), path: format!("operations.{id}") }); } } for id in old_ops.keys() { if let (Some(o), Some(n)) = (old_ops.get(id), new_ops.get(id)) { diff_operation(o, n, findings); } } } -fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) { let path = format!("operations.{}", old.operation_id); for p in &new.parameters { if p.required && !old.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required parameter: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } for p in &old.parameters { if !new.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("parameter removed: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } if new.request_body.is_some() && old.request_body.is_none() && new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: "new required request body".into(), path: format!("{path}.requestBody") }); } for r in &old.responses { if !new.responses.iter().any(|x| x.status == r.status) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("response removed: {}", r.status), path: format!("{path}.responses.{}", r.status) }); } } } -fn diff_schemas(old: &Document, new: &Document, findings: &mut Vec, schema_diffs: &mut Vec) { let old_s: std::collections::HashSet = old.schemas.iter().map(|(k, _)| k.clone()).collect(); let new_s: std::collections::HashSet = new.schemas.iter().map(|(k, _)| k.clone()).collect(); for n in &old_s { if !new_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("schema removed: {n}"), path: format!("schemas.{n}") }); } } for n in &new_s { if !old_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("schema added: {n}"), path: format!("schemas.{n}") }); } } for n in old_s.intersection(&new_s) { if let Some(d) = diff_schema(n, old.schemas.get(n).unwrap(), new.schemas.get(n).unwrap(), findings) { schema_diffs.push(d); } } } -fn diff_schema(name: &str, old: &Model, new: &Model, findings: &mut Vec) -> Option { let (op, np) = match (old, new) { (Model::Object(o), Model::Object(n)) => (&o.properties, &n.properties), _ => return None }; let path = format!("schemas.{name}"); let mut changes = Vec::new(); for p in np { if p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::AddedRequired { ty: type_to_string(&p.ty) } }); } } for p in np { if !p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("new optional property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Added { ty: type_to_string(&p.ty) } }); } } for p in op { if !np.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("property removed: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Removed { ty: type_to_string(&p.ty) } }); } } for p in op { if let Some(np) = np.iter().find(|x| x.name == p.name) { if type_changed(&p.ty, &np.ty) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("type changed for property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::TypeChanged { old_type: type_to_string(&p.ty), new_type: type_to_string(&np.ty) } }); } else if p.required != np.required { findings.push(DiffFinding { severity: if np.required { DiffSeverity::Breaking } else { DiffSeverity::Info }, message: format!("required changed for property: {} ({} → {})", p.name, if p.required { "required" } else { "optional" }, if np.required { "required" } else { "optional" }), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::RequiredChanged { old_required: p.required, new_required: np.required } }); } } } if changes.is_empty() { None } else { Some(SchemaDiffDetail { name: name.to_string(), changes }) } } -fn type_changed(old: &Type, new: &Type) -> bool { match (old, new) { (Type::Scalar(a), Type::Scalar(b)) => a != b, (Type::Reference { name: a, .. }, Type::Reference { name: b, .. }) => a != b, (Type::StringEnum { variants: a, .. }, Type::StringEnum { variants: b, .. }) => a != b, (Type::Array { item: a, .. }, Type::Array { item: b, .. }) => type_changed(a, b), _ => std::mem::discriminant(old) != std::mem::discriminant(new) } } +fn diff_operations(old: &Document, new: &Document, findings: &mut Vec) { + let old_ops: std::collections::HashMap = + old.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); + let new_ops: std::collections::HashMap = + new.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); + + // Removed operations are breaking. + for id in old_ops.keys() { + if !new_ops.contains_key(id) { + findings.push(DiffFinding { + severity: DiffSeverity::Breaking, + message: format!("operation removed: {id}"), + path: format!("operations.{id}"), + }); + } + } + // Added operations are informational. + for id in new_ops.keys() { + if !old_ops.contains_key(id) { + findings.push(DiffFinding { + severity: DiffSeverity::Info, + message: format!("operation added: {id}"), + path: format!("operations.{id}"), + }); + } + } + // Operations present in both may have changed. + for id in old_ops.keys() { + if let (Some(o), Some(n)) = (old_ops.get(id), new_ops.get(id)) { + diff_operation(o, n, findings); + } + } +} + +fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) { + let path = format!("operations.{}", old.operation_id); -pub fn format_text(findings: &[DiffFinding]) -> String { let mut out = String::new(); for finding in findings { match finding.severity { DiffSeverity::Breaking => out.push_str(&format!("breaking: {finding}\n")), DiffSeverity::Info => out.push_str(&format!("info: {finding}\n")) } } let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(); let ic = findings.len() - bc; if !findings.is_empty() { out.push_str(&format!("\n{bc} breaking change(s), {ic} info finding(s)\n")); } out } + // New required parameters are breaking. + for p in &new.parameters { + if p.required && !old.parameters.iter().any(|x| x.name == p.name) { + findings.push(DiffFinding { + severity: DiffSeverity::Breaking, + message: format!("new required parameter: {}", p.name), + path: format!("{path}.params.{}", p.name), + }); + } + } + // Removed parameters are breaking. + for p in &old.parameters { + if !new.parameters.iter().any(|x| x.name == p.name) { + findings.push(DiffFinding { + severity: DiffSeverity::Breaking, + message: format!("parameter removed: {}", p.name), + path: format!("{path}.params.{}", p.name), + }); + } + } + // A newly required request body is breaking. + if new.request_body.is_some() + && old.request_body.is_none() + && new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false) + { + findings.push(DiffFinding { + severity: DiffSeverity::Breaking, + message: "new required request body".into(), + path: format!("{path}.requestBody"), + }); + } + // Removed response codes are breaking. + for r in &old.responses { + if !new.responses.iter().any(|x| x.status == r.status) { + findings.push(DiffFinding { + severity: DiffSeverity::Breaking, + message: format!("response removed: {}", r.status), + path: format!("{path}.responses.{}", r.status), + }); + } + } +} +fn diff_schemas( + old: &Document, + new: &Document, + findings: &mut Vec, + schema_diffs: &mut Vec, +) { + let old_s: std::collections::HashSet = + old.schemas.iter().map(|(k, _)| k.clone()).collect(); + let new_s: std::collections::HashSet = + new.schemas.iter().map(|(k, _)| k.clone()).collect(); -pub fn format_colored(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> String { const RED: &str = "\x1b[31m"; const GREEN: &str = "\x1b[32m"; const YELLOW: &str = "\x1b[33m"; const BOLD: &str = "\x1b[1m"; const DIM: &str = "\x1b[2m"; const RESET: &str = "\x1b[0m"; let mut out = String::new(); for f in findings { match f.severity { DiffSeverity::Breaking => out.push_str(&format!("{RED}{BOLD}breaking{RESET} {RED}{}\n{RESET}", f.message)), DiffSeverity::Info => out.push_str(&format!("{GREEN}{BOLD}info{RESET} {GREEN}{}\n{RESET}", f.message)) } } if !schema_diffs.is_empty() { out.push('\n'); for d in schema_diffs { out.push_str(&format!("{BOLD}Schema {}:{RESET}\n", d.name)); for c in &d.changes { let (col, pre) = match &c.change { PropertyChangeKind::Added { .. } | PropertyChangeKind::AddedRequired { .. } => (GREEN, "+"), PropertyChangeKind::Removed { .. } => (RED, "-"), _ => (YELLOW, "~") }; out.push_str(&format!(" {col}{pre} {c}{RESET}\n")); } } } let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(); let ic = findings.len() - bc; if !findings.is_empty() { out.push_str(&format!("\n{DIM}{bc} breaking change(s), {ic} info finding(s){RESET}\n")); } out } + // Removed schemas are breaking. + for n in &old_s { + if !new_s.contains(n) { + findings.push(DiffFinding { + severity: DiffSeverity::Breaking, + message: format!("schema removed: {n}"), + path: format!("schemas.{n}"), + }); + } + } + // Added schemas are informational. + for n in &new_s { + if !old_s.contains(n) { + findings.push(DiffFinding { + severity: DiffSeverity::Info, + message: format!("schema added: {n}"), + path: format!("schemas.{n}"), + }); + } + } + // Schemas present in both may have property-level changes. + for n in old_s.intersection(&new_s) { + if let Some(d) = diff_schema( + n, + old.schemas.get(n).unwrap(), + new.schemas.get(n).unwrap(), + findings, + ) { + schema_diffs.push(d); + } + } +} -pub fn format_markdown(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail], old_version: &str, new_version: &str) -> String { let mut out = String::new(); out.push_str(&format!("# API Changes: {old_version} → {new_version}\n\n")); let brk: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).collect(); let inf: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).collect(); out.push_str("## Breaking Changes\n\n"); if brk.is_empty() { out.push_str("_None._\n\n"); } else { for f in &brk { out.push_str(&format!("- ❌ {}\n", fmt_md(f))); } out.push('\n'); } - out.push_str("## Non-Breaking Changes\n\n"); if inf.is_empty() { out.push_str("_None._\n\n"); } else { for f in &inf { out.push_str(&format!("- ✅ {}\n", fmt_md(f))); } out.push('\n'); } - if !schema_diffs.is_empty() { out.push_str("## Schema Changes\n\n"); for d in schema_diffs { out.push_str(&format!("### `{}`\n\n| Change | Property | Details |\n|--------|----------|--------|\n", d.name)); for c in &d.changes { let (icon, det) = match &c.change { PropertyChangeKind::Added { ty } => ("Added", format!("new property (`{ty}`)")), PropertyChangeKind::AddedRequired { ty } => ("Added", format!("new required property (`{ty}`)")), PropertyChangeKind::Removed { ty } => ("Removed", format!("was `{ty}`")), PropertyChangeKind::TypeChanged { old_type, new_type } => ("Changed", format!("`{old_type}` → `{new_type}`")), PropertyChangeKind::RequiredChanged { old_required, new_required } => { let o = if *old_required { "required" } else { "optional" }; let n = if *new_required { "required" } else { "optional" }; ("Changed", format!("{o} → {n}")) } }; out.push_str(&format!("| {icon} | `{}` | {det} |\n", c.property)); } out.push('\n'); } } out.push_str("## Summary\n\n"); out.push_str(&format!("- **{}** breaking change(s)\n- **{}** non-breaking change(s)\n", brk.len(), inf.len())); if !schema_diffs.is_empty() { out.push_str(&format!("- **{}** schema(s) modified\n", schema_diffs.len())); } out } +fn diff_schema( + name: &str, + old: &Model, + new: &Model, + findings: &mut Vec, +) -> Option { + let (op, np) = match (old, new) { + (Model::Object(o), Model::Object(n)) => (&o.properties, &n.properties), + _ => return None, + }; + let path = format!("schemas.{name}"); + let mut changes = Vec::new(); + + // New required properties are breaking. + for p in np { + if p.required && !op.iter().any(|x| x.name == p.name) { + findings.push(DiffFinding { + severity: DiffSeverity::Breaking, + message: format!("new required property: {}", p.name), + path: format!("{path}.{}", p.name), + }); + changes.push(PropertyChange { + property: p.name.clone(), + change: PropertyChangeKind::AddedRequired { ty: type_to_string(&p.ty) }, + }); + } + } + // New optional properties are informational. + for p in np { + if !p.required && !op.iter().any(|x| x.name == p.name) { + findings.push(DiffFinding { + severity: DiffSeverity::Info, + message: format!("new optional property: {}", p.name), + path: format!("{path}.{}", p.name), + }); + changes.push(PropertyChange { + property: p.name.clone(), + change: PropertyChangeKind::Added { ty: type_to_string(&p.ty) }, + }); + } + } + // Removed properties are breaking. + for p in op { + if !np.iter().any(|x| x.name == p.name) { + findings.push(DiffFinding { + severity: DiffSeverity::Breaking, + message: format!("property removed: {}", p.name), + path: format!("{path}.{}", p.name), + }); + changes.push(PropertyChange { + property: p.name.clone(), + change: PropertyChangeKind::Removed { ty: type_to_string(&p.ty) }, + }); + } + } + // Type or required-status changes for shared properties. + for p in op { + if let Some(np) = np.iter().find(|x| x.name == p.name) { + if type_changed(&p.ty, &np.ty) { + findings.push(DiffFinding { + severity: DiffSeverity::Breaking, + message: format!("type changed for property: {}", p.name), + path: format!("{path}.{}", p.name), + }); + changes.push(PropertyChange { + property: p.name.clone(), + change: PropertyChangeKind::TypeChanged { + old_type: type_to_string(&p.ty), + new_type: type_to_string(&np.ty), + }, + }); + } else if p.required != np.required { + findings.push(DiffFinding { + severity: if np.required { DiffSeverity::Breaking } else { DiffSeverity::Info }, + message: format!( + "required changed for property: {} ({} → {})", + p.name, + if p.required { "required" } else { "optional" }, + if np.required { "required" } else { "optional" } + ), + path: format!("{path}.{}", p.name), + }); + changes.push(PropertyChange { + property: p.name.clone(), + change: PropertyChangeKind::RequiredChanged { + old_required: p.required, + new_required: np.required, + }, + }); + } + } + } + + if changes.is_empty() { None } else { Some(SchemaDiffDetail { name: name.to_string(), changes }) } +} +fn type_changed(old: &Type, new: &Type) -> bool { + match (old, new) { + (Type::Scalar(a), Type::Scalar(b)) => a != b, + (Type::Reference { name: a, .. }, Type::Reference { name: b, .. }) => a != b, + (Type::StringEnum { variants: a, .. }, Type::StringEnum { variants: b, .. }) => a != b, + (Type::Array { item: a, .. }, Type::Array { item: b, .. }) => type_changed(a, b), + _ => std::mem::discriminant(old) != std::mem::discriminant(new), + } +} + +pub fn format_text(findings: &[DiffFinding]) -> String { + let mut out = String::new(); + for finding in findings { + match finding.severity { + DiffSeverity::Breaking => out.push_str(&format!("breaking: {finding}\n")), + DiffSeverity::Info => out.push_str(&format!("info: {finding}\n")), + } + } + let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(); + let ic = findings.len() - bc; + if !findings.is_empty() { + out.push_str(&format!("\n{bc} breaking change(s), {ic} info finding(s)\n")); + } + out +} + +pub fn format_colored(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> String { + const RED: &str = "\x1b[31m"; + const GREEN: &str = "\x1b[32m"; + const YELLOW: &str = "\x1b[33m"; + const BOLD: &str = "\x1b[1m"; + const DIM: &str = "\x1b[2m"; + const RESET: &str = "\x1b[0m"; + + let mut out = String::new(); + for f in findings { + match f.severity { + DiffSeverity::Breaking => { + out.push_str(&format!("{RED}{BOLD}breaking{RESET} {RED}{}\n{RESET}", f.message)) + } + DiffSeverity::Info => { + out.push_str(&format!("{GREEN}{BOLD}info{RESET} {GREEN}{}\n{RESET}", f.message)) + } + } + } + if !schema_diffs.is_empty() { + out.push('\n'); + for d in schema_diffs { + out.push_str(&format!("{BOLD}Schema {}:{RESET}\n", d.name)); + for c in &d.changes { + let (col, pre) = match &c.change { + PropertyChangeKind::Added { .. } | PropertyChangeKind::AddedRequired { .. } => (GREEN, "+"), + PropertyChangeKind::Removed { .. } => (RED, "-"), + _ => (YELLOW, "~"), + }; + out.push_str(&format!(" {col}{pre} {c}{RESET}\n")); + } + } + } + let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(); + let ic = findings.len() - bc; + if !findings.is_empty() { + out.push_str(&format!("\n{DIM}{bc} breaking change(s), {ic} info finding(s){RESET}\n")); + } + out +} + +pub fn format_markdown( + findings: &[DiffFinding], + schema_diffs: &[SchemaDiffDetail], + old_version: &str, + new_version: &str, +) -> String { + let mut out = String::new(); + out.push_str(&format!("# API Changes: {old_version} → {new_version}\n\n")); + + let brk: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).collect(); + let inf: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).collect(); + + // Breaking changes section. + out.push_str("## Breaking Changes\n\n"); + if brk.is_empty() { + out.push_str("_None._\n\n"); + } else { + for f in &brk { + out.push_str(&format!("- ❌ {}\n", fmt_md(f))); + } + out.push('\n'); + } + + // Non-breaking changes section. + out.push_str("## Non-Breaking Changes\n\n"); + if inf.is_empty() { + out.push_str("_None._\n\n"); + } else { + for f in &inf { + out.push_str(&format!("- ✅ {}\n", fmt_md(f))); + } + out.push('\n'); + } + + // Schema changes section (table per schema). + if !schema_diffs.is_empty() { + out.push_str("## Schema Changes\n\n"); + for d in schema_diffs { + out.push_str(&format!( + "### `{}`\n\n| Change | Property | Details |\n|--------|----------|--------|\n", + d.name + )); + for c in &d.changes { + let (icon, det) = match &c.change { + PropertyChangeKind::Added { ty } => ("Added", format!("new property (`{ty}`)")), + PropertyChangeKind::AddedRequired { ty } => { + ("Added", format!("new required property (`{ty}`)")) + } + PropertyChangeKind::Removed { ty } => ("Removed", format!("was `{ty}`")), + PropertyChangeKind::TypeChanged { old_type, new_type } => { + ("Changed", format!("`{old_type}` → `{new_type}`")) + } + PropertyChangeKind::RequiredChanged { old_required, new_required } => { + let o = if *old_required { "required" } else { "optional" }; + let n = if *new_required { "required" } else { "optional" }; + ("Changed", format!("{o} → {n}")) + } + }; + out.push_str(&format!("| {icon} | `{}` | {det} |\n", c.property)); + } + out.push('\n'); + } + } + + // Summary section. + out.push_str("## Summary\n\n"); + out.push_str(&format!( + "- **{}** breaking change(s)\n- **{}** non-breaking change(s)\n", + brk.len(), + inf.len() + )); + if !schema_diffs.is_empty() { + out.push_str(&format!("- **{}** schema(s) modified\n", schema_diffs.len())); + } + out +} fn fmt_md(f: &DiffFinding) -> String { fmt_md_msg(&f.message) } fn fmt_md_msg(msg: &str) -> String { @@ -140,7 +530,30 @@ fn fmt_md_msg(msg: &str) -> String { } } -pub fn format_json(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> Result { let breaking: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).cloned().collect(); let info: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).cloned().collect(); serde_json::to_string_pretty(&DiffJsonOutput { breaking, info, schema_diffs: schema_diffs.to_vec(), summary: DiffSummary { breaking_count: findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(), info_count: findings.iter().filter(|f| f.severity == DiffSeverity::Info).count() } }) } +pub fn format_json( + findings: &[DiffFinding], + schema_diffs: &[SchemaDiffDetail], +) -> Result { + let breaking: Vec<_> = findings + .iter() + .filter(|f| f.severity == DiffSeverity::Breaking) + .cloned() + .collect(); + let info: Vec<_> = findings + .iter() + .filter(|f| f.severity == DiffSeverity::Info) + .cloned() + .collect(); + serde_json::to_string_pretty(&DiffJsonOutput { + breaking, + info, + schema_diffs: schema_diffs.to_vec(), + summary: DiffSummary { + breaking_count: findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(), + info_count: findings.iter().filter(|f| f.severity == DiffSeverity::Info).count(), + }, + }) +} #[cfg(test)] mod tests { From 92d31d53346ce2c7ea02076f0fbae70a04e11e71 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 21:49:33 +0000 Subject: [PATCH 08/19] core: Expand golfed functions in diff/security/graph - diff.rs: format diff_detailed entry point - security.rs: expand match arms in scheme_info_from_raw - graph.rs: expand render_mermaid/render_dot node/edge loops Readability only; all tests pass. --- crates/specforge-core/src/diff.rs | 9 +++++- crates/specforge-core/src/graph.rs | 46 +++++++++++++++++++++++---- crates/specforge-core/src/security.rs | 34 +++++++++++++++++--- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs index 41dbfd8..dd2afd4 100644 --- a/crates/specforge-core/src/diff.rs +++ b/crates/specforge-core/src/diff.rs @@ -72,7 +72,14 @@ fn type_to_string(ty: &Type) -> String { } } pub fn diff(old: &Document, new: &Document) -> Vec { diff_detailed(old, new).findings } -pub fn diff_detailed(old: &Document, new: &Document) -> DiffResult { let mut findings = Vec::new(); let mut schema_diffs = Vec::new(); diff_ir_version(old, new, &mut findings); diff_operations(old, new, &mut findings); diff_schemas(old, new, &mut findings, &mut schema_diffs); DiffResult { findings, schema_diffs } } +pub fn diff_detailed(old: &Document, new: &Document) -> DiffResult { + let mut findings = Vec::new(); + let mut schema_diffs = Vec::new(); + diff_ir_version(old, new, &mut findings); + diff_operations(old, new, &mut findings); + diff_schemas(old, new, &mut findings, &mut schema_diffs); + DiffResult { findings, schema_diffs } +} /// Check for IR version mismatches between old and new documents. /// A major version difference indicates breaking IR schema changes. diff --git a/crates/specforge-core/src/graph.rs b/crates/specforge-core/src/graph.rs index befa49a..f7e7d91 100644 --- a/crates/specforge-core/src/graph.rs +++ b/crates/specforge-core/src/graph.rs @@ -62,15 +62,49 @@ fn dlbl(l: &str) -> String { l.replace('\\', "\\\\").replace('"', "\\\"").replac fn render_mermaid(g: &Graph) -> String { let mut o = String::from("graph TD\n"); - for n in &g.nodes { let id = mid(&n.id); match n.shape { NodeShape::Default => writeln!(o, " {id}[\"{}\"]", mlbl(&n.label)).unwrap(), NodeShape::Diamond => writeln!(o, " {id}{{\"{}\"}}", mlbl(&n.label)).unwrap() } } - for e in &g.edges { let f = mid(&e.from); let t = mid(&e.to); match &e.label { Some(l) => writeln!(o, " {f} -->|{l}| {t}").unwrap(), None => writeln!(o, " {f} --> {t}").unwrap() } } + for n in &g.nodes { + let id = mid(&n.id); + match n.shape { + NodeShape::Default => writeln!(o, " {id}[\"{}\"]", mlbl(&n.label)).unwrap(), + NodeShape::Diamond => writeln!(o, " {id}{{\"{}\"}}", mlbl(&n.label)).unwrap(), + } + } + for e in &g.edges { + let f = mid(&e.from); + let t = mid(&e.to); + match &e.label { + Some(l) => writeln!(o, " {f} -->|{l}| {t}").unwrap(), + None => writeln!(o, " {f} --> {t}").unwrap(), + } + } o } fn render_dot(g: &Graph, title: &str) -> String { - let mut o = format!("digraph \"{title}\" {{\n rankdir=TB;\n node [shape=box, style=filled, fillcolor=\"#f0f0f0\"];\n edge [color=\"#555555\"];\n\n"); - for n in &g.nodes { let id = did(&n.id); let l = dlbl(&n.label); match n.shape { NodeShape::Default => writeln!(o, " {id} [label=\"{l}\"];").unwrap(), NodeShape::Diamond => writeln!(o, " {id} [label=\"{l}\", shape=diamond, fillcolor=\"#e8e0f0\"];").unwrap() } } + let mut o = format!( + "digraph \"{title}\" {{\n rankdir=TB;\n node [shape=box, style=filled, fillcolor=\"#f0f0f0\"];\n edge [color=\"#555555\"];\n\n" + ); + for n in &g.nodes { + let id = did(&n.id); + let l = dlbl(&n.label); + match n.shape { + NodeShape::Default => writeln!(o, " {id} [label=\"{l}\"];").unwrap(), + NodeShape::Diamond => writeln!( + o, + " {id} [label=\"{l}\", shape=diamond, fillcolor=\"#e8e0f0\"];" + ) + .unwrap(), + } + } o.push('\n'); - for e in &g.edges { let f = did(&e.from); let t = did(&e.to); match &e.label { Some(l) => writeln!(o, " {f} -> {t} [label=\"{l}\"];").unwrap(), None => writeln!(o, " {f} -> {t};").unwrap() } } - o.push_str("}\n"); o + for e in &g.edges { + let f = did(&e.from); + let t = did(&e.to); + match &e.label { + Some(l) => writeln!(o, " {f} -> {t} [label=\"{l}\"];").unwrap(), + None => writeln!(o, " {f} -> {t};").unwrap(), + } + } + o.push_str("}\n"); + o } diff --git a/crates/specforge-core/src/security.rs b/crates/specforge-core/src/security.rs index dc2afdf..2a13269 100644 --- a/crates/specforge-core/src/security.rs +++ b/crates/specforge-core/src/security.rs @@ -55,10 +55,36 @@ fn scheme_info_from_ir(s: &SecurityScheme) -> SecuritySchemeInfo { SecurityScheme::ApiKey { header } => SecuritySchemeInfo { name: format!("ApiKey({})", header), kind: "apikey".into(), header: Some(header.clone()), bearer_format: None } } } fn scheme_info_from_raw(name: &str, s: &OApiSecurityScheme) -> SecuritySchemeInfo { - match s { OApiSecurityScheme::HTTP { scheme, bearer_format, .. } => SecuritySchemeInfo { name: name.into(), kind: scheme.to_lowercase(), header: if scheme.eq_ignore_ascii_case("bearer") { Some("Authorization".into()) } else { None }, bearer_format: bearer_format.clone() }, - OApiSecurityScheme::APIKey { name: kn, .. } => SecuritySchemeInfo { name: name.into(), kind: "apikey".into(), header: Some(kn.clone()), bearer_format: None }, - OApiSecurityScheme::OAuth2 { .. } => SecuritySchemeInfo { name: name.into(), kind: "oauth2".into(), header: None, bearer_format: None }, - OApiSecurityScheme::OpenIDConnect { .. } => SecuritySchemeInfo { name: name.into(), kind: "openidconnect".into(), header: None, bearer_format: None } } + match s { + OApiSecurityScheme::HTTP { scheme, bearer_format, .. } => SecuritySchemeInfo { + name: name.into(), + kind: scheme.to_lowercase(), + header: if scheme.eq_ignore_ascii_case("bearer") { + Some("Authorization".into()) + } else { + None + }, + bearer_format: bearer_format.clone(), + }, + OApiSecurityScheme::APIKey { name: kn, .. } => SecuritySchemeInfo { + name: name.into(), + kind: "apikey".into(), + header: Some(kn.clone()), + bearer_format: None, + }, + OApiSecurityScheme::OAuth2 { .. } => SecuritySchemeInfo { + name: name.into(), + kind: "oauth2".into(), + header: None, + bearer_format: None, + }, + OApiSecurityScheme::OpenIDConnect { .. } => SecuritySchemeInfo { + name: name.into(), + kind: "openidconnect".into(), + header: None, + bearer_format: None, + }, + } } fn scheme_label(s: &SecurityScheme) -> String { match s { SecurityScheme::HttpBearer => "bearer".into(), SecurityScheme::ApiKey { header } => format!("apikey ({})", header) } } fn extract_scheme_names(security: &Option>) -> Vec { let Some(reqs) = security else { return Vec::new() }; reqs.iter().flat_map(|r| r.keys().cloned()).collect() } From 0dcec79b8faf54c101a1a760e22ca032599f03be Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 21:51:58 +0000 Subject: [PATCH 09/19] core/changelog: Gate test-only fn with cfg(test); expose example plugin - changelog.rs: build_operation_entries is only used by tests, so gate it with #[cfg(test)] (removes dead-code warning from the lib build) - plugin-example: make ReadmePlugin pub so the example is a usable, importable demonstration rather than an unused struct --- crates/specforge-core/src/changelog.rs | 1 + examples/plugin-example/src/lib.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/specforge-core/src/changelog.rs b/crates/specforge-core/src/changelog.rs index e880686..a36b0e8 100644 --- a/crates/specforge-core/src/changelog.rs +++ b/crates/specforge-core/src/changelog.rs @@ -237,6 +237,7 @@ fn classify_finding(finding: &diff::DiffFinding) -> ChangeImpact { } /// Build per-operation changelog entries from the diff between old and new documents. +#[cfg(test)] fn build_operation_entries( old_doc: &Document, new_doc: &Document, diff --git a/examples/plugin-example/src/lib.rs b/examples/plugin-example/src/lib.rs index bb385cd..a67a6c4 100644 --- a/examples/plugin-example/src/lib.rs +++ b/examples/plugin-example/src/lib.rs @@ -1,7 +1,8 @@ use specforge_plugin::{GeneratedFile, Plugin, PluginResult}; use serde_json::Value; -struct ReadmePlugin; +/// Example plugin that emits a project README summarizing the generated SDK. +pub struct ReadmePlugin; impl Default for ReadmePlugin { fn default() -> Self { From e714459b3fe45f3398a9e3f1ddcfc3a4152fd2fe Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 21:53:55 +0000 Subject: [PATCH 10/19] core: Document panics on built_in/generate_demo_spec/migration_guide Add # Panics sections to the 4 public functions flagged by missing_panics_doc (marketplace built_in x2, demo spec, deprecation migration guide). All document 'should never happen' panics on compile-time-baked data or resolve()-invariant assumptions. --- crates/specforge-core/src/demo.rs | 6 ++++++ crates/specforge-core/src/deprecation.rs | 6 ++++++ crates/specforge-core/src/marketplace.rs | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/crates/specforge-core/src/demo.rs b/crates/specforge-core/src/demo.rs index 5b82d53..490eae2 100644 --- a/crates/specforge-core/src/demo.rs +++ b/crates/specforge-core/src/demo.rs @@ -6,6 +6,12 @@ //! point for new APIs. /// Generate the demo Petstore spec as a YAML string. +/// +/// # Panics +/// +/// Panics if the internally-built spec fails to serialize to YAML. Since the +/// spec is constructed from known-valid `serde_json::Value`s, this should never +/// happen; a panic here indicates an internal bug. pub fn generate_demo_spec() -> String { // We build the spec as a serde_json::Value for reliable escaping, // then serialize to YAML. diff --git a/crates/specforge-core/src/deprecation.rs b/crates/specforge-core/src/deprecation.rs index 25e90de..155e7e9 100644 --- a/crates/specforge-core/src/deprecation.rs +++ b/crates/specforge-core/src/deprecation.rs @@ -217,6 +217,12 @@ fn extract_alternative(text: &str) -> Option { /// /// Produces a Markdown document listing deprecated operations, removed /// operations, new required parameters, and schema changes. +/// +/// # Panics +/// +/// Panics if a schema name present in both documents' schema sets is missing +/// from the schema map of either document. This cannot happen for documents +/// produced by [`resolve`](crate::resolve), which keep both in sync. pub fn generate_migration_guide( old_doc: &Document, new_doc: &Document, diff --git a/crates/specforge-core/src/marketplace.rs b/crates/specforge-core/src/marketplace.rs index dd79731..7ffd21f 100644 --- a/crates/specforge-core/src/marketplace.rs +++ b/crates/specforge-core/src/marketplace.rs @@ -41,6 +41,11 @@ impl MarketplaceIndex { } /// Return the built-in curated marketplace index bundled with this binary. + /// + /// # Panics + /// + /// Panics if the bundled `assets/marketplace-index.json` is malformed. This + /// is a compile-time-baked asset, so a panic here indicates a corrupt build. pub fn built_in() -> Self { let json = include_str!("../../../assets/marketplace-index.json"); serde_json::from_str(json).expect("bundled marketplace-index.json is valid JSON") @@ -176,6 +181,11 @@ impl PluginIndex { } /// Return the built-in curated plugin index bundled with this binary. + /// + /// # Panics + /// + /// Panics if the bundled `assets/plugin-index.json` is malformed. This + /// is a compile-time-baked asset, so a panic here indicates a corrupt build. pub fn built_in() -> Self { let json = include_str!("../../../assets/plugin-index.json"); serde_json::from_str(json).expect("bundled plugin-index.json is valid JSON") From f40737c54950c22e8e2efe608d8135717f295b85 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 21:55:03 +0000 Subject: [PATCH 11/19] core: Expand golfed Display impls + security analyzers - diff.rs: format DiffSeverity/SchemaDiffDetail Display impls - security.rs: expand analyze_common_issues/analyze_detailed_issues (multiple statements-per-line -> readable blocks), and use is_none() instead of !is_some() for OAuth2 flow checks --- crates/specforge-core/src/diff.rs | 19 +++- crates/specforge-core/src/security.rs | 121 ++++++++++++++++++++++---- 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs index dd2afd4..a14f49f 100644 --- a/crates/specforge-core/src/diff.rs +++ b/crates/specforge-core/src/diff.rs @@ -5,7 +5,14 @@ use serde::Serialize; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum DiffSeverity { Breaking, Info } -impl std::fmt::Display for DiffSeverity { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DiffSeverity::Breaking => write!(f, "breaking"), DiffSeverity::Info => write!(f, "info") } } } +impl std::fmt::Display for DiffSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DiffSeverity::Breaking => write!(f, "breaking"), + DiffSeverity::Info => write!(f, "info"), + } + } +} #[derive(Debug, Clone, Serialize)] pub struct DiffFinding { pub severity: DiffSeverity, pub message: String, pub path: String } @@ -40,7 +47,15 @@ impl std::fmt::Display for PropertyChange { pub enum PropertyChangeKind { Added { ty: String }, AddedRequired { ty: String }, Removed { ty: String }, TypeChanged { old_type: String, new_type: String }, RequiredChanged { old_required: bool, new_required: bool } } #[derive(Debug, Clone, Serialize)] pub struct SchemaDiffDetail { pub name: String, pub changes: Vec } -impl std::fmt::Display for SchemaDiffDetail { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!(f, "Schema {}:", self.name)?; for c in &self.changes { writeln!(f, " {c}")?; } Ok(()) } } +impl std::fmt::Display for SchemaDiffDetail { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Schema {}:", self.name)?; + for c in &self.changes { + writeln!(f, " {c}")?; + } + Ok(()) + } +} #[derive(Debug, Clone, Serialize)] pub struct DiffResult { pub findings: Vec, pub schema_diffs: Vec } #[derive(Debug, Clone, Serialize)] diff --git a/crates/specforge-core/src/security.rs b/crates/specforge-core/src/security.rs index 2a13269..96cc58e 100644 --- a/crates/specforge-core/src/security.rs +++ b/crates/specforge-core/src/security.rs @@ -100,24 +100,111 @@ fn build_op_security_map(spec: &OpenAPI) -> BTreeMap) { - if schemes.is_empty() { issues.push(SecurityIssue { severity: "warning".into(), message: "No security schemes defined in the specification".into(), path: "/".into() }); } - for op in operations { if matches!(op.method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") && !op.requires_auth { - issues.push(SecurityIssue { severity: "warning".into(), message: format!("Write operation {} {} does not require authentication", op.method, op.path), path: op.path.clone() }); } } - let auth: Vec> = operations.iter().filter(|o| o.requires_auth).map(|o| o.scheme.as_deref()).collect(); - if auth.iter().collect::>().len() > 1 { issues.push(SecurityIssue { severity: "info".into(), message: "Operations use mixed authentication schemes".into(), path: "/".into() }); } +fn analyze_common_issues( + schemes: &[SecuritySchemeInfo], + operations: &[OperationSecurity], + issues: &mut Vec, +) { + if schemes.is_empty() { + issues.push(SecurityIssue { + severity: "warning".into(), + message: "No security schemes defined in the specification".into(), + path: "/".into(), + }); + } + for op in operations { + if matches!(op.method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") && !op.requires_auth { + issues.push(SecurityIssue { + severity: "warning".into(), + message: format!( + "Write operation {} {} does not require authentication", + op.method, op.path + ), + path: op.path.clone(), + }); + } + } + let auth: Vec> = operations + .iter() + .filter(|o| o.requires_auth) + .map(|o| o.scheme.as_deref()) + .collect(); + if auth.iter().collect::>().len() > 1 { + issues.push(SecurityIssue { + severity: "info".into(), + message: "Operations use mixed authentication schemes".into(), + path: "/".into(), + }); + } } -fn analyze_detailed_issues(spec: &OpenAPI, operations: &[OperationSecurity], global_names: &[String], issues: &mut Vec) { - for op in operations { if op.has_override && !op.requires_auth { - issues.push(SecurityIssue { severity: "info".into(), message: format!("Operation {} {} explicitly disables authentication", op.method, op.path), path: op.path.clone() }); } } - let used: std::collections::HashSet<&str> = operations.iter().filter(|o| o.requires_auth).filter_map(|o| o.scheme.as_deref()).collect(); - if let Some(components) = &spec.components { for (name, scheme_or) in &components.security_schemes { - if let openapiv3::ReferenceOr::Item(_s) = scheme_or { if !used.contains(name.as_str()) && !global_names.contains(name) { - issues.push(SecurityIssue { severity: "info".into(), message: format!("Security scheme '{}' is defined but not referenced by any operation", name), path: format!("/components/securitySchemes/{}", name) }); } } - if let openapiv3::ReferenceOr::Item(OApiSecurityScheme::OAuth2 { flows, .. }) = scheme_or { if !flows.implicit.is_some() && !flows.password.is_some() && !flows.client_credentials.is_some() && !flows.authorization_code.is_some() { - issues.push(SecurityIssue { severity: "warning".into(), message: format!("OAuth2 scheme '{}' has no configured flows", name), path: format!("/components/securitySchemes/{}", name) }); } } } } - for name in global_names { if let Some(components) = &spec.components { if !components.security_schemes.contains_key(name) { - issues.push(SecurityIssue { severity: "error".into(), message: format!("Global security references undefined scheme '{}'", name), path: "/security".into() }); } } } +fn analyze_detailed_issues( + spec: &OpenAPI, + operations: &[OperationSecurity], + global_names: &[String], + issues: &mut Vec, +) { + // Operations that explicitly turn off auth. + for op in operations { + if op.has_override && !op.requires_auth { + issues.push(SecurityIssue { + severity: "info".into(), + message: format!( + "Operation {} {} explicitly disables authentication", + op.method, op.path + ), + path: op.path.clone(), + }); + } + } + // Schemes referenced by at least one authenticated operation. + let used: std::collections::HashSet<&str> = operations + .iter() + .filter(|o| o.requires_auth) + .filter_map(|o| o.scheme.as_deref()) + .collect(); + if let Some(components) = &spec.components { + for (name, scheme_or) in &components.security_schemes { + // Defined-but-unreferenced schemes. + if let openapiv3::ReferenceOr::Item(_s) = scheme_or { + if !used.contains(name.as_str()) && !global_names.contains(name) { + issues.push(SecurityIssue { + severity: "info".into(), + message: format!( + "Security scheme '{}' is defined but not referenced by any operation", + name + ), + path: format!("/components/securitySchemes/{}", name), + }); + } + } + // OAuth2 schemes with no configured flows. + if let openapiv3::ReferenceOr::Item(OApiSecurityScheme::OAuth2 { flows, .. }) = scheme_or { + if flows.implicit.is_none() + && flows.password.is_none() + && flows.client_credentials.is_none() + && flows.authorization_code.is_none() + { + issues.push(SecurityIssue { + severity: "warning".into(), + message: format!("OAuth2 scheme '{}' has no configured flows", name), + path: format!("/components/securitySchemes/{}", name), + }); + } + } + } + } + // Global security referencing an undefined scheme. + for name in global_names { + if let Some(components) = &spec.components { + if !components.security_schemes.contains_key(name) { + issues.push(SecurityIssue { + severity: "error".into(), + message: format!("Global security references undefined scheme '{}'", name), + path: "/security".into(), + }); + } + } + } } #[cfg(test)] From 3bcbb8c615a8d0a0b1e3a6719e0e92d2eb658109 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 21:55:49 +0000 Subject: [PATCH 12/19] cli: Expand golfed analysis report printers Reformat the text + markdown analysis report blocks in main.rs (each was a single line with a for-loop body inline). No logic change. --- crates/specforge-cli/src/main.rs | 89 +++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/crates/specforge-cli/src/main.rs b/crates/specforge-cli/src/main.rs index dc9d2c4..3a97aa0 100644 --- a/crates/specforge-cli/src/main.rs +++ b/crates/specforge-cli/src/main.rs @@ -2095,12 +2095,42 @@ fn print_analysis_text(report: &specforge_core::AnalysisReport) { eprintln!("Operations: {}", report.total_operations); eprintln!("IR size: {:.1} KB", report.total_size_bytes as f64 / 1024.0); eprintln!(); - if !report.unused_schemas.is_empty() { eprintln!("Unused schemas ({}):", report.unused_schemas.len()); for name in &report.unused_schemas { eprintln!(" - {name}"); } eprintln!(); } - if !report.duplicate_schemas.is_empty() { eprintln!("Duplicate schemas ({} pair(s)):", report.duplicate_schemas.len()); for (a, b) in &report.duplicate_schemas { eprintln!(" - {a} <-> {b}"); } eprintln!(); } - if !report.large_schemas.is_empty() { eprintln!("Large schemas (>20 properties):"); for (name, count) in &report.large_schemas { eprintln!(" - {name}: {count} properties"); } eprintln!(); } - if !report.deep_refs.is_empty() { eprintln!("Deep reference chains:"); for (name, depth) in &report.deep_refs { eprintln!(" - {name}: depth {depth}"); } eprintln!(); } - if report.recommendations.is_empty() { eprintln!("No issues found. Spec looks healthy."); } - else { eprintln!("Recommendations ({}):", report.recommendations.len()); for (i, rec) in report.recommendations.iter().enumerate() { eprintln!(" {}. {rec}", i + 1); } } + if !report.unused_schemas.is_empty() { + eprintln!("Unused schemas ({}):", report.unused_schemas.len()); + for name in &report.unused_schemas { + eprintln!(" - {name}"); + } + eprintln!(); + } + if !report.duplicate_schemas.is_empty() { + eprintln!("Duplicate schemas ({} pair(s)):", report.duplicate_schemas.len()); + for (a, b) in &report.duplicate_schemas { + eprintln!(" - {a} <-> {b}"); + } + eprintln!(); + } + if !report.large_schemas.is_empty() { + eprintln!("Large schemas (>20 properties):"); + for (name, count) in &report.large_schemas { + eprintln!(" - {name}: {count} properties"); + } + eprintln!(); + } + if !report.deep_refs.is_empty() { + eprintln!("Deep reference chains:"); + for (name, depth) in &report.deep_refs { + eprintln!(" - {name}: depth {depth}"); + } + eprintln!(); + } + if report.recommendations.is_empty() { + eprintln!("No issues found. Spec looks healthy."); + } else { + eprintln!("Recommendations ({}):", report.recommendations.len()); + for (i, rec) in report.recommendations.iter().enumerate() { + eprintln!(" {}. {rec}", i + 1); + } + } } fn print_analysis_markdown(report: &specforge_core::AnalysisReport) { println!("# Spec Bundle Analysis"); @@ -2111,12 +2141,47 @@ fn print_analysis_markdown(report: &specforge_core::AnalysisReport) { println!("| Operations | {} |", report.total_operations); println!("| IR size | {:.1} KB |", report.total_size_bytes as f64 / 1024.0); println!(); - if !report.unused_schemas.is_empty() { println!("## Unused Schemas ({})", report.unused_schemas.len()); println!(); for name in &report.unused_schemas { println!("- `{name}`"); } println!(); } - if !report.duplicate_schemas.is_empty() { println!("## Duplicate Schemas ({} pair(s))", report.duplicate_schemas.len()); println!(); for (a, b) in &report.duplicate_schemas { println!("- `{a}` <-> `{b}`"); } println!(); } - if !report.large_schemas.is_empty() { println!("## Large Schemas (>20 properties)"); println!(); for (name, count) in &report.large_schemas { println!("- `{name}`: {count} properties"); } println!(); } - if !report.deep_refs.is_empty() { println!("## Deep Reference Chains"); println!(); for (name, depth) in &report.deep_refs { println!("- `{name}`: depth {depth}"); } println!(); } - if !report.recommendations.is_empty() { println!("## Recommendations"); println!(); for (i, rec) in report.recommendations.iter().enumerate() { println!("{}. {rec}", i + 1); } } - else { println!("No issues found. Spec looks healthy."); } + if !report.unused_schemas.is_empty() { + println!("## Unused Schemas ({})", report.unused_schemas.len()); + println!(); + for name in &report.unused_schemas { + println!("- `{name}`"); + } + println!(); + } + if !report.duplicate_schemas.is_empty() { + println!("## Duplicate Schemas ({} pair(s))", report.duplicate_schemas.len()); + println!(); + for (a, b) in &report.duplicate_schemas { + println!("- `{a}` <-> `{b}`"); + } + println!(); + } + if !report.large_schemas.is_empty() { + println!("## Large Schemas (>20 properties)"); + println!(); + for (name, count) in &report.large_schemas { + println!("- `{name}`: {count} properties"); + } + println!(); + } + if !report.deep_refs.is_empty() { + println!("## Deep Reference Chains"); + println!(); + for (name, depth) in &report.deep_refs { + println!("- `{name}`: depth {depth}"); + } + println!(); + } + if !report.recommendations.is_empty() { + println!("## Recommendations"); + println!(); + for (i, rec) in report.recommendations.iter().enumerate() { + println!("{}. {rec}", i + 1); + } + } else { + println!("No issues found. Spec looks healthy."); + } } /// Recursively upgrade OpenAPI 3.0 constructs to 3.1 equivalents. fn upgrade_30_to_31(json: &mut JsonValue) { From f5f61315a29ddb0f40849d510731931956648beb Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 21:56:45 +0000 Subject: [PATCH 13/19] core: Expand golfed IR/Security helpers - ir.rs: format SchemaRegistry impl + HttpMethod as_str/upper matches - security.rs: expand scheme_info_from_ir, scheme_label, extract_scheme_names, op_key, build_op_security_map, and the OperationSecurity construction Readability only; all tests pass. --- crates/specforge-core/src/ir.rs | 34 +++++++++++++- crates/specforge-core/src/security.rs | 67 ++++++++++++++++++++++----- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/crates/specforge-core/src/ir.rs b/crates/specforge-core/src/ir.rs index 2b44697..c6b05cb 100644 --- a/crates/specforge-core/src/ir.rs +++ b/crates/specforge-core/src/ir.rs @@ -33,11 +33,41 @@ impl Model { pub fn name(&self) -> &str { match self { Model::Object(o) => &o.na #[derive(Debug, Clone, Default, serde::Serialize)] pub struct SchemaRegistry { pub models: IndexMap } -impl SchemaRegistry { pub fn get(&self, name: &str) -> Option<&Model> { self.models.get(name) } pub fn iter(&self) -> impl Iterator { self.models.iter() } } +impl SchemaRegistry { + pub fn get(&self, name: &str) -> Option<&Model> { + self.models.get(name) + } + pub fn iter(&self) -> impl Iterator { + self.models.iter() + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)] pub enum HttpMethod { Get, Post, Put, Patch, Delete, Head, Options } -impl HttpMethod { pub fn as_str(self) -> &'static str { match self { HttpMethod::Get => "get", HttpMethod::Post => "post", HttpMethod::Put => "put", HttpMethod::Patch => "patch", HttpMethod::Delete => "delete", HttpMethod::Head => "head", HttpMethod::Options => "options" } } pub fn upper(self) -> &'static str { match self { HttpMethod::Get => "GET", HttpMethod::Post => "POST", HttpMethod::Put => "PUT", HttpMethod::Patch => "PATCH", HttpMethod::Delete => "DELETE", HttpMethod::Head => "HEAD", HttpMethod::Options => "OPTIONS" } } } +impl HttpMethod { + pub fn as_str(self) -> &'static str { + match self { + HttpMethod::Get => "get", + HttpMethod::Post => "post", + HttpMethod::Put => "put", + HttpMethod::Patch => "patch", + HttpMethod::Delete => "delete", + HttpMethod::Head => "head", + HttpMethod::Options => "options", + } + } + pub fn upper(self) -> &'static str { + match self { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + HttpMethod::Put => "PUT", + HttpMethod::Patch => "PATCH", + HttpMethod::Delete => "DELETE", + HttpMethod::Head => "HEAD", + HttpMethod::Options => "OPTIONS", + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub enum ParamLocation { Path, Query, Header } diff --git a/crates/specforge-core/src/security.rs b/crates/specforge-core/src/security.rs index 96cc58e..cc2db90 100644 --- a/crates/specforge-core/src/security.rs +++ b/crates/specforge-core/src/security.rs @@ -42,7 +42,14 @@ pub fn analyze_security_detailed(doc: &Document, spec: &OpenAPI) -> SecurityRepo Some(Some(r)) => { let n: Vec = r.iter().flat_map(|x| x.keys().cloned()).collect(); (true, n.first().cloned(), true) } _ => (has_global_auth, global_names.first().cloned(), false), }; - operations.push(OperationSecurity { operation_id: ir_op.operation_id.clone(), method: ir_op.method.as_str().to_string(), path: ir_op.path.clone(), requires_auth: req_auth, scheme: sch, has_override: ov }); + operations.push(OperationSecurity { + operation_id: ir_op.operation_id.clone(), + method: ir_op.method.as_str().to_string(), + path: ir_op.path.clone(), + requires_auth: req_auth, + scheme: sch, + has_override: ov, + }); } let mut issues = Vec::new(); analyze_common_issues(&schemes, &operations, &mut issues); @@ -51,8 +58,20 @@ pub fn analyze_security_detailed(doc: &Document, spec: &OpenAPI) -> SecurityRepo } fn scheme_info_from_ir(s: &SecurityScheme) -> SecuritySchemeInfo { - match s { SecurityScheme::HttpBearer => SecuritySchemeInfo { name: "BearerAuth".into(), kind: "bearer".into(), header: Some("Authorization".into()), bearer_format: None }, - SecurityScheme::ApiKey { header } => SecuritySchemeInfo { name: format!("ApiKey({})", header), kind: "apikey".into(), header: Some(header.clone()), bearer_format: None } } + match s { + SecurityScheme::HttpBearer => SecuritySchemeInfo { + name: "BearerAuth".into(), + kind: "bearer".into(), + header: Some("Authorization".into()), + bearer_format: None, + }, + SecurityScheme::ApiKey { header } => SecuritySchemeInfo { + name: format!("ApiKey({})", header), + kind: "apikey".into(), + header: Some(header.clone()), + bearer_format: None, + }, + } } fn scheme_info_from_raw(name: &str, s: &OApiSecurityScheme) -> SecuritySchemeInfo { match s { @@ -86,18 +105,44 @@ fn scheme_info_from_raw(name: &str, s: &OApiSecurityScheme) -> SecuritySchemeInf }, } } -fn scheme_label(s: &SecurityScheme) -> String { match s { SecurityScheme::HttpBearer => "bearer".into(), SecurityScheme::ApiKey { header } => format!("apikey ({})", header) } } -fn extract_scheme_names(security: &Option>) -> Vec { let Some(reqs) = security else { return Vec::new() }; reqs.iter().flat_map(|r| r.keys().cloned()).collect() } -fn op_key(method: &str, path: &str) -> String { format!("{} {}", method.to_uppercase(), path) } +fn scheme_label(s: &SecurityScheme) -> String { + match s { + SecurityScheme::HttpBearer => "bearer".into(), + SecurityScheme::ApiKey { header } => format!("apikey ({})", header), + } +} + +fn extract_scheme_names(security: &Option>) -> Vec { + let Some(reqs) = security else { return Vec::new() }; + reqs.iter().flat_map(|r| r.keys().cloned()).collect() +} + +fn op_key(method: &str, path: &str) -> String { + format!("{} {}", method.to_uppercase(), path) +} + fn build_op_security_map(spec: &OpenAPI) -> BTreeMap>> { let mut map = BTreeMap::new(); - { let paths = &spec.paths; + let paths = &spec.paths; for (path, item_or) in &paths.paths { - let item = match item_or { openapiv3::ReferenceOr::Item(i) => i, _ => continue }; - for (m, op_opt) in [("get", &item.get), ("post", &item.post), ("put", &item.put), ("patch", &item.patch), ("delete", &item.delete), ("head", &item.head), ("options", &item.options)] { - if let Some(op) = op_opt { map.insert(format!("{} {}", m.to_uppercase(), path), op.security.clone()); } + let item = match item_or { + openapiv3::ReferenceOr::Item(i) => i, + _ => continue, + }; + for (m, op_opt) in [ + ("get", &item.get), + ("post", &item.post), + ("put", &item.put), + ("patch", &item.patch), + ("delete", &item.delete), + ("head", &item.head), + ("options", &item.options), + ] { + if let Some(op) = op_opt { + map.insert(format!("{} {}", m.to_uppercase(), path), op.security.clone()); + } } - } } + } map } fn analyze_common_issues( From 61fb3457f584b190970b76301c11b7d5ba6770b4 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 31 Jul 2026 01:02:57 +0000 Subject: [PATCH 14/19] emitters: Escape + multiline-prefix doc comments from spec descriptions Real bug: schema/operation descriptions from real-world specs (GitHub, Stripe) contain embedded newlines, backticks, and '/*' '*/' sequences. The emitters emitted them as single '// ' or '/// ' comments, so: - continuation lines lost their comment prefix -> bare prose in source -> 'non-declaration statement' / parse errors - '*/' closed enclosing block comments -> 'unterminated block comment' - backticks -> 'looks like single quote' token errors Fix: - rust emitter: rust_doc now escapes /* */ and backticks; added rust_doc_indented for property docs (which were inline-format!ed without rust_doc at all) - go emitter: go_doc escapes */ and prefixes every line; property docs now route through go_doc; param/body/returns docs use new go_doc_label helper Result: github + stripe RUST regression tests now pass (were failing); stripe GO now passes. github GO still fails on a separate, deeper ValidationError name-collision bug (not doc-related). --- crates/specforge-go/src/lib.rs | 32 +++++++++++++++++++++++++++----- crates/specforge-rust/src/lib.rs | 20 ++++++++++++++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/specforge-go/src/lib.rs b/crates/specforge-go/src/lib.rs index 2f02f5a..bb21cbf 100644 --- a/crates/specforge-go/src/lib.rs +++ b/crates/specforge-go/src/lib.rs @@ -437,7 +437,7 @@ fn emit_object(o: &ObjectModel, doc: &Document) -> String { } // Property description comment. if let Some(desc) = &p.description { - out.push_str(&format!("// {desc}\n")); + out.push_str(&go_doc(desc, "\t")); } let field = unique_field_name(&field_name(&p.name), &mut used_fields); let ty = render_type(&p.ty); @@ -2589,14 +2589,14 @@ fn emit_method(op: &Operation) -> String { for p in &all_params { let pname = go_param_ident(&p.name); if let Some(desc) = &p.description { - body.push_str(&format!("// {pname}: {desc}\n")); + body.push_str(&go_doc_label(desc, &format!("{pname}: "))); } } } // Request body description. if let Some(rb) = &op.request_body { if let Some(desc) = &rb.description { - body.push_str(&format!("// body: {desc}\n")); + body.push_str(&go_doc_label(desc, "body: ")); } } // Return description from success response. @@ -2606,7 +2606,7 @@ fn emit_method(op: &Operation) -> String { .and_then(|r| r.description.clone()); if let Some(desc) = &success_desc { body.push('\n'); - body.push_str(&format!("// Returns {desc}.\n")); + body.push_str(&go_doc_label(desc, "Returns ")); } // Error response descriptions. let error_responses: Vec<&specforge_core::Response> = op.responses.iter() @@ -3146,11 +3146,33 @@ fn go_schema_deprecation_alternative(desc: &str) -> Option { } fn go_doc(text: &str, pad: &str) -> String { + // Go `//` line comments are safe from `/* */` block-comment tokenizing, but + // a stray `*/` can still close an enclosing block comment in some tooling, + // and we want one `//` per line so multi-paragraph descriptions don't leak + // bare prose into the source (which breaks parsing). Escape `*/` and prefix + // every line. text.lines() - .map(|l| format!("{pad}// {l}\n")) + .map(|l| { + let escaped = l.replace("*/", "*/"); + format!("{pad}// {escaped}\n") + }) .collect() } +/// Like [`go_doc`], but prefixes the first line with `label` (e.g. `name: `) +/// and `// ` on continuation lines. Used for per-field doc paragraphs. +fn go_doc_label(text: &str, label: &str) -> String { + let mut lines = text.lines(); + let mut out = String::new(); + if let Some(first) = lines.next() { + out.push_str(&format!("// {label}{}\n", first.replace("*/", "*/"))); + } + for l in lines { + out.push_str(&format!("// {}\n", l.replace("*/", "*/"))); + } + out +} + fn escape_go_string(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs index 304a876..d0d769b 100644 --- a/crates/specforge-rust/src/lib.rs +++ b/crates/specforge-rust/src/lib.rs @@ -2946,7 +2946,7 @@ fn emit_object(o: &ObjectModel, registry: &specforge_core::SchemaRegistry) -> St } // Property description doc comment. if let Some(desc) = &p.description { - out.push_str(&format!(" /// {desc}\n")); + out.push_str(&rust_doc_indented(desc, " ")); } let field = unique_field_name(&snake(&p.name), &mut used); let mut ty = render_type(&p.ty); @@ -3411,7 +3411,23 @@ fn success_body(op: &Operation) -> Option { } fn rust_doc(text: &str) -> String { - text.lines().map(|l| format!("/// {l}\n")).collect() + rust_doc_indented(text, "") +} + +/// Emit `text` as indented rustdoc (`///`) lines. Doc comments are still +/// tokenized by rustc, so sequences from arbitrary spec descriptions can break +/// the generated crate: `/*` and `*/` start/end block comments (causing +/// "unterminated block comment" errors), and a bare backtick triggers a +/// confusing "looks like a single quote" token error. Each line is prefixed +/// (with `indent`) and both dangerous sequences are escaped so any prose +/// survives verbatim into the rendered docs. +fn rust_doc_indented(text: &str, indent: &str) -> String { + text.lines() + .map(|l| { + let escaped = l.replace("/*", "/*").replace("*/", "*/").replace('`', "\\`"); + format!("{indent}/// {escaped}\n") + }) + .collect() } fn escape_rust_string(s: &str) -> String { From 59beed9c3c8fac16c2ea349972422f78d30cc4f3 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 31 Jul 2026 01:06:33 +0000 Subject: [PATCH 15/19] go emitter: Reserve built-in SDK type names to avoid model collisions Real bug: the GitHub spec defines its own 'ValidationError' model, which collided with the emitter's hardcoded ValidationError validation type, causing 'redeclared in this block' + missing-field compile errors. Fix: export_ident now suffixes any user model whose name matches a built-in SDK type (ValidationError, ServiceContainer, Client, Auth, Middleware, RateLimiter, etc.) with 'Model'. Result: github_generates_and_compiles_go now passes. Combined with the prior doc-escaping fix, ALL 11 regression tests now pass (4 were failing before this session: github+stripe x go+rust). --- crates/specforge-go/src/lib.rs | 48 +++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/specforge-go/src/lib.rs b/crates/specforge-go/src/lib.rs index bb21cbf..273a7a6 100644 --- a/crates/specforge-go/src/lib.rs +++ b/crates/specforge-go/src/lib.rs @@ -211,7 +211,53 @@ fn snake(input: &str) -> String { fn export_ident(input: &str) -> String { // Go requires exported names to start with uppercase. - pascal(input) + let name = pascal(input); + // Avoid colliding with built-in SDK types emitted into the same package + // (e.g. a spec that defines its own `ValidationError` model would otherwise + // redeclare the emitter's validation type and break compilation). + if is_sdk_builtin_type(&name) { + format!("{name}Model") + } else { + name + } +} + +/// Whether `name` matches a type the Go SDK emitter hardcodes into the +/// generated package. User models with these names get a `Model` suffix to +/// avoid redeclaration errors. +fn is_sdk_builtin_type(name: &str) -> bool { + matches!( + name, + "ValidationError" + | "ValidationErrors" + | "TokenBucket" + | "TelemetryHooks" + | "StreamMiddleware" + | "SseIterator" + | "SlidingWindow" + | "ServiceContainer" + | "ServerSentEvent" + | "Semaphore" + | "RouteSchemaMap" + | "RetryOptions" + | "ResponseTransformer" + | "ResponseInterceptor" + | "ResponseCache" + | "RequestInterceptor" + | "RequestDeduper" + | "RateLimiter" + | "OffsetPage" + | "MiddlewareResponse" + | "MiddlewareRequest" + | "Middleware" + | "MetricsCollector" + | "Metrics" + | "CursorPage" + | "Client" + | "Auth" + | "Logger" + | "WebhookHandler" + ) } fn field_name(input: &str) -> String { From c745e5214b4e6385f325adb4d4e712a62060d38b Mon Sep 17 00:00:00 2001 From: root Date: Fri, 31 Jul 2026 01:10:18 +0000 Subject: [PATCH 16/19] rust emitter: Reserve built-in SDK type names for model collisions Same bug class as the Go ValidationError collision: a spec model named Error/Client/Semaphore/etc. would redefine an SDK built-in and break compilation. Add safe_model_name() that suffixes colliding names with 'Model', applied at both enum and object model emit sites. github/stripe/petstore rust regression tests still pass; this guards the latent case (none of these specs use the colliding names). --- crates/specforge-rust/src/lib.rs | 52 ++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs index d0d769b..4828727 100644 --- a/crates/specforge-rust/src/lib.rs +++ b/crates/specforge-rust/src/lib.rs @@ -226,6 +226,54 @@ fn pascal(input: &str) -> String { out } +/// Whether `name` matches a type the Rust SDK emitter hardcodes into the +/// generated crate (client.rs, error.rs, etc.). User models with these names +/// are suffixed with `Model` to avoid redefinition errors. Only applied to +/// generated model/enum names, not to all pascal-case identifiers. +fn is_rust_sdk_builtin_type(name: &str) -> bool { + matches!( + name, + "Error" + | "Client" + | "ClientBuilder" + | "ServiceContainer" + | "Semaphore" + | "RetryOptions" + | "ResponseCache" + | "RequestDeduper" + | "ResponseTransformer" + | "RequestInterceptor" + | "ResponseInterceptor" + | "RateLimiter" + | "TokenBucket" + | "SlidingWindow" + | "MetricsCollector" + | "Metrics" + | "TelemetryHooks" + | "Middleware" + | "MiddlewareRequest" + | "MiddlewareResponse" + | "StreamMiddleware" + | "ServerSentEvent" + | "SseStream" + | "Auth" + | "CursorPage" + | "OffsetPage" + | "ConsoleLogger" + | "NoopLogger" + ) +} + +/// Wrap a generated model/enum name so it can't collide with a built-in SDK +/// type. Returns `name` unchanged when safe, or `name + "Model"` when it would. +fn safe_model_name(name: &str) -> String { + if is_rust_sdk_builtin_type(name) { + format!("{name}Model") + } else { + name.to_string() + } +} + fn snake(input: &str) -> String { // Expand punctuation first so "+1"/"-1" don't both collapse to "1". let mut expanded = String::new(); @@ -2764,7 +2812,7 @@ use serde::{Deserialize, Serialize}; } fn emit_enum(e: &EnumModel) -> String { - let name = pascal(&e.name); + let name = safe_model_name(&pascal(&e.name)); let mut out = String::new(); if let Some(d) = &e.description { out.push_str(&rust_doc(d)); @@ -2822,7 +2870,7 @@ fn emit_enum(e: &EnumModel) -> String { } fn emit_object(o: &ObjectModel, registry: &specforge_core::SchemaRegistry) -> String { - let name = pascal(&o.name); + let name = safe_model_name(&pascal(&o.name)); let mut out = String::new(); let is_deprecated = o.description.as_deref().is_some_and(|d| { d.to_lowercase().contains("deprecated") From 0cab98d456318d40e8f61cfed19a0a084ecdd127 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 31 Jul 2026 01:10:57 +0000 Subject: [PATCH 17/19] examples: Regenerate petstore rust/go/ts SDKs with emitter fixes Reflects the doc-comment escaping + reserved-name fixes in the checked-in example output. All three compile/typecheck clean (cargo check, go build, tsc --noEmit). --- examples/petstore-go/sdk/specforge-version.json | 2 +- examples/petstore-rust/sdk/specforge-version.json | 2 +- examples/petstore-rust/sdk/src/models.rs | 2 +- examples/petstore-ts/sdk/specforge-version.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/petstore-go/sdk/specforge-version.json b/examples/petstore-go/sdk/specforge-version.json index 1b2d9ec..41fbf7b 100644 --- a/examples/petstore-go/sdk/specforge-version.json +++ b/examples/petstore-go/sdk/specforge-version.json @@ -1 +1 @@ -{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-30T06:02:33Z"} \ No newline at end of file +{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-31T01:10:31Z"} \ No newline at end of file diff --git a/examples/petstore-rust/sdk/specforge-version.json b/examples/petstore-rust/sdk/specforge-version.json index 6e5a49d..41fbf7b 100644 --- a/examples/petstore-rust/sdk/specforge-version.json +++ b/examples/petstore-rust/sdk/specforge-version.json @@ -1 +1 @@ -{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-30T06:04:16Z"} \ No newline at end of file +{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-31T01:10:31Z"} \ No newline at end of file diff --git a/examples/petstore-rust/sdk/src/models.rs b/examples/petstore-rust/sdk/src/models.rs index b896a1f..788e3bb 100644 --- a/examples/petstore-rust/sdk/src/models.rs +++ b/examples/petstore-rust/sdk/src/models.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Error { +pub struct ErrorModel { pub code: i32, pub message: String, } diff --git a/examples/petstore-ts/sdk/specforge-version.json b/examples/petstore-ts/sdk/specforge-version.json index 1b2d9ec..41fbf7b 100644 --- a/examples/petstore-ts/sdk/specforge-version.json +++ b/examples/petstore-ts/sdk/specforge-version.json @@ -1 +1 @@ -{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-30T06:02:33Z"} \ No newline at end of file +{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-31T01:10:31Z"} \ No newline at end of file From 80930a34f9ef3390a0edd1603f466d9b020b3ac0 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 31 Jul 2026 01:12:59 +0000 Subject: [PATCH 18/19] docs: CHANGELOG entry for doc-escaping + reserved-name fixes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8440d6..39d28c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +#### Codegen (all emitters) +- **Doc-comment escaping** — schema/operation descriptions from real-world specs (GitHub, Stripe) contain embedded newlines, backticks, and `/*`/`*/` sequences that broke generated code: continuation lines lost their comment prefix (→ parse errors), `*/` closed block comments (→ "unterminated block comment"), and backticks tripped token errors. The Rust (`rust_doc`/`rust_doc_indented`) and Go (`go_doc`/`go_doc_label`) emitters now escape these and prefix every line. **Result: the 4 `*_generates_and_compiles_{rust,go}` regression tests for GitHub + Stripe specs now pass** (they were failing). +- **Reserved-name collisions** — a spec model whose name matches a built-in SDK type (e.g. GitHub's `ValidationError`) would redeclare it and break compilation. The Go and Rust emitters now suffix colliding model names with `Model`. + #### Rust emitter - **Generated SDK now compiles** — fixed six template bugs that silently broke every generated Rust SDK since the interceptors/validation_middleware/ServiceContainer modules were added in 1.6.0: a response-interceptor loop nested inside the response-transformer loop; a missing `logger` field on `ClientBuilder`; telemetry call-site signature mismatches (`&Method`/`Duration` vs `&str`/`u128`); a two-arg `Result` used where the crate alias is one-arg; a `do_once` parameter-order mismatch; and `serde_json::to_vec` moving instead of borrowing. The example SDK was stale and never regenerated to catch this. - **Generated README expanded** — `emit_readme` now documents interceptors, runtime validation (`validation_middleware`), and the `ServiceContainer` telemetry/DI helpers. From 061ca521e1603d5923a750b24396311c8a3fa899 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 31 Jul 2026 02:04:37 +0000 Subject: [PATCH 19/19] =?UTF-8?q?docs:=20Update=20README=20=E2=80=94=20fix?= =?UTF-8?q?=20stale=20version/test=20counts,=20commands,=20WASM=20target,?= =?UTF-8?q?=20TS=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accuracy pass: - version badge 1.6.0 -> 1.6.1 - unit test count 227 -> 259 - CLI: remove phantom 'security'/'graph' subcommands (don't exist), add real 'profile'/'plugin', drop the now-wrong '22 commands' count, point readers to 'specforge help' as source of truth - WASM build target wasm32-wasi -> wasm32-wasip1 (wasi was renamed in recent Rust; the old target no longer installs) - TypeScript layout tree: add the 7 modules the emitter actually generates (cache, ratelimit, telemetry, logging, interceptors, validate, validation-middleware, service_container) - VS Code extension command count 23 -> 24 - compile-gates: note the per-emitter regenerate-and-compile tests + regression.rs covering real GitHub/Stripe specs --- README.md | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b09440f..216fda9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Quick start Languages Tests - Version + Version License

@@ -85,7 +85,7 @@ Each generated SDK is a standalone project — no shared runtime dependency, no **5. Full IDE integration** -The [VS Code extension](vscode-extension/) provides 23 commands, auto-validation on save, context menus, keyboard shortcuts, and a status bar — everything you need to work with OpenAPI specs without leaving your editor. +The [VS Code extension](vscode-extension/) provides 24 commands, auto-validation on save, context menus, keyboard shortcuts, and a status bar — everything you need to work with OpenAPI specs without leaving your editor. **6. Extensible via WASM plugins** @@ -183,9 +183,9 @@ Every generated SDK is a **complete, production-ready client** — not just type **WASM plugins** - `specforge-plugin` crate with `Plugin` trait and `export_plugin!` macro - Receives full IR as JSON, returns generated files -- Compile to `wasm32-wasi` for any language emitter +- Compile to `wasm32-wasip1` for any language emitter -### CLI subcommands (22 commands) +### CLI subcommands | Command | What it does | |---|---| @@ -202,9 +202,8 @@ Every generated SDK is a **complete, production-ready client** — not just type | `specforge versions` | List API versions in a spec directory | | `specforge workspace` | Generate SDKs for all specs in a workspace config | | `specforge workspace-init` | Generate a workspace config from a directory | -| `specforge dashboard` | Generate HTML metrics dashboard from SDK telemetry | -| `specforge security` | Analyze auth requirements and detect issues | -| `specforge graph` | Generate schema dependency diagrams (Mermaid/DOT) | +| `specforge profile` | Profile the performance of API endpoints from an OpenAPI spec | +| `specforge plugin` | Manage WASM emitter plugins | | `specforge analyze` | Detect unused schemas, duplicates, optimization opportunities | | `specforge mock` | Start a local mock server from spec examples | | `specforge export` | Export Swagger Editor-compatible spec (inline `$ref`) | @@ -219,11 +218,13 @@ Every generated SDK is a **complete, production-ready client** — not just type | `specforge market add` | Add a spec to the marketplace | | `specforge changelog` | Auto-generate CHANGELOG from spec changes | +> Run `specforge help` to see the full, current list — the CLI is the source of truth. + ### Quality gates -- **227 unit tests** across core, emitters, and CLI +- **259 unit tests** across core, emitters, and CLI - **21 test fixtures** (0.02MB to 14MB): petstore, GitHub, Stripe, Kubernetes, Atlassian, OpenAI, Vercel, Linode, Bitbucket, Adyen, Notion, Spotify, Adobe AEM, CircleCI, Okta, and more -- **Compile gates**: generated Go must `go build`, generated Rust must `cargo check` +- **Compile gates**: every emitter has a test that regenerates the SDK into a temp crate and compiles it — Go (`go build`), Rust (`cargo check`), TypeScript (`tsc --noEmit`). On top of that, `regression.rs` generates + compiles SDKs from real-world specs (GitHub ~9MB, Stripe ~7.6MB) in all three languages. - **E2E smoke**: mock server × list/show/create + auth/retry/pagination (all 3 langs) - **E2E advanced**: concurrency serialisation, dedupe single-flight, middleware rewrite, idempotency-key on POST, SSE parse (all 3 langs) - **Performance benchmarks**: criterion benchmarks + 13 perf tests (petstore < 100ms, GitHub/Stripe < 10s) @@ -632,7 +633,7 @@ sdk-ts/ ├── README.md └── src/ ├── index.ts # createClient() + re-exports - ├── client.ts # fetch core + ├── client.ts # fetch core (ApiClient + retry/dedupe/telemetry) ├── auth.ts ├── retry.ts ├── paginate.ts @@ -641,6 +642,14 @@ sdk-ts/ ├── middleware.ts ├── idempotency.ts # Idempotency-Key generation ├── streaming.ts # streamBytes / streamLines / streamSse + ├── cache.ts # ETag/conditional GET response cache + ├── ratelimit.ts # token-bucket + sliding-window rate limiters + ├── telemetry.ts # TelemetryHooks interface + MetricsCollector + ├── logging.ts # pluggable Logger (ConsoleLogger) + ├── interceptors.ts # request/response body interceptors + ├── validate.ts # generated per-model validators + ├── validation-middleware.ts # schema validation as middleware + ├── service_container.ts # DI grouping (fetch/cache/logger/telemetry) ├── errors.ts # discriminated-union ApiError ├── models/.ts # one file per schema (+ oneOf guards) └── api/.ts # one class per tag @@ -713,7 +722,7 @@ plugin/ └── README.md ``` -Build: `cargo build --target wasm32-wasi --release` +Build: `cargo build --target wasm32-wasip1 --release` @@ -1046,7 +1055,7 @@ Commands: `generate`, `check`, `diff`, `emit` The [specforge VS Code extension](vscode-extension/) provides a full IDE experience for working with OpenAPI specs: -**23 commands** accessible via `Ctrl+Shift+P`: +**24 commands** accessible via `Ctrl+Shift+P`: - **Generate SDK** — Pick language, generate to output directory - **Check Spec** — Validate with `--strict` mode - **Diff Spec** — Compare two spec versions