backend-skeleton 1.4.0 → 1.6.0

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 (42) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +331 -53
  3. package/contracts/emit.mjs +22 -6
  4. package/contracts/openapi.mjs +125 -18
  5. package/handles/providers/java-spring/ast-bridge.mjs +85 -1
  6. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
  7. package/handles/providers/java-spring/emit.mjs +126 -6
  8. package/handles/providers/java-spring/plan.mjs +244 -77
  9. package/handles/providers/java-spring/source-splice.mjs +477 -0
  10. package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
  11. package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
  12. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
  13. package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
  14. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
  15. package/handles/providers/typescript-express/plan.mjs +15 -2
  16. package/lib/attest.mjs +59 -1
  17. package/lib/cli.mjs +79 -7
  18. package/lib/doctor.mjs +23 -0
  19. package/lib/exit-codes.mjs +17 -0
  20. package/lib/gate-definitions.mjs +65 -2
  21. package/lib/gate-export.mjs +199 -0
  22. package/lib/impact-export-graphify.mjs +145 -0
  23. package/lib/impact-graph.mjs +194 -0
  24. package/lib/impact-surface.mjs +158 -0
  25. package/lib/impact.mjs +286 -0
  26. package/lib/patch-kinds.mjs +24 -0
  27. package/lib/repo.mjs +46 -0
  28. package/lib/workflow.mjs +16 -0
  29. package/package.json +1 -1
  30. package/scanners/adapters/_java-spring-analyzer.mjs +55 -0
  31. package/scanners/adapters/java-spring.mjs +154 -3
  32. package/scanners/index.mjs +10 -0
  33. package/schemas/feature-contract.schema.json +12 -1
  34. package/schemas/gate-attestation.schema.json +6 -1
  35. package/schemas/gate-export.schema.json +530 -22
  36. package/schemas/handles-plan.schema.json +32 -0
  37. package/schemas/impact-baseline.schema.json +59 -0
  38. package/schemas/impact-graph.schema.json +53 -0
  39. package/schemas/impact-report.schema.json +86 -0
  40. package/schemas/impact-resolution.schema.json +33 -0
  41. package/schemas/java-source-splice.schema.json +84 -0
  42. package/schemas/patch-transaction.schema.json +87 -2
package/bin/bskel.mjs CHANGED
@@ -9,6 +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 { buildGateExportReport, readGateHistory } from '../lib/gate-export.mjs';
12
13
  import { writeFileAtomic, sha256File, readJsonIfExists } from '../lib/fsutil.mjs';
13
14
  import { hydrateScanReportFilePaths, dehydrateScanReportFilePaths } from '../lib/scan-report-paths.mjs';
14
15
  import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
