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.
Files changed (25) hide show
  1. package/README.md +11 -7
  2. package/bin/bskel.mjs +252 -2
  3. package/handles/_engine.mjs +3 -1
  4. package/handles/observe-schema-projection.mjs +92 -0
  5. package/handles/providers/java-spring/emit.mjs +51 -27
  6. package/handles/providers/java-spring/observe.mjs +93 -0
  7. package/handles/providers/java-spring/templates/ContractCheck.java.tmpl +129 -0
  8. package/handles/providers/java-spring/templates/ContractObservationAspect.java.tmpl +190 -0
  9. package/handles/providers/java-spring/templates/ObserveContract.java.tmpl +42 -0
  10. package/handles/providers/java-spring/templates/ObserveSchemaLoader.java.tmpl +134 -0
  11. package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +54 -0
  12. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +10 -12
  13. package/handles/providers/python-fastapi/emit.mjs +47 -21
  14. package/handles/providers/python-fastapi/observe.mjs +96 -0
  15. package/handles/providers/python-fastapi/templates/contract_check.py.tmpl +112 -0
  16. package/handles/providers/python-fastapi/templates/observe_contract.py.tmpl +165 -0
  17. package/handles/providers/python-fastapi/templates/observed_schema.py.tmpl +105 -0
  18. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +6 -8
  19. package/handles/providers/python-fastapi/templates/resolver_policy.py.tmpl +19 -0
  20. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +5 -0
  21. package/lib/cli.mjs +26 -0
  22. package/lib/gate-definitions.mjs +20 -1
  23. package/package.json +1 -1
  24. package/schemas/conformance-report.schema.json +49 -0
  25. 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, and two named gaps
28
- stay open specifically because of that: generated `fetch()`/`patch()` paths never check
29
- `HandleRegistry` for revocation, and generated authorization is inferred from a single
30
- `@PreAuthorize(hasRole(...))` shape rather than a real policy contract. Both are tracked as `O3`
31
- and `O5` in `CATALOG.md`, explicitly deferred until handles are actually used somewhere real --
32
- treat `handles emit`'s output as a scaffold to finish by hand, not a production-ready subsystem,
33
- until that work lands.
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, no pipe-truncation risk.
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;
@@ -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
- return { written, resolverStubs, conflicts, orphans, notes, forced, blocked: conflicts.length > 0, actions };
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
- .map((resource) => {
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 vars = {
159
- BASE_PACKAGE: basePackage,
160
- MODULE: plan.module,
161
- RESOURCE_TYPE: resource.type,
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
- return {
186
- id: 'ResourceResolverStub.java.tmpl',
187
- resourceType: resource.type,
188
- module: plan.module,
189
- templatePath: RESOLVER_TEMPLATE,
190
- targetAbs: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure', `${resource.type}Resolver.java`),
191
- rendered: render(RESOLVER_TEMPLATE, vars),
192
- pristineRenderFor: (ownerId) => render(RESOLVER_TEMPLATE, {
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
- matchesFile: (file) => file.endsWith('Resolver.java'),
205
- resourceTypeOf: (file, _content) => file.replace(/Resolver\.java$/, ''),
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
+ }