backend-skeleton 1.0.0-beta.2 → 1.0.0-beta.4

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 (38) hide show
  1. package/README.md +12 -2
  2. package/bin/bskel.mjs +260 -11
  3. package/contracts/completeness.mjs +27 -2
  4. package/contracts/emit.mjs +68 -9
  5. package/contracts/export.mjs +94 -25
  6. package/contracts/openapi.mjs +307 -40
  7. package/handles/audit.mjs +83 -0
  8. package/handles/codec.mjs +11 -5
  9. package/handles/providers/java-spring/emit.mjs +9 -2
  10. package/handles/providers/java-spring/plan.mjs +20 -0
  11. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
  12. package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
  13. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
  14. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
  15. package/handles/providers/java-spring.mjs +2 -2
  16. package/handles/providers/python-fastapi/emit.mjs +5 -1
  17. package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
  18. package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
  19. package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
  20. package/lib/cli.mjs +51 -3
  21. package/lib/handles-manifest.mjs +10 -4
  22. package/lib/repo.mjs +56 -0
  23. package/package.json +1 -1
  24. package/scanners/adapters/_express-shared.mjs +17 -12
  25. package/scanners/adapters/generic-grep.mjs +5 -1
  26. package/scanners/adapters/java-spring.mjs +60 -14
  27. package/scanners/adapters/javascript-express.mjs +5 -1
  28. package/scanners/adapters/python-fastapi.mjs +17 -14
  29. package/scanners/adapters/typescript-express.mjs +6 -1
  30. package/scanners/registry.mjs +5 -2
  31. package/scanners/text-util.mjs +25 -0
  32. package/schemas/adapter.schema.json +6 -2
  33. package/schemas/contract-resolution.schema.json +6 -1
  34. package/schemas/feature-contract.schema.json +10 -1
  35. package/schemas/handles-plan.schema.json +1 -0
  36. package/stack/bootstrap/db-up.sh +52 -0
  37. package/stack/bootstrap/docker-compose.postgres.yml +18 -0
  38. package/stack/catalog/postgres-dev-db.yml +55 -0
@@ -53,6 +53,20 @@ const MAX_SECURITY_SCHEMES = 64;
53
53
  // 9). MAX_REQUEST_MEDIA_TYPES is new: real max observed on one operation's requestBody.content is
54
54
  // 1 (always either application/json alone or multipart/form-data alone in the oracle).
55
55
  const MAX_REQUEST_MEDIA_TYPES = 16;
56
+ // A10: operation-level `description`, same "generous multiple of the real observed max" style as
57
+ // every cap above -- real max observed on the Team-IZ-Backend oracle (148 operations, 146 carry a
58
+ // non-empty description) is 9,083 (`.length`, UTF-16 code units, same measure MAX_PATTERN_LENGTH
59
+ // already uses -- NOT a UTF-8 byte count, which runs higher for this oracle's real multi-byte
60
+ // Korean text: 13,758 bytes for the same longest description).
61
+ const MAX_DESCRIPTION_LENGTH = 40000;
62
+ // A11: a FIELD-level `example` value, inside a schema this whole file resolves -- unlike
63
+ // MAX_DESCRIPTION_LENGTH's single operation-level string, `example` is an arbitrary JSON value
64
+ // (string/number/array/object all occur for real, see D-openapi-field-docs), so the cap applies to
65
+ // its serialized (`JSON.stringify(value).length`) size, not `.length` directly. Real max observed
66
+ // on the oracle: 70. A generously round bound, not a tight multiple, since a legitimately useful
67
+ // example (e.g. a full sample response object) could reasonably run longer than any single real
68
+ // value happened to here.
69
+ const MAX_EXAMPLE_LENGTH = 2000;
56
70
  // A6 (D-openapi-export): widened from `/^2[0-9]{2}$/` and `/^[45][0-9]{2}$/` to also accept
57
71
  // OpenAPI's own RANGE keys. These are ordinary in real hand-written documents and legal per the
58
72
  // official 3.1 meta-schema, whose `responses` object accepts exactly `^[1-5](?:[0-9]{2}|XX)$` plus
@@ -130,12 +144,16 @@ export const PER_STATUS_NO_DESCRIPTION_STANDIN = 'The source document documents
130
144
 
131
145
  // inlineSchema()'s keyword policy: RECURSED keywords are walked into; ASSERTION keywords are
132
146
  // copied verbatim (their values are scalars/arrays of scalars, not schema nodes -- nothing to
133
- // recurse); DROPPED keywords carry no validation meaning and are silently discarded (their
134
- // absence changes nothing about what a schema accepts); anything else fails that schema closed.
135
- // The FORMAT set is checked separately (see inlineSchema's format handling) since `uuid` gets
136
- // rewritten rather than either copied or dropped. A missing-and-therefore-fail-closed keyword is
137
- // deliberate: silently dropping an assertion (e.g. an unrecognized `pattern`-like keyword) would
138
- // emit a schema WEAKER than the real one, which is worse than emitting no schema at all -- see
147
+ // recurse); DOCUMENTATION keywords (A11) are copied verbatim ONLY when opted in
148
+ // (`includeFieldDocs`), dropped otherwise -- unlike an ASSERTION keyword, dropping one never
149
+ // changes what a schema VALIDATES, only how well-documented it is, so there is no fail-closed
150
+ // concern either way; DROPPED keywords carry no validation meaning AND have zero real occurrences
151
+ // on the oracle (measured, not assumed -- see D-openapi-field-docs), so they are unconditionally
152
+ // discarded regardless of any flag; anything else fails that schema closed. The FORMAT set is
153
+ // checked separately (see inlineSchema's format handling) since `uuid` gets rewritten rather than
154
+ // either copied or dropped. A missing-and-therefore-fail-closed keyword is deliberate: silently
155
+ // dropping an assertion (e.g. an unrecognized `pattern`-like keyword) would emit a schema WEAKER
156
+ // than the real one, which is worse than emitting no schema at all -- see
139
157
  // D-openapi-request-schema in DECISIONS.md.
