From b39a9b9b32146e6821312a18fbb9d88794816410 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 10 Sep 2026 13:32:30 +0200 Subject: [PATCH 1/2] fix: align native renderer output and add cross-platform parity CI --- .github/workflows/renderer-parity.yml | 79 +++++ native/src/lib.rs | 3 +- native/src/render.rs | 17 +- native/src/resolver.rs | 115 +++++-- native/src/zod.rs | 318 +++++++++++++++----- package.json | 1 + scripts/renderer-parity.ts | 109 +++++++ src/native/renderer-parity-manifest.test.ts | 32 ++ src/native/renderer-parity.test.ts | 185 ++++++++++++ 9 files changed, 759 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/renderer-parity.yml create mode 100644 scripts/renderer-parity.ts create mode 100644 src/native/renderer-parity-manifest.test.ts create mode 100644 src/native/renderer-parity.test.ts diff --git a/.github/workflows/renderer-parity.yml b/.github/workflows/renderer-parity.yml new file mode 100644 index 0000000..0abebf6 --- /dev/null +++ b/.github/workflows/renderer-parity.yml @@ -0,0 +1,79 @@ +name: Renderer parity + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + generate: + name: Generate (${{ matrix.renderer }}, ${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + renderer: [js, native] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.4.1" + + - uses: dtolnay/rust-toolchain@stable + if: matrix.renderer == 'native' + + - run: bun install --frozen-lockfile + + - run: bun run build:native + if: matrix.renderer == 'native' + + - name: Check naming regressions + if: matrix.renderer == 'native' + run: bun run test:parity + env: + OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE: "1" + + - name: Generate and hash every output file + run: bun scripts/renderer-parity.ts generate "${{ matrix.renderer }}" "$RUNNER_TEMP/renderer-parity" + + - uses: actions/upload-artifact@v4 + with: + name: renderer-${{ matrix.renderer }}-${{ matrix.os }} + path: ${{ runner.temp }}/renderer-parity + if-no-files-found: error + retention-days: 7 + + compare: + name: Compare generated file hashes + needs: generate + if: always() + runs-on: ubuntu-latest + steps: + - name: Require all generation jobs to succeed + if: needs.generate.result != 'success' + run: exit 1 + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.4.1" + + - run: bun install --frozen-lockfile + + - uses: actions/download-artifact@v4 + with: + pattern: renderer-* + path: ${{ runner.temp }}/renderer-artifacts + + - name: Require identical file lists and SHA-256 hashes + run: >- + bun scripts/renderer-parity.ts compare "$RUNNER_TEMP/renderer-artifacts" + renderer-js-ubuntu-latest renderer-js-macos-latest + renderer-native-ubuntu-latest renderer-native-macos-latest diff --git a/native/src/lib.rs b/native/src/lib.rs index 35706b5..5482a72 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -206,7 +206,8 @@ pub fn compile_data(source: String, yaml: bool, options_json: String) -> Result< schema_owners.insert(name, Value::String(tag)); } let circular_schemas = compiler.circular_schema_names(); - let generated_objects = generated.objects; + let mut generated_objects = compiler.extracted_schema_objects(); + generated_objects.extend(generated.objects); let generated_dependencies = generated.dependencies; let topology_order: Vec = resolver .topology_order diff --git a/native/src/render.rs b/native/src/render.rs index 39e122e..450b9c7 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -1771,14 +1771,16 @@ fn endpoint_name(value: &str) -> String { } fn snake_to_camel(value: &str) -> String { + // JavaScript snakeToCamel replaces non-overlapping /(_\w)/g matches. let mut output = String::new(); - let mut uppercase = false; - for character in value.chars() { - if character == '_' || character == '-' || character == ' ' { - uppercase = true; - } else if uppercase { - output.extend(character.to_uppercase()); - uppercase = false; + let mut characters = value.chars().peekable(); + while let Some(character) = characters.next() { + if character == '_' + && characters + .peek() + .is_some_and(|next| next.is_ascii_alphanumeric() || *next == '_') + { + output.push(characters.next().unwrap().to_ascii_uppercase()); } else { output.push(character); } @@ -3255,6 +3257,7 @@ fn parameter_description(parameter: &Value) -> String { .or_else(|| parameter.get("bodyObject")) .and_then(|object| object.get("description")) .and_then(Value::as_str) + .filter(|description| !description.is_empty()) { parts.push(description.to_string()); } diff --git a/native/src/resolver.rs b/native/src/resolver.rs index 65106a0..f99b33f 100644 --- a/native/src/resolver.rs +++ b/native/src/resolver.rs @@ -480,29 +480,85 @@ fn index_operations<'a>( Ok(result) } +#[derive(Default)] +struct OperationNameIndex { + stable_counts: HashMap, + path_source_count: usize, + reserved_fallback_count: usize, +} + fn assign_unique_names(operations: &mut [IndexedOperation<'_>], options: &GenerateOptions) { - let mut counts: HashMap<(String, String), usize> = HashMap::default(); - for operation in operations.iter() { - let name = operation_name(operation, options, false, false); - *counts.entry((operation.tag.clone(), name)).or_default() += 1; - } - let mut tag_counts: HashMap<(String, String), usize> = HashMap::default(); + // Match the JavaScript index: path-derived names are evaluated in the current + // operation's context, not at each candidate's own path. + let mut indexes: HashMap = HashMap::default(); for operation in operations.iter() { - let name = operation_name(operation, options, true, false); - *tag_counts.entry((operation.tag.clone(), name)).or_default() += 1; + let tag_indexes = indexes.entry(operation.tag.clone()).or_default(); + for (index, keep_tag) in tag_indexes.iter_mut().zip([false, true]) { + if operation + .operation + .get("operationId") + .and_then(Value::as_str) + .unwrap_or_default() + .is_empty() + { + index.path_source_count += 1; + continue; + } + let context = IndexedOperation { + path: "", + method: "", + operation: operation.operation, + tag: operation.tag.clone(), + name: String::new(), + path_parameters: None, + }; + let (name, reserved_fallback) = + resolve_operation_name(&context, options, keep_tag, false); + if reserved_fallback { + index.reserved_fallback_count += 1; + } else { + *index.stable_counts.entry(name).or_default() += 1; + } + } } + let empty_operation = Map::new(); for operation in operations.iter_mut() { - let short = operation_name(operation, options, false, false); - operation.name = if counts.get(&(operation.tag.clone(), short.clone())) == Some(&1) { - short - } else { - let tagged = operation_name(operation, options, true, false); - if tag_counts.get(&(operation.tag.clone(), tagged.clone())) == Some(&1) { - tagged - } else { - operation_name(operation, options, true, true) + let mut unique = None; + for (index, keep_tag) in indexes[&operation.tag].iter().zip([false, true]) { + let name = operation_name(operation, options, keep_tag, false); + let path_context = IndexedOperation { + path: operation.path, + method: operation.method, + operation: &empty_operation, + tag: operation.tag.clone(), + name: String::new(), + path_parameters: None, + }; + let path_matches = index.path_source_count > 0 + && operation_name(&path_context, options, keep_tag, false) == name; + let reserved_matches = index.reserved_fallback_count > 0 + && format!( + "{}{}", + operation.method, + path_to_variable_name(operation.path) + ) == name; + let count = index.stable_counts.get(&name).copied().unwrap_or_default() + + if path_matches { + index.path_source_count + } else { + 0 + } + + if reserved_matches { + index.reserved_fallback_count + } else { + 0 + }; + if count == 1 { + unique = Some(name); + break; } - }; + } + operation.name = unique.unwrap_or_else(|| operation_name(operation, options, true, true)); } } @@ -512,10 +568,20 @@ fn operation_name( keep_tag: bool, keep_prefix: bool, ) -> String { + resolve_operation_name(operation, options, keep_tag, keep_prefix).0 +} + +fn resolve_operation_name( + operation: &IndexedOperation<'_>, + options: &GenerateOptions, + keep_tag: bool, + keep_prefix: bool, +) -> (String, bool) { let mut name = operation .operation .get("operationId") .and_then(Value::as_str) + .filter(|id| !id.is_empty()) .map(invalid_identifier) .unwrap_or_else(|| { format!( @@ -550,13 +616,16 @@ fn operation_name( } } if RESERVED.contains(&name.as_str()) { - format!( - "{}{}", - operation.method, - path_to_variable_name(operation.path) + ( + format!( + "{}{}", + operation.method, + path_to_variable_name(operation.path) + ), + true, ) } else { - name + (name, false) } } diff --git a/native/src/zod.rs b/native/src/zod.rs index 436d605..baf7213 100644 --- a/native/src/zod.rs +++ b/native/src/zod.rs @@ -20,6 +20,7 @@ pub struct ZodCompiler<'a> { resolver: &'a Resolver<'a>, root_enums: HashMap, extracted_enums: IndexMap, + extracted_enum_objects: Map, circular_getters: HashSet, runtime_tags: Rc>>>, } @@ -57,7 +58,8 @@ impl<'a> ZodCompiler<'a> { } } } - let extracted_enums = collect_extracted_enums(document, resolver, options, &root_enums); + let (extracted_enums, extracted_enum_objects) = + collect_extracted_enums(document, resolver, options, &root_enums); let circular_getters = collect_precompiled_circular_refs(document, resolver); Self { document, @@ -65,6 +67,7 @@ impl<'a> ZodCompiler<'a> { resolver, root_enums, extracted_enums, + extracted_enum_objects, circular_getters, runtime_tags, } @@ -148,6 +151,10 @@ impl<'a> ZodCompiler<'a> { .collect() } + pub fn extracted_schema_objects(&self) -> Map { + self.extracted_enum_objects.clone() + } + pub fn extracted_schema_tags(&self) -> HashMap { self.extracted_enums .values() @@ -843,9 +850,9 @@ fn escape_pattern(value: &str) -> String { #[derive(Default)] struct ExtractedEnumCandidate { - last_segments: Vec, - component_names: Vec, + name_segments: Vec>, tags: HashSet, + schema_object: Map, } fn collect_extracted_enums( @@ -853,11 +860,12 @@ fn collect_extracted_enums( resolver: &Resolver<'_>, options: &GenerateOptions, root_enums: &HashMap, -) -> IndexMap { +) -> (IndexMap, Map) { if !options.extract_enums { - return IndexMap::default(); + return (IndexMap::default(), Map::new()); } let mut candidates: IndexMap = IndexMap::default(); + collect_operation_enum_candidates(document, resolver, &mut candidates); if let Some(schemas) = document .pointer("/components/schemas") .and_then(Value::as_object) @@ -872,15 +880,25 @@ fn collect_extracted_enums( .get(&reference) .cloned() .unwrap_or_default(); - collect_inline_enum_candidates(schema, component_name, &tags, &mut candidates); + collect_inline_enum_candidates( + schema, + &[component_name.clone()], + &tags, + &mut candidates, + ); } } // Codes backed by a canonical component enum never become separately extracted schemas. candidates.retain(|code, _| !root_enums.contains_key(code)); - let mut preliminary: IndexMap)> = IndexMap::default(); - for (code, candidate) in candidates { - let Some(common) = most_common_adjacent(&candidate.last_segments) else { + let mut preliminary: IndexMap = IndexMap::default(); + for (code, candidate) in &candidates { + let last_segments = candidate + .name_segments + .iter() + .filter_map(|segments| segments.last().cloned()) + .collect::>(); + let Some(common) = most_common_adjacent(&last_segments) else { continue; }; let name = enum_schema_name(&common, &options.enum_suffix, &options.schema_suffix); @@ -894,94 +912,256 @@ fn collect_extracted_enums( } else { options.default_tag.clone() }; - preliminary.insert(code, (name, tag, candidate.component_names)); + preliminary.insert(code.clone(), (name, tag)); + } + // Match JavaScript's progressive disambiguation using preceding name segments. + for depth in 2..=6 { + for index in 0..preliminary.len() { + let name = preliminary[index].0.clone(); + let duplicates: Vec = (0..preliminary.len()) + .filter(|&other| preliminary[other].0 == name) + .collect(); + if duplicates.len() < 2 { + continue; + } + for other in duplicates { + let (code, (name, _)) = preliminary.get_index_mut(other).unwrap(); + let preceding: Vec = candidates[code] + .name_segments + .iter() + .filter_map(|segments| { + segments + .len() + .checked_sub(depth) + .map(|i| segments[i].clone()) + }) + .collect(); + let prefix = if preceding.len() == 1 { + preceding.first().cloned() + } else { + most_common_adjacent(&preceding) + }; + if let Some(prefix) = prefix { + *name = format!("{}{name}", sanitize_enum_segment(&prefix)); + } + } + } } - let duplicate_names = preliminary - .values() - .filter_map(|(name, _, _)| { - (preliminary + let duplicates: Vec = (0..preliminary.len()) + .filter(|&index| { + preliminary .values() - .filter(|(other, _, _)| other == name) + .filter(|(name, _)| name == &preliminary[index].0) .count() - > 1) - .then_some(name.clone()) + > 1 }) - .collect::>(); - for (name, _, component_names) in preliminary.values_mut() { - if !duplicate_names.contains(name) { - continue; + .collect(); + for index in duplicates { + let (code, (name, _)) = preliminary.get_index_mut(index).unwrap(); + let full_name = candidates[code].name_segments[0] + .iter() + .map(|segment| sanitize_enum_segment(segment)) + .collect::(); + *name = enum_schema_name(&full_name, &options.enum_suffix, &options.schema_suffix); + } + let objects = preliminary + .iter() + .map(|(code, (name, _))| { + ( + name.clone(), + Value::Object(candidates[code].schema_object.clone()), + ) + }) + .collect(); + (preliminary, objects) +} + +fn sanitize_enum_segment(segment: &str) -> String { + capitalize(segment) + .replace("Dto", "") + .replace("DTO", "") + .replace("Response", "") + .replace("Request", "") +} + +fn collect_operation_enum_candidates( + document: &Value, + resolver: &Resolver<'_>, + candidates: &mut IndexMap, +) { + fn resolve<'a>(document: &'a Value, value: &'a Value) -> &'a Value { + value + .get("$ref") + .and_then(Value::as_str) + .and_then(|reference| document.pointer(reference.trim_start_matches('#'))) + .unwrap_or(value) + } + let mut name_counts = HashMap::default(); + for operation in &resolver.operations { + *name_counts.entry(operation.name.as_str()).or_insert(0usize) += 1; + } + for operation in &resolver.operations { + let unique = name_counts[operation.name.as_str()] == 1; + let operation_name = normalize_name(&if unique { + operation.name.clone() + } else { + format!("{}_{}", operation.tag, operation.name) + }); + let tags = HashSet::from_iter([operation.tag.clone()]); + let mut parameters: IndexMap = IndexMap::default(); + for source in [ + operation.path_parameters, + operation.operation.get("parameters"), + ] + .into_iter() + .flatten() + { + for parameter in source.as_array().into_iter().flatten() { + let key = parameter + .get("$ref") + .or_else(|| parameter.get("name")) + .and_then(Value::as_str) + .unwrap_or_default(); + parameters.insert(key.to_string(), parameter); + } } - if let Some(prefix) = most_common_adjacent(component_names) { - *name = format!("{}{}", sanitize_enum_name(&capitalize(&prefix)), name); + for parameter in parameters.values() { + let parameter = resolve(document, parameter); + if let Some(schema) = parameter.get("schema") { + let segments = vec![ + operation_name.clone(), + parameter + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ]; + add_enum_candidate(schema, &segments, &tags, candidates); + collect_inline_enum_candidates(schema, &segments, &tags, candidates); + } + } + if let Some(body) = operation.operation.get("requestBody") { + let body = resolve(document, body); + if let Some(content) = body.get("content").and_then(Value::as_object) { + if let Some((_, media)) = content.iter().find(|(media, _)| { + (media.contains("application/") && media.contains("json")) + || media.contains("text/") + || matches!( + media.as_str(), + "application/x-www-form-urlencoded" + | "multipart/form-data" + | "application/octet-stream" + | "*/*" + ) + }) { + if let Some(schema) = media.get("schema") { + collect_inline_enum_candidates( + schema, + &[format!("{operation_name}Body")], + &tags, + candidates, + ); + } + } + } + } + if let Some(responses) = operation + .operation + .get("responses") + .and_then(Value::as_object) + { + for (status, response) in responses { + let response = resolve(document, response); + if let Some(content) = response.get("content").and_then(Value::as_object) { + if let Some((_, media)) = content + .iter() + .find(|(_, media)| media.get("schema").is_some()) + .or_else(|| { + content + .iter() + .find(|(media, _)| media.starts_with("application/")) + }) + { + if let Some(schema) = media.get("schema") { + let name = if status == "default" + || status + .parse::() + .is_ok_and(|status| (200..300).contains(&status)) + { + format!("{operation_name}Response") + } else { + format!("{operation_name}{status}ErrorResponse") + }; + collect_inline_enum_candidates(schema, &[name], &tags, candidates); + } + } + } + } } } - preliminary - .into_iter() - .map(|(code, (name, tag, _))| (code, (name, tag))) - .collect() +} + +fn add_enum_candidate( + schema: &Value, + segments: &[String], + tags: &HashSet, + candidates: &mut IndexMap, +) { + if let Some(code) = enum_code(schema) { + let candidate = candidates.entry(code).or_default(); + candidate.name_segments.push(segments.to_vec()); + if let Some(object) = schema.as_object() { + candidate.schema_object.extend(object.clone()); + } + candidate.tags.extend(tags.iter().cloned()); + } } fn collect_inline_enum_candidates( schema: &Value, - component_name: &str, + segments: &[String], tags: &HashSet, candidates: &mut IndexMap, ) { let Some(object) = schema.as_object() else { return; }; - if object.contains_key("$ref") { + if object.contains_key("$ref") || object.contains_key("x-domain-error-domain") { return; } - if let Some(properties) = object.get("properties").and_then(Value::as_object) { - for (property_name, property) in properties { - if let Some(code) = enum_code(property) { - let candidate = candidates.entry(code).or_default(); - candidate.last_segments.push(property_name.clone()); - candidate.component_names.push(component_name.to_string()); - candidate.tags.extend(tags.iter().cloned()); - } - collect_inline_enum_candidates(property, component_name, tags, candidates); - } - } - for keyword in ["allOf", "anyOf", "oneOf"] { - if let Some(items) = object.get(keyword).and_then(Value::as_array) { - for item in items { - if let Some(code) = enum_code(item) { - let candidate = candidates.entry(code).or_default(); - candidate.last_segments.push(component_name.to_string()); - candidate.component_names.push(component_name.to_string()); - candidate.tags.extend(tags.iter().cloned()); - } - collect_inline_enum_candidates(item, component_name, tags, candidates); - } + if let Some(items) = object + .get("allOf") + .or_else(|| object.get("anyOf")) + .or_else(|| object.get("oneOf")) + .and_then(Value::as_array) + { + for item in items { + add_enum_candidate(item, segments, tags, candidates); + collect_inline_enum_candidates(item, segments, tags, candidates); } } - if object.get("type").and_then(Value::as_str) == Some("array") { - if let Some(item) = object.get("items") { - if let Some(code) = enum_code(item) { - let candidate = candidates.entry(code).or_default(); - candidate.last_segments.push(component_name.to_string()); - candidate.component_names.push(component_name.to_string()); - candidate.tags.extend(tags.iter().cloned()); - } - collect_inline_enum_candidates(item, component_name, tags, candidates); + if let Some(properties) = object.get("properties").and_then(Value::as_object) { + for (property_name, property) in properties { + let mut property_segments = segments.to_vec(); + property_segments.push(property_name.clone()); + add_enum_candidate(property, &property_segments, tags, candidates); + // The JS schema iterator carries the original context into nested schemas. + collect_inline_enum_candidates(property, segments, tags, candidates); } } if let Some(additional) = object .get("additionalProperties") .filter(|value| value.is_object()) { - collect_inline_enum_candidates(additional, component_name, tags, candidates); + add_enum_candidate(additional, segments, tags, candidates); + collect_inline_enum_candidates(additional, segments, tags, candidates); + } + if object.get("type").and_then(Value::as_str) == Some("array") { + if let Some(item) = object.get("items") { + add_enum_candidate(item, segments, tags, candidates); + collect_inline_enum_candidates(item, segments, tags, candidates); + } } -} - -fn sanitize_enum_name(name: &str) -> String { - ["Dto", "DTO", "Response", "Request"] - .into_iter() - .fold(name.to_string(), |value, fragment| { - value.replace(fragment, "") - }) } fn enum_schema_name(name: &str, enum_suffix: &str, schema_suffix: &str) -> String { diff --git a/package.json b/package.json index 8269d3d..bf33469 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "test": "bun run build:native && bun run test:ts && bun run test:native", "test:ts": "OPENAPI_CODEGEN_NATIVE=0 vitest run", "test:native": "OPENAPI_CODEGEN_NATIVE=1 OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE=1 vitest run", + "test:parity": "vitest run src/native/renderer-parity.test.ts src/native/renderer-parity-manifest.test.ts", "test:watch": "vitest", "build": "tsdown && bun run build:native", "build:ts": "tsdown", diff --git a/scripts/renderer-parity.ts b/scripts/renderer-parity.ts new file mode 100644 index 0000000..d930165 --- /dev/null +++ b/scripts/renderer-parity.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { parse } from "yaml"; +import type { OpenAPIV3 } from "openapi-types"; + +import { resolveConfig } from "../src/generators/core/resolveConfig"; +import { generateCodeFromOpenAPIDoc } from "../src/generators/generateCodeFromOpenAPIDoc"; +import { generateFilesFromNativeOpenAPI } from "../src/native/generateFilesFromNativeOpenAPI"; + +export type Manifest = Record; + +function validateManifest(manifest: unknown): asserts manifest is Manifest { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest) || Object.keys(manifest).length === 0) { + throw new Error("Renderer manifests must contain generated files"); + } + if ( + Object.entries(manifest).some(([file, hash]) => !file || typeof hash !== "string" || !/^[a-f0-9]{64}$/.test(hash)) + ) { + throw new Error("Invalid file name or SHA-256 hash in renderer manifest"); + } +} + +export function compareManifests(expected: unknown, actual: unknown): string[] { + validateManifest(expected); + validateManifest(actual); + return [...new Set([...Object.keys(expected), ...Object.keys(actual)])] + .sort() + .filter((file) => expected[file] !== actual[file]); +} + +async function generate(renderer: string, output: string) { + if (renderer !== "js" && renderer !== "native") throw new Error(`Unknown renderer: ${renderer}`); + // Explicit selection also protects against accidentally adding automatic fallback here. + process.env.OPENAPI_CODEGEN_NATIVE = renderer === "js" ? "0" : "1"; + process.env.OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE = "1"; + const source = await readFile("test/petstore.yaml", "utf8"); + const document = parse(source) as OpenAPIV3.Document; + // Refuse stale output: every uploaded file must belong to this generation. + await mkdir(path.dirname(output), { recursive: true }); + await mkdir(output); + const manifest: Manifest = {}; + for (const tsNamespaces of [true, false]) { + const scenario = tsNamespaces ? "namespaces" : "modules"; + const options = resolveConfig({ + fileConfig: { + input: "test/petstore.yaml", + output: "generated", + tsNamespaces, + modelsInCommon: tsNamespaces, + acl: false, + restClientImportPath: "@test/app-rest-client", + }, + params: {}, + }); + const files = + renderer === "js" + ? generateCodeFromOpenAPIDoc(document, options) + : generateFilesFromNativeOpenAPI(source, true, options); + if (!files?.length) throw new Error(`${renderer} did not generate files for ${scenario}`); + for (const file of files) { + const relative = path.relative(options.output, file.fileName); + if (relative.startsWith("..") || path.isAbsolute(relative)) + throw new Error(`Unexpected output: ${file.fileName}`); + const name = `${scenario}/${relative.split(path.sep).join("/")}`; + if (name in manifest) throw new Error(`Duplicate generated file: ${name}`); + const destination = path.join(output, "files", name); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, file.content); + // Hash exact bytes on disk; do not normalize whitespace or generated code. + manifest[name] = createHash("sha256") + .update(await readFile(destination)) + .digest("hex"); + } + } + const sorted = Object.fromEntries(Object.entries(manifest).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); + await writeFile(path.join(output, "manifest.json"), `${JSON.stringify(sorted, null, 2)}\n`); + console.log(`${renderer}: hashed ${Object.keys(manifest).length} generated files`); +} + +async function compare(directory: string, artifacts: string[]) { + if (artifacts.length < 2) throw new Error("Specify a baseline and at least one comparison artifact"); + const manifests = await Promise.all( + artifacts.map( + async (artifact) => + JSON.parse(await readFile(path.join(directory, artifact, "manifest.json"), "utf8")) as Manifest, + ), + ); + let failed = false; + for (let index = 1; index < artifacts.length; index++) { + const differences = compareManifests(manifests[0], manifests[index]); + if (differences.length) { + failed = true; + console.error(`${artifacts[index]} differs from ${artifacts[0]}:\n${differences.join("\n")}`); + } + } + if (failed) throw new Error("Generated file lists or SHA-256 hashes differ"); + console.log(`All ${artifacts.length} renderers/platforms match (${Object.keys(manifests[0]).length} files each)`); +} + +if (import.meta.main) { + const [command, ...args] = process.argv.slice(2); + if (command === "generate" && args.length === 2) await generate(args[0], args[1]); + else if (command === "compare" && args.length >= 3) await compare(args[0], args.slice(1)); + else + throw new Error( + "Usage: renderer-parity.ts generate | compare [...]", + ); +} diff --git a/src/native/renderer-parity-manifest.test.ts b/src/native/renderer-parity-manifest.test.ts new file mode 100644 index 0000000..910486f --- /dev/null +++ b/src/native/renderer-parity-manifest.test.ts @@ -0,0 +1,32 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "vitest"; + +import { compareManifests } from "../../scripts/renderer-parity"; + +const hash = (content: string) => createHash("sha256").update(content).digest("hex"); + +describe("renderer file hash comparison", () => { + test("compares file contents regardless of manifest entry order", () => { + const first = { "pet.ts": hash("pet"), "store.ts": hash("store") }; + const second = { "store.ts": hash("store"), "pet.ts": hash("pet") }; + expect(compareManifests(first, second)).toEqual([]); + }); + + test("detects even whitespace differences in file contents", () => { + expect(compareManifests({ "pet.ts": hash("pet\n") }, { "pet.ts": hash("pet\r\n") })).toEqual(["pet.ts"]); + }); + + test("detects missing and additional files even when their contents match", () => { + const same = hash("same"); + expect(compareManifests({ "pet.ts": same, "old.ts": same }, { "pet.ts": same, "new.ts": same })).toEqual([ + "new.ts", + "old.ts", + ]); + }); + + test.each([{}, null, [], { "pet.ts": "not a hash" }])("rejects an empty or invalid manifest: %j", (invalid) => { + const valid = { "pet.ts": hash("pet") }; + expect(() => compareManifests(valid, invalid)).toThrow(); + expect(() => compareManifests(invalid, valid)).toThrow(); + }); +}); diff --git a/src/native/renderer-parity.test.ts b/src/native/renderer-parity.test.ts new file mode 100644 index 0000000..b42c68f --- /dev/null +++ b/src/native/renderer-parity.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "vitest"; +import { OpenAPIV3 } from "openapi-types"; +import fs from "node:fs/promises"; +import { parse } from "yaml"; + +import { generateCodeFromOpenAPIDoc } from "@/generators/generateCodeFromOpenAPIDoc"; +import { resolveConfig } from "@/generators/core/resolveConfig"; +import { generateFilesFromNativeOpenAPI } from "./generateFilesFromNativeOpenAPI"; + +describe("renderer naming parity", () => { + test("keeps controller names when multiple operations reduce to a reserved word", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.3", + info: { title: "Controller names", version: "1" }, + paths: Object.fromEntries( + ["PetSystemAdmin", "PetCustomAdmin"].map((controller) => [ + `/api/admin/${controller.toLowerCase()}/{id}`, + { + delete: { + operationId: `${controller}Controller_delete`, + tags: ["PetAdmin"], + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { "204": { description: "Deleted" } }, + }, + }, + ]), + ), + }; + const options = resolveConfig({ + fileConfig: { + input: "fixture", + output: "output", + modelsInCommon: true, + restClientImportPath: "@test/app-rest-client", + acl: false, + mutationEffects: false, + }, + params: {}, + }); + const expected = generateCodeFromOpenAPIDoc(document, options); + const actual = generateFilesFromNativeOpenAPI(JSON.stringify(document), false, options); + const query = expected.find(({ fileName }) => fileName.endsWith("queries.ts"))!; + expect(query.content).toContain("export const usePetSystemAdminDelete ="); + expect(actual?.find(({ fileName }) => fileName === query.fileName)).toEqual(query); + }); + + test("does not merge enum metadata from excluded operations", () => { + const document: OpenAPIV3.Document = { + openapi: "3.0.3", + info: { title: "Excluded enum metadata", version: "1" }, + paths: { + "/skip": { + get: { + operationId: "skip", + tags: ["Records"], + deprecated: true, + parameters: [{ name: "status", in: "query", schema: { type: "string", enum: ["a", "b"], default: "a" } }], + responses: { "204": { description: "Skipped" } }, + }, + }, + "/keep": { + get: { + operationId: "keep", + tags: ["Records"], + responses: { + "200": { + description: "Kept", + content: { "application/json": { schema: { $ref: "#/components/schemas/Thing" } } }, + }, + }, + }, + }, + }, + components: { + schemas: { Thing: { type: "object", properties: { status: { type: "string", enum: ["a", "b"] } } } }, + }, + }; + const options = resolveConfig({ + fileConfig: { + input: "fixture", + output: "output", + modelsInCommon: true, + acl: false, + restClientImportPath: "@test/app-rest-client", + }, + params: {}, + }); + const expected = generateCodeFromOpenAPIDoc(document, options); + const actual = generateFilesFromNativeOpenAPI(JSON.stringify(document), false, options); + const common = expected.find(({ fileName }) => fileName.endsWith("common.models.ts"))!; + expect(common.content).not.toContain("Default:"); + expect(actual?.find(({ fileName }) => fileName === common.fileName)).toEqual(common); + }); + + test.each([true, false])("matches the repository Petstore schema with namespaces=%s", async (tsNamespaces) => { + const source = await fs.readFile("test/petstore.yaml", "utf8"); + const document = parse(source) as OpenAPIV3.Document; + const options = resolveConfig({ + fileConfig: { + input: "fixture", + output: "output", + tsNamespaces, + modelsInCommon: tsNamespaces, + acl: false, + restClientImportPath: "@test/app-rest-client", + }, + params: {}, + }); + const expected = generateCodeFromOpenAPIDoc(document, options); + const actual = generateFilesFromNativeOpenAPI(source, true, options); + expect(actual).toBeDefined(); + const sorted = (files: typeof expected) => [...files].sort((a, b) => a.fileName.localeCompare(b.fileName)); + expect(expected.length).toBeGreaterThan(0); + expect(sorted(actual!)).toEqual(sorted(expected)); + const models = actual! + .filter(({ fileName }) => fileName.endsWith("models.ts")) + .map(({ content }) => content) + .join("\n"); + expect(models).toContain('z.enum(["available", "pending", "sold"])'); + expect(models).toContain('export const OrderStatusEnumSchema = z.enum(["placed", "approved", "delivered"])'); + }); + + test.each([ + ["read_items", "ReadItems"], + ["read__items", "Read_items"], + ["read___items", "Read_Items"], + ["read_items_", "ReadItems_"], + ["_read_items", "ReadItems"], + ["read_2_items", "Read2Items"], + ["read-items", "ReadItems"], + ])("matches JavaScript exports for %s", (operationId, hookName) => { + const document: OpenAPIV3.Document = { + openapi: "3.0.3", + info: { title: "Naming parity", version: "1" }, + paths: { + "/records": { + get: { + operationId, + tags: ["Records"], + parameters: [{ name: "page", in: "query", schema: { type: "integer" } }], + responses: { "200": { description: "OK" } }, + }, + post: { + operationId: `create${operationId[0].toUpperCase()}${operationId.slice(1)}`, + tags: ["Records"], + responses: { "204": { description: "Created" } }, + }, + }, + }, + }; + for (const tsNamespaces of [true, false]) { + const options = resolveConfig({ + fileConfig: { + input: "fixture", + output: "output", + tsNamespaces, + modelsInCommon: tsNamespaces, + removeOperationPrefixEndingWith: "", + infiniteQueries: true, + acl: false, + restClientImportPath: "@test/app-rest-client", + mutationEffects: false, + }, + params: {}, + }); + // No nativeSource: this always runs JavaScript extraction and rendering. + const expected = generateCodeFromOpenAPIDoc(document, options); + // Call the complete native renderer directly: missing bindings or fallback must fail. + const actual = generateFilesFromNativeOpenAPI(JSON.stringify(document), false, options); + expect(actual).toBeDefined(); + for (const suffix of ["api.ts", "queries.ts"]) { + const expectedFile = expected.find(({ fileName }) => fileName.endsWith(suffix)); + expect(expectedFile).toBeDefined(); + const actualFile = actual!.find(({ fileName }) => fileName === expectedFile!.fileName); + expect(actualFile, `${operationId}, namespaces=${tsNamespaces}, ${suffix}`).toEqual(expectedFile); + if (suffix === "queries.ts") { + expect(actualFile!.content).toContain(`export const use${hookName} =`); + expect(actualFile!.content).toContain(`export const use${hookName}Infinite =`); + expect(actualFile!.content).toContain(`export const prefetch${hookName} =`); + expect(actualFile!.content).toContain(`export const useCreate${hookName} =`); + } + } + } + }); +}); From ed8de030920060888abbadbe055b2d94a535a806 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 10 Sep 2026 13:50:58 +0200 Subject: [PATCH 2/2] test: match main's compiled enum schema output --- src/native/renderer-parity.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/native/renderer-parity.test.ts b/src/native/renderer-parity.test.ts index b42c68f..94723e0 100644 --- a/src/native/renderer-parity.test.ts +++ b/src/native/renderer-parity.test.ts @@ -117,7 +117,9 @@ describe("renderer naming parity", () => { .map(({ content }) => content) .join("\n"); expect(models).toContain('z.enum(["available", "pending", "sold"])'); - expect(models).toContain('export const OrderStatusEnumSchema = z.enum(["placed", "approved", "delivered"])'); + expect(models).toContain( + 'export const OrderStatusEnumSchema = z.compile(z.enum(["placed", "approved", "delivered"]))', + ); }); test.each([