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 +7 -2
- package/bin/bskel.mjs +13 -2
- package/contracts/completeness.mjs +6 -0
- package/contracts/emit.mjs +57 -9
- package/contracts/export.mjs +94 -25
- package/contracts/openapi.mjs +212 -40
- package/lib/cli.mjs +6 -1
- package/package.json +1 -1
- package/schemas/feature-contract.schema.json +10 -1
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`
|
|
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));
|
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
|
|
@@ -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
|
|
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
|
}
|
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) {
|
package/contracts/openapi.mjs
CHANGED
|
@@ -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);
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
468
|
-
// documentation-only `description`) is harmless and
|
|
469
|
-
//
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
}
|