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.
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- 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
|
+
}
|