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

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 (36) hide show
  1. package/README.md +5 -0
  2. package/bin/bskel.mjs +247 -9
  3. package/contracts/completeness.mjs +21 -2
  4. package/contracts/emit.mjs +11 -0
  5. package/contracts/openapi.mjs +95 -0
  6. package/handles/audit.mjs +83 -0
  7. package/handles/codec.mjs +11 -5
  8. package/handles/providers/java-spring/emit.mjs +9 -2
  9. package/handles/providers/java-spring/plan.mjs +20 -0
  10. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
  11. package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
  12. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
  13. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
  14. package/handles/providers/java-spring.mjs +2 -2
  15. package/handles/providers/python-fastapi/emit.mjs +5 -1
  16. package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
  17. package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
  18. package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
  19. package/lib/cli.mjs +45 -2
  20. package/lib/handles-manifest.mjs +10 -4
  21. package/lib/repo.mjs +56 -0
  22. package/package.json +1 -1
  23. package/scanners/adapters/_express-shared.mjs +17 -12
  24. package/scanners/adapters/generic-grep.mjs +5 -1
  25. package/scanners/adapters/java-spring.mjs +60 -14
  26. package/scanners/adapters/javascript-express.mjs +5 -1
  27. package/scanners/adapters/python-fastapi.mjs +17 -14
  28. package/scanners/adapters/typescript-express.mjs +6 -1
  29. package/scanners/registry.mjs +5 -2
  30. package/scanners/text-util.mjs +25 -0
  31. package/schemas/adapter.schema.json +6 -2
  32. package/schemas/contract-resolution.schema.json +6 -1
  33. package/schemas/handles-plan.schema.json +1 -0
  34. package/stack/bootstrap/db-up.sh +52 -0
  35. package/stack/bootstrap/docker-compose.postgres.yml +18 -0
  36. package/stack/catalog/postgres-dev-db.yml +55 -0
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # backend-skeleton
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/backend-skeleton.svg)](https://www.npmjs.com/package/backend-skeleton)
4
+ [![npm license](https://img.shields.io/npm/l/backend-skeleton.svg)](https://github.com/popixoxipop-collab/backend-skeleton/blob/main/LICENSE)
5
+ [![node](https://img.shields.io/node/v/backend-skeleton.svg)](https://www.npmjs.com/package/backend-skeleton)
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
+
3
8
  Spec-driven backend scaffolding for brownfield (and greenfield) Java/Spring Boot, Python/FastAPI,
4
9
  and TypeScript/JavaScript Express repos: a brownfield-collision gate before any spec/plan step,
5
10
  feature_id-scoped machine-readable contracts, UUID-addressable field handles, and stack-choice
package/bin/bskel.mjs CHANGED
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import fs from 'node:fs';
7
7
  import os from 'node:os';
8
- import { repoRoot, localDefaultBranch } from '../lib/repo.mjs';
8
+ import { repoRoot, localDefaultBranch, fileHistory, showFileAtRevision, headSha, currentBranch, isDirty } from '../lib/repo.mjs';
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';
@@ -22,6 +22,7 @@ import {
22
22
  import { runScan } from '../scanners/index.mjs';
23
23
  import { scanMigrations } from '../scanners/db/migrations.mjs';
24
24
  import { introspectSchema, describeConnectionError } from '../scanners/db/introspect.mjs';
25
+ import { auditHandles, summarizeAudit, isMissingHandleTables } from '../handles/audit.mjs';
25
26
  import { renderScanMarkdown, renderPlanConstraints, renderScanExplain } from '../scanners/render.mjs';
26
27
  import { ADAPTERS, LOAD_ERRORS, adapterById } from '../scanners/registry.mjs';
27
28
  import { COMMAND_CAPABILITIES, CAPABILITY_SATISFIERS, explainMissingCapability } from '../scanners/capabilities.mjs';
@@ -29,6 +30,7 @@ import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contrac
29
30
  import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
30
31
  import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
31
32
  import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
33
+ import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
32
34
  import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting } from '../new/index.mjs';
33
35
  import {
34
36
  requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
@@ -67,14 +69,16 @@ function usage() {
67
69
  bskel feature archive <id> --reason "..." [--json]
68
70
  bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0] [--descriptions]
69
71
  bskel contract export --feature <id> [--out <path>] [--json] [--allow-unprefixed] [--status-codes range|literal]
72
+ bskel contract history --feature <id> [--json]
70
73
  bskel contract validate --feature <id> --file <envelope.json>
71
74
  bskel contract tool-schema --feature <id> --operation <operationId>
72
- bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..."
75
+ bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..." [--expires <Nd>]
73
76
  bskel stack apply --choice <id> [--apply] [--port N] [--json]
74
77
  bskel catalog lint [<choice>] [--json]
75
78
  bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
76
- bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff]
79
+ bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
77
80
  bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
81
+ bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]
78
82
  bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
79
83
  bskel status [--feature <id>] [--json]
80
84
  bskel next [--feature <id>] [--json]
@@ -83,6 +87,7 @@ function usage() {
83
87
  bskel gate revoke <name> --reason "..." [--feature <id>]
84
88
  bskel gate history <name> [--feature <id>] [--json]
85
89
  bskel gate show [<name>] [--feature <id>]
90
+ bskel gate export --feature <id> [--out <path>] [--json]
86
91
  bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
87
92
  `);
88
93
  }
@@ -360,6 +365,48 @@ function cmdGateShow(args) {
360
365
  process.exit(0);
361
366
  }
362
367
 
368
+ // D-gate-export (S7's own sibling item): a standalone, human- and machine-readable report of
369
+ // exactly what THIS repo's own `.sbf/*.history.jsonl` shows -- current state + full history for
370
+ // every gate, plus enough git provenance (branch/HEAD/dirty) to say when it was captured. Built to
371
+ // answer "what did this PR actually get verified against" *independent of whether CI ran at all* --
372
+ // the concrete, real mitigation for the exact GitHub-Actions-billing outage this repo itself has
373
+ // been running under (see feedback_backend_skeleton_ci_gate_suspended_billing in project memory).
374
+ // Pure reader -- never mutates a gate, never requires one to currently pass.
375
+ function cmdGateExport(args) {
376
+ const flags = parseCommand('gate export', args);
377
+ if (flags.help) { console.log(renderCommandHelp('gate export')); process.exit(0); }
378
+ setContext('gate export', flags);
379
+ const root = requireRepoRoot();
380
+ requireValidFeatureId(flags.feature);
381
+
382
+ const gates = {};
383
+ for (const name of GATE_NAMES) {
384
+ const scopeId = gateScopeId(name, flags.feature);
385
+ gates[name] = { scope: scopeId, current: getGate(root, scopeId, name), history: readGateHistory(root, scopeId, name) };
386
+ }
387
+
388
+ const report = {
389
+ schema: 'sbf.gate-export/1',
390
+ feature_id: flags.feature,
391
+ generated_at: new Date().toISOString(),
392
+ git: { branch: currentBranch(root), head_sha: headSha(root), dirty: isDirty(root) },
393
+ gates,
394
+ };
395
+ const rendered = `${JSON.stringify(report, null, 2)}\n`;
396
+
397
+ if (flags.out) {
398
+ const outPath = path.resolve(process.cwd(), flags.out);
399
+ writeFileAtomic(outPath, rendered);
400
+ if (!flags.quiet) {
401
+ const passCount = GATE_NAMES.filter((n) => gates[n].current?.status === 'pass').length;
402
+ console.log(`wrote ${flags.out} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${report.git.dirty ? ' (dirty)' : ''}`);
403
+ }
404
+ } else {
405
+ console.log(rendered);
406
+ }
407
+ process.exit(0);
408
+ }
409
+
363
410
  // Structural enforcement of "preflight blocks everything below it" (see the workflow table in
364
411
  // SKILL.md) for every feature-scoped command -- not just documented as a step order, checked.
365
412
  // Ad-hoc `bskel scan` (no --feature) is exempt: it's an explicit side-channel quick-look
@@ -942,6 +989,7 @@ function cmdContractEmit(args) {
942
989
  warning_codes: countByCode(contract.warnings),
943
990
  waived_count: evaluation.waived.length,
944
991
  stale_waivers: evaluation.staleWaivers.length,
992
+ expired_waivers: evaluation.expiredWaivers.length,
945
993
  openapi: reconciliation
946
994
  ? {
947
995
  applied: true,
@@ -993,6 +1041,10 @@ function cmdContractEmit(args) {
993
1041
  }
994
1042
  for (const w of contract.warnings) console.error(`warning[${w.severity}] ${w.code}${w.subject ? ` (${w.subject})` : ''}: ${w.message}`);
995
1043
  if (!flags.quiet) console.log(`gate: contract -> ${gateState.gates.contract.status}`);
1044
+ if (evaluation.expiredWaivers.length > 0) {
1045
+ console.error(`\nnote: ${evaluation.expiredWaivers.length} recorded waiver(s) have expired and no longer cover their warning (re-waive with --expires if still needed):`);
1046
+ for (const w of evaluation.expiredWaivers) console.error(` ${w.code} (${w.subject ?? '*'}) expired ${w.expires_at}`);
1047
+ }
996
1048
  if (evaluation.staleWaivers.length > 0) {
997
1049
  console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current warning (kept as-is, not auto-removed):`);
998
1050
  for (const w of evaluation.staleWaivers) console.error(` ${w.code} (${w.subject ?? '*'})`);
@@ -1197,12 +1249,26 @@ function cmdContractExport(args) {
1197
1249
  // waiver: `--all` expands to the SPECIFIC code+subject pairs present right now, recorded as
1198
1250
  // individual entries -- a warning that doesn't exist yet (e.g. a new unannotated endpoint added
1199
1251
  // later) is never covered by an old waive. See D-contract-completeness in DECISIONS.md.
1252
+ // D-waiver-expiry: only `<N>d` (whole days) -- the realistic common case for "look at this
1253
+ // again later," not a general ISO-8601 duration parser nobody asked for. `N` must be a positive
1254
+ // integer; `0d`/negative would either be a no-op waiver (already expired the moment it's written)
1255
+ // or nonsensical, and silently accepting either would be more confusing than refusing.
1256
+ function parseExpiresFlag(raw) {
1257
+ if (raw == null) return null;
1258
+ const match = /^([1-9][0-9]*)d$/.exec(raw);
1259
+ if (!match) {
1260
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--expires must look like "<N>d" (whole days, N >= 1), got "${raw}"`);
1261
+ }
1262
+ const days = Number(match[1]);
1263
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
1264
+ }
1265
+
1200
1266
  function cmdContractWaive(args) {
1201
1267
  const flags = parseCommand('contract waive', args);
1202
1268
  if (flags.help) { console.log(renderCommandHelp('contract waive')); process.exit(0); }
1203
1269
  setContext('contract waive', flags);
1204
1270
  const root = requireRepoRoot();
1205
- const usageText = 'usage: bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path" | --all) --reason "..."';
1271
+ const usageText = 'usage: bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path" | --all) --reason "..." [--expires <Nd>]';
1206
1272
  try {
1207
1273
  requireWarningCode(flags.code);
1208
1274
  } catch (err) {
@@ -1214,6 +1280,7 @@ function cmdContractWaive(args) {
1214
1280
  if (!flags.subject && !flags.all) {
1215
1281
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', usageText);
1216
1282
  }
1283
+ const expiresAt = parseExpiresFlag(flags.expires);
1217
1284
 
1218
1285
  const contract = loadContract(root, flags.feature);
1219
1286
  if (contract.completeness.status === 'blocked') {
@@ -1247,7 +1314,7 @@ function cmdContractWaive(args) {
1247
1314
  const at = new Date().toISOString();
1248
1315
  const entries = toWaive
1249
1316
  .filter((w) => !existingKeys.has(warningKey(w)))
1250
- .map((w) => ({ code: w.code, subject: w.subject, reason: flags.reason, at }));
1317
+ .map((w) => ({ code: w.code, subject: w.subject, reason: flags.reason, at, ...(expiresAt ? { expires_at: expiresAt } : {}) }));
1251
1318
  const next = {
1252
1319
  schema: 'sbf.contract-resolution/1',
1253
1320
  feature_id: flags.feature,
@@ -1265,6 +1332,7 @@ function cmdContractWaive(args) {
1265
1332
  warning_codes: countByCode(contract.warnings),
1266
1333
  waived_count: evaluation.waived.length,
1267
1334
  stale_waivers: evaluation.staleWaivers.length,
1335
+ expired_waivers: evaluation.expiredWaivers.length,
1268
1336
  };
1269
1337
  const gateState = evaluation.blocking
1270
1338
  ? awaitNamedGateDisposition(root, 'contract', flags.feature, { ...evidence, unwaived: evaluation.unwaived.map(({ code, subject }) => ({ code, subject })) })
@@ -1274,9 +1342,13 @@ function cmdContractWaive(args) {
1274
1342
  console.log(JSON.stringify({ waived: newEntries, gate: gateState.gates.contract }, null, 2));
1275
1343
  } else {
1276
1344
  if (!flags.quiet) {
1277
- console.log(`waived ${newEntries.length} new warning(s)${newEntries.length < toWaive.length ? ` (${toWaive.length - newEntries.length} already waived)` : ''}`);
1345
+ console.log(`waived ${newEntries.length} new warning(s)${newEntries.length < toWaive.length ? ` (${toWaive.length - newEntries.length} already waived)` : ''}${expiresAt ? `, expiring ${expiresAt}` : ''}`);
1278
1346
  console.log(`gate: contract -> ${gateState.gates.contract.status}`);
1279
1347
  }
1348
+ if (evaluation.expiredWaivers.length > 0) {
1349
+ console.error(`\nnote: ${evaluation.expiredWaivers.length} recorded waiver(s) have expired and no longer cover their warning (re-waive with --expires if still needed):`);
1350
+ for (const w of evaluation.expiredWaivers) console.error(` ${w.code} (${w.subject ?? '*'}) expired ${w.expires_at}`);
1351
+ }
1280
1352
  if (evaluation.blocking) {
1281
1353
  console.error(`\nstill blocked: ${evaluation.unwaived.length} unresolved warning(s) remain:`);
1282
1354
  for (const w of evaluation.unwaived) console.error(` ${w.code} (${w.subject})`);
@@ -1285,6 +1357,74 @@ function cmdContractWaive(args) {
1285
1357
  process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
1286
1358
  }
1287
1359
 
1360
+ // D-contract-history: a derived VIEW over the contract file's own git history in whatever repo
1361
+ // bskel is invoked in -- reads, never writes. Deliberately does NOT try to correlate a commit to
1362
+ // a specific `.sbf/<feature>.history.jsonl` gate-pass event: that file is per-machine, gitignored,
1363
+ // ephemeral state (see .gitignore's own comment on `.sbf/`), while a commit is shared -- the two
1364
+ // have no reliable 1:1 relationship, so this only reports what git itself can prove. `bskel gate
1365
+ // export` (a separate, later item) is the tool for "what did THIS machine's gate history record."
1366
+ function cmdContractHistory(args) {
1367
+ const flags = parseCommand('contract history', args);
1368
+ if (flags.help) { console.log(renderCommandHelp('contract history')); process.exit(0); }
1369
+ setContext('contract history', flags);
1370
+ const root = requireRepoRoot();
1371
+ requireValidFeatureId(flags.feature);
1372
+
1373
+ const contractPath = specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`);
1374
+ const relPath = path.relative(root, contractPath);
1375
+ const commits = fileHistory(root, relPath);
1376
+
1377
+ if (commits.length === 0) {
1378
+ if (flags.json) {
1379
+ console.log(JSON.stringify({ feature_id: flags.feature, path: relPath, revisions: [] }, null, 2));
1380
+ } else {
1381
+ console.log(`no git history for ${relPath} -- either this feature's contract was never committed, or specs/ isn't tracked in this repo. bskel does not require specs/ to be committed; if you want a history view, commit the contract as part of your normal workflow.`);
1382
+ }
1383
+ process.exit(0);
1384
+ }
1385
+
1386
+ let prevOperationNames = new Set();
1387
+ const revisions = commits.map(({ sha, date, subject }) => {
1388
+ const raw = showFileAtRevision(root, sha, relPath);
1389
+ let parsed = null;
1390
+ if (raw !== null) {
1391
+ try { parsed = JSON.parse(raw); } catch { parsed = null; }
1392
+ }
1393
+ if (parsed === null) {
1394
+ return { sha: sha.slice(0, 12), date, subject, parse_error: true };
1395
+ }
1396
+ const operationNames = new Set(Object.keys(parsed.operations ?? {}));
1397
+ const added = [...operationNames].filter((n) => !prevOperationNames.has(n)).sort();
1398
+ const removed = [...prevOperationNames].filter((n) => !operationNames.has(n)).sort();
1399
+ prevOperationNames = operationNames;
1400
+ return {
1401
+ sha: sha.slice(0, 12), date, subject,
1402
+ sbf_contract: parsed.sbf_contract ?? null,
1403
+ completeness_status: parsed.completeness?.status ?? null,
1404
+ operation_count: parsed.completeness?.operation_count ?? operationNames.size,
1405
+ operations_added: added,
1406
+ operations_removed: removed,
1407
+ };
1408
+ });
1409
+
1410
+ if (flags.json) {
1411
+ console.log(JSON.stringify({ feature_id: flags.feature, path: relPath, revisions }, null, 2));
1412
+ } else {
1413
+ console.log(`${relPath} -- ${revisions.length} revision(s), oldest first:\n`);
1414
+ for (const r of revisions) {
1415
+ if (r.parse_error) {
1416
+ console.log(`${r.date} ${r.sha} (unparseable at this revision -- pre-JSON format or corrupted)`);
1417
+ continue;
1418
+ }
1419
+ const delta = [];
1420
+ if (r.operations_added.length > 0) delta.push(`+${r.operations_added.join(',+')}`);
1421
+ if (r.operations_removed.length > 0) delta.push(`-${r.operations_removed.join(',-')}`);
1422
+ console.log(`${r.date} ${r.sha} sbf_contract=${r.sbf_contract} completeness=${r.completeness_status} operations=${r.operation_count}${delta.length > 0 ? ` (${delta.join(' ')})` : ''}`);
1423
+ }
1424
+ }
1425
+ process.exit(0);
1426
+ }
1427
+
1288
1428
  function cmdContractValidate(args) {
1289
1429
  const flags = parseCommand('contract validate', args);
1290
1430
  if (flags.help) { console.log(renderCommandHelp('contract validate')); process.exit(0); }
@@ -1572,7 +1712,8 @@ function renderHandlesPlan(plan, actions) {
1572
1712
  lines.push(`## ${r.type}${r.willGenerateResolver ? '' : ' (resolver will NOT be generated -- see notes)'}`);
1573
1713
  lines.push(`- table: ${r.table ?? '(unknown)'}, PK field: ${r.idField ?? '(unknown)'}`);
1574
1714
  lines.push(`- read via: ${r.readPath ?? '(not found)'}`);
1575
- lines.push(`- requiredAuthority: ${r.requiredAuthority}`);
1715
+ lines.push(`- requiredAuthority (fetch/recover): ${r.requiredAuthority}`);
1716
+ if (r.requiredAuthorityForPatch !== undefined) lines.push(`- requiredAuthorityForPatch: ${r.requiredAuthorityForPatch}`);
1576
1717
  lines.push('');
1577
1718
  }
1578
1719
  if (plan.notes.length > 0) {
@@ -1733,6 +1874,20 @@ function cmdHandlesEmit(args) {
1733
1874
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles emit --force requires --reason "..." -- every overwrite of diverged generated code must be auditable');
1734
1875
  }
1735
1876
 
1877
+ // O3 (D-handle-registry-enforcement): repo-wide, singleton state -- read BEFORE provider.emit()
1878
+ // (which loads its own, separate in-memory copy for `files` tracking) so an omitted flag
1879
+ // reuses whatever this repo's own manifest last recorded, rather than silently defaulting to
1880
+ // off. --enforce-registry off requires --reason ONLY when it's a REAL downgrade (currently on)
1881
+ // -- reaffirming an already-off value, or turning it on, never needs one.
1882
+ const priorManifest = loadManifest(root);
1883
+ if (flags['enforce-registry'] !== null && flags['enforce-registry'] !== 'on' && flags['enforce-registry'] !== 'off') {
1884
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--enforce-registry must be "on" or "off" (got "${flags['enforce-registry']}")`);
1885
+ }
1886
+ const enforceRegistry = flags['enforce-registry'] === null ? priorManifest.enforceRegistry : flags['enforce-registry'] === 'on';
1887
+ if (flags['enforce-registry'] === 'off' && priorManifest.enforceRegistry === true && (!flags.reason || !flags.reason.trim())) {
1888
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles emit --enforce-registry off requires --reason "..." when registry enforcement was previously on -- every downgrade of a security posture must be auditable');
1889
+ }
1890
+
1736
1891
  // Handles are only emitted for a feature whose contract has actually been established --
1737
1892
  // codegen against a feature nobody has scanned/contracted yet has nothing real to route to.
1738
1893
  const contractResult = requireNamedGate(root, 'contract', flags.feature);
@@ -1766,8 +1921,21 @@ function cmdHandlesEmit(args) {
1766
1921
  // does, without requiring both flags together.
1767
1922
  const dryRun = flags.check || flags.diff;
1768
1923
  const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = provider.emit({
1769
- repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff,
1924
+ repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff, enforceRegistry,
1770
1925
  });
1926
+
1927
+ // O3 (D-handle-registry-enforcement): persisted whenever the effective value actually changed
1928
+ // on a real (non-dryRun) run -- deliberately NOT gated on `blocked` below: O2's own "infra is
1929
+ // one all-or-nothing unit" rule means global/handle/* (including HandleController.java.tmpl/
1930
+ // router.py.tmpl) either all wrote together or none did, independent of a SEPARATE resolver
1931
+ // file conflicting -- the manifest should track what ACTUALLY landed on disk, not the overall
1932
+ // command's exit code. Re-reads the manifest fresh rather than reusing `priorManifest`, since
1933
+ // provider.emit() above may have just updated its own `files` tracking via a separate
1934
+ // loadManifest()/saveManifest() pair inside handles/_engine.mjs.
1935
+ if (!dryRun && enforceRegistry !== priorManifest.enforceRegistry) {
1936
+ const freshManifest = loadManifest(root);
1937
+ saveManifest(root, { ...freshManifest, enforceRegistry });
1938
+ }
1771
1939
  // D4: found live while grounding this against a real fixture -- `written` (pre-existing field,
1772
1940
  // unchanged semantics) unconditionally includes a java-spring `outputs.spec` file like
1773
1941
  // migration.sql even when its content is byte-identical (P4 already found this: it's never
@@ -1904,6 +2072,69 @@ function cmdHandlesPatchApprove(args) {
1904
2072
  process.exit(0);
1905
2073
  }
1906
2074
 
2075
+ // O7 (D-handle-audit-report): a pure reader, deliberately gate-independent -- matches
2076
+ // D-contract-history/D-gate-export's own posture, not `handles plan`/`handles emit`'s capability
2077
+ // gating. It never touches adapter-specific codegen (the query is over `feature_uid` alone, the
2078
+ // same regardless of which provider backed this feature), so it works even before a scan report
2079
+ // exists, as long as `specs/<id>/feature.json` does.
2080
+ async function cmdHandlesAudit(args) {
2081
+ const flags = parseCommand('handles audit', args);
2082
+ if (flags.help) { console.log(renderCommandHelp('handles audit')); process.exit(0); }
2083
+ setContext('handles audit', flags);
2084
+ const root = requireRepoRoot();
2085
+ requireValidFeatureId(flags.feature);
2086
+ const featureRecord = loadFeatureRecord(root, flags.feature);
2087
+
2088
+ // Same "never read from .env directly, name an already-exported env var" convention as A4's
2089
+ // --database-url-env (D-db-schema-plane) -- reused unchanged, not reinvented.
2090
+ const connectionString = process.env[flags['database-url-env']];
2091
+ if (!connectionString) {
2092
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--database-url-env ${flags['database-url-env']} names an environment variable that isn't set -- export it first (never read from .env directly; see D-db-schema-plane in DECISIONS.md)`);
2093
+ }
2094
+ const resourceTypes = flags.resource ? flags.resource.split(',').map((s) => s.trim()).filter(Boolean) : null;
2095
+
2096
+ let rows;
2097
+ try {
2098
+ rows = await auditHandles({ connectionString, featureUid: featureRecord.feature_uid, resourceTypes });
2099
+ } catch (err) {
2100
+ if (isMissingHandleTables(err)) {
2101
+ fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `sbf_handle/sbf_handle_snapshot don't exist in this database -- the generated migration.sql (see \`bskel handles emit\`'s own output) was never applied here. bskel never applies a migration automatically (see D-migration-scope in DECISIONS.md).`);
2102
+ }
2103
+ fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `could not query handle audit data: ${describeConnectionError(err)}`);
2104
+ }
2105
+
2106
+ const summary = summarizeAudit(rows);
2107
+ // Printed in EVERY mode, not just as a doc comment -- this command's whole value is genuinely
2108
+ // capped until O3 (revocation enforcement)/O5 (authorization contracts) close, and that must
2109
+ // not be discoverable only by someone who already read DECISIONS.md prose (see
2110
+ // D-openapi-extraction-hint's own precedent for "the CLI itself carries this warning, not
2111
+ // just documentation").
2112
+ const caveat = 'this reports what the target application chose to record via @RecordHandleSnapshot / record_snapshot -- it is NOT, and cannot be, a security control on its own (see O3/O5 in CATALOG.md for revocation enforcement and authorization contracts). Absence of a snapshot does not mean a handle was never used, only that recording was never opted into for that call path.';
2113
+ const report = {
2114
+ schema: 'sbf.handle-audit/1',
2115
+ feature_id: flags.feature,
2116
+ feature_uid: featureRecord.feature_uid,
2117
+ generated_at: new Date().toISOString(),
2118
+ summary,
2119
+ handles: rows,
2120
+ caveat,
2121
+ };
2122
+
2123
+ if (flags.json) {
2124
+ console.log(JSON.stringify(report, null, 2));
2125
+ } else {
2126
+ console.log(`handle audit -- feature ${flags.feature} (${featureRecord.feature_uid})`);
2127
+ console.log(` ${summary.total_handles} handle(s), ${summary.revoked_handles} revoked, ${summary.never_snapshotted} never snapshotted, ${summary.total_snapshots} snapshot(s) total`);
2128
+ for (const h of rows) {
2129
+ const revokedNote = h.revoked_at ? ` -- REVOKED (${h.revoked_reason ?? 'no reason recorded'})` : '';
2130
+ const pointerNote = h.pointer ? `#${h.pointer}` : '';
2131
+ console.log(` ${h.kind} ${h.resource_type}/${h.resource_uid}${pointerNote} -- ${h.snapshot_count} snapshot(s), last ${h.last_recorded_at ?? 'never'}${revokedNote}`);
2132
+ }
2133
+ console.error(`\nnote: ${caveat}`);
2134
+ }
2135
+ process.exit(0);
2136
+ }
2137
+
1907
2138
  // S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
1908
2139
  // input that actually moved, using the exact reason requireGate()'s explainStaleness() reports.
1909
2140
  function describeStale(g) {
@@ -2082,6 +2313,10 @@ function cmdDoctor(args) {
2082
2313
  const adapters = showAdapters
2083
2314
  ? ADAPTERS.map((a) => ({
2084
2315
  id: a.id, specificity: a.specificity, confidence: a.confidence, capabilities: a.capabilities,
2316
+ // D-adapter-verification-basis: a DIFFERENT axis from confidence (schemas/adapter.
2317
+ // schema.json's own description has the full explanation) -- how well this adapter's
2318
+ // codegen was ever checked against real code, not how sure detect() is about this repo.
2319
+ verificationBasis: a.verificationBasis,
2085
2320
  // `detect()` itself can return null on a legitimate non-match -- coerce to a real
2086
2321
  // boolean here so `null` unambiguously means "not applicable, no root" below, not
2087
2322
  // "detect() happened to return a falsy value".
@@ -2113,7 +2348,7 @@ function cmdDoctor(args) {
2113
2348
  console.log('Scanner adapters:');
2114
2349
  for (const a of adapters) {
2115
2350
  const caps = Object.entries(a.capabilities).filter(([, v]) => v).map(([k]) => k).join(', ') || '(none)';
2116
- let line = ` ${a.id} (specificity ${a.specificity}, confidence ${a.confidence}) -- capabilities: ${caps}`;
2351
+ let line = ` ${a.id} (specificity ${a.specificity}, confidence ${a.confidence}, verified: ${a.verificationBasis}) -- capabilities: ${caps}`;
2117
2352
  if (a.detects !== null) line += a.detects ? ' -- DETECTS this repo' : ' -- does not detect this repo';
2118
2353
  console.log(line);
2119
2354
  for (const d of a.diagnostics) console.log(` [${d.level}] ${d.code}: ${d.message}`);
@@ -2364,6 +2599,7 @@ async function dispatchCommand(cmd, rest) {
2364
2599
  const subArgs = rest.slice(1);
2365
2600
  if (sub === 'emit') return cmdContractEmit(subArgs);
2366
2601
  if (sub === 'export') return cmdContractExport(subArgs);
2602
+ if (sub === 'history') return cmdContractHistory(subArgs);
2367
2603
  if (sub === 'validate') return cmdContractValidate(subArgs);
2368
2604
  if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
2369
2605
  if (sub === 'waive') return cmdContractWaive(subArgs);
@@ -2387,6 +2623,7 @@ async function dispatchCommand(cmd, rest) {
2387
2623
  if (rest[0] === 'plan') return cmdHandlesPlan(rest.slice(1));
2388
2624
  if (rest[0] === 'emit') return cmdHandlesEmit(rest.slice(1));
2389
2625
  if (rest[0] === 'patch' && rest[1] === 'approve') return cmdHandlesPatchApprove(rest.slice(2));
2626
+ if (rest[0] === 'audit') return await cmdHandlesAudit(rest.slice(1));
2390
2627
  usage();
2391
2628
  process.exit(14);
2392
2629
  break;
@@ -2408,6 +2645,7 @@ async function dispatchCommand(cmd, rest) {
2408
2645
  if (sub === 'revoke') return cmdGateRevoke(subArgs);
2409
2646
  if (sub === 'history') return cmdGateHistory(subArgs);
2410
2647
  if (sub === 'show') return cmdGateShow(subArgs);
2648
+ if (sub === 'export') return cmdGateExport(subArgs);
2411
2649
  usage();
2412
2650
  process.exit(14);
2413
2651
  break;
@@ -105,6 +105,15 @@ export const WARNING_CODES = Object.freeze({
105
105
  // this operation carries is unaffected, this is a missed (opt-in) enhancement, same severity
106
106
  // class as its A7/A8 siblings.
107
107
  CONTRACT_OPENAPI_DESCRIPTION_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
108
+ // D-unsupported-annotation-warning: the source document uses a schema keyword (title/plural
109
+ // examples/externalDocs/xml/deprecated) this whole module unconditionally drops -- 0 real
110
+ // occurrences were ever measured on the one oracle these caps/keyword sets were built against,
111
+ // but a genuinely different real document can still use any of them. Module-wide, not
112
+ // per-operation (subject is the keyword NAME, not an operationId) -- contracts/openapi.mjs's
113
+ // findUnsupportedAnnotations() computes this once per document. WARN: nothing this projection
114
+ // already copies is affected, this is purely a disclosure that something in the source is
115
+ // silently unrepresented.
116
+ CONTRACT_OPENAPI_UNSUPPORTED_ANNOTATION_PRESENT: { severity: SEVERITY.WARN, waivable: true },
108
117
  });
109
118
 
110
119
  export const WARNING_CODE_NAMES = Object.freeze(Object.keys(WARNING_CODES));
@@ -199,10 +208,20 @@ export function saveResolution(root, featureId, resolution) {
199
208
  // blocks, waived or not). Deliberately no wildcard match: a waiver only cancels the EXACT
200
209
  // code+subject pair recorded for it, so a new unmatched endpoint added later is never silently
201
210
  // covered by an old "--all" waive -- see the "waiver invalidation" test in test/contract-cli.test.mjs.
211
+ // D-waiver-expiry: `expires_at` is a genuinely different axis from `staleWaivers` below --
212
+ // staleness means "the warning this waiver covered no longer exists at all" (the underlying
213
+ // problem was fixed), expiry means "the waiver covered a warning that's STILL there, but the
214
+ // grace period the person who filed it granted has run out" -- an expired waiver stops covering
215
+ // its warning, so `unwaived`/`blocking` treat it exactly as if it had never been recorded. A
216
+ // waiver can be BOTH stale and expired at once (nothing prevents that combination); the two
217
+ // lists are independent, not mutually exclusive.
202
218
  export function evaluateResolution(contract, resolution) {
203
219
  const status = classifyContract(contract);
204
220
  const waivers = resolution.waivers ?? [];
205
- const waivedKeys = new Set(waivers.map(warningKey));
221
+ const now = Date.now();
222
+ const expiredWaivers = waivers.filter((w) => typeof w.expires_at === 'string' && Date.parse(w.expires_at) <= now);
223
+ const expiredKeys = new Set(expiredWaivers.map(warningKey));
224
+ const waivedKeys = new Set(waivers.filter((w) => !expiredKeys.has(warningKey(w))).map(warningKey));
206
225
 
207
226
  const errorWarnings = contract.warnings.filter((w) => w.severity === SEVERITY.ERROR);
208
227
  const unwaived = errorWarnings.filter((w) => !waivedKeys.has(warningKey(w)));
@@ -213,5 +232,5 @@ export function evaluateResolution(contract, resolution) {
213
232
 
214
233
  const blocking = status === COMPLETENESS.BLOCKED || unwaived.length > 0;
215
234
 
216
- return { status, blocking, unwaived, waived, staleWaivers };
235
+ return { status, blocking, unwaived, waived, staleWaivers, expiredWaivers };
217
236
  }
@@ -103,6 +103,17 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
103
103
  const warnings = [];
104
104
  let endpointCount = 0;
105
105
 
106
+ // D-unsupported-annotation-warning: module-wide, computed once, independent of targetModule/
107
+ // endpoint iteration below -- the source document either uses one of the 5 permanently-dropped
108
+ // schema keywords or it doesn't, a fact about the WHOLE document, not any one operation.
109
+ for (const keyword of openapi?.unsupportedAnnotations ?? []) {
110
+ warnings.push(makeWarning('CONTRACT_OPENAPI_UNSUPPORTED_ANNOTATION_PRESENT', {
111
+ subject: keyword,
112
+ message: `the source OpenAPI document uses the schema keyword "${keyword}" at least once -- this projection unconditionally drops it (0 real occurrences were measured against the reference document this behavior was built against, but this document has at least one), so it is never represented in any operation's projected schema`,
113
+ detail: { keyword },
114
+ }));
115
+ }
116
+
106
117
  if (!targetModule) {
107
118
  warnings.push(makeWarning('CONTRACT_NO_MODULE', {
108
119
  message: 'no related module in the scan report -- emitting an empty operation set. Pass --module, or re-run `bskel scan` with terms that actually match the intended feature.',
@@ -179,6 +179,97 @@ const COPIED_KEYWORDS = Object.freeze(new Set([
179
179
  // `response-headers`.
180
180
  const DOCUMENTATION_KEYWORDS = Object.freeze(new Set(['description', 'example']));
181
181
  const DROPPED_KEYWORDS = Object.freeze(new Set(['title', 'examples', 'externalDocs', 'xml', 'deprecated']));
182
+
183
+ // D-unsupported-annotation-warning: 0 real occurrences on the ONE oracle this whole module's
184
+ // caps/keyword sets were measured against does not mean 0 occurrences everywhere -- a genuinely
185
+ // different real-world document could use any of DROPPED_KEYWORDS, and silently dropping them
186
+ // with no signal at all is a real honesty gap (see D-contract-history/D-gate-export's own backlog
187
+ // for the broader "self-identified weaknesses" context this closes one instance of).
188
+ //
189
+ // Deliberately walks ONLY genuine Schema Object structure (via RECURSED_KEYWORDS, the exact same
190
+ // `properties`/`items`/`additionalProperties`/`oneOf`/`anyOf`/`allOf` set inlineSchema() itself
191
+ // recurses through) -- NOT a blanket "every key anywhere in the document" scan. That distinction
192
+ // is load-bearing, not cosmetic: several of DROPPED_KEYWORDS' names collide with REAL, unrelated
193
+ // OpenAPI concepts that live outside a Schema Object entirely -- an Operation Object's own
194
+ // `deprecated` (marks a whole ENDPOINT deprecated) and a Parameter Object's own `deprecated`, both
195
+ // legitimate 3.1 fields with nothing to do with inlineSchema()'s keyword handling. A blanket walk
196
+ // would misreport those as "an unsupported schema keyword found," which is false. This function
197
+ // only descends from confirmed schema roots (a `$ref`-or-inline `schema` under `content.<media>`
198
+ // or `parameters[].schema`, or a named entry in `components.schemas`), the same roots
199
+ // inlineSchema() itself is ever called on.
200
+ //
201
+ // NOT routed through walkSchemaNode()'s fail-closed machinery -- it must never throw on a shape
202
+ // inlineSchema() itself would reject, since its only job is presence detection, not validation.
203
+ // Bounded for free by loadOpenApiDocument()'s own MAX_DOCUMENT_BYTES check upstream.
204
+ function collectUnsupportedAnnotationKeys(node, found, seen) {
205
+ if (node === null || typeof node !== 'object' || Array.isArray(node) || seen.has(node)) return;
206
+ seen.add(node);
207
+ for (const key of Object.keys(node)) {
208
+ if (DROPPED_KEYWORDS.has(key)) found.add(key);
209
+ if (RECURSED_KEYWORDS.has(key)) {
210
+ const value = node[key];
211
+ // `properties` is a field-NAME -> schema map (its VALUES are schemas, its keys are not
212
+ // schema keywords at all) -- a real bug caught live before merge: recursing into the
213
+ // map object itself, instead of `Object.values(value)`, silently walked past every
214
+ // property's actual schema and found nothing beneath `properties` ever. `items`/
215
+ // `additionalProperties` ARE schemas directly; `oneOf`/`anyOf`/`allOf` are arrays of
216
+ // schemas, already handled by the branch below.
217
+ if (key === 'properties' && value && typeof value === 'object' && !Array.isArray(value)) {
218
+ for (const propSchema of Object.values(value)) collectUnsupportedAnnotationKeys(propSchema, found, seen);
219
+ } else if (Array.isArray(value)) {
220
+ for (const item of value) collectUnsupportedAnnotationKeys(item, found, seen);
221
+ } else {
222
+ collectUnsupportedAnnotationKeys(value, found, seen);
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ function collectSchemaRootsFromMediaTypes(content, roots) {
229
+ if (!content || typeof content !== 'object' || Array.isArray(content)) return;
230
+ for (const mediaEntry of Object.values(content)) {
231
+ const schema = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
232
+ if (schema && typeof schema === 'object' && !Array.isArray(schema)) roots.push(schema);
233
+ }
234
+ }
235
+
236
+ // Every real schema root a document can offer `inlineSchema()`: named `components.schemas`
237
+ // entries, every operation's requestBody/response content schemas, and every operation's
238
+ // parameter schemas -- deliberately NOT `info`/`servers`/`tags`/security schemes, none of which
239
+ // are ever schema-shaped.
240
+ export function findUnsupportedAnnotations(doc) {
241
+ const roots = [];
242
+ const schemas = doc.components?.schemas;
243
+ if (schemas && typeof schemas === 'object' && !Array.isArray(schemas)) roots.push(...Object.values(schemas));
244
+
245
+ const paths = doc.paths;
246
+ if (paths && typeof paths === 'object' && !Array.isArray(paths)) {
247
+ for (const item of Object.values(paths)) {
248
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
249
+ for (const [verbOrKey, operation] of Object.entries(item)) {
250
+ if (!HTTP_METHODS.has(verbOrKey) || !operation || typeof operation !== 'object' || Array.isArray(operation)) continue;
251
+ collectSchemaRootsFromMediaTypes(operation.requestBody?.content, roots);
252
+ if (operation.responses && typeof operation.responses === 'object' && !Array.isArray(operation.responses)) {
253
+ for (const resp of Object.values(operation.responses)) {
254
+ collectSchemaRootsFromMediaTypes(resp?.content, roots);
255
+ }
256
+ }
257
+ if (Array.isArray(operation.parameters)) {
258
+ for (const p of operation.parameters) {
259
+ if (p && typeof p === 'object' && !Array.isArray(p) && p.schema && typeof p.schema === 'object' && !Array.isArray(p.schema)) {
260
+ roots.push(p.schema);
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ const found = new Set();
269
+ const seen = new Set();
270
+ for (const root of roots) collectUnsupportedAnnotationKeys(root, found, seen);
271
+ return [...found].sort();
272
+ }
182
273
  // Real Team-IZ-Backend format-value histogram (request-body-reachable schemas only): uuid(20),
183
274
  // int32(10), email(7), date(10), date-time(3), int64(2). `uuid` is handled separately (rewritten
184
275
  // to BARE_UUID_PATTERN, see inlineSchema) -- not in this set, since it never survives as `format`.
@@ -1481,6 +1572,10 @@ export function buildReconciliation({ filePath, module, pathPrefix = null, inclu
1481
1572
  // A7: only schemes actually referenced by at least one copied sourceSecurity requirement --
1482
1573
  // contracts/emit.mjs attaches this at the contract root when non-empty.
1483
1574
  sourceSecuritySchemes: recon.sourceSecuritySchemes,
1575
+ // D-unsupported-annotation-warning: computed once per document, not per-operation -- a
1576
+ // module-wide presence signal, not a per-operation fact, so contracts/emit.mjs pushes at
1577
+ // most one warning per keyword name for the whole module, not one per operation.
1578
+ unsupportedAnnotations: findUnsupportedAnnotations(loaded.doc),
1484
1579
  };
1485
1580
  }
1486
1581