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.
- package/README.md +113 -8
- package/bin/bskel.mjs +331 -53
- package/contracts/emit.mjs +22 -6
- 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 +244 -77
- 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/handles/providers/typescript-express/plan.mjs +15 -2
- package/lib/attest.mjs +59 -1
- package/lib/cli.mjs +79 -7
- 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 +199 -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 +286 -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 +55 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +530 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
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));
|
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;
|
|
@@ -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
|
+
}
|