backend-skeleton 1.5.0 → 1.7.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 (40) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +549 -63
  3. package/contracts/completeness.mjs +12 -1
  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 +220 -74
  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/lib/attest.mjs +59 -1
  16. package/lib/cli.mjs +125 -7
  17. package/lib/decision-log.mjs +58 -0
  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 +250 -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 +334 -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 +6 -0
  31. package/schemas/decision-event.schema.json +46 -0
  32. package/schemas/gate-attestation.schema.json +6 -1
  33. package/schemas/gate-export.schema.json +606 -22
  34. package/schemas/handles-plan.schema.json +32 -0
  35. package/schemas/impact-baseline.schema.json +59 -0
  36. package/schemas/impact-graph.schema.json +53 -0
  37. package/schemas/impact-report.schema.json +86 -0
  38. package/schemas/impact-resolution.schema.json +33 -0
  39. package/schemas/java-source-splice.schema.json +84 -0
  40. package/schemas/patch-transaction.schema.json +87 -2
package/lib/attest.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // sign, and verify a JSON payload -- it has no opinion about WHERE a key lives (bin/bskel.mjs's
5
5
  // `--key`/`--pubkey` flags are the only interface, per the user's own explicit choice to reject a
6
6
  // new home-directory key-storage convention for this slice -- see DECISIONS.md).
