backend-skeleton 1.0.0-beta.4 → 1.0.0-beta.5
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 +11 -7
- package/bin/bskel.mjs +252 -2
- package/handles/_engine.mjs +3 -1
- package/handles/observe-schema-projection.mjs +92 -0
- package/handles/providers/java-spring/emit.mjs +51 -27
- package/handles/providers/java-spring/observe.mjs +93 -0
- package/handles/providers/java-spring/templates/ContractCheck.java.tmpl +129 -0
- package/handles/providers/java-spring/templates/ContractObservationAspect.java.tmpl +190 -0
- package/handles/providers/java-spring/templates/ObserveContract.java.tmpl +42 -0
- package/handles/providers/java-spring/templates/ObserveSchemaLoader.java.tmpl +134 -0
- package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +54 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +10 -12
- package/handles/providers/python-fastapi/emit.mjs +47 -21
- package/handles/providers/python-fastapi/observe.mjs +96 -0
- package/handles/providers/python-fastapi/templates/contract_check.py.tmpl +112 -0
- package/handles/providers/python-fastapi/templates/observe_contract.py.tmpl +165 -0
- package/handles/providers/python-fastapi/templates/observed_schema.py.tmpl +105 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +6 -8
- package/handles/providers/python-fastapi/templates/resolver_policy.py.tmpl +19 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +5 -0
- package/lib/cli.mjs +26 -0
- package/lib/gate-definitions.mjs +20 -1
- package/package.json +1 -1
- package/schemas/conformance-report.schema.json +49 -0
- package/schemas/observe-receipt.schema.json +32 -0
package/README.md
CHANGED
|
@@ -24,13 +24,17 @@ stable. Split by actual maturity, not by feature list:
|
|
|
24
24
|
measured verification against a real production Spring Boot repo (see `DECISIONS.md`), plus a
|
|
25
25
|
synthetic fixture corpus for every adapter, run in CI on every change.
|
|
26
26
|
- **`handles`** (the UUID-addressable resolver/codec/router codegen) is functionally complete and
|
|
27
|
-
tested the same way, but has never been deployed to a real production repo
|
|
28
|
-
|
|
29
|
-
`HandleRegistry
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
tested the same way, but has never been deployed to a real production repo. Two gaps named in
|
|
28
|
+
earlier betas have since been partially closed: `O3` (opt-in `--enforce-registry`, checked
|
|
29
|
+
fetch/patch/recover against `HandleRegistry`, revocation-aware) and `O5` (fetch/patch now derive
|
|
30
|
+
independently correct roles instead of silently sharing one) are both implemented -- see
|
|
31
|
+
`DECISIONS.md`'s `D-handle-registry-enforcement`/`D-resolver-authorization-action-aware`. Real
|
|
32
|
+
gaps remain and are explicitly still open, not closed: registry enforcement is opt-in, off by
|
|
33
|
+
default; authorization inference still only recognizes a single `@PreAuthorize(hasRole(...))`
|
|
34
|
+
shape (`hasAuthority`, role lists, ownership/tenant policy are unaddressed); and Java/Python are
|
|
35
|
+
the only providers either applies to -- TypeScript Express has no persistent handle table at
|
|
36
|
+
all. Treat `handles emit`'s output as a scaffold to finish by hand, not a production-ready
|
|
37
|
+
subsystem, until a real deployment happens.
|
|
34
38
|
|
|
35
39
|
Version numbers, install instructions, and a real feedback path will firm up as this gets used
|
|
36
40
|
against more real repos.
|
package/bin/bskel.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { repoRoot, localDefaultBranch, fileHistory, showFileAtRevision, headSha,
|
|
|
9
9
|
import { forceNamedGate, revokeNamedGate, requireNamedGate, passNamedGate, awaitNamedGateDisposition, EXIT } from '../lib/gates.mjs';
|
|
10
10
|
import { REPO_GATE_ID, GATE_NAMES, gateScopeId, requireGateDefinition } from '../lib/gate-definitions.mjs';
|
|
11
11
|
import { getGate, loadState, historyPath } from '../lib/state.mjs';
|
|
12
|
-
import { writeFileAtomic } from '../lib/fsutil.mjs';
|
|
12
|
+
import { writeFileAtomic, sha256File } from '../lib/fsutil.mjs';
|
|
13
13
|
import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
|
|
14
14
|
import { withLockSync } from '../lib/lock.mjs';
|
|
15
15
|
import { specDir, specPath } from '../lib/paths.mjs';
|
|
@@ -43,6 +43,10 @@ import { buildOpenApiDocument, pathPrefixCandidates, unreflectedPathPrefixes, ST
|
|
|
43
43
|
import { loadCatalogEntry, listCatalogChoices, planApply, applyPlan } from '../stack/apply.mjs';
|
|
44
44
|
import { PROVIDERS, PROVIDER_LOAD_ERRORS, providerById } from '../handles/registry.mjs';
|
|
45
45
|
import { detectAstHelperAvailable, runAstClassify } from '../handles/providers/java-spring/ast-bridge.mjs';
|
|
46
|
+
import { detectBasePackage } from '../handles/providers/java-spring/plan.mjs';
|
|
47
|
+
import { emitObserveJavaSpring } from '../handles/providers/java-spring/observe.mjs';
|
|
48
|
+
import { plan as planPythonFastApi } from '../handles/providers/python-fastapi/plan.mjs';
|
|
49
|
+
import { emitObservePythonFastApi } from '../handles/providers/python-fastapi/observe.mjs';
|
|
46
50
|
import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
|
|
47
51
|
import { computeWorkflowState } from '../lib/workflow.mjs';
|
|
48
52
|
import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
|
|
@@ -79,6 +83,8 @@ function usage() {
|
|
|
79
83
|
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
|
|
80
84
|
bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
|
|
81
85
|
bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]
|
|
86
|
+
bskel observe emit --feature <id> [--module <name>] [--force --reason "..."] [--check] [--diff] [--json]
|
|
87
|
+
bskel observe import --feature <id> --receipts <path> [--json]
|
|
82
88
|
bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
|
|
83
89
|
bskel status [--feature <id>] [--json]
|
|
84
90
|
bskel next [--feature <id>] [--json]
|
|
@@ -1972,7 +1978,8 @@ function cmdHandlesEmit(args) {
|
|
|
1972
1978
|
}
|
|
1973
1979
|
if (dryRun) console.error(`\n${renderFileActions(actions)}`);
|
|
1974
1980
|
}
|
|
1975
|
-
// D-process-exit-audit: bounded by 7 + plan.resources.length units
|
|
1981
|
+
// D-process-exit-audit: bounded by 7 + 2*plan.resources.length units (D-resolver-policy-split:
|
|
1982
|
+
// Resolver + Policy per resource), no pipe-truncation risk.
|
|
1976
1983
|
// Carries a real payload (already printed above in --json mode) -- no diagnostic envelope.
|
|
1977
1984
|
// P4 precedent (catalog lint): reused, not a new exit code -- --check reaching the exact
|
|
1978
1985
|
// same verdict a real run would (exit 15) is the point, not a distinct "check found a
|
|
@@ -2135,6 +2142,242 @@ async function cmdHandlesAudit(args) {
|
|
|
2135
2142
|
process.exit(0);
|
|
2136
2143
|
}
|
|
2137
2144
|
|
|
2145
|
+
// D-runtime-conformance-receipts: mirrors cmdHandlesEmit's own precondition chain and
|
|
2146
|
+
// blocked/--check reporting shape closely -- same "contract must be pass first" gate dependency,
|
|
2147
|
+
// same O2-style conflict machinery via emitUnits() (reused unmodified inside
|
|
2148
|
+
// emitObserveJavaSpring), same --check/--diff/--force/--reason semantics. Does not pass any gate
|
|
2149
|
+
// itself -- only `observe import` (real receipts imported) represents evidence worth gating on.
|
|
2150
|
+
function cmdObserveEmit(args) {
|
|
2151
|
+
const flags = parseCommand('observe emit', args);
|
|
2152
|
+
if (flags.help) { console.log(renderCommandHelp('observe emit')); process.exit(0); }
|
|
2153
|
+
setContext('observe emit', flags);
|
|
2154
|
+
const root = requireRepoRoot();
|
|
2155
|
+
requirePreflightPassed(root);
|
|
2156
|
+
if (flags.force && (!flags.reason || !flags.reason.trim())) {
|
|
2157
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel observe emit --force requires --reason "..." -- every overwrite of diverged generated code must be auditable');
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
const contractResult = requireNamedGate(root, 'contract', flags.feature);
|
|
2161
|
+
if (contractResult.code !== EXIT.PASS) {
|
|
2162
|
+
const hint = contractResult.status === 'awaiting_disposition'
|
|
2163
|
+
? `resolve it first -- \`bskel contract waive --feature ${flags.feature} --code <CODE> (--subject "..."|--all) --reason "..."\`, or \`bskel gate force contract --feature ${flags.feature} --reason "..."\` if intentional.`
|
|
2164
|
+
: `run \`bskel contract emit --feature ${flags.feature}\` first.`;
|
|
2165
|
+
fail(contractResult.code, gateReasonForCode(contractResult.code), `blocked: \`contract\` gate for ${flags.feature} is ${contractResult.status} -- ${hint}`, {
|
|
2166
|
+
next_actions: [{ command: `bskel contract emit --feature ${flags.feature}`, reason: 'the contract gate has not passed yet', mutating: true }],
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const scanReport = loadScanReportOrExit(root, flags.feature);
|
|
2171
|
+
const contract = loadContract(root, flags.feature);
|
|
2172
|
+
const dryRun = flags.check || flags.diff;
|
|
2173
|
+
|
|
2174
|
+
// D-runtime-conformance-receipts: explicit two-branch dispatch, not handles/registry.mjs's own
|
|
2175
|
+
// provider mechanism -- that registry's schema/loader (a closed plan+emit verb pair) is
|
|
2176
|
+
// specifically shaped for the HANDLES feature (per-resource resolver units); observe has no
|
|
2177
|
+
// `plan` verb and operates directly on contract.operations. Matches this project's own
|
|
2178
|
+
// established precedent for a single-other-provider feature (cmdHandlesPlan's --ast flag: a
|
|
2179
|
+
// bare adapter check, no registry involved) rather than adopting a mechanism built for a
|
|
2180
|
+
// different, unrelated concern.
|
|
2181
|
+
let result;
|
|
2182
|
+
if (scanReport.adapter === 'java-spring') {
|
|
2183
|
+
let basePackage;
|
|
2184
|
+
try {
|
|
2185
|
+
basePackage = detectBasePackage(root);
|
|
2186
|
+
} catch (err) {
|
|
2187
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2188
|
+
}
|
|
2189
|
+
if (!basePackage) {
|
|
2190
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', 'could not detect the base package (no *Application.java found under src/main/java) -- is this a Spring Boot project?');
|
|
2191
|
+
}
|
|
2192
|
+
try {
|
|
2193
|
+
result = emitObserveJavaSpring({ repoRoot: root, featureId: flags.feature, contract, basePackage, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
|
|
2194
|
+
} catch (err) {
|
|
2195
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2196
|
+
}
|
|
2197
|
+
} else if (scanReport.adapter === 'python-fastapi') {
|
|
2198
|
+
// python's own package-root detection needs a module to anchor itself (unlike java's
|
|
2199
|
+
// detectBasePackage(), which needs no module/feature context at all) -- a genuine
|
|
2200
|
+
// CLI-surface asymmetry between the two providers, not an oversight. See DECISIONS.md.
|
|
2201
|
+
let fastApiPlan;
|
|
2202
|
+
try {
|
|
2203
|
+
fastApiPlan = planPythonFastApi({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
|
|
2204
|
+
} catch (err) {
|
|
2205
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2206
|
+
}
|
|
2207
|
+
try {
|
|
2208
|
+
result = emitObservePythonFastApi({ repoRoot: root, featureId: flags.feature, contract, plan: fastApiPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
|
|
2209
|
+
} catch (err) {
|
|
2210
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2211
|
+
}
|
|
2212
|
+
} else {
|
|
2213
|
+
fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel observe emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi)`);
|
|
2214
|
+
}
|
|
2215
|
+
const { written, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = result;
|
|
2216
|
+
const wouldChange = actions.some((a) => a.action !== 'unchanged' && a.action !== 'adopt-unchanged');
|
|
2217
|
+
const allNotes = [...notes];
|
|
2218
|
+
if (flags.force && forced.length === 0 && conflicts.length === 0) allNotes.push('--force had no effect: 0 conflicts found in this run\'s scope');
|
|
2219
|
+
else if (flags.force && forced.length > 0) allNotes.push(`--force overwrote ${forced.length} diverged file(s): ${forced.join(', ')}`);
|
|
2220
|
+
|
|
2221
|
+
if (blocked) {
|
|
2222
|
+
if (flags.json) {
|
|
2223
|
+
console.log(JSON.stringify({ written, conflicts, orphans, forced, notes: allNotes, actions, blocked: true, check: dryRun }, null, 2));
|
|
2224
|
+
} else {
|
|
2225
|
+
const verb = dryRun ? 'would be blocked' : 'blocked';
|
|
2226
|
+
console.error(`${verb}: ${conflicts.length} generated file(s) diverged from what backend-skeleton last wrote -- ${dryRun ? 'a real run would refuse to overwrite them' : 'refusing to overwrite'} without --force:`);
|
|
2227
|
+
for (const c of conflicts) console.error(` ${c.path} (${c.kind})\n ${c.reason}`);
|
|
2228
|
+
if (written.length > 0) {
|
|
2229
|
+
console.error(`\n${written.length} other file(s) ${dryRun ? 'would still be written' : 'were still written this run'}:`);
|
|
2230
|
+
for (const w of written) console.error(` ${w}`);
|
|
2231
|
+
}
|
|
2232
|
+
if (!dryRun) console.error(`\nre-run with: bskel observe emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''} --force --reason "..."`);
|
|
2233
|
+
if (dryRun) console.error(`\n${renderFileActions(actions)}`);
|
|
2234
|
+
}
|
|
2235
|
+
// D-process-exit-audit: same shape/reasoning as `handles emit`'s own blocked path -- reused
|
|
2236
|
+
// exit code, not a new one (D-cli-contract: numbers are a public contract, not renumbered).
|
|
2237
|
+
process.exit(EXIT_CODES.HANDLES_CONFLICT);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
if (flags.json) {
|
|
2241
|
+
console.log(JSON.stringify({ written, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, check: dryRun, postEmitNotes }, null, 2));
|
|
2242
|
+
} else if (!flags.quiet) {
|
|
2243
|
+
console.log(`${dryRun ? 'would write' : 'wrote'} ${written.length} file(s):`);
|
|
2244
|
+
for (const w of written) console.log(` ${w}`);
|
|
2245
|
+
if (allNotes.length > 0) {
|
|
2246
|
+
console.log('\nnotes:');
|
|
2247
|
+
for (const n of allNotes) console.log(` - ${n}`);
|
|
2248
|
+
}
|
|
2249
|
+
if (dryRun) {
|
|
2250
|
+
console.log(`\n${renderFileActions(actions)}`);
|
|
2251
|
+
} else {
|
|
2252
|
+
for (const n of postEmitNotes) console.log(`\n${n}`);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
if (dryRun) process.exit(wouldChange ? EXIT_CODES.CHECK_FAILED : EXIT_CODES.OK);
|
|
2256
|
+
process.exit(0);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// D-runtime-conformance-receipts: validation order mirrors `contract emit --openapi-file`'s
|
|
2260
|
+
// "compute+validate everything before writing anything" discipline. Two failure classes on a
|
|
2261
|
+
// per-line basis, handled differently -- a line that is not valid JSON at all is NOISE (a human's
|
|
2262
|
+
// log pipeline realistically is not perfectly scoped to just the receipts logger), counted and
|
|
2263
|
+
// warned, not fatal; a line that IS valid JSON but fails observe-receipt.schema.json is real
|
|
2264
|
+
// CORRUPTION -- abort the whole import loudly, same "a bad file must not leave a half-updated
|
|
2265
|
+
// state" principle `contract emit --openapi-file` already applies.
|
|
2266
|
+
const MAX_RECEIPTS_BYTES = 64 * 1024 * 1024; // provisional -- no real oracle to measure against yet, unlike A1's own measured caps (stated explicitly, not pretended-measured)
|
|
2267
|
+
const MAX_RECEIPTS_LINES = 200_000;
|
|
2268
|
+
|
|
2269
|
+
function cmdObserveImport(args) {
|
|
2270
|
+
const flags = parseCommand('observe import', args);
|
|
2271
|
+
if (flags.help) { console.log(renderCommandHelp('observe import')); process.exit(0); }
|
|
2272
|
+
setContext('observe import', flags);
|
|
2273
|
+
const root = requireRepoRoot();
|
|
2274
|
+
requirePreflightPassed(root);
|
|
2275
|
+
|
|
2276
|
+
const contractResult = requireNamedGate(root, 'contract', flags.feature);
|
|
2277
|
+
if (contractResult.code !== EXIT.PASS) {
|
|
2278
|
+
fail(contractResult.code, gateReasonForCode(contractResult.code), `blocked: \`contract\` gate for ${flags.feature} is ${contractResult.status} -- receipts are only meaningful against an established contract. Run \`bskel contract emit --feature ${flags.feature}\` first.`);
|
|
2279
|
+
}
|
|
2280
|
+
const contract = loadContract(root, flags.feature);
|
|
2281
|
+
|
|
2282
|
+
let stat;
|
|
2283
|
+
try {
|
|
2284
|
+
stat = fs.statSync(flags.receipts);
|
|
2285
|
+
} catch {
|
|
2286
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no readable file at ${flags.receipts}`);
|
|
2287
|
+
}
|
|
2288
|
+
if (!stat.isFile()) {
|
|
2289
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${flags.receipts} is not a regular file`);
|
|
2290
|
+
}
|
|
2291
|
+
if (stat.size > MAX_RECEIPTS_BYTES) {
|
|
2292
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${flags.receipts} is ${stat.size} bytes, over the provisional ${MAX_RECEIPTS_BYTES}-byte cap`);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
const rawLines = fs.readFileSync(flags.receipts, 'utf8').split('\n').filter((l) => l.trim().length > 0);
|
|
2296
|
+
if (rawLines.length > MAX_RECEIPTS_LINES) {
|
|
2297
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${flags.receipts} has ${rawLines.length} non-empty lines, over the provisional ${MAX_RECEIPTS_LINES}-line cap`);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
let noiseLines = 0;
|
|
2301
|
+
const receipts = [];
|
|
2302
|
+
for (const line of rawLines) {
|
|
2303
|
+
let parsed;
|
|
2304
|
+
try {
|
|
2305
|
+
parsed = JSON.parse(line);
|
|
2306
|
+
} catch {
|
|
2307
|
+
noiseLines++;
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
const { ok, errors } = validateAgainstSchema('observe-receipt.schema.json', parsed);
|
|
2311
|
+
if (!ok) {
|
|
2312
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${flags.receipts}: a line is valid JSON but not a valid receipt -- ${formatSchemaErrors(errors).join('; ')}. Aborting the whole import (a corrupted receipts file must not partially land).`);
|
|
2313
|
+
}
|
|
2314
|
+
receipts.push(parsed);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
for (const r of receipts) {
|
|
2318
|
+
if (r.feature_id !== flags.feature || r.feature_uid !== contract.feature_uid) {
|
|
2319
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${flags.receipts}: a receipt is for feature "${r.feature_id}" (${r.feature_uid}), not "${flags.feature}" (${contract.feature_uid}) -- aborting the whole import (a receipts file for the wrong feature must never partially land).`);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const currentContractHash = sha256File(specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`));
|
|
2324
|
+
let matched = 0;
|
|
2325
|
+
let staleContractRef = 0;
|
|
2326
|
+
let violationCount = 0;
|
|
2327
|
+
let unsupportedCount = 0;
|
|
2328
|
+
const byOperation = {};
|
|
2329
|
+
for (const r of receipts) {
|
|
2330
|
+
const isMatched = r.contract_ref === currentContractHash;
|
|
2331
|
+
if (isMatched) matched++; else staleContractRef++;
|
|
2332
|
+
const opStats = byOperation[r.operation_id] ?? { matched: 0, stale_contract_ref: 0, violations: 0 };
|
|
2333
|
+
if (isMatched) opStats.matched++; else opStats.stale_contract_ref++;
|
|
2334
|
+
for (const v of r.violations ?? []) {
|
|
2335
|
+
if (v.keyword === 'unsupported') unsupportedCount++; else violationCount++;
|
|
2336
|
+
if (isMatched) opStats.violations++;
|
|
2337
|
+
}
|
|
2338
|
+
byOperation[r.operation_id] = opStats;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
const report = {
|
|
2342
|
+
sbf_conformance_report: '1',
|
|
2343
|
+
feature_id: flags.feature,
|
|
2344
|
+
feature_uid: contract.feature_uid,
|
|
2345
|
+
generated_at: new Date().toISOString(),
|
|
2346
|
+
source: describeSourceFile(root, flags.receipts),
|
|
2347
|
+
counts: {
|
|
2348
|
+
receipt_lines: receipts.length,
|
|
2349
|
+
noise_lines: noiseLines,
|
|
2350
|
+
matched, stale_contract_ref: staleContractRef,
|
|
2351
|
+
violations: violationCount,
|
|
2352
|
+
unsupported: unsupportedCount,
|
|
2353
|
+
},
|
|
2354
|
+
by_operation: byOperation,
|
|
2355
|
+
};
|
|
2356
|
+
const { ok: reportOk, errors: reportErrors } = validateAgainstSchema('conformance-report.schema.json', report);
|
|
2357
|
+
if (!reportOk) {
|
|
2358
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed conformance report failed its own schema -- ${formatSchemaErrors(reportErrors).join('; ')}`);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
const reportPath = specPath(root, flags.feature, 'observe', `${flags.feature}.conformance-report.json`);
|
|
2362
|
+
writeFileAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
2363
|
+
|
|
2364
|
+
// Evidence-first, not verdict-first (same as `contract`'s own precedent: a `partial` contract
|
|
2365
|
+
// is still passable via waiver) -- passes on a successful STRUCTURAL import, never on "zero
|
|
2366
|
+
// violations found". Whether violation counts should block CI is a policy question for whoever
|
|
2367
|
+
// reads the report, deliberately not decided here -- see DECISIONS.md's own deferred list.
|
|
2368
|
+
const gateState = passNamedGate(root, 'conformance', flags.feature, { receipt_count: receipts.length, matched, violations: violationCount });
|
|
2369
|
+
|
|
2370
|
+
if (flags.json) {
|
|
2371
|
+
console.log(JSON.stringify({ report, noise_lines: noiseLines, gate: gateState.gates.conformance }, null, 2));
|
|
2372
|
+
} else {
|
|
2373
|
+
console.log(`imported ${receipts.length} receipt(s) (${matched} matched the current contract, ${staleContractRef} stale, ${noiseLines} noise line(s) skipped)`);
|
|
2374
|
+
console.log(`${violationCount} violation(s), ${unsupportedCount} unsupported field(s) across matched receipts`);
|
|
2375
|
+
console.log(`wrote ${path.relative(root, reportPath)}`);
|
|
2376
|
+
console.log(`gate: conformance -> ${gateState.gates.conformance.status}`);
|
|
2377
|
+
}
|
|
2378
|
+
process.exit(0);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2138
2381
|
// S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
|
|
2139
2382
|
// input that actually moved, using the exact reason requireGate()'s explainStaleness() reports.
|
|
2140
2383
|
function describeStale(g) {
|
|
@@ -2628,6 +2871,13 @@ async function dispatchCommand(cmd, rest) {
|
|
|
2628
2871
|
process.exit(14);
|
|
2629
2872
|
break;
|
|
2630
2873
|
}
|
|
2874
|
+
case 'observe': {
|
|
2875
|
+
if (rest[0] === 'emit') return cmdObserveEmit(rest.slice(1));
|
|
2876
|
+
if (rest[0] === 'import') return cmdObserveImport(rest.slice(1));
|
|
2877
|
+
usage();
|
|
2878
|
+
process.exit(14);
|
|
2879
|
+
break;
|
|
2880
|
+
}
|
|
2631
2881
|
case 'verify':
|
|
2632
2882
|
cmdVerify(rest);
|
|
2633
2883
|
break;
|
package/handles/_engine.mjs
CHANGED
|
@@ -277,5 +277,7 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
277
277
|
|
|
278
278
|
if (!dryRun && manifestChanged) saveManifest(repoRoot, manifest);
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
// D-resolver-policy-split: two units (Resolver + Policy) now share one resourceType, so
|
|
281
|
+
// resolverStubs.push above runs twice per resource -- dedupe here rather than at every push site.
|
|
282
|
+
return { written, resolverStubs: [...new Set(resolverStubs)], conflicts, orphans, notes, forced, blocked: conflicts.length > 0, actions };
|
|
281
283
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// D-runtime-conformance-receipts: pure functions projecting a feature contract's own
|
|
2
|
+
// `operations[opId]` shape down to the bounded, checkable-only structure every observe provider's
|
|
3
|
+
// generated runtime checker understands. Extracted out of handles/providers/java-spring/observe.mjs
|
|
4
|
+
// (the first provider to need this) once python-fastapi needed the identical logic -- nothing here
|
|
5
|
+
// touches java-specific or python-specific concepts, it operates only on the contract's own
|
|
6
|
+
// JSON-schema-shaped fields (contracts/emit.mjs's own output). See DECISIONS.md
|
|
7
|
+
// D-runtime-conformance-receipts, Decision B, for the full "what's checkable is decided ONCE here"
|
|
8
|
+
// reasoning.
|
|
9
|
+
|
|
10
|
+
// A property this project's own contracts already fully control the generation of when there's
|
|
11
|
+
// nothing deeper than a directly-checkable value -- string/number/integer/boolean with no nested
|
|
12
|
+
// properties/items/$ref/anyOf/allOf/oneOf. What's checkable is decided ONCE here, in JS, at
|
|
13
|
+
// `observe emit` time (baked into a projected, pre-simplified resource for the target runtime);
|
|
14
|
+
// each provider's own generated checker is a dumb, mechanical executor of an already-simplified
|
|
15
|
+
// instruction set, never a second independent JSON-Schema interpreter.
|
|
16
|
+
const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean']);
|
|
17
|
+
|
|
18
|
+
export function isScalarLeaf(schema) {
|
|
19
|
+
if (!schema || typeof schema !== 'object') return false;
|
|
20
|
+
if (schema.properties || schema.items || schema.$ref || schema.anyOf || schema.allOf || schema.oneOf) return false;
|
|
21
|
+
return typeof schema.type === 'string' && SCALAR_TYPES.has(schema.type);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Projects a real (possibly arbitrarily deep) JSON Schema object -- contract.operations[id]'s own
|
|
25
|
+
// requestBodySchema/responseSchema/errorSchema, present only when `contract emit --openapi-file`
|
|
26
|
+
// was used and something resolved (contracts/emit.mjs) -- down to the bounded, checkable-only
|
|
27
|
+
// shape every generated checker understands: top-level `required`, scalar-leaf `properties` kept
|
|
28
|
+
// as {type, pattern?}, everything else marked `unsupported` at its own JSON Pointer rather than
|
|
29
|
+
// silently treated as pass. A non-object root (anyOf/allOf/oneOf/$ref/any non-'object' type -- the
|
|
30
|
+
// real shape a multi-status-unioned responseSchema can take) is marked unsupported at the root
|
|
31
|
+
// pointer "" wholesale, rather than guessed into a partial projection.
|
|
32
|
+
export function projectBodySchema(schema) {
|
|
33
|
+
if (!schema || typeof schema !== 'object') return null;
|
|
34
|
+
if (schema.anyOf || schema.allOf || schema.oneOf || schema.$ref || schema.type !== 'object') {
|
|
35
|
+
return { required: [], properties: {}, unsupported: [''] };
|
|
36
|
+
}
|
|
37
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === 'string') : [];
|
|
38
|
+
const properties = {};
|
|
39
|
+
const unsupported = [];
|
|
40
|
+
for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
|
|
41
|
+
if (isScalarLeaf(propSchema)) {
|
|
42
|
+
properties[key] = { type: propSchema.type, ...(typeof propSchema.pattern === 'string' ? { pattern: propSchema.pattern } : {}) };
|
|
43
|
+
} else {
|
|
44
|
+
unsupported.push(`/${key}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { required, properties, unsupported };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// pathParams is ALWAYS the narrow, bskel-controlled vocabulary contracts/emit.mjs's own
|
|
51
|
+
// pathParamsSchema() produces (type:'object', additionalProperties:false, properties/required,
|
|
52
|
+
// each property {type:'string', pattern?}) -- passed through structurally rather than re-derived,
|
|
53
|
+
// but still routed through the same scalar-leaf check as body properties, defensively, in case a
|
|
54
|
+
// hand-edited contract ever puts something deeper there.
|
|
55
|
+
export function projectPathParams(pathParamsSchema) {
|
|
56
|
+
const required = Array.isArray(pathParamsSchema?.required) ? pathParamsSchema.required.filter((r) => typeof r === 'string') : [];
|
|
57
|
+
const properties = {};
|
|
58
|
+
const unsupported = [];
|
|
59
|
+
for (const [key, propSchema] of Object.entries(pathParamsSchema?.properties ?? {})) {
|
|
60
|
+
if (isScalarLeaf(propSchema)) {
|
|
61
|
+
properties[key] = { type: propSchema.type, ...(typeof propSchema.pattern === 'string' ? { pattern: propSchema.pattern } : {}) };
|
|
62
|
+
} else {
|
|
63
|
+
unsupported.push(`/${key}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { required, properties, unsupported };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A8: sourceResponses' own keys are literal status codes/ranges/"default" straight from a real
|
|
70
|
+
// source document (schemas/feature-contract.schema.json's own propertyNames pattern), never
|
|
71
|
+
// re-bucketed here -- matching status against them at runtime (a real observed code against
|
|
72
|
+
// "4XX"/"default") is a bounded, mechanical string comparison, not JSON-Schema interpretation, so
|
|
73
|
+
// doing it in each provider's own generated checker doesn't violate the "one interpreter" boundary.
|
|
74
|
+
export function projectStatuses(sourceResponses) {
|
|
75
|
+
return sourceResponses ? Object.keys(sourceResponses) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function projectOperation(opContract) {
|
|
79
|
+
return {
|
|
80
|
+
verb: opContract.verb,
|
|
81
|
+
path: opContract.path,
|
|
82
|
+
pathParams: projectPathParams(opContract.pathParams),
|
|
83
|
+
// Normalized to always a JSON string ("true"/"false"/"unknown") -- opContract.body is a
|
|
84
|
+
// true|false|'unknown' tri-state (mixed boolean/string), awkward for a generated checker to
|
|
85
|
+
// parse unambiguously; String() keeps the projected shape uniformly typed.
|
|
86
|
+
body: String(opContract.body),
|
|
87
|
+
request: opContract.body === false ? null : projectBodySchema(opContract.requestBodySchema),
|
|
88
|
+
response: projectBodySchema(opContract.responseSchema),
|
|
89
|
+
error: projectBodySchema(opContract.errorSchema),
|
|
90
|
+
statuses: projectStatuses(opContract.sourceResponses),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -12,6 +12,11 @@ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
|
12
12
|
const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
|
|
13
13
|
const MIGRATION_TEMPLATE = path.join(TEMPLATES_DIR, 'migration.sql.tmpl');
|
|
14
14
|
const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'ResourceResolverStub.java.tmpl');
|
|
15
|
+
// D-resolver-policy-split: a resource's live-derived, security-relevant declarations
|
|
16
|
+
// (type/requiredAuthority/requiredAuthorityForPatch/contractRef/featureUid) are generated into
|
|
17
|
+
// this separate, always-safe-to-regenerate companion file rather than the same file as the
|
|
18
|
+
// hand-editable patchField() body -- see the template's own javadoc and DECISIONS.md.
|
|
19
|
+
const RESOLVER_POLICY_TEMPLATE = path.join(TEMPLATES_DIR, 'ResourceResolverPolicyStub.java.tmpl');
|
|
15
20
|
|
|
16
21
|
const INFRA_FILES = [
|
|
17
22
|
{ template: 'HandleCodec.java.tmpl', target: 'global/handle/HandleCodec.java' },
|
|
@@ -146,7 +151,7 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
146
151
|
|
|
147
152
|
const resolverUnits = plan.resources
|
|
148
153
|
.filter((r) => r.willGenerateResolver) // see plan.mjs: no broken imports generated on purpose
|
|
149
|
-
.
|
|
154
|
+
.flatMap((resource) => {
|
|
150
155
|
const patchable = resource.patchable ?? [];
|
|
151
156
|
// A blocked update service (see plan.mjs's updateServiceBlockedReason) means NO field of
|
|
152
157
|
// this resource can be auto-generated regardless of approvals -- ignore any recorded
|
|
@@ -155,21 +160,17 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
155
160
|
const approvedFields = resource.updateServiceBlockedReason ? new Set() : currentlyApprovedFields(patchApprovals, resource.type, patchable);
|
|
156
161
|
const { needsValidation, needsPatchFieldImport } = computeCodegenNeeds(patchable, approvedFields);
|
|
157
162
|
const serviceField = lowerFirst(resource.service.serviceType);
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
163
|
+
const sharedVars = { BASE_PACKAGE: basePackage, MODULE: plan.module, RESOURCE_TYPE: resource.type, FEATURE_ID: featureId };
|
|
164
|
+
// D-resolver-policy-split: the resolver's own vars no longer include the four
|
|
165
|
+
// live-derived/security-relevant tokens -- those move to policyVars below, into a
|
|
166
|
+
// separate generated file, specifically so a hand-edited patchField() (which stales
|
|
167
|
+
// THIS file's own template render) can never block one of those values from updating.
|
|
168
|
+
const resolverVars = {
|
|
169
|
+
...sharedVars,
|
|
162
170
|
SERVICE_IMPORT: `${basePackage}.domain.${plan.module}.application.${resource.service.serviceType}`,
|
|
163
171
|
SERVICE_TYPE: resource.service.serviceType,
|
|
164
172
|
SERVICE_FIELD: serviceField,
|
|
165
173
|
FETCH_METHOD: resource.fetchOperation.method,
|
|
166
|
-
REQUIRED_AUTHORITY: resource.requiredAuthority,
|
|
167
|
-
// O5 (D-resolver-authorization-action-aware): independently derived from the UPDATE
|
|
168
|
-
// endpoint's own @PreAuthorize -- see plan.mjs's own computation.
|
|
169
|
-
REQUIRED_AUTHORITY_PATCH: resource.requiredAuthorityForPatch,
|
|
170
|
-
FEATURE_ID: featureId,
|
|
171
|
-
CONTRACT_REF: contractRef,
|
|
172
|
-
FEATURE_UID: featureUid,
|
|
173
174
|
PATCH_IMPORTS: needsValidation ? buildPatchImports({ basePackage, module: plan.module, dtoTypeName: resource.dtoTypeName, needsPatchFieldImport, jacksonPackage }) : '',
|
|
174
175
|
PATCH_FIELDS: needsValidation ? buildPatchFields() : '',
|
|
175
176
|
PATCH_FIELD_BODY: renderPatchFieldBody({
|
|
@@ -182,27 +183,50 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
182
183
|
blockedReason: resource.updateServiceBlockedReason,
|
|
183
184
|
}),
|
|
184
185
|
};
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
...vars,
|
|
194
|
-
FEATURE_ID: ownerId,
|
|
195
|
-
CONTRACT_REF: ownerId === featureId ? contractRef : contractRefFor(ownerId),
|
|
196
|
-
FEATURE_UID: ownerId === featureId ? featureUid : featureUidFor(ownerId),
|
|
197
|
-
}),
|
|
186
|
+
const policyVars = {
|
|
187
|
+
...sharedVars,
|
|
188
|
+
REQUIRED_AUTHORITY: resource.requiredAuthority,
|
|
189
|
+
// O5 (D-resolver-authorization-action-aware): independently derived from the UPDATE
|
|
190
|
+
// endpoint's own @PreAuthorize -- see plan.mjs's own computation.
|
|
191
|
+
REQUIRED_AUTHORITY_PATCH: resource.requiredAuthorityForPatch,
|
|
192
|
+
CONTRACT_REF: contractRef,
|
|
193
|
+
FEATURE_UID: featureUid,
|
|
198
194
|
};
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
id: 'ResourceResolverStub.java.tmpl',
|
|
198
|
+
resourceType: resource.type,
|
|
199
|
+
module: plan.module,
|
|
200
|
+
templatePath: RESOLVER_TEMPLATE,
|
|
201
|
+
targetAbs: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure', `${resource.type}Resolver.java`),
|
|
202
|
+
rendered: render(RESOLVER_TEMPLATE, resolverVars),
|
|
203
|
+
// Only FEATURE_ID varies by owner now -- the four moved tokens no longer live here.
|
|
204
|
+
pristineRenderFor: (ownerId) => render(RESOLVER_TEMPLATE, { ...resolverVars, FEATURE_ID: ownerId }),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: 'ResourceResolverPolicyStub.java.tmpl',
|
|
208
|
+
resourceType: resource.type,
|
|
209
|
+
module: plan.module,
|
|
210
|
+
templatePath: RESOLVER_POLICY_TEMPLATE,
|
|
211
|
+
targetAbs: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure', `${resource.type}ResolverPolicy.java`),
|
|
212
|
+
rendered: render(RESOLVER_POLICY_TEMPLATE, policyVars),
|
|
213
|
+
pristineRenderFor: (ownerId) => render(RESOLVER_POLICY_TEMPLATE, {
|
|
214
|
+
...policyVars,
|
|
215
|
+
FEATURE_ID: ownerId,
|
|
216
|
+
CONTRACT_REF: ownerId === featureId ? contractRef : contractRefFor(ownerId),
|
|
217
|
+
FEATURE_UID: ownerId === featureId ? featureUid : featureUidFor(ownerId),
|
|
218
|
+
}),
|
|
219
|
+
},
|
|
220
|
+
];
|
|
199
221
|
});
|
|
200
222
|
|
|
201
223
|
const orphanScan = (!resourceFilter && plan.module) ? {
|
|
202
224
|
dir: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure'),
|
|
203
225
|
module: plan.module,
|
|
204
|
-
|
|
205
|
-
|
|
226
|
+
// D-resolver-policy-split: recognize both generated suffixes -- longest-first so
|
|
227
|
+
// 'FooResolverPolicy.java' isn't misclassified as a plain '...Policy' resource type.
|
|
228
|
+
matchesFile: (file) => file.endsWith('ResolverPolicy.java') || file.endsWith('Resolver.java'),
|
|
229
|
+
resourceTypeOf: (file, _content) => file.replace(/ResolverPolicy\.java$|Resolver\.java$/, ''),
|
|
206
230
|
} : null;
|
|
207
231
|
|
|
208
232
|
const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// D-runtime-conformance-receipts: the emit-side half of opt-in runtime contract-conformance
|
|
2
|
+
// checking. Kept as its own file, sibling to emit.mjs, rather than folded into it -- observe and
|
|
3
|
+
// handles are orthogonal capabilities (one validates real traffic shape, the other exposes/patches
|
|
4
|
+
// UUID-addressable fields) that happen to share the same repo-wide "generated infra" pattern, not
|
|
5
|
+
// the same feature. See DECISIONS.md for the full WHY.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { emitUnits, unifiedDiff } from '../../_engine.mjs';
|
|
10
|
+
import { sha256File } from '../../../lib/fsutil.mjs';
|
|
11
|
+
import { specPath } from '../../../lib/paths.mjs';
|
|
12
|
+
import { detectJacksonPackage } from './emit.mjs';
|
|
13
|
+
import { projectOperation } from '../../observe-schema-projection.mjs';
|
|
14
|
+
|
|
15
|
+
const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
|
|
17
|
+
|
|
18
|
+
// Repo-wide, shared across every feature that ever runs `bskel observe emit` -- ObserveSchemaLoader
|
|
19
|
+
// discovers every `bskel/*.observed-schema.json` classpath resource at startup rather than being
|
|
20
|
+
// regenerated per feature, so these four files are true infra (create-once-per-repo, all-or-nothing
|
|
21
|
+
// conflict unit), same treatment INFRA_FILES gives handles' own global/handle/* files.
|
|
22
|
+
const INFRA_FILES = [
|
|
23
|
+
{ template: 'ObserveContract.java.tmpl', target: 'global/observe/ObserveContract.java' },
|
|
24
|
+
{ template: 'ContractCheck.java.tmpl', target: 'global/observe/ContractCheck.java' },
|
|
25
|
+
{ template: 'ObserveSchemaLoader.java.tmpl', target: 'global/observe/ObserveSchemaLoader.java' },
|
|
26
|
+
{ template: 'ContractObservationAspect.java.tmpl', target: 'global/observe/ContractObservationAspect.java' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function render(templatePath, vars) {
|
|
30
|
+
let content = fs.readFileSync(templatePath, 'utf8');
|
|
31
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
32
|
+
content = content.replaceAll(`{{${key}}}`, String(value));
|
|
33
|
+
}
|
|
34
|
+
return content;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeUnit(target, content) {
|
|
38
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
39
|
+
fs.writeFileSync(target, content);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// isScalarLeaf/projectBodySchema/projectPathParams/projectStatuses/projectOperation moved to
|
|
43
|
+
// handles/observe-schema-projection.mjs (D-runtime-conformance-receipts) -- pure contract-shape
|
|
44
|
+
// logic shared verbatim with python-fastapi/observe.mjs, nothing java-specific about it.
|
|
45
|
+
|
|
46
|
+
// See DECISIONS.md D-runtime-conformance-receipts. `contract` is the already-loaded, already
|
|
47
|
+
// schema-validated feature contract (bin/bskel.mjs's loadContract) -- this function does not read
|
|
48
|
+
// specs/ itself. `resolverUnits: []`/`orphanScan: null`: there is no per-resource generated file
|
|
49
|
+
// here (a human applies @ObserveContract directly to arbitrary existing methods), so emitUnits()'s
|
|
50
|
+
// resolver/orphan machinery has nothing to do -- confirmed its signature tolerates both.
|
|
51
|
+
export function emitObserveJavaSpring({ repoRoot, featureId, contract, basePackage, force = false, reason = '', dryRun = false, computeDiff = false }) {
|
|
52
|
+
const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
|
|
53
|
+
const jacksonPackage = detectJacksonPackage(repoRoot);
|
|
54
|
+
|
|
55
|
+
const infraUnits = INFRA_FILES.map((f) => ({
|
|
56
|
+
id: f.template,
|
|
57
|
+
templatePath: path.join(TEMPLATES_DIR, f.template),
|
|
58
|
+
targetAbs: path.join(javaSrcRoot, f.target),
|
|
59
|
+
rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage }),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
|
|
63
|
+
|
|
64
|
+
// The projected observed-schema.json classpath resource -- regenerated unconditionally every
|
|
65
|
+
// run, like handles' own migration.sql, and for the identical reason: nobody hand-finishes a
|
|
66
|
+
// generated data file the way they hand-finish a resolver stub, so O2-style conflict tracking
|
|
67
|
+
// buys nothing here. `kind: 'spec'` matches migration.sql's own action-reporting convention.
|
|
68
|
+
const operations = {};
|
|
69
|
+
for (const [opId, opContract] of Object.entries(contract.operations)) {
|
|
70
|
+
operations[opId] = projectOperation(opContract);
|
|
71
|
+
}
|
|
72
|
+
const contractRef = sha256File(specPath(repoRoot, featureId, 'contracts', `${featureId}.schema.json`));
|
|
73
|
+
const schemaContent = `${JSON.stringify({ sbf_observed_schema: '1', feature_id: featureId, feature_uid: contract.feature_uid, contract_ref: contractRef, operations }, null, '\t')}\n`;
|
|
74
|
+
const schemaPath = path.join(repoRoot, 'src', 'main', 'resources', 'bskel', `${featureId}.observed-schema.json`);
|
|
75
|
+
const schemaRelPath = path.relative(repoRoot, schemaPath);
|
|
76
|
+
const schemaDiskContent = fs.existsSync(schemaPath) ? fs.readFileSync(schemaPath, 'utf8') : null;
|
|
77
|
+
const schemaAction = schemaDiskContent === null ? 'create' : (schemaDiskContent === schemaContent ? 'unchanged' : 'update');
|
|
78
|
+
if (!dryRun) writeUnit(schemaPath, schemaContent);
|
|
79
|
+
result.written.push(schemaRelPath);
|
|
80
|
+
const schemaActionEntry = { path: schemaRelPath, kind: 'spec', action: schemaAction };
|
|
81
|
+
if (computeDiff && schemaAction === 'update') schemaActionEntry.diff = unifiedDiff(schemaRelPath, schemaDiskContent, schemaContent);
|
|
82
|
+
result.actions.push(schemaActionEntry);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
...result,
|
|
86
|
+
postEmitNotes: [
|
|
87
|
+
'NOT done automatically: ContractObservationAspect.java requires spring-boot-starter-aop on your own build.gradle classpath -- if you already added it for @RecordHandleSnapshot (O4/handles), no new dependency is needed here.',
|
|
88
|
+
'NOT done automatically: route the "bskel.observe.receipts" SLF4J logger to wherever you want receipt lines collected (a dedicated logback/log4j2 appender to a file, your existing log pipeline, etc.) -- bskel never edits your logging config. Point `bskel observe import --receipts <path>` at whatever that logger\'s output ends up as.',
|
|
89
|
+
`Contract-conformance checking only covers path params always, plus a bounded slice of request/response/error body shape -- and only when this contract was emitted with --openapi-file. See the emitted ${path.relative(repoRoot, schemaPath)}'s own "unsupported" markers for exactly what is skipped for this feature.`,
|
|
90
|
+
'NOT done automatically: apply @ObserveContract(operationId = "...") to whichever existing controller/service methods you want observed -- nothing is annotated for you (D-resolver-scope: never guess which method implements which operation).',
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
}
|