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
@@ -57,7 +57,13 @@ function findFetchRoute(controllers, entityClassName) {
57
57
  if (ep.verb !== 'GET') continue;
58
58
  const suffix = ep.path.slice(controller.basePath.length);
59
59
  if (/^\/:[^/(]+(\([^)]*\))?$/.test(suffix)) {
60
- return { method: ep.method, path: ep.path, file: controller.file, line: ep.line, controllerClassName: controller.className };
60
+ // X5 (D-route-expansion-provenance): threaded through so the caller can distinguish
61
+ // "inline arrow handler" (this provider's real, current no-method case) from "1:N
62
+ // framework-synthesized route" (a declaration is present) in its note text. null on
63
+ // every endpoint in today's adapter (it never populates declarationIndex) --
64
+ // forward-compatible only, not yet reachable.
65
+ const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
66
+ return { method: ep.method, path: ep.path, file: controller.file, line: ep.line, controllerClassName: controller.className, declaration };
61
67
  }
62
68
  }
63
69
  }
@@ -183,7 +189,14 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
183
189
  if (!fetchRoute) {
184
190
  notes.push(`${entity.className}: no single-resource GET route found on a router whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
185
191
  } else if (!fetchRoute.method) {
186
- notes.push(`${entity.className}: the single-resource GET route's handler is an inline function expression, not a named export -- nothing to correlate to a defining file, resolver NOT generated.`);
192
+ // X5 (D-route-expansion-provenance): a declaration means this route was expanded from a
193
+ // 1:N framework construct (no literal per-action method exists at all, not yet reachable
194
+ // in this adapter); no declaration keeps this provider's own real, current cause (an
195
+ // inline arrow-function handler) unchanged.
196
+ const note = fetchRoute.declaration
197
+ ? `the matched endpoint (GET ${fetchRoute.path}) was expanded from ${fetchRoute.declaration.label ?? fetchRoute.declaration.rule} at ${path.relative(repoRoot, fetchRoute.file)}:${fetchRoute.declaration.line} (rule: ${fetchRoute.declaration.rule}) -- the framework generates this handler at runtime, so no literal per-action source method exists to correlate to. Resolver NOT generated -- this is a structural boundary of static-scan-based handles codegen, not a bug. See D-resolver-scope.`
198
+ : 'the single-resource GET route\'s handler is an inline function expression, not a named export -- nothing to correlate to a defining file, resolver NOT generated.';
199
+ notes.push(`${entity.className}: ${note}`);
187
200
  } else if (!handlerFile) {
188
201
  notes.push(`${entity.className}: could not resolve ${fetchRoute.method}'s own defining file (import, or one barrel hop, from ${path.relative(repoRoot, fetchRoute.file)}) -- resolver NOT generated.`);
189
202
  } else if (!selectFields) {
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
  },
@@ -339,6 +347,67 @@ export const COMMANDS = {
339
347
  json: { type: 'boolean', default: false },
340
348
  },
341
349
  },