140
158
  const RECURSED_KEYWORDS = Object.freeze(new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf']));
141
159
  // A7: `default` added -- annotation-only per 2020-12 (Ajv runs with useDefaults off here, so it's
@@ -151,7 +169,107 @@ const COPIED_KEYWORDS = Object.freeze(new Set([
151
169
  'minItems', 'maxItems', 'uniqueItems',
152
170
  'minProperties', 'maxProperties',
153
171
  ]));
154
- const DROPPED_KEYWORDS = Object.freeze(new Set(['description', 'title', 'example', 'examples', 'externalDocs', 'xml', 'deprecated']));
172
+ // A11 (D-openapi-field-docs): `description`/`example` measured real and heavily used at the FIELD
173
+ // level (3,982 / 2,077 occurrences across the oracle's request/response/parameter schemas,
174
+ // 520,527 / 32,708 real bytes) -- moved out of DROPPED_KEYWORDS into their own conditionally-
175
+ // copied set. `title`/`examples`(plural)/`externalDocs`/`xml`/`deprecated` stay unconditionally
176
+ // dropped: 0 real occurrences for every one of them (measured, not assumed), so building any
177
+ // copy path for them would violate this project's own "don't build for zero real cases"
178
+ // discipline -- named here, not built, a permanent gap like A8's `non-json-response-schemas`/
179
+ // `response-headers`.
180
+ const DOCUMENTATION_KEYWORDS = Object.freeze(new Set(['description', 'example']));
181
+ const DROPPED_KEYWORDS = Object.freeze(new Set(['title', 'examples', 'externalDocs', 'xml', 'deprecated']));
182
+
183
+ // D-unsupported-annotation-warning: 0 real occurrences on the ONE oracle this whole module's
184
+ // caps/keyword sets were measured against does not mean 0 occurrences everywhere -- a genuinely
185
+ // different real-world document could use any of DROPPED_KEYWORDS, and silently dropping them
186
+ // with no signal at all is a real honesty gap (see D-contract-history/D-gate-export's own backlog
187
+ // for the broader "self-identified weaknesses" context this closes one instance of).
188
+ //
189
+ // Deliberately walks ONLY genuine Schema Object structure (via RECURSED_KEYWORDS, the exact same
190
+ // `properties`/`items`/`additionalProperties`/`oneOf`/`anyOf`/`allOf` set inlineSchema() itself
191
+ // recurses through) -- NOT a blanket "every key anywhere in the document" scan. That distinction
192
+ // is load-bearing, not cosmetic: several of DROPPED_KEYWORDS' names collide with REAL, unrelated
193
+ // OpenAPI concepts that live outside a Schema Object entirely -- an Operation Object's own
194
+ // `deprecated` (marks a whole ENDPOINT deprecated) and a Parameter Object's own `deprecated`, both
195
+ // legitimate 3.1 fields with nothing to do with inlineSchema()'s keyword handling. A blanket walk
196
+ // would misreport those as "an unsupported schema keyword found," which is false. This function
197
+ // only descends from confirmed schema roots (a `$ref`-or-inline `schema` under `content.<media>`
198
+ // or `parameters[].schema`, or a named entry in `components.schemas`), the same roots
199
+ // inlineSchema() itself is ever called on.
200
+ //
201
+ // NOT routed through walkSchemaNode()'s fail-closed machinery -- it must never throw on a shape
202
+ // inlineSchema() itself would reject, since its only job is presence detection, not validation.
203
+ // Bounded for free by loadOpenApiDocument()'s own MAX_DOCUMENT_BYTES check upstream.
204
+ function collectUnsupportedAnnotationKeys(node, found, seen) {
205
+ if (node === null || typeof node !== 'object' || Array.isArray(node) || seen.has(node)) return;
206
+ seen.add(node);
207
+ for (const key of Object.keys(node)) {
208
+ if (DROPPED_KEYWORDS.has(key)) found.add(key);
209
+ if (RECURSED_KEYWORDS.has(key)) {
210
+ const value = node[key];
211
+ // `properties` is a field-NAME -> schema map (its VALUES are schemas, its keys are not
212
+ // schema keywords at all) -- a real bug caught live before merge: recursing into the
213
+ // map object itself, instead of `Object.values(value)`, silently walked past every
214
+ // property's actual schema and found nothing beneath `properties` ever. `items`/
215
+ // `additionalProperties` ARE schemas directly; `oneOf`/`anyOf`/`allOf` are arrays of
216
+ // schemas, already handled by the branch below.
217
+ if (key === 'properties' && value && typeof value === 'object' && !Array.isArray(value)) {
218
+ for (const propSchema of Object.values(value)) collectUnsupportedAnnotationKeys(propSchema, found, seen);
219
+ } else if (Array.isArray(value)) {
220
+ for (const item of value) collectUnsupportedAnnotationKeys(item, found, seen);
221
+ } else {
222
+ collectUnsupportedAnnotationKeys(value, found, seen);
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ function collectSchemaRootsFromMediaTypes(content, roots) {
229
+ if (!content || typeof content !== 'object' || Array.isArray(content)) return;
230
+ for (const mediaEntry of Object.values(content)) {
231
+ const schema = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
232
+ if (schema && typeof schema === 'object' && !Array.isArray(schema)) roots.push(schema);
233
+ }
234
+ }
235
+
236
+ // Every real schema root a document can offer `inlineSchema()`: named `components.schemas`
237
+ // entries, every operation's requestBody/response content schemas, and every operation's
238
+ // parameter schemas -- deliberately NOT `info`/`servers`/`tags`/security schemes, none of which
239
+ // are ever schema-shaped.
240
+ export function findUnsupportedAnnotations(doc) {
241
+ const roots = [];
242
+ const schemas = doc.components?.schemas;
243
+ if (schemas && typeof schemas === 'object' && !Array.isArray(schemas)) roots.push(...Object.values(schemas));
244
+
245
+ const paths = doc.paths;
246
+ if (paths && typeof paths === 'object' && !Array.isArray(paths)) {
247
+ for (const item of Object.values(paths)) {
248
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
249
+ for (const [verbOrKey, operation] of Object.entries(item)) {
250
+ if (!HTTP_METHODS.has(verbOrKey) || !operation || typeof operation !== 'object' || Array.isArray(operation)) continue;
251
+ collectSchemaRootsFromMediaTypes(operation.requestBody?.content, roots);
252
+ if (operation.responses && typeof operation.responses === 'object' && !Array.isArray(operation.responses)) {
253
+ for (const resp of Object.values(operation.responses)) {
254
+ collectSchemaRootsFromMediaTypes(resp?.content, roots);
255
+ }
256
+ }
257
+ if (Array.isArray(operation.parameters)) {
258
+ for (const p of operation.parameters) {
259
+ if (p && typeof p === 'object' && !Array.isArray(p) && p.schema && typeof p.schema === 'object' && !Array.isArray(p.schema)) {
260
+ roots.push(p.schema);
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ const found = new Set();
269
+ const seen = new Set();
270
+ for (const root of roots) collectUnsupportedAnnotationKeys(root, found, seen);
271
+ return [...found].sort();
272
+ }
155
273
  // Real Team-IZ-Backend format-value histogram (request-body-reachable schemas only): uuid(20),
156
274
  // int32(10), email(7), date(10), date-time(3), int64(2). `uuid` is handled separately (rewritten
157
275
  // to BARE_UUID_PATTERN, see inlineSchema) -- not in this set, since it never survives as `format`.
@@ -374,7 +492,11 @@ export function indexOpenApiDocument(doc) {
374
492
  const security = Array.isArray(operation.security) ? operation.security : null;
375
493
  const summary = typeof operation.summary === 'string' ? operation.summary : null;
376
494
  const tags = Array.isArray(operation.tags) ? operation.tags : null;
377
- const entry = { verb, path: routeKey, operationId, requestBody, responses, parameters, security, summary, tags };
495
+ // A10: same "raw, no size cap at index time" reasoning as summary above -- MAX_DESCRIPTION_
496
+ // LENGTH is enforced only at applyDescription() (the point of actually copying it into the
497
+ // contract), matching where every other length/count cap in this file is enforced.
498
+ const description = typeof operation.description === 'string' ? operation.description : null;
499
+ const entry = { verb, path: routeKey, operationId, requestBody, responses, parameters, security, summary, tags, description };
378
500
 
379
501
  const routeMatchKey = `${verb} ${normalizedRoute}`;
380
502
  const existingRoute = byRoute.get(routeMatchKey);
@@ -438,6 +560,9 @@ export function inlineSchema(node, componentSchemas, opts = {}) {
438
560
  maxDepth: opts.maxDepth ?? MAX_SCHEMA_DEPTH,
439
561
  maxNodes: opts.maxNodes ?? MAX_SCHEMA_NODES,
440
562
  maxPatternLength: opts.maxPatternLength ?? MAX_PATTERN_LENGTH,
563
+ // A11: opt-in only (default false, matching every prior call site's existing behavior
564
+ // byte-for-byte when the caller doesn't pass it) -- see D-openapi-field-docs.
565
+ includeFieldDocs: opts.includeFieldDocs ?? false,
441
566
  };
442
567
  const state = { nodes: 0 };
443
568
  try {
@@ -464,11 +589,14 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
464
589
  // module doesn't attempt to MERGE $ref with a sibling assertion, with ONE exception (A7):
465
590
  // `default` is a real, human-authored override worth carrying through (the exact real shape
466
591
  // `{"$ref": ".../ProjectListSort", "default": "READINESS"}` -- a $ref-typed parameter
467
- // schema with its own default value, 9 real occurrences). A DROPPED_KEYWORDS sibling (e.g. a
468
- // documentation-only `description`) is harmless and ignored; anything else would need merge
469
- // semantics this vertical slice doesn't implement, so it fails closed.
592
+ // schema with its own default value, 9 real occurrences). A DROPPED_KEYWORDS or
593
+ // DOCUMENTATION_KEYWORDS sibling (e.g. a documentation-only `description`) is harmless and
594
+ // ignored -- NOT merged onto the resolved schema even when includeFieldDocs is on (A11: 0
595
+ // real occurrences of a $ref carrying a sibling description/example, measured directly, so
596
+ // there is no real case to build merge semantics for, unlike `default`'s 9); anything else
597
+ // would need merge semantics this vertical slice doesn't implement, so it fails closed.
470
598
  const siblingKeys = Object.keys(node).filter((k) => k !== '$ref');
471
- if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k) && k !== 'default')) fail('ref-with-siblings');
599
+ if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k) && !DOCUMENTATION_KEYWORDS.has(k) && k !== 'default')) fail('ref-with-siblings');
472
600
  const ref = node['$ref'];
473
601
  if (typeof ref !== 'string' || !ref.startsWith(SCHEMA_REF_PREFIX)) fail('unsupported-ref');
474
602
  const name = ref.slice(SCHEMA_REF_PREFIX.length);
@@ -516,6 +644,32 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
516
644
  if (key === '$ref' || key === 'format') continue; // format already handled above
517
645
  if (DROPPED_KEYWORDS.has(key)) continue;
518
646
 
647
+ // A11: description/example are DROPPED (same as before this item) unless includeFieldDocs is
648
+ // on -- when it is, copy verbatim IF the value passes a defensive length check, else drop
649
+ // (silently, same as if the flag were off for this one field) rather than failing the whole
650
+ // schema closed. Unlike an ASSERTION keyword's fail-closed policy, dropping an annotation
651
+ // NEVER changes what the schema validates -- only how well-documented it is -- so there is no
652
+ // correctness reason to fail the operation over one oversized documentation string, and a
653
+ // per-FIELD warning here would be unusably noisy (a single schema can carry dozens of these,
654
+ // unlike A10's one-per-operation description). Real data never exercises this path (measured
655
+ // max: 3,148 for description, 70 for example -- both far under their caps), so this is a
656
+ // defensive bound against a hostile/malformed --openapi-file, not an expected real branch.
657
+ if (DOCUMENTATION_KEYWORDS.has(key)) {
658
+ if (!limits.includeFieldDocs) continue;
659
+ if (key === 'description') {
660
+ if (typeof node.description === 'string' && node.description.length <= MAX_DESCRIPTION_LENGTH) {
661
+ out.description = node.description;
662
+ }
663
+ } else if (key === 'example') {
664
+ let serialized;
665
+ try { serialized = JSON.stringify(node.example); } catch { serialized = null; }
666
+ if (serialized !== undefined && serialized !== null && serialized.length <= MAX_EXAMPLE_LENGTH) {
667
+ out.example = node.example;
668
+ }
669
+ }
670
+ continue;
671
+ }
672
+
519
673
  if (key === 'pattern') {
520
674
  // Two patterns can't be expressed without allOf, which this slice doesn't attempt to
521
675
  // synthesize -- a node with BOTH format:'uuid' and an explicit pattern fails closed
@@ -597,7 +751,7 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
597
751
  // let alone body shape. `docEntry` is the OpenAPI-side entry (from byOperationId or byRoute) whose
598
752
  // `.requestBody` indexOpenApiDocument() retained. Never treats "nothing to project" as a failure --
599
753
  // only an actual unresolvable schema increments schema_unresolved / sets schemaUnresolvedReason.
600
- function applyRequestBodySchema(result, docEntry, componentSchemas, stats) {
754
+ function applyRequestBodySchema(result, docEntry, componentSchemas, stats, includeFieldDocs) {
601
755
  const requestBody = docEntry.requestBody;
602
756
  if (!requestBody || Object.hasOwn(requestBody, '$ref')) {
603
757
  stats.schema_none++;
@@ -614,7 +768,7 @@ function applyRequestBodySchema(result, docEntry, componentSchemas, stats) {
614
768
  stats.schema_none++;
615
769
  return;
616
770
  }
617
- const resolved = inlineSchema(schemaNode, componentSchemas);
771
+ const resolved = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
618
772
  if (resolved.ok) {
619
773
  result.requestBodySchema = resolved.schema;
620
774
  result.requestBodyRequired = requestBody.required === true;
@@ -666,7 +820,7 @@ function canonicalJson(value) {
666
820
  // nothing it had before (nothing read `default` at all until now); one whose `default` describes an
667
821
  // error -- the overwhelmingly common case, and the only case `bskel contract export` itself emits --
668
822
  // gains a real error schema it previously dropped silently.
669
- function projectResponseSchemas(responses, statusRe, componentSchemas, { includeDefault = false } = {}) {
823
+ function projectResponseSchemas(responses, statusRe, componentSchemas, { includeDefault = false, includeFieldDocs = false } = {}) {
670
824
  if (!responses) return { outcome: 'none' };
671
825
  const statusKeys = Object.keys(responses);
672
826
  if (statusKeys.length > MAX_RESPONSES_PER_OPERATION) {
@@ -693,9 +847,14 @@ function projectResponseSchemas(responses, statusRe, componentSchemas, { include
693
847
  return { outcome: sawContentWithoutJson ? 'skipped-media-type' : 'none' };
694
848
  }
695
849
 
850
+ // A11: includeFieldDocs can make two previously-identical-looking resolved schemas turn out
851
+ // distinct (different field-level description/example), which correctly increases `sources` --
852
+ // see D-openapi-field-docs for why this is a self-consistent consequence of being more precise
853
+ // about equality, not a bug, and the real measurement confirming it never actually happens on
854
+ // the Team-IZ-Backend oracle.
696
855
  const resolvedByCanonical = new Map();
697
856
  for (const node of rawNodesByKey.values()) {
698
- const resolved = inlineSchema(node, componentSchemas);
857
+ const resolved = inlineSchema(node, componentSchemas, { includeFieldDocs });
699
858
  if (!resolved.ok) return { outcome: 'unresolved', reason: resolved.reason };
700
859
  const canonicalKey = canonicalJson(resolved.schema);
701
860
  if (!resolvedByCanonical.has(canonicalKey)) resolvedByCanonical.set(canonicalKey, resolved.schema);
@@ -719,10 +878,10 @@ function projectResponseSchemas(responses, statusRe, componentSchemas, { include
719
878
  // schemaProjection.enabled guard). Fields are set ONLY when resolved -- omitted, not null/false,
720
879
  // so an operation with nothing to project stays byte-identical to pre-A3 output (same discipline
721
880
  // as A2's requestBodySchema).
722
- function applyResponseSchemas(result, docEntry, componentSchemas, stats) {
723
- applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, SUCCESS_STATUS_RE, componentSchemas), stats, 'response');
881
+ function applyResponseSchemas(result, docEntry, componentSchemas, stats, includeFieldDocs) {
882
+ applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, SUCCESS_STATUS_RE, componentSchemas, { includeFieldDocs }), stats, 'response');
724
883
  // A6: `default` contributes to the ERROR side only -- see projectResponseSchemas' own comment.
725
- applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, ERROR_STATUS_RE, componentSchemas, { includeDefault: true }), stats, 'error');
884
+ applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, ERROR_STATUS_RE, componentSchemas, { includeDefault: true, includeFieldDocs }), stats, 'error');
726
885
  }
727
886
 
728
887
  function applyProjectionOutcome(result, projected, stats, kind) {
@@ -811,7 +970,7 @@ function collectNonPathParameters(rawParameters) {
811
970
  // The middle+last cases both add the parameter to sourceParameters (every OTHER field it carries is
812
971
  // real and safe); the last case additionally drives CONTRACT_OPENAPI_PARAMETERS_UNRESOLVED, exactly
813
972
  // the "found but couldn't project" distinction applyRequestBodySchema already draws for a body.
814
- function copyParameter(raw, componentSchemas) {
973
+ function copyParameter(raw, componentSchemas, includeFieldDocs) {
815
974
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: 'not-a-parameter-object' };
816
975
  if (Object.hasOwn(raw, '$ref')) return { ok: false, reason: 'ref-parameter' };
817
976
  if (Object.hasOwn(raw, 'content')) return { ok: false, reason: 'content-parameter' };
@@ -829,7 +988,7 @@ function copyParameter(raw, componentSchemas) {
829
988
  }
830
989
 
831
990
  if (Object.hasOwn(raw, 'schema')) {
832
- const resolved = inlineSchema(raw.schema, componentSchemas);
991
+ const resolved = inlineSchema(raw.schema, componentSchemas, { includeFieldDocs });
833
992
  if (resolved.ok) {
834
993
  parameter.schema = resolved.schema;
835
994
  } else {
@@ -846,7 +1005,7 @@ function copyParameter(raw, componentSchemas) {
846
1005
  // `result`: parametersTotal/parametersCleanCount (reconciliation-internal bookkeeping consumed only
847
1006
  // by snapshotFromReconciliation, never copied into the contract itself), parametersSkippedDialect,
848
1007
  // sourceParameters (omitted when empty), parametersUnresolved (omitted when empty).
849
- function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled) {
1008
+ function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled, includeFieldDocs) {
850
1009
  const candidates = collectNonPathParameters(docEntry.parameters);
851
1010
  if (candidates.length === 0) {
852
1011
  stats.parameters_none++;
@@ -871,7 +1030,7 @@ function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled
871
1030
  }
872
1031
  } else {
873
1032
  for (const raw of candidates) {
874
- const outcome = copyParameter(raw, index.componentSchemas);
1033
+ const outcome = copyParameter(raw, index.componentSchemas, includeFieldDocs);
875
1034
  if (!outcome.ok) {
876
1035
  unresolved.push({ name: safeParamName(raw), in: safeParamIn(raw), reason: outcome.reason });
877
1036
  continue;
@@ -954,6 +1113,34 @@ function applySummaryAndTags(result, docEntry, stats) {
954
1113
  }
955
1114
  }
956
1115
 
1116
+ // A10 (D-openapi-description): the one A7/A8/A9 sibling that is NOT default-on -- gated behind
1117
+ // `includeDescriptions`, opt-in only, because the measured cost (2,442.7 bytes/operation average
1118
+ // across the real oracle, re-confirmed exactly at this item's own implementation) is larger than
1119
+ // every other field this whole passthrough effort copies COMBINED. Copied verbatim, no
1120
+ // transformation (unlike a schema, there is no keyword whitelist to apply to a plain string) --
1121
+ // fails closed only on length, via MAX_DESCRIPTION_LENGTH, the same defensive posture every other
1122
+ // unbounded-size field in this file has (same defensive-cap class as D-security-1/D-security-2) --
1123
+ // protecting the contract file/gate-token hashing cost from a malformed or hostile --openapi-file,
1124
+ // not a normal document.
1125
+ function applyDescription(result, docEntry, stats, includeDescriptions) {
1126
+ if (!includeDescriptions) {
1127
+ stats.description_skipped_flag++;
1128
+ return;
1129
+ }
1130
+ const raw = docEntry.description;
1131
+ if (typeof raw !== 'string' || raw.length === 0) {
1132
+ stats.description_none++;
1133
+ return;
1134
+ }
1135
+ if (raw.length > MAX_DESCRIPTION_LENGTH) {
1136
+ result.descriptionUnresolvedReason = 'too-long';
1137
+ stats.description_unresolved++;
1138
+ return;
1139
+ }
1140
+ result.sourceDescription = raw;
1141
+ stats.description_copied++;
1142
+ }
1143
+
957
1144
  // A8: per-status responses -- additive to (never replacing) the responseSchema/errorSchema union
958
1145
  // projection above; contracts/validate.mjs is untouched by this item, see D-openapi-per-status.
959
1146
  // Gated on schemaProjectionEnabled, same reasoning as applyParameters -- a JSON Schema resolved
@@ -971,7 +1158,7 @@ function applySummaryAndTags(result, docEntry, stats) {
971
1158
  // at it instead of re-resolving. When sources>1 (never observed on real data, but structurally
972
1159
  // possible) or the status sits outside both buckets (a 1xx/3xx key), the status's own schema is
973
1160
  // resolved individually into an inline `schema` instead.
974
- function applyPerStatusResponses(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
1161
+ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
975
1162
  if (!schemaProjectionEnabled) {
976
1163
  result.perStatusResponsesSkippedDialect = true;
977
1164
  stats.per_status_skipped_dialect++;
@@ -1020,7 +1207,7 @@ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, sche
1020
1207
  } else if ((ERROR_STATUS_RE.test(key) || key === DEFAULT_STATUS_KEY) && result.errorSchema && result.errorSchemaSources === 1) {
1021
1208
  entry.schemaFrom = 'error';
1022
1209
  } else {
1023
- const resolved = inlineSchema(schemaNode, componentSchemas);
1210
+ const resolved = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
1024
1211
  // unresolved here just leaves `schema` absent -- `description` alone (if any) stays
1025
1212
  // valid, same "copied without schema" posture copyParameter() already takes.
1026
1213
  if (resolved.ok) entry.schema = resolved.schema;
@@ -1050,7 +1237,7 @@ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, sche
1050
1237
  // schemas/feature-contract.schema.json too). Gated on schemaProjectionEnabled for the same reason
1051
1238
  // as applyPerStatusResponses above -- a media-type schema resolves through the same inlineSchema()
1052
1239
  // path.
1053
- function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
1240
+ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
1054
1241
  if (!schemaProjectionEnabled) {
1055
1242
  result.requestMediaTypesSkippedDialect = true;
1056
1243
  stats.request_media_types_skipped_dialect++;
@@ -1086,7 +1273,7 @@ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schem
1086
1273
  const entry = {};
1087
1274
  const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
1088
1275
  if (schemaNode && typeof schemaNode === 'object' && !Array.isArray(schemaNode)) {
1089
- const resolved = inlineSchema(schemaNode, componentSchemas);
1276
+ const resolved = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
1090
1277
  if (resolved.ok) { entry.schema = resolved.schema; cleanCount++; }
1091
1278
  } else {
1092
1279
  cleanCount++; // no schema declared for this media type at all is not a failure -- same
@@ -1105,18 +1292,73 @@ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schem
1105
1292
  }
1106
1293
  }
1107
1294
 
1295
+ // A9 (D-openapi-path-params): the real fix for A7's own disclosed `batchRequestId` finding --
1296
+ // contracts/emit.mjs's pathParamsSchema() names a path segment by NAME ONLY (`/id$/i` ->
1297
+ // BARE_UUID_PATTERN), a heuristic that is provably wrong for at least one real path parameter
1298
+ // (`batchRequestId`, a plain string despite the "Id" suffix). Unlike A7's own parameters (query/
1299
+ // header/cookie, which stay ADDITIVE alongside pathParams' own separate story), this one REPLACES
1300
+ // the heuristic's guess in place for any segment the source document can answer -- the same
1301
+ // "positive information overrides a guess" principle A8's `hasSourceMediaTypeInfo` already
1302
+ // established, applied here to path-param TYPE instead of request media type.
1303
+ //
1304
+ // Returns a Map (never a plain object -- this is `--openapi-file`-sourced, untrusted data keyed by
1305
+ // parameter NAME, the exact class RESPONSE_STATUS_KEY_RE/MEDIA_TYPE_RE exist to defend against
1306
+ // elsewhere in this file; a Map sidesteps prototype pollution entirely rather than needing a third
1307
+ // whitelist regex) from resolved path-parameter name to its inlined schema, stashed transiently on
1308
+ // `result` for contracts/emit.mjs's pathParamsSchema() call to consult -- never itself persisted to
1309
+ // the contract; only the corrected `pathParams` (and, when at least one segment still falls back to
1310
+ // the heuristic, `pathParamsHeuristic`) are.
1311
+ function applyPathParameterSchemas(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
1312
+ const rawParameters = Array.isArray(docEntry.parameters) ? docEntry.parameters : [];
1313
+ const pathParams = rawParameters.filter((p) => p && typeof p === 'object' && !Array.isArray(p) && p.in === 'path');
1314
+ if (pathParams.length === 0) {
1315
+ stats.path_params_none++;
1316
+ return;
1317
+ }
1318
+ if (!schemaProjectionEnabled) {
1319
+ result.pathParamsSkippedDialect = true;
1320
+ stats.path_params_skipped_dialect++;
1321
+ return;
1322
+ }
1323
+ const resolved = new Map();
1324
+ for (const p of pathParams) {
1325
+ const name = safeParamName(p);
1326
+ if (name === null || !Object.hasOwn(p, 'schema')) continue; // no name, or source declared no schema -- nothing to prefer over the heuristic for this one segment
1327
+ const schemaNode = p.schema;
1328
+ if (!schemaNode || typeof schemaNode !== 'object' || Array.isArray(schemaNode)) continue;
1329
+ const out = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
1330
+ if (out.ok) resolved.set(name, out.schema);
1331
+ }
1332
+ if (resolved.size > 0) {
1333
+ result.pathParamSchemas = resolved;
1334
+ stats.path_params_copied++;
1335
+ } else {
1336
+ stats.path_params_unresolved++;
1337
+ }
1338
+ }
1339
+
1108
1340
  // A7: the single entry point called from reconcileModule()'s two matched/adopted call sites --
1109
1341
  // exactly the placement A2/A3's own helpers already occupy, which IS the refusal mechanism for
1110
1342
  // every other resolution kind (drift/missing/ambiguous/unresolved never reach this function at
1111
1343
  // all, see reconcileModule below).
1112
- function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnabled, referencedSchemeNames) {
1113
- applyParameters(result, docEntry, index, stats, schemaProjectionEnabled);
1344
+ function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnabled, referencedSchemeNames, includeDescriptions) {
1345
+ // A11 (D-openapi-field-docs): includeDescriptions doubles as includeFieldDocs here -- reusing
1346
+ // the existing --descriptions flag rather than adding a second one, since it already governs
1347
+ // "copy source-authored documentation prose" at the operation level (A10); field-level
1348
+ // description/example is the same policy applied one level deeper into the same schemas.
1349
+ applyParameters(result, docEntry, index, stats, schemaProjectionEnabled, includeDescriptions);
1114
1350
  applySecurity(result, docEntry, index, stats, referencedSchemeNames);
1115
1351
  applySummaryAndTags(result, docEntry, stats);
1116
1352
  // A8: same matched/adopted-only placement as the three calls above -- this IS the refusal
1117
1353
  // mechanism for every other resolution kind, extended unchanged for the two new fields.
1118
- applyPerStatusResponses(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled);
1119
- applyRequestMediaTypes(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled);
1354
+ applyPerStatusResponses(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled, includeDescriptions);
1355
+ applyRequestMediaTypes(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled, includeDescriptions);
1356
+ // A9: same placement again, for the path-parameter schema fix.
1357
+ applyPathParameterSchemas(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled, includeDescriptions);
1358
+ // A10: same placement again -- applyDescription() itself decides whether includeDescriptions
1359
+ // gates it off, matching how applyParameters decides its own schemaProjectionEnabled gate
1360
+ // internally rather than being skipped by the caller.
1361
+ applyDescription(result, docEntry, stats, includeDescriptions);
1120
1362
  }
1121
1363
 
1122
1364
  // The core reconciliation, pure (no I/O). `module` is a scanReport related_modules entry (as
@@ -1124,7 +1366,7 @@ function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnable
1124
1366
  // selection buildContract() will use, so endpointKey(ci,ei) lines up). `pathPrefix`, if given
1125
1367
  // (from --path-prefix), overrides inference entirely but the anchor pass still runs so its
1126
1368
  // deltas are recorded for audit in the snapshot.
1127
- export function reconcileModule({ index, module, pathPrefix = null }) {
1369
+ export function reconcileModule({ index, module, pathPrefix = null, includeDescriptions = false }) {
1128
1370
  const anchorDeltas = [];
1129
1371
  for (const controller of module.controllers) {
1130
1372
  for (const ep of controller.endpoints) {
@@ -1164,6 +1406,11 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
1164
1406
  // the A7 counters above.
1165
1407
  per_status_copied: 0, per_status_skipped_unresolved: 0, per_status_none: 0, per_status_skipped_dialect: 0,
1166
1408
  request_media_types_copied: 0, request_media_types_unresolved: 0, request_media_types_none: 0, request_media_types_skipped_dialect: 0,
1409
+ // A9: source-backed path-parameter schema counters, same per-operation-tally shape.
1410
+ path_params_copied: 0, path_params_unresolved: 0, path_params_none: 0, path_params_skipped_dialect: 0,
1411
+ // A10: operation-level description counters. skipped_flag is the common case when
1412
+ // --descriptions was not passed -- distinct from `none` (flag WAS passed, source had nothing).
1413
+ description_copied: 0, description_unresolved: 0, description_none: 0, description_skipped_flag: 0,
1167
1414
  };
1168
1415
  // A7: accumulates every security-scheme name any operation's COPIED security requirement
1169
1416
  // actually referenced, across the WHOLE module -- becomes the contract-root sourceSecuritySchemes
@@ -1206,14 +1453,14 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
1206
1453
  // A2/A3: matched/adopted ONLY -- schema enrichment never applies to drift/missing/
1207
1454
  // ambiguous/unresolved, same "don't guess" rule A1 established for path/verb.
1208
1455
  if (schemaProjection.enabled) {
1209
- applyRequestBodySchema(result, docEntry, index.componentSchemas, stats);
1210
- applyResponseSchemas(result, docEntry, index.componentSchemas, stats);
1456
+ applyRequestBodySchema(result, docEntry, index.componentSchemas, stats, includeDescriptions);
1457
+ applyResponseSchemas(result, docEntry, index.componentSchemas, stats, includeDescriptions);
1211
1458
  }
1212
1459
  // A7: same matched/adopted-only placement -- this IS the refusal mechanism for
1213
1460
  // every other resolution kind. Called unconditionally (not gated on
1214
1461
  // schemaProjection.enabled): parameters gate internally (schema-bearing);
1215
1462
  // security/summary/tags are dialect-independent and always attempted.
1216
- applyPassthrough(result, docEntry, index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
1463
+ applyPassthrough(result, docEntry, index, stats, schemaProjection.enabled, referencedSecuritySchemeNames, includeDescriptions);
1217
1464
  } else {
1218
1465
  result = {
1219
1466
  kind: 'drift', reason: 'path',
@@ -1239,10 +1486,10 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
1239
1486
  };
1240
1487
  stats.adopted++;
1241
1488
  if (schemaProjection.enabled) {
1242
- applyRequestBodySchema(result, hits[0], index.componentSchemas, stats);
1243
- applyResponseSchemas(result, hits[0], index.componentSchemas, stats);
1489
+ applyRequestBodySchema(result, hits[0], index.componentSchemas, stats, includeDescriptions);
1490
+ applyResponseSchemas(result, hits[0], index.componentSchemas, stats, includeDescriptions);
1244
1491
  }
1245
- applyPassthrough(result, hits[0], index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
1492
+ applyPassthrough(result, hits[0], index, stats, schemaProjection.enabled, referencedSecuritySchemeNames, includeDescriptions);
1246
1493
  } else if (hits.length === 1) {
1247
1494
  // A single route match, but the document itself never gave that operation an
1248
1495
  // operationId -- nothing to route by, so this can't become an addressable
@@ -1277,7 +1524,7 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
1277
1524
 
1278
1525
  // Convenience entry point: load + index + reconcile in one call, propagating the first failure.
1279
1526
  // This is what bin/bskel.mjs's cmdContractEmit calls.
1280
- export function buildReconciliation({ filePath, module, pathPrefix = null }) {
1527
+ export function buildReconciliation({ filePath, module, pathPrefix = null, includeDescriptions = false }) {
1281
1528
  if (pathPrefix != null && !PATH_PREFIX_RE.test(pathPrefix)) {
1282
1529
  return { ok: false, error: `--path-prefix "${pathPrefix}" is not a valid path prefix (expected e.g. "/api/v0")` };
1283
1530
  }
@@ -1301,7 +1548,7 @@ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
1301
1548
  }
1302
1549
  const indexed = indexOpenApiDocument(loaded.doc);
1303
1550
  if (!indexed.ok) return indexed;
1304
- const recon = reconcileModule({ index: indexed, module, pathPrefix });
1551
+ const recon = reconcileModule({ index: indexed, module, pathPrefix, includeDescriptions });
1305
1552
  return {
1306
1553
  ok: true,
1307
1554
  document: {
@@ -1325,6 +1572,10 @@ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
1325
1572
  // A7: only schemes actually referenced by at least one copied sourceSecurity requirement --
1326
1573
  // contracts/emit.mjs attaches this at the contract root when non-empty.
1327
1574
  sourceSecuritySchemes: recon.sourceSecuritySchemes,
1575
+ // D-unsupported-annotation-warning: computed once per document, not per-operation -- a
1576
+ // module-wide presence signal, not a per-operation fact, so contracts/emit.mjs pushes at
1577
+ // most one warning per keyword name for the whole module, not one per operation.
1578
+ unsupportedAnnotations: findUnsupportedAnnotations(loaded.doc),
1328
1579
  };
1329
1580
  }
1330
1581
 
@@ -1365,6 +1616,18 @@ function requestMediaTypesDecision(result) {
1365
1616
  if (result.requestMediaTypesUnresolvedReason) return `unresolved:${result.requestMediaTypesUnresolvedReason}`;
1366
1617
  return 'none';
1367
1618
  }
1619
+ // A9: same decision-only audit-trail shape, for the path-parameter schema fix.
1620
+ function pathParamSchemasDecision(result) {
1621
+ if (result.pathParamsSkippedDialect) return 'skipped:dialect';
1622
+ if (result.pathParamSchemas) return `copied:${result.pathParamSchemas.size}`;
1623
+ return 'none';
1624
+ }
1625
+ // A10: same decision-only audit-trail shape as every field above.
1626
+ function descriptionDecision(result) {
1627
+ if (result.sourceDescription) return 'copied';
1628
+ if (result.descriptionUnresolvedReason) return `unresolved:${result.descriptionUnresolvedReason}`;
1629
+ return 'none';
1630
+ }
1368
1631
 
1369
1632
  // `sourceFile`: {file, outsideRepo} precomputed by the caller (bin/bskel.mjs knows the repo
1370
1633
  // root; this module deliberately doesn't) -- keeps machine-specific absolute paths out of a
@@ -1405,6 +1668,10 @@ export function snapshotFromReconciliation(reconciliation, { featureId, sourceFi
1405
1668
  // A8: same decision-only audit trail, for the two new passthrough fields.
1406
1669
  per_status_responses: perStatusResponsesDecision(result),
1407
1670
  request_media_types: requestMediaTypesDecision(result),
1671
+ // A9: same decision-only audit trail, for the path-parameter schema fix.
1672
+ path_param_schemas: pathParamSchemasDecision(result),
1673
+ // A10: same decision-only audit trail, for the opt-in operation-level description.
1674
+ description: descriptionDecision(result),
1408
1675
  };
1409
1676
  }
1410
1677
  }