@workos/oagen 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/cli/index.mjs +1123 -436
  2. package/dist/cli/index.mjs.map +1 -1
  3. package/dist/compat/index.d.mts +144 -57
  4. package/dist/compat/index.d.mts.map +1 -1
  5. package/dist/compat/index.mjs +362 -41
  6. package/dist/compat/index.mjs.map +1 -1
  7. package/dist/{diff-BYadQQV7.mjs → diff-BaH9qa8k.mjs} +11 -2
  8. package/dist/{diff-BYadQQV7.mjs.map → diff-BaH9qa8k.mjs.map} +1 -1
  9. package/dist/differ-CTl98yoS.d.mts +116 -0
  10. package/dist/differ-CTl98yoS.d.mts.map +1 -0
  11. package/dist/index.d.mts +3 -1
  12. package/dist/index.d.mts.map +1 -1
  13. package/dist/index.mjs +50 -10
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/{differ-CG5ZErKl.mjs → overlay-TYxHOCow.mjs} +1112 -553
  16. package/dist/overlay-TYxHOCow.mjs.map +1 -0
  17. package/dist/scripts/smoke/baseline.mjs +49 -9
  18. package/dist/scripts/smoke/baseline.mjs.map +1 -1
  19. package/dist/smoke.d.mts +10 -0
  20. package/dist/smoke.d.mts.map +1 -1
  21. package/dist/smoke.mjs +49 -9
  22. package/dist/smoke.mjs.map +1 -1
  23. package/dist/tree-sitter-C53w_0WE.mjs.map +1 -1
  24. package/dist/{types-DJMbCtIO.d.mts → types-mTtkgy_h.d.mts} +180 -2
  25. package/dist/types-mTtkgy_h.d.mts.map +1 -0
  26. package/dist/verify/index.d.mts +4 -3
  27. package/dist/verify/index.d.mts.map +1 -1
  28. package/dist/verify/index.mjs +12 -10
  29. package/dist/verify/index.mjs.map +1 -1
  30. package/docs/architecture/emitter-contract.md +2 -2
  31. package/docs/architecture/extractor-contract.md +6 -5
  32. package/docs/architecture/pipeline.md +4 -3
  33. package/docs/architecture/workflows.md +5 -3
  34. package/docs/cli.md +148 -6
  35. package/docs/contributor/public-api.md +1 -1
  36. package/docs/core/compatibility-ir.md +200 -0
  37. package/docs/core/compatibility-policy.md +170 -0
  38. package/docs/core/compatibility.md +83 -0
  39. package/docs/core/index.md +5 -0
  40. package/docs/core/manifest-schema.md +76 -0
  41. package/docs/core/quickstart.md +9 -2
  42. package/docs/core/verify-compat.md +235 -0
  43. package/package.json +1 -1
  44. package/skills/generate-emitter/SKILL.md +12 -7
  45. package/skills/generate-emitter/references/project-scaffold.md +27 -6
  46. package/skills/generate-extractor/SKILL.md +14 -8
  47. package/skills/generate-sdk/SKILL.md +2 -2
  48. package/skills/generate-smoke-test/SKILL.md +9 -5
  49. package/skills/review-operations/SKILL.md +2 -2
  50. package/skills/verify-compat/SKILL.md +1 -1
  51. package/dist/differ-CG5ZErKl.mjs.map +0 -1
  52. package/dist/types-DJMbCtIO.d.mts.map +0 -1
@@ -154,9 +154,9 @@ Production emitters live in the separate `oagen-emitters` project and import typ
154
154
  1. Create design doc at `docs/sdk-architecture/{language}.md` in the emitter project
155
155
  2. Scaffold files under `src/{language}/` in the emitter project
156
156
  3. Implement all `Emitter` methods
157
- 4. Register in the emitter project's `oagen.config.ts`
157
+ 4. Export through the emitter project's plugin bundle (e.g. `src/plugin.ts`)
158
158
  5. Add tests under `test/{language}/` in the emitter project
159
159
 
160
160
  Or use: `/generate-emitter <language>`
161
161
 
