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.
- package/README.md +12 -2
- package/bin/bskel.mjs +260 -11
- package/contracts/completeness.mjs +27 -2
- package/contracts/emit.mjs +68 -9
- package/contracts/export.mjs +94 -25
- package/contracts/openapi.mjs +307 -40
- package/handles/audit.mjs +83 -0
- package/handles/codec.mjs +11 -5
- package/handles/providers/java-spring/emit.mjs +9 -2
- package/handles/providers/java-spring/plan.mjs +20 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
- package/handles/providers/java-spring.mjs +2 -2
- package/handles/providers/python-fastapi/emit.mjs +5 -1
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
- package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
- package/lib/cli.mjs +51 -3
- package/lib/handles-manifest.mjs +10 -4
- package/lib/repo.mjs +56 -0
- package/package.json +1 -1
- package/scanners/adapters/_express-shared.mjs +17 -12
- package/scanners/adapters/generic-grep.mjs +5 -1
- package/scanners/adapters/java-spring.mjs +60 -14
- package/scanners/adapters/javascript-express.mjs +5 -1
- package/scanners/adapters/python-fastapi.mjs +17 -14
- package/scanners/adapters/typescript-express.mjs +6 -1
- package/scanners/registry.mjs +5 -2
- package/scanners/text-util.mjs +25 -0
- package/schemas/adapter.schema.json +6 -2
- package/schemas/contract-resolution.schema.json +6 -1
- package/schemas/feature-contract.schema.json +10 -1
- package/schemas/handles-plan.schema.json +1 -0
- package/stack/bootstrap/db-up.sh +52 -0
- package/stack/bootstrap/docker-compose.postgres.yml +18 -0
- package/stack/catalog/postgres-dev-db.yml +55 -0
package/contracts/emit.mjs
CHANGED
|
@@ -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 "
|
|
21
|
-
// for this item (
|
|
22
|
-
//
|
|
23
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
}
|
package/contracts/export.mjs
CHANGED
|
@@ -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.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// (
|
|
72
|
-
//
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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) {
|