Skip to content

Refactored the generator into explicit abstractions and typed the JavaScript surface - #7

Open
Andrzej-Swietek wants to merge 11 commits into
AVSystem:mainfrom
Andrzej-Swietek:refactor/pipeline-abstractions
Open

Refactored the generator into explicit abstractions and typed the JavaScript surface#7
Andrzej-Swietek wants to merge 11 commits into
AVSystem:mainfrom
Andrzej-Swietek:refactor/pipeline-abstractions

Conversation

@Andrzej-Swietek

@Andrzej-Swietek Andrzej-Swietek commented Sep 8, 2026

Copy link
Copy Markdown

Summary

The generator was correct but hard to change safely: there were no
abstractions to change through. No traits; diagnostics threaded as &mut
through 22 signatures, which made collect::<Result<..>> unavailable
anywhere; schema depth counted by convention at each recursive call site;
four files past 400 lines of code.

Two checks were also wrong. A repeated mapping key under paths reported as
a duplicate schema name. The fixture list regen-snapshots read had
drifted from test/fixtures/, leaving nine fixtures never snapshotted, and
the reader kept a second copy of that list which already disagreed.

Nothing type-checked the 2064 lines of published JavaScript.

Verified

Output is byte-identical.

  • 131 pre-existing snapshots unchanged; the snapshot diff is additions only.
  • CLI output identical across 21 invocations — help, generate, eight error
    paths, exit codes — diffed against the base commit.
  • templates/, browser.d.ts and native.js untouched. index.d.ts
    changes only in doc comments and in the const-enum rewrites below; its
    156 declarations are unchanged.
  • 305 Rust tests, 345 ava (was 331), clippy --all-targets clean.
  • Longest function in src/ is 50 lines, from 118; 28 functions over 45
    lines, now 5.
  • Each of the ten commits builds and tests standalone.

Release builds of this branch and of main, same machine, the same
harness against each binding through NAPI_RS_NATIVE_LIBRARY_PATH, three
alternating rounds of 40 iterations, median of the per-round medians. Both
bindings were checked to emit identical artifacts first.

bench main this branch
petstore-rich, json 340 µs 311 µs 1.09x
petstore-rich, yaml 750 µs 470 µs 1.60x
bench-large, yaml 6.44 ms 3.31 ms 1.95x
bench-multi-tag, yaml 10.56 ms 4.75 ms 2.23x
bench-stress, yaml 50.91 ms 28.47 ms 1.79x

JSON is the control and barely moves: the win is the YAML double parse,
which JSON never had. None of these fixtures declare an anchor, so the
re-serialisation the expansion guard needs is skipped outright.

The main side is the noisy one — bench-multi-tag measured 9.2 / 10.8 /
10.6 ms across rounds against 4.8 / 4.6 / 4.7 for this branch, so read that
row's 2.23x with less confidence than bench-large, where both sides are
stable.

Behaviour changes

  1. Duplicate-key diagnostic keeps its code and subcode; the message gains the
    field path and source position, and each position reports as itself.
    Nothing pinned the old wording.
  2. InputFormat and ResponseType stop being const enum, which a consumer
    under isolatedModules could not import. patch-types already did this
    for EmitTarget. Strictly more permissive.
  3. build:debug now runs postbuild. It did not, so a debug build left
    index.d.ts unpatched and failed package.spec.ts.

Reviewing

Three commits move code between files, so git blame over src/ is
disrupted; --follow or -M reads it. The nine new snapshots are committed
data nobody has read — each fixture carries a one-line pins note saying
what a failing diff means.

@pkurcx pkurcx self-assigned this Sep 8, 2026

@pkurcx pkurcx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, this is a careful refactor and the structure is a clear improvement. I built both this head and its base commit (1f1944e) and ran every fixture in test/fixtures through both CLIs: generated files, stdout, stderr and exit codes are identical for all 73, except the disclosed duplicate-schema-name wording. That covers the ten newly pinned fixtures too, which the new snapshots alone cannot prove. Also confirmed: fmt/clippy/cargo test clean (305), typecheck/lint/coverage/ava clean (345), and all ten commits compile standalone.

Two things need fixing before merge, and one rebase hazard:

  1. Runtime enums are declared but not exported. index.d.ts now promises InputFormat and ResponseType as runtime constants; lib/index.js and lib/browser.js export only generate, GenerateError, EmitTarget. InputFormat.Yaml (shown in the node-api docs) type-checks and throws at runtime. Under the old const enum it was inlined and worked. Inline comment below.
  2. Inline-object depth is charged twice per level in schema/mod.rs, roughly halving the nesting cap. Inline comment below.
  3. Rebase onto main. main has moved (v0.6.0, #8): 20 files conflict. #8 also added a Layout const-enum rewrite to scripts/patch-types.mjs, which this PR deletes, so that rewrite has to be ported into patch-types.ts or it silently disappears. The ten new snapshot dirs need regenerating under the new model.ts / rest/<tag>.rest.ts names.

Smaller behaviour changes worth listing in the description, since they are not covered by the byte-identical claim:

  • JSON inputs with a repeated key were last-wins on the base and now fail with E_INPUT_INVALID (no subcode, so a duplicate schema name in JSON never gets duplicate-schema-name).
  • A repeated key under paths changes code from E_POLICY_VIOLATION to E_INPUT_INVALID, not only its message.
  • default_group now returns Default for tags: [""] where the base fell through to the path segment.
  • Re-export lines (export type { A as X, ... } from) now wrap at 100 columns.

Non-blocking: a few why-comments dropped in e60917d are worth restoring (the Params-suffix collision rationale in plan/naming/fixed.rs, the verbatim-brace-instead-of-panic note in emit/angular/request.rs, the alias-collision note in emit/model/emit_ts_models.rs). The Emitter trait has one impl per target and no second implementation planned, so a plain function would carry less indirection; fine to keep if you prefer the shape.

Comment thread index.d.ts
Yaml = 'yaml'
}
export type InputFormat = 'json' | 'yaml';
export declare const InputFormat: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This declares a runtime value, but lib/index.js and lib/browser.js do not export InputFormat (or ResponseType). Verified on this head:

node -e "console.log(require('./lib/index.js').InputFormat)"  # undefined

A consumer writing inputFormat: InputFormat.Yaml, as website/src/content/docs/reference/node-api.md shows, compiles under strict + isolatedModules and throws at runtime. The old const enum was inlined to 'yaml', so this is a regression rather than the strictly-more-permissive change the description states.

Fix: export frozen mirrors the way EmitTarget already is, re-export them from browser.d.ts, and widen the allow-list in __test__/package.spec.ts (public surface is a fixed allow-list), which currently pins the export set and would have caught this if the enums had been added there.

)?)))
}
Some("object") => Ok(SchemaType::InlineObject {
properties: normalize_properties(schema, walk.item())?,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Depth is now charged twice per inline-object level: walk.item() here, then walk.property(name) inside normalize_properties before normalize_type runs check_depth. The base charged one unit (normalize_properties(.., depth + 1, ..) and then normalize_schema_raw(.., depth, ..) per property), so the effective cap for nested inline objects roughly halves. Arrays, compositions and maps are unaffected. The existing depth test uses arrays only, so it does not catch this. Passing walk instead of walk.item() restores the old accounting and matches walk.rs's contract that each constructor descends exactly one level.

Comment thread src/parse/input.rs
/// measured by re-serialising the node tree. A source this cannot parse
/// passes, leaving the typed parse to report the error.
fn check_anchor_expansion(source: &str, display_path: &Rc<str>) -> Result<(), Diagnostic> {
let Ok(value) = serde_yml::from_str::<serde_yml::Value>(source) else {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Returning Ok when the Value parse fails opens a gap: serde_yml 0.0.12's Value rejects duplicate mapping keys, while the typed parse accepts them last-wins inside the serde_json::Value-typed fields (example, enum). A document with one repeated key inside example plus a fanned-out anchor skips this guard and decodes; the base rejected it (mislabelled, but fatal). serde_yml's own repetition limit still stops large bombs, so the impact is bounded, but the guard is no longer total. Options: fail on a duplicate-key Value error, or fall back to a cheap alias-count check when the Value parse fails.

Comment thread src/parse/input.rs
/// under `components.schemas` carries the `duplicate-schema-name` subcode so
/// consumers can route on it; anything else is a plain decode failure.
fn decode_failure(message: &str, display_path: &Rc<str>) -> Diagnostic {
if message.contains(DUPLICATE_KEY) && message.contains(SCHEMAS_FIELD_PATH) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

contains("components.schemas") also matches nested paths, so components.schemas.Pet.properties: duplicate key 'id' and a repeated discriminator.mapping key are still reported as duplicate-schema-name with "Each schema name must be declared once." Same behaviour as the base, but the description presents the misrouting as fixed. Matching the prefix components.schemas: would make it exact.

Related: for JSON input the same defect surfaces as plain E_INPUT_INVALID with no subcode (serde_json carries no field path), so the subcode is format-dependent. Worth a line in the description at least.

Comment thread Cargo.toml
# duplicate-mapping-key rejection, which the duplicate-schema-name and
# mapping-expansion-exceeded diagnostics both read out of serde_yml errors.
# Pinned: 0.0.13 swapped its YAML backend and dropped the line/column suffix
# from decode errors, which every `E_INPUT_INVALID` message forwards verbatim.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Checked 0.0.13: it drops the line/column suffix and also the field.path: prefix on decode errors. decode_failure above depends on that prefix to route duplicate-schema-name, so the pin has two reasons now; worth naming both here.

.tags()
.first()
.map(String::as_str)
.or_else(|| ctx.lookup_indexed("pathSegments", 0))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

or_else only fires when tags() is empty, so tags: [""] yields Default here where the base fell through to the path segment (Pets for /pets). Tags are copied unfiltered from the spec. Low frequency, but a behaviour change with no test; filtering the tag before or_else restores the old result.

Comment thread __test__/generate.spec.ts
const first = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] });
const second = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] });
const third = await generate({ inputPath: fixture(name), emit: [...DEFAULT_EMIT] });
const [first, second, third] = await Promise.all(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Running the three generations concurrently weakens what this test checks: state leaking from run 1 into run 2 is only observable when they run in sequence, and a separate concurrency test already exists further down. Suggest keeping these sequential.

Comment thread lib/browser.js
const binding = await load();
const [prepared, binding] = await Promise.all([
prepareOptions(options, unreachableFetch),
load(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

prepareOptions does no I/O in the browser (URL inputs are rejected earlier), so Promise.all gains nothing here, and it changes behaviour slightly: invalid options now trigger the wasm download before failing, and if both reject the first to settle wins. Sequential was simpler and had the stricter ordering. Non-blocking.

Comment thread scripts/patch-types.ts
[
EMIT_TARGET_UNION,
'export declare const EmitTarget: {',
" readonly Models: 'models';",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Heads-up for the rebase: main (#8) added a Layout const-enum rewrite to scripts/patch-types.mjs, which this branch deletes. Git will resolve that as a modify/delete conflict and the rewrite is gone unless it is ported here as a fourth pattern.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants