feat(client-generator)!: self-contained generator folders, module runtime, and eject as source - #3047
Draft
RomanHotsiy wants to merge 36 commits into
Draft
feat(client-generator)!: self-contained generator folders, module runtime, and eject as source#3047RomanHotsiy wants to merge 36 commits into
RomanHotsiy wants to merge 36 commits into
Conversation
…alyze the helper surface Three ADRs for the self-contained generator rewrite, plus the measured inventory they rest on. ADR-0020 makes every generator a self-contained folder ejected as source, replacing the esbuild bundle that inlines already-public toolkit code. ADR-0021 records the text-printer architecture the code already uses and supersedes ADR-0001, which still documents the removed ts.factory codegen. ADR-0022 drops runtime: package for a sibling runtime module, and amends ADR-0017 point 3. helper-surface.md catalogues twelve duplications with file:line evidence. Three are defects rather than untidiness: two TypeScript string escapers with different escaping policies, no escaper at all in python and go, and two pagination resolvers that can disagree about whether an operation paginates.
🦋 Changeset detectedLatest commit: 8d994d2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
RomanHotsiy
marked this pull request as draft
August 21, 2026 06:43
Contributor
…actored too python, go, and php are already self-contained, so ADR-0020 could be read as leaving them alone — the split was mentioned only as a migration cost. Make it a decision: self-containment was never the goal on its own, and leaving a 953-line python and a 1169-line go whole would keep the asymmetry the ADR removes. Both docs now show the re-grouping is not a rewrite: the existing functions in all three generators sort into the same stages as they are.
Each reproduced before it was fixed, each with a regression test. An enum value of `-1` produced `VALUE_-1 = -1` — a SyntaxError that made the whole generated Python module unusable. `enumValues` now routes every value through `casing` (which already knew `MINUS_1`) and `uniqueIdentifiers`, so decimals, values that fold to one name, and the empty string all stay usable. Go and PHP built their own member names with the same folding flaw (`1.5` and `15` both became `15`) and dedupe per enum now, with plain integers keeping their old spelling. PHP fataled on two operationIds that camel-case alike (`get-user`, `getUser` → `Cannot redeclare Client::getUser()`). It now derives one deduped name per operation, like Python and Go already did. The sample hooks in all three languages used the raw name, so on a collision the snippet called a method that goes to a different operation — they read the deduped map now, and `goSample` also matches its assignment to the return shape (`err :=` for void, `stream :=` for SSE, where `result, err :=` does not compile). A Go array query param went through `fmt.Sprint`, putting `?tags=[a b]` on the wire as one value. An array now repeats the key per element — proven with a real `httptest` server: `tags=a&tags=b`. `register()` copied seven fields and dropped `docs` and `notApplicable`, so an ejected generator quietly did less than the built-in it replaced: `--docs` wrote no page, ignored options stopped warning. Two dropped-`dateType` call sites: PHP's top-level hydration returned a raw string where its own signature declared `\DateTimeImmutable`, and Python's iterator signatures said `str` where the method beside them said `datetime`. The defaulted parameter was the trap, so PHP's `hydration` now requires it.
…o to the toolkit Which response is the JSON success, whether an operation streams, and whether its body is multipart were answered by identical private functions in python, go, and php — and shipped three more times in the eject assets. They are one answer each now: `jsonSuccessSchema`, `sseResponse`, and `isMultipartBody` in a new `authoring/operation.ts`, so two generators cannot disagree about the same operation, and a user-authored generator gets them instead of re-deriving them. `deref` goes public with them: `authoring/schema.ts` kept it private, and php had re-implemented it line for line.
…oolkit `serverUrlExpression` was copied character-for-character into python, go, and php — same regex loop, same undeclared-variable comment — differing only in how a literal is quoted and parts are joined. The parsing is `serverUrlParts(server)` in the toolkit now, returning literal/variable parts, and each language keeps the five lines that are genuinely its own syntax.
…he toolkit
The scheme-key → {scheme, kind, name, in} mapping existed four times — python, go,
php, and the TypeScript descriptor — with the same OR-alternatives/AND-sets shape
and the same drop-unknown-scheme rule. It is `securityRequirements(op, model)` in
the toolkit now; each consumer keeps only its own literal syntax.
…once The `schemaAtPointer` → is-it-an-array → take-the-raw-element block existed in python, go, and php with the same comment about why the element must stay a `ref`. It is `paginationItemSchema(pageSchema, itemsPointer, model)` in the toolkit now. The envelope-header plan stays per language on purpose: its key naming carries per-language knowledge (Go's digit-leading `N` rule, where a `_` prefix would make the field unexported and invisible to encoding/json) that the printers own in the next stage — promoting it here would have traded three small copies for one wrong abstraction.
…dupe loop, no phantom parameter The 46-word TypeScript reserved list existed twice and had to be hand-synced — `emitters/identifier.ts` now reads `RESERVED_WORDS.typescript` (verified identical before merging). Python's `operationIdents` re-implemented `uniqueIdentifiers`, which the file already imported; it is a call to it now. Go keeps its own loop on purpose: its names go through `exported`, whose digit-leading `N` rule `identifierFor` does not know. And `paginationRuleFor` loses the `_model` parameter nothing ever passed.
…021) The common `Printer` keeps structure; four new printers own each language's syntax — identifier safety, string escaping, literal rendering, comment and doc form, the indent unit, and (for Go) the layout pass `toString()` applies. They fill the same slots, which is the check that the abstraction is real: `typeName`, `memberName`, `identifier`, `identifiers`, `string`, `literal`, `comment`, `doc`. The per-language knowledge moves with them rather than being flattened: Go's digit-leading `N` rule (a `_` prefix means unexported, so encoding/json would silently skip the field) and its gofmt column alignment, Python's `memberName` reporting a rename for `_field_map`, PHP's `@tag` doc form, TypeScript's bare-or-quoted `key`. Python and Go gain a real `string()` policy — controls escaped, non-ASCII raw, a lone surrogate spelled (`\uXXXX`) in Python and replaced (U+FFFD) in Go, which cannot represent one — with call sites adopting it in the next change. python, go, and php now construct their printer and delegate naming, escaping, and doc comments to it; their generated output is byte-identical (verified on two fixtures each). The dogfooding guard gains the printer as a sharing tier — each generator may import its OWN language's printer, never another's — and the eject build rewrites the import to `@redocly/client-generator/printers/<language>`, which is a new public subpath.
…ing policy Both languages built string literals with `JSON.stringify` — 18 sites in python, 28 in go — relying on JSON escaping being close enough to each language's syntax. It is not, at the edges that matter: JSON encodes an astral character (any emoji) as a surrogate PAIR, which Go rejects outright (`\uD83C` is an invalid code point to the compiler) and Python parses as two lone surrogates instead of the character. A description or parameter name with an emoji broke the generated Go module and corrupted the Python one. Every site now goes through the printer's `string()` — controls escaped, non-ASCII written as itself (generated files are UTF-8), a lone surrogate spelled `\uXXXX` in Python and replaced with U+FFFD in Go, which has no spelling for one. `pythonLiteral` delegates to the printer's `literal`, which also fixes booleans in `Literal[...]` and enum members: JSON's lowercase `true` was never valid Python. Output for ordinary specs is byte-identical (verified on two fixtures per language); a spec with `"mood 🎉"` parameter and emoji enum values now py_compiles and `go build`s.
… policy Two escapers existed with different security policies: `codeString` escaped U+2028/U+2029, `sanitizeCodeString` also escaped `<`/`>` to stop a `</script>` breakout when generated output lands in an inline script. Which protection applied depended on which one the caller imported. There is one policy now — the stricter one, owned by `codeString` (the printers' `string()` and `sanitizeCodeString` are the same function) — so `<` and `>` are escaped in the few places that previously left them literal. The cafe fixture's output is unchanged; the injection e2e and the golden snapshots pass as they are.
…rator the same verified map
… the emitters/sse helpers
… instead of a raw path and a shared HEADER
…, transformers, cli) into their generator folders
…de, DateType) to the generator toolkit types
… publish the SDK ABI as contracts/typescript
…, jsdoc, support) into the TypeScript printer module
…ifier, resolved to src by tsconfig paths and a vitest alias
…s with per-file update merges and a Node type-stripping floor
…efine the setup and pagination contracts at package level
…peScript client and the generated CLI
…l files beside their clients
…rs dissolved, ABI contracts at package level, one import guard for all ten
…d single-file path
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What/Why/How?
Design docs only — no code changes. Records the decisions behind the planned
generator rewrite before any of it is implemented, plus the measured analysis
those decisions rest on.
Three ADRs
python/go/phpeject as their own type-stripped source, while the seven TypeScript-family generators esbuild-bundle ~24 modules — the ejectedtypescript.mjsis 178 kB opening with__defPropshims and inlining copies ofauthoring/code that is already public API. Every generator becomes one folder with the same stage skeleton, ejected as.ts— including the single-file generators, which are re-grouped into the same stages rather than grandfathered. Self-containment was never the goal on its own; leaving a 953-linepython/index.tsand a 1169-linego/index.tswhole would keep the very asymmetry this removes. Their existing functions sort into the stages as they are, so it is a re-grouping and not a rewrite. The.replaceAll("'../../authoring/index.js'", …)import rewrite is replaced by real package specifiers plus a guard test.ts.factoryAST codegen;emitters/ts.tsandemitters/package-client.tsno longer exist and every generator emits text. The boundary is syntax vs shape: the printer owns identifier safety, escaping, literals, comments and layout; the generator writes classes and signatures as template literals so the emitted code stays readable in the file the user owns.entry-weight.test.tsexists, it makes the TypeScript runtime dual-purpose, and it silently ignores an ejected runtime the user edited.runtime: 'module'serves its actual purpose — deduplication — while staying zero-dependency.The analysis (
docs/helper-surface.md) is a point-in-time inventory: 87 files, 15,913 lines, 183 exported values, measured by reachability, direct symbol use, and toolkit use.Two findings drove the decisions:
authoring/is not a neutral toolkit in practice — it is the non-TypeScript toolkit. Ten of its sixteen helpers have exactly three consumers, always go/php/python. No TypeScript-family generator usesPrinter,docText,identifierFor, or any schema-shape helper; TypeScript keeps a complete shadow implementation inemitters/.safeIdent,pascalCase,codeLiteral,codeString). The "large shared TypeScript emitter layer" is mostly thetypescriptgenerator's own body living in a shared directory.Twelve duplications are catalogued with
file:lineevidence. Three are defects, not untidiness:codeStringescapes U+2028/U+2029;sanitizeCodeStringalso escapes</>to stop a</script>breakout. Which protection applies depends on which one the caller imported.JSON.stringifycalls.Reference
Follow-up to #3016. Implementation will land in separate PRs against that branch.
Testing
Documentation only; no code changed, so no tests apply. Cross-document links and every cited
file:linewere verified against the source.Note: the pre-commit hook was bypassed.
npm run lintcurrently fails on ~200 pre-existing files across all packages because the localnode_moduleshasoxlint@1.79.0against a declared^1.48.0, so newer rules fire. Zero errors are in the files this PR touches.Screenshots (optional)
Check yourself
Security
No code changes, so no direct security impact. The analysis does document two
existing security-relevant issues for the rewrite to fix: the divergent
TypeScript string-escaping policies (
</script>breakout protection appliesonly on one path) and the absence of any defined escaping policy in the Python
and Go generators.
🤖 Generated with Claude Code
Note
Low Risk
Documentation only; no runtime or API code changes. The ADRs do commit to later breaking removals of package-mode runtime.
Overview
Adds design docs for the planned client-generator rewrite: three accepted ADRs plus a point-in-time helper inventory. No implementation.
ADR-0020 makes every generator a self-contained folder ejected as TypeScript source (no esbuild bundle or import rewrite). ADR-0021 supersedes ADR-0001: codegen is text via a structural
Printerplus per-language syntax printers. ADR-0022 amends ADR-0017 by droppingruntime: 'package'in favor of'inline' | 'module'.helper-surface.mdmeasures sharing (TS-family generators share four functions / 27 lines) and catalogs twelve duplications, including divergent string escapers and two pagination resolvers. The ADR index marks 0001 superseded and 0017 amended.Reviewed by Cursor Bugbot for commit 75f9e00. Bugbot is set up for automated code reviews on this repo. Configure here.