@@ -33,13 +34,18 @@ import { evaluateResolution, loadResolution, saveResolution, requireWarningCode,
33
34
  import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
34
35
  import { proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions } from '../lib/patch-transactions.mjs';
35
36
  import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from '../lib/patch-kinds.mjs';
36
- import { generateKeypair, signPayload, verifyPayload } from '../lib/attest.mjs';
37
+ import { generateKeypair, signPayload, verifyPayload, publicKeyIdFromPrivate, publicKeyIdFromPublic } from '../lib/attest.mjs';
37
38
  import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
38
39
  import { createHttpServer } from '../lib/http-server.mjs';
39
40
  import {
40
41
  resolveClassFile, listDownstreamDependents, DependencyOperationError,
41
42
  declareDependency, removeDependency, buildDependencyListReport,
42
43
  } from '../lib/field-dependencies.mjs';
44
+ import {
45
+ ImpactOperationError, checkImpact, acceptImpact, recordDisposition, acknowledgeInbound,
46
+ } from '../lib/impact.mjs';
47
+ import { buildImpactGraph } from '../lib/impact-graph.mjs';
48
+ import { toGraphifyExtraction, toMermaid } from '../lib/impact-export-graphify.mjs';
43
49
  import {
44
50
  findCollisions, evaluateCrossFeatureFindings, waiverKey,
45
51
  crossFeatureReportPath, loadCrossFeatureReport, loadCrossFeatureResolution, saveCrossFeatureResolution,
@@ -114,6 +120,11 @@ function usage() {
114
120
  bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
115
121
  bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
116
122
  bskel dependency list --feature <id> [--json]
123
+ bskel impact check --feature <id> [--all] [--json]
124
+ bskel impact accept --feature <id> [--json]
125
+ bskel impact disposition --feature <id> --change <change_key> --downstream <id> --mode <compatible|migrate|waive> --reason "..." [--tracked-by "..."] [--expires-days <N>] [--json]
126
+ bskel impact ack --feature <id> --from <id> --change <change_key> --reason "..." [--json]
127
+ bskel impact export --format <graphify|json|mermaid> [--out <path>] [--focus <id>] [--rings <N>]
117
128
  bskel rules check --feature <id> [--init] [--json]
118
129
  bskel rules list --feature <id> [--json]
119
130
  bskel rules explain --feature <id> --rule <id> [--json]
@@ -124,7 +135,7 @@ function usage() {
124
135
  bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
125
136
  bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
126
137
  bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]
127
- bskel patch propose --feature <id> [--kind config-apply|ddl-apply] --choice <stackChoiceId> --target <config_check target path> --database-url-env <NAME> --schema <name> --sql-file <path> [--json]
138
+ bskel patch propose --feature <id> [--kind config-apply|ddl-apply|java-source-splice] --choice <stackChoiceId> --target <config_check target path> --database-url-env <NAME> --schema <name> --sql-file <path> --splice-file <path> [--json]
128
139
  bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
129
140
  bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
130
141
  bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
@@ -139,9 +150,9 @@ function usage() {
139
150
  bskel gate revoke <name> --reason "..." [--feature <id>]
140
151
  bskel gate history <name> [--feature <id>] [--json]
141
152
  bskel gate show [<name>] [--feature <id>]
142
- bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]
153
+ bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath> [--allow-dirty]] [--json]
143
154
  bskel attest keygen --out <dir> [--force] [--json]
144
- bskel attest verify --file <path> --pubkey <path> [--json]
155
+ bskel attest verify --file <path> --pubkey <path> [--expect-head <sha>] [--max-age-minutes N] [--reject-dirty] [--json]
145
156
  bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
146
157
  bskel serve [--port N] [--host <addr>] [--database-url-env <NAME>] [--schema <name>] [--sign-key <path>] [--require-sign-key] [--json]
147
158
  `);
@@ -352,32 +363,6 @@ function cmdGateRevoke(args) {
352
363
  process.exit(EXIT_CODES.NOT_PASSED);
353
364
  }
354
365
 
355
- // S4 (D-gate-history): reads the append-only .sbf/<feature>.history.jsonl -- a corrupt/invalid
356
- // line is skipped with a warning, not a hard failure, matching JSONL's own resilience rationale
357
- // (see lib/state.mjs's appendGateEvent).
358
- function readGateHistory(root, featureId, gateName) {
359
- const file = historyPath(root, featureId);
360
- if (!fs.existsSync(file)) return [];
361
- const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
362
- const events = [];
363
- for (const [i, line] of lines.entries()) {
364
- let parsed;
365
- try {
366
- parsed = JSON.parse(line);
367
- } catch {
368
- console.error(`warning: ${file}:${i + 1}: not valid JSON, skipped`);
369
- continue;
370
- }
371
- const { ok, errors } = validateAgainstSchema('gate-event.schema.json', parsed);
372
- if (!ok) {
373
- console.error(`warning: ${file}:${i + 1}: does not match schemas/gate-event.schema.json, skipped:\n${formatSchemaErrors(errors).join('\n')}`);
374
- continue;
375
- }
376
- if (parsed.gate === gateName) events.push(parsed);
377
- }
378
- return events;
379
- }
380
-
381
366
  function cmdGateHistory(args) {
382
367
  const flags = parseCommand('gate history', args);
383
368
  if (flags.help) { console.log(renderCommandHelp('gate history')); process.exit(0); }
@@ -386,7 +371,11 @@ function cmdGateHistory(args) {
386
371
  const gateName = flags._[0];
387
372
  if (!gateName) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel gate history <name> [--feature <id>] [--json]');
388
373
  resolveGateArg(gateName, flags.feature);
389
- const events = readGateHistory(root, flags.feature, gateName);
374
+ // S4 (D-gate-history): a corrupt/invalid history line is skipped with a warning, not a hard
375
+ // failure, matching JSONL's own resilience rationale (see lib/state.mjs's appendGateEvent).
376
+ const events = readGateHistory(root, flags.feature, gateName, {
377
+ onWarning: (msg, errors) => console.error(`warning: ${msg}${errors ? `:\n${formatSchemaErrors(errors).join('\n')}` : ''}`),
378
+ });
390
379
  if (flags.json) {
391
380
  console.log(JSON.stringify(events, null, 2));
392
381
  } else if (events.length === 0) {
@@ -439,20 +428,15 @@ function cmdGateExport(args) {
439
428
  if (flags.key && !flags.sign) {
440
429
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--key only has an effect together with --sign');
441
430
  }
442
-
443
- const gates = {};
444
- for (const name of GATE_NAMES) {
445
- const scopeId = gateScopeId(name, flags.feature);
446
- gates[name] = { scope: scopeId, current: getGate(root, scopeId, name), history: readGateHistory(root, scopeId, name) };
431
+ // D-attestation-payload-completeness (K4): --allow-dirty only means something alongside --sign
432
+ // -- an unsigned export never refuses a dirty tree (unchanged behavior), so a bare --allow-dirty
433
+ // would otherwise be a silently-ignored flag, the exact failure mode D-cli-contract already
434
+ // refuses everywhere else.
435
+ if (flags['allow-dirty'] && !flags.sign) {
436
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--allow-dirty only has an effect together with --sign -- an unsigned gate export never refuses a dirty tree');
447
437
  }
448
438
 
449
- const report = {
450
- schema: 'sbf.gate-export/1',
451
- feature_id: flags.feature,
452
- generated_at: new Date().toISOString(),
453
- git: { branch: currentBranch(root), head_sha: headSha(root), dirty: isDirty(root) },
454
- gates,
455
- };
439
+ const report = buildGateExportReport(root, flags.feature, { dirtyAcknowledged: Boolean(flags['allow-dirty']) });
456
440
  // D-gate-attestation-signing: validated unconditionally, signed or not -- a document that can
457
441
  // be exported unsigned should be exactly as trustworthy in shape as one that gets signed later.
458
442
  {
@@ -464,6 +448,20 @@ function cmdGateExport(args) {
464
448
 
465
449
  let payload = report;
466
450
  if (flags.sign) {
451
+ // D-attestation-payload-completeness (K4): refuses to sign over a dirty working tree unless
452
+ // explicitly acknowledged -- direct reuse of scripts/preflight-base-ref.sh's own already-
453
+ // shipped --allow-dirty convention (same flag name, same DIRTY exit code, same reasoning: "so
454
+ // evidence can honestly distinguish 'clean tree, passed' from 'dirty tree, --allow-dirty
455
+ // overrode it'"). Signing is a strictly stronger claim than preflighting, so it cannot be
456
+ // laxer. `report.git.dirty === true` is checked specifically, not truthiness -- isDirty()
457
+ // (and therefore this field) can be `null` when git itself failed, and "we could not
458
+ // determine dirtiness" must not silently pass this refusal.
459
+ if (report.git.dirty === null) {
460
+ fail(EXIT_CODES.DIRTY, 'DIRTY', 'refusing to sign an attestation: could not determine whether the working tree is dirty (git status failed) -- fix the git error, or pass --allow-dirty to sign anyway.');
461
+ }
462
+ if (report.git.dirty === true && !flags['allow-dirty']) {
463
+ fail(EXIT_CODES.DIRTY, 'DIRTY', `refusing to sign an attestation over a dirty working tree (${report.git.dirty_file_count} uncommitted change(s)) -- commit/stash them, or pass --allow-dirty to sign anyway (the acknowledgement is recorded inside the signed payload). The same refusal \`bskel preflight\` already makes.`);
464
+ }
467
465
  let privateKeyPem;
