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/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**: the contract carries no query or
123
- header parameters, no security requirements, no summaries or tags, and no per-status responses, so
124
- none of those appear — every omission is disclosed both in prose (`info.description`) and
125
- machine-readably (`info.x-bskel-omitted`). Nothing is invented to fill a gap: `security` is omitted
126
- rather than emitted as `[]` (an empty array would positively claim no authentication is required),
127
- and an operation whose body shape the contract doesn't know gets a JSON media-type entry with no
128
- schema rather than a fabricated one.
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
- MIT — see `LICENSE`.
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));
@@ -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 -- keeps
251
- // `openapi:null` (and any operation that isn't matched/adopted) byte-identical to
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: '4',
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,