backend-skeleton 1.0.0-beta.2 → 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 (38) hide show
  1. package/README.md +12 -2
  2. package/bin/bskel.mjs +260 -11
  3. package/contracts/completeness.mjs +27 -2
  4. package/contracts/emit.mjs +68 -9
  5. package/contracts/export.mjs +94 -25
  6. package/contracts/openapi.mjs +307 -40
  7. package/handles/audit.mjs +83 -0
  8. package/handles/codec.mjs +11 -5
  9. package/handles/providers/java-spring/emit.mjs +9 -2
  10. package/handles/providers/java-spring/plan.mjs +20 -0
  11. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
  12. package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
  13. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
  14. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
  15. package/handles/providers/java-spring.mjs +2 -2
  16. package/handles/providers/python-fastapi/emit.mjs +5 -1
  17. package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
  18. package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
  19. package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
  20. package/lib/cli.mjs +51 -3
  21. package/lib/handles-manifest.mjs +10 -4
  22. package/lib/repo.mjs +56 -0
  23. package/package.json +1 -1
  24. package/scanners/adapters/_express-shared.mjs +17 -12
  25. package/scanners/adapters/generic-grep.mjs +5 -1
  26. package/scanners/adapters/java-spring.mjs +60 -14
  27. package/scanners/adapters/javascript-express.mjs +5 -1
  28. package/scanners/adapters/python-fastapi.mjs +17 -14
  29. package/scanners/adapters/typescript-express.mjs +6 -1
  30. package/scanners/registry.mjs +5 -2
  31. package/scanners/text-util.mjs +25 -0
  32. package/schemas/adapter.schema.json +6 -2
  33. package/schemas/contract-resolution.schema.json +6 -1
  34. package/schemas/feature-contract.schema.json +10 -1
  35. package/schemas/handles-plan.schema.json +1 -0
  36. package/stack/bootstrap/db-up.sh +52 -0
  37. package/stack/bootstrap/docker-compose.postgres.yml +18 -0
  38. 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
@@ -133,8 +138,13 @@ operation, copied byte-for-byte, never reconstructed. `security: []` is emitted
133
138
  document itself said `[]` (a genuine claim that no authentication is required); it is never
134
139
  invented as a default. Where no source document was given, or it said nothing for an operation
135
140
  (or a particular field of one), the key is simply omitted, meaning "unspecified." Operation-level
136
- `description` remains excluded measured too expensive to copy by default (real average 2,442.7
137
- bytes/operation).
141
+ `description` is copied too, but **opt-in only** (`contract emit --descriptions`) measured too
142
+ expensive to copy by default (real average 2,442.7 bytes/operation, larger than every other field
143
+ this projection copies combined). The same flag also copies a schema FIELD's own `description`/
144
+ `example` (a property's own annotation, not the operation's) one level deeper into request-body/
145
+ response/error/parameter/per-status/path-param schemas — `title`, plural `examples`,
146
+ `externalDocs`, `xml`, and `deprecated` stay unconditionally dropped either way (0 real occurrences
147
+ measured against the Team-IZ-Backend oracle).
138
148
 
139
149
  Export refuses a zero-operation contract, refuses when the scan found a global path prefix the
140
150
  contract's paths don't reflect (`--allow-unprefixed` overrides), and stamps every document with an
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,
@@ -65,16 +67,18 @@ function usage() {
65
67
  bskel feature rename <id> --to <new-slug> --reason "..." [--json]
66
68
  bskel feature link <keepId> <aliasId> --reason "..." [--json]
67
69
  bskel feature archive <id> --reason "..." [--json]
68
- bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0]
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
@@ -844,6 +891,11 @@ function cmdContractEmit(args) {
844
891
  if (flags['path-prefix'] && !flags['openapi-file']) {
845
892
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--path-prefix only applies when reconciling against a real OpenAPI document -- pass --openapi-file <path> together with it, or drop --path-prefix (the value has no effect on its own).`);
846
893
  }
894
+ // A10: same "would be a silent no-op" reasoning as --path-prefix above -- --descriptions only has
895
+ // any effect inside buildReconciliation(), which only runs when --openapi-file is also given.
896
+ if (flags.descriptions && !flags['openapi-file']) {
897
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--descriptions only applies when reconciling against a real OpenAPI document -- pass --openapi-file <path> together with it, or drop --descriptions (it has no effect on its own).`);
898
+ }
847
899
 