350
+ // D-cross-feature-impact-graph: `check` is read-mostly (writes impact-report.json, never
351
+ // advances the baseline); `accept` is the only command that advances impact-baseline.json, and
352
+ // refuses (exit 3) if any proven outbound impact is undisposed or inbound migration
353
+ // unacknowledged. `--all` (check only) sweeps every active feature -- the only thing that closes
354
+ // the brand-new-counterparty narrowing gap the `impact` gate's own recompute() accepts (see D4/D7
355
+ // in DECISIONS.md).
356
+ 'impact check': {
357
+ usage: 'bskel impact check (--feature <id> | --all) [--json]',
358
+ options: {
359
+ feature: { type: 'string', default: null },
360
+ all: { type: 'boolean', default: false },
361
+ json: { type: 'boolean', default: false },
362
+ },
363
+ },
364
+ 'impact accept': {
365
+ usage: 'bskel impact accept --feature <id> [--json]',
366
+ options: {
367
+ feature: { type: 'string', default: null, required: true },
368
+ json: { type: 'boolean', default: false },
369
+ },
370
+ },
371
+ // D-cross-feature-impact-graph (D6): three modes, three mechanically different consequences --
372
+ // `migrate` requires --tracked-by, `waive` requires --expires-days. `compatible` needs neither.
373
+ 'impact disposition': {
374
+ usage: 'bskel impact disposition --feature <id> --change <change_key> --downstream <id> --mode compatible|migrate|waive --reason "..." [--tracked-by "<ref>"] [--expires-days <N>] [--json]',
375
+ options: {
376
+ feature: { type: 'string', default: null, required: true },
377
+ change: { type: 'string', default: null, required: true },
378
+ downstream: { type: 'string', default: null, required: true },
379
+ mode: { type: 'string', default: null, required: true },
380
+ reason: { type: 'string', default: '' },
381
+ 'tracked-by': { type: 'string', default: null },
382
+ 'expires-days': { type: 'string', default: null, numeric: { min: 1 } },
383
+ json: { type: 'boolean', default: false },
384
+ },
385
+ },
386
+ 'impact ack': {
387
+ usage: 'bskel impact ack --feature <id> --from <upstream_id> --change <change_key> --reason "..." [--json]',
388
+ options: {
389
+ feature: { type: 'string', default: null, required: true },
390
+ from: { type: 'string', default: null, required: true },
391
+ change: { type: 'string', default: null, required: true },
392
+ reason: { type: 'string', default: '' },
393
+ json: { type: 'boolean', default: false },
394
+ },
395
+ },
396
+ // D-cross-feature-impact-graph (IG8): the one LLM-free seam to the exploration layer -- `--format
397
+ // graphify` writes graphify's own native extraction file shape directly (bypassing its own
398
+ // install/detect/extract steps), `--format json` writes the raw sbf.impact-graph/1 document,
399
+ // `--format mermaid` mirrors `bskel db erd`'s own posture. `--focus`/`--rings` implement IG9's
400
+ // Focus+Context data projection (ring/detail), written only into the export, never read back by
401
+ // any gate.
402
+ 'impact export': {
403
+ usage: 'bskel impact export --format graphify|json|mermaid [--out <path>] [--focus <node-id>] [--rings <N>]',
404
+ options: {
405
+ format: { type: 'string', default: null, required: true },
406
+ out: { type: 'string', default: null },
407
+ focus: { type: 'string', default: null },
408
+ rings: { type: 'string', default: null, numeric: { min: 0 } },
409
+ },
410
+ },
342
411
  // D-business-rules: `check` compiles + verifies + establishes the `rules` gate; `list`/`explain`
343
412
  // are read-only. `--init` writes a starter rules.yaml only when none exists (never overwrites).