7
- import { generateKeyPairSync, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto';
7
+ import { generateKeyPairSync, createPublicKey, createHash, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto';
8
8
  import { sortKeysDeep } from './gates.mjs';
9
9
 
10
10
  export function generateKeypair() {
@@ -15,6 +15,13 @@ export function generateKeypair() {
15
15
  };
16
16
  }
17
17
 
18
+ // D-attestation-payload-completeness (K1): the named canonicalization algorithm this module
19
+ // implements -- recorded in a signed payload's own `tool.canonicalization` field so a verifier on
20
+ // a future bskel build can tell whether it's speaking the same dialect. Kept as `sortkeysdeep-json`
21
+ // forever (never silently redefined) -- see K1 in DECISIONS.md for why RFC 8785/JCS was considered
22
+ // and rejected for this codebase's value space.
23
+ export const CANONICALIZATION_ID = 'sortkeysdeep-json';
24
+
18
25
  // Deep-sorted, whitespace-free JSON -- the ONLY thing that's ever actually signed/verified.
19
26
  // Reusing lib/gates.mjs's own sortKeysDeep() (already proven correct via every gate's `inputs`
20
27
  // field) rather than a second, possibly-subtly-different implementation.
@@ -22,7 +29,58 @@ export function canonicalize(value) {
22
29
  return JSON.stringify(sortKeysDeep(value));
23
30
  }
24
31
 
32
+ // D-attestation-payload-completeness (K1): fail-closed guard, called ONLY from signPayload() --
33
+ // verifyPayload() keeps its shipped never-throws contract. A NaN/Infinity/undefined/BigInt/Date/
34
+ // function/symbol reaching JSON.stringify would silently serialize to something other than
35
+ // itself (or be dropped entirely) -- signing that payload would be signing a lie about what the
36
+ // value actually was. Walks the value with the exact JSON path of the first offending node in the
37
+ // thrown message, so a caller can find it without a second investigation.
38
+ export function assertCanonicalizable(value, jsonPath = '$') {
39
+ if (value === null) return;
40
+ const t = typeof value;
41
+ if (t === 'string' || t === 'boolean') return;
42
+ if (t === 'number') {
43
+ if (!Number.isFinite(value)) throw new Error(`assertCanonicalizable: ${jsonPath} is ${Number.isNaN(value) ? 'NaN' : value}, not a finite JSON number`);
44
+ return;
45
+ }
46
+ if (t === 'undefined') throw new Error(`assertCanonicalizable: ${jsonPath} is undefined`);
47
+ if (t === 'bigint') throw new Error(`assertCanonicalizable: ${jsonPath} is a BigInt, which JSON.stringify cannot represent`);
48
+ if (t === 'function') throw new Error(`assertCanonicalizable: ${jsonPath} is a function`);
49
+ if (t === 'symbol') throw new Error(`assertCanonicalizable: ${jsonPath} is a symbol`);
50
+ if (Array.isArray(value)) {
51
+ value.forEach((item, i) => assertCanonicalizable(item, `${jsonPath}[${i}]`));
52
+ return;
53
+ }
54
+ // t === 'object' from here -- reject anything whose prototype isn't a plain object (Date, Map,
55
+ // Set, a class instance, ...): JSON.stringify would silently call .toJSON() or drop it instead
56
+ // of representing its real shape.
57
+ const proto = Object.getPrototypeOf(value);
58
+ if (proto !== Object.prototype && proto !== null) {
59
+ throw new Error(`assertCanonicalizable: ${jsonPath} is a non-plain object (${value?.constructor?.name ?? 'unknown'}), not a plain {} or []`);
60
+ }
61
+ for (const [key, v] of Object.entries(value)) assertCanonicalizable(v, `${jsonPath}.${key}`);
62
+ }
63
+
64
+ // D-attestation-payload-completeness (K6): 'ed25519:<32 hex chars>' -- sha256 over the public
65
+ // key's SPKI DER bytes, first 16 bytes hex-encoded. A SELECTION HINT for `attest verify`'s error
66
+ // message, never a trust claim: it lives OUTSIDE the signed bytes (only `report` is signed), so it
67
+ // is exactly as attacker-modifiable as any other envelope field. See K6 in DECISIONS.md.
68
+ function keyIdFromDer(der) {
69
+ return `ed25519:${createHash('sha256').update(der).digest('hex').slice(0, 32)}`;
70
+ }
71
+
72
+ export function publicKeyIdFromPublic(publicKeyPem) {
73
+ const der = createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
74
+ return keyIdFromDer(der);
75
+ }
76
+
77
+ export function publicKeyIdFromPrivate(privateKeyPem) {
78
+ const der = createPublicKey(privateKeyPem).export({ type: 'spki', format: 'der' });
79
+ return keyIdFromDer(der);
80
+ }
81
+
25
82
  export function signPayload(payload, privateKeyPem) {
83
+ assertCanonicalizable(payload);
26
84
  const canonical = canonicalize(payload);
27
85
  return cryptoSign(null, Buffer.from(canonical), privateKeyPem).toString('base64');
28
86
  }
package/lib/cli.mjs CHANGED
@@ -84,13 +84,16 @@ export const COMMANDS = {
84
84
  },
85
85
  // D-gate-export: unlike `gate show`, always feature-scoped -- the whole point is one feature's
86
86
  // own evidence trail across all 5 gates, not a single gate/repo-level snapshot.
87
+ // D-attestation-payload-completeness (K4): --allow-dirty only has an effect together with
88
+ // --sign (bskel gate export enforces this itself -- see cmdGateExport's own BAD_ARGS check).
87
89
  'gate export': {
88
- usage: 'bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]',
90
+ usage: 'bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath> [--allow-dirty]] [--json]',
89
91
  options: {
90
92
  feature: { type: 'string', default: null, required: true },
91
93
  out: { type: 'string', default: null },
92
94
  sign: { type: 'boolean', default: false },
93
95
  key: { type: 'string', default: null },
96
+ 'allow-dirty': { type: 'boolean', default: false },
94
97
  json: { type: 'boolean', default: false },
95
98
  },
96
99
  },
@@ -103,11 +106,16 @@ export const COMMANDS = {
103
106
  json: { type: 'boolean', default: false },
104
107
  },
105
108
  },
109
+ // D-attestation-payload-completeness (K5): three OPT-IN, default-off assertions, evaluated
110
+ // purely against fields already inside the (offline, repo-independent) report.
106
111
  'attest verify': {
107
- usage: 'bskel attest verify --file <path> --pubkey <path> [--json]',
112
+ usage: 'bskel attest verify --file <path> --pubkey <path> [--expect-head <sha>] [--max-age-minutes N] [--reject-dirty] [--json]',
108
113
  options: {
109
114
  file: { type: 'string', default: null, required: true },
110
115
  pubkey: { type: 'string', default: null, required: true },
116
+ 'expect-head': { type: 'string', default: null },
117
+ 'max-age-minutes': { type: 'string', default: null, numeric: { min: 0 } },
118
+ 'reject-dirty': { type: 'boolean', default: false },
111
119
  json: { type: 'boolean', default: false },
112
120
  },
113
121
  },
@@ -179,6 +187,20 @@ export const COMMANDS = {
179
187
  json: { type: 'boolean', default: false },
180
188
  },
181
189
  },
