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.
- package/README.md +113 -8
- package/bin/bskel.mjs +549 -63
- package/contracts/completeness.mjs +12 -1
- package/contracts/openapi.mjs +125 -18
- package/handles/providers/java-spring/ast-bridge.mjs +85 -1
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
- package/handles/providers/java-spring/emit.mjs +126 -6
- package/handles/providers/java-spring/plan.mjs +220 -74
- package/handles/providers/java-spring/source-splice.mjs +477 -0
- package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
- package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
- package/lib/attest.mjs +59 -1
- package/lib/cli.mjs +125 -7
- package/lib/decision-log.mjs +58 -0
- package/lib/doctor.mjs +23 -0
- package/lib/exit-codes.mjs +17 -0
- package/lib/gate-definitions.mjs +65 -2
- package/lib/gate-export.mjs +250 -0
- package/lib/impact-export-graphify.mjs +145 -0
- package/lib/impact-graph.mjs +194 -0
- package/lib/impact-surface.mjs +158 -0
- package/lib/impact.mjs +334 -0
- package/lib/patch-kinds.mjs +24 -0
- package/lib/repo.mjs +46 -0
- package/lib/workflow.mjs +16 -0
- package/package.json +1 -1
- package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
- package/schemas/decision-event.schema.json +46 -0
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +606 -22
- package/schemas/handles-plan.schema.json +32 -0
- package/schemas/impact-baseline.schema.json +59 -0
- package/schemas/impact-graph.schema.json +53 -0
- package/schemas/impact-report.schema.json +86 -0
- package/schemas/impact-resolution.schema.json +33 -0
- package/schemas/java-source-splice.schema.json +84 -0
- 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
|
|
590
|
-
// by hand inside cmdPatchPropose (kind-conditional requirements aren't
|
|
591
|
-
// this table's own unconditional `required: true`), matching this file's
|
|
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));
|
package/lib/exit-codes.mjs
CHANGED
|
@@ -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
|
package/lib/gate-definitions.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|