468
466
  try {
469
467
  privateKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.key), 'utf8');
@@ -471,12 +469,14 @@ function cmdGateExport(args) {
471
469
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --key "${flags.key}": ${err.message}`);
472
470
  }
473
471
  let signatureValue;
472
+ let keyId;
474
473
  try {
475
474
  signatureValue = signPayload(report, privateKeyPem);
475
+ keyId = publicKeyIdFromPrivate(privateKeyPem);
476
476
  } catch (err) {
477
477
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--key "${flags.key}" is not a usable Ed25519 private key: ${err.message}`);
478
478
  }
479
- const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue } };
479
+ const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue, key_id: keyId } };
480
480
  const { ok, errors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
481
481
  if (!ok) {
482
482
  fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate attestation failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
@@ -489,9 +489,11 @@ function cmdGateExport(args) {
489
489
  const outPath = path.resolve(process.cwd(), flags.out);
490
490
  writeFileAtomic(outPath, rendered);
491
491
  if (!flags.quiet) {
492
- const passCount = GATE_NAMES.filter((n) => gates[n].current?.status === 'pass').length;
492
+ const passCount = report.verdict.passing;
493
493
  const signedNote = flags.sign ? ' (signed)' : '';
494
- console.log(`wrote ${flags.out}${signedNote} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${report.git.dirty ? ' (dirty)' : ''}`);
494
+ const dirtyNote = report.git.dirty ? (report.git.dirty_acknowledged ? ', dirty (acknowledged)' : ', dirty') : '';
495
+ const blockingNote = report.verdict.blocking_gates.length > 0 ? ` -- blocking: ${report.verdict.blocking_gates.join(', ')}` : '';
496
+ console.log(`wrote ${flags.out}${signedNote} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${dirtyNote}${blockingNote}`);
495
497
  }
496
498
  } else {
497
499
  console.log(rendered);
@@ -562,13 +564,64 @@ function cmdAttestVerify(args) {
562
564
  const valid = verifyPayload(attestation.report, attestation.signature.value, publicKeyPem);
563
565
  const passCount = GATE_NAMES.filter((n) => attestation.report.gates[n]?.current?.status === 'pass').length;
564
566
 
567
+ // D-attestation-payload-completeness (K5): three OPT-IN, default-off assertions, evaluated
568
+ // purely against fields already inside the report (so verification stays fully offline and
569
+ // repo-independent). They exist for both sbf.gate-export/1 and /2 reports -- head_sha/
570
+ // generated_at/dirty are unchanged fields from /1, nothing here needed a /2-only field.
571
+ const assertions = [];
572
+ if (flags['expect-head']) {
573
+ const actual = attestation.report.git?.head_sha ?? null;
574
+ const ok = actual === flags['expect-head'];
575
+ assertions.push({ name: 'expect-head', ok, detail: ok ? `head_sha matches ${flags['expect-head']}` : `report's head_sha is "${actual}", expected "${flags['expect-head']}"` });
576
+ }
577
+ const maxAgeMinutes = flags['max-age-minutes'] != null ? Number(flags['max-age-minutes']) : 0;
578
+ if (maxAgeMinutes > 0) {
579
+ const generatedAtMs = Date.parse(attestation.report.generated_at);
580
+ const ageSeconds = Number.isFinite(generatedAtMs) ? Math.max(0, Math.round((Date.now() - generatedAtMs) / 1000)) : null;
581
+ const ok = ageSeconds !== null && ageSeconds <= maxAgeMinutes * 60;
582
+ assertions.push({ name: 'max-age-minutes', ok, detail: ageSeconds === null ? `report's generated_at ("${attestation.report.generated_at}") is not a parseable timestamp` : `age ${ageSeconds}s, limit ${maxAgeMinutes * 60}s` });
583
+ }
584
+ if (flags['reject-dirty']) {
585
+ const dirty = attestation.report.git?.dirty ?? null;
586
+ const ok = dirty !== true;
587
+ assertions.push({ name: 'reject-dirty', ok, detail: ok ? 'report is not dirty' : 'report.git.dirty === true' });
588
+ }
589
+ const assertionsOk = assertions.every((a) => a.ok);
590
+
591
+ // D-attestation-payload-completeness (K6): key_id is a SELECTION HINT only -- it sits OUTSIDE
592
+ // the signed bytes (only `report` is signed), so it cannot change whether `valid` is true. It
593
+ // exists purely to make a wrong---pubkey mistake legible instead of an unexplained INVALID.
594
+ let keyIdNote = null;
595
+ if (attestation.signature.key_id) {
596
+ try {
597
+ const actualKeyId = publicKeyIdFromPublic(publicKeyPem);
598
+ if (attestation.signature.key_id !== actualKeyId) {
599
+ keyIdNote = `this attestation declares key_id ${attestation.signature.key_id}, but --pubkey is ${actualKeyId} -- you may have the wrong public key`;
600
+ }
601
+ } catch {
602
+ // A --pubkey that isn't even a parseable key already drives verifyPayload() to false
603
+ // above; nothing more useful to say here.
604
+ }
605
+ }
606
+ const legacyReport = attestation.report.schema === 'sbf.gate-export/1';
607
+
565
608
  if (flags.json) {
566
- console.log(JSON.stringify({ valid, report_summary: { feature_id: attestation.report.feature_id, generated_at: attestation.report.generated_at, gates_passing: `${passCount}/${GATE_NAMES.length}` } }, null, 2));
609
+ console.log(JSON.stringify({
610
+ valid,
611
+ report_format: attestation.report.schema ?? 'sbf.gate-export/1',
612
+ report_summary: { feature_id: attestation.report.feature_id, generated_at: attestation.report.generated_at, gates_passing: `${passCount}/${GATE_NAMES.length}` },
613
+ assertions,
614
+ key_id_note: keyIdNote,
615
+ }, null, 2));
567
616
  } else if (!flags.quiet) {
568
617
  console.log(valid ? 'VALID: this attestation was genuinely signed by the holder of the matching private key' : 'INVALID: signature does not match this report + public key');
618
+ if (legacyReport) console.log('report format: sbf.gate-export/1 (pre-D-attestation-payload-completeness -- no tool version, no live gate verdict)');
619
+ if (keyIdNote) console.log(`note: ${keyIdNote}`);
569
620
  console.log(`report says: feature ${attestation.report.feature_id}, ${passCount}/${GATE_NAMES.length} gate(s) passing, generated ${attestation.report.generated_at}`);
621
+ for (const a of assertions) console.log(`assertion ${a.name}: ${a.ok ? 'ok' : 'FAILED'} -- ${a.detail}`);
570
622
  }
571
- process.exit(valid ? EXIT_CODES.OK : EXIT_CODES.CHECK_FAILED);
623
+ if (!valid) process.exit(EXIT_CODES.CHECK_FAILED);
624
+ process.exit(assertionsOk ? EXIT_CODES.OK : EXIT_CODES.ATTESTATION_ASSERTION_FAILED);
572
625
  }
573
626
 
574
627
  // Structural enforcement of "preflight blocks everything below it" (see the workflow table in
@@ -2026,6 +2079,143 @@ function cmdDependencyList(args) {
2026
2079
  process.exit(0);
2027
2080
  }
2028
2081
 
2082
+ // D-cross-feature-impact-graph: `bskel impact check` -- read-mostly, never advances the baseline.
2083
+ // `--all` sweeps every active feature; this is the only thing that closes the `impact` gate's own
2084
+ // counterparty-narrowing limitation (a brand-new downstream feature is otherwise only caught at the
2085
+ // next explicit check, see D4/D7 in DECISIONS.md) -- the recommended CI invocation.
2086
+ function cmdImpactCheck(args) {
2087
+ const flags = parseCommand('impact check', args);
2088
+ if (flags.help) { console.log(renderCommandHelp('impact check')); process.exit(0); }
2089
+ setContext('impact check', flags);
2090
+ const root = requireRepoRoot();
2091
+ if (!flags.all && !flags.feature) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel impact check requires --feature <id> or --all');
2092
+ const featureIds = flags.all ? listFeatures(root).map((r) => r.feature_id) : [flags.feature];
2093
+
2094
+ const results = [];
2095
+ for (const featureId of featureIds) {
2096
+ let outcome;
2097
+ try {
2098
+ outcome = checkImpact(root, featureId);
2099
+ } catch (err) {
2100
+ if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
2101
+ throw err;
2102
+ }
2103
+ results.push({ feature_id: featureId, ...outcome });
2104
+ }
2105
+
2106
+ if (flags.json) {
2107
+ console.log(JSON.stringify(flags.all ? results : results[0], null, 2));
2108
+ } else {
2109
+ for (const r of results) {
2110
+ console.log(`impact check -- ${r.feature_id}`);
2111
+ console.log(` changes: ${r.report.changes.length}, outbound: ${r.report.outbound.length}, inbound: ${r.report.inbound.length}`);
2112
+ console.log(` gate: ${r.evaluation.blocking ? 'awaiting_disposition' : 'pass'}`);
2113
+ for (const o of r.evaluation.blockingOutbound) console.log(` BLOCKING: ${o.change_key} -> ${o.downstream_feature} (${o.via}, ${o.confidence})`);
2114
+ for (const i of r.evaluation.blockingInbound) console.log(` BLOCKING (inbound): ${i.change_key} from ${i.upstream_feature}, unacknowledged`);
2115
+ }
2116
+ }
2117
+ const anyBlocking = results.some((r) => r.evaluation.blocking);
2118
+ process.exit(anyBlocking ? EXIT_CODES.AWAITING_DISPOSITION : EXIT.PASS);
2119
+ }
2120
+
2121
+ // D-cross-feature-impact-graph: the DECIDE half -- refuses (exit 3) if anything proven is
2122
+ // undisposed, otherwise atomically rewrites impact-baseline.json from the CURRENT surface.
2123
+ function cmdImpactAccept(args) {
2124
+ const flags = parseCommand('impact accept', args);
2125
+ if (flags.help) { console.log(renderCommandHelp('impact accept')); process.exit(0); }
2126
+ setContext('impact accept', flags);
2127
+ const root = requireRepoRoot();
2128
+ let result;
2129
+ try {
2130
+ result = acceptImpact(root, flags.feature);
2131
+ } catch (err) {
2132
+ if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
2133
+ throw err;
2134
+ }
2135
+ const gateState = passNamedGate(root, 'impact', flags.feature);
2136
+ if (flags.json) {
2137
+ console.log(JSON.stringify({ ...result, gate: gateState.gates.impact }, null, 2));
2138
+ } else {
2139
+ console.log(`accepted: ${flags.feature} -- baseline captured at ${result.baseline.captured_at}`);
2140
+ console.log(`gate: impact -> ${gateState.gates.impact.status}`);
2141
+ }
2142
+ process.exit(EXIT.PASS);
2143
+ }
2144
+
2145
+ function cmdImpactDisposition(args) {
2146
+ const flags = parseCommand('impact disposition', args);
2147
+ if (flags.help) { console.log(renderCommandHelp('impact disposition')); process.exit(0); }
2148
+ setContext('impact disposition', flags);
2149
+ const root = requireRepoRoot();
2150
+ let result;
2151
+ try {
2152
+ result = recordDisposition(root, {
2153
+ feature: flags.feature, changeKey: flags.change, downstreamFeature: flags.downstream,
2154
+ mode: flags.mode, reason: flags.reason, trackedBy: flags['tracked-by'],
2155
+ expiresDays: flags['expires-days'] != null ? Number(flags['expires-days']) : null,
2156
+ });
2157
+ } catch (err) {
2158
+ if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
2159
+ throw err;
2160
+ }
2161
+ if (flags.json) {
2162
+ console.log(JSON.stringify(result, null, 2));
2163
+ } else {
2164
+ console.log(`disposition recorded: ${flags.change} -> ${flags.downstream} (${flags.mode})`);
2165
+ }
2166
+ process.exit(EXIT.PASS);
2167
+ }
2168
+
2169
+ function cmdImpactAck(args) {
2170
+ const flags = parseCommand('impact ack', args);
2171
+ if (flags.help) { console.log(renderCommandHelp('impact ack')); process.exit(0); }
2172
+ setContext('impact ack', flags);
2173
+ const root = requireRepoRoot();
2174
+ let result;
2175
+ try {
2176
+ result = acknowledgeInbound(root, { feature: flags.feature, from: flags.from, changeKey: flags.change, reason: flags.reason });
2177
+ } catch (err) {
2178
+ if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
2179
+ throw err;
2180
+ }
2181
+ if (flags.json) {
2182
+ console.log(JSON.stringify(result, null, 2));
2183
+ } else {
2184
+ console.log(`acknowledged: ${flags.change} from ${flags.from}`);
2185
+ }
2186
+ process.exit(EXIT.PASS);
2187
+ }
2188
+
2189
+ // D-cross-feature-impact-graph (IG8/IG9): the one seam to the LLM-driven exploration layer -- writes
2190
+ // data only, spawns nothing, never touches a gate. `--format graphify` writes graphify's own native
2191
+ // extraction shape directly (see lib/impact-export-graphify.mjs's header for why this bypasses its
2192
+ // own Steps 1-3 entirely).
2193
+ function cmdImpactExport(args) {
2194
+ const flags = parseCommand('impact export', args);
2195
+ if (flags.help) { console.log(renderCommandHelp('impact export')); process.exit(0); }
2196
+ setContext('impact export', flags);
2197
+ const root = requireRepoRoot();
2198
+ if (!['graphify', 'json', 'mermaid'].includes(flags.format)) {
2199
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--format must be one of graphify|json|mermaid (got "${flags.format}")`);
2200
+ }
2201
+ const graph = buildImpactGraph(root);
2202
+ const rings = flags.rings != null ? Number(flags.rings) : null;
2203
+ if (rings != null && !flags.focus) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--rings only has an effect together with --focus');
2204
+
2205
+ let output;
2206
+ if (flags.format === 'json') output = `${JSON.stringify(graph, null, 2)}\n`;
2207
+ else if (flags.format === 'mermaid') output = `${toMermaid(graph)}\n`;
2208
+ else output = `${JSON.stringify(toGraphifyExtraction(graph, { focus: flags.focus, rings }), null, 2)}\n`;
2209
+
2210
+ if (flags.out) {
2211
+ writeFileAtomic(flags.out, output);
2212
+ console.log(`wrote ${flags.out}`);
2213
+ } else {
2214
+ process.stdout.write(output);
2215
+ }
2216
+ process.exit(EXIT.PASS);
2217
+ }
2218
+
2029
2219
  // D-business-rules (R6): compiles specs/<id>/rules.yaml (optional) plus the feature's own contract
2030
2220
  // into specs/<id>/rules/<id>.rules.json, and establishes the `rules` gate.
2031
2221
  //
@@ -2719,6 +2909,15 @@ function renderHandlesPlan(plan, actions) {
2719
2909
  lines.push(`- read via: ${r.readPath ?? '(not found)'}`);
2720
2910
  lines.push(`- requiredAuthority (fetch/recover): ${r.requiredAuthority}`);
2721
2911
  if (r.requiredAuthorityForPatch !== undefined) lines.push(`- requiredAuthorityForPatch: ${r.requiredAuthorityForPatch}`);
2912
+ // D-resolver-policy-contract (PC2): one line per policies[] record -- java-spring only
2913
+ // (the field is absent on python-fastapi/typescript-express plans).
2914
+ for (const p of r.policies ?? []) {
2915
+ if (p.status === 'materialized') {
2916
+ lines.push(`- policy[${p.action}]: materialized (${p.mode}) ${p.authority} -- ${p.evidence.kind}${p.evidence.file ? ` @ ${p.evidence.file}${p.evidence.line ? `:${p.evidence.line}` : ''}` : ''}`);
2917
+ } else {
2918
+ lines.push(`- policy[${p.action}]: UNRESOLVED (${p.mode}) -- ${p.evidence.kind}${p.evidence.file ? ` @ ${p.evidence.file}${p.evidence.line ? `:${p.evidence.line}` : ''}` : ''} -- ${p.reason}`);
2919
+ }
2920
+ }
2722
2921
  lines.push('');
2723
2922
  }
2724
2923
  if (plan.notes.length > 0) {
@@ -2959,7 +3158,7 @@ function cmdHandlesEmit(args) {
2959
3158
  // a diff" that also means "and actually write it", so --diff forces dryRun the same as --check
2960
3159
  // does, without requiring both flags together.
2961
3160
  const dryRun = flags.check || flags.diff;
2962
- const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [] } = provider.emit({
3161
+ const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [], unresolvedPolicies = [] } = provider.emit({
2963
3162
  repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff, enforceRegistry,
2964
3163
  });
2965
3164
 
@@ -3039,6 +3238,24 @@ function cmdHandlesEmit(args) {
3039
3238
  process.exit(EXIT_CODES.HANDLES_REGISTRATION_GAP);
3040
3239
  }
3041
3240
 
3241
+ // D-resolver-policy-contract (PC9): unconditional -- NOT gated on enforceRegistry (orthogonal
3242
+ // axis: that one is about revocation/lifecycle and produces 404s; this one is about
3243
+ // authorization and produces 403s/boot failures). Same acknowledgement mechanism as
3244
+ // registrationGaps above (--force --reason, no new flag) -- the AuthorizationPolicy.java
3245
+ // file(s) ARE still written either way; only the overall command's reported success, and the
3246
+ // `handles` gate passing, are gated on acknowledging the gap.
3247
+ if (unresolvedPolicies.length > 0 && !flags.force) {
3248
+ if (flags.json) {
3249
+ console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, unresolvedPolicies, blocked: true, gate: null, check: dryRun }, null, 2));
3250
+ } else {
3251
+ const verb = dryRun ? 'would refuse to report success' : 'refusing to report success';
3252
+ console.error(`${verb}: ${unresolvedPolicies.length} resource action(s) could not have their authorization safely auto-derived:`);
3253
+ for (const u of unresolvedPolicies) console.error(` ${u.resourceType} [${u.action}] (${u.kind}${u.file ? ` @ ${u.file}${u.line ? `:${u.line}` : ''}` : ''})\n ${u.note}`);
3254
+ if (!dryRun) console.error(`\nthe resolver/AuthorizationPolicy file(s) above were still written -- nothing about their content is wrong. Implement the generated AuthorizationPolicy interface(s) by hand and re-run, or acknowledge and proceed with: bskel handles emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''}${flags.resource ? ` --resource ${flags.resource}` : ''} --force --reason "..."`);
3255
+ }
3256
+ process.exit(EXIT_CODES.HANDLES_UNRESOLVED_POLICY);
3257
+ }
3258
+
3042
3259
  // D4: dryRun never marks the gate passed -- nothing real happened this run.
3043
3260
  const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
3044
3261
 
@@ -3048,7 +3265,7 @@ function cmdHandlesEmit(args) {
3048
3265
  postEmitNotes.push(...describeDownstreamImpact(root, flags.feature));
3049
3266
 
3050
3267
  if (flags.json) {
3051
- console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, gate: gateState?.gates.handles ?? null, check: dryRun, postEmitNotes }, null, 2));
3268
+ console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, unresolvedPolicies, blocked: false, gate: gateState?.gates.handles ?? null, check: dryRun, postEmitNotes }, null, 2));
3052
3269
  } else if (!flags.quiet) {
3053
3270
  console.log(`${dryRun ? 'would write' : 'wrote'} ${written.length} file(s):`);
3054
3271
  for (const w of written) console.log(` ${w}`);
@@ -3164,7 +3381,7 @@ async function cmdPatchPropose(args) {
3164
3381
  }
3165
3382
  params = { choice: flags.choice, target: flags.target };
3166
3383
  source = { choice: flags.choice };
3167
- } else {
3384
+ } else if (kind === 'ddl-apply') {
3168
3385
  if (!flags['database-url-env'] || !flags['sql-file']) {
3169
3386
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind ddl-apply requires --database-url-env <NAME> --sql-file <path>');
3170
3387
  }
@@ -3176,6 +3393,23 @@ async function cmdPatchPropose(args) {
3176
3393
  }
3177
3394
  params = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, sqlText };
3178
3395
  source = { database_url_env: flags['database-url-env'], schema: flags.schema };
3396
+ } else {
3397
+ // java-source-splice
3398
+ if (!flags['splice-file']) {
3399
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind java-source-splice requires --splice-file <path>');
3400
+ }
3401
+ let spliceDoc;
3402
+ try {
3403
+ spliceDoc = JSON.parse(fs.readFileSync(flags['splice-file'], 'utf8'));
3404
+ } catch (err) {
3405
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read/parse --splice-file "${flags['splice-file']}": ${err.message}`);
3406
+ }
3407
+ const { ok: spliceOk, errors: spliceErrors } = validateAgainstSchema('java-source-splice.schema.json', spliceDoc);
3408
+ if (!spliceOk) {
3409
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags['splice-file']}" does not match schemas/java-source-splice.schema.json:\n${formatSchemaErrors(spliceErrors).join('\n')}`);
3410
+ }
3411
+ params = { file: spliceDoc.file, edits: spliceDoc.edits };
3412
+ source = { request_file: flags['splice-file'] };
3179
3413
  }
