backend-skeleton 1.0.0-beta.4 → 1.0.0-beta.6

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 +27 -15
  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 +2 -2
  24. package/schemas/conformance-report.schema.json +49 -0
  25. package/schemas/observe-receipt.schema.json +32 -0
package/README.md CHANGED
@@ -5,11 +5,19 @@
5
5
  [![node](https://img.shields.io/node/v/backend-skeleton.svg)](https://www.npmjs.com/package/backend-skeleton)
6
6
  [![GitHub release](https://img.shields.io/github/v/release/popixoxipop-collab/backend-skeleton?include_prereleases&label=release)](https://github.com/popixoxipop-collab/backend-skeleton/releases)
7
7
 
8
- Spec-driven backend scaffolding for brownfield (and greenfield) Java/Spring Boot, Python/FastAPI,
9
- and TypeScript/JavaScript Express repos: a brownfield-collision gate before any spec/plan step,
8
+ `bskel` is a deterministic gate layer for AI-assisted (and human) backend work: before a change is
9
+ allowed to count as done, `bskel` checks it against disk — not against what an agent or a person
10
+ claims. Is this branch actually based on the real default branch? Does this "new" module collide
11
+ with one that already exists elsewhere in the codebase? Does the emitted contract still match the
12
+ source it claims to describe? Did a hand-finished file just get silently overwritten? Every one of
13
+ those is a gate backed by a `content-hash` on disk, not a prompt instruction a future session could
14
+ ignore, forget, or talk itself past.
15
+
16
+ Scaffolding codegen — Java/Spring Boot, Python/FastAPI, and TypeScript/JavaScript Express repos,
10
17
  feature_id-scoped machine-readable contracts, UUID-addressable field handles, and stack-choice
11
- (e.g. ngrok) wiring — all enforced by disk `content-hash` gates, not prompt instructions a future
12
- session could ignore.
18
+ (e.g. ngrok) wiring — rides on top of that same gate machinery. It's useful on its own, but the
19
+ reason the gates exist first is what makes the codegen safe to trust in a brownfield repo, instead
20
+ of just another thing to double-check by hand.
13
21
 
14
22
  `bskel` exists because a previous ad-hoc agent-driven scaffolding attempt branched a worktree 658
15
23
  commits behind the real default branch and never noticed. Every gate in this tool is a regression
@@ -24,13 +32,17 @@ stable. Split by actual maturity, not by feature list:
24
32
  measured verification against a real production Spring Boot repo (see `DECISIONS.md`), plus a
25
33
  synthetic fixture corpus for every adapter, run in CI on every change.
26
34
  - **`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.
35
+ tested the same way, but has never been deployed to a real production repo. Two gaps named in
36
+ earlier betas have since been partially closed: `O3` (opt-in `--enforce-registry`, checked
37
+ fetch/patch/recover against `HandleRegistry`, revocation-aware) and `O5` (fetch/patch now derive
38
+ independently correct roles instead of silently sharing one) are both implemented -- see
39
+ `DECISIONS.md`'s `D-handle-registry-enforcement`/`D-resolver-authorization-action-aware`. Real
40
+ gaps remain and are explicitly still open, not closed: registry enforcement is opt-in, off by
41
+ default; authorization inference still only recognizes a single `@PreAuthorize(hasRole(...))`
42
+ shape (`hasAuthority`, role lists, ownership/tenant policy are unaddressed); and Java/Python are
43
+ the only providers either applies to -- TypeScript Express has no persistent handle table at
44
+ all. Treat `handles emit`'s output as a scaffold to finish by hand, not a production-ready
45
+ subsystem, until a real deployment happens.
34
46
 
35
47
  Version numbers, install instructions, and a real feedback path will firm up as this gets used
36
48
  against more real repos.
@@ -227,10 +239,10 @@ audited, never silent.
227
239
  ## Security model
228
240
 
229
241
  `bskel` generates code that runs in production, so it was put through an adversarial security
230
- review (Codex, security-only lens, independent of the build process) — 8 findings, all fixed, each
231
- with an inline `D-security-N` comment at its exact location in the code. Highlights (full record,
232
- including 3 additional defensive-hardening items, in `DECISIONS.md`'s "Security hardening pass"
233
- section):
242
+ review (Codex, security-only lens, independent of the build process) — 10 numbered items, all
243
+ fixed, each with an inline `D-security-N` comment at its exact location in the code (the last item
244
+ bundles three additional lower-priority fixes from Codex's own broader "other things worth
245
+ checking" pass). Highlights (full record in `DECISIONS.md`'s "Security hardening pass" section):
234
246
 
235
247
  - **Prototype-pollution guards** everywhere a user-controlled string indexes a plain object
236
248
  (`operation_id` values like `"constructor"`/`"__proto__"` are rejected, not silently resolved via
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 });