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
@@ -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
@@ -85,6 +103,17 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
85
103
  const warnings = [];
86
104
  let endpointCount = 0;
87
105
 
106
+ // D-unsupported-annotation-warning: module-wide, computed once, independent of targetModule/
107
+ // endpoint iteration below -- the source document either uses one of the 5 permanently-dropped
108
+ // schema keywords or it doesn't, a fact about the WHOLE document, not any one operation.
109
+ for (const keyword of openapi?.unsupportedAnnotations ?? []) {
110
+ warnings.push(makeWarning('CONTRACT_OPENAPI_UNSUPPORTED_ANNOTATION_PRESENT', {
111
+ subject: keyword,
112
+ message: `the source OpenAPI document uses the schema keyword "${keyword}" at least once -- this projection unconditionally drops it (0 real occurrences were measured against the reference document this behavior was built against, but this document has at least one), so it is never represented in any operation's projected schema`,
113
+ detail: { keyword },
114
+ }));
115
+ }
116
+
88
117
  if (!targetModule) {
89
118
  warnings.push(makeWarning('CONTRACT_NO_MODULE', {
90
119
  message: 'no related module in the scan report -- emitting an empty operation set. Pass --module, or re-run `bskel scan` with terms that actually match the intended feature.',
@@ -122,6 +151,12 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
122
151
  let sourceResponses = null;
123
152
  let sourceRequestBody = null;
124
153
  let requestMediaTypesUnresolvedReason = null;
154
+ // A9: same discipline -- transient (Map), consulted below by pathParamsSchema(), never
155
+ // itself spread into the persisted operation object (see that function's own comment).
156
+ let pathParamSchemas = null;
157
+ // A10: same discipline, for the opt-in operation-level description.
158
+ let sourceDescription = null;
159
+ let descriptionUnresolvedReason = null;
125
160
 
126
161
  if (res) {
127
162
  switch (res.kind) {
@@ -148,6 +183,9 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
148
183
  sourceResponses = res.sourceResponses ?? null;
149
184
  sourceRequestBody = res.sourceRequestBody ?? null;
150
185
  requestMediaTypesUnresolvedReason = res.requestMediaTypesUnresolvedReason ?? null;
186
+ pathParamSchemas = res.pathParamSchemas ?? null;
187
+ sourceDescription = res.sourceDescription ?? null;
188
+ descriptionUnresolvedReason = res.descriptionUnresolvedReason ?? null;
151
189
  break;
152
190
  case 'adopted':
153
191
  // No @Operation(operationId=...) in source at all -- the id itself comes from
@@ -174,6 +212,9 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
174
212
  sourceResponses = res.sourceResponses ?? null;
175
213
  sourceRequestBody = res.sourceRequestBody ?? null;
176
214
  requestMediaTypesUnresolvedReason = res.requestMediaTypesUnresolvedReason ?? null;
215
+ pathParamSchemas = res.pathParamSchemas ?? null;
216
+ sourceDescription = res.sourceDescription ?? null;
217
+ descriptionUnresolvedReason = res.descriptionUnresolvedReason ?? null;
177
218
  warnings.push(makeWarning('CONTRACT_OPENAPI_DERIVED_OPERATION_ID', {
178
219
  subject: operationId,
179
220
  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 +361,22 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
320
361
  detail: { reason: requestMediaTypesUnresolvedReason, verb, path: route, operationId },
321
362
  }));
322
363
  }
364
+ // A10: only fires when --descriptions was passed AND the source declared one AND it
365
+ // exceeded MAX_DESCRIPTION_LENGTH -- independent from every other unresolved code above
366
+ // (a genuinely new failure mode, not reusing an existing one), same reasoning A8 used to
367
+ // justify its own new multipart code instead of overloading an existing one.
368
+ if (descriptionUnresolvedReason) {
369
+ warnings.push(makeWarning('CONTRACT_OPENAPI_DESCRIPTION_UNRESOLVED', {
370
+ subject: operationId,
371
+ message: `operationId "${operationId}" (${verb} ${route}) declares a description that could not be copied (${descriptionUnresolvedReason}) -- description stays unrepresented for this operation, same as before --descriptions`,
372
+ detail: { reason: descriptionUnresolvedReason, verb, path: route, operationId },
373
+ }));
374
+ }
375
+ const { pathParams, pathParamsHeuristic } = pathParamsSchema(route, pathParamSchemas);
323
376
  operations[operationId] = {
324
377
  verb,
325
378
  path: route,
326
- pathParams: pathParamsSchema(route),
379
+ pathParams,
327
380
  body: hasBody === null ? 'unknown' : hasBody,
328
381
  provenance,
329
382
  // A2/A3/A7: omitted entirely (not null/false) when there's nothing to project/copy --
@@ -338,6 +391,12 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
338
391
  ...(sourceTags ? { sourceTags } : {}),
339
392
  ...(sourceResponses ? { sourceResponses } : {}),
340
393
  ...(sourceRequestBody ? { sourceRequestBody } : {}),
394
+ // A9: omitted (not []) when every segment resolved from source, or the route has none.
395
+ ...(pathParamsHeuristic ? { pathParamsHeuristic } : {}),
396
+ // A10: omitted entirely when --descriptions was not passed, the source had none, or
397
+ // it failed the length cap -- same "omitted, never null/false" discipline as every
398
+ // other field above.
399
+ ...(sourceDescription ? { sourceDescription } : {}),
341
400
  };
342
401
  }
343
402
  }
@@ -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) {