848
900
  const root = requireRepoRoot();
849
901
  requirePreflightPassed(root);
@@ -878,7 +930,7 @@ function cmdContractEmit(args) {
878
930
  if (flags['openapi-file']) {
879
931
  const targetModule = selectModule(scanReport, flags.module);
880
932
  if (targetModule) {
881
- const result = buildReconciliation({ filePath: flags['openapi-file'], module: targetModule, pathPrefix: flags['path-prefix'] });
933
+ const result = buildReconciliation({ filePath: flags['openapi-file'], module: targetModule, pathPrefix: flags['path-prefix'], includeDescriptions: flags.descriptions });
882
934
  if (!result.ok) {
883
935
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', result.error);
884
936
  }
@@ -937,6 +989,7 @@ function cmdContractEmit(args) {
937
989
  warning_codes: countByCode(contract.warnings),
938
990
  waived_count: evaluation.waived.length,
939
991
  stale_waivers: evaluation.staleWaivers.length,
992
+ expired_waivers: evaluation.expiredWaivers.length,
940
993
  openapi: reconciliation
941
994
  ? {
942
995
  applied: true,
@@ -978,10 +1031,20 @@ function cmdContractEmit(args) {
978
1031
  console.log(`openapi: ${p.parameters_copied} operation(s) with parameters copied (${p.parameters_unresolved} partial/unresolved), ${p.security_copied + p.security_public} with security copied, ${p.summary_copied} summaries + ${p.tags_copied} tag sets copied`);
979
1032
  // A8: same "just print the numbers" style.
980
1033
  console.log(`openapi: ${p.per_status_copied} operation(s) with per-status responses copied, ${p.request_media_types_copied} with non-JSON request media type(s) copied`);
1034
+ // A10: printed only when --descriptions was actually passed -- unlike A7/A8/A9's
1035
+ // default-on fields, printing "0 copied" unconditionally here would misleadingly
1036
+ // suggest this opt-in field was attempted when it never was.
1037
+ if (flags.descriptions) {
1038
+ console.log(`openapi: ${p.description_copied} operation(s) with description copied, ${p.description_unresolved} unresolved`);
1039
+ }
981
1040
  }
982
1041
  }
983
1042
  for (const w of contract.warnings) console.error(`warning[${w.severity}] ${w.code}${w.subject ? ` (${w.subject})` : ''}: ${w.message}`);
984
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
+ }
985
1048
  if (evaluation.staleWaivers.length > 0) {
986
1049
  console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current warning (kept as-is, not auto-removed):`);
987
1050
  for (const w of evaluation.staleWaivers) console.error(` ${w.code} (${w.subject ?? '*'})`);
@@ -1186,12 +1249,26 @@ function cmdContractExport(args) {
1186
1249
  // waiver: `--all` expands to the SPECIFIC code+subject pairs present right now, recorded as
1187
1250
  // individual entries -- a warning that doesn't exist yet (e.g. a new unannotated endpoint added
1188
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
+
1189
1266
  function cmdContractWaive(args) {
1190
1267
  const flags = parseCommand('contract waive', args);
1191
1268
  if (flags.help) { console.log(renderCommandHelp('contract waive')); process.exit(0); }
1192
1269
  setContext('contract waive', flags);
1193
1270
  const root = requireRepoRoot();
1194
- 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>]';
1195
1272
  try {
1196
1273
  requireWarningCode(flags.code);
1197
1274
  } catch (err) {
@@ -1203,6 +1280,7 @@ function cmdContractWaive(args) {
1203
1280
  if (!flags.subject && !flags.all) {
1204
1281
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', usageText);
1205
1282
  }
1283
+ const expiresAt = parseExpiresFlag(flags.expires);
1206
1284
 
1207
1285
  const contract = loadContract(root, flags.feature);
1208
1286
  if (contract.completeness.status === 'blocked') {
@@ -1236,7 +1314,7 @@ function cmdContractWaive(args) {
1236
1314
  const at = new Date().toISOString();
1237
1315
  const entries = toWaive
1238
1316
  .filter((w) => !existingKeys.has(warningKey(w)))
1239
- .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 } : {}) }));
1240
1318
  const next = {
1241
1319
  schema: 'sbf.contract-resolution/1',
1242
1320
  feature_id: flags.feature,
@@ -1254,6 +1332,7 @@ function cmdContractWaive(args) {
1254
1332
  warning_codes: countByCode(contract.warnings),
1255
1333
  waived_count: evaluation.waived.length,
1256
1334
  stale_waivers: evaluation.staleWaivers.length,
1335
+ expired_waivers: evaluation.expiredWaivers.length,
1257
1336
  };
1258
1337
  const gateState = evaluation.blocking
1259
1338
  ? awaitNamedGateDisposition(root, 'contract', flags.feature, { ...evidence, unwaived: evaluation.unwaived.map(({ code, subject }) => ({ code, subject })) })
@@ -1263,9 +1342,13 @@ function cmdContractWaive(args) {
1263
1342
  console.log(JSON.stringify({ waived: newEntries, gate: gateState.gates.contract }, null, 2));
1264
1343
  } else {
1265
1344
  if (!flags.quiet) {
1266
- 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}` : ''}`);
1267
1346
  console.log(`gate: contract -> ${gateState.gates.contract.status}`);
1268
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
+ }
1269
1352
  if (evaluation.blocking) {
1270
1353
  console.error(`\nstill blocked: ${evaluation.unwaived.length} unresolved warning(s) remain:`);
1271
1354
  for (const w of evaluation.unwaived) console.error(` ${w.code} (${w.subject})`);
@@ -1274,6 +1357,74 @@ function cmdContractWaive(args) {
1274
1357
  process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
1275
1358
  }
1276
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
+
1277
1428
  function cmdContractValidate(args) {
1278
1429
  const flags = parseCommand('contract validate', args);
1279
1430
  if (flags.help) { console.log(renderCommandHelp('contract validate')); process.exit(0); }
@@ -1561,7 +1712,8 @@ function renderHandlesPlan(plan, actions) {
1561
1712
  lines.push(`## ${r.type}${r.willGenerateResolver ? '' : ' (resolver will NOT be generated -- see notes)'}`);
1562
1713
  lines.push(`- table: ${r.table ?? '(unknown)'}, PK field: ${r.idField ?? '(unknown)'}`);
1563
1714
  lines.push(`- read via: ${r.readPath ?? '(not found)'}`);
1564
- lines.push(`- requiredAuthority: ${r.requiredAuthority}`);
1715
+ lines.push(`- requiredAuthority (fetch/recover): ${r.requiredAuthority}`);
1716
+ if (r.requiredAuthorityForPatch !== undefined) lines.push(`- requiredAuthorityForPatch: ${r.requiredAuthorityForPatch}`);
1565
1717
  lines.push('');
1566
1718
  }
1567
1719
  if (plan.notes.length > 0) {
@@ -1722,6 +1874,20 @@ function cmdHandlesEmit(args) {
1722
1874
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles emit --force requires --reason "..." -- every overwrite of diverged generated code must be auditable');
1723
1875
  }
1724
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
+
1725
1891
  // Handles are only emitted for a feature whose contract has actually been established --
1726
1892
  // codegen against a feature nobody has scanned/contracted yet has nothing real to route to.
1727
1893
  const contractResult = requireNamedGate(root, 'contract', flags.feature);
@@ -1755,8 +1921,21 @@ function cmdHandlesEmit(args) {
1755
1921
  // does, without requiring both flags together.
1756
1922
  const dryRun = flags.check || flags.diff;
1757
1923
  const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = provider.emit({
1758
- 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,
1759
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
+ }
1760
1939
  // D4: found live while grounding this against a real fixture -- `written` (pre-existing field,
1761
1940
  // unchanged semantics) unconditionally includes a java-spring `outputs.spec` file like
1762
1941
  // migration.sql even when its content is byte-identical (P4 already found this: it's never
@@ -1893,6 +2072,69 @@ function cmdHandlesPatchApprove(args) {
1893
2072
  process.exit(0);
1894
2073
  }
1895
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
+
1896
2138
  // S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
1897
2139
  // input that actually moved, using the exact reason requireGate()'s explainStaleness() reports.
1898
2140
  function describeStale(g) {
@@ -2071,6 +2313,10 @@ function cmdDoctor(args) {
2071
2313
  const adapters = showAdapters
2072
2314
  ? ADAPTERS.map((a) => ({
2073
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,
2074
2320
  // `detect()` itself can return null on a legitimate non-match -- coerce to a real
2075
2321
  // boolean here so `null` unambiguously means "not applicable, no root" below, not
2076
2322
  // "detect() happened to return a falsy value".
@@ -2102,7 +2348,7 @@ function cmdDoctor(args) {
2102
2348
  console.log('Scanner adapters:');
2103
2349
  for (const a of adapters) {
2104
2350
  const caps = Object.entries(a.capabilities).filter(([, v]) => v).map(([k]) => k).join(', ') || '(none)';
2105
- 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}`;
2106
2352
  if (a.detects !== null) line += a.detects ? ' -- DETECTS this repo' : ' -- does not detect this repo';
2107
2353
  console.log(line);
2108
2354
  for (const d of a.diagnostics) console.log(` [${d.level}] ${d.code}: ${d.message}`);
@@ -2353,6 +2599,7 @@ async function dispatchCommand(cmd, rest) {
2353
2599
  const subArgs = rest.slice(1);
2354
2600
  if (sub === 'emit') return cmdContractEmit(subArgs);
2355
2601
  if (sub === 'export') return cmdContractExport(subArgs);
2602
+ if (sub === 'history') return cmdContractHistory(subArgs);
2356
2603
  if (sub === 'validate') return cmdContractValidate(subArgs);
2357
2604
  if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
2358
2605
  if (sub === 'waive') return cmdContractWaive(subArgs);
@@ -2376,6 +2623,7 @@ async function dispatchCommand(cmd, rest) {
2376
2623
  if (rest[0] === 'plan') return cmdHandlesPlan(rest.slice(1));
2377
2624
  if (rest[0] === 'emit') return cmdHandlesEmit(rest.slice(1));
2378
2625
  if (rest[0] === 'patch' && rest[1] === 'approve') return cmdHandlesPatchApprove(rest.slice(2));
2626
+ if (rest[0] === 'audit') return await cmdHandlesAudit(rest.slice(1));
2379
2627
  usage();
2380
2628
  process.exit(14);
2381
2629
  break;
@@ -2397,6 +2645,7 @@ async function dispatchCommand(cmd, rest) {
2397
2645
  if (sub === 'revoke') return cmdGateRevoke(subArgs);
2398
2646
  if (sub === 'history') return cmdGateHistory(subArgs);
2399
2647
  if (sub === 'show') return cmdGateShow(subArgs);
2648
+ if (sub === 'export') return cmdGateExport(subArgs);
2400
2649
  usage();
2401
2650
  process.exit(14);
2402
2651
  break;
@@ -99,6 +99,21 @@ export const WARNING_CODES = Object.freeze({
99
99
  // it reuses CONTRACT_OPENAPI_RESPONSE_SCHEMA_UNRESOLVED/CONTRACT_OPENAPI_ERROR_SCHEMA_UNRESOLVED
100
100
  // unchanged -- see D-openapi-per-status.
101
101
  CONTRACT_OPENAPI_REQUEST_MEDIA_TYPE_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
102
+ // A10: the operation's description exceeded MAX_DESCRIPTION_LENGTH -- independent of every code
103
+ // above (nothing else tracks description length), so it gets its own code rather than reusing
104
+ // one, same reasoning A8 used to justify its own new multipart code. WARN: every other field
105
+ // this operation carries is unaffected, this is a missed (opt-in) enhancement, same severity
106
+ // class as its A7/A8 siblings.
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 },
102
117
  });
103
118
 
104
119
  export const WARNING_CODE_NAMES = Object.freeze(Object.keys(WARNING_CODES));
@@ -193,10 +208,20 @@ export function saveResolution(root, featureId, resolution) {
193
208
  // blocks, waived or not). Deliberately no wildcard match: a waiver only cancels the EXACT
194
209
  // code+subject pair recorded for it, so a new unmatched endpoint added later is never silently
195
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.
196
218
  export function evaluateResolution(contract, resolution) {
197
219
  const status = classifyContract(contract);
198
220
  const waivers = resolution.waivers ?? [];
199
- 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));
200
225
 
201
226
  const errorWarnings = contract.warnings.filter((w) => w.severity === SEVERITY.ERROR);
202
227
  const unwaived = errorWarnings.filter((w) => !waivedKeys.has(warningKey(w)));
@@ -207,5 +232,5 @@ export function evaluateResolution(contract, resolution) {
207
232
 
208
233
  const blocking = status === COMPLETENESS.BLOCKED || unwaived.length > 0;
209
234
 
210
- return { status, blocking, unwaived, waived, staleWaivers };
235
+ return { status, blocking, unwaived, waived, staleWaivers, expiredWaivers };
211
236
  }