backend-skeleton 1.0.0-beta.1 → 1.0.0-beta.2
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/COMMERCIAL-LICENSE-AGREEMENT.md +399 -0
- package/COMMERCIAL-LICENSE.md +46 -0
- package/LICENSE +661 -21
- package/README.md +20 -8
- package/bin/bskel.mjs +32 -1
- package/contracts/completeness.mjs +35 -0
- package/contracts/emit.mjs +114 -4
- package/contracts/export.mjs +298 -45
- package/contracts/openapi.mjs +593 -17
- package/package.json +6 -3
- package/schemas/feature-contract.schema.json +56 -1
package/README.md
CHANGED
|
@@ -119,13 +119,22 @@ inverse of `contract emit --openapi-file`. Useful for a Swagger UI page scoped t
|
|
|
119
119
|
instead of the whole repo, a client generator that can't follow `$ref` (an exported document has
|
|
120
120
|
none), or a mock server for one feature's operations.
|
|
121
121
|
|
|
122
|
-
It is a **deliberately lossy, narrow projection, and it says so
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
122
|
+
It is a **deliberately lossy, narrow projection, and it says so** — every omission is disclosed
|
|
123
|
+
both in prose (`info.description`) and machine-readably (`info.x-bskel-omitted`). Nothing is
|
|
124
|
+
invented to fill a gap: an operation whose body shape the contract doesn't know gets a JSON
|
|
125
|
+
media-type entry with no schema rather than a fabricated one, and an operation with no per-status
|
|
126
|
+
source data still collapses its 2xx/4xx/5xx bodies into two unions rather than guessing a status
|
|
127
|
+
code.
|
|
128
|
+
|
|
129
|
+
Query/header/cookie parameters, `security` (plus the referenced security schemes), `summary`,
|
|
130
|
+
`tags`, per-status responses, and non-JSON request media types (e.g. `multipart/form-data`) **are**
|
|
131
|
+
emitted — but only when a real `--openapi-file` source document said something for that exact
|
|
132
|
+
operation, copied byte-for-byte, never reconstructed. `security: []` is emitted when the source
|
|
133
|
+
document itself said `[]` (a genuine claim that no authentication is required); it is never
|
|
134
|
+
invented as a default. Where no source document was given, or it said nothing for an operation
|
|
135
|
+
(or a particular field of one), the key is simply omitted, meaning "unspecified." Operation-level
|
|
136
|
+
`description` remains excluded — measured too expensive to copy by default (real average 2,442.7
|
|
137
|
+
bytes/operation).
|
|
129
138
|
|
|
130
139
|
Export refuses a zero-operation contract, refuses when the scan found a global path prefix the
|
|
131
140
|
contract's paths don't reflect (`--allow-unprefixed` overrides), and stamps every document with an
|
|
@@ -281,4 +290,7 @@ are not part of the published package — clone this repository directly if you
|
|
|
281
290
|
|
|
282
291
|
## License
|
|
283
292
|
|
|
284
|
-
|
|
293
|
+
Dual-licensed: [AGPL-3.0-or-later](./LICENSE) for open-source use, or a
|
|
294
|
+
commercial license for closed-source/proprietary use without AGPL's
|
|
295
|
+
copyleft obligations. See [`COMMERCIAL-LICENSE.md`](./COMMERCIAL-LICENSE.md)
|
|
296
|
+
for why, and how to obtain one.
|
package/bin/bskel.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { introspectSchema, describeConnectionError } from '../scanners/db/intros
|
|
|
25
25
|
import { renderScanMarkdown, renderPlanConstraints, renderScanExplain } from '../scanners/render.mjs';
|
|
26
26
|
import { ADAPTERS, LOAD_ERRORS, adapterById } from '../scanners/registry.mjs';
|
|
27
27
|
import { COMMAND_CAPABILITIES, CAPABILITY_SATISFIERS, explainMissingCapability } from '../scanners/capabilities.mjs';
|
|
28
|
-
import { buildContract, selectModule } from '../contracts/emit.mjs';
|
|
28
|
+
import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contracts/emit.mjs';
|
|
29
29
|
import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
|
|
30
30
|
import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
|
|
31
31
|
import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
|
|
@@ -836,6 +836,15 @@ function cmdContractEmit(args) {
|
|
|
836
836
|
const flags = parseCommand('contract emit', args);
|
|
837
837
|
if (flags.help) { console.log(renderCommandHelp('contract emit')); process.exit(0); }
|
|
838
838
|
setContext('contract emit', flags);
|
|
839
|
+
|
|
840
|
+
// Real dogfooding finding (Phase 3, Team-IZ/Backend, 2026-08-24): --path-prefix only has any
|
|
841
|
+
// effect inside buildReconciliation(), which only runs when --openapi-file is also given (see
|
|
842
|
+
// below) -- passing --path-prefix alone was a silent no-op with zero feedback. Checked before
|
|
843
|
+
// any repo/gate work so the failure is immediate and doesn't depend on feature/gate state.
|
|
844
|
+
if (flags['path-prefix'] && !flags['openapi-file']) {
|
|
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
|
+
}
|
|
847
|
+
|
|
839
848
|
const root = requireRepoRoot();
|
|
840
849
|
requirePreflightPassed(root);
|
|
841
850
|
requireValidFeatureId(flags.feature);
|
|
@@ -962,6 +971,13 @@ function cmdContractEmit(args) {
|
|
|
962
971
|
console.log(`openapi: ${s.schema_resolved} request body schema(s) projected, ${s.schema_unresolved} unresolved`);
|
|
963
972
|
console.log(`openapi: ${s.response_schema_resolved} response + ${s.error_schema_resolved} error schema(s) projected, ${s.response_schema_unresolved + s.error_schema_unresolved} unresolved`);
|
|
964
973
|
}
|
|
974
|
+
// A7: dialect-independent (security/summary/tags) always ran; parameters only when
|
|
975
|
+
// schema projection was enabled -- printed unconditionally either way, same "just print
|
|
976
|
+
// the numbers, they may be zero" style as the two lines above.
|
|
977
|
+
const p = reconciliation.stats;
|
|
978
|
+
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
|
+
// A8: same "just print the numbers" style.
|
|
980
|
+
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`);
|
|
965
981
|
}
|
|
966
982
|
}
|
|
967
983
|
for (const w of contract.warnings) console.error(`warning[${w.severity}] ${w.code}${w.subject ? ` (${w.subject})` : ''}: ${w.message}`);
|
|
@@ -1005,6 +1021,14 @@ function loadContract(root, featureId) {
|
|
|
1005
1021
|
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no contract at ${contractPath} -- run \`bskel contract emit --feature ${featureId}\` first`);
|
|
1006
1022
|
}
|
|
1007
1023
|
const parsed = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
|
|
1024
|
+
// A7: a targeted pre-check for the single most common way an old contract fails the schema
|
|
1025
|
+
// below -- an `sbf_contract` bump has a real read-time consequence for the first time (S5's
|
|
1026
|
+
// schema validation postdates A2/A3, which had none), so a contract emitted by an older bskel
|
|
1027
|
+
// gets a friendly re-emit instruction instead of a raw ajv dump. Any OTHER schema violation
|
|
1028
|
+
// (hand-edited, corrupted, etc.) still falls through to the generic message below unchanged.
|
|
1029
|
+
if (typeof parsed?.sbf_contract === 'string' && parsed.sbf_contract !== CONTRACT_SCHEMA_VERSION) {
|
|
1030
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${contractPath}: this contract was emitted by an older bskel (sbf_contract "${parsed.sbf_contract}", expected "${CONTRACT_SCHEMA_VERSION}") -- re-run \`bskel contract emit --feature ${featureId}\` to re-emit it in the current format.`);
|
|
1031
|
+
}
|
|
1008
1032
|
// S5 (D-persistence-integrity): validated against schemas/feature-contract.schema.json (the
|
|
1009
1033
|
// meta-schema for THIS file's own shape -- not the same as contracts/validate.mjs, which
|
|
1010
1034
|
// validates a runtime agent envelope's PAYLOAD against one operation inside an already-valid
|
|
@@ -1116,6 +1140,13 @@ function cmdContractExport(args) {
|
|
|
1116
1140
|
if (built.literalStatusStandIn) {
|
|
1117
1141
|
console.error('note: --status-codes literal writes `200` for every documented success body. The source contract records no status codes whatsoever, so `200` is a bskel-chosen stand-in, NOT a claim that any of these operations actually returns 200 -- `--status-codes range` (the default) emits the spec-legal `2XX` range key and invents nothing.');
|
|
1118
1142
|
}
|
|
1143
|
+
// A7: ONE stderr note, not per-operation -- mixed passthrough coverage can only arise in an
|
|
1144
|
+
// explicitly waived `partial` contract (a `complete` one has 100% coverage by construction, see
|
|
1145
|
+
// D-openapi-passthrough); disclosure, not a new refusal. The exact per-operation map is always in
|
|
1146
|
+
// info.x-bskel-generated.passthrough regardless of whether this note fires.
|
|
1147
|
+
if (built.mixedPassthrough) {
|
|
1148
|
+
console.error(`note: ${built.passthroughWithoutCount} of ${Object.keys(contract.operations).length} operation(s) in this export carry no source-document passthrough (parameters/security/summary/tags) -- see info.x-bskel-generated.passthrough for exactly which ones.`);
|
|
1149
|
+
}
|
|
1119
1150
|
|
|
1120
1151
|
const rendered = `${JSON.stringify(built.document, null, 2)}\n`;
|
|
1121
1152
|
if (flags.out) {
|
|
@@ -64,6 +64,41 @@ export const WARNING_CODES = Object.freeze({
|
|
|
64
64
|
// enhancement, not a defect. See D-openapi-response-schema in DECISIONS.md.
|
|
65
65
|
CONTRACT_OPENAPI_RESPONSE_SCHEMA_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
|
|
66
66
|
CONTRACT_OPENAPI_ERROR_SCHEMA_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
|
|
67
|
+
// A7: at least one non-path parameter on a matched/adopted operation could not be copied into
|
|
68
|
+
// sourceParameters -- an unsupported shape ($ref/content/unknown key/cap exceeded), or a schema
|
|
69
|
+
// inlineSchema() itself could not resolve. WARN, not ERROR: verb/path/body are still correctly
|
|
70
|
+
// contracted, this is a missed enhancement -- same reasoning as CONTRACT_OPENAPI_SCHEMA_UNRESOLVED.
|
|
71
|
+
CONTRACT_OPENAPI_PARAMETERS_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
|
|
72
|
+
// A7: the operation declared `security` in the source document, but at least one named scheme
|
|
73
|
+
// could not be resolved against components.securitySchemes (or the requirement itself was
|
|
74
|
+
// malformed/oversized) -- the WHOLE security value is dropped for that operation rather than a
|
|
75
|
+
// partial/dangling one, and this warns about it. Deliberately a SEPARATE code from
|
|
76
|
+
// CONTRACT_OPENAPI_PARAMETERS_UNRESOLVED (same operation, same subject, unrelated failure) --
|
|
77
|
+
// same "a waiver for one must never silently cover the other" reasoning D-openapi-response-schema
|
|
78
|
+
// already established for the response/error split. WARN: omitting security is still honest
|
|
79
|
+
// ("unspecified"), just less specific than the source document actually was.
|
|
80
|
+
CONTRACT_OPENAPI_SECURITY_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
|
|
81
|
+
// Real dogfooding finding (Phase 3, Team-IZ/Backend, 2026-08-24): A1 §7's `path_prefix_signals`
|
|
82
|
+
// were only ever consulted at `contract export` time (A6's own refusal, contracts/export.mjs's
|
|
83
|
+
// unreflectedPathPrefixes()) -- a module with an unaddressed global prefix signal could still
|
|
84
|
+
// classify as `complete` at `contract emit` time, which is exactly backwards: the paths ARE
|
|
85
|
+
// wrong, `contract export` just happens to independently catch it before publishing. ERROR (not
|
|
86
|
+
// WARN) for the same reason CONTRACT_OPENAPI_DRIFT is ERROR: a wrong path is a correctness
|
|
87
|
+
// defect, not a missed enhancement. Waivable so a false-positive signal (see --allow-unprefixed's
|
|
88
|
+
// own escape hatch) doesn't permanently block a module it genuinely doesn't apply to.
|
|
89
|
+
CONTRACT_UNREFLECTED_PATH_PREFIX: { severity: SEVERITY.ERROR, waivable: true },
|
|
90
|
+
// A8: the operation's request body declared a non-JSON media type (e.g. multipart/form-data)
|
|
91
|
+
// whose schema could not be fully projected -- the media type name is still recorded, its
|
|
92
|
+
// shape is not. Deliberately a SEPARATE code from CONTRACT_OPENAPI_SCHEMA_UNRESOLVED (the
|
|
93
|
+
// application/json-body code) -- the two can co-occur on one operation, and a shared code
|
|
94
|
+
// would let a waiver for one silently cover the other, same reasoning D-openapi-response-schema
|
|
95
|
+
// established for its own response/error split. WARN: verb/path/the JSON body (if any) stay
|
|
96
|
+
// correctly contracted, this is a missed enhancement. No equivalent code exists for per-status
|
|
97
|
+
// responses -- that failure mode is not independent of A3's own response/error unresolved
|
|
98
|
+
// codes (both walk the same responses map through the same inlineSchema() at the same cap), so
|
|
99
|
+
// it reuses CONTRACT_OPENAPI_RESPONSE_SCHEMA_UNRESOLVED/CONTRACT_OPENAPI_ERROR_SCHEMA_UNRESOLVED
|
|
100
|
+
// unchanged -- see D-openapi-per-status.
|
|
101
|
+
CONTRACT_OPENAPI_REQUEST_MEDIA_TYPE_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
|
|
67
102
|
});
|
|
68
103
|
|
|
69
104
|
export const WARNING_CODE_NAMES = Object.freeze(Object.keys(WARNING_CODES));
|
package/contracts/emit.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { makeWarning, classifyContract } from './completeness.mjs';
|
|
3
3
|
import { findMethodParams } from '../scanners/adapters/_java-spring-analyzer.mjs';
|
|
4
|
+
import { pathPrefixCandidates, unreflectedPathPrefixes } from './export.mjs';
|
|
4
5
|
|
|
5
6
|
// D-security-2: a plain UUID `pattern`, not `format: 'uuid'`. ajv-formats' uuid format accepts
|
|
6
7
|
// an optional `urn:uuid:` prefix (per its RFC 4122 reading), but Spring's `UUID` path-variable
|
|
@@ -14,6 +15,14 @@ import { findMethodParams } from '../scanners/adapters/_java-spring-analyzer.mjs
|
|
|
14
15
|
// a path param. Direction stays one-way (openapi.mjs imports from emit.mjs, never the reverse).
|
|
15
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}$';
|
|
16
17
|
|
|
18
|
+
// A7/A8: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
|
|
19
|
+
// const -- bin/bskel.mjs's loadContract() imports this too, so the friendly "re-emit with the
|
|
20
|
+
// current bskel" message and the value actually written here cannot drift apart. Bumped "5" -> "6"
|
|
21
|
+
// for this item (sourceResponses/sourceRequestBody) -- cheap this time: the friendly re-emit
|
|
22
|
+
// pre-check in loadContract() needed zero code change, it already compares against this imported
|
|
23
|
+
// constant -- see D-openapi-per-status.
|
|
24
|
+
export const CONTRACT_SCHEMA_VERSION = '6';
|
|
25
|
+
|
|
17
26
|
function pathParamsSchema(routePath) {
|
|
18
27
|
const params = [...routePath.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
|
|
19
28
|
const properties = {};
|
|
@@ -101,6 +110,18 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
101
110
|
let responseSchemaUnresolvedReason = null;
|
|
102
111
|
let errorSchema = null;
|
|
103
112
|
let errorSchemaUnresolvedReason = null;
|
|
113
|
+
// A7: same matched/adopted-only discipline as A2/A3's fields above -- stays null for
|
|
114
|
+
// every other kind.
|
|
115
|
+
let sourceParameters = null;
|
|
116
|
+
let parametersUnresolved = null;
|
|
117
|
+
let sourceSecurity = null;
|
|
118
|
+
let securityUnresolvedReason = null;
|
|
119
|
+
let sourceSummary = null;
|
|
120
|
+
let sourceTags = null;
|
|
121
|
+
// A8: same discipline, for the two new passthrough fields.
|
|
122
|
+
let sourceResponses = null;
|
|
123
|
+
let sourceRequestBody = null;
|
|
124
|
+
let requestMediaTypesUnresolvedReason = null;
|
|
104
125
|
|
|
105
126
|
if (res) {
|
|
106
127
|
switch (res.kind) {
|
|
@@ -118,6 +139,15 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
118
139
|
responseSchemaUnresolvedReason = res.responseSchemaUnresolvedReason ?? null;
|
|
119
140
|
errorSchema = res.errorSchema ?? null;
|
|
120
141
|
errorSchemaUnresolvedReason = res.errorSchemaUnresolvedReason ?? null;
|
|
142
|
+
sourceParameters = res.sourceParameters ?? null;
|
|
143
|
+
parametersUnresolved = res.parametersUnresolved ?? null;
|
|
144
|
+
sourceSecurity = res.sourceSecurity ?? null;
|
|
145
|
+
securityUnresolvedReason = res.securityUnresolvedReason ?? null;
|
|
146
|
+
sourceSummary = res.sourceSummary ?? null;
|
|
147
|
+
sourceTags = res.sourceTags ?? null;
|
|
148
|
+
sourceResponses = res.sourceResponses ?? null;
|
|
149
|
+
sourceRequestBody = res.sourceRequestBody ?? null;
|
|
150
|
+
requestMediaTypesUnresolvedReason = res.requestMediaTypesUnresolvedReason ?? null;
|
|
121
151
|
break;
|
|
122
152
|
case 'adopted':
|
|
123
153
|
// No @Operation(operationId=...) in source at all -- the id itself comes from
|
|
@@ -135,6 +165,15 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
135
165
|
responseSchemaUnresolvedReason = res.responseSchemaUnresolvedReason ?? null;
|
|
136
166
|
errorSchema = res.errorSchema ?? null;
|
|
137
167
|
errorSchemaUnresolvedReason = res.errorSchemaUnresolvedReason ?? null;
|
|
168
|
+
sourceParameters = res.sourceParameters ?? null;
|
|
169
|
+
parametersUnresolved = res.parametersUnresolved ?? null;
|
|
170
|
+
sourceSecurity = res.sourceSecurity ?? null;
|
|
171
|
+
securityUnresolvedReason = res.securityUnresolvedReason ?? null;
|
|
172
|
+
sourceSummary = res.sourceSummary ?? null;
|
|
173
|
+
sourceTags = res.sourceTags ?? null;
|
|
174
|
+
sourceResponses = res.sourceResponses ?? null;
|
|
175
|
+
sourceRequestBody = res.sourceRequestBody ?? null;
|
|
176
|
+
requestMediaTypesUnresolvedReason = res.requestMediaTypesUnresolvedReason ?? null;
|
|
138
177
|
warnings.push(makeWarning('CONTRACT_OPENAPI_DERIVED_OPERATION_ID', {
|
|
139
178
|
subject: operationId,
|
|
140
179
|
message: `operationId "${operationId}" for ${res.verb} ${res.path} was not found in the source (no @Operation(operationId=...)) -- adopted directly from the OpenAPI document instead`,
|
|
@@ -241,18 +280,64 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
241
280
|
detail: { reason: errorSchemaUnresolvedReason, verb, path: route, operationId },
|
|
242
281
|
}));
|
|
243
282
|
}
|
|
283
|
+
// A7: at least one non-path parameter on this matched/adopted operation could not be
|
|
284
|
+
// copied verbatim -- an unsupported parameter shape ($ref/content/unknown key/cap
|
|
285
|
+
// exceeded) or a schema inlineSchema() itself could not resolve. One warning per
|
|
286
|
+
// operation (not per parameter), same "subject is the operationId" shape as the schema-
|
|
287
|
+
// unresolved codes above -- the message enumerates every individual failure.
|
|
288
|
+
if (parametersUnresolved) {
|
|
289
|
+
warnings.push(makeWarning('CONTRACT_OPENAPI_PARAMETERS_UNRESOLVED', {
|
|
290
|
+
subject: operationId,
|
|
291
|
+
message: `operationId "${operationId}" (${verb} ${route}) has ${parametersUnresolved.length} parameter(s) that could not be copied from the source document: ${parametersUnresolved.map((p) => `${p.in ?? '?'} "${p.name ?? '?'}" (${p.reason})`).join(', ')}`,
|
|
292
|
+
detail: { verb, path: route, operationId, unresolved: parametersUnresolved },
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
// A7: the operation declared `security` in the source document, but at least one named
|
|
296
|
+
// scheme could not be resolved against components.securitySchemes (or the requirement
|
|
297
|
+
// itself was malformed/oversized) -- the WHOLE security value is dropped for this
|
|
298
|
+
// operation, never a dangling reference (see contracts/openapi.mjs's applySecurity).
|
|
299
|
+
if (securityUnresolvedReason) {
|
|
300
|
+
warnings.push(makeWarning('CONTRACT_OPENAPI_SECURITY_UNRESOLVED', {
|
|
301
|
+
subject: operationId,
|
|
302
|
+
message: `operationId "${operationId}" (${verb} ${route}) declares a security requirement that could not be copied (${securityUnresolvedReason}) -- security stays unrepresented for this operation, same as before --openapi-file`,
|
|
303
|
+
detail: { reason: securityUnresolvedReason, verb, path: route, operationId },
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
// A8: the operation's request body declared a non-application/json media type (e.g.
|
|
307
|
+
// multipart/form-data) whose schema could not be fully projected -- the media type
|
|
308
|
+
// itself is still recorded in sourceRequestBody (see contracts/openapi.mjs's
|
|
309
|
+
// applyRequestMediaTypes), only its shape is missing. A SEPARATE code from
|
|
310
|
+
// CONTRACT_OPENAPI_SCHEMA_UNRESOLVED (the application/json body's own code): the two
|
|
311
|
+
// CAN co-occur on one operation (an endpoint legally accepting both application/json
|
|
312
|
+
// and multipart/form-data), and warningKey is {code, subject=operationId} -- sharing a
|
|
313
|
+
// code would let a waiver for one silently cover the other, the same reasoning
|
|
314
|
+
// D-openapi-response-schema's response/error split already established and A7 reused
|
|
315
|
+
// for parameters/security.
|
|
316
|
+
if (requestMediaTypesUnresolvedReason) {
|
|
317
|
+
warnings.push(makeWarning('CONTRACT_OPENAPI_REQUEST_MEDIA_TYPE_UNRESOLVED', {
|
|
318
|
+
subject: operationId,
|
|
319
|
+
message: `operationId "${operationId}" (${verb} ${route}) declares a non-JSON request media type that could not be fully copied (${requestMediaTypesUnresolvedReason}) -- the media type name is still recorded where possible, its shape is not`,
|
|
320
|
+
detail: { reason: requestMediaTypesUnresolvedReason, verb, path: route, operationId },
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
244
323
|
operations[operationId] = {
|
|
245
324
|
verb,
|
|
246
325
|
path: route,
|
|
247
326
|
pathParams: pathParamsSchema(route),
|
|
248
327
|
body: hasBody === null ? 'unknown' : hasBody,
|
|
249
328
|
provenance,
|
|
250
|
-
// A2/A3: omitted entirely (not null/false) when there's nothing to project --
|
|
251
|
-
// `openapi:null` (and any operation that isn't matched/adopted) byte-identical
|
|
252
|
-
// pre-A2/A3 output, the same guarantee A1 established for its own fields.
|
|
329
|
+
// A2/A3/A7: omitted entirely (not null/false) when there's nothing to project/copy --
|
|
330
|
+
// keeps `openapi:null` (and any operation that isn't matched/adopted) byte-identical
|
|
331
|
+
// to pre-A2/A3/A7 output, the same guarantee A1 established for its own fields.
|
|
253
332
|
...(requestBodySchema ? { requestBodySchema, requestBodyRequired } : {}),
|
|
254
333
|
...(responseSchema ? { responseSchema } : {}),
|
|
255
334
|
...(errorSchema ? { errorSchema } : {}),
|
|
335
|
+
...(sourceParameters ? { sourceParameters } : {}),
|
|
336
|
+
...(sourceSecurity ? { sourceSecurity } : {}),
|
|
337
|
+
...(sourceSummary ? { sourceSummary } : {}),
|
|
338
|
+
...(sourceTags ? { sourceTags } : {}),
|
|
339
|
+
...(sourceResponses ? { sourceResponses } : {}),
|
|
340
|
+
...(sourceRequestBody ? { sourceRequestBody } : {}),
|
|
256
341
|
};
|
|
257
342
|
}
|
|
258
343
|
}
|
|
@@ -267,19 +352,44 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
267
352
|
}));
|
|
268
353
|
}
|
|
269
354
|
|
|
355
|
+
// Real dogfooding finding (Phase 3, Team-IZ/Backend, 2026-08-24): reuses A6's own
|
|
356
|
+
// pathPrefixCandidates()/unreflectedPathPrefixes() (contracts/export.mjs) rather than
|
|
357
|
+
// re-deriving prefix detection here -- this is the SAME check `contract export` already runs,
|
|
358
|
+
// just moved earlier so `completeness` itself reflects it instead of only being caught later at
|
|
359
|
+
// export time. Runs against the final `operations` (post-reconciliation, if any) rather than
|
|
360
|
+
// gating on `!openapi`, because a partially-reconciled contract (matched paths corrected, a
|
|
361
|
+
// drift/missing one left at its uncorrected scan path) is exactly the mixed case
|
|
362
|
+
// unreflectedPathPrefixes() itself warns is no safer than a wholly-unprefixed contract.
|
|
363
|
+
for (const prefix of unreflectedPathPrefixes({ operations }, pathPrefixCandidates(scanReport.path_prefix_signals))) {
|
|
364
|
+
warnings.push(makeWarning('CONTRACT_UNREFLECTED_PATH_PREFIX', {
|
|
365
|
+
subject: prefix,
|
|
366
|
+
message: `the scan found a global path-prefix signal (${prefix}) that this contract's operation paths do not reflect -- \`contract export\` already refuses to publish this by default (see D-openapi-export); this warning surfaces the same issue at \`contract emit\` time instead of only at export time. Re-run with --openapi-file to correct the paths, or waive if this signal genuinely does not apply to this module.`,
|
|
367
|
+
detail: { prefix },
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
|
|
270
371
|
const completeness = {
|
|
271
372
|
status: classifyContract({ operations, warnings }),
|
|
272
373
|
operation_count: Object.keys(operations).length,
|
|
273
374
|
endpoint_count: endpointCount,
|
|
274
375
|
};
|
|
275
376
|
|
|
377
|
+
// A7: only schemes actually referenced by at least one operation's COPIED sourceSecurity
|
|
378
|
+
// requirement anywhere in this contract -- omitted entirely (not {}) when openapi is null or no
|
|
379
|
+
// operation ended up with a resolvable security requirement, same byte-identity discipline as
|
|
380
|
+
// every other openapi-derived field.
|
|
381
|
+
const sourceSecuritySchemes = openapi && openapi.sourceSecuritySchemes && openapi.sourceSecuritySchemes.size > 0
|
|
382
|
+
? Object.fromEntries(openapi.sourceSecuritySchemes)
|
|
383
|
+
: null;
|
|
384
|
+
|
|
276
385
|
return {
|
|
277
|
-
sbf_contract:
|
|
386
|
+
sbf_contract: CONTRACT_SCHEMA_VERSION,
|
|
278
387
|
feature_id: featureId,
|
|
279
388
|
feature_uid: featureUid,
|
|
280
389
|
source: targetModule
|
|
281
390
|
? { adapter: scanReport.adapter, module: targetModule.module, provenance: openapi ? 'scan+openapi' : 'scan' }
|
|
282
391
|
: { adapter: scanReport.adapter ?? null, module: null, provenance: 'none' },
|
|
392
|
+
...(sourceSecuritySchemes ? { sourceSecuritySchemes } : {}),
|
|
283
393
|
operations,
|
|
284
394
|
warnings,
|
|
285
395
|
completeness,
|