3180
3414
 
3181
3415
  let plan;
@@ -3202,6 +3436,12 @@ function describePatchTransaction(txn) {
3202
3436
  if (txn.kind === 'config-apply') {
3203
3437
  return `${txn.target.file} @ ${txn.target.key_path.join('.')}: "${txn.current_value}" -> "${txn.proposed_value}"`;
3204
3438
  }
3439
+ if (txn.kind === 'java-source-splice') {
3440
+ const members = txn.target.edits
3441
+ .map((e) => (e.locator ? `${e.op}:${e.locator.member_name}` : `add-import:${e.imports?.[0]}`))
3442
+ .join(', ');
3443
+ return `[java-source-splice] ${txn.target.file}: ${members}`;
3444
+ }
3205
3445
  const sql = txn.target.sql_text.replace(/\s+/g, ' ').trim();
3206
3446
  return `[${txn.kind}] ${txn.target.database_url_env}/${txn.target.schema}: ${sql.slice(0, 100)}${sql.length > 100 ? '...' : ''}`;
3207
3447
  }
@@ -3231,12 +3471,33 @@ async function cmdPatchApprove(args) {
3231
3471
  } catch (err) {
3232
3472
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
3233
3473
  }
3234
- const updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
3474
+ let updated;
3475
+ try {
3476
+ updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
3477
+ } catch (err) {
3478
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', describeStaleTransactionError(err, root, txn, freshPlan));
3479
+ }
3235
3480
 