190
+ // D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
191
+ // waiver entry (its absence is the state) and appends a `withdraw` decision event. Never a
192
+ // snapshot restore.
193
+ 'scan cross-feature-unwaive': {
194
+ usage: 'bskel scan cross-feature-unwaive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..." [--json]',
195
+ options: {
196
+ feature: { type: 'string', default: null, required: true },
197
+ signal: { type: 'string', default: null, required: true },
198
+ identifier: { type: 'string', default: null, required: true },
199
+ 'other-feature': { type: 'string', default: null, required: true },
200
+ reason: { type: 'string', default: '' },
201
+ json: { type: 'boolean', default: false },
202
+ },
203
+ },
182
204
  'feature init': {
183
205
  usage: 'bskel feature init --slug <name>',
184
206
  options: { slug: { type: 'string', default: null, required: true } },
@@ -291,6 +313,20 @@ export const COMMANDS = {
291
313
  json: { type: 'boolean', default: false },
292
314
  },
293
315
  },
316
+ // D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
317
+ // waiver entry (its absence is the state) and appends a `withdraw` decision event. Never a
318
+ // snapshot restore (the entry's prior reason/expiry is preserved only in the decision log, not
319
+ // restorable through this command).
320
+ 'contract unwaive': {
321
+ usage: 'bskel contract unwaive --feature <id> --code <CODE> --subject "VERB /path" --reason "..." [--json]',
322
+ options: {
323
+ feature: { type: 'string', default: null, required: true },
324
+ code: { type: 'string', default: null, required: true },
325
+ subject: { type: 'string', default: null, required: true },
326
+ reason: { type: 'string', default: '' },
327
+ json: { type: 'boolean', default: false },
328
+ },
329
+ },
294
330
  'contract validate': {
295
331
  usage: 'bskel contract validate --feature <id> --file <envelope.json>',
296
332
  options: {
@@ -339,6 +375,72 @@ export const COMMANDS = {
339
375
  json: { type: 'boolean', default: false },
340
376
  },
341
377
  },
378
+ // D-cross-feature-impact-graph: `check` is read-mostly (writes impact-report.json, never
379
+ // advances the baseline); `accept` is the only command that advances impact-baseline.json, and
380
+ // refuses (exit 3) if any proven outbound impact is undisposed or inbound migration
381
+ // unacknowledged. `--all` (check only) sweeps every active feature -- the only thing that closes
382
+ // the brand-new-counterparty narrowing gap the `impact` gate's own recompute() accepts (see D4/D7
383
+ // in DECISIONS.md).
384
+ 'impact check': {
385
+ usage: 'bskel impact check (--feature <id> | --all) [--json]',
386
+ options: {
387
+ feature: { type: 'string', default: null },
388
+ all: { type: 'boolean', default: false },
389
+ json: { type: 'boolean', default: false },
390
+ },
391
+ },
392
+ 'impact accept': {
393
+ usage: 'bskel impact accept --feature <id> [--json]',
394
+ options: {
395
+ feature: { type: 'string', default: null, required: true },
396
+ json: { type: 'boolean', default: false },
397
+ },
398
+ },
399
+ // D-cross-feature-impact-graph (D6): three modes, three mechanically different consequences --
400
+ // `migrate` requires --tracked-by, `waive` requires --expires-days. `compatible` needs neither.
401
+ // D-decision-event-log (D6): --withdraw is a flag variant of this same command, not a separate
402
+ // subcommand -- a withdrawal needs no --mode, so `mode` is NOT `required: true` at the parse
403
+ // layer (recordDisposition() itself still refuses a missing/invalid mode on the record path --
404
+ // see cmdImpactDisposition's own comment for why this split is deliberate).
405
+ 'impact disposition': {
406
+ usage: 'bskel impact disposition --feature <id> --change <change_key> --downstream <id> (--mode compatible|migrate|waive [--tracked-by "<ref>"] [--expires-days <N>] | --withdraw) --reason "..." [--json]',
407
+ options: {
408
+ feature: { type: 'string', default: null, required: true },
409
+ change: { type: 'string', default: null, required: true },
410
+ downstream: { type: 'string', default: null, required: true },
411
+ mode: { type: 'string', default: null },
412
+ reason: { type: 'string', default: '' },
413
+ 'tracked-by': { type: 'string', default: null },
414
+ 'expires-days': { type: 'string', default: null, numeric: { min: 1 } },
415
+ withdraw: { type: 'boolean', default: false },
416
+ json: { type: 'boolean', default: false },
417
+ },
418
+ },
419
+ 'impact ack': {
420
+ usage: 'bskel impact ack --feature <id> --from <upstream_id> --change <change_key> --reason "..." [--json]',
421
+ options: {
422
+ feature: { type: 'string', default: null, required: true },
423
+ from: { type: 'string', default: null, required: true },
424
+ change: { type: 'string', default: null, required: true },
425
+ reason: { type: 'string', default: '' },
426
+ json: { type: 'boolean', default: false },
427
+ },
428
+ },
429
+ // D-cross-feature-impact-graph (IG8): the one LLM-free seam to the exploration layer -- `--format
430
+ // graphify` writes graphify's own native extraction file shape directly (bypassing its own
431
+ // install/detect/extract steps), `--format json` writes the raw sbf.impact-graph/1 document,
432
+ // `--format mermaid` mirrors `bskel db erd`'s own posture. `--focus`/`--rings` implement IG9's
433
+ // Focus+Context data projection (ring/detail), written only into the export, never read back by
434
+ // any gate.
435
+ 'impact export': {
436
+ usage: 'bskel impact export --format graphify|json|mermaid [--out <path>] [--focus <node-id>] [--rings <N>]',
437
+ options: {
438
+ format: { type: 'string', default: null, required: true },
439
+ out: { type: 'string', default: null },
440
+ focus: { type: 'string', default: null },
441
+ rings: { type: 'string', default: null, numeric: { min: 0 } },
442
+ },
443
+ },
342
444
  // D-business-rules: `check` compiles + verifies + establishes the `rules` gate; `list`/`explain`
343
445
  // are read-only. `--init` writes a starter rules.yaml only when none exists (never overwrites).
344
446
  'rules check': {
@@ -576,26 +678,42 @@ export const COMMANDS = {
576
678
  json: { type: 'boolean', default: false },
577
679
  },
578
680
  },
681
+ // D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
682
+ // approval entry (its absence is the state) and appends a `withdraw` decision event. Never a
683
+ // snapshot restore.
684
+ 'handles patch unapprove': {
685
+ usage: 'bskel handles patch unapprove --feature <id> --resource <Type> --field <name> --reason "..." [--json]',
686
+ options: {
687
+ feature: { type: 'string', default: null, required: true },
688
+ resource: { type: 'string', default: null, required: true },
689
+ field: { type: 'string', default: null, required: true },
690
+ reason: { type: 'string', default: '' },
691
+ json: { type: 'boolean', default: false },
692
+ },
693
+ },
579
694
  // D-patch-transactions: content-addressed patch transactions, Slice 1 (config_check ->
580
695
  // config_apply). `propose`/`approve` only touch specs/, so no --force escape exists on either --
581
696
  // re-propose is the only remediation for a stale target. `rollback` alone gets --force (reverting
582
697
  // to a known-good, git-recoverable prior state is materially lower-risk than forcing a forward
583
698
  // edit whose collateral effects were never re-verified).
584
699
  'patch propose': {
585
- usage: 'bskel patch propose --feature <id> [--kind config-apply --choice <stackChoiceId> --target <config_check target path> | --kind ddl-apply --database-url-env <NAME> --sql-file <path> [--schema public]] [--json]',
700
+ usage: 'bskel patch propose --feature <id> [--kind config-apply --choice <stackChoiceId> --target <config_check target path> | --kind ddl-apply --database-url-env <NAME> --sql-file <path> [--schema public] | --kind java-source-splice --splice-file <path>] [--json]',
586
701
  options: {
587
702
  feature: { type: 'string', default: null, required: true },
588
703
  // D-ddl-apply: default 'config-apply' -- omitting --kind entirely is byte-identical to
589
- // this project's prior behavior. choice/target/database-url-env/sql-file are validated
590
- // by hand inside cmdPatchPropose (kind-conditional requirements aren't expressible via
591
- // this table's own unconditional `required: true`), matching this file's existing
592
- // convention for kind-conditional flags (e.g. --reason on approve/rollback).
704
+ // this project's prior behavior. choice/target/database-url-env/sql-file/splice-file are
705
+ // validated by hand inside cmdPatchPropose (kind-conditional requirements aren't
706
+ // expressible via this table's own unconditional `required: true`), matching this file's
707
+ // existing convention for kind-conditional flags (e.g. --reason on approve/rollback).
593
708
  kind: { type: 'string', default: 'config-apply' },
594
709
  choice: { type: 'string', default: null },
595
710
  target: { type: 'string', default: null },
596
711
  'database-url-env': { type: 'string', default: null },
597
712
  schema: { type: 'string', default: 'public' },
598
713
  'sql-file': { type: 'string', default: null },
714
+ // D-java-source-splice: a JSON request document, not raw text like --sql-file -- a method
715
+ // body cannot live in a shell flag, and the edit list itself needs real structure.
716
+ 'splice-file': { type: 'string', default: null },
599
717
  json: { type: 'boolean', default: false },
600
718
  },
601
719
  },
@@ -0,0 +1,58 @@
1
+ // D-decision-event-log: an append-only audit trail for the four spec-side decision files
2
+ // (contract waivers, cross-feature waivers, impact dispositions, patch approvals) -- mirrors
3
+ // lib/state.mjs's appendGateEvent()/readGateHistory() shape exactly (same file family, same
4
+ // schema-validated-JSONL contract, same corrupt-line-is-skipped-not-fatal resilience). A sibling
5
+ // to .sbf/<featureId>.history.jsonl, not a replacement for it -- gates keep their own log.
6
+ // WHY: F2 (found during this item's own grounding) -- all four decision files silently
7
+ // OVERWRITE the prior decision (reason/actor/timestamp) on re-decision, and none of the four had
8
+ // any retraction command. `gate revoke` is the only retraction primitive in the whole tool.
9
+ // COST: one more .sbf/ file per feature. Machine-local, gitignored-by-convention -- this log is
10
+ // NOT tamper-evident on its own (see schemas/decision-event.schema.json's own description);
11
+ // its evidentiary value comes from being rolled into a SIGNED gate-export attestation.
12
+ // EXIT: no cross-feature aggregate view; per-feature only, matching gate history's own scoping.
13
+ import fs from 'node:fs';
14
+ import { sbfDir } from './state.mjs';
15
+ import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
16
+
17
+ export function decisionLogPath(repoRoot, featureId) {
18
+ return `${sbfDir(repoRoot)}/${featureId}.decisions.jsonl`;
19
+ }
20
+
21
+ // Called from inside the SAME withLockSync(root, 'state', ...) each of the four write paths
22
+ // already holds -- never opens its own lock, so the decision write and the log append can never
23
+ // observe each other out of order (same discipline setGate() already applies to gate writes).
24
+ export function appendDecisionEvent(repoRoot, featureId, event) {
25
+ const line = { schema: 'sbf.decision-event/1', ...event };
26
+ const { ok, errors } = validateAgainstSchema('decision-event.schema.json', line);
27
+ if (!ok) {
28
+ throw new Error(`refusing to append an invalid decision event for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
29
+ }
30
+ fs.mkdirSync(sbfDir(repoRoot), { recursive: true });
31
+ fs.appendFileSync(decisionLogPath(repoRoot, featureId), `${JSON.stringify(line)}\n`);
32
+ }
33
+
34
+ // Mirrors lib/gate-export.mjs's readGateHistory() resilience contract exactly: a corrupt/invalid
35
+ // line is skipped with a warning, never a hard failure.
36
+ export function readDecisionLog(repoRoot, featureId, { kind = null, onWarning } = {}) {
37
+ const file = decisionLogPath(repoRoot, featureId);
38
+ if (!fs.existsSync(file)) return [];
39
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
40
+ const events = [];
41
+ for (const [i, line] of lines.entries()) {
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(line);
45
+ } catch {
46
+ onWarning?.(`${file}:${i + 1}: not valid JSON, skipped`);
47
+ continue;
48
+ }
49
+ const { ok, errors } = validateAgainstSchema('decision-event.schema.json', parsed);
50
+ if (!ok) {
51
+ onWarning?.(`${file}:${i + 1}: does not match schemas/decision-event.schema.json, skipped`, errors);
52
+ continue;
53
+ }
54
+ if (kind && parsed.kind !== kind) continue;
55
+ events.push(parsed);
56
+ }
57
+ return events;
58
+ }
package/lib/doctor.mjs CHANGED
@@ -94,6 +94,28 @@ function astHelperCheck() {
94
94
  };
95
95
  }
96
96
 
97
+ // D-java-source-splice: combines the two real prerequisites `bskel patch propose --kind
98
+ // java-source-splice` needs beyond the base install -- the AST helper (reuses
99
+ // detectAstHelperAvailable(), same function astHelperCheck() and the real command both call, so
100
+ // this can never disagree with them) AND a recognized build command (detectBuildCommand()) to run
101
+ // the compile postcondition against. Either missing means the whole kind is unusable, so this
102
+ // check fails if EITHER is unavailable, naming which one(s).
103
+ function javaSourceSpliceCheck(root) {
104
+ const ast = detectAstHelperAvailable();
105
+ const build = root ? detectBuildCommand(root) : null;
106
+ const ok = ast.available && build !== null;
107
+ const missing = [];
108
+ if (!ast.available) missing.push(`AST helper (${ast.reason})`);
109
+ if (!build) missing.push('a recognized build command (gradlew/pom.xml/package.json)');
110
+ return {
111
+ name: 'java-source-splice prerequisites',
112
+ required: false,
113
+ ok,
114
+ detail: ok ? `ready (build tool: ${build.tool})` : missing.join('; '),
115
+ remediation: ok ? null : `${missing.join('; ')} -- only needed for \`bskel patch propose --kind java-source-splice\`, never for the base install.`,
116
+ };
117
+ }
118
+
97
119
  // D5: sourced from stack/catalog/*.yml's own `runtime.requires` field (schemas/stack-choice.
98
120
  // schema.json) rather than a hardcoded ["curl", "ngrok"] list here -- a future catalog entry
99
121
  // declares its own runtime binaries and doctor picks them up with zero code changes, the same
@@ -187,6 +209,7 @@ export function computeDoctorChecks(root, { workflow = null } = {}) {
187
209
  }
188
210
  if (workflow === null || workflow === 'handles') {
189
211
  checks.push(astHelperCheck());
212
+ checks.push(javaSourceSpliceCheck(root));
190
213
  }
191
214
  if (root && (workflow === null || workflow === 'stack')) {
192
215
  checks.push(...stackToolChecks(root));
@@ -39,6 +39,23 @@ export const EXIT_CODES = Object.freeze({
39
39
  // can find -- distinct from HANDLES_CONFLICT(15), which is about a GENERATED file diverging;
40
40
  // this is about a hand-written file bskel never touches lacking an annotation it can't add.
41
41
  HANDLES_REGISTRATION_GAP: 21,
42
+ // D-attestation-payload-completeness (K5): `attest verify`'s OPT-IN assertions (--expect-head /
43
+ // --max-age-minutes / --reject-dirty) failing on a document whose signature is genuinely VALID.
44
+ // Deliberately not a reuse of CHECK_FAILED(1): D-gate-attestation-signing's shipped contract is
45
+ // that exit 1 means, and only ever means, "the signature does not verify". Folding "authentic
46
+ // but not what you asked for" into the same number would destroy exactly the distinction that
47
+ // entry was written to preserve.
48
+ ATTESTATION_ASSERTION_FAILED: 22,
49
+ // D-resolver-policy-contract (PC9): `handles emit` refusing to proceed (without --force
50
+ // --reason) because at least one resource's fetch/patch authorization could not be safely
51
+ // auto-derived (see plan.mjs's derivePolicy() refusal ladder) -- an AuthorizationPolicy.java
52
+ // interface was still written (the human needs to see it), but nothing implements it yet, so
53
+ // the target app will not start once this resolver's bean is wired. Not a reuse of
54
+ // HANDLES_REGISTRATION_GAP(21), which is about a hand-written file lacking an annotation bskel
55
+ // can't add; this is about a generated file that is itself an unresolved authorization decision.
56
+ // Orthogonal to --enforce-registry (that axis produces 404s; this one produces 403s/boot
57
+ // failures) -- unconditional, not gated on enforceRegistry.
58
+ HANDLES_UNRESOLVED_POLICY: 23,
42
59
  });
43
60
 
44
61
  // `reason` values a `sbf.cli-diagnostic/1` envelope (lib/cli.mjs) can carry. Deliberately does
@@ -65,6 +65,23 @@ const OTHER_FEATURE_CONTRACT_PREFIX = 'other_feature_contract:';
65
65
  // specifically rather than reported as a generic "stale".
66
66
  const APPLIED_TARGET_PREFIX = 'applied_target:';
67
67
  const TRANSACTION_RECORD_PREFIX = 'transaction_record:';
68
+ // D-cross-feature-impact-graph: same OTHER_FEATURE_*_PREFIX narrowing convention as
69
+ // `cross_feature` above -- one pair of keys per OTHER feature the LAST persisted impact-report.json
70
+ // actually named (either as an outbound downstream or an inbound upstream), not every feature in
71
+ // the repo. Same accepted limitation: a brand-new counterparty is only caught at the next explicit
72
+ // `bskel impact check --all` (see D-cross-feature-impact-graph's D4/D7 in DECISIONS.md).
73
+ const IMPACT_DOWNSTREAM_DEPS_PREFIX = 'impact_downstream_deps:';
74
+ const IMPACT_UPSTREAM_RESOLUTION_PREFIX = 'impact_upstream_resolution:';
75
+ // D-cross-feature-impact-graph: path builders duplicated here rather than imported from
76
+ // lib/impact-surface.mjs/lib/impact.mjs -- importing either would close a real circular-import
77
+ // loop (gate-definitions.mjs -> lib/impact.mjs -> lib/impact-graph.mjs ->
78
+ // lib/field-dependencies.mjs -> lib/gates.mjs -> gate-definitions.mjs), the SAME tradeoff
79
+ // TARGET_FIELD_FILE_PREFIX's own header comment above already accepts for a two-line duplication
80
+ // rather than a real coupling. If any of these three literal filenames ever changes, this must
81
+ // change with it.
82
+ const impactBaselineFilePath = (root, featureId) => specPath(root, featureId, 'impact-baseline.json');
83
+ const impactReportFilePath = (root, featureId) => specPath(root, featureId, 'impact-report.json');
84
+ const impactResolutionFilePath = (root, featureId) => specPath(root, featureId, 'impact-resolution.json');
68
85
 
69
86
  // The preflight and stack gates are repo-scoped, not feature-scoped -- preflight runs before a
70
87
  // feature_id exists at all, and a stack choice is a project-wide decision, not per-feature.
@@ -295,6 +312,47 @@ export const GATE_DEFINITIONS = Object.freeze({
295
312
  return inputs;
296
313
  },
297
314
  },
315
+ // D-cross-feature-impact-graph: a change to THIS feature's own public surface (operations,
316
+ // resources, declared-dependency fields) that a downstream feature is provably wired to, with
317
+ // no recorded disposition yet. Complements `dependencies` above (which blocks the DOWNSTREAM
318
+ // side when what it depends on moves) by blocking the UPSTREAM side -- the feature that
319
+ // CHANGED, which is Codex's literal ask ("the next gate identifies downstream features and
320
+ // requires an explicit disposition"). Deliberately NOT a hard prerequisite of `handles emit`
321
+ // (see D7 in DECISIONS.md, citing this repo's own real CI incident from the `cross_feature`
322
+ // gate's identical mistake) -- gated only at `bskel verify`.
323
+ //
324
+ // recompute() is intentionally the cheapest possible: pure sha256File() over three already-
325
+ // persisted files plus the SAME artifacts `contract`/`dependencies`/`scan` already hash, never
326
+ // lib/impact-graph.mjs's buildImpactGraph() (real work -- O(features x artifacts) file reads,
327
+ // kept entirely out of the fast verify/require path, only ever called from `bskel impact
328
+ // check`/`export`). This is also what T6 (test/gate-definitions.test.mjs) asserts: this
329
+ // module's own transitive import closure contains no node:http/node:child_process/pg, and
330
+ // calling recompute() twice over an unchanged tree returns byte-identical inputs.
331
+ impact: {
332
+ name: 'impact',
333
+ scope: SCOPE.FEATURE,
334
+ verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
335
+ recompute: (root, featureId) => {
336
+ const inputs = {
337
+ impact_baseline_hash: sha256File(impactBaselineFilePath(root, featureId)),
338
+ impact_report_hash: sha256File(impactReportFilePath(root, featureId)),
339
+ impact_resolution_hash: sha256File(impactResolutionFilePath(root, featureId)),
340
+ contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
341
+ dependencies_hash: sha256File(dependenciesPath(root, featureId)),
342
+ scan_report_hash: sha256File(specPath(root, featureId, 'brownfield-scan.json')),
343
+ };
344
+ const report = readJsonIfExists(impactReportFilePath(root, featureId));
345
+ const others = new Set([
346
+ ...(report?.outbound ?? []).map((o) => o.downstream_feature),
347
+ ...(report?.inbound ?? []).map((i) => i.upstream_feature),
348
+ ]);
349
+ for (const other of others) {
350
+ inputs[`${IMPACT_DOWNSTREAM_DEPS_PREFIX}${other}`] = sha256File(dependenciesPath(root, other));
351
+ inputs[`${IMPACT_UPSTREAM_RESOLUTION_PREFIX}${other}`] = sha256File(impactResolutionFilePath(root, other));
352
+ }
353
+ return inputs;
354
+ },
355
+ },
298
356
  // D-business-rules (R7): a feature's compiled business rules. Its own gate rather than a
299
357
  // widening of `dependencies` -- the same call cross_feature/dependencies/patch_transactions/
300
358
  // conformance each made rather than overloading a neighbour, and the two genuinely differ:
@@ -394,7 +452,12 @@ export const GATE_DEFINITIONS = Object.freeze({
394
452
  const inputs = {};
395
453
  for (const txn of listTransactions(root, featureId)) {
396
454
  if (txn.status !== 'applied') continue; // only a LIVE edit is drift-risk
397
- if (txn.kind === 'config-apply') {
455
+ // D-java-source-splice: a second filesystem-targeting kind, added to this explicit list
456
+ // deliberately -- NOT via a dynamic getPatchKind(txn.kind) lookup, which would make
457
+ // lib/gate-definitions.mjs import lib/patch-kinds.mjs and, transitively, `pg`
458
+ // (scanners/db/ddl-apply.mjs) into EVERY gate recomputation, the exact live-dependency-
459
+ // in-a-gate coupling this gate's own header comment already refuses.
460
+ if (txn.kind === 'config-apply' || txn.kind === 'java-source-splice') {
398
461
  const abs = resolveWithinRoot(root, txn.target.file);
399
462
  if (!abs) continue; // a path escaping the repo is not something this feature applied
400
463
  inputs[`${APPLIED_TARGET_PREFIX}${txn.transaction_id}`] = sha256File(abs); // null == deleted -> stale
@@ -429,7 +492,7 @@ export const GATE_DEFINITIONS = Object.freeze({
429
492
  // test/gate-definitions.test.mjs asserts this stays exactly in sync with GATE_DEFINITIONS' own
430
493
  // key set, so a gate added to one and not the other fails loudly instead of silently vanishing
431
494
  // from `bskel verify` the way `stack` did before this module existed.
432
- export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'rules', 'handles', 'stack', 'patch_transactions', 'conformance']);
495
+ export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'impact', 'rules', 'handles', 'stack', 'patch_transactions', 'conformance']);
433
496
 
434
497
  export function getGateDefinition(name) {
435
498
  return Object.hasOwn(GATE_DEFINITIONS, name) ? GATE_DEFINITIONS[name] : null;