backend-skeleton 1.0.0-beta.1

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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
@@ -0,0 +1,869 @@
1
+ // A1: reconciles a scan-derived module against a real OpenAPI document -- recovers operationIds
2
+ // the scanner's regex heuristics missed, and (its main purpose) corrects `path` for operations
3
+ // the scanner already matched, since a global path-prefix Spring config (e.g. `addPathPrefix` /
4
+ // `springdoc.paths-to-match`) is invisible to source-annotation scanning. See
5
+ // D-openapi-reconciliation in DECISIONS.md for the real Team-IZ-Backend defect this closes
6
+ // (every emitted contract's `path` was missing `/api/v0` -- verified by generating the real
7
+ // document and diffing).
8
+ //
9
+ // This module never looks at waivers (same discipline as contracts/emit.mjs) and never writes
10
+ // anything -- `loadOpenApiDocument` is the only place a file is read, and `reconcileModule` is
11
+ // pure. contracts/emit.mjs imports `selectModule`/`endpointKey` FROM here -- wait, the reverse:
12
+ // THIS module imports them FROM contracts/emit.mjs (never the other way), so "which module" and
13
+ // "which endpoint is which" are defined in exactly one place.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { createHash } from 'node:crypto';
17
+ import { endpointKey, BARE_UUID_PATTERN } from './emit.mjs';
18
+
19
+ const MAX_DOCUMENT_BYTES = 16 * 1024 * 1024;
20
+ const MAX_PATHS = 5000;
21
+ const MAX_OPERATIONS = 10000;
22
+ const HTTP_METHODS = Object.freeze(new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']));
23
+
24
+ // A2: request-body JSON Schema projection. Real-data-measured against Team-IZ-Backend's actual
25
+ // OpenAPI document (308 components.schemas, 0 cycles, max $ref-chain depth 5, max structural
26
+ // depth reachable from a requestBody 8, max single-operation node count 23, longest real
27
+ // `pattern` 77 chars) -- every cap below carries multiple times that headroom, see
28
+ // D-openapi-request-schema in DECISIONS.md for the full measurement.
29
+ const MAX_COMPONENT_SCHEMAS = 5000;
30
+ const MAX_SCHEMA_DEPTH = 32; // every recursion level (structural AND $ref), not just $ref-chain depth
31
+ const MAX_SCHEMA_NODES = 2000; // shared counter per top-level inlineSchema() call, enum entries count
32
+ const MAX_PATTERN_LENGTH = 300; // real max observed: 77
33
+ const JSON_MEDIA_TYPE = 'application/json';
34
+ const SCHEMA_REF_PREFIX = '#/components/schemas/';
35
+
36
+ // A3: response/error JSON Schema projection. Reuses every inlineSchema() defense above
37
+ // unchanged (keyword/format whitelist, MAX_SCHEMA_DEPTH/NODES/PATTERN_LENGTH) -- measured by
38
+ // running inlineSchema() itself over all 634 real response/error schema roots in Team-IZ-
39
+ // Backend's document at the current caps: 634/634 resolved, zero failures, max depth 14 (2.3x
40
+ // headroom), max single-schema node count 231 (8.7x headroom). The one genuinely new risk A3
41
+ // introduces is fan-out: A2 resolved at most 1 schema per operation (a request body), but a
42
+ // response/error side can have many documented statuses -- MAX_RESPONSES_PER_OPERATION bounds
43
+ // that (real max observed: 9). See D-openapi-response-schema in DECISIONS.md.
44
+ const MAX_RESPONSES_PER_OPERATION = 64;
45
+ // A6 (D-openapi-export): widened from `/^2[0-9]{2}$/` and `/^[45][0-9]{2}$/` to also accept
46
+ // OpenAPI's own RANGE keys. These are ordinary in real hand-written documents and legal per the
47
+ // official 3.1 meta-schema, whose `responses` object accepts exactly `^[1-5](?:[0-9]{2}|XX)$` plus
48
+ // `default` -- confirmed by executing the real 2022-10-07 schema, not by reading prose about it.
49
+ // Before this widening a document written with `2XX`/`4XX` silently lost every response and error
50
+ // schema: `projectResponseSchemas` simply never matched the status key, and "no matching status"
51
+ // is (correctly) not a failure, so the loss produced no warning anywhere. This is a general
52
+ // importer capability gain, not round-trip plumbing -- it is what makes such a document readable
53
+ // by `contract emit --openapi-file` at all.
54
+ const SUCCESS_STATUS_RE = /^2(?:[0-9]{2}|XX)$/;
55
+ const ERROR_STATUS_RE = /^[45](?:[0-9]{2}|XX)$/;
56
+ const DEFAULT_STATUS_KEY = 'default';
57
+
58
+ // Same shape convention as the rest of contracts/ -- operationId becomes an object key
59
+ // downstream (contracts/emit.mjs's `operations[operationId]`), so it's whitelisted before it's
60
+ // trusted anywhere. Deliberately excludes a leading `_` (so `__proto__` fails on the first
61
+ // character alone); `constructor`/`toString` DO match this shape, but every index in this module
62
+ // is a `Map` (never a plain object), so there is no lookup path where that resolves to an
63
+ // inherited property -- see D-security-1 in DECISIONS.md for the equivalent concern this
64
+ // mirrors, and bin/bskel.mjs's cmdContractToolSchema fix (Object.hasOwn) for the one place a
65
+ // contract's operations DO become plain-object keys downstream of this module.
66
+ export const OPERATION_ID_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,199}$/;
67
+
68
+ // A path-prefix candidate must look like one or more clean path segments -- rules out `{}`
69
+ // (template params leaking into a "prefix"), empty segments (`//`), and anything that isn't a
70
+ // plain path string. Used both for prefix inference (a delta must match this to be trusted) and
71
+ // to validate an explicit `--path-prefix` value.
72
+ export const PATH_PREFIX_RE = /^(?:\/[A-Za-z0-9._~%-]+)+$/;
73
+
74
+ // A2: same whitelist-not-denylist reasoning as OPERATION_ID_RE above, applied to two new classes
75
+ // of externally-influenceable string this module now handles: OpenAPI component-schema names
76
+ // (`components.schemas.<name>`, e.g. "CreateOrganizationRequest") and, inside a resolved schema,
77
+ // its `properties` keys / `required[]` entries (e.g. "dataRetentionDays"). Both become object
78
+ // keys downstream -- inlineSchema()'s output `properties` is a plain object built with
79
+ // `out.properties[k] = ...`, so a `k` of "__proto__" from JSON.parse'd input would hit
80
+ // Object.prototype's `__proto__` setter instead of adding a property. The leading-letter
81
+ // requirement kills `__proto__` on the first character alone, same as OPERATION_ID_RE; measured
82
+ // against all 308 real Team-IZ-Backend component-schema names with zero rejections.
83
+ export const COMPONENT_SCHEMA_NAME_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,199}$/;
84
+ export const SCHEMA_PROPERTY_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
85
+
86
+ // inlineSchema()'s keyword policy: RECURSED keywords are walked into; ASSERTION keywords are
87
+ // copied verbatim (their values are scalars/arrays of scalars, not schema nodes -- nothing to
88
+ // recurse); DROPPED keywords carry no validation meaning and are silently discarded (their
89
+ // absence changes nothing about what a schema accepts); anything else fails that schema closed.
90
+ // The FORMAT set is checked separately (see inlineSchema's format handling) since `uuid` gets
91
+ // rewritten rather than either copied or dropped. A missing-and-therefore-fail-closed keyword is
92
+ // deliberate: silently dropping an assertion (e.g. an unrecognized `pattern`-like keyword) would
93
+ // emit a schema WEAKER than the real one, which is worse than emitting no schema at all -- see
94
+ // D-openapi-request-schema in DECISIONS.md.
95
+ const RECURSED_KEYWORDS = Object.freeze(new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf']));
96
+ const COPIED_KEYWORDS = Object.freeze(new Set([
97
+ 'type', 'enum', 'const', 'required',
98
+ 'minLength', 'maxLength',
99
+ 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
100
+ 'minItems', 'maxItems', 'uniqueItems',
101
+ 'minProperties', 'maxProperties',
102
+ ]));
103
+ const DROPPED_KEYWORDS = Object.freeze(new Set(['description', 'title', 'example', 'examples', 'externalDocs', 'xml', 'deprecated']));
104
+ // Real Team-IZ-Backend format-value histogram (request-body-reachable schemas only): uuid(20),
105
+ // int32(10), email(7), date(10), date-time(3), int64(2). `uuid` is handled separately (rewritten
106
+ // to BARE_UUID_PATTERN, see inlineSchema) -- not in this set, since it never survives as `format`.
107
+ const SAFE_FORMATS = Object.freeze(new Set(['int32', 'int64', 'email', 'date', 'date-time', 'double', 'float', 'binary', 'uri']));
108
+
109
+ // Thrown internally by inlineSchema()'s recursive walk and caught exactly once at the exported
110
+ // boundary -- with 12+ distinct failure points, threading {ok:false} through every return would
111
+ // bury the actual walking logic. The "never throws across the module boundary" invariant
112
+ // (loadOpenApiDocument's own comment) is about the EXPORTED function, which this preserves.
113
+ class InlineFailure extends Error {
114
+ constructor(reason) {
115
+ super(reason);
116
+ this.reason = reason;
117
+ }
118
+ }
119
+
120
+ // A6 (D-openapi-export): the `info` extension `bskel contract export` stamps on every document it
121
+ // writes. Declared HERE, on the reading side, because the guard below is the load-bearing consumer
122
+ // -- contracts/export.mjs imports this constant rather than spelling the key a second time, so the
123
+ // writer and the reader cannot drift apart and silently disarm the guard.
124
+ export const BSKEL_GENERATED_EXTENSION = 'x-bskel-generated';
125
+
126
+ export function hasBskelExportMarker(doc) {
127
+ const info = doc?.info;
128
+ if (typeof info !== 'object' || info === null || Array.isArray(info)) return false;
129
+ return Object.hasOwn(info, BSKEL_GENERATED_EXTENSION);
130
+ }
131
+
132
+ export function normalizeRoute(routePath) {
133
+ let normalized = routePath.replace(/\/{2,}/g, '/');
134
+ if (normalized.length > 1 && normalized.endsWith('/')) normalized = normalized.slice(0, -1);
135
+ return normalized;
136
+ }
137
+
138
+ // Reads and parses exactly once. Every failure mode returns {ok:false, error}, never throws --
139
+ // this is the one function in the module that touches the filesystem, so it's the one place that
140
+ // has to be defensive about a file that's huge, unreadable, not JSON, or JSON-but-not-an-object.
141
+ export function loadOpenApiDocument(filePath) {
142
+ let stat;
143
+ try {
144
+ stat = fs.statSync(filePath);
145
+ } catch (err) {
146
+ return { ok: false, error: `could not read "${filePath}": ${err.message}` };
147
+ }
148
+ if (!stat.isFile()) {
149
+ return { ok: false, error: `"${filePath}" is not a regular file` };
150
+ }
151
+ if (stat.size > MAX_DOCUMENT_BYTES) {
152
+ return { ok: false, error: `"${filePath}" is ${stat.size} bytes, exceeds the ${MAX_DOCUMENT_BYTES}-byte limit for an OpenAPI document` };
153
+ }
154
+ let raw;
155
+ try {
156
+ raw = fs.readFileSync(filePath, 'utf8');
157
+ } catch (err) {
158
+ return { ok: false, error: `could not read "${filePath}": ${err.message}` };
159
+ }
160
+ let doc;
161
+ try {
162
+ doc = JSON.parse(raw);
163
+ } catch (err) {
164
+ return { ok: false, error: `could not parse "${filePath}" as JSON: ${err.message}` };
165
+ }
166
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
167
+ return { ok: false, error: `"${filePath}" does not contain a JSON object at its root` };
168
+ }
169
+ const hash = createHash('sha256').update(raw).digest('hex');
170
+ return { ok: true, doc, hash, bytes: stat.size };
171
+ }
172
+
173
+ // Builds two Maps (never plain objects -- see OPERATION_ID_RE's comment) from `doc.paths`:
174
+ // byOperationId (one entry per distinct valid operationId, first occurrence wins) and byRoute
175
+ // (keyed "VERB normalizedPath", value is an array -- more than one entry means the normalized
176
+ // route is ambiguous even within the document itself). `$ref` path items are skipped, not
177
+ // resolved (out of scope for this vertical slice -- see DECISIONS.md).
178
+ //
179
+ // A2: also builds `componentSchemas` (Map<name, schemaNode>, from `doc.components.schemas`) and
180
+ // retains each operation's raw `requestBody` node on its `entry` -- both were previously
181
+ // discarded entirely (A1 only needed {verb, path, operationId}). Indexing stays O(top-level
182
+ // count) here; deep walking into a schema's own `properties`/`$ref` chain happens lazily, only
183
+ // for the operations reconcileModule() actually needs a request-body schema for (see
184
+ // inlineSchema below) -- resolution cost doesn't scale with the size of the whole document.
185
+ export function indexOpenApiDocument(doc) {
186
+ const byOperationId = new Map();
187
+ const byRoute = new Map();
188
+ const componentSchemas = new Map();
189
+ const stats = {
190
+ path_count: 0, operation_count: 0, skipped_path_refs: 0, rejected_operation_ids: 0,
191
+ component_schema_count: 0, rejected_component_schemas: 0,
192
+ };
193
+
194
+ const openapiVersion = typeof doc.openapi === 'string' ? doc.openapi : null;
195
+ const schemaDialectSupported = typeof openapiVersion === 'string' && /^3\.1(?:\.|$)/.test(openapiVersion);
196
+
197
+ const rawComponentSchemas = doc.components && typeof doc.components === 'object' && !Array.isArray(doc.components)
198
+ ? doc.components.schemas
199
+ : null;
200
+ if (rawComponentSchemas && typeof rawComponentSchemas === 'object' && !Array.isArray(rawComponentSchemas)) {
201
+ const schemaNames = Object.keys(rawComponentSchemas);
202
+ if (schemaNames.length > MAX_COMPONENT_SCHEMAS) {
203
+ return { ok: false, error: `OpenAPI document has ${schemaNames.length} component schemas, exceeds the ${MAX_COMPONENT_SCHEMAS}-schema limit` };
204
+ }
205
+ for (const name of schemaNames) {
206
+ const value = rawComponentSchemas[name];
207
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) continue;
208
+ if (!COMPONENT_SCHEMA_NAME_RE.test(name)) { stats.rejected_component_schemas++; continue; }
209
+ componentSchemas.set(name, value);
210
+ }
211
+ stats.component_schema_count = componentSchemas.size;
212
+ }
213
+
214
+ const paths = doc.paths;
215
+ if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) {
216
+ return { ok: true, byOperationId, byRoute, componentSchemas, stats, servers: [], openapiVersion, schemaDialectSupported };
217
+ }
218
+
219
+ const pathKeys = Object.keys(paths);
220
+ if (pathKeys.length > MAX_PATHS) {
221
+ return { ok: false, error: `OpenAPI document has ${pathKeys.length} paths, exceeds the ${MAX_PATHS}-path limit` };
222
+ }
223
+
224
+ for (const routeKey of pathKeys) {
225
+ if (typeof routeKey !== 'string' || !routeKey.startsWith('/')) continue;
226
+ const pathItem = paths[routeKey];
227
+ if (typeof pathItem !== 'object' || pathItem === null || Array.isArray(pathItem)) continue;
228
+ if (Object.hasOwn(pathItem, '$ref')) {
229
+ stats.skipped_path_refs++;
230
+ continue;
231
+ }
232
+ stats.path_count++;
233
+ const normalizedRoute = normalizeRoute(routeKey);
234
+
235
+ for (const methodKey of Object.keys(pathItem)) {
236
+ const verbLower = methodKey.toLowerCase();
237
+ if (!HTTP_METHODS.has(verbLower)) continue;
238
+ const operation = pathItem[methodKey];
239
+ if (typeof operation !== 'object' || operation === null || Array.isArray(operation)) continue;
240
+
241
+ if (stats.operation_count >= MAX_OPERATIONS) {
242
+ return { ok: false, error: `OpenAPI document has more than ${MAX_OPERATIONS} operations` };
243
+ }
244
+ stats.operation_count++;
245
+
246
+ const verb = verbLower.toUpperCase();
247
+ const rawOperationId = operation.operationId;
248
+ let operationId = null;
249
+ if (typeof rawOperationId === 'string') {
250
+ if (OPERATION_ID_RE.test(rawOperationId)) {
251
+ operationId = rawOperationId;
252
+ } else {
253
+ stats.rejected_operation_ids++;
254
+ }
255
+ }
256
+
257
+ // A2: raw requestBody node retained verbatim (bounded by the document's own
258
+ // MAX_DOCUMENT_BYTES cap -- no new read, no new size limit needed). A `$ref` requestBody
259
+ // (`#/components/requestBodies/*`) is out of scope -- reconcileModule treats it as "no
260
+ // body to project" rather than resolving it, same as a genuinely bodyless operation.
261
+ const requestBody = typeof operation.requestBody === 'object' && operation.requestBody !== null && !Array.isArray(operation.requestBody)
262
+ ? operation.requestBody
263
+ : null;
264
+ // A3: raw responses map retained verbatim, same "no new read, no new size cap" reasoning
265
+ // as requestBody above -- bounded by MAX_DOCUMENT_BYTES already.
266
+ const responses = typeof operation.responses === 'object' && operation.responses !== null && !Array.isArray(operation.responses)
267
+ ? operation.responses
268
+ : null;
269
+ const entry = { verb, path: routeKey, operationId, requestBody, responses };
270
+
271
+ const routeMatchKey = `${verb} ${normalizedRoute}`;
272
+ const existingRoute = byRoute.get(routeMatchKey);
273
+ if (existingRoute) existingRoute.push(entry); else byRoute.set(routeMatchKey, [entry]);
274
+
275
+ // "First occurrence wins" -- same convention as contracts/emit.mjs's own
276
+ // CONTRACT_DUPLICATE_OPERATION_ID handling for scan-side duplicates.
277
+ if (operationId && !byOperationId.has(operationId)) {
278
+ byOperationId.set(operationId, entry);
279
+ }
280
+ }
281
+ }
282
+
283
+ const servers = Array.isArray(doc.servers)
284
+ ? doc.servers.filter((s) => s && typeof s.url === 'string').map((s) => s.url)
285
+ : [];
286
+
287
+ return { ok: true, byOperationId, byRoute, componentSchemas, stats, servers, openapiVersion, schemaDialectSupported };
288
+ }
289
+
290
+ // `S` (scan path) always starts with "/" (scanners/adapters/java-spring.mjs's joinPath guarantees
291
+ // this), so `O.endsWith(S)` alone would risk a false match at a non-segment boundary (e.g. "/api/
292
+ // v0/suborganizations".endsWith("/organizations")); requiring the remainder to itself look like a
293
+ // clean prefix (PATH_PREFIX_RE, which requires each segment to start with "/") makes that
294
+ // impossible -- a match can only occur at an actual "/" boundary between candidate segments.
295
+ function computeDelta(scanPath, docPath) {
296
+ if (docPath === scanPath) return '';
297
+ if (docPath.endsWith(scanPath)) {
298
+ const candidate = docPath.slice(0, docPath.length - scanPath.length);
299
+ if (PATH_PREFIX_RE.test(candidate)) return candidate;
300
+ }
301
+ return null;
302
+ }
303
+
304
+ // anchorDeltas: an array of delta strings, ONE PER ANCHOR (duplicates expected and meaningful --
305
+ // the tally becomes the snapshot's `path_prefix.deltas` for audit). A single distinct delta
306
+ // confirms the prefix; zero or conflicting deltas both leave path correction of ALREADY-matched
307
+ // operations unaffected (that never needed a prefix -- see reconcileModule) and only disable
308
+ // recovery of unmatched endpoints, which is the less valuable half of this feature.
309
+ export function inferPathPrefix(anchorDeltas) {
310
+ const counts = new Map();
311
+ for (const d of anchorDeltas) counts.set(d, (counts.get(d) ?? 0) + 1);
312
+ const uniqueDeltas = [...counts.keys()];
313
+ const deltas = Object.fromEntries(counts);
314
+ if (uniqueDeltas.length === 0) return { value: null, origin: 'none', deltas, conflicting: [] };
315
+ if (uniqueDeltas.length === 1) return { value: uniqueDeltas[0], origin: 'inferred', deltas, conflicting: [] };
316
+ return { value: null, origin: 'none', deltas, conflicting: uniqueDeltas };
317
+ }
318
+
319
+ // A2: dereferences `node` (a schema fragment from a requestBody's application/json content) into
320
+ // a single self-contained JSON Schema tree with NO `$ref` anywhere in the output. Pure, and NEVER
321
+ // throws across this exported boundary (InlineFailure is caught here, anything else re-thrown --
322
+ // it would be a real programming bug, not an untrusted-input failure, and must not be swallowed).
323
+ // Full inlining (never registering a component with ajv by $id) for two independent reasons: ajv
324
+ // would otherwise need every one of a document's component schemas registered just to validate
325
+ // ONE operation's body, and bin/bskel.mjs's cmdContractToolSchema promises its `input_schema`
326
+ // output is a JSON Schema subset "directly usable as-is" for Anthropic tool-use -- no $ref/$defs
327
+ // is exactly what that promise requires; this function is what upholds it.
328
+ export function inlineSchema(node, componentSchemas, opts = {}) {
329
+ const limits = {
330
+ maxDepth: opts.maxDepth ?? MAX_SCHEMA_DEPTH,
331
+ maxNodes: opts.maxNodes ?? MAX_SCHEMA_NODES,
332
+ maxPatternLength: opts.maxPatternLength ?? MAX_PATTERN_LENGTH,
333
+ };
334
+ const state = { nodes: 0 };
335
+ try {
336
+ const schema = walkSchemaNode(node, componentSchemas, 0, new Set(), state, limits);
337
+ return { ok: true, schema, nodes: state.nodes };
338
+ } catch (err) {
339
+ if (err instanceof InlineFailure) return { ok: false, reason: err.reason };
340
+ throw err;
341
+ }
342
+ }
343
+
344
+ function fail(reason) {
345
+ throw new InlineFailure(reason);
346
+ }
347
+
348
+ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits) {
349
+ if (typeof node !== 'object' || node === null || Array.isArray(node)) fail('not-a-schema-object');
350
+ if (depth > limits.maxDepth) fail('max-depth-exceeded');
351
+ state.nodes++;
352
+ if (state.nodes > limits.maxNodes) fail('too-many-nodes');
353
+
354
+ if (Object.hasOwn(node, '$ref')) {
355
+ // 2020-12 permits siblings alongside $ref (unlike OpenAPI 3.0's restriction), but this
356
+ // module doesn't attempt to MERGE $ref with a sibling assertion -- a DROPPED_KEYWORDS
357
+ // sibling (e.g. a documentation-only `description`) is harmless and ignored; anything else
358
+ // would need merge semantics this vertical slice doesn't implement, so it fails closed.
359
+ const siblingKeys = Object.keys(node).filter((k) => k !== '$ref');
360
+ if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k))) fail('ref-with-siblings');
361
+ const ref = node['$ref'];
362
+ if (typeof ref !== 'string' || !ref.startsWith(SCHEMA_REF_PREFIX)) fail('unsupported-ref');
363
+ const name = ref.slice(SCHEMA_REF_PREFIX.length);
364
+ // JSON-Pointer escapes (~0/~1) or percent-encoding in the name are never produced by
365
+ // springdoc for a plain component name -- reject rather than decode-and-guess.
366
+ if (name.includes('~') || name.includes('%') || !COMPONENT_SCHEMA_NAME_RE.test(name)) fail('unsupported-ref');
367
+ if (visiting.has(name)) fail('cycle-detected');
368
+ const target = componentSchemas.get(name);
369
+ if (!target) fail('component-not-found');
370
+ visiting.add(name);
371
+ try {
372
+ return walkSchemaNode(target, componentSchemas, depth + 1, visiting, state, limits);
373
+ } finally {
374
+ // Delete-on-exit: a diamond (two sibling properties referencing the SAME component) stays
375
+ // legal and is inlined independently for each -- only a true ancestor-chain cycle fails.
376
+ visiting.delete(name);
377
+ }
378
+ }
379
+
380
+ const out = {};
381
+
382
+ // `format` is handled before the general loop below because `uuid` is REWRITTEN (D-security-2,
383
+ // reapplied one layer down -- see emit.mjs's BARE_UUID_PATTERN comment), not copied or dropped
384
+ // like every other keyword; this must run before the loop reaches a `pattern` key so the
385
+ // uuid+pattern conflict check below (inside the loop) sees `out.pattern` already set.
386
+ if (Object.hasOwn(node, 'format')) {
387
+ const format = node.format;
388
+ if (format === 'uuid') {
389
+ out.pattern = BARE_UUID_PATTERN;
390
+ } else if (typeof format === 'string' && SAFE_FORMATS.has(format)) {
391
+ out.format = format;
392
+ } else {
393
+ fail(`unsupported-format:${typeof format === 'string' ? format : typeof format}`);
394
+ }
395
+ }
396
+
397
+ for (const key of Object.keys(node)) {
398
+ if (key === '$ref' || key === 'format') continue; // format already handled above
399
+ if (DROPPED_KEYWORDS.has(key)) continue;
400
+
401
+ if (key === 'pattern') {
402
+ // Two patterns can't be expressed without allOf, which this slice doesn't attempt to
403
+ // synthesize -- a node with BOTH format:'uuid' and an explicit pattern fails closed
404
+ // rather than guessing which one wins (out.pattern is already set if format:'uuid' ran).
405
+ if (Object.hasOwn(out, 'pattern')) fail('uuid-format-with-pattern');
406
+ const pattern = node.pattern;
407
+ if (typeof pattern !== 'string' || pattern.length > limits.maxPatternLength) fail('pattern-too-long');
408
+ // Partial ReDoS mitigation only -- bounds input SIZE, not regex STRUCTURE. A real,
409
+ // already-deployed Team-IZ-Backend pattern (CreateOrganizationRequest.emailDomain) has a
410
+ // nested quantifier well within this length cap. See D-openapi-request-schema.
411
+ try { new RegExp(pattern); } catch { fail('invalid-pattern'); }
412
+ out.pattern = pattern;
413
+ continue;
414
+ }
415
+
416
+ if (key === 'required') {
417
+ const req = node.required;
418
+ if (!Array.isArray(req)) fail('unsupported-keyword:required');
419
+ for (const r of req) {
420
+ if (typeof r !== 'string' || !SCHEMA_PROPERTY_NAME_RE.test(r)) fail('unsupported-property-name');
421
+ }
422
+ out.required = [...req];
423
+ continue;
424
+ }
425
+
426
+ if (COPIED_KEYWORDS.has(key)) {
427
+ if (key === 'enum' && Array.isArray(node.enum)) {
428
+ state.nodes += node.enum.length; // enum entries aren't separate schema nodes, but still cost budget
429
+ if (state.nodes > limits.maxNodes) fail('too-many-nodes');
430
+ }
431
+ out[key] = node[key];
432
+ continue;
433
+ }
434
+
435
+ if (key === 'properties') {
436
+ const props = node.properties;
437
+ if (typeof props !== 'object' || props === null || Array.isArray(props)) fail('unsupported-keyword:properties');
438
+ const outProps = {};
439
+ for (const propName of Object.keys(props)) {
440
+ // Same prototype-pollution class as OPERATION_ID_RE/COMPONENT_SCHEMA_NAME_RE -- a
441
+ // violating key fails the WHOLE schema closed (not a per-property drop, which would
442
+ // silently emit a schema weaker than the real one).
443
+ if (!SCHEMA_PROPERTY_NAME_RE.test(propName)) fail('unsupported-property-name');
444
+ outProps[propName] = walkSchemaNode(props[propName], componentSchemas, depth + 1, visiting, state, limits);
445
+ }
446
+ out.properties = outProps;
447
+ continue;
448
+ }
449
+
450
+ if (key === 'items') {
451
+ out.items = walkSchemaNode(node.items, componentSchemas, depth + 1, visiting, state, limits);
452
+ continue;
453
+ }
454
+
455
+ if (key === 'additionalProperties') {
456
+ const ap = node.additionalProperties;
457
+ out.additionalProperties = typeof ap === 'boolean'
458
+ ? ap
459
+ : walkSchemaNode(ap, componentSchemas, depth + 1, visiting, state, limits);
460
+ continue;
461
+ }
462
+
463
+ if (key === 'oneOf' || key === 'anyOf' || key === 'allOf') {
464
+ const arr = node[key];
465
+ if (!Array.isArray(arr) || arr.length === 0) fail(`unsupported-keyword:${key}`);
466
+ out[key] = arr.map((el) => walkSchemaNode(el, componentSchemas, depth + 1, visiting, state, limits));
467
+ continue;
468
+ }
469
+
470
+ fail(`unsupported-keyword:${key}`);
471
+ }
472
+
473
+ return out;
474
+ }
475
+
476
+ // A2: attaches requestBodySchema/requestBodyRequired (or schemaUnresolvedReason) to a `matched`/
477
+ // `adopted` result, mutating it in place -- called only for those two kinds (see reconcileModule),
478
+ // since a `drift`/`missing`/`ambiguous`/`unresolved` operation hasn't earned trust on path/verb,
479
+ // let alone body shape. `docEntry` is the OpenAPI-side entry (from byOperationId or byRoute) whose
480
+ // `.requestBody` indexOpenApiDocument() retained. Never treats "nothing to project" as a failure --
481
+ // only an actual unresolvable schema increments schema_unresolved / sets schemaUnresolvedReason.
482
+ function applyRequestBodySchema(result, docEntry, componentSchemas, stats) {
483
+ const requestBody = docEntry.requestBody;
484
+ if (!requestBody || Object.hasOwn(requestBody, '$ref')) {
485
+ stats.schema_none++;
486
+ return;
487
+ }
488
+ const content = requestBody.content;
489
+ if (typeof content !== 'object' || content === null || Array.isArray(content) || !Object.hasOwn(content, JSON_MEDIA_TYPE)) {
490
+ stats.schema_skipped_media_type++;
491
+ return;
492
+ }
493
+ const mediaEntry = content[JSON_MEDIA_TYPE];
494
+ const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
495
+ if (!schemaNode || typeof schemaNode !== 'object' || Array.isArray(schemaNode)) {
496
+ stats.schema_none++;
497
+ return;
498
+ }
499
+ const resolved = inlineSchema(schemaNode, componentSchemas);
500
+ if (resolved.ok) {
501
+ result.requestBodySchema = resolved.schema;
502
+ result.requestBodyRequired = requestBody.required === true;
503
+ stats.schema_resolved++;
504
+ } else {
505
+ result.schemaUnresolvedReason = resolved.reason;
506
+ stats.schema_unresolved++;
507
+ }
508
+ }
509
+
510
+ // A3: recursive key-sorted serialization, used to compare two INLINE-RESOLVED schemas for
511
+ // structural equality (two different raw response-object nodes can resolve to the identical
512
+ // schema -- e.g. the real Team-IZ-Backend `findCurrentProject`, whose 200 and 204 responses are
513
+ // separate objects but both reference the same `ProjectResponse` component). Array order
514
+ // (`required`/`enum`) is significant, which makes this comparison CONSERVATIVE: two schemas that
515
+ // are semantically equal but differ in array order compare as distinct, which only ever produces
516
+ // an extra (still-correct) `anyOf` branch -- never a false "these are the same" collapse.
517
+ function canonicalJson(value) {
518
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
519
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
520
+ const keys = Object.keys(value).sort();
521
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
522
+ }
523
+
524
+ // A3: pure. Projects every documented response whose status matches `statusRe` (SUCCESS_STATUS_RE
525
+ // or ERROR_STATUS_RE) and has an `application/json` schema, deduplicating on the RAW schema node
526
+ // first (JSON.stringify -- cheap, and collapses the extremely common case of many statuses all
527
+ // pointing at the literal same node, e.g. every error status sharing one $ref to `ErrorResponse`)
528
+ // and then on the RESOLVED schema via canonicalJson (catches distinct $refs that happen to
529
+ // resolve identically). Returns exactly one discriminated outcome:
530
+ // {outcome:'none'} no matching status, or none had a usable schema
531
+ // {outcome:'skipped-media-type'} a matching status had `content` but no application/json
532
+ // {outcome:'unresolved', reason} at least one matching schema failed inlineSchema()
533
+ // {outcome:'resolved', schema, sources} sources = how many distinct resolved shapes were unioned
534
+ // Fails closed on the FIRST unresolvable schema rather than unioning the rest -- a partial anyOf
535
+ // would describe a NARROWER set of shapes than the document actually allows, i.e. it would reject
536
+ // a real response the API produces. That is the same "never emit something that contradicts
537
+ // reality" rule A2 applied in the opposite direction (never emit a schema weaker than the real
538
+ // DTO) -- here the risk runs the other way, so the fix runs the other way too, but the underlying
539
+ // principle (don't guess, don't approximate) is identical.
540
+ //
541
+ // A6 (D-openapi-export): `includeDefault` folds OpenAPI's `default` response into this direction.
542
+ // It is passed ONLY for the error side, and that asymmetry is the whole point: `default` means
543
+ // "every status not otherwise listed", so its body may well be an error shape. Folding it into
544
+ // SUCCESS would let an error shape satisfy success validation -- a real false negative. Folding it
545
+ // into ERROR can only ever WIDEN the error union, which A3's `anyOf` design already tolerates by
546
+ // construction ("matches at least one documented shape"), and never narrows what a real response is
547
+ // permitted to be. A document whose `default` genuinely describes a success body therefore loses
548
+ // nothing it had before (nothing read `default` at all until now); one whose `default` describes an
549
+ // error -- the overwhelmingly common case, and the only case `bskel contract export` itself emits --
550
+ // gains a real error schema it previously dropped silently.
551
+ function projectResponseSchemas(responses, statusRe, componentSchemas, { includeDefault = false } = {}) {
552
+ if (!responses) return { outcome: 'none' };
553
+ const statusKeys = Object.keys(responses);
554
+ if (statusKeys.length > MAX_RESPONSES_PER_OPERATION) {
555
+ return { outcome: 'unresolved', reason: 'too-many-responses' };
556
+ }
557
+
558
+ const rawNodesByKey = new Map();
559
+ let sawContentWithoutJson = false;
560
+ for (const status of statusKeys) {
561
+ if (!statusRe.test(status) && !(includeDefault && status === DEFAULT_STATUS_KEY)) continue;
562
+ const resp = responses[status];
563
+ if (typeof resp !== 'object' || resp === null || Array.isArray(resp)) continue;
564
+ const content = resp.content;
565
+ if (typeof content !== 'object' || content === null || Array.isArray(content)) continue; // no content at all -- nothing to project for this status, not a failure
566
+ if (!Object.hasOwn(content, JSON_MEDIA_TYPE)) { sawContentWithoutJson = true; continue; }
567
+ const mediaEntry = content[JSON_MEDIA_TYPE];
568
+ const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
569
+ if (!schemaNode || typeof schemaNode !== 'object' || Array.isArray(schemaNode)) continue;
570
+ const rawKey = JSON.stringify(schemaNode);
571
+ if (!rawNodesByKey.has(rawKey)) rawNodesByKey.set(rawKey, schemaNode);
572
+ }
573
+
574
+ if (rawNodesByKey.size === 0) {
575
+ return { outcome: sawContentWithoutJson ? 'skipped-media-type' : 'none' };
576
+ }
577
+
578
+ const resolvedByCanonical = new Map();
579
+ for (const node of rawNodesByKey.values()) {
580
+ const resolved = inlineSchema(node, componentSchemas);
581
+ if (!resolved.ok) return { outcome: 'unresolved', reason: resolved.reason };
582
+ const canonicalKey = canonicalJson(resolved.schema);
583
+ if (!resolvedByCanonical.has(canonicalKey)) resolvedByCanonical.set(canonicalKey, resolved.schema);
584
+ }
585
+
586
+ const distinct = [...resolvedByCanonical.values()];
587
+ if (distinct.length === 1) return { outcome: 'resolved', schema: distinct[0], sources: 1 };
588
+ // A3: anyOf, NEVER oneOf. A2 established that projected schemas never carry
589
+ // additionalProperties:false (Team-IZ-Backend has no Jackson customization -- see
590
+ // D-openapi-request-schema), so two documented response shapes routinely overlap (a minimal
591
+ // 202/204 body is often a strict subset of the 200 body's fields). oneOf requires EXACTLY one
592
+ // branch to match and would reject a real, valid response matching more than one branch --
593
+ // verified directly against the installed Ajv2020: a payload matching two overlapping
594
+ // branches is rejected by oneOf and accepted by anyOf. anyOf states precisely what's true
595
+ // given the envelope carries no status code: "matches at least one documented shape."
596
+ return { outcome: 'resolved', schema: { anyOf: distinct }, sources: distinct.length };
597
+ }
598
+
599
+ // A3: applies both response (2xx) and error (4xx/5xx) projection to a `matched`/`adopted` result,
600
+ // mirroring applyRequestBodySchema's placement/gating exactly (same two call sites, same
601
+ // schemaProjection.enabled guard). Fields are set ONLY when resolved -- omitted, not null/false,
602
+ // so an operation with nothing to project stays byte-identical to pre-A3 output (same discipline
603
+ // as A2's requestBodySchema).
604
+ function applyResponseSchemas(result, docEntry, componentSchemas, stats) {
605
+ applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, SUCCESS_STATUS_RE, componentSchemas), stats, 'response');
606
+ // A6: `default` contributes to the ERROR side only -- see projectResponseSchemas' own comment.
607
+ applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, ERROR_STATUS_RE, componentSchemas, { includeDefault: true }), stats, 'error');
608
+ }
609
+
610
+ function applyProjectionOutcome(result, projected, stats, kind) {
611
+ const fieldSchema = kind === 'response' ? 'responseSchema' : 'errorSchema';
612
+ const fieldSources = kind === 'response' ? 'responseSchemaSources' : 'errorSchemaSources';
613
+ const fieldReason = kind === 'response' ? 'responseSchemaUnresolvedReason' : 'errorSchemaUnresolvedReason';
614
+ const counterPrefix = kind === 'response' ? 'response_schema_' : 'error_schema_';
615
+ if (projected.outcome === 'resolved') {
616
+ result[fieldSchema] = projected.schema;
617
+ result[fieldSources] = projected.sources;
618
+ stats[`${counterPrefix}resolved`]++;
619
+ } else if (projected.outcome === 'unresolved') {
620
+ result[fieldReason] = projected.reason;
621
+ stats[`${counterPrefix}unresolved`]++;
622
+ } else if (projected.outcome === 'skipped-media-type') {
623
+ stats[`${counterPrefix}skipped_media_type`]++;
624
+ } else {
625
+ stats[`${counterPrefix}none`]++;
626
+ }
627
+ }
628
+
629
+ // The core reconciliation, pure (no I/O). `module` is a scanReport related_modules entry (as
630
+ // selected by contracts/emit.mjs's selectModule -- caller's responsibility to pass the SAME
631
+ // selection buildContract() will use, so endpointKey(ci,ei) lines up). `pathPrefix`, if given
632
+ // (from --path-prefix), overrides inference entirely but the anchor pass still runs so its
633
+ // deltas are recorded for audit in the snapshot.
634
+ export function reconcileModule({ index, module, pathPrefix = null }) {
635
+ const anchorDeltas = [];
636
+ for (const controller of module.controllers) {
637
+ for (const ep of controller.endpoints) {
638
+ if (!ep.operationId) continue;
639
+ const docEntry = index.byOperationId.get(ep.operationId);
640
+ if (!docEntry || docEntry.verb !== ep.verb) continue; // verb mismatch => not a safe anchor, surfaces as drift below
641
+ const delta = computeDelta(ep.path, docEntry.path);
642
+ if (delta !== null) anchorDeltas.push(delta);
643
+ }
644
+ }
645
+ const inferred = inferPathPrefix(anchorDeltas);
646
+ const prefix = pathPrefix != null
647
+ ? { value: pathPrefix, origin: 'flag', deltas: inferred.deltas, conflicting: [] }
648
+ : inferred;
649
+
650
+ const byEndpoint = new Map();
651
+ const stats = {
652
+ matched: 0, adopted: 0, drift: 0, missing: 0, ambiguous: 0, unresolved: 0,
653
+ // A2: initialized here (not left implicit) so they're always present in evidence.openapi /
654
+ // the snapshot, even for a document with no request bodies at all -- a stable shape for
655
+ // downstream consumers that already spread ...stats (bin/bskel.mjs, snapshotFromReconciliation).
656
+ // Bare `schema_*` means request-body specifically (A2) -- kept as-is, not renamed to
657
+ // `request_schema_*`, for evidence-key stability (test/contract-cli.test.mjs and real gate
658
+ // records already assert this exact name).
659
+ schema_resolved: 0, schema_unresolved: 0, schema_none: 0, schema_skipped_media_type: 0,
660
+ // A3: response (2xx) / error (4xx/5xx) counters, same stable-shape reasoning.
661
+ response_schema_resolved: 0, response_schema_unresolved: 0, response_schema_none: 0, response_schema_skipped_media_type: 0,
662
+ error_schema_resolved: 0, error_schema_unresolved: 0, error_schema_none: 0, error_schema_skipped_media_type: 0,
663
+ };
664
+ // A2: an OpenAPI 3.0 document's `exclusiveMinimum`/`nullable` mean something different under
665
+ // JSON Schema 2020-12 (the dialect Ajv2020 speaks) -- rather than silently misinterpreting
666
+ // those, schema projection is disabled for the WHOLE document, once, here -- not per-operation
667
+ // (which would flood every contract with N warnings for one root cause). Path/verb
668
+ // reconciliation above is dialect-independent and stays fully active either way.
669
+ const schemaProjection = index.schemaDialectSupported
670
+ ? { enabled: true, reason: null }
671
+ : { enabled: false, reason: 'unsupported-openapi-version' };
672
+
673
+ for (const [ci, controller] of module.controllers.entries()) {
674
+ for (const [ei, ep] of controller.endpoints.entries()) {
675
+ const key = endpointKey(ci, ei);
676
+ let result;
677
+
678
+ if (ep.operationId) {
679
+ const docEntry = index.byOperationId.get(ep.operationId);
680
+ if (!docEntry) {
681
+ result = { kind: 'missing', scanVerb: ep.verb, scanPath: ep.path };
682
+ stats.missing++;
683
+ } else if (docEntry.verb !== ep.verb) {
684
+ result = {
685
+ kind: 'drift', reason: 'verb',
686
+ openapi: { verb: docEntry.verb, path: docEntry.path },
687
+ scanVerb: ep.verb, scanPath: ep.path,
688
+ };
689
+ stats.drift++;
690
+ } else {
691
+ const delta = computeDelta(ep.path, docEntry.path);
692
+ if (docEntry.path === ep.path || delta !== null) {
693
+ result = {
694
+ kind: 'matched', operationId: ep.operationId, verb: docEntry.verb, path: docEntry.path,
695
+ scanVerb: ep.verb, scanPath: ep.path,
696
+ };
697
+ stats.matched++;
698
+ // A2/A3: matched/adopted ONLY -- schema enrichment never applies to drift/missing/
699
+ // ambiguous/unresolved, same "don't guess" rule A1 established for path/verb.
700
+ if (schemaProjection.enabled) {
701
+ applyRequestBodySchema(result, docEntry, index.componentSchemas, stats);
702
+ applyResponseSchemas(result, docEntry, index.componentSchemas, stats);
703
+ }
704
+ } else {
705
+ result = {
706
+ kind: 'drift', reason: 'path',
707
+ openapi: { verb: docEntry.verb, path: docEntry.path },
708
+ scanVerb: ep.verb, scanPath: ep.path,
709
+ };
710
+ stats.drift++;
711
+ }
712
+ }
713
+ } else if (prefix.value == null) {
714
+ result = { kind: 'unresolved', reason: 'prefix-inconclusive', scanVerb: ep.verb, scanPath: ep.path };
715
+ stats.unresolved++;
716
+ } else {
717
+ const candidates = prefix.value === '' ? [ep.path] : [...new Set([prefix.value + ep.path, ep.path])];
718
+ const hits = candidates.flatMap((c) => index.byRoute.get(`${ep.verb} ${normalizeRoute(c)}`) ?? []);
719
+ if (hits.length === 0) {
720
+ result = { kind: 'unresolved', reason: 'no-candidate', scanVerb: ep.verb, scanPath: ep.path };
721
+ stats.unresolved++;
722
+ } else if (hits.length === 1 && hits[0].operationId) {
723
+ result = {
724
+ kind: 'adopted', operationId: hits[0].operationId, verb: hits[0].verb, path: hits[0].path,
725
+ scanVerb: ep.verb, scanPath: ep.path,
726
+ };
727
+ stats.adopted++;
728
+ if (schemaProjection.enabled) {
729
+ applyRequestBodySchema(result, hits[0], index.componentSchemas, stats);
730
+ applyResponseSchemas(result, hits[0], index.componentSchemas, stats);
731
+ }
732
+ } else if (hits.length === 1) {
733
+ // A single route match, but the document itself never gave that operation an
734
+ // operationId -- nothing to route by, so this can't become an addressable
735
+ // operation regardless. Distinct reason from "no-candidate" for diagnosability.
736
+ result = { kind: 'unresolved', reason: 'document-missing-operation-id', scanVerb: ep.verb, scanPath: ep.path };
737
+ stats.unresolved++;
738
+ } else {
739
+ result = {
740
+ kind: 'ambiguous',
741
+ candidates: hits.map((h) => ({ verb: h.verb, path: h.path, operationId: h.operationId })),
742
+ scanVerb: ep.verb, scanPath: ep.path,
743
+ };
744
+ stats.ambiguous++;
745
+ }
746
+ }
747
+
748
+ byEndpoint.set(key, result);
749
+ }
750
+ }
751
+
752
+ return { byEndpoint, prefix, stats, schemaProjection };
753
+ }
754
+
755
+ // Convenience entry point: load + index + reconcile in one call, propagating the first failure.
756
+ // This is what bin/bskel.mjs's cmdContractEmit calls.
757
+ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
758
+ if (pathPrefix != null && !PATH_PREFIX_RE.test(pathPrefix)) {
759
+ return { ok: false, error: `--path-prefix "${pathPrefix}" is not a valid path prefix (expected e.g. "/api/v0")` };
760
+ }
761
+ const loaded = loadOpenApiDocument(filePath);
762
+ if (!loaded.ok) return loaded;
763
+ // A6 (D-openapi-export): the structural half of the hazard `bskel contract export` creates.
764
+ // Piping an export straight back into `contract emit --openapi-file` would make the contract
765
+ // "confirm" itself -- stats.matched would read N/N and A1's entire point (an INDEPENDENT
766
+ // oracle) would evaporate silently. Worse than a no-op: a `drift` operation still sits in
767
+ // `contract.operations` at the scan's own uncorrected verb/path, so an export puts it in the
768
+ // document at exactly that verb/path, computeDelta() then agrees, and a recorded
769
+ // CONTRACT_OPENAPI_DRIFT (ERROR) reclassifies as `matched` and disappears. Same for `missing`;
770
+ // an `ambiguous` endpoint is worse still, since it is never in the contract at all and would
771
+ // come back as a plain CONTRACT_UNMATCHED_ENDPOINT -- a DIFFERENT code, which breaks any
772
+ // waiver already recorded against it ({code, subject} is the waiver key, see
773
+ // D-contract-completeness). Refused here rather than defended against downstream, and through
774
+ // the existing BAD_ARGS/exit-14 path cmdContractEmit already uses for a malformed
775
+ // --openapi-file -- no new exit code, no new machinery.
776
+ if (hasBskelExportMarker(loaded.doc)) {
777
+ return { ok: false, error: `"${filePath}" was generated by \`bskel contract export\` -- reconciling a contract against its own export defeats the point of an independent oracle (every operation would confirm itself, and an already-recorded drift/missing ERROR would silently reclassify as matched). Point --openapi-file at a document the application itself produces. If you genuinely mean to reconcile against a hand-edited copy, remove the "${BSKEL_GENERATED_EXTENSION}" extension from its \`info\` object first.` };
778
+ }
779
+ const indexed = indexOpenApiDocument(loaded.doc);
780
+ if (!indexed.ok) return indexed;
781
+ const recon = reconcileModule({ index: indexed, module, pathPrefix });
782
+ return {
783
+ ok: true,
784
+ document: {
785
+ hash: loaded.hash,
786
+ bytes: loaded.bytes,
787
+ path_count: indexed.stats.path_count,
788
+ operation_count: indexed.stats.operation_count,
789
+ skipped_path_refs: indexed.stats.skipped_path_refs,
790
+ rejected_operation_ids: indexed.stats.rejected_operation_ids,
791
+ component_schema_count: indexed.stats.component_schema_count,
792
+ rejected_component_schemas: indexed.stats.rejected_component_schemas,
793
+ openapi_version: indexed.openapiVersion,
794
+ servers: indexed.servers,
795
+ },
796
+ byEndpoint: recon.byEndpoint,
797
+ prefix: recon.prefix,
798
+ stats: recon.stats,
799
+ schemaProjection: recon.schemaProjection,
800
+ };
801
+ }
802
+
803
+ // `sourceFile`: {file, outsideRepo} precomputed by the caller (bin/bskel.mjs knows the repo
804
+ // root; this module deliberately doesn't) -- keeps machine-specific absolute paths out of a
805
+ // committed artifact when the OpenAPI file lives outside the repo.
806
+ export function snapshotFromReconciliation(reconciliation, { featureId, sourceFile }) {
807
+ const operations = {};
808
+ for (const result of reconciliation.byEndpoint.values()) {
809
+ if (result.kind === 'matched' || result.kind === 'adopted') {
810
+ operations[result.operationId] = {
811
+ verb: result.verb,
812
+ path: result.path,
813
+ via: result.kind === 'adopted' ? 'openapi' : 'operationId',
814
+ scan_path: result.scanPath,
815
+ // A2: records the DECISION, not the schema itself -- the schema payload already lives
816
+ // in the contract file, already covered by the contract gate's contract_hash. This is
817
+ // an audit trail of what reconciliation concluded, not a second copy of the data.
818
+ request_body_schema: result.requestBodySchema
819
+ ? 'resolved'
820
+ : result.schemaUnresolvedReason
821
+ ? `unresolved:${result.schemaUnresolvedReason}`
822
+ : 'none',
823
+ // A3: same decision-only audit trail, for the response/error projections.
824
+ response_schema: result.responseSchema
825
+ ? (result.responseSchemaSources > 1 ? `resolved:union:${result.responseSchemaSources}` : 'resolved')
826
+ : result.responseSchemaUnresolvedReason
827
+ ? `unresolved:${result.responseSchemaUnresolvedReason}`
828
+ : 'none',
829
+ error_schema: result.errorSchema
830
+ ? (result.errorSchemaSources > 1 ? `resolved:union:${result.errorSchemaSources}` : 'resolved')
831
+ : result.errorSchemaUnresolvedReason
832
+ ? `unresolved:${result.errorSchemaUnresolvedReason}`
833
+ : 'none',
834
+ };
835
+ }
836
+ }
837
+ return {
838
+ schema: 'sbf.openapi-snapshot/1',
839
+ feature_id: featureId,
840
+ source: {
841
+ file: sourceFile.file,
842
+ outside_repo: sourceFile.outsideRepo,
843
+ sha256: reconciliation.document.hash,
844
+ bytes: reconciliation.document.bytes,
845
+ },
846
+ document: {
847
+ path_count: reconciliation.document.path_count,
848
+ operation_count: reconciliation.document.operation_count,
849
+ skipped_path_refs: reconciliation.document.skipped_path_refs,
850
+ component_schema_count: reconciliation.document.component_schema_count,
851
+ rejected_component_schemas: reconciliation.document.rejected_component_schemas,
852
+ openapi_version: reconciliation.document.openapi_version,
853
+ servers: reconciliation.document.servers,
854
+ },
855
+ path_prefix: reconciliation.prefix,
856
+ schema_projection: reconciliation.schemaProjection,
857
+ operations,
858
+ stats: reconciliation.stats,
859
+ };
860
+ }
861
+
862
+ // Repo-relative-or-basename-only descriptor for snapshotFromReconciliation's sourceFile param --
863
+ // exported so bin/bskel.mjs doesn't need to duplicate the path.relative/outside-repo logic.
864
+ export function describeSourceFile(repoRoot, filePath) {
865
+ const resolved = path.resolve(filePath);
866
+ const rel = path.relative(repoRoot, resolved);
867
+ const outsideRepo = rel.startsWith('..') || path.isAbsolute(rel);
868
+ return { file: outsideRepo ? path.basename(resolved) : rel, outsideRepo };
869
+ }