backend-skeleton 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
@@ -0,0 +1,176 @@
1
+ // A5: distinguishes "a contract file was written" from "this contract is complete enough to
2
+ // trust". `buildContract()` (contracts/emit.mjs) always succeeds and always returns SOME
3
+ // object, even when it found zero usable operations -- Team-IZ-Backend's `codeanalysis` module
4
+ // (1 entity, 0 controllers) produces operations:0, warnings:0 with the pre-A5 code, and the
5
+ // `contract` gate passed silently (see D-contract-completeness in DECISIONS.md for the full
6
+ // before/after). This module is the single place that turns a contract's warnings into a
7
+ // completeness verdict and evaluates that verdict against recorded waivers -- contracts/emit.mjs
8
+ // stays a pure "what did the scan find" function and never looks at waivers itself, and
9
+ // bin/bskel.mjs never re-derives severity/blocking logic inline.
10
+ import { readJsonIfExists, writeFileAtomic } from '../lib/fsutil.mjs';
11
+ import { specPath } from '../lib/paths.mjs';
12
+ import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
13
+
14
+ export const SEVERITY = Object.freeze({ ERROR: 'error', WARN: 'warn' });
15
+ export const COMPLETENESS = Object.freeze({ COMPLETE: 'complete', PARTIAL: 'partial', BLOCKED: 'blocked' });
16
+
17
+ // `waivable: false` codes only ever co-occur with zero operations (CONTRACT_NO_MODULE and
18
+ // CONTRACT_EMPTY both mean the endpoint loop in buildContract() never ran at all) -- so gating
19
+ // waivers on `completeness === 'blocked'` in cmdContractWaive is sufficient to keep them
20
+ // unwaivable; there is no case where either fires with operations > 0.
21
+ export const WARNING_CODES = Object.freeze({
22
+ CONTRACT_NO_MODULE: { severity: SEVERITY.ERROR, waivable: false },
23
+ CONTRACT_EMPTY: { severity: SEVERITY.ERROR, waivable: false },
24
+ CONTRACT_UNMATCHED_ENDPOINT: { severity: SEVERITY.ERROR, waivable: true },
25
+ CONTRACT_DUPLICATE_OPERATION_ID: { severity: SEVERITY.ERROR, waivable: true },
26
+ CONTRACT_BODY_UNKNOWN: { severity: SEVERITY.WARN, waivable: true },
27
+ // A1: an operationId correlated by the scan (or by OpenAPI reconciliation) that disagrees
28
+ // with the OpenAPI document on verb or path in a way the inferred/given path prefix can't
29
+ // explain -- a real conflict, not just a missing prefix. Never silently resolved in favor of
30
+ // either source; the scan's own value is kept (fail-closed) and this blocks until a human
31
+ // looks. See contracts/openapi.mjs.
32
+ CONTRACT_OPENAPI_DRIFT: { severity: SEVERITY.ERROR, waivable: true },
33
+ // A1: the scan found a real operationId that simply isn't in the OpenAPI document at all --
34
+ // distinct from DRIFT (found but disagrees) because a waiver keyed on one must never silently
35
+ // cover the other if the underlying cause changes later (see D-contract-completeness's
36
+ // wildcard-waiver reasoning, reapplied here). Left uncorrected (still the unprefixed scan
37
+ // path) specifically so this can't be mistaken for a successful reconciliation.
38
+ CONTRACT_OPENAPI_MISSING_OPERATION: { severity: SEVERITY.ERROR, waivable: true },
39
+ // A1: an unmatched (no operationId) endpoint's verb+normalized-path resolved to more than one
40
+ // OpenAPI operation candidate -- never guessed, see contracts/openapi.mjs's reconcileModule.
41
+ CONTRACT_OPENAPI_AMBIGUOUS: { severity: SEVERITY.ERROR, waivable: true },
42
+ // A1: an operationId was adopted from the OpenAPI document itself (the scan found no
43
+ // @Operation(operationId=...) at all) rather than confirmed against source -- low-risk (same
44
+ // class as CONTRACT_BODY_UNKNOWN): the id is real and addressable, but isn't pinned in Java
45
+ // source, so renaming the handler method silently changes what clients see.
46
+ CONTRACT_OPENAPI_DERIVED_OPERATION_ID: { severity: SEVERITY.WARN, waivable: true },
47
+ // A2: a `matched`/`adopted` operation (path/verb already reconciled) whose OpenAPI requestBody
48
+ // declares an application/json schema, but that schema could not be projected into a
49
+ // self-contained JSON Schema -- an unsupported keyword, an over-long or uncompilable `pattern`,
50
+ // a $ref cycle, or a depth/node-count cap. Deliberately WARN, not ERROR: the contract is still
51
+ // CORRECT (the pre-A2 `body:true -> {type:'object'}` fallback still applies), just less
52
+ // specific -- a missed enhancement, not a defect. Making this ERROR would make `partial`/
53
+ // `blocked` depend on how exotic a downstream DTO's validation annotations happen to be, across
54
+ // every real module -- same class as CONTRACT_BODY_UNKNOWN. See contracts/openapi.mjs's
55
+ // inlineSchema() and D-openapi-request-schema in DECISIONS.md.
56
+ CONTRACT_OPENAPI_SCHEMA_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
57
+ // A3: same shape as CONTRACT_OPENAPI_SCHEMA_UNRESOLVED above, one for the response (2xx) side
58
+ // and one for the error (4xx/5xx) side -- deliberately TWO codes, not one shared with each
59
+ // other or with the request-side code above. An operation's request/response/error projection
60
+ // can each fail independently for unrelated reasons; if they shared a code, a waiver for one
61
+ // failure (keyed on {code, subject=operationId}) would silently also cover the other, and a
62
+ // future severity change to one direction would force splitting a shipped code. Same WARN
63
+ // reasoning: the pre-A3 unconstrained response/error check still applies, so this is a missed
64
+ // enhancement, not a defect. See D-openapi-response-schema in DECISIONS.md.
65
+ CONTRACT_OPENAPI_RESPONSE_SCHEMA_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
66
+ CONTRACT_OPENAPI_ERROR_SCHEMA_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
67
+ });
68
+
69
+ export const WARNING_CODE_NAMES = Object.freeze(Object.keys(WARNING_CODES));
70
+
71
+ export function getWarningCode(code) {
72
+ return Object.hasOwn(WARNING_CODES, code) ? WARNING_CODES[code] : null;
73
+ }
74
+
75
+ // The typo-defense point for `bskel contract waive --code <CODE>` -- same pattern as
76
+ // lib/gate-definitions.mjs's requireGateDefinition.
77
+ export function requireWarningCode(code) {
78
+ const spec = getWarningCode(code);
79
+ if (!spec) {
80
+ throw new Error(`unknown contract warning code "${code}" -- known codes: ${WARNING_CODE_NAMES.join(', ')}`);
81
+ }
82
+ return spec;
83
+ }
84
+
85
+ // severity is stamped onto the warning at creation time (so a plain JSON reader never needs this
86
+ // module to know what a contract means), but blocking decisions always re-derive severity from
87
+ // WARNING_CODES, not from the stamped value -- see evaluateResolution.
88
+ export function makeWarning(code, { subject = null, message, detail = {} }) {
89
+ const spec = requireWarningCode(code);
90
+ return { code, severity: spec.severity, subject, message, detail };
91
+ }
92
+
93
+ // The waiver key. Deliberately code+subject only, NEVER message -- message text gets rephrased
94
+ // over time, and a waiver keyed on it would silently stop matching. subject is derived from the
95
+ // stable verb+path or operationId, not from anything a human might reword.
96
+ export function warningKey(warning) {
97
+ return `${warning.code}::${warning.subject ?? '*'}`;
98
+ }
99
+
100
+ export function countByCode(warnings) {
101
+ const counts = {};
102
+ for (const w of warnings) counts[w.code] = (counts[w.code] ?? 0) + 1;
103
+ return counts;
104
+ }
105
+
106
+ // Completeness from the contract's own content alone -- knows nothing about waivers (see
107
+ // evaluateResolution for the waiver-aware verdict). Zero operations always means `blocked`
108
+ // regardless of what warnings say (a blocked contract usually has zero warnings too, since the
109
+ // endpoint loop that would generate them never ran -- but this stays correct either way).
110
+ export function classifyContract({ operations, warnings }) {
111
+ if (Object.keys(operations).length === 0) return COMPLETENESS.BLOCKED;
112
+ if (warnings.some((w) => w.severity === SEVERITY.ERROR)) return COMPLETENESS.PARTIAL;
113
+ return COMPLETENESS.COMPLETE;
114
+ }
115
+
116
+ const RESOLUTION_SCHEMA = 'sbf.contract-resolution/1';
117
+
118
+ export function resolutionPath(root, featureId) {
119
+ return specPath(root, featureId, 'contracts', `${featureId}.resolution.json`);
120
+ }
121
+
122
+ export function loadResolution(root, featureId) {
123
+ const path = resolutionPath(root, featureId);
124
+ const parsed = readJsonIfExists(path);
125
+ if (parsed === null) {
126
+ return { schema: RESOLUTION_SCHEMA, feature_id: featureId, waivers: [] };
127
+ }
128
+ // S5 (D-persistence-integrity): lib-style read function -- throws a plain Error (same
129
+ // convention as lib/state.mjs's loadState), which bin/bskel.mjs's main() catch-all already
130
+ // treats as a documented case ("a malformed-state read", exit 14/BAD_ARGS).
131
+ const { ok, errors } = validateAgainstSchema('contract-resolution.schema.json', parsed);
132
+ if (!ok) {
133
+ throw new Error(`${path}: does not match schemas/contract-resolution.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
134
+ }
135
+ return parsed;
136
+ }
137
+
138
+ // S5 (D-persistence-integrity): the write-side sibling of loadResolution() above -- validated
139
+ // before it touches disk, same "fail loud here, not later" reasoning as every other write site
140
+ // this item touched. Deliberately does NOT lock by itself: the load-modify-save race this file
141
+ // has (`bskel contract waive` reads the current resolution, appends new waiver entries, then
142
+ // writes -- no synchronization) can only be closed by locking the WHOLE cycle, not just the final
143
+ // write -- see bin/bskel.mjs's cmdContractWaive, which wraps loadResolution()...saveResolution()
144
+ // in withLockSync(), the same shape lib/state.mjs's setGate() already uses for its own
145
+ // load-modify-save.
146
+ export function saveResolution(root, featureId, resolution) {
147
+ const { ok, errors } = validateAgainstSchema('contract-resolution.schema.json', resolution);
148
+ if (!ok) {
149
+ throw new Error(`refusing to write an invalid contract resolution for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
150
+ }
151
+ writeFileAtomic(resolutionPath(root, featureId), `${JSON.stringify(resolution, null, 2)}\n`);
152
+ return resolution;
153
+ }
154
+
155
+ // The waiver-aware verdict `bskel contract emit`/`bskel contract waive` act on. `blocked` is
156
+ // never waivable, full stop -- a contract with zero operations has nothing waiving could fix.
157
+ // For `partial`, only ERROR-severity warnings can block (a WARN like CONTRACT_BODY_UNKNOWN never
158
+ // blocks, waived or not). Deliberately no wildcard match: a waiver only cancels the EXACT
159
+ // code+subject pair recorded for it, so a new unmatched endpoint added later is never silently
160
+ // covered by an old "--all" waive -- see the "waiver invalidation" test in test/contract-cli.test.mjs.
161
+ export function evaluateResolution(contract, resolution) {
162
+ const status = classifyContract(contract);
163
+ const waivers = resolution.waivers ?? [];
164
+ const waivedKeys = new Set(waivers.map(warningKey));
165
+
166
+ const errorWarnings = contract.warnings.filter((w) => w.severity === SEVERITY.ERROR);
167
+ const unwaived = errorWarnings.filter((w) => !waivedKeys.has(warningKey(w)));
168
+ const waived = errorWarnings.filter((w) => waivedKeys.has(warningKey(w)));
169
+
170
+ const currentErrorKeys = new Set(errorWarnings.map(warningKey));
171
+ const staleWaivers = waivers.filter((w) => !currentErrorKeys.has(warningKey(w)));
172
+
173
+ const blocking = status === COMPLETENESS.BLOCKED || unwaived.length > 0;
174
+
175
+ return { status, blocking, unwaived, waived, staleWaivers };
176
+ }
@@ -0,0 +1,287 @@
1
+ import fs from 'node:fs';
2
+ import { makeWarning, classifyContract } from './completeness.mjs';
3
+ import { findMethodParams } from '../scanners/adapters/_java-spring-analyzer.mjs';
4
+
5
+ // D-security-2: a plain UUID `pattern`, not `format: 'uuid'`. ajv-formats' uuid format accepts
6
+ // an optional `urn:uuid:` prefix (per its RFC 4122 reading), but Spring's `UUID` path-variable
7
+ // converter expects the bare form -- a contract using `format: 'uuid'` could certify a
8
+ // `urn:uuid:...` request as valid when the real endpoint would reject it. Found by the Codex
9
+ // security review, verified against the installed ajv-formats@3.0.1.
10
+ //
11
+ // A2: exported so contracts/openapi.mjs's inlineSchema() can apply the identical fix one layer
12
+ // down -- springdoc renders a Java `UUID` request-body field as `{type:'string', format:'uuid'}`,
13
+ // the exact shape this const was created to avoid, just inside a projected body schema instead of
14
+ // a path param. Direction stays one-way (openapi.mjs imports from emit.mjs, never the reverse).
15
+ 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
+ function pathParamsSchema(routePath) {
18
+ const params = [...routePath.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
19
+ const properties = {};
20
+ for (const p of params) {
21
+ // Naming convention seen throughout Team-IZ-Backend (`UUID organizationId`, etc.) --
22
+ // a heuristic, not a guarantee; wrong for a path param that happens to end in "Id" but
23
+ // isn't a UUID, which just means an over-strict uuid-shaped check on that one field.
24
+ properties[p] = /id$/i.test(p) ? { type: 'string', pattern: BARE_UUID_PATTERN } : { type: 'string' };
25
+ }
26
+ return { type: 'object', additionalProperties: false, properties, required: params };
27
+ }
28
+
29
+ // Re-reads the controller source (already located by the scan) to check whether this specific
30
+ // method's parameter list has @RequestBody -- verb alone is not reliable in this codebase
31
+ // (e.g. `deleteOrganization` is DELETE but still takes a @RequestBody confirm-name payload).
32
+ // A2 Phase 1 (D-java-analyzer): confirmed live that the old non-greedy `([\s\S]*?)\)\s*\{` regex
33
+ // (the exact pattern the catalog's own A2 Why names alongside extractController()) failed to
34
+ // match at all against a return type with a space inside a generic (`ResponseEntity<Map<String,
35
+ // Object>>`) -- findMethodParams() shares the same balanced-delimiter analyzer that fixes the
36
+ // scanner's identical GenericWithSpaceController case.
37
+ function detectRequestBody(filePath, methodName) {
38
+ if (!filePath || !fs.existsSync(filePath)) return null;
39
+ const text = fs.readFileSync(filePath, 'utf8');
40
+ const params = findMethodParams(text, methodName);
41
+ if (params === null) return null;
42
+ return /@RequestBody/.test(params);
43
+ }
44
+
45
+ // A1: shared with contracts/openapi.mjs so "which module" and "which endpoint is which" are
46
+ // defined in exactly one place -- openapi.mjs imports these (never the reverse), so a
47
+ // reconciliation is guaranteed to line up with the same module/endpoint buildContract() sees,
48
+ // as long as both are called with the same (scanReport, moduleName) inputs.
49
+ export function selectModule(scanReport, moduleName) {
50
+ return moduleName
51
+ ? scanReport.related_modules.find((m) => m.module === moduleName)
52
+ : scanReport.related_modules[0];
53
+ }
54
+
55
+ // A string, not object identity, so it survives serialization into the openapi snapshot and
56
+ // doesn't depend on both callers sharing the exact same parsed scanReport object.
57
+ export function endpointKey(controllerIndex, endpointIndex) {
58
+ return `${controllerIndex}:${endpointIndex}`;
59
+ }
60
+
61
+ // A5: warnings are structured ({code, severity, subject, message, detail}), not bare strings --
62
+ // see contracts/completeness.mjs. The three original warning conditions (no-module/unmatched/
63
+ // duplicate) and their exact message text are unchanged; two are new (CONTRACT_EMPTY,
64
+ // CONTRACT_BODY_UNKNOWN). This function still never looks at waivers -- it reports what the scan
65
+ // found, nothing more; bin/bskel.mjs's cmdContractEmit is what weighs warnings against
66
+ // contracts/completeness.mjs's evaluateResolution() to decide whether to block.
67
+ //
68
+ // A1: `openapi` (default null) is an already-computed contracts/openapi.mjs Reconciliation --
69
+ // this function never opens the OpenAPI file itself (same "stays pure" discipline as never
70
+ // looking at waivers). `openapi === null` is a hard guarantee of byte-identical output to
71
+ // pre-A1 behavior -- see test/contract.test.mjs's "openapi param omitted" test.
72
+ export function buildContract({ featureId, featureUid, scanReport, module: moduleName, openapi = null }) {
73
+ const targetModule = selectModule(scanReport, moduleName);
74
+
75
+ const operations = {};
76
+ const warnings = [];
77
+ let endpointCount = 0;
78
+
79
+ if (!targetModule) {
80
+ warnings.push(makeWarning('CONTRACT_NO_MODULE', {
81
+ message: 'no related module in the scan report -- emitting an empty operation set. Pass --module, or re-run `bskel scan` with terms that actually match the intended feature.',
82
+ }));
83
+ } else {
84
+ for (const [ci, controller] of targetModule.controllers.entries()) {
85
+ for (const [ei, ep] of controller.endpoints.entries()) {
86
+ endpointCount++;
87
+ const res = openapi ? openapi.byEndpoint.get(endpointKey(ci, ei)) ?? null : null;
88
+
89
+ let operationId = ep.operationId;
90
+ let verb = ep.verb;
91
+ let route = ep.path;
92
+ let provenance = 'scan';
93
+ let openapiAttempted = false;
94
+ let openapiReason = null;
95
+ // A2/A3: only ever set for matched/adopted (contracts/openapi.mjs's applyRequestBodySchema/
96
+ // applyResponseSchemas run for those two kinds only) -- stays null for every other kind.
97
+ let requestBodySchema = null;
98
+ let requestBodyRequired = false;
99
+ let schemaUnresolvedReason = null;
100
+ let responseSchema = null;
101
+ let responseSchemaUnresolvedReason = null;
102
+ let errorSchema = null;
103
+ let errorSchemaUnresolvedReason = null;
104
+
105
+ if (res) {
106
+ switch (res.kind) {
107
+ case 'matched':
108
+ // operationId came from source (scan), verb/path are OpenAPI-confirmed --
109
+ // this is A1's main fix: the endpoint was already addressable, but its path
110
+ // was wrong (missing e.g. a global /api/v0 prefix the scanner can't see).
111
+ verb = res.verb;
112
+ route = res.path;
113
+ provenance = 'scan+openapi';
114
+ requestBodySchema = res.requestBodySchema ?? null;
115
+ requestBodyRequired = res.requestBodyRequired ?? false;
116
+ schemaUnresolvedReason = res.schemaUnresolvedReason ?? null;
117
+ responseSchema = res.responseSchema ?? null;
118
+ responseSchemaUnresolvedReason = res.responseSchemaUnresolvedReason ?? null;
119
+ errorSchema = res.errorSchema ?? null;
120
+ errorSchemaUnresolvedReason = res.errorSchemaUnresolvedReason ?? null;
121
+ break;
122
+ case 'adopted':
123
+ // No @Operation(operationId=...) in source at all -- the id itself comes from
124
+ // the document, not from anything pinned in Java. Real and addressable, but
125
+ // flagged (WARN, not ERROR) since renaming the handler method would silently
126
+ // change it.
127
+ operationId = res.operationId;
128
+ verb = res.verb;
129
+ route = res.path;
130
+ provenance = 'openapi';
131
+ requestBodySchema = res.requestBodySchema ?? null;
132
+ requestBodyRequired = res.requestBodyRequired ?? false;
133
+ schemaUnresolvedReason = res.schemaUnresolvedReason ?? null;
134
+ responseSchema = res.responseSchema ?? null;
135
+ responseSchemaUnresolvedReason = res.responseSchemaUnresolvedReason ?? null;
136
+ errorSchema = res.errorSchema ?? null;
137
+ errorSchemaUnresolvedReason = res.errorSchemaUnresolvedReason ?? null;
138
+ warnings.push(makeWarning('CONTRACT_OPENAPI_DERIVED_OPERATION_ID', {
139
+ subject: operationId,
140
+ message: `operationId "${operationId}" for ${res.verb} ${res.path} was not found in the source (no @Operation(operationId=...)) -- adopted directly from the OpenAPI document instead`,
141
+ detail: { verb: res.verb, path: res.path, scan_verb: ep.verb, scan_path: ep.path },
142
+ }));
143
+ break;
144
+ case 'drift':
145
+ // operationId matches on both sides, but verb/path disagree in a way the
146
+ // path prefix can't explain -- possibly the scanner's "nearest preceding
147
+ // @Operation(" heuristic mis-attributed this id to the wrong method. Fail
148
+ // closed: keep the scan's own value, don't silently adopt the document's.
149
+ warnings.push(makeWarning('CONTRACT_OPENAPI_DRIFT', {
150
+ subject: ep.operationId,
151
+ message: `operationId "${ep.operationId}" disagrees with the OpenAPI document on ${res.reason} -- scan has ${ep.verb} ${ep.path}, OpenAPI has ${res.openapi.verb} ${res.openapi.path}. Not auto-resolved.`,
152
+ detail: { reason: res.reason, scan: { verb: ep.verb, path: ep.path }, openapi: res.openapi },
153
+ }));
154
+ break;
155
+ case 'missing':
156
+ // The scan's operationId isn't in the document anywhere -- left uncorrected
157
+ // (still the unprefixed scan path) specifically so this can't be mistaken
158
+ // for a successful reconciliation.
159
+ warnings.push(makeWarning('CONTRACT_OPENAPI_MISSING_OPERATION', {
160
+ subject: ep.operationId,
161
+ message: `operationId "${ep.operationId}" (${ep.verb} ${ep.path}) was not found anywhere in the OpenAPI document -- path left uncorrected`,
162
+ detail: { verb: ep.verb, path: ep.path },
163
+ }));
164
+ break;
165
+ case 'ambiguous':
166
+ warnings.push(makeWarning('CONTRACT_OPENAPI_AMBIGUOUS', {
167
+ subject: `${ep.verb} ${ep.path}`,
168
+ message: `${ep.verb} ${ep.path} matched more than one OpenAPI operation candidate -- not guessed`,
169
+ detail: { verb: ep.verb, path: ep.path, candidates: res.candidates },
170
+ }));
171
+ continue; // still has no operationId -- can't be addressed either way
172
+ case 'unresolved':
173
+ // No candidate, or the path prefix couldn't be determined -- falls through
174
+ // to the ordinary CONTRACT_UNMATCHED_ENDPOINT path below, with detail
175
+ // recording that OpenAPI reconciliation was attempted and why it didn't help.
176
+ openapiAttempted = true;
177
+ openapiReason = res.reason;
178
+ break;
179
+ default:
180
+ break;
181
+ }
182
+ }
183
+
184
+ if (!operationId) {
185
+ warnings.push(makeWarning('CONTRACT_UNMATCHED_ENDPOINT', {
186
+ subject: `${ep.verb} ${ep.path}`,
187
+ message: `${ep.verb} ${ep.path} (method ${ep.method}) has no correlated operationId in the scan -- skipped, it cannot be addressed by operation_id in the envelope`,
188
+ detail: {
189
+ verb: ep.verb, path: ep.path, method: ep.method,
190
+ ...(openapiAttempted ? { openapi_attempted: true, openapi_reason: openapiReason } : {}),
191
+ },
192
+ }));
193
+ continue;
194
+ }
195
+ if (operations[operationId]) {
196
+ warnings.push(makeWarning('CONTRACT_DUPLICATE_OPERATION_ID', {
197
+ subject: operationId,
198
+ message: `duplicate operationId "${operationId}" seen more than once -- keeping the first occurrence`,
199
+ detail: { verb, path: route, method: ep.method },
200
+ }));
201
+ continue;
202
+ }
203
+ const hasBody = detectRequestBody(controller.file, ep.method);
204
+ if (hasBody === null) {
205
+ // Low-risk metadata gap, not a routing/addressing problem -- warn, not error (see
206
+ // WARNING_CODES in completeness.mjs). operationPayloadSchema() already treats
207
+ // body:'unknown' as optional, so this just makes that leniency visible instead of silent.
208
+ warnings.push(makeWarning('CONTRACT_BODY_UNKNOWN', {
209
+ subject: `${verb} ${route}`,
210
+ message: `${verb} ${route} (operationId "${operationId}") -- could not determine whether this method takes a @RequestBody (controller source not found or method signature not matched); payload body is treated as optional`,
211
+ detail: { verb, path: route, method: ep.method, operationId },
212
+ }));
213
+ }
214
+ // A2: the schema was found and couldn't be projected -- distinct from "no schema to
215
+ // project at all" (requestBodySchema stays null with no warning in that case, see
216
+ // contracts/openapi.mjs's applyRequestBodySchema). Falls back to the pre-A2 bare-object
217
+ // check (operationPayloadSchema treats a missing requestBodySchema as before); never
218
+ // blocks completeness (WARN, see contracts/completeness.mjs).
219
+ if (schemaUnresolvedReason) {
220
+ warnings.push(makeWarning('CONTRACT_OPENAPI_SCHEMA_UNRESOLVED', {
221
+ subject: operationId,
222
+ message: `operationId "${operationId}" (${verb} ${route}) matched an OpenAPI operation with a JSON request body, but its schema could not be projected (${schemaUnresolvedReason}) -- the body is still validated, just as a bare object instead of its real shape`,
223
+ detail: { reason: schemaUnresolvedReason, verb, path: route, operationId },
224
+ }));
225
+ }
226
+ // A3: same "found but couldn't project" distinction as the request-body warning above,
227
+ // applied separately to response (2xx) and error (4xx/5xx) -- two DIFFERENT codes (see
228
+ // D-openapi-response-schema), so a projection failure on one direction never shares a
229
+ // waiver key with an unrelated failure on another direction for the same operation.
230
+ if (responseSchemaUnresolvedReason) {
231
+ warnings.push(makeWarning('CONTRACT_OPENAPI_RESPONSE_SCHEMA_UNRESOLVED', {
232
+ subject: operationId,
233
+ message: `operationId "${operationId}" (${verb} ${route}) documents a 2xx JSON response, but its schema could not be projected (${responseSchemaUnresolvedReason}) -- the response stays unconstrained, same as before --openapi-file`,
234
+ detail: { reason: responseSchemaUnresolvedReason, verb, path: route, operationId },
235
+ }));
236
+ }
237
+ if (errorSchemaUnresolvedReason) {
238
+ warnings.push(makeWarning('CONTRACT_OPENAPI_ERROR_SCHEMA_UNRESOLVED', {
239
+ subject: operationId,
240
+ message: `operationId "${operationId}" (${verb} ${route}) documents a 4xx/5xx JSON response, but its schema could not be projected (${errorSchemaUnresolvedReason}) -- the error payload stays unconstrained, same as before --openapi-file`,
241
+ detail: { reason: errorSchemaUnresolvedReason, verb, path: route, operationId },
242
+ }));
243
+ }
244
+ operations[operationId] = {
245
+ verb,
246
+ path: route,
247
+ pathParams: pathParamsSchema(route),
248
+ body: hasBody === null ? 'unknown' : hasBody,
249
+ 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.
253
+ ...(requestBodySchema ? { requestBodySchema, requestBodyRequired } : {}),
254
+ ...(responseSchema ? { responseSchema } : {}),
255
+ ...(errorSchema ? { errorSchema } : {}),
256
+ };
257
+ }
258
+ }
259
+ }
260
+
261
+ // Fires regardless of WHY operations ended up empty (no module matched, or a module matched
262
+ // but had zero controllers/endpoints, or every endpoint was unmatched/duplicate) -- the other
263
+ // warnings explain the cause, this one states the consequence: nothing here can be trusted.
264
+ if (Object.keys(operations).length === 0) {
265
+ warnings.push(makeWarning('CONTRACT_EMPTY', {
266
+ message: 'this contract has zero operations -- it cannot be used by `contract validate`/`tool-schema`, or routed to by `handles emit`. Fix --module/--terms, or if this module genuinely has no HTTP surface (yet), there is nothing to contract.',
267
+ }));
268
+ }
269
+
270
+ const completeness = {
271
+ status: classifyContract({ operations, warnings }),
272
+ operation_count: Object.keys(operations).length,
273
+ endpoint_count: endpointCount,
274
+ };
275
+
276
+ return {
277
+ sbf_contract: '4',
278
+ feature_id: featureId,
279
+ feature_uid: featureUid,
280
+ source: targetModule
281
+ ? { adapter: scanReport.adapter, module: targetModule.module, provenance: openapi ? 'scan+openapi' : 'scan' }
282
+ : { adapter: scanReport.adapter ?? null, module: null, provenance: 'none' },
283
+ operations,
284
+ warnings,
285
+ completeness,
286
+ };
287
+ }