162
- Emitters are registered via `oagen.config.ts` — no need to modify CLI source. See the Configuration section in the README.
162
+ Emitters are registered via the plugin bundle exported from the emitter package. The consumer project's `oagen.config.ts` imports the bundle and layers spec interpretation policy on top. See the [CLI Reference](../cli.md#configuration-oagenconfigts) for details.
@@ -98,12 +98,13 @@ const goHints = resolveHints({
98
98
  Extractors are registered via `oagen.config.ts` in the consumer project. The CLI loads the config at startup and registers all extractors automatically before any command runs.
99
99
 
100
100
  ```typescript
101
- // oagen.config.ts
102
- import { myExtractor } from "./src/compat/extractors/my-language.js";
101
+ // oagen.config.ts (consumer project)
102
+ import { workosEmittersPlugin } from "@workos/oagen-emitters";
103
103
  import type { OagenConfig } from "@workos/oagen";
104
104
 
105
105
  const config: OagenConfig = {
106
- extractors: [myExtractor],
106
+ ...workosEmittersPlugin, // includes all extractors
107
+ // spec interpretation policy...
107
108
  };
108
109
  export default config;
109
110
  ```
@@ -212,8 +213,8 @@ All tree-sitter parsers use `safeParse()` from `src/utils/tree-sitter.ts` to han
212
213
  3. Provide `hints` as a `LanguageHints` object. Every extractor must have hints — the differ and overlay delegate all language-specific logic through them.
213
214
  4. Split into `{language}-parser.ts` and `{language}-surface.ts` if either: (a) the language separates type definitions from method implementations (structural joining needed), or (b) the classification logic for mapping parsed types to `ApiClass`/`ApiInterface`/`ApiEnum` is non-trivial. If the AST co-locates symbols and classification is straightforward, a single `{language}-parser.ts` returning `ApiClass[]` directly is sufficient.
214
215
  5. Use `safeParse()` from `src/utils/tree-sitter.ts` instead of calling `parser.parse()` directly.
215
- 6. Export from `src/index.ts`
216
- 7. Register in the consumer project's `oagen.config.ts`
216
+ 6. Export from `src/index.ts` and include in the plugin bundle
217
+ 7. The consumer project's `oagen.config.ts` picks it up through the plugin bundle
217
218
  8. Add tests in `test/compat/extractors/{language}.test.ts`
218
219
  9. Create a fixture SDK in `test/fixtures/sample-sdk-{language}/` with known classes, methods, and exports
219
220
  10. Verify deterministic output: extracting twice produces identical JSON
@@ -96,11 +96,12 @@ Every generator method receives:
96
96
  Simple map from language name → `Emitter` instance. Emitters are registered at startup via `oagen.config.ts`:
97
97
 
98
98
  ```typescript
99
- // In oagen.config.ts (emitter project)
100
- import { nodeEmitter, rubyEmitter } from "./src/index.js";
99
+ // In oagen.config.ts (consumer project)
100
+ import { workosEmittersPlugin } from "@workos/oagen-emitters";
101
101
 
102
102
  export default {
103
- emitters: [nodeEmitter, rubyEmitter],
103
+ ...workosEmittersPlugin,
104
+ // spec interpretation policy...
104
105
  };
105
106
  ```
106
107
 
@@ -12,7 +12,7 @@ gets written or fixed.
12
12
 
13
13
  ### Skill sequence
14
14
 
15
- **Project setup:** Run `oagen init --lang {language}` to create the emitter project, then implement the emitter methods.
15
+ **Project setup:** Run `oagen init --lang {language}` to scaffold an emitter project, then implement the emitter methods. Export the emitter through a plugin bundle so the consumer project can register it via its `oagen.config.ts`.
16
16
 
17
17
  **Scenario A** (existing SDK to preserve):
18
18
 
@@ -106,8 +106,10 @@ to preserve backwards compatibility:
106
106
  3. **Verify:** `oagen verify --spec v2.yml --lang {lang} --output ./sdk --api-surface sdk-{lang}-surface.json`
107
107
  4. **Ship** if verify exits 0
108
108
 
109
- **External consumers** configure emitters and extractors via `oagen.config.ts`
110
- instead of modifying CLI source — see the Configuration section in the README.
109
+ **Consumer projects** register emitters and extractors by importing plugin bundles
110
+ from emitter packages into their `oagen.config.ts`. Spec interpretation policy
111
+ (transforms, hints, mount rules) also lives in the consumer config. See the
112
+ [CLI Reference](../cli.md#configuration-oagenconfigts) for details.
111
113
 
112
114
  ### If verify fails on a spec update
113
115
 
package/docs/cli.md CHANGED
@@ -52,6 +52,85 @@ oagen extract --sdk-path ./existing-sdk --lang ruby --output my-surface.json
52
52
  | `--lang <language>` | Yes | | Target language |
53
53
  | `--output <path>` | No | `sdk-{lang}-surface.json` | Output file path (recommend writing to your output dir, e.g. `./sdk/sdk-ruby-surface.json`, and gitignoring it) |
54
54
 
55
+ ## `oagen compat-extract`
56
+
57
+ Extract a compat snapshot from a live SDK and write it to a JSON file. The snapshot is a machine-readable record of the SDK's public API surface, used as input to `compat-diff`.
58
+
59
+ ```bash
60
+ oagen compat-extract --sdk-path ./existing-sdk --lang node --output .
61
+ oagen compat-extract --sdk-path ./existing-sdk --lang php --output ./sdk --spec openapi.yml
62
+ ```
63
+
64
+ | Argument | Required | Default | Description |
65
+ | ------------------- | -------- | ----------------------- | ---------------------------------------------------------------------------------- |
66
+ | `--sdk-path <path>` | Yes | | Path to the live SDK |
67
+ | `--lang <language>` | Yes | | Target language |
68
+ | `--output <dir>` | Yes | | Directory to write `.oagen-compat-snapshot.json` into |
69
+ | `--spec <path>` | No | `OPENAPI_SPEC_PATH` env | Path to OpenAPI spec — enriches symbols with `operationId`, `route`, and `specSha` |
70
+
71
+ Always writes `.oagen-compat-snapshot.json` in the specified directory. The snapshot file is meant to be committed to the repository and updated on each release.
72
+
73
+ When `--spec` is provided, the command parses the spec and enriches callable symbols with `operationId` and `route` (HTTP method + path) by matching against spec operations. It also computes a SHA-256 of the spec file and stores it as `source.specSha` in the snapshot.
74
+
75
+ ## `oagen compat-diff`
76
+
77
+ Diff two compat snapshot files and produce a classified change report. No extraction or spec required — pure file-in, report-out.
78
+
79
+ ```bash
80
+ # Basic diff with terminal output
81
+ oagen compat-diff --baseline .oagen-compat-snapshot.json --candidate /tmp/candidate.json
82
+
83
+ # With machine-readable report and failure threshold
84
+ oagen compat-diff \
85
+ --baseline .oagen-compat-snapshot.json \
86
+ --candidate /tmp/candidate.json \
87
+ --output compat-report.json \
88
+ --fail-on breaking
89
+ ```
90
+
91
+ | Argument | Required | Default | Description |
92
+ | -------------------- | -------- | ---------- | -------------------------------------------------- |
93
+ | `--baseline <path>` | Yes | | Path to the baseline compat snapshot JSON |
94
+ | `--candidate <path>` | Yes | | Path to the candidate compat snapshot JSON |
95
+ | `--output <path>` | No | | Write machine-readable JSON report to this path |
96
+ | `--fail-on <level>` | No | `breaking` | Fail threshold: `none`, `breaking`, or `soft-risk` |
97
+ | `--explain` | No | `false` | Include provenance explanations in terminal output |
98
+
99
+ **Exit codes:** 0 = no changes exceed threshold, 1 = changes exceed threshold.
100
+
101
+ ## `oagen compat-summary`
102
+
103
+ Format a compat report as a markdown PR comment. Reads the JSON report produced by `compat-diff --output` and outputs markdown to stdout or a file.
104
+
105
+ ```bash
106
+ # Single language
107
+ oagen compat-summary --report compat-report.json | gh pr comment --body-file -
108
+
109
+ # Cross-language rollup (multiple reports)
110
+ oagen compat-summary --report php.json --report python.json --report go.json
111
+
112
+ # Write to a file
113
+ oagen compat-summary --report compat-report.json --output summary.md
114
+ ```
115
+
116
+ | Argument | Required | Default | Description |
117
+ | -------------------- | -------- | ------- | -------------------------------------------------------------------------- |
118
+ | `--report <path...>` | Yes | | Path(s) to compat report JSON(s) — pass multiple for cross-language rollup |
119
+ | `--output <path>` | No | stdout | Write markdown to this file instead of stdout |
120
+
121
+ **Single report** output includes:
122
+
123
+ - Status header (pass/warning/fail)
124
+ - Summary table with breaking, soft-risk, and additive counts
125
+ - Breaking changes table (always visible)
126
+ - Soft-risk and additive changes in collapsible `<details>` sections
127
+
128
+ **Multiple reports** produce a cross-language rollup:
129
+
130
+ - Per-language summary table (breaking/soft-risk/additive per language)
131
+ - Conceptual changes table showing per-language severity for each change
132
+ - Same conceptual change across languages is shown as one row, not N separate rows
133
+
55
134
  ## `oagen verify`
56
135
 
57
136
  Verify an already-generated SDK — run smoke tests and optional compat checks. Use after `oagen generate`.
@@ -103,9 +182,10 @@ Creates:
103
182
 
104
183
  - `package.json` with `@workos/oagen` dependency and `sdk:generate`/`sdk:verify`/`sdk:extract` scripts
105
184
  - `tsconfig.json`, `vitest.config.ts`, `tsup.config.ts` — build/test tooling
106
- - `oagen.config.ts` — registers the stub emitter
185
+ - `oagen.config.ts` — minimal config for local development, imports the plugin bundle
186
+ - `src/plugin.ts` — plugin bundle export registering the stub emitter
107
187
  - `src/{language}/index.ts` — stub emitter implementing the full `Emitter` interface
108
- - `src/index.ts` — barrel export
188
+ - `src/index.ts` — barrel export including the plugin bundle
109
189
  - Empty directories: `test/`, `smoke/`, `docs/sdk-architecture/`
110
190
 
111
191
  After initialization, `npm run typecheck` passes immediately. Implement your emitter methods in `src/{language}/` to start generating SDK code.
@@ -141,19 +221,80 @@ oagen resolve --spec openapi.yml --format json
141
221
 
142
222
  When `oagen.config.ts` defines `operationHints` and/or `mountRules`, they are applied automatically. The table format flags operations that have no hint and are resolved purely by algorithm.
143
223
 
224
+ ## `--config <path>`
225
+
226
+ All commands accept a global `--config <path>` option to load a specific config file instead of the default cwd-based discovery.
227
+
228
+ ```bash
229
+ # Explicit config
230
+ oagen generate --config ../openapi-spec/oagen.config.ts --spec openapi.yml --lang node --output ./sdk
231
+
232
+ # Default: searches for oagen.config.ts in the current directory
233
+ oagen generate --spec openapi.yml --lang node --output ./sdk
234
+ ```
235
+
236
+ When `--config` is omitted, the CLI searches the current working directory for `oagen.config.ts`, `oagen.config.js`, or `oagen.config.mjs` in that order.
237
+
144
238
  ## Configuration (`oagen.config.ts`)
145
239
 
146
- Place an `oagen.config.ts` (or `.js`/`.mjs`) in your project root to register emitters, extractors, and customize pipeline behavior. The CLI loads this file at startup before any command runs.
240
+ Place an `oagen.config.ts` (or `.js`/`.mjs`) in the project that drives generation. The CLI loads this file at startup before any command runs.
241
+
242
+ The config file belongs to the **consumer project** (the project that owns spec interpretation policy), not the emitter library. Emitter packages export plugin bundles that the consumer config composes.
243
+
244
+ ### Consumer config (recommended)
245
+
246
+ ```ts
247
+ import type { OagenConfig } from "@workos/oagen";
248
+ import { workosEmittersPlugin } from "@workos/oagen-emitters";
249
+
250
+ const config: OagenConfig = {
251
+ ...workosEmittersPlugin,
252
+ docUrl: "https://workos.com/docs",
253
+ operationIdTransform: (id) => id.replace(/Controller_/, ""),
254
+ operationHints,
255
+ mountRules,
256
+ };
257
+
258
+ export default config;
259
+ ```
260
+
261
+ ### Standalone emitter config
262
+
263
+ For standalone emitter projects that also serve as the consumer:
147
264
 
148
265
  ```ts
149
266
  import type { OagenConfig } from "@workos/oagen";
150
267
  import { nodeEmitter } from "./src/node/index.js";
151
- import { nodeExtractor } from "./src/node/extractor.js";
152
268
 
153
269
  const config: OagenConfig = {
154
270
  emitters: [nodeEmitter],
155
- extractors: [nodeExtractor],
156
- docUrl: "https://workos.com/docs",
271
+ };
272
+
273
+ export default config;
274
+ ```
275
+
276
+ ### Composing multiple plugin bundles
277
+
278
+ ```ts
279
+ import type { OagenConfig } from "@workos/oagen";
280
+ import { workosEmittersPlugin } from "@workos/oagen-emitters";
281
+ import { experimentalPlugin } from "@workos/oagen-experimental";
282
+
283
+ const config: OagenConfig = {
284
+ emitters: [
285
+ ...(workosEmittersPlugin.emitters ?? []),
286
+ ...(experimentalPlugin.emitters ?? []),
287
+ ],
288
+ extractors: [
289
+ ...(workosEmittersPlugin.extractors ?? []),
290
+ ...(experimentalPlugin.extractors ?? []),
291
+ ],
292
+ smokeRunners: {
293
+ ...(workosEmittersPlugin.smokeRunners ?? {}),
294
+ ...(experimentalPlugin.smokeRunners ?? {}),
295
+ },
296
+ operationHints,
297
+ mountRules,
157
298
  };
158
299
 
159
300
  export default config;
@@ -168,6 +309,7 @@ export default config;
168
309
  | `emitterProject` | `string` | Path to the emitter project directory. Used by skills to scaffold new emitters, tests, and smoke runners in the correct location. |
169
310
  | `smokeRunners` | `Record<string, string>` | Map from language key to custom smoke runner script path. Overrides the built-in `sdk-test.ts` for `oagen verify`. Can also be set per-invocation with `--smoke-runner`. |
170
311
  | `operationIdTransform` | `(id: string) => string` | Custom transform for operation IDs. Receives the raw `operationId` from the spec; return the desired operation name. No additional casing conversion is applied. When omitted, `operationId` values are converted to `camelCase`. |
312
+ | `schemaNameTransform` | `(name: string) => string` | Custom transform for schema (model/enum) names. Applied after the built-in `cleanSchemaName` normalization. Receives the cleaned PascalCase name; return the desired name. |
171
313
  | `docUrl` | `string` | Base URL for documentation links. When set, relative markdown paths in descriptions (e.g. `[User](/reference/authkit/user)`) are expanded to full URLs (e.g. `[User](https://workos.com/docs/reference/authkit/user)`). |
172
314
  | `operationHints` | `Record<string, OperationHint>` | Per-operation overrides keyed by `"METHOD /path"`. Override derived method names, remount to a different service, or split union-body operations into typed wrappers. See [Operation Resolution](architecture/ir-types.md#operation-resolution). |
173
315
  | `mountRules` | `Record<string, string>` | Service-level remounting. Maps IR service name to target service/namespace (PascalCase). All operations in the source service are mounted on the target unless overridden per-operation in `operationHints`. |
@@ -15,7 +15,7 @@ It includes:
15
15
  - spec diffing APIs such as `diffSpecs`
16
16
  - naming utilities used by emitters
17
17
  - `planOperation`
18
- - config typing needed by emitter projects
18
+ - config typing needed by consumer and emitter projects
19
19
 
20
20
  Changes to this surface require a deliberate compatibility review.
21
21
 
@@ -0,0 +1,200 @@
1
+ # Compatibility IR
2
+
3
+ The compatibility IR defines the types used to represent, diff, and classify SDK public API surfaces.
4
+
5
+ ## CompatSnapshot
6
+
7
+ A full snapshot of an SDK's public API surface.
8
+
9
+ ```ts
10
+ interface CompatSnapshot {
11
+ schemaVersion: string;
12
+ source: {
13
+ specSha?: string;
14
+ extractedAt: string;
15
+ };
16
+ policies: CompatPolicyHints;
17
+ symbols: CompatSymbol[];
18
+ }
19
+ ```
20
+
21
+ ## CompatSymbol
22
+
23
+ A single public API symbol.
24
+
25
+ ```ts
26
+ interface CompatSymbol {
27
+ id: string;
28
+ kind: CompatSymbolKind;
29
+ fqName: string;
30
+ ownerFqName?: string;
31
+ displayName: string;
32
+ visibility: "public" | "protected" | "internal";
33
+ stability: "stable" | "unstable" | "deprecated";
34
+ sourceKind: CompatSourceKind;
35
+ operationId?: string;
36
+ schemaName?: string;
37
+ route?: { method: string; path: string };
38
+ parameters?: CompatParameter[];
39
+ returns?: CompatTypeRef;
40
+ }
41
+ ```
42
+
43
+ ### Symbol Kinds
44
+
45
+ | Kind | Description |
46
+ | ------------------ | ---------------------------------------- |
47
+ | `service_accessor` | A service class (e.g., `UserManagement`) |
48
+ | `callable` | A method on a service class |
49
+ | `constructor` | A constructor/initializer |
50
+ | `field` | A field on an interface/model |
51
+ | `property` | A property on a class |
52
+ | `enum` | An enum type |
53
+ | `enum_member` | A member of an enum |
54
+ | `alias` | A type alias or interface name |
55
+
56
+ ### Source Kinds
57
+
58
+ | Source Kind | Description |
59
+ | -------------------------------- | -------------------------------- |
60
+ | `generated_service_wrapper` | Generated from an API service |
61
+ | `generated_model_constructor` | Generated model constructor |
62
+ | `generated_resource_constructor` | Generated resource constructor |
63
+ | `generated_enum` | Generated from an enum schema |
64
+ | `compat_alias` | Added for backward compatibility |
65
+
66
+ ## CompatParameter
67
+
68
+ ```ts
69
+ interface CompatParameter {
70
+ publicName: string;
71
+ wireName?: string;
72
+ position: number;
73
+ required: boolean;
74
+ nullable: boolean;
75
+ hasDefault: boolean;
76
+ passing: CompatPassingStyle;
77
+ type: CompatTypeRef;
78
+ sensitivity: ParameterSensitivity;
79
+ }
80
+ ```
81
+
82
+ ### Passing Styles
83
+
84
+ | Style | Description | Languages |
85
+ | ----------------------- | ----------------------- | ------------------------------ |
86
+ | `positional` | Position-only arguments | Go, Rust |
87
+ | `keyword` | Keyword arguments | Python, Ruby, Elixir |
88
+ | `named` | Named arguments | PHP, Kotlin, C# |
89
+ | `keyword_or_positional` | Either style | Python (positional-or-keyword) |
90
+ | `options_object` | Object destructuring | Node/TypeScript |
91
+ | `builder` | Builder pattern | Java-style |
92
+
93
+ ### Parameter Sensitivity
94
+
95
+ ```ts
96
+ interface ParameterSensitivity {
97
+ order: boolean; // Position changes are breaking
98
+ publicName: boolean; // Name changes are breaking
99
+ requiredness: boolean; // Requiredness changes are breaking
100
+ type: boolean; // Type changes are breaking
101
+ }
102
+ ```
103
+
104
+ ## Change Categories
105
+
106
+ ### Breaking
107
+
108
+ | Category | Description |
109
+ | ---------------------------------------------- | --------------------------------------------------- |
110
+ | `symbol_removed` | A public symbol was removed |
111
+ | `symbol_renamed` | A public symbol was renamed |
112
+ | `parameter_removed` | A parameter was removed |
113
+ | `parameter_renamed` | A parameter was renamed |
114
+ | `parameter_requiredness_increased` | Optional → required |
115
+ | `parameter_type_narrowed` | Parameter type changed |
116
+ | `parameter_position_changed_order_sensitive` | Parameter moved in order-sensitive language |
117
+ | `constructor_position_changed_order_sensitive` | Constructor param moved in order-sensitive language |
118
+ | `named_arg_name_removed` | Named argument removed |
119
+ | `keyword_name_removed` | Keyword argument removed |
120
+ | `overload_removed` | Method overload removed |
121
+ | `union_wrapper_migration_without_compat_alias` | Union wrapper migration without backward compat |
122
+
123
+ ### Soft-Risk
124
+
125
+ | Category | Description |
126
+ | ----------------------------------------------------- | ------------------------------------------------ |
127
+ | `parameter_added_non_terminal_optional` | Optional param added (not at end) |
128
+ | `constructor_reordered_named_friendly` | Constructor reordered in named-friendly language |
129
+ | `default_value_changed` | Default value changed |
130
+ | `wrapper_stricter_than_previous_sdk_but_matches_spec` | Stricter but spec-correct |
131
+ | `doc_surface_drift` | Documentation-only drift |
132
+
133
+ ### Additive
134
+
135
+ | Category | Description |
136
+ | ----------------------------------- | --------------------------- |
137
+ | `symbol_added` | New symbol added |
138
+ | `parameter_added_optional_terminal` | Optional param added at end |
139
+ | `new_constructor_overload_added` | New constructor overload |
140
+ | `new_wrapper_alias_added` | New compatibility alias |
141
+
142
+ ## Provenance Buckets
143
+
144
+ Every classified change includes a provenance field explaining the source of drift:
145
+
146
+ | Provenance | Description |
147
+ | ------------------------- | ---------------------------------------------- |
148
+ | `spec_shape_change` | The OpenAPI spec changed shape |
149
+ | `spec_ordering_change` | The spec changed property ordering |
150
+ | `emitter_template_change` | The emitter template was modified |
151
+ | `compat_extractor_change` | The extractor logic changed |
152
+ | `operation_hint_change` | An operation hint changed |
153
+ | `manual_override_change` | A manual override was applied |
154
+ | `normalization_change` | Naming normalization produced different output |
155
+ | `unknown` | Source not determined |
156
+
157
+ ## Extracting Snapshots
158
+
159
+ Every extractor implements `extractSnapshot()`, which returns a `CompatSnapshot` directly:
160
+
161
+ ```ts
162
+ const extractor = getExtractor("node");
163
+ const snapshot: CompatSnapshot = await extractor.extractSnapshot(sdkPath);
164
+ ```
165
+
166
+ This is the primary method used by `oagen compat-extract`. Internally, extractors call `extract()` to produce an `ApiSurface`, then convert it to a `CompatSnapshot` via the bridge function.
167
+
168
+ ### Extractor Interface
169
+
170
+ ```ts
171
+ interface Extractor {
172
+ language: string;
173
+ extract(sdkPath: string): Promise<ApiSurface>;
174
+ extractSnapshot(sdkPath: string): Promise<CompatSnapshot>;
175
+ hints: LanguageHints;
176
+ }
177
+ ```
178
+
179
+ Both `extract()` and `extractSnapshot()` are available. Use `extractSnapshot()` for the compat pipeline. Use `extract()` when you need the raw `ApiSurface` for overlay or legacy workflows.
180
+
181
+ ### Spec Enrichment
182
+
183
+ When `oagen compat-extract` receives `--spec`, it enriches callable symbols with spec-derived metadata:
184
+
185
+ - **`operationId`** — the operation name from the spec, matched by service + method name
186
+ - **`route`** — the HTTP method and path (e.g., `{ method: "POST", path: "/users" }`)
187
+ - **`source.specSha`** — SHA-256 of the spec file
188
+
189
+ This enrichment happens after extraction, as a post-step in the CLI. Extractors themselves don't need spec awareness.
190
+
191
+ ### Bridge: ApiSurface → CompatSnapshot
192
+
193
+ The `apiSurfaceToSnapshot()` function converts an `ApiSurface` to a `CompatSnapshot`, applying language-appropriate passing styles and sensitivity flags. This is what `extractSnapshot()` uses internally.
194
+
195
+ ```ts
196
+ import { apiSurfaceToSnapshot } from "@workos/oagen";
197
+
198
+ const surface: ApiSurface = await extractor.extract(sdkPath);
199
+ const snapshot: CompatSnapshot = apiSurfaceToSnapshot(surface);
200
+ ```
@@ -0,0 +1,170 @@
1
+ # Compatibility Policy
2
+
3
+ Human-authored compatibility policy lives in the `compat` section of `oagen.config.ts`. This is the single place for fail thresholds, language overrides, and intentional break approvals.
4
+
5
+ ## Config Schema
6
+
7
+ ```ts
8
+ export default {
9
+ compat: {
10
+ failOn: "breaking",
11
+ reportPath: "compat-report.json",
12
+ explain: true,
13
+ baselinePath: "sdk-surface.json",
14
+ languagePolicy: {
15
+ php: {
16
+ // optional sparse overrides
17
+ },
18
+ },
19
+ allow: [
20
+ // intentional break approvals
21
+ ],
22
+ },
23
+ };
24
+ ```
25
+
26
+ ### Fields
27
+
28
+ | Field | Type | Default | Description |
29
+ | ---------------- | ------------------------------------------------ | ------------ | ------------------------------------------ |
30
+ | `failOn` | `'none' \| 'breaking' \| 'soft-risk'` | `'breaking'` | Severity threshold for `verify` failure |
31
+ | `reportPath` | `string` | — | Path to write machine-readable JSON report |
32
+ | `explain` | `boolean` | `false` | Include provenance explanations in output |
33
+ | `baselinePath` | `string` | — | Path to baseline compatibility snapshot |
34
+ | `languagePolicy` | `Record<LanguageId, Partial<CompatPolicyHints>>` | — | Per-language policy overrides |
35
+ | `allow` | `CompatApproval[]` | — | Intentional break approvals |
36
+
37
+ ## Language Policy
38
+
39
+ Language policy determines which aspects of the public API are breaking. Built-in defaults capture language semantics:
40
+
41
+ | Language | Caller Uses Param Names | Constructor Order | Param Names Public | Overloads Public | Arity Public |
42
+ | -------- | ----------------------- | ----------------- | ------------------ | ---------------- | ------------ |
43
+ | PHP | yes | yes | yes | no | no |
44
+ | Python | yes | yes | yes | no | no |
45
+ | Ruby | yes | yes | yes | no | no |
46
+ | Go | no | yes | no | no | yes |
47
+ | Kotlin | yes | no | yes | yes | no |
48
+ | .NET | yes | no | yes | yes | no |
49
+ | Elixir | yes | no | yes | no | yes |
50
+ | Rust | no | yes | no | no | yes |
51
+ | Node | no | no | no | no | no |
52
+
53
+ ### Overriding Language Defaults
54
+
55
+ Use `languagePolicy` only when a specific SDK intentionally diverges from language norms:
56
+
57
+ ```ts
58
+ compat: {
59
+ languagePolicy: {
60
+ node: {
61
+ // This Node SDK uses positional args, not options objects
62
+ methodParameterNamesArePublicApi: true,
63
+ },
64
+ },
65
+ }
66
+ ```
67
+
68
+ ## Approvals
69
+
70
+ Approvals are concept-first: one approval covers one conceptual change across affected languages.
71
+
72
+ ### Schema
73
+
74
+ ```ts
75
+ interface CompatApproval {
76
+ symbol: string; // Fully-qualified symbol
77
+ category: string; // Change category (e.g., 'parameter_renamed')
78
+ appliesTo?: string[]; // Language IDs, or omit for all
79
+ match?: {
80
+ // Optional narrowing
81
+ parameter?: string;
82
+ member?: string;
83
+ oldName?: string;
84
+ newName?: string;
85
+ };
86
+ allowedReleaseLevel?: "major" | "minor" | "patch";
87
+ reason: string; // Required explanation
88
+ issue?: string; // Issue tracker reference
89
+ expiresAfterVersion?: string;
90
+ approved?: boolean; // Whether this approval is active (default: true)
91
+ }
92
+ ```
93
+
94
+ ### The `approved` Field
95
+
96
+ Approvals default to active. Set `approved: false` to deactivate an approval without removing it from the config — useful for temporarily disabling an approval or keeping a record of past approvals.
97
+
98
+ ```ts
99
+ {
100
+ symbol: 'Authorization.check',
101
+ category: 'parameter_renamed',
102
+ reason: 'Intentional rename for v3',
103
+ issue: 'SDK-1234',
104
+ approved: false, // deactivated — this break is no longer approved
105
+ }
106
+ ```
107
+
108
+ When `approved` is `false`, the matching engine skips the approval entirely. When `approved` is `true` or omitted, the approval is active.
109
+
110
+ ### Example
111
+
112
+ ```ts
113
+ compat: {
114
+ allow: [
115
+ {
116
+ symbol: 'WorkOS\\Service\\UserManagement::createUser',
117
+ category: 'parameter_removed',
118
+ match: { parameter: 'passwordHashType' },
119
+ appliesTo: ['php', 'python', 'kotlin', 'dotnet'],
120
+ allowedReleaseLevel: 'major',
121
+ reason: 'Intentional wrapper-object migration',
122
+ issue: 'SDK-1234',
123
+ expiresAfterVersion: '6.0.0',
124
+ },
125
+ ],
126
+ }
127
+ ```
128
+
129
+ ### Matching Rules
130
+
131
+ Approvals must be narrow:
132
+
133
+ **Good:**
134
+
135
+ - One symbol, one category, one conceptual change
136
+ - Optionally one parameter/member
137
+ - Optionally a bounded set of affected languages
138
+
139
+ **Bad (rejected by validation):**
140
+
141
+ - Wildcard symbols (`*`, `Authorization.*`)
142
+ - Empty reason
143
+ - Missing symbol or category
144
+
145
+ ### Anti-patterns
146
+
147
+ | Pattern | Problem |
148
+ | ---------------------------------- | ---------------------------------- |
149
+ | Approve all breaks in one language | Too broad — masks real regressions |
150
+ | Approve all parameter changes | Hides future unintentional breaks |
151
+ | No reason field | No audit trail |
152
+ | No issue reference | Can't trace back to decisions |
153
+
154
+ ## Severity Determination
155
+
156
+ The default severity for a category comes from the classification engine. Language policy can modify this:
157
+
158
+ 1. A `parameter_renamed` change is `breaking` by default
159
+ 2. If the language policy says `methodParameterNamesArePublicApi: false` (e.g., Go), the classifier can downgrade to `soft-risk`
160
+ 3. Approvals can further suppress changes from the failure threshold
161
+
162
+ ## Fail Threshold
163
+
164
+ The `failOn` level determines which unapproved changes cause `oagen verify` to fail:
165
+
166
+ | Level | Fails on |
167
+ | ----------- | ---------------------------------------- |
168
+ | `none` | Never fails for compat |
169
+ | `breaking` | Unapproved breaking changes |
170
+ | `soft-risk` | Unapproved breaking OR soft-risk changes |