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

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.
package/README.md CHANGED
@@ -133,8 +133,13 @@ operation, copied byte-for-byte, never reconstructed. `security: []` is emitted
133
133
  document itself said `[]` (a genuine claim that no authentication is required); it is never
134
134
  invented as a default. Where no source document was given, or it said nothing for an operation
135
135
  (or a particular field of one), the key is simply omitted, meaning "unspecified." Operation-level
136
- `description` remains excluded measured too expensive to copy by default (real average 2,442.7
137
- bytes/operation).
136
+ `description` is copied too, but **opt-in only** (`contract emit --descriptions`) measured too
137
+ expensive to copy by default (real average 2,442.7 bytes/operation, larger than every other field
138
+ this projection copies combined). The same flag also copies a schema FIELD's own `description`/
139
+ `example` (a property's own annotation, not the operation's) one level deeper into request-body/
140
+ response/error/parameter/per-status/path-param schemas — `title`, plural `examples`,
141
+ `externalDocs`, `xml`, and `deprecated` stay unconditionally dropped either way (0 real occurrences
142
+ measured against the Team-IZ-Backend oracle).
138
143
 
139
144
  Export refuses a zero-operation contract, refuses when the scan found a global path prefix the
140
145
  contract's paths don't reflect (`--allow-unprefixed` overrides), and stamps every document with an
package/bin/bskel.mjs CHANGED
@@ -65,7 +65,7 @@ function usage() {
65
65
  bskel feature rename <id> --to <new-slug> --reason "..." [--json]
66
66
  bskel feature link <keepId> <aliasId> --reason "..." [--json]
67
67
  bskel feature archive <id> --reason "..." [--json]
68
- bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0]
68
+ bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0] [--descriptions]
69
69
  bskel contract export --feature <id> [--out <path>] [--json] [--allow-unprefixed] [--status-codes range|literal]
70
70
  bskel contract validate --feature <id> --file <envelope.json>
71
71
  bskel contract tool-schema --feature <id> --operation <operationId>
@@ -844,6 +844,11 @@ function cmdContractEmit(args) {
844
844
  if (flags['path-prefix'] && !flags['openapi-file']) {
845
845
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--path-prefix only applies when reconciling against a real OpenAPI document -- pass --openapi-file <path> together with it, or drop --path-prefix (the value has no effect on its own).`);
846
846
  }
847
+ // A10: same "would be a silent no-op" reasoning as --path-prefix above -- --descriptions only has
848
+ // any effect inside buildReconciliation(), which only runs when --openapi-file is also given.
849
+ if (flags.descriptions && !flags['openapi-file']) {
850
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--descriptions only applies when reconciling against a real OpenAPI document -- pass --openapi-file <path> together with it, or drop --descriptions (it has no effect on its own).`);
851
+ }
847
852
 
848
853
  const root = requireRepoRoot();
849
854
  requirePreflightPassed(root);