3236
3481
  console.log(flags.json ? JSON.stringify(updated, null, 2) : `approved: ${flags.transaction}`);
3237
3482
  process.exit(0);
3238
3483
  }
3239
3484
 
3485
+ // D-java-source-splice: enriches a StaleTransactionError with the kind's own optional
3486
+ // describeStaleness() hook (only java-source-splice defines one today) -- never changes WHETHER
3487
+ // the transaction is rejected, only the diagnostic text. Checked by `.name`, not `instanceof`
3488
+ // (StaleTransactionError is intentionally not exported from lib/patch-transactions.mjs -- this is
3489
+ // its only consumer, and importing the class just to narrow a catch is unnecessary coupling).
3490
+ function describeStaleTransactionError(err, root, txn, freshPlan) {
3491
+ if (err.name !== 'StaleTransactionError') return err.message;
3492
+ const describe = getPatchKind(txn.kind).describeStaleness;
3493
+ if (!describe) return err.message;
3494
+ try {
3495
+ return describe(root, txn, freshPlan);
3496
+ } catch {
3497
+ return err.message;
3498
+ }
3499
+ }
3500
+
3240
3501
  // D-ddl-apply: --confirm is required (and must exactly equal --transaction) for any kind other
3241
3502
  // than 'config-apply' -- deliberately checked here, at the CLI boundary, before applyTransaction()