344
413
  'rules check': {
@@ -582,20 +651,23 @@ export const COMMANDS = {
582
651
  // to a known-good, git-recoverable prior state is materially lower-risk than forcing a forward
583
652
  // edit whose collateral effects were never re-verified).
584
653
  '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]',
654
+ 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
655
  options: {
587
656
  feature: { type: 'string', default: null, required: true },
588
657
  // 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).
658
+ // this project's prior behavior. choice/target/database-url-env/sql-file/splice-file are
659
+ // validated by hand inside cmdPatchPropose (kind-conditional requirements aren't
660
+ // expressible via this table's own unconditional `required: true`), matching this file's
661
+ // existing convention for kind-conditional flags (e.g. --reason on approve/rollback).
593
662
  kind: { type: 'string', default: 'config-apply' },
594
663
  choice: { type: 'string', default: null },
595
664
  target: { type: 'string', default: null },
596
665
  'database-url-env': { type: 'string', default: null },
597
666
  schema: { type: 'string', default: 'public' },
598
667
  'sql-file': { type: 'string', default: null },
668
+ // D-java-source-splice: a JSON request document, not raw text like --sql-file -- a method
669
+ // body cannot live in a shell flag, and the edit list itself needs real structure.
670
+ 'splice-file': { type: 'string', default: null },
599
671
  json: { type: 'boolean', default: false },
600
672
  },
601
673
  },
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;
@@ -0,0 +1,199 @@
1
+ // D-attestation-payload-completeness: pure report-construction logic for `bskel gate export`,
2
+ // pulled out of bin/bskel.mjs so it is unit-testable without spawning a CLI process -- the same
3
+ // split lib/verify.mjs and lib/gates.mjs already follow (CLI stays thin, real logic lives in lib/).
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { GATE_NAMES, gateScopeId } from './gate-definitions.mjs';
8
+ import { collectGateStatuses } from './verify.mjs';
9
+ import { getGate, historyPath } from './state.mjs';
10
+ import { requireNamedGate } from './gates.mjs';
11
+ import { sha256File } from './fsutil.mjs';
12
+ import { specPath, sbfPath } from './paths.mjs';
13
+ import { crossFeatureReportPath, crossFeatureResolutionPath } from './cross-feature-collisions.mjs';
14
+ import { dependenciesPath } from './field-dependencies.mjs';
15
+ import { impactBaselinePath } from './impact-surface.mjs';
16
+ import { impactReportPath, impactResolutionPath } from './impact.mjs';
17
+ import { manifestPath } from './handles-manifest.mjs';
18
+ import { currentBranch, headSha, headTreeSha, worktreeStatus } from './repo.mjs';
19
+ import { validateAgainstSchema } from './schema-validate.mjs';
20
+ import { CANONICALIZATION_ID } from './attest.mjs';
21
+
22
+ export const EXPORT_SCHEMA_VERSION = 'sbf.gate-export/3';
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const SKILL_ROOT = path.resolve(__dirname, '..');
26
+
27
+ let cachedToolVersion = null;
28
+ // D-attestation-payload-completeness (K2): backend-skeleton's OWN package.json (this tool's
29
+ // version), never the TARGET repo's -- resolved from this module's own file location, the same
30
+ // derivation bin/bskel.mjs's SKILL_ROOT already uses. Memoized: it cannot change within one
31
+ // process, and building one report already calls `sha256File`/git a dozen times.
32
+ export function toolVersion() {
33
+ if (cachedToolVersion === null) {
34
+ cachedToolVersion = JSON.parse(fs.readFileSync(path.join(SKILL_ROOT, 'package.json'), 'utf8')).version;
35
+ }
36
+ return cachedToolVersion;
37
+ }
38
+
39
+ // S4 (D-gate-history), moved here from bin/bskel.mjs so both cmdGateHistory and
40
+ // buildGateExportReport read history through one implementation instead of two copies that could
41
+ // drift. Reads the append-only .sbf/<feature>.history.jsonl -- a corrupt/invalid line is skipped
42
+ // with a warning, not a hard failure, matching JSONL's own resilience rationale (see
43
+ // lib/state.mjs's appendGateEvent).
44
+ export function readGateHistory(root, featureId, gateName, { onWarning } = {}) {
45
+ const file = historyPath(root, featureId);
46
+ if (!fs.existsSync(file)) return [];
47
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
48
+ const events = [];
49
+ for (const [i, line] of lines.entries()) {
50
+ let parsed;
51
+ try {
52
+ parsed = JSON.parse(line);
53
+ } catch {
54
+ onWarning?.(`${file}:${i + 1}: not valid JSON, skipped`);
55
+ continue;
56
+ }
57
+ const { ok, errors } = validateAgainstSchema('gate-event.schema.json', parsed);
58
+ if (!ok) {
59
+ onWarning?.(`${file}:${i + 1}: does not match schemas/gate-event.schema.json, skipped`, errors);
60
+ continue;
61
+ }
62
+ if (parsed.gate === gateName) events.push(parsed);
63
+ }
64
+ return events;
65
+ }
66
+
67
+ // D-attestation-payload-completeness (K2/K8): the ONE place an attestation-bound artifact is
68
+ // declared -- adding a future artifact means adding one line here, the same single-source-of-
69
+ // truth argument D-gate-definitions made for GATE_NAMES. Each value is (root, featureId) => an
70
+ // absolute path; `test/gate-export-report.test.mjs` asserts this key set equals
71
+ // `schemas/gate-export.schema.json`'s own `artifacts.properties` key set, so the two cannot drift.
72
+ export const ARTIFACT_SOURCES = Object.freeze({
73
+ scan_report_hash: (root, featureId) => specPath(root, featureId, 'brownfield-scan.json'),
74
+ spec_hash: (root, featureId) => specPath(root, featureId, 'spec.md'),
75
+ contract_hash: (root, featureId) => specPath(root, featureId, 'contracts', `${featureId}.schema.json`),
76
+ contract_resolution_hash: (root, featureId) => specPath(root, featureId, 'contracts', `${featureId}.resolution.json`),
77
+ openapi_snapshot_hash: (root, featureId) => specPath(root, featureId, 'contracts', `${featureId}.openapi.snapshot.json`),
78
+ cross_feature_report_hash: (root, featureId) => crossFeatureReportPath(root, featureId),
79
+ cross_feature_resolution_hash: (root, featureId) => crossFeatureResolutionPath(root, featureId),
80
+ dependencies_hash: (root, featureId) => dependenciesPath(root, featureId),
81
+ rules_source_hash: (root, featureId) => specPath(root, featureId, 'rules.yaml'),
82
+ rules_compiled_hash: (root, featureId) => specPath(root, featureId, 'rules', `${featureId}.rules.json`),
83
+ conformance_report_hash: (root, featureId) => specPath(root, featureId, 'observe', `${featureId}.conformance-report.json`),
84
+ handles_manifest_hash: (root) => manifestPath(root),
85
+ stack_record_hash: (root) => sbfPath(root, 'stack.json'),
86
+ // D-cross-feature-impact-graph (IG4): additive, sbf.gate-export/2 -> /3 -- a /2 attestation
87
+ // still verifies (K7's own promise), it just won't have these three keys.
88
+ impact_baseline_hash: (root, featureId) => impactBaselinePath(root, featureId),
89
+ impact_report_hash: (root, featureId) => impactReportPath(root, featureId),
90
+ impact_resolution_hash: (root, featureId) => impactResolutionPath(root, featureId),
91
+ });
92
+
93
+ export function collectArtifactHashes(root, featureId) {
94
+ const out = {};
95
+ for (const [key, resolvePath] of Object.entries(ARTIFACT_SOURCES)) {
96
+ out[key] = sha256File(resolvePath(root, featureId));
97
+ }
98
+ return out;
99
+ }
100
+
101
+ // D-attestation-payload-completeness (K2): forced/revoked decisions rolled up from the gate
102
+ // records already in the payload -- zero new file reads. Waivers (contract_resolution/
103
+ // cross_feature_resolution) are represented by presence+hash only, never embedded content: the
104
+ // content is already bound via `artifacts` above, and embedding it again would duplicate that
105
+ // binding while ballooning the payload for no new guarantee.
106
+ export function collectDecisions(gatesById, artifacts) {
107
+ const forced = [];
108
+ const revoked = [];
109
+ for (const [gate, entry] of Object.entries(gatesById)) {
110
+ const record = entry.current;
111
+ if (!record) continue;
112
+ if (record.forced) forced.push({ gate, scope: entry.scope, reason: record.reason ?? null, at: record.at ?? null });
113
+ if (record.status === 'revoked') revoked.push({ gate, scope: entry.scope, reason: record.reason ?? null, at: record.at ?? null });
114
+ }
115
+ return {
116
+ forced,
117
+ revoked,
118
+ waiver_files: {
119
+ contract_resolution: { present: artifacts.contract_resolution_hash !== null, hash: artifacts.contract_resolution_hash },
120
+ cross_feature_resolution: { present: artifacts.cross_feature_resolution_hash !== null, hash: artifacts.cross_feature_resolution_hash },
121
+ },
122
+ };
123
+ }
124
+
125
+ // D-attestation-payload-completeness (K2/K3): pure derivation from `live` -- what a human or CI
126
+ // actually greps for, so they read one line instead of walking every gate.
127
+ export function buildVerdict(liveEntries) {
128
+ const blocking_gates = liveEntries.filter((g) => g.blocking).map((g) => g.gate);
129
+ const passing = liveEntries.filter((g) => g.status === 'pass' || g.status === 'pass (forced)').length;
130
+ return { blocking_gates, passing, total: liveEntries.length, ok: blocking_gates.length === 0 };
131
+ }
132
+
133
+ // D-attestation-payload-completeness (K2/K3): the report `bskel gate export` builds and (with
134
+ // --sign) signs. `gates[name].live` is RECOMPUTED at export time via lib/verify.mjs's
135
+ // collectGateStatuses() -- the exact same function `bskel verify` uses -- so a gate export and
136
+ // `bskel verify` can never disagree about the same repo's current state. `gates[name].current`
137
+ // stays the raw STORED record (unchanged from schema /1), so a reader sees both "what was last
138
+ // written" and "what is honestly true right now" side by side.
139
+ export function buildGateExportReport(root, featureId, { now = new Date(), dirtyAcknowledged = false, dirtyCap = 200 } = {}) {
140
+ const liveResults = collectGateStatuses(root, featureId, { getGate, requireNamedGate });
141
+ const liveByName = new Map(liveResults.map((g) => [g.gate, g]));
142
+
143
+ const gates = {};
144
+ for (const name of GATE_NAMES) {
145
+ const live = liveByName.get(name);
146
+ // collectGateStatuses()'s own `scope` field is the gate's DEFINITION scope type
147
+ // ('repo'|'feature'), not the resolved scope id -- gateScopeId() computes the real one
148
+ // (REPO_GATE_ID for a repo-scoped gate, or featureId itself), matching cmdGateExport's own
149
+ // pre-existing lookup exactly.
150
+ const scopeId = gateScopeId(name, featureId);
151
+ gates[name] = {
152
+ scope: scopeId,
153
+ current: getGate(root, scopeId, name),
154
+ history: readGateHistory(root, scopeId, name),
155
+ live: {
156
+ policy: live.policy,
157
+ status: live.status,
158
+ blocking: live.blocking,
159
+ ran: live.ran,
160
+ current_token: live.currentToken ?? null,
161
+ stale_reason: live.stale_reason ?? null,
162
+ changed_inputs: live.changed_inputs && live.changed_inputs.length > 0 ? live.changed_inputs : null,
163
+ },
164
+ };
165
+ }
166
+
167
+ const artifacts = collectArtifactHashes(root, featureId);
168
+ const decisions = collectDecisions(gates, artifacts);
169
+ const verdict = buildVerdict(Object.entries(gates).map(([gate, g]) => ({ gate, blocking: g.live.blocking, status: g.live.status })));
170
+
171
+ const status = worktreeStatus(root, { cap: dirtyCap });
172
+ const dirty = status === null ? null : status.count > 0;
173
+
174
+ return {
175
+ schema: EXPORT_SCHEMA_VERSION,
176
+ feature_id: featureId,
177
+ generated_at: now.toISOString(),
178
+ tool: {
179
+ name: 'bskel',
180
+ version: toolVersion(),
181
+ gate_names: [...GATE_NAMES],
182
+ canonicalization: CANONICALIZATION_ID,
183
+ },
184
+ git: {
185
+ branch: currentBranch(root),
186
+ head_sha: headSha(root),
187
+ dirty,
188
+ head_tree_sha: headTreeSha(root),
189
+ dirty_acknowledged: Boolean(dirtyAcknowledged),
190
+ dirty_file_count: status?.count ?? 0,
191
+ dirty_files_truncated: status?.truncated ?? false,
192
+ dirty_files: status?.entries ?? [],
193
+ },
194
+ artifacts,
195
+ gates,
196
+ decisions,
197
+ verdict,
198
+ };
199
+ }