@@ -878,7 +883,7 @@ function cmdContractEmit(args) {
878
883
  if (flags['openapi-file']) {
879
884
  const targetModule = selectModule(scanReport, flags.module);
880
885
  if (targetModule) {
881
- const result = buildReconciliation({ filePath: flags['openapi-file'], module: targetModule, pathPrefix: flags['path-prefix'] });
886
+ const result = buildReconciliation({ filePath: flags['openapi-file'], module: targetModule, pathPrefix: flags['path-prefix'], includeDescriptions: flags.descriptions });
882
887
  if (!result.ok) {
883
888
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', result.error);
884
889
  }
@@ -978,6 +983,12 @@ function cmdContractEmit(args) {
978
983
  console.log(`openapi: ${p.parameters_copied} operation(s) with parameters copied (${p.parameters_unresolved} partial/unresolved), ${p.security_copied + p.security_public} with security copied, ${p.summary_copied} summaries + ${p.tags_copied} tag sets copied`);
979
984
  // A8: same "just print the numbers" style.
980
985
  console.log(`openapi: ${p.per_status_copied} operation(s) with per-status responses copied, ${p.request_media_types_copied} with non-JSON request media type(s) copied`);
986
+ // A10: printed only when --descriptions was actually passed -- unlike A7/A8/A9's
987
+ // default-on fields, printing "0 copied" unconditionally here would misleadingly
988
+ // suggest this opt-in field was attempted when it never was.
989
+ if (flags.descriptions) {
990
+ console.log(`openapi: ${p.description_copied} operation(s) with description copied, ${p.description_unresolved} unresolved`);
991
+ }
981
992
  }
982
993
  }
983
994
  for (const w of contract.warnings) console.error(`warning[${w.severity}] ${w.code}${w.subject ? ` (${w.subject})` : ''}: ${w.message}`);
@@ -99,6 +99,12 @@ export const WARNING_CODES = Object.freeze({
99
99
  // it reuses CONTRACT_OPENAPI_RESPONSE_SCHEMA_UNRESOLVED/CONTRACT_OPENAPI_ERROR_SCHEMA_UNRESOLVED
100
100
  // unchanged -- see D-openapi-per-status.
101
101
  CONTRACT_OPENAPI_REQUEST_MEDIA_TYPE_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
102
+ // A10: the operation's description exceeded MAX_DESCRIPTION_LENGTH -- independent of every code
103
+ // above (nothing else tracks description length), so it gets its own code rather than reusing
104
+ // one, same reasoning A8 used to justify its own new multipart code. WARN: every other field
105
+ // this operation carries is unaffected, this is a missed (opt-in) enhancement, same severity
106
+ // class as its A7/A8 siblings.
107
+ CONTRACT_OPENAPI_DESCRIPTION_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
102
108
  });
103
109
 
104
110
  export const WARNING_CODE_NAMES = Object.freeze(Object.keys(WARNING_CODES));
@@ -15,24 +15,42 @@ import { pathPrefixCandidates, unreflectedPathPrefixes } from './export.mjs';
15
15
  // a path param. Direction stays one-way (openapi.mjs imports from emit.mjs, never the reverse).
16
16
  export const BARE_UUID_PATTERN = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$';
17
17
 
18
- // A7/A8: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
18
+ // A7/A8/A9/A10: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
19
19
  // const -- bin/bskel.mjs's loadContract() imports this too, so the friendly "re-emit with the
20
- // current bskel" message and the value actually written here cannot drift apart. Bumped "5" -> "6"
21
- // for this item (sourceResponses/sourceRequestBody) -- cheap this time: the friendly re-emit
22
- // pre-check in loadContract() needed zero code change, it already compares against this imported
23
- // constant -- see D-openapi-per-status.
24
- export const CONTRACT_SCHEMA_VERSION = '6';
20
+ // current bskel" message and the value actually written here cannot drift apart. Bumped "7" -> "8"
21
+ // for this item (sourceDescription) -- again cheap, the friendly re-emit pre-check needs zero
22
+ // code change -- see D-openapi-description.
23
+ export const CONTRACT_SCHEMA_VERSION = '8';
25
24
 
26
- function pathParamsSchema(routePath) {
25
+ // A9 (D-openapi-path-params): `sourcePathParamSchemas` (a Map<name, schema>, contracts/openapi.mjs's
26
+ // applyPathParameterSchemas -- present only for a matched/adopted operation whose source document
27
+ // resolved at least one real path-param schema) is preferred per-segment over the name heuristic
28
+ // below. The heuristic remains the fallback for any segment the source doesn't answer (no source
29
+ // document at all, source declared no schema for that name, or the schema failed to resolve) --
30
+ // this function's OWN correctness posture is unchanged for those cases, exactly as before this
31
+ // item. `pathParamsHeuristic` names every segment that still fell back, so a downstream consumer
32
+ // (contracts/export.mjs's collectOmissions()) can tell, per operation, whether ANY segment is still
33
+ // a guess -- `null` (never `[]`) when every segment was source-resolved or the route has none.
34
+ function pathParamsSchema(routePath, sourcePathParamSchemas = null) {
27
35
  const params = [...routePath.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
28
36
  const properties = {};
37
+ const heuristicNames = [];
29
38
  for (const p of params) {
39
+ const sourced = sourcePathParamSchemas ? sourcePathParamSchemas.get(p) : undefined;
40
+ if (sourced) {
41
+ properties[p] = sourced;
42
+ continue;
43
+ }
30
44
  // Naming convention seen throughout Team-IZ-Backend (`UUID organizationId`, etc.) --
31
45
  // a heuristic, not a guarantee; wrong for a path param that happens to end in "Id" but
32
46
  // isn't a UUID, which just means an over-strict uuid-shaped check on that one field.
33
47
  properties[p] = /id$/i.test(p) ? { type: 'string', pattern: BARE_UUID_PATTERN } : { type: 'string' };
48
+ heuristicNames.push(p);
34
49
  }
35
- return { type: 'object', additionalProperties: false, properties, required: params };
50
+ return {
51
+ pathParams: { type: 'object', additionalProperties: false, properties, required: params },
52
+ pathParamsHeuristic: heuristicNames.length > 0 ? heuristicNames : null,
53
+ };
36
54
  }
37
55
 
38
56
  // Re-reads the controller source (already located by the scan) to check whether this specific
@@ -122,6 +140,12 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
122
140
  let sourceResponses = null;
123
141
  let sourceRequestBody = null;
124
142
  let requestMediaTypesUnresolvedReason = null;
143
+ // A9: same discipline -- transient (Map), consulted below by pathParamsSchema(), never
144
+ // itself spread into the persisted operation object (see that function's own comment).
145
+ let pathParamSchemas = null;
146
+ // A10: same discipline, for the opt-in operation-level description.
147
+ let sourceDescription = null;
148
+ let descriptionUnresolvedReason = null;
125
149
 
126
150
  if (res) {
127
151
  switch (res.kind) {
@@ -148,6 +172,9 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
148
172
  sourceResponses = res.sourceResponses ?? null;
149
173
  sourceRequestBody = res.sourceRequestBody ?? null;
150
174
  requestMediaTypesUnresolvedReason = res.requestMediaTypesUnresolvedReason ?? null;
175
+ pathParamSchemas = res.pathParamSchemas ?? null;
176
+ sourceDescription = res.sourceDescription ?? null;
177
+ descriptionUnresolvedReason = res.descriptionUnresolvedReason ?? null;
151
178
  break;
152
179
  case 'adopted':
153
180
  // No @Operation(operationId=...) in source at all -- the id itself comes from
@@ -174,6 +201,9 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
174
201
  sourceResponses = res.sourceResponses ?? null;
175
202
  sourceRequestBody = res.sourceRequestBody ?? null;
176
203
  requestMediaTypesUnresolvedReason = res.requestMediaTypesUnresolvedReason ?? null;
204
+ pathParamSchemas = res.pathParamSchemas ?? null;
205
+ sourceDescription = res.sourceDescription ?? null;
206
+ descriptionUnresolvedReason = res.descriptionUnresolvedReason ?? null;
177
207
  warnings.push(makeWarning('CONTRACT_OPENAPI_DERIVED_OPERATION_ID', {
178
208
  subject: operationId,
179
209
  message: `operationId "${operationId}" for ${res.verb} ${res.path} was not found in the source (no @Operation(operationId=...)) -- adopted directly from the OpenAPI document instead`,
@@ -320,10 +350,22 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
320
350
  detail: { reason: requestMediaTypesUnresolvedReason, verb, path: route, operationId },
321
351
  }));
322
352
  }
353
+ // A10: only fires when --descriptions was passed AND the source declared one AND it
354
+ // exceeded MAX_DESCRIPTION_LENGTH -- independent from every other unresolved code above
355
+ // (a genuinely new failure mode, not reusing an existing one), same reasoning A8 used to
356
+ // justify its own new multipart code instead of overloading an existing one.
357
+ if (descriptionUnresolvedReason) {
358
+ warnings.push(makeWarning('CONTRACT_OPENAPI_DESCRIPTION_UNRESOLVED', {
359
+ subject: operationId,
360
+ message: `operationId "${operationId}" (${verb} ${route}) declares a description that could not be copied (${descriptionUnresolvedReason}) -- description stays unrepresented for this operation, same as before --descriptions`,
361
+ detail: { reason: descriptionUnresolvedReason, verb, path: route, operationId },
362
+ }));
363
+ }
364
+ const { pathParams, pathParamsHeuristic } = pathParamsSchema(route, pathParamSchemas);
323
365
  operations[operationId] = {
324
366
  verb,
325
367
  path: route,
326
- pathParams: pathParamsSchema(route),
368
+ pathParams,
327
369
  body: hasBody === null ? 'unknown' : hasBody,
328
370
  provenance,
329
371
  // A2/A3/A7: omitted entirely (not null/false) when there's nothing to project/copy --
@@ -338,6 +380,12 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
338
380
  ...(sourceTags ? { sourceTags } : {}),
339
381
  ...(sourceResponses ? { sourceResponses } : {}),
340
382
  ...(sourceRequestBody ? { sourceRequestBody } : {}),
383
+ // A9: omitted (not []) when every segment resolved from source, or the route has none.
384
+ ...(pathParamsHeuristic ? { pathParamsHeuristic } : {}),
385
+ // A10: omitted entirely when --descriptions was not passed, the source had none, or
386
+ // it failed the length cap -- same "omitted, never null/false" discipline as every
387
+ // other field above.
388
+ ...(sourceDescription ? { sourceDescription } : {}),
341
389
  };
342
390
  }
343
391
  }
@@ -16,12 +16,20 @@
16
16
  // but ONLY when a real source document (--openapi-file) licensed it for that EXACT operation; where
17
17
  // no source stated one, the key is still omitted, meaning "unspecified". A8 (D-openapi-per-status)
18
18
  // extends the same discipline to per-status responses (additive to, never replacing, the
19
- // responseSchema/errorSchema union) and non-JSON request media types. Operation-level
20
- // `description` remains excluded -- measured too expensive to default-on (2,442.7 bytes/operation
21
- // average, larger than every other field this projection copies combined), still disclosed as
22
- // structural. Every omission is disclosed in prose (`info.description`) and machine-readably
19
+ // responseSchema/errorSchema union) and non-JSON request media types. A9 (D-openapi-path-params) is
20
+ // different in kind from A7/A8 -- not additive, a REPLACEMENT in place: a path parameter's own
21
+ // `pathParams` schema is corrected from the source document per-segment when it resolves one,
22
+ // falling back to the pre-existing name heuristic only where the source doesn't answer. A10
23
+ // (D-openapi-description) finally builds operation-level `description` -- the one field this whole
24
+ // effort measured too expensive to default-on (2,442.7 bytes/operation average, larger than every
25
+ // other field this projection copies combined) -- as the one source-backed field that is opt-in
26
+ // (`contract emit --descriptions`) rather than default-on. A11 (D-openapi-field-docs) extends the
27
+ // SAME `--descriptions` flag one level deeper: schema FIELD-level `description`/`example` (a
28
+ // property's own annotation, not the operation's), reusing the flag rather than adding a second
29
+ // one. Every omission is disclosed in prose (`info.description`) and machine-readably
23
30
  // (`info.x-bskel-omitted`) rather than papered over -- see D-openapi-export, D-openapi-passthrough,
24
- // and D-openapi-per-status in DECISIONS.md.
31
+ // D-openapi-per-status, D-openapi-path-params, D-openapi-description, and D-openapi-field-docs in
32
+ // DECISIONS.md.
25
33
  import { createHash } from 'node:crypto';
26
34
  import { BSKEL_GENERATED_EXTENSION, BSKEL_PASSTHROUGH_EXTENSION, PATH_PREFIX_RE, RESPONSE_STATUS_KEY_RE, MEDIA_TYPE_RE, PER_STATUS_NO_DESCRIPTION_STANDIN, SUCCESS_STATUS_RE, ERROR_STATUS_RE, DEFAULT_STATUS_KEY } from './openapi.mjs';
27
35
 
@@ -58,34 +66,48 @@ const ERROR_RESPONSE_DESCRIPTION = 'Error. The source contract records the union
58
66
  // A7: query/header/cookie parameters, security, summaries, and tags moved OUT of this list -- they
59
67
  // are now real emitted content when a source document licensed them, so they only belong in the
60
68
  // DERIVED (ANY-based) set collectOmissions() builds below. A8 moves `per-status-responses` and
61
- // `non-json-request-media-types` (renamed from `non-json-media-types`) out the same way. What
62
- // stays structural: `descriptions` (field-level AND operation-level -- the latter measured and
63
- // deliberately excluded, not merely unbuilt), `path-parameter-schemas` (path-param schemas come
64
- // from this contract's own name heuristic, never from a source document, even when --openapi-file
65
- // was given -- see the real `batchRequestId` finding in D-openapi-passthrough), `vendor-extensions`
66
- // (x-* keys on an operation are never copied -- excluded in principle, not by cap or failure, since
67
- // their semantics are tool-specific), and two A8 additions: `non-json-response-schemas` (a
68
- // non-JSON response media type's NAME is copied via a per-status entry's `mediaTypes`, but its
69
- // SHAPE is never projected -- 0/674 real occurrences, so building that machinery would violate
70
- // this project's own "don't build for zero real cases" discipline) and `response-headers`
71
- // (response `headers`/`links` -- 0/694 real occurrences, a genuinely visible gap only now that
72
- // per-status responses look complete).
69
+ // `non-json-request-media-types` (renamed from `non-json-media-types`) out the same way. A9 moves
70
+ // `path-parameter-schemas` out the same way too -- path-param schemas now come from a real source
71
+ // document whenever it declares a resolvable one (see D-openapi-path-params, the real fix for the
72
+ // `batchRequestId` finding this omission entry used to describe unconditionally), falling back to
73
+ // the contract's own name heuristic only per-segment, so its presence is now content-conditional
74
+ // like every other A7/A8 field, not a permanent structural fact. A10 splits the old single
75
+ // `descriptions` entry in two: `operation-descriptions` moves out (opt-in via `--descriptions`, so
76
+ // its presence is now content-AND-flag-conditional, same ANY-based doctrine), while
77
+ // `field-descriptions` (schema-field-level `description`/`title`/`example`, dropped as
78
+ // DROPPED_KEYWORDS while inlining ANY schema) stays structural at that point. A11
79
+ // (D-openapi-field-docs) splits `field-descriptions` again the same way: `description`/`example`
80
+ // move OUT to the ANY-based set below (`--descriptions` now doubles as the field-level flag too,
81
+ // keeping the `field-descriptions` NAME since that is still exactly what it describes -- only its
82
+ // meaning moves from "never built" to "content-AND-flag-conditional"), while `title`/`examples`
83
+ // (plural)/`externalDocs`/`xml`/`deprecated` move to a new, narrower structural entry
84
+ // (`field-metadata`) -- measured 0 real occurrences each against the Team-IZ-Backend oracle, so
85
+ // they stay permanently unbuilt on the same "don't build for zero real cases" grounds as the two
86
+ // A8 entries below, not this item's scope. What else stays structural: `vendor-extensions` (x-*
87
+ // keys on an operation are never copied -- excluded in principle, not by cap or failure, since
88
+ // their semantics are tool-specific), and two A8 additions: `non-json-response-schemas` (a non-JSON
89
+ // response media type's NAME is copied via a per-status entry's `mediaTypes`, but its SHAPE is never
90
+ // projected -- 0/674 real occurrences, so building that machinery would violate this project's own
91
+ // "don't build for zero real cases" discipline) and `response-headers` (response `headers`/`links`
92
+ // -- 0/694 real occurrences, a genuinely visible gap only now that per-status responses look
93
+ // complete).
73
94
  const STRUCTURAL_OMISSIONS = Object.freeze([
74
- 'descriptions',
95
+ 'field-metadata',
75
96
  'non-json-response-schemas',
76
- 'path-parameter-schemas',
77
97
  'response-headers',
78
98
  'vendor-extensions',
79
99
  ]);
80
100
 
81
101
  const OMISSION_PROSE = Object.freeze({
82
102
  'cookie-parameters': 'cookie parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
83
- descriptions: 'field-level descriptions/titles/examples (contracts/openapi.mjs drops them as DROPPED_KEYWORDS while inlining a schema), and operation-level `description` -- measured and deliberately excluded (real average 2,442.7 bytes/operation, larger than every other field this projection copies combined); if ever built, it must be opt-in behind a flag, unlike everything else this projection copies by default',
84
103
  'error-schemas': 'a JSON error-body schema for at least one operation',
104
+ 'field-descriptions': 'a schema field\'s own `description`/`example` (a property\'s own annotation, distinct from the operation-level `description` field -- see `operation-descriptions` below), for at least one field in the request-body/response/error schema of at least one operation -- copied only when `contract emit --descriptions` was used (the same flag as operation-level description) AND the source declared one for that exact field AND it did not exceed the length/size cap; otherwise the field carries no `description`/`example` key, never synthesized. Not tracked separately for per-status responses, non-JSON request media types, or path-parameter schemas -- those may carry field docs when the flag is on, but their presence is not reflected in this specific omission entry',
105
+ 'field-metadata': 'a schema field\'s `title`, plural `examples`, `externalDocs`, `xml`, or `deprecated` keyword -- dropped unconditionally while inlining a schema (contracts/openapi.mjs\'s DROPPED_KEYWORDS), regardless of `--descriptions`. Permanently unbuilt: 0 real occurrences of any of these five measured against the Team-IZ-Backend oracle',
85
106
  'header-parameters': 'header parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
86
107
  'non-json-request-media-types': 'the media type of the request body, for at least one operation that takes one -- a non-application/json request media type is emitted only when a real source document declared one for that exact operation, copied byte-for-byte; otherwise this document shows a JSON media-type entry because that is all the contract knows, never because the real body is known to be JSON',
87
108
  'non-json-response-schemas': 'a JSON Schema for any response body in a media type other than application/json -- the media type is named where a source document declared one for that status, but its shape is never projected',
88
- 'path-parameter-schemas': 'path parameter schemas -- always derived from this contract\'s own name heuristic (a trailing "Id" is assumed to be a UUID), never from a source document even when --openapi-file was given; a real, small false-negative of this heuristic is known and disclosed, not fixed, by this projection',
109
+ 'operation-descriptions': 'the operation-level `description`, for at least one operation -- copied only when `contract emit --descriptions` was used (opt-in: measured real average 2,442.7 bytes/operation, larger than every other field this projection copies combined) AND the source document declared one for that exact operation AND it did not exceed the length cap; otherwise this key is absent for that operation, never synthesized',
110
+ 'path-parameter-schemas': 'a path parameter\'s schema, for at least one path segment on at least one operation -- derived from this contract\'s own name heuristic (a trailing "Id" is assumed to be a UUID) rather than a real source document, because no source document was given, the source declared no schema for that segment, or the schema failed to resolve; a segment not covered by this note was resolved from the source document\'s own real schema',
89
111
  'per-status-responses': 'per-status responses, for at least one operation -- that operation\'s entry collapses every documented 2xx body into one `2XX` union and every 4xx/5xx body into one `default` union, and records no real status codes. Where a real source document (--openapi-file) documented statuses for an operation, its own status codes and descriptions are emitted verbatim instead; nothing is invented for an operation the source said nothing about',
90
112
  'query-parameters': 'query parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
91
113
  'request-body-schemas': 'a JSON request-body schema for at least one operation that takes a body',
@@ -101,6 +123,28 @@ function hasSourceParamIn(op, loc) {
101
123
  return Array.isArray(op.sourceParameters) && op.sourceParameters.some((p) => p.in === loc);
102
124
  }
103
125
 
126
+ // A11: whether AT LEAST ONE node anywhere in this schema carries a copied `description`/`example`
127
+ // -- deliberately the same coarse "presence, not completeness" doctrine `operation-descriptions`
128
+ // already uses for `op.sourceDescription` (this cannot know, from the exported contract alone,
129
+ // whether a field WITHOUT one had none in the source or was simply never reached with the flag
130
+ // off; it only discloses whether ANY field-level annotation survived at all). `seen` guards the
131
+ // same delete-on-exit-shaped cycle risk inlineSchema() itself defends against -- belt-and-braces,
132
+ // since a contract's own schemas are already acyclic by construction, but this walk is generic over
133
+ // whatever JSON shape ends up in a contract field.
134
+ function schemaHasFieldDocs(node, seen = new Set()) {
135
+ if (node === null || typeof node !== 'object' || Array.isArray(node) || seen.has(node)) return false;
136
+ seen.add(node);
137
+ if (typeof node.description === 'string' || Object.hasOwn(node, 'example')) return true;
138
+ if (node.properties && typeof node.properties === 'object' && !Array.isArray(node.properties)) {
139
+ for (const propSchema of Object.values(node.properties)) {
140
+ if (schemaHasFieldDocs(propSchema, seen)) return true;
141
+ }
142
+ }
143
+ if (node.items && typeof node.items === 'object' && schemaHasFieldDocs(node.items, seen)) return true;
144
+ if (node.additionalProperties && typeof node.additionalProperties === 'object' && schemaHasFieldDocs(node.additionalProperties, seen)) return true;
145
+ return false;
146
+ }
147
+
104
148
  // Derived from the contract's ACTUAL content, not hardcoded -- an operation that takes a body but
105
149
  // has no projected schema, or has no response/error schema, each add their own entry, so the list
106
150
  // says what is missing from THIS document rather than reciting a fixed disclaimer.
@@ -133,6 +177,25 @@ export function collectOmissions(contract) {
133
177
  if ((op.body === true || op.body === 'unknown') && !op.requestBodySchema && !op.sourceRequestBody) {
134
178
  omissions.add('non-json-request-media-types');
135
179
  }
180
+ // A9: path-parameter schemas -- ANY-based, same doctrine as every check above. An operation
181
+ // with zero path params, or every one source-resolved, has no pathParamsHeuristic at all
182
+ // (contracts/emit.mjs never persists an empty array), so it naturally never trips this check.
183
+ if (Array.isArray(op.pathParamsHeuristic) && op.pathParamsHeuristic.length > 0) {
184
+ omissions.add('path-parameter-schemas');
185
+ }
186
+ // A10: operation-level description -- ANY-based, same doctrine. Absent whenever
187
+ // --descriptions was not used at all (every operation then lacks sourceDescription, so this
188
+ // always trips until the flag is used), the source had none for this operation, or it
189
+ // exceeded the length cap.
190
+ if (!op.sourceDescription) omissions.add('operation-descriptions');
191
+ // A11: schema field-level description/example -- ANY-based, same doctrine as
192
+ // response-schemas/error-schemas just above: added whenever NONE of this operation's
193
+ // projected schemas carry a field-level annotation, including the (common) case where the
194
+ // operation has no projected schema at all to carry one -- same "absence of the whole class
195
+ // is itself disclosed" posture response-schemas/error-schemas already take unconditionally,
196
+ // not gated on whether a schema exists first.
197
+ const fieldSchemas = [op.requestBodySchema, op.responseSchema, op.errorSchema].filter(Boolean);
198
+ if (!fieldSchemas.some((s) => schemaHasFieldDocs(s))) omissions.add('field-descriptions');
136
199
  }
137
200
  return [...omissions].sort();
138
201
  }
@@ -464,25 +527,31 @@ export function buildOpenApiDocument({ contract, snapshot = null, options = {} }
464
527
  // for this exact operation. `security: []` is spec-legal (confirmed by executing the
465
528
  // meta-schema) AND, when copied, a genuine positive claim FROM THE SOURCE that no
466
529
  // authentication is required -- Array.isArray, not a truthy check, so `[]` is correctly
467
- // treated as present. `op.sourceSecurity` is never emitted when absent; `description` (the
468
- // operation-level field, not the response-object one) remains deliberately unset -- Phase 2.
530
+ // treated as present. `op.sourceSecurity` is never emitted when absent.
469
531
  if (Array.isArray(op.sourceSecurity)) operation.security = op.sourceSecurity;
470
532
  if (op.sourceSummary) operation.summary = op.sourceSummary;
471
533
  if (Array.isArray(op.sourceTags) && op.sourceTags.length > 0) operation.tags = op.sourceTags;
534
+ // A10: same "only when the contract carries a copied value" discipline -- `sourceDescription`
535
+ // is present only when `contract emit --descriptions` was used AND the source had one for this
536
+ // exact operation, so this is never a synthesized or inferred string.
537
+ if (op.sourceDescription) operation.description = op.sourceDescription;
472
538
 
473
539
  // A8: two more clauses -- an operation whose ONLY passthrough is per-status responses or a
474
540
  // copied multipart body (no source parameters/security/summary/tags at all) previously got NO
475
541
  // marker, reopening the exact self-import hole A7 closed for that one operation. Never arises
476
542
  // on the real oracle (148/148 already carry summary+tags+security) but is structurally
477
543
  // reachable from a minimal hand-written document declaring only `responses` -- see
478
- // D-openapi-per-status.
544
+ // D-openapi-per-status. A10 adds a third clause for the same reason: an operation whose ONLY
545
+ // passthrough is a copied description (structurally reachable even though never arises on the
546
+ // real oracle, which already carries summary/tags/security everywhere).
479
547
  const hasPassthrough = Boolean(
480
548
  (Array.isArray(op.sourceParameters) && op.sourceParameters.length > 0)
481
549
  || Array.isArray(op.sourceSecurity)
482
550
  || op.sourceSummary
483
551
  || (Array.isArray(op.sourceTags) && op.sourceTags.length > 0)
484
552
  || (op.sourceResponses && typeof op.sourceResponses === 'object' && Object.keys(op.sourceResponses).length > 0)
485
- || (op.sourceRequestBody && typeof op.sourceRequestBody === 'object'),
553
+ || (op.sourceRequestBody && typeof op.sourceRequestBody === 'object')
554
+ || op.sourceDescription,
486
555
  );
487
556
  passthroughByOperation[operationId] = hasPassthrough;
488
557
  if (hasPassthrough) {
@@ -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,16 @@ 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']));
155
182
  // Real Team-IZ-Backend format-value histogram (request-body-reachable schemas only): uuid(20),
156
183
  // int32(10), email(7), date(10), date-time(3), int64(2). `uuid` is handled separately (rewritten
157
184
  // to BARE_UUID_PATTERN, see inlineSchema) -- not in this set, since it never survives as `format`.
@@ -374,7 +401,11 @@ export function indexOpenApiDocument(doc) {
374
401
  const security = Array.isArray(operation.security) ? operation.security : null;
375
402
  const summary = typeof operation.summary === 'string' ? operation.summary : null;
376
403
  const tags = Array.isArray(operation.tags) ? operation.tags : null;
377
- const entry = { verb, path: routeKey, operationId, requestBody, responses, parameters, security, summary, tags };
404
+ // A10: same "raw, no size cap at index time" reasoning as summary above -- MAX_DESCRIPTION_
405
+ // LENGTH is enforced only at applyDescription() (the point of actually copying it into the
406
+ // contract), matching where every other length/count cap in this file is enforced.
407
+ const description = typeof operation.description === 'string' ? operation.description : null;
408
+ const entry = { verb, path: routeKey, operationId, requestBody, responses, parameters, security, summary, tags, description };
378
409
 
379
410
  const routeMatchKey = `${verb} ${normalizedRoute}`;
380
411
  const existingRoute = byRoute.get(routeMatchKey);
@@ -438,6 +469,9 @@ export function inlineSchema(node, componentSchemas, opts = {}) {
438
469
  maxDepth: opts.maxDepth ?? MAX_SCHEMA_DEPTH,
439
470
  maxNodes: opts.maxNodes ?? MAX_SCHEMA_NODES,
440
471
  maxPatternLength: opts.maxPatternLength ?? MAX_PATTERN_LENGTH,
472
+ // A11: opt-in only (default false, matching every prior call site's existing behavior
473
+ // byte-for-byte when the caller doesn't pass it) -- see D-openapi-field-docs.
474
+ includeFieldDocs: opts.includeFieldDocs ?? false,
441
475
  };
442
476
  const state = { nodes: 0 };
443
477
  try {
@@ -464,11 +498,14 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
464
498
  // module doesn't attempt to MERGE $ref with a sibling assertion, with ONE exception (A7):
465
499
  // `default` is a real, human-authored override worth carrying through (the exact real shape
466
500
  // `{"$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.
501
+ // schema with its own default value, 9 real occurrences). A DROPPED_KEYWORDS or
502
+ // DOCUMENTATION_KEYWORDS sibling (e.g. a documentation-only `description`) is harmless and
503
+ // ignored -- NOT merged onto the resolved schema even when includeFieldDocs is on (A11: 0
504
+ // real occurrences of a $ref carrying a sibling description/example, measured directly, so
505
+ // there is no real case to build merge semantics for, unlike `default`'s 9); anything else
506
+ // would need merge semantics this vertical slice doesn't implement, so it fails closed.
470
507
  const siblingKeys = Object.keys(node).filter((k) => k !== '$ref');
471
- if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k) && k !== 'default')) fail('ref-with-siblings');
508
+ if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k) && !DOCUMENTATION_KEYWORDS.has(k) && k !== 'default')) fail('ref-with-siblings');
472
509
  const ref = node['$ref'];
473
510
  if (typeof ref !== 'string' || !ref.startsWith(SCHEMA_REF_PREFIX)) fail('unsupported-ref');
474
511
  const name = ref.slice(SCHEMA_REF_PREFIX.length);
@@ -516,6 +553,32 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
516
553
  if (key === '$ref' || key === 'format') continue; // format already handled above
517
554
  if (DROPPED_KEYWORDS.has(key)) continue;
518
555
 
556
+ // A11: description/example are DROPPED (same as before this item) unless includeFieldDocs is
557
+ // on -- when it is, copy verbatim IF the value passes a defensive length check, else drop
558
+ // (silently, same as if the flag were off for this one field) rather than failing the whole
559
+ // schema closed. Unlike an ASSERTION keyword's fail-closed policy, dropping an annotation
560
+ // NEVER changes what the schema validates -- only how well-documented it is -- so there is no
561
+ // correctness reason to fail the operation over one oversized documentation string, and a
562
+ // per-FIELD warning here would be unusably noisy (a single schema can carry dozens of these,
563
+ // unlike A10's one-per-operation description). Real data never exercises this path (measured
564
+ // max: 3,148 for description, 70 for example -- both far under their caps), so this is a
565
+ // defensive bound against a hostile/malformed --openapi-file, not an expected real branch.
566
+ if (DOCUMENTATION_KEYWORDS.has(key)) {
567
+ if (!limits.includeFieldDocs) continue;
568
+ if (key === 'description') {
569
+ if (typeof node.description === 'string' && node.description.length <= MAX_DESCRIPTION_LENGTH) {
570
+ out.description = node.description;
571
+ }
572
+ } else if (key === 'example') {
573
+ let serialized;
574
+ try { serialized = JSON.stringify(node.example); } catch { serialized = null; }
575
+ if (serialized !== undefined && serialized !== null && serialized.length <= MAX_EXAMPLE_LENGTH) {
576
+ out.example = node.example;
577
+ }
578
+ }
579
+ continue;
580
+ }
581
+
519
582
  if (key === 'pattern') {
520
583
  // Two patterns can't be expressed without allOf, which this slice doesn't attempt to
521
584
  // synthesize -- a node with BOTH format:'uuid' and an explicit pattern fails closed
@@ -597,7 +660,7 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
597
660
  // let alone body shape. `docEntry` is the OpenAPI-side entry (from byOperationId or byRoute) whose
598
661
  // `.requestBody` indexOpenApiDocument() retained. Never treats "nothing to project" as a failure --
599
662
  // only an actual unresolvable schema increments schema_unresolved / sets schemaUnresolvedReason.
600
- function applyRequestBodySchema(result, docEntry, componentSchemas, stats) {
663
+ function applyRequestBodySchema(result, docEntry, componentSchemas, stats, includeFieldDocs) {
601
664
  const requestBody = docEntry.requestBody;
602
665
  if (!requestBody || Object.hasOwn(requestBody, '$ref')) {
603
666
  stats.schema_none++;
@@ -614,7 +677,7 @@ function applyRequestBodySchema(result, docEntry, componentSchemas, stats) {
614
677
  stats.schema_none++;
615
678
  return;
616
679
  }
617
- const resolved = inlineSchema(schemaNode, componentSchemas);
680
+ const resolved = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
618
681
  if (resolved.ok) {
619
682
  result.requestBodySchema = resolved.schema;
620
683
  result.requestBodyRequired = requestBody.required === true;
@@ -666,7 +729,7 @@ function canonicalJson(value) {
666
729
  // nothing it had before (nothing read `default` at all until now); one whose `default` describes an
667
730
  // error -- the overwhelmingly common case, and the only case `bskel contract export` itself emits --
668
731
  // gains a real error schema it previously dropped silently.
669
- function projectResponseSchemas(responses, statusRe, componentSchemas, { includeDefault = false } = {}) {
732
+ function projectResponseSchemas(responses, statusRe, componentSchemas, { includeDefault = false, includeFieldDocs = false } = {}) {
670
733
  if (!responses) return { outcome: 'none' };
671
734
  const statusKeys = Object.keys(responses);
672
735
  if (statusKeys.length > MAX_RESPONSES_PER_OPERATION) {
@@ -693,9 +756,14 @@ function projectResponseSchemas(responses, statusRe, componentSchemas, { include
693
756
  return { outcome: sawContentWithoutJson ? 'skipped-media-type' : 'none' };
694
757
  }
695
758
 
759
+ // A11: includeFieldDocs can make two previously-identical-looking resolved schemas turn out
760
+ // distinct (different field-level description/example), which correctly increases `sources` --
761
+ // see D-openapi-field-docs for why this is a self-consistent consequence of being more precise
762
+ // about equality, not a bug, and the real measurement confirming it never actually happens on
763
+ // the Team-IZ-Backend oracle.
696
764
  const resolvedByCanonical = new Map();
697
765
  for (const node of rawNodesByKey.values()) {
698
- const resolved = inlineSchema(node, componentSchemas);
766
+ const resolved = inlineSchema(node, componentSchemas, { includeFieldDocs });
699
767
  if (!resolved.ok) return { outcome: 'unresolved', reason: resolved.reason };
700
768
  const canonicalKey = canonicalJson(resolved.schema);
701
769
  if (!resolvedByCanonical.has(canonicalKey)) resolvedByCanonical.set(canonicalKey, resolved.schema);
@@ -719,10 +787,10 @@ function projectResponseSchemas(responses, statusRe, componentSchemas, { include
719
787
  // schemaProjection.enabled guard). Fields are set ONLY when resolved -- omitted, not null/false,
720
788
  // so an operation with nothing to project stays byte-identical to pre-A3 output (same discipline
721
789
  // as A2's requestBodySchema).
722
- function applyResponseSchemas(result, docEntry, componentSchemas, stats) {
723
- applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, SUCCESS_STATUS_RE, componentSchemas), stats, 'response');
790
+ function applyResponseSchemas(result, docEntry, componentSchemas, stats, includeFieldDocs) {
791
+ applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, SUCCESS_STATUS_RE, componentSchemas, { includeFieldDocs }), stats, 'response');
724
792
  // 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');
793
+ applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, ERROR_STATUS_RE, componentSchemas, { includeDefault: true, includeFieldDocs }), stats, 'error');
726
794
  }
727
795
 
728
796
  function applyProjectionOutcome(result, projected, stats, kind) {
@@ -811,7 +879,7 @@ function collectNonPathParameters(rawParameters) {
811
879
  // The middle+last cases both add the parameter to sourceParameters (every OTHER field it carries is
812
880
  // real and safe); the last case additionally drives CONTRACT_OPENAPI_PARAMETERS_UNRESOLVED, exactly
813
881
  // the "found but couldn't project" distinction applyRequestBodySchema already draws for a body.
814
- function copyParameter(raw, componentSchemas) {
882
+ function copyParameter(raw, componentSchemas, includeFieldDocs) {
815
883
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: 'not-a-parameter-object' };
816
884
  if (Object.hasOwn(raw, '$ref')) return { ok: false, reason: 'ref-parameter' };
817
885
  if (Object.hasOwn(raw, 'content')) return { ok: false, reason: 'content-parameter' };
@@ -829,7 +897,7 @@ function copyParameter(raw, componentSchemas) {
829
897
  }
830
898
 
831
899
  if (Object.hasOwn(raw, 'schema')) {
832
- const resolved = inlineSchema(raw.schema, componentSchemas);
900
+ const resolved = inlineSchema(raw.schema, componentSchemas, { includeFieldDocs });
833
901
  if (resolved.ok) {
834
902
  parameter.schema = resolved.schema;
835
903
  } else {
@@ -846,7 +914,7 @@ function copyParameter(raw, componentSchemas) {
846
914
  // `result`: parametersTotal/parametersCleanCount (reconciliation-internal bookkeeping consumed only
847
915
  // by snapshotFromReconciliation, never copied into the contract itself), parametersSkippedDialect,
848
916
  // sourceParameters (omitted when empty), parametersUnresolved (omitted when empty).
849
- function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled) {
917
+ function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled, includeFieldDocs) {
850
918
  const candidates = collectNonPathParameters(docEntry.parameters);
851
919
  if (candidates.length === 0) {
852
920
  stats.parameters_none++;
@@ -871,7 +939,7 @@ function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled
871
939
  }
872
940
  } else {
873
941
  for (const raw of candidates) {
874
- const outcome = copyParameter(raw, index.componentSchemas);
942
+ const outcome = copyParameter(raw, index.componentSchemas, includeFieldDocs);
875
943
  if (!outcome.ok) {
876
944
  unresolved.push({ name: safeParamName(raw), in: safeParamIn(raw), reason: outcome.reason });
877
945
  continue;
@@ -954,6 +1022,34 @@ function applySummaryAndTags(result, docEntry, stats) {
954
1022
  }
955
1023
  }
956
1024
 
1025
+ // A10 (D-openapi-description): the one A7/A8/A9 sibling that is NOT default-on -- gated behind
1026
+ // `includeDescriptions`, opt-in only, because the measured cost (2,442.7 bytes/operation average
1027
+ // across the real oracle, re-confirmed exactly at this item's own implementation) is larger than
1028
+ // every other field this whole passthrough effort copies COMBINED. Copied verbatim, no
1029
+ // transformation (unlike a schema, there is no keyword whitelist to apply to a plain string) --
1030
+ // fails closed only on length, via MAX_DESCRIPTION_LENGTH, the same defensive posture every other
1031
+ // unbounded-size field in this file has (same defensive-cap class as D-security-1/D-security-2) --
1032
+ // protecting the contract file/gate-token hashing cost from a malformed or hostile --openapi-file,
1033
+ // not a normal document.
1034
+ function applyDescription(result, docEntry, stats, includeDescriptions) {
1035
+ if (!includeDescriptions) {
1036
+ stats.description_skipped_flag++;
1037
+ return;
1038
+ }
1039
+ const raw = docEntry.description;
1040
+ if (typeof raw !== 'string' || raw.length === 0) {
1041
+ stats.description_none++;
1042
+ return;
1043
+ }
1044
+ if (raw.length > MAX_DESCRIPTION_LENGTH) {
1045
+ result.descriptionUnresolvedReason = 'too-long';
1046
+ stats.description_unresolved++;
1047
+ return;
1048
+ }
1049
+ result.sourceDescription = raw;
1050
+ stats.description_copied++;
1051
+ }
1052
+
957
1053
  // A8: per-status responses -- additive to (never replacing) the responseSchema/errorSchema union
958
1054
  // projection above; contracts/validate.mjs is untouched by this item, see D-openapi-per-status.
959
1055
  // Gated on schemaProjectionEnabled, same reasoning as applyParameters -- a JSON Schema resolved
@@ -971,7 +1067,7 @@ function applySummaryAndTags(result, docEntry, stats) {
971
1067
  // at it instead of re-resolving. When sources>1 (never observed on real data, but structurally
972
1068
  // possible) or the status sits outside both buckets (a 1xx/3xx key), the status's own schema is
973
1069
  // resolved individually into an inline `schema` instead.
974
- function applyPerStatusResponses(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
1070
+ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
975
1071
  if (!schemaProjectionEnabled) {
976
1072
  result.perStatusResponsesSkippedDialect = true;
977
1073
  stats.per_status_skipped_dialect++;
@@ -1020,7 +1116,7 @@ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, sche
1020
1116
  } else if ((ERROR_STATUS_RE.test(key) || key === DEFAULT_STATUS_KEY) && result.errorSchema && result.errorSchemaSources === 1) {
1021
1117
  entry.schemaFrom = 'error';
1022
1118
  } else {
1023
- const resolved = inlineSchema(schemaNode, componentSchemas);
1119
+ const resolved = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
1024
1120
  // unresolved here just leaves `schema` absent -- `description` alone (if any) stays
1025
1121
  // valid, same "copied without schema" posture copyParameter() already takes.
1026
1122
  if (resolved.ok) entry.schema = resolved.schema;
@@ -1050,7 +1146,7 @@ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, sche
1050
1146
  // schemas/feature-contract.schema.json too). Gated on schemaProjectionEnabled for the same reason
1051
1147
  // as applyPerStatusResponses above -- a media-type schema resolves through the same inlineSchema()
1052
1148
  // path.
1053
- function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
1149
+ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
1054
1150
  if (!schemaProjectionEnabled) {
1055
1151
  result.requestMediaTypesSkippedDialect = true;
1056
1152
  stats.request_media_types_skipped_dialect++;
@@ -1086,7 +1182,7 @@ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schem
1086
1182
  const entry = {};
1087
1183
  const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
1088
1184
  if (schemaNode && typeof schemaNode === 'object' && !Array.isArray(schemaNode)) {
1089
- const resolved = inlineSchema(schemaNode, componentSchemas);
1185
+ const resolved = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
1090
1186
  if (resolved.ok) { entry.schema = resolved.schema; cleanCount++; }
1091
1187
  } else {
1092
1188
  cleanCount++; // no schema declared for this media type at all is not a failure -- same
@@ -1105,18 +1201,73 @@ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schem
1105
1201
  }
1106
1202
  }
1107
1203
 
1204
+ // A9 (D-openapi-path-params): the real fix for A7's own disclosed `batchRequestId` finding --
1205
+ // contracts/emit.mjs's pathParamsSchema() names a path segment by NAME ONLY (`/id$/i` ->
1206
+ // BARE_UUID_PATTERN), a heuristic that is provably wrong for at least one real path parameter
1207
+ // (`batchRequestId`, a plain string despite the "Id" suffix). Unlike A7's own parameters (query/
1208
+ // header/cookie, which stay ADDITIVE alongside pathParams' own separate story), this one REPLACES
1209
+ // the heuristic's guess in place for any segment the source document can answer -- the same
1210
+ // "positive information overrides a guess" principle A8's `hasSourceMediaTypeInfo` already
1211
+ // established, applied here to path-param TYPE instead of request media type.
1212
+ //
1213
+ // Returns a Map (never a plain object -- this is `--openapi-file`-sourced, untrusted data keyed by
1214
+ // parameter NAME, the exact class RESPONSE_STATUS_KEY_RE/MEDIA_TYPE_RE exist to defend against
1215
+ // elsewhere in this file; a Map sidesteps prototype pollution entirely rather than needing a third
1216
+ // whitelist regex) from resolved path-parameter name to its inlined schema, stashed transiently on
1217
+ // `result` for contracts/emit.mjs's pathParamsSchema() call to consult -- never itself persisted to
1218
+ // the contract; only the corrected `pathParams` (and, when at least one segment still falls back to
1219
+ // the heuristic, `pathParamsHeuristic`) are.
1220
+ function applyPathParameterSchemas(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
1221
+ const rawParameters = Array.isArray(docEntry.parameters) ? docEntry.parameters : [];
1222
+ const pathParams = rawParameters.filter((p) => p && typeof p === 'object' && !Array.isArray(p) && p.in === 'path');
1223
+ if (pathParams.length === 0) {
1224
+ stats.path_params_none++;
1225
+ return;
1226
+ }
1227
+ if (!schemaProjectionEnabled) {
1228
+ result.pathParamsSkippedDialect = true;
1229
+ stats.path_params_skipped_dialect++;
1230
+ return;
1231
+ }
1232
+ const resolved = new Map();
1233
+ for (const p of pathParams) {
1234
+ const name = safeParamName(p);
1235
+ 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
1236
+ const schemaNode = p.schema;
1237
+ if (!schemaNode || typeof schemaNode !== 'object' || Array.isArray(schemaNode)) continue;
1238
+ const out = inlineSchema(schemaNode, componentSchemas, { includeFieldDocs });
1239
+ if (out.ok) resolved.set(name, out.schema);
1240
+ }
1241
+ if (resolved.size > 0) {
1242
+ result.pathParamSchemas = resolved;
1243
+ stats.path_params_copied++;
1244
+ } else {
1245
+ stats.path_params_unresolved++;
1246
+ }
1247
+ }
1248
+
1108
1249
  // A7: the single entry point called from reconcileModule()'s two matched/adopted call sites --
1109
1250
  // exactly the placement A2/A3's own helpers already occupy, which IS the refusal mechanism for
1110
1251
  // every other resolution kind (drift/missing/ambiguous/unresolved never reach this function at
1111
1252
  // all, see reconcileModule below).
1112
- function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnabled, referencedSchemeNames) {
1113
- applyParameters(result, docEntry, index, stats, schemaProjectionEnabled);
1253
+ function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnabled, referencedSchemeNames, includeDescriptions) {
1254
+ // A11 (D-openapi-field-docs): includeDescriptions doubles as includeFieldDocs here -- reusing
1255
+ // the existing --descriptions flag rather than adding a second one, since it already governs
1256
+ // "copy source-authored documentation prose" at the operation level (A10); field-level
1257
+ // description/example is the same policy applied one level deeper into the same schemas.
1258
+ applyParameters(result, docEntry, index, stats, schemaProjectionEnabled, includeDescriptions);
1114
1259
  applySecurity(result, docEntry, index, stats, referencedSchemeNames);
1115
1260
  applySummaryAndTags(result, docEntry, stats);
1116
1261
  // A8: same matched/adopted-only placement as the three calls above -- this IS the refusal
1117
1262
  // 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);
1263
+ applyPerStatusResponses(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled, includeDescriptions);
1264
+ applyRequestMediaTypes(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled, includeDescriptions);
1265
+ // A9: same placement again, for the path-parameter schema fix.
1266
+ applyPathParameterSchemas(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled, includeDescriptions);
1267
+ // A10: same placement again -- applyDescription() itself decides whether includeDescriptions
1268
+ // gates it off, matching how applyParameters decides its own schemaProjectionEnabled gate
1269
+ // internally rather than being skipped by the caller.
1270
+ applyDescription(result, docEntry, stats, includeDescriptions);
1120
1271
  }
1121
1272
 
1122
1273
  // The core reconciliation, pure (no I/O). `module` is a scanReport related_modules entry (as
@@ -1124,7 +1275,7 @@ function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnable
1124
1275
  // selection buildContract() will use, so endpointKey(ci,ei) lines up). `pathPrefix`, if given
1125
1276
  // (from --path-prefix), overrides inference entirely but the anchor pass still runs so its
1126
1277
  // deltas are recorded for audit in the snapshot.
1127
- export function reconcileModule({ index, module, pathPrefix = null }) {
1278
+ export function reconcileModule({ index, module, pathPrefix = null, includeDescriptions = false }) {
1128
1279
  const anchorDeltas = [];
1129
1280
  for (const controller of module.controllers) {
1130
1281
  for (const ep of controller.endpoints) {
@@ -1164,6 +1315,11 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
1164
1315
  // the A7 counters above.
1165
1316
  per_status_copied: 0, per_status_skipped_unresolved: 0, per_status_none: 0, per_status_skipped_dialect: 0,
1166
1317
  request_media_types_copied: 0, request_media_types_unresolved: 0, request_media_types_none: 0, request_media_types_skipped_dialect: 0,
1318
+ // A9: source-backed path-parameter schema counters, same per-operation-tally shape.
1319
+ path_params_copied: 0, path_params_unresolved: 0, path_params_none: 0, path_params_skipped_dialect: 0,
1320
+ // A10: operation-level description counters. skipped_flag is the common case when
1321
+ // --descriptions was not passed -- distinct from `none` (flag WAS passed, source had nothing).
1322
+ description_copied: 0, description_unresolved: 0, description_none: 0, description_skipped_flag: 0,
1167
1323
  };
1168
1324
  // A7: accumulates every security-scheme name any operation's COPIED security requirement
1169
1325
  // actually referenced, across the WHOLE module -- becomes the contract-root sourceSecuritySchemes
@@ -1206,14 +1362,14 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
1206
1362
  // A2/A3: matched/adopted ONLY -- schema enrichment never applies to drift/missing/
1207
1363
  // ambiguous/unresolved, same "don't guess" rule A1 established for path/verb.
1208
1364
  if (schemaProjection.enabled) {
1209
- applyRequestBodySchema(result, docEntry, index.componentSchemas, stats);
1210
- applyResponseSchemas(result, docEntry, index.componentSchemas, stats);
1365
+ applyRequestBodySchema(result, docEntry, index.componentSchemas, stats, includeDescriptions);
1366
+ applyResponseSchemas(result, docEntry, index.componentSchemas, stats, includeDescriptions);
1211
1367
  }
1212
1368
  // A7: same matched/adopted-only placement -- this IS the refusal mechanism for
1213
1369
  // every other resolution kind. Called unconditionally (not gated on
1214
1370
  // schemaProjection.enabled): parameters gate internally (schema-bearing);
1215
1371
  // security/summary/tags are dialect-independent and always attempted.
1216
- applyPassthrough(result, docEntry, index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
1372
+ applyPassthrough(result, docEntry, index, stats, schemaProjection.enabled, referencedSecuritySchemeNames, includeDescriptions);
1217
1373
  } else {
1218
1374
  result = {
1219
1375
  kind: 'drift', reason: 'path',
@@ -1239,10 +1395,10 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
1239
1395
  };
1240
1396
  stats.adopted++;
1241
1397
  if (schemaProjection.enabled) {
1242
- applyRequestBodySchema(result, hits[0], index.componentSchemas, stats);
1243
- applyResponseSchemas(result, hits[0], index.componentSchemas, stats);
1398
+ applyRequestBodySchema(result, hits[0], index.componentSchemas, stats, includeDescriptions);
1399
+ applyResponseSchemas(result, hits[0], index.componentSchemas, stats, includeDescriptions);
1244
1400
  }
1245
- applyPassthrough(result, hits[0], index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
1401
+ applyPassthrough(result, hits[0], index, stats, schemaProjection.enabled, referencedSecuritySchemeNames, includeDescriptions);
1246
1402
  } else if (hits.length === 1) {
1247
1403
  // A single route match, but the document itself never gave that operation an
1248
1404
  // operationId -- nothing to route by, so this can't become an addressable
@@ -1277,7 +1433,7 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
1277
1433
 
1278
1434
  // Convenience entry point: load + index + reconcile in one call, propagating the first failure.
1279
1435
  // This is what bin/bskel.mjs's cmdContractEmit calls.
1280
- export function buildReconciliation({ filePath, module, pathPrefix = null }) {
1436
+ export function buildReconciliation({ filePath, module, pathPrefix = null, includeDescriptions = false }) {
1281
1437
  if (pathPrefix != null && !PATH_PREFIX_RE.test(pathPrefix)) {
1282
1438
  return { ok: false, error: `--path-prefix "${pathPrefix}" is not a valid path prefix (expected e.g. "/api/v0")` };
1283
1439
  }
@@ -1301,7 +1457,7 @@ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
1301
1457
  }
1302
1458
  const indexed = indexOpenApiDocument(loaded.doc);
1303
1459
  if (!indexed.ok) return indexed;
1304
- const recon = reconcileModule({ index: indexed, module, pathPrefix });
1460
+ const recon = reconcileModule({ index: indexed, module, pathPrefix, includeDescriptions });
1305
1461
  return {
1306
1462
  ok: true,
1307
1463
  document: {
@@ -1365,6 +1521,18 @@ function requestMediaTypesDecision(result) {
1365
1521
  if (result.requestMediaTypesUnresolvedReason) return `unresolved:${result.requestMediaTypesUnresolvedReason}`;
1366
1522
  return 'none';
1367
1523
  }
1524
+ // A9: same decision-only audit-trail shape, for the path-parameter schema fix.
1525
+ function pathParamSchemasDecision(result) {
1526
+ if (result.pathParamsSkippedDialect) return 'skipped:dialect';
1527
+ if (result.pathParamSchemas) return `copied:${result.pathParamSchemas.size}`;
1528
+ return 'none';
1529
+ }
1530
+ // A10: same decision-only audit-trail shape as every field above.
1531
+ function descriptionDecision(result) {
1532
+ if (result.sourceDescription) return 'copied';
1533
+ if (result.descriptionUnresolvedReason) return `unresolved:${result.descriptionUnresolvedReason}`;
1534
+ return 'none';
1535
+ }
1368
1536
 
1369
1537
  // `sourceFile`: {file, outsideRepo} precomputed by the caller (bin/bskel.mjs knows the repo
1370
1538
  // root; this module deliberately doesn't) -- keeps machine-specific absolute paths out of a
@@ -1405,6 +1573,10 @@ export function snapshotFromReconciliation(reconciliation, { featureId, sourceFi
1405
1573
  // A8: same decision-only audit trail, for the two new passthrough fields.
1406
1574
  per_status_responses: perStatusResponsesDecision(result),
1407
1575
  request_media_types: requestMediaTypesDecision(result),
1576
+ // A9: same decision-only audit trail, for the path-parameter schema fix.
1577
+ path_param_schemas: pathParamSchemasDecision(result),
1578
+ // A10: same decision-only audit trail, for the opt-in operation-level description.
1579
+ description: descriptionDecision(result),
1408
1580
  };
1409
1581
  }
1410
1582
  }
package/lib/cli.mjs CHANGED
@@ -152,13 +152,18 @@ export const COMMANDS = {
152
152
  allowPositionals: true,
153
153
  },
154
154
  'contract emit': {
155
- usage: 'bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0]',
155
+ usage: 'bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0] [--descriptions]',
156
156
  options: {
157
157
  feature: { type: 'string', default: null, required: true },
158
158
  module: { type: 'string', default: null },
159
159
  json: { type: 'boolean', default: false },
160
160
  'openapi-file': { type: 'string', default: null },
161
161
  'path-prefix': { type: 'string', default: null },
162
+ // A10 (D-openapi-description): the one source-backed field that is NOT default-on --
163
+ // measured real cost (2,442.7 bytes/operation average) is larger than every other field
164
+ // this whole passthrough effort copies combined, so it stays opt-in rather than joining
165
+ // A7/A8/A9's default-on behavior.
166
+ descriptions: { type: 'boolean', default: false },
162
167
  },
163
168
  },
164
169
  // A6 (D-openapi-export): the export direction. `--allow-unprefixed` is deliberately NOT a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.3",
4
4
  "type": "module",
5
5
  "description": "Spec-driven backend scaffolding: brownfield-scan gate, feature_id-keyed contracts, UUID bidirectional handles, stack-choice wiring.",
6
6
  "license": "AGPL-3.0-or-later",
@@ -7,7 +7,7 @@
7
7
  "additionalProperties": false,
8
8
  "required": ["sbf_contract", "feature_id", "feature_uid", "source", "operations", "warnings", "completeness"],
9
9
  "properties": {
10
- "sbf_contract": { "const": "6" },
10
+ "sbf_contract": { "const": "8" },
11
11
  "feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
12
12
  "feature_uid": { "type": "string", "format": "uuid" },
13
13
  "source": {
@@ -98,6 +98,15 @@
98
98
  }
99
99
  }
100
100
  }
101
+ },
102
+ "pathParamsHeuristic": {
103
+ "description": "A9: names every path-parameter segment in `pathParams` whose schema still came from this contract's own name heuristic (`/id$/i` -> a UUID pattern) rather than a real source document -- because no source document was given, the source declared no schema for that segment, or the schema failed to resolve. Present only when at least one segment is still heuristic-derived; a segment absent from this list was resolved from the source document's own real schema.",
104
+ "type": "array",
105
+ "items": { "type": "string" }
106
+ },
107
+ "sourceDescription": {
108
+ "description": "A10: the operation's `description`, copied verbatim from a real source document. Present only when `contract emit --descriptions` (opt-in, unlike every other source-backed field in this schema) was passed AND the source document declared one for this exact operation AND it did not exceed the length cap.",
109
+ "type": "string"
101
110
  }
102
111
  }
103
112
  }