3242
3503
  // is ever called, as human-factors friction layered ON TOP of the engine's own load-bearing
@@ -3262,7 +3523,12 @@ async function cmdPatchApply(args) {
3262
3523
  } catch (err) {
3263
3524
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
3264
3525
  }
3265
- const updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
3526
+ let updated;
3527
+ try {
3528
+ updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
3529
+ } catch (err) {
3530
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', describeStaleTransactionError(err, root, txn, freshPlan));
3531
+ }
3266
3532
 
3267
3533
  const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, applied_at: updated.apply.at };
3268
3534
  const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
@@ -4453,6 +4719,18 @@ async function dispatchCommand(cmd, rest) {
4453
4719
  process.exit(14);
4454
4720
  break;
4455
4721
  }
4722
+ case 'impact': {
4723
+ const sub = rest[0];
4724
+ const subArgs = rest.slice(1);
4725
+ if (sub === 'check') return cmdImpactCheck(subArgs);
4726
+ if (sub === 'accept') return cmdImpactAccept(subArgs);
4727
+ if (sub === 'disposition') return cmdImpactDisposition(subArgs);
4728
+ if (sub === 'ack') return cmdImpactAck(subArgs);
4729
+ if (sub === 'export') return cmdImpactExport(subArgs);
4730
+ usage();
4731
+ process.exit(14);
4732
+ break;
4733
+ }
4456
4734
  case 'rules': {
4457
4735
  const sub = rest[0];
4458
4736
  const subArgs = rest.slice(1);
@@ -15,12 +15,12 @@ import { pathPrefixCandidates, unreflectedPathPrefixes } from './export.mjs';
15
15
  // a path param. Direction stays one-way (openapi.mjs imports from emit.mjs, never the reverse).
16
16
  export const BARE_UUID_PATTERN = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$';
17
17
 
18
- // A7/A8/A9/A10: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
18
+ // A7/A8/A9/A10/X2: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
19
19
  // const -- bin/bskel.mjs's loadContract() imports this too, so the friendly "re-emit with the
20
- // current bskel" message and the value actually written here cannot drift apart. Bumped "7" -> "8"
21
- // for this item (sourceDescription) -- again cheap, the friendly re-emit pre-check needs zero
22
- // code change -- see D-openapi-description.
23
- export const CONTRACT_SCHEMA_VERSION = '8';
20
+ // current bskel" message and the value actually written here cannot drift apart. Bumped "8" -> "9"
21
+ // for this item (the `expansion` field, D-route-expansion-provenance) -- again cheap, the friendly
22
+ // re-emit pre-check needs zero code change.
23
+ export const CONTRACT_SCHEMA_VERSION = '9';
24
24
 
25
25
  // A9 (D-openapi-path-params): `sourcePathParamSchemas` (a Map<name, schema>, contracts/openapi.mjs's
26
26
  // applyPathParameterSchemas -- present only for a matched/adopted operation whose source document
@@ -66,7 +66,11 @@ function pathParamsSchema(routePath, sourcePathParamSchemas = null) {
66
66
  // Object>>`) -- findMethodParams() shares the same balanced-delimiter analyzer that fixes the
67
67
  // scanner's identical GenericWithSpaceController case.
68
68
  function detectRequestBody(filePath, methodName) {
69
- if (!filePath || !fs.existsSync(filePath)) return null;
69
+ // X5 (D-route-expansion-provenance): explicit guard, not the incidental fact that a regex built
70
+ // from the literal string "null" also happens not to match anything -- a null methodName means
71
+ // there is genuinely no literal per-action source method to look in (see the same reasoning
72
+ // D-typescript-express-inline-handlers already established for resolveHandlerFile()).
73
+ if (!filePath || !methodName || !fs.existsSync(filePath)) return null;
70
74
  const text = fs.readFileSync(filePath, 'utf8');
71
75
  const params = findMethodParams(text, methodName);
72
76
  if (params === null) return null;
@@ -377,6 +381,15 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
377
381
  }));
378
382
  }
379
383
  const { pathParams, pathParamsHeuristic } = pathParamsSchema(route, pathParamSchemas);
384
+ // X2 (D-route-expansion-provenance): present only when the scan adapter recorded which
385
+ // multi-route declaration this endpoint came from (ep.declarationIndex) AND that
386
+ // declaration actually resolves on the owning controller -- omitted for every ordinary
387
+ // 1:1 endpoint, the same "absent unless it applies" discipline A9's pathParamsHeuristic
388
+ // already established. No adapter populates declarationIndex yet (forward-compatible
389
+ // shape only) -- see test/contract.test.mjs's expansion-field tests for a hand-built
390
+ // fixture exercising this.
391
+ const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
392
+ const expansion = declaration ? { rule: declaration.rule, declarationLine: declaration.line, label: declaration.label ?? null } : null;
380
393
  operations[operationId] = {
381
394
  verb,
382
395
  path: route,
@@ -397,6 +410,9 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
397
410
  ...(sourceRequestBody ? { sourceRequestBody } : {}),
398
411
  // A9: omitted (not []) when every segment resolved from source, or the route has none.
399
412
  ...(pathParamsHeuristic ? { pathParamsHeuristic } : {}),
413
+ // X2: omitted entirely when this endpoint wasn't expanded from a multi-route
414
+ // declaration -- see the computation above.
415
+ ...(expansion ? { expansion } : {}),
400
416
  // A10: omitted entirely when --descriptions was not passed, the source had none, or
401
417
  // it failed the length cap -- same "omitted, never null/false" discipline as every
402
418
  // other field above.