backend-skeleton 1.0.0-beta.9 → 1.0.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 +122 -12
- package/bin/bskel.mjs +564 -15
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +26 -3
- package/contracts/openapi.mjs +29 -3
- package/handles/_engine.mjs +79 -29
- package/handles/providers/java-spring/plan.mjs +22 -9
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
- package/handles/providers/typescript-express/emit.mjs +23 -23
- package/handles/providers/typescript-express/observe.mjs +101 -0
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +125 -3
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +85 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +192 -6
- package/lib/lock.mjs +68 -15
- package/lib/patch-kinds.mjs +52 -0
- package/lib/patch-transactions.mjs +206 -0
- package/lib/serve-ui.html +211 -0
- package/lib/workflow.mjs +31 -3
- package/package.json +5 -2
- package/scanners/adapters/java-spring.mjs +6 -0
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +6 -0
- package/scanners/db/ddl-apply.mjs +253 -0
- package/scanners/db/introspect.mjs +61 -32
- package/scanners/db/migrations.mjs +73 -18
- package/schemas/cross-feature-report.schema.json +66 -0
- package/schemas/cross-feature-resolution.schema.json +28 -0
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/patch-transaction.schema.json +182 -0
- package/schemas/scan-report.schema.json +6 -4
- package/schemas/stack-choice.schema.json +12 -1
- package/stack/apply.mjs +4 -1
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
package/lib/cli.mjs
CHANGED
|
@@ -85,10 +85,29 @@ export const COMMANDS = {
|
|
|
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
87
|
'gate export': {
|
|
88
|
-
usage: 'bskel gate export --feature <id> [--out <path>] [--json]',
|
|
88
|
+
usage: 'bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]',
|
|
89
89
|
options: {
|
|
90
90
|
feature: { type: 'string', default: null, required: true },
|
|
91
91
|
out: { type: 'string', default: null },
|
|
92
|
+
sign: { type: 'boolean', default: false },
|
|
93
|
+
key: { type: 'string', default: null },
|
|
94
|
+
json: { type: 'boolean', default: false },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
// D-gate-attestation-signing.
|
|
98
|
+
'attest keygen': {
|
|
99
|
+
usage: 'bskel attest keygen --out <dir> [--force] [--json]',
|
|
100
|
+
options: {
|
|
101
|
+
out: { type: 'string', default: null, required: true },
|
|
102
|
+
force: { type: 'boolean', default: false },
|
|
103
|
+
json: { type: 'boolean', default: false },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
'attest verify': {
|
|
107
|
+
usage: 'bskel attest verify --file <path> --pubkey <path> [--json]',
|
|
108
|
+
options: {
|
|
109
|
+
file: { type: 'string', default: null, required: true },
|
|
110
|
+
pubkey: { type: 'string', default: null, required: true },
|
|
92
111
|
json: { type: 'boolean', default: false },
|
|
93
112
|
},
|
|
94
113
|
},
|
|
@@ -129,6 +148,30 @@ export const COMMANDS = {
|
|
|
129
148
|
},
|
|
130
149
|
allowPositionals: true,
|
|
131
150
|
},
|
|
151
|
+
'scan cross-feature-check': {
|
|
152
|
+
usage: 'bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]',
|
|
153
|
+
options: {
|
|
154
|
+
feature: { type: 'string', default: null, required: true },
|
|
155
|
+
// D-cross-feature-fk-inference: byte-identical shape to `scan`'s own --db/--database-url-env/
|
|
156
|
+
// --schema (resolved via the SAME resolveDbSchemaOrExit() helper) -- omitting them entirely
|
|
157
|
+
// is the exact prior behavior, unchanged.
|
|
158
|
+
db: { type: 'boolean', default: false },
|
|
159
|
+
'database-url-env': { type: 'string', default: null },
|
|
160
|
+
schema: { type: 'string', default: 'public' },
|
|
161
|
+
json: { type: 'boolean', default: false },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
'scan cross-feature-waive': {
|
|
165
|
+
usage: 'bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..." [--json]',
|
|
166
|
+
options: {
|
|
167
|
+
feature: { type: 'string', default: null, required: true },
|
|
168
|
+
signal: { type: 'string', default: null, required: true },
|
|
169
|
+
identifier: { type: 'string', default: null, required: true },
|
|
170
|
+
'other-feature': { type: 'string', default: null, required: true },
|
|
171
|
+
reason: { type: 'string', default: '' },
|
|
172
|
+
json: { type: 'boolean', default: false },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
132
175
|
'feature init': {
|
|
133
176
|
usage: 'bskel feature init --slug <name>',
|
|
134
177
|
options: { slug: { type: 'string', default: null, required: true } },
|
|
@@ -343,10 +386,11 @@ export const COMMANDS = {
|
|
|
343
386
|
},
|
|
344
387
|
},
|
|
345
388
|
'observe import': {
|
|
346
|
-
usage: 'bskel observe import --feature <id> --receipts <path> [--json]',
|
|
389
|
+
usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]',
|
|
347
390
|
options: {
|
|
348
391
|
feature: { type: 'string', default: null, required: true },
|
|
349
392
|
receipts: { type: 'string', default: null, required: true },
|
|
393
|
+
'fail-on-violation': { type: 'boolean', default: false },
|
|
350
394
|
json: { type: 'boolean', default: false },
|
|
351
395
|
},
|
|
352
396
|
},
|
|
@@ -410,6 +454,70 @@ export const COMMANDS = {
|
|
|
410
454
|
json: { type: 'boolean', default: false },
|
|
411
455
|
},
|
|
412
456
|
},
|
|
457
|
+
// D-patch-transactions: content-addressed patch transactions, Slice 1 (config_check ->
|
|
458
|
+
// config_apply). `propose`/`approve` only touch specs/, so no --force escape exists on either --
|
|
459
|
+
// re-propose is the only remediation for a stale target. `rollback` alone gets --force (reverting
|
|
460
|
+
// to a known-good, git-recoverable prior state is materially lower-risk than forcing a forward
|
|
461
|
+
// edit whose collateral effects were never re-verified).
|
|
462
|
+
'patch propose': {
|
|
463
|
+
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]',
|
|
464
|
+
options: {
|
|
465
|
+
feature: { type: 'string', default: null, required: true },
|
|
466
|
+
// D-ddl-apply: default 'config-apply' -- omitting --kind entirely is byte-identical to
|
|
467
|
+
// this project's prior behavior. choice/target/database-url-env/sql-file are validated
|
|
468
|
+
// by hand inside cmdPatchPropose (kind-conditional requirements aren't expressible via
|
|
469
|
+
// this table's own unconditional `required: true`), matching this file's existing
|
|
470
|
+
// convention for kind-conditional flags (e.g. --reason on approve/rollback).
|
|
471
|
+
kind: { type: 'string', default: 'config-apply' },
|
|
472
|
+
choice: { type: 'string', default: null },
|
|
473
|
+
target: { type: 'string', default: null },
|
|
474
|
+
'database-url-env': { type: 'string', default: null },
|
|
475
|
+
schema: { type: 'string', default: 'public' },
|
|
476
|
+
'sql-file': { type: 'string', default: null },
|
|
477
|
+
json: { type: 'boolean', default: false },
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
'patch approve': {
|
|
481
|
+
usage: 'bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]',
|
|
482
|
+
options: {
|
|
483
|
+
feature: { type: 'string', default: null, required: true },
|
|
484
|
+
transaction: { type: 'string', default: null, required: true },
|
|
485
|
+
reason: { type: 'string', default: '' },
|
|
486
|
+
json: { type: 'boolean', default: false },
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
'patch apply': {
|
|
490
|
+
usage: 'bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]',
|
|
491
|
+
options: {
|
|
492
|
+
feature: { type: 'string', default: null, required: true },
|
|
493
|
+
transaction: { type: 'string', default: null, required: true },
|
|
494
|
+
// D-ddl-apply: required for any kind other than config-apply -- what value it must
|
|
495
|
+
// exactly equal is kind- AND transaction-specific (getPatchKind(kind).requiredConfirmValue(txn)):
|
|
496
|
+
// the transaction id for a non-drop ddl-apply transaction, or the sorted, comma-joined
|
|
497
|
+
// dropped-table name(s) for one that drops a table. Checked by hand inside cmdPatchApply
|
|
498
|
+
// once the transaction's own kind/shape is known, not declaratively here. Ignored for
|
|
499
|
+
// config-apply.
|
|
500
|
+
confirm: { type: 'string', default: null },
|
|
501
|
+
json: { type: 'boolean', default: false },
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
'patch rollback': {
|
|
505
|
+
usage: 'bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]',
|
|
506
|
+
options: {
|
|
507
|
+
feature: { type: 'string', default: null, required: true },
|
|
508
|
+
transaction: { type: 'string', default: null, required: true },
|
|
509
|
+
reason: { type: 'string', default: '' },
|
|
510
|
+
force: { type: 'boolean', default: false },
|
|
511
|
+
json: { type: 'boolean', default: false },
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
'patch list': {
|
|
515
|
+
usage: 'bskel patch list --feature <id> [--json]',
|
|
516
|
+
options: {
|
|
517
|
+
feature: { type: 'string', default: null, required: true },
|
|
518
|
+
json: { type: 'boolean', default: false },
|
|
519
|
+
},
|
|
520
|
+
},
|
|
413
521
|
verify: {
|
|
414
522
|
usage: 'bskel verify --feature <id> [--build [--allow-skip-build]] [--json]',
|
|
415
523
|
options: {
|
|
@@ -444,13 +552,27 @@ export const COMMANDS = {
|
|
|
444
552
|
},
|
|
445
553
|
},
|
|
446
554
|
serve: {
|
|
447
|
-
usage: 'bskel serve [--port N] [--host <addr>] [--json]',
|
|
555
|
+
usage: 'bskel serve [--port N] [--host <addr>] [--database-url-env <NAME> [--schema public] [--sign-key <path>] [--require-sign-key]] [--json]',
|
|
448
556
|
options: {
|
|
449
557
|
// min:0 (unlike stack apply --port's min:1) -- 0 is the standard "let the OS pick a free
|
|
450
558
|
// ephemeral port" sentinel, genuinely useful both for tests and for a user who doesn't care
|
|
451
559
|
// which port they get, not just a testing convenience.
|
|
452
560
|
port: { type: 'string', default: '4747', numeric: { min: 0, max: 65535 } },
|
|
453
561
|
host: { type: 'string', default: '127.0.0.1' },
|
|
562
|
+
// D-ddl-apply: every new DB-schema/patch-transaction route is gated behind this one flag
|
|
563
|
+
// being present -- a plain `bskel serve` (no --database-url-env) stays byte-identical to
|
|
564
|
+
// today, those paths simply don't exist (404, not 403), mirroring --host's own "safe
|
|
565
|
+
// default, explicit override" convention. --sign-key is optional even when the DB surface
|
|
566
|
+
// is enabled (see D-ddl-apply's "detect and warn, never hard-require" signing posture).
|
|
567
|
+
'database-url-env': { type: 'string', default: null },
|
|
568
|
+
schema: { type: 'string', default: 'public' },
|
|
569
|
+
'sign-key': { type: 'string', default: null },
|
|
570
|
+
// D-ddl-apply: opt-in-to-MORE-strictness -- refuses to even start the DDL surface without
|
|
571
|
+
// --sign-key also given, closing this feature's own named "mandatory signing... cheap,
|
|
572
|
+
// well-justified near-term addition" EXIT item. A no-op when --database-url-env wasn't
|
|
573
|
+
// given at all (nothing to enforce on a surface that isn't running), same as --schema/
|
|
574
|
+
// --sign-key themselves already being inert outside that case.
|
|
575
|
+
'require-sign-key': { type: 'boolean', default: false },
|
|
454
576
|
json: { type: 'boolean', default: false },
|
|
455
577
|
},
|
|
456
578
|
},
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// D-cross-feature-collision: detects NAME-identity collisions between features -- two DIFFERENT
|
|
2
|
+
// features whose scan reports declare the same resourceType/DTO className, the same DB table name,
|
|
3
|
+
// or the same contract operationId. Originally NOT a dependency-direction or FK claim (see
|
|
4
|
+
// lib/field-dependencies.mjs's own declared-dependency system for that, a separate, complementary
|
|
5
|
+
// concern) -- it existed because the runtime handle-resolver dispatch system (java-spring/python-
|
|
6
|
+
// fastapi/typescript-express, all three) already implicitly assumes resourceType is unique across
|
|
7
|
+
// a whole target repo, and nothing detected or protected against that assumption being silently
|
|
8
|
+
// violated before this. See DECISIONS.md for the full design and the real collision this was found
|
|
9
|
+
// against.
|
|
10
|
+
//
|
|
11
|
+
// D-cross-feature-fk-inference: closed this entry's own named EXIT item -- a 4th signal,
|
|
12
|
+
// `db_foreign_key`, correlates a REAL live Postgres foreign-key edge (Plane C,
|
|
13
|
+
// scanners/db/introspect.mjs) against which feature declares each side's table, reusing this same
|
|
14
|
+
// per-other-feature loop (ownClasses/otherClasses) rather than a new subsystem or a persisted
|
|
15
|
+
// "table -> feature" index. See resolveLiveTables()/findCollisions() below and DECISIONS.md.
|
|
16
|
+
import { readJsonIfExists, writeFileAtomic } from './fsutil.mjs';
|
|
17
|
+
import { specPath } from './paths.mjs';
|
|
18
|
+
import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
|
|
19
|
+
import { listFeatures } from './featurelifecycle.mjs';
|
|
20
|
+
|
|
21
|
+
const REPORT_SCHEMA = 'sbf.cross-feature-report/1';
|
|
22
|
+
const RESOLUTION_SCHEMA = 'sbf.cross-feature-resolution/1';
|
|
23
|
+
|
|
24
|
+
export function crossFeatureReportPath(root, featureId) {
|
|
25
|
+
return specPath(root, featureId, 'cross-feature-report.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function crossFeatureResolutionPath(root, featureId) {
|
|
29
|
+
return specPath(root, featureId, 'cross-feature-resolution.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Reads the PERSISTED report from the last `bskel scan cross-feature-check` run -- `bskel scan
|
|
33
|
+
// cross-feature-waive` validates against this snapshot, never a live re-computation, matching
|
|
34
|
+
// `contract waive`'s own established precedent (contracts/completeness.mjs's loadContract): a
|
|
35
|
+
// waiver targets what was actually reported, and if reality has moved since, the gate's own
|
|
36
|
+
// staleness token (which already covers every OTHER feature named in this same report) is what
|
|
37
|
+
// surfaces that, not a silent re-check inside the waive command itself.
|
|
38
|
+
export function loadCrossFeatureReport(root, featureId) {
|
|
39
|
+
return readJsonIfExists(crossFeatureReportPath(root, featureId));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadCrossFeatureResolution(root, featureId) {
|
|
43
|
+
const path = crossFeatureResolutionPath(root, featureId);
|
|
44
|
+
const parsed = readJsonIfExists(path);
|
|
45
|
+
if (parsed === null) return { schema: RESOLUTION_SCHEMA, feature_id: featureId, waivers: [] };
|
|
46
|
+
const { ok, errors } = validateAgainstSchema('cross-feature-resolution.schema.json', parsed);
|
|
47
|
+
if (!ok) {
|
|
48
|
+
throw new Error(`${path}: does not match schemas/cross-feature-resolution.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function saveCrossFeatureResolution(root, featureId, resolution) {
|
|
54
|
+
const { ok, errors } = validateAgainstSchema('cross-feature-resolution.schema.json', resolution);
|
|
55
|
+
if (!ok) {
|
|
56
|
+
throw new Error(`refusing to write an invalid cross-feature resolution for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
57
|
+
}
|
|
58
|
+
writeFileAtomic(crossFeatureResolutionPath(root, featureId), `${JSON.stringify(resolution, null, 2)}\n`);
|
|
59
|
+
return resolution;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The waiver key -- deliberately signal+identifier+other_feature only, never a message/reason, so
|
|
63
|
+
// rephrasing a --reason later never silently stops a waiver from matching (same discipline
|
|
64
|
+
// contracts/completeness.mjs's own warningKey() already established).
|
|
65
|
+
export function waiverKey(w) {
|
|
66
|
+
return `${w.signal}::${w.identifier}::${w.other_feature}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Own-module accessors -- reads THIS feature's own disposed module (className list + table names),
|
|
70
|
+
// mirroring resolveClassFile()'s own lookup shape (lib/field-dependencies.mjs) but returning every
|
|
71
|
+
// candidate at once instead of resolving one resourceType.
|
|
72
|
+
function ownDisposedModule(root, featureId) {
|
|
73
|
+
const report = readJsonIfExists(specPath(root, featureId, 'brownfield-scan.json'));
|
|
74
|
+
if (!report) return null;
|
|
75
|
+
const moduleName = report.disposition?.module ?? report.related_modules?.[0]?.module;
|
|
76
|
+
if (!moduleName) return null;
|
|
77
|
+
return report.related_modules?.find((m) => m.module === moduleName) ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ownOperationIds(root, featureId) {
|
|
81
|
+
const contract = readJsonIfExists(specPath(root, featureId, 'contracts', `${featureId}.schema.json`));
|
|
82
|
+
return contract ? Object.keys(contract.operations ?? {}) : [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// D-cross-feature-fk-inference (Plane A FK extraction): Plane A is strictly LOWER priority than
|
|
86
|
+
// any Plane C (live/persisted) source -- a migration FILE existing is not proof it was ever
|
|
87
|
+
// actually applied (D-migration-scope's own standing caveat), so Plane C is always more
|
|
88
|
+
// trustworthy when available. Migration files are repo-wide, not feature-scoped, so any ONE
|
|
89
|
+
// feature's persisted db_schema.migrations carries the same content as any other's -- "first one
|
|
90
|
+
// found" is sufficient, matching the persisted-live tier's own "first found" logic exactly.
|
|
91
|
+
function migrationsTables(dbSchema) {
|
|
92
|
+
return dbSchema?.migrations?.tool && dbSchema.migrations.tool !== 'none' ? dbSchema.migrations.tables : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// D-cross-feature-fk-inference: resolves ONE usable live-table snapshot to correlate FK edges
|
|
96
|
+
// against, in four tiers (live > this feature's own persisted live > another feature's persisted
|
|
97
|
+
// live > migration-file-derived, Plane A). `liveDbSchema` is whatever the CLI boundary already
|
|
98
|
+
// resolved (the SAME resolveDbSchemaOrExit() result `cmdScan` itself uses -- this function never
|
|
99
|
+
// opens a connection itself). Plane C is already schema-wide (every table in `--schema`, not
|
|
100
|
+
// filtered to any one feature), so there is nothing to MERGE across features' own snapshots --
|
|
101
|
+
// locating one usable snapshot is enough; it already contains every table in that schema.
|
|
102
|
+
function resolveLiveTables(root, featureId, liveDbSchema) {
|
|
103
|
+
// D-cross-feature-fk-inference (staleness/freshness token): `generated_at` threaded into
|
|
104
|
+
// `fk_check` at every tier below, read from whichever source that tier actually used --
|
|
105
|
+
// `introspectWithClient()`/`scanMigrations()` both stamp it at the real point of capture, this
|
|
106
|
+
// function only ever passes it through, never invents or re-derives it.
|
|
107
|
+
if (liveDbSchema?.live) {
|
|
108
|
+
return { tables: liveDbSchema.live.tables, fk_check: { mode: 'live', schema: liveDbSchema.live.schema, source_feature: null, generated_at: liveDbSchema.live.generated_at ?? null } };
|
|
109
|
+
}
|
|
110
|
+
const ownReport = readJsonIfExists(specPath(root, featureId, 'brownfield-scan.json'));
|
|
111
|
+
if (ownReport?.db_schema?.live) {
|
|
112
|
+
return { tables: ownReport.db_schema.live.tables, fk_check: { mode: 'persisted', schema: ownReport.db_schema.live.schema, source_feature: featureId, generated_at: ownReport.db_schema.live.generated_at ?? null } };
|
|
113
|
+
}
|
|
114
|
+
// Deterministic by feature_id sort order -- listFeatures() itself already returns records
|
|
115
|
+
// sorted by directory name, so "first other feature with a persisted live snapshot" is a
|
|
116
|
+
// stable, repeatable choice, not an arbitrary one. Reused below for the migrations tier too.
|
|
117
|
+
const otherFeatures = listFeatures(root).filter((record) => record.feature_id !== featureId);
|
|
118
|
+
for (const record of otherFeatures) {
|
|
119
|
+
const otherReport = readJsonIfExists(specPath(root, record.feature_id, 'brownfield-scan.json'));
|
|
120
|
+
if (otherReport?.db_schema?.live) {
|
|
121
|
+
return { tables: otherReport.db_schema.live.tables, fk_check: { mode: 'persisted', schema: otherReport.db_schema.live.schema, source_feature: record.feature_id, generated_at: otherReport.db_schema.live.generated_at ?? null } };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (migrationsTables(liveDbSchema)) {
|
|
126
|
+
return { tables: migrationsTables(liveDbSchema), fk_check: { mode: 'migrations', schema: null, source_feature: null, generated_at: liveDbSchema.migrations.generated_at ?? null } };
|
|
127
|
+
}
|
|
128
|
+
if (migrationsTables(ownReport?.db_schema)) {
|
|
129
|
+
return { tables: migrationsTables(ownReport.db_schema), fk_check: { mode: 'migrations', schema: null, source_feature: featureId, generated_at: ownReport.db_schema.migrations.generated_at ?? null } };
|
|
130
|
+
}
|
|
131
|
+
for (const record of otherFeatures) {
|
|
132
|
+
const otherReport = readJsonIfExists(specPath(root, record.feature_id, 'brownfield-scan.json'));
|
|
133
|
+
if (migrationsTables(otherReport?.db_schema)) {
|
|
134
|
+
return { tables: migrationsTables(otherReport.db_schema), fk_check: { mode: 'migrations', schema: null, source_feature: record.feature_id, generated_at: otherReport.db_schema.migrations.generated_at ?? null } };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { tables: null, fk_check: { mode: 'unavailable', schema: null, source_feature: null, generated_at: null } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Flattens Plane C's PER-TABLE foreign_keys[] (scanners/db/introspect.mjs) into one flat edge list
|
|
142
|
+
// -- {table, column, references_table, references_column}, `table` being the CONSTRAINED
|
|
143
|
+
// (child/referencing) side.
|
|
144
|
+
function flattenLiveForeignKeys(liveTables) {
|
|
145
|
+
const edges = [];
|
|
146
|
+
for (const t of liveTables) {
|
|
147
|
+
for (const fk of t.foreign_keys ?? []) {
|
|
148
|
+
edges.push({ table: t.name, column: fk.column, references_table: fk.references_table, references_column: fk.references_column });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return edges;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// D-cross-feature-collision: the core comparison -- for the GIVEN feature's own disposed module,
|
|
155
|
+
// checks every OTHER active feature (listFeatures() excludes archived by default, same reasoning
|
|
156
|
+
// D-dependency-propagation-notice's own listDownstreamDependents() already established: an archived
|
|
157
|
+
// feature's naming collision isn't worth blocking a human over) for a resourceType/table/operationId
|
|
158
|
+
// match. One level only, symmetric-but-independent per feature -- feature B finding a collision
|
|
159
|
+
// against feature A does not automatically waive feature A's own, separate check against B (each
|
|
160
|
+
// feature's own cross-feature-resolution.json is its own record, matching contract waive's own
|
|
161
|
+
// per-feature-file precedent).
|
|
162
|
+
//
|
|
163
|
+
// D-cross-feature-fk-inference: `liveDbSchema` (optional, `null` by default -- callers that never
|
|
164
|
+
// pass it get the exact prior behavior for the first 3 signals, plus a `fk_check: {mode:
|
|
165
|
+
// 'unavailable', ...}` and possibly one `unknowns` entry) is the ALREADY-RESOLVED
|
|
166
|
+
// resolveDbSchemaOrExit() result. Returns {findings, fk_check, unknowns} -- a superset of the old
|
|
167
|
+
// bare array, not a rename; findings for the existing 3 signals are byte-identical to before.
|
|
168
|
+
export function findCollisions(root, featureId, { liveDbSchema = null } = {}) {
|
|
169
|
+
const ownModule = ownDisposedModule(root, featureId);
|
|
170
|
+
const ownClasses = ownModule ? [...(ownModule.entities ?? []), ...(ownModule.dtos ?? [])] : [];
|
|
171
|
+
const ownOperationIdSet = new Set(ownOperationIds(root, featureId));
|
|
172
|
+
|
|
173
|
+
const { tables: liveTables, fk_check } = resolveLiveTables(root, featureId, liveDbSchema);
|
|
174
|
+
const liveEdges = liveTables ? flattenLiveForeignKeys(liveTables) : [];
|
|
175
|
+
// This feature's own table -> entity map (case-folded), matching computeDbDrift()'s own
|
|
176
|
+
// established case-folding convention (scanners/index.mjs).
|
|
177
|
+
const ownTablesByName = new Map(ownClasses.filter((c) => c.table).map((c) => [c.table.toLowerCase(), c]));
|
|
178
|
+
// Every table name (own + every OTHER feature seen) that matched SOME feature, accumulated
|
|
179
|
+
// across the whole loop below -- used only to report FK edges touching an UNattributed table
|
|
180
|
+
// (see the unknowns pass after the loop). In-memory, local to this one call -- not a new
|
|
181
|
+
// persisted "table -> feature" index.
|
|
182
|
+
const matchedTableNames = new Set(ownTablesByName.keys());
|
|
183
|
+
|
|
184
|
+
const findings = [];
|
|
185
|
+
for (const record of listFeatures(root)) {
|
|
186
|
+
if (record.feature_id === featureId) continue;
|
|
187
|
+
const otherModule = ownDisposedModule(root, record.feature_id);
|
|
188
|
+
const otherClasses = otherModule ? [...(otherModule.entities ?? []), ...(otherModule.dtos ?? [])] : [];
|
|
189
|
+
const otherTablesByName = new Map(otherClasses.filter((c) => c.table).map((c) => [c.table.toLowerCase(), c]));
|
|
190
|
+
for (const name of otherTablesByName.keys()) matchedTableNames.add(name);
|
|
191
|
+
|
|
192
|
+
for (const ownClass of ownClasses) {
|
|
193
|
+
const match = otherClasses.find((c) => c.className === ownClass.className);
|
|
194
|
+
if (match) {
|
|
195
|
+
findings.push({ signal: 'resource_type', identifier: ownClass.className, other_feature: record.feature_id, confidence: 'high' });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Table names: only compared when BOTH sides actually have one (a class with no table --
|
|
200
|
+
// e.g. a DTO, or an entity with no @Table and no fallback -- has nothing to collide on).
|
|
201
|
+
// Case-folded for comparison, matching computeDbDrift()'s own established convention
|
|
202
|
+
// (scanners/index.mjs) -- the ONE place in this codebase that already case-folds a `.table`
|
|
203
|
+
// value before comparing it.
|
|
204
|
+
for (const ownEntity of ownClasses.filter((c) => c.table)) {
|
|
205
|
+
const match = otherClasses.find((c) => c.table && c.table.toLowerCase() === ownEntity.table.toLowerCase());
|
|
206
|
+
if (match) {
|
|
207
|
+
const confidence = ownEntity.tableSource === 'explicit' && match.tableSource === 'explicit' ? 'high' : 'medium';
|
|
208
|
+
findings.push({ signal: 'table', identifier: ownEntity.table.toLowerCase(), other_feature: record.feature_id, confidence });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const otherOperationIds = ownOperationIds(root, record.feature_id);
|
|
213
|
+
for (const opId of ownOperationIdSet) {
|
|
214
|
+
if (otherOperationIds.includes(opId)) {
|
|
215
|
+
findings.push({ signal: 'operation_id', identifier: opId, other_feature: record.feature_id, confidence: 'high' });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// D-cross-feature-fk-inference: a real, live FK edge where ONE side is a table THIS
|
|
220
|
+
// feature declares and the OTHER side is a table `record` declares. Confidence reuses the
|
|
221
|
+
// exact `table` signal's own tableSource rule -- the FK itself is never in doubt (a live,
|
|
222
|
+
// Postgres-enforced constraint), what's uncertain is whether the source-derived table name
|
|
223
|
+
// on each side was a real annotation or an adapter's guessed fallback, the SAME risk the
|
|
224
|
+
// `table` signal already scores this way. Self-referencing/same-feature edges never reach
|
|
225
|
+
// here at all -- this only ever compares ownClasses against a DIFFERENT feature's classes.
|
|
226
|
+
for (const edge of liveEdges) {
|
|
227
|
+
const childName = edge.table.toLowerCase();
|
|
228
|
+
const parentName = edge.references_table.toLowerCase();
|
|
229
|
+
const identifier = `${edge.table}.${edge.column} -> ${edge.references_table}.${edge.references_column}`;
|
|
230
|
+
|
|
231
|
+
if (ownTablesByName.has(childName) && otherTablesByName.has(parentName)) {
|
|
232
|
+
const ownEntity = ownTablesByName.get(childName);
|
|
233
|
+
const otherEntity = otherTablesByName.get(parentName);
|
|
234
|
+
const confidence = ownEntity.tableSource === 'explicit' && otherEntity.tableSource === 'explicit' ? 'high' : 'medium';
|
|
235
|
+
findings.push({ signal: 'db_foreign_key', identifier, other_feature: record.feature_id, confidence, direction: 'references' });
|
|
236
|
+
}
|
|
237
|
+
if (ownTablesByName.has(parentName) && otherTablesByName.has(childName)) {
|
|
238
|
+
const ownEntity = ownTablesByName.get(parentName);
|
|
239
|
+
const otherEntity = otherTablesByName.get(childName);
|
|
240
|
+
const confidence = ownEntity.tableSource === 'explicit' && otherEntity.tableSource === 'explicit' ? 'high' : 'medium';
|
|
241
|
+
findings.push({ signal: 'db_foreign_key', identifier, other_feature: record.feature_id, confidence, direction: 'referenced_by' });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// D-cross-feature-fk-inference: honest, explicit reporting for the two "no useful FK signal"
|
|
247
|
+
// cases -- never a silent gap, matching this project's own repeated "no silent caps" discipline
|
|
248
|
+
// (see D-db-schema-plane's own unknowns precedent for the same reasoning on a different check).
|
|
249
|
+
const unknowns = [];
|
|
250
|
+
if (liveTables) {
|
|
251
|
+
for (const edge of liveEdges) {
|
|
252
|
+
const childName = edge.table.toLowerCase();
|
|
253
|
+
const parentName = edge.references_table.toLowerCase();
|
|
254
|
+
const identifier = `${edge.table}.${edge.column} -> ${edge.references_table}.${edge.references_column}`;
|
|
255
|
+
if (ownTablesByName.has(childName) && !matchedTableNames.has(parentName)) {
|
|
256
|
+
unknowns.push(`FK ${identifier}: referenced table "${edge.references_table}" is not declared by any active feature (untracked/external table)`);
|
|
257
|
+
} else if (ownTablesByName.has(parentName) && !matchedTableNames.has(childName)) {
|
|
258
|
+
unknowns.push(`FK ${identifier}: referencing table "${edge.table}" is not declared by any active feature (untracked/external table)`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
unknowns.push('no live DB foreign-key data available to check -- pass --db --database-url-env <NAME> to `bskel scan cross-feature-check`, or run `bskel scan --feature <id> --db --database-url-env <NAME>` at least once to persist a snapshot this check can reuse');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { findings, fk_check, unknowns };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Mirrors contracts/completeness.mjs's own evaluateResolution() exactly, for a different axis:
|
|
269
|
+
// there, the split is by warning SEVERITY (error vs warn); here, it's by finding CONFIDENCE (high
|
|
270
|
+
// vs medium) -- only a `high`-confidence, unwaived finding blocks. A `medium`-confidence finding
|
|
271
|
+
// (an inferred/guessed table name on at least one side) is always reported, never blocking on its
|
|
272
|
+
// own -- the named mitigation for python-fastapi/typescript-express's own table-name-guessing false-
|
|
273
|
+
// match risk (see DECISIONS.md), not a silently dropped signal.
|
|
274
|
+
export function evaluateCrossFeatureFindings(findings, resolution) {
|
|
275
|
+
const waivers = resolution.waivers ?? [];
|
|
276
|
+
const waivedKeys = new Set(waivers.map(waiverKey));
|
|
277
|
+
|
|
278
|
+
const highConfidence = findings.filter((f) => f.confidence === 'high');
|
|
279
|
+
const unwaived = highConfidence.filter((f) => !waivedKeys.has(waiverKey(f)));
|
|
280
|
+
const waived = highConfidence.filter((f) => waivedKeys.has(waiverKey(f)));
|
|
281
|
+
|
|
282
|
+
const currentKeys = new Set(findings.map(waiverKey));
|
|
283
|
+
const staleWaivers = waivers.filter((w) => !currentKeys.has(waiverKey(w)));
|
|
284
|
+
|
|
285
|
+
return { blocking: unwaived.length > 0, unwaived, waived, staleWaivers };
|
|
286
|
+
}
|
package/lib/diff.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// D-patch-transactions: promoted verbatim from handles/_engine.mjs (D4/D-handles-dryrun's own
|
|
2
|
+
// unifiedDiff()) -- pure code motion, zero behavior change. Generic enough that stack/config-apply.
|
|
3
|
+
// mjs needs the identical mechanism for its own collateral-diff safety gate, and there was no
|
|
4
|
+
// existing stack <-> handles import in either direction to introduce by leaving it where it was.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
// D4 (D-handles-dryrun): a real unified diff via `git diff --no-index`, not a hand-rolled diff
|
|
11
|
+
// algorithm -- `git` is already a hard dependency elsewhere in this codebase, so this adds zero
|
|
12
|
+
// new dependencies. `cwd: tmpDir` + relative `a/<relPath>`/`b/<relPath>` paths (rather than
|
|
13
|
+
// absolute temp paths) keep the diff header clean and reproducible -- the random tmpdir name
|
|
14
|
+
// never leaks into the output. `git diff --no-index` exits 1 when the two sides differ (the
|
|
15
|
+
// expected, common case here, not a failure) -- only a status other than 0/1 is a genuine error
|
|
16
|
+
// worth throwing.
|
|
17
|
+
export function unifiedDiff(relPath, before, after) {
|
|
18
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bskel-diff-'));
|
|
19
|
+
try {
|
|
20
|
+
const beforeAbs = path.join(tmpDir, 'a', relPath);
|
|
21
|
+
const afterAbs = path.join(tmpDir, 'b', relPath);
|
|
22
|
+
fs.mkdirSync(path.dirname(beforeAbs), { recursive: true });
|
|
23
|
+
fs.mkdirSync(path.dirname(afterAbs), { recursive: true });
|
|
24
|
+
fs.writeFileSync(beforeAbs, before ?? '');
|
|
25
|
+
fs.writeFileSync(afterAbs, after ?? '');
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync('git', ['diff', '--no-index', '--no-color', '--', `a/${relPath}`, `b/${relPath}`], { cwd: tmpDir, encoding: 'utf8' });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.status === 1 && typeof err.stdout === 'string') return err.stdout;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
} finally {
|
|
33
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
package/lib/fsutil.mjs
CHANGED
|
@@ -29,10 +29,15 @@ export function readJsonIfExists(filePath) {
|
|
|
29
29
|
// Atomic write (temp + rename), same technique as lib/state.mjs -- reused by every command that
|
|
30
30
|
// writes a durable artifact under specs/<feature_id>/ so a mid-write crash can't leave a
|
|
31
31
|
// half-written file that a later gate check would treat as valid.
|
|
32
|
-
|
|
32
|
+
// D-gate-attestation-signing: `mode` is additive and optional (undefined -> Node's own default
|
|
33
|
+
// mode-minus-umask, byte-for-byte the same behavior every existing caller already gets) -- added
|
|
34
|
+
// so a sensitive file (a private signing key) can be created with a restrictive mode from its
|
|
35
|
+
// very first write, rather than a separate chmod() after the fact leaving a real, if brief,
|
|
36
|
+
// window where the file exists at the default (world/group-readable) permissions.
|
|
37
|
+
export function writeFileAtomic(filePath, content, mode) {
|
|
33
38
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
34
39
|
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
35
|
-
fs.writeFileSync(tmp, content);
|
|
40
|
+
fs.writeFileSync(tmp, content, mode === undefined ? undefined : { mode });
|
|
36
41
|
fs.renameSync(tmp, filePath);
|
|
37
42
|
}
|
|
38
43
|
|
package/lib/gate-definitions.mjs
CHANGED
|
@@ -23,6 +23,8 @@ import { specPath, sbfPath } from './paths.mjs';
|
|
|
23
23
|
import { ADAPTERS, adapterById } from '../scanners/registry.mjs';
|
|
24
24
|
import { loadManifest } from './handles-manifest.mjs';
|
|
25
25
|
import { dependenciesPath, resolveClassFile } from './field-dependencies.mjs';
|
|
26
|
+
import { crossFeatureReportPath, crossFeatureResolutionPath } from './cross-feature-collisions.mjs';
|
|
27
|
+
import { listTransactions, transactionPath } from './patch-transactions.mjs';
|
|
26
28
|
|
|
27
29
|
// S2: prefix for stack's per-applied-file input keys -- lib/gates.mjs's diffInputs() compares
|
|
28
30
|
// top-level keys only, so a manifest-shaped input (one hash per applied file) has to flatten
|
|
@@ -48,6 +50,20 @@ const MODULE_FILE_PREFIX = 'module_file:';
|
|
|
48
50
|
// would otherwise just repeat the identical file hash N times with zero added diagnostic value).
|
|
49
51
|
const TARGET_FIELD_FILE_PREFIX = 'target_field_file:';
|
|
50
52
|
const SOURCE_FIELD_FILE_PREFIX = 'source_field_file:';
|
|
53
|
+
// D-cross-feature-collision: same flattened-manifest convention -- one pair of keys per OTHER
|
|
54
|
+
// feature named in the LAST persisted cross-feature-report.json's own findings (not every feature
|
|
55
|
+
// in the repo), so a change to a feature this one was never found colliding with never stales this
|
|
56
|
+
// gate. Known, accepted limitation (same class as D-field-dependency's own): a BRAND NEW feature
|
|
57
|
+
// created later with a colliding name is not caught until the next explicit re-check -- see
|
|
58
|
+
// DECISIONS.md.
|
|
59
|
+
const OTHER_FEATURE_SCAN_PREFIX = 'other_feature_scan:';
|
|
60
|
+
const OTHER_FEATURE_CONTRACT_PREFIX = 'other_feature_contract:';
|
|
61
|
+
// D-patch-transactions: same flattened-manifest convention -- one pair of keys per APPLIED
|
|
62
|
+
// transaction (never proposed/approved/rolled_back ones -- only a LIVE edit is drift-risk), so a
|
|
63
|
+
// deleted or hand-edited target file, or a hand-edited transaction record itself, is named
|
|
64
|
+
// specifically rather than reported as a generic "stale".
|
|
65
|
+
const APPLIED_TARGET_PREFIX = 'applied_target:';
|
|
66
|
+
const TRANSACTION_RECORD_PREFIX = 'transaction_record:';
|
|
51
67
|
|
|
52
68
|
// The preflight and stack gates are repo-scoped, not feature-scoped -- preflight runs before a
|
|
53
69
|
// feature_id exists at all, and a stack choice is a project-wide decision, not per-feature.
|
|
@@ -154,6 +170,39 @@ export const GATE_DEFINITIONS = Object.freeze({
|
|
|
154
170
|
return inputs;
|
|
155
171
|
},
|
|
156
172
|
},
|
|
173
|
+
// D-cross-feature-collision: covers the persisted report+resolution files' own hashes (a
|
|
174
|
+
// re-check or a new waiver invalidates the old token, same as contract's own
|
|
175
|
+
// contract_hash/resolution_hash pair), plus one pair of keys per OTHER feature the LAST
|
|
176
|
+
// persisted report actually found a collision against -- so if that other feature's own scan
|
|
177
|
+
// report or contract later changes (renaming away the collision, or introducing a new one),
|
|
178
|
+
// this gate goes stale and names exactly which other feature moved. See
|
|
179
|
+
// OTHER_FEATURE_SCAN_PREFIX's own comment for the one accepted limitation this narrowing has.
|
|
180
|
+
cross_feature: {
|
|
181
|
+
name: 'cross_feature',
|
|
182
|
+
scope: SCOPE.FEATURE,
|
|
183
|
+
verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
|
|
184
|
+
// D-cross-feature-fk-inference: the new `db_foreign_key` signal needs ZERO changes here --
|
|
185
|
+
// `otherFeatures` is derived generically from every finding's own `other_feature`, never
|
|
186
|
+
// branching on `f.signal`, so this recompute already covers the new signal automatically.
|
|
187
|
+
// It also never touches a live DB itself (pure filesystem hashing over the already-persisted
|
|
188
|
+
// report/resolution) regardless of whether that report's findings came from a fresh live
|
|
189
|
+
// connection, a persisted snapshot, or neither -- D-db-schema-plane's "no gate whose
|
|
190
|
+
// recomputation requires a live DB connection" boundary is unaffected by this signal.
|
|
191
|
+
recompute: (root, featureId) => {
|
|
192
|
+
const reportPath = crossFeatureReportPath(root, featureId);
|
|
193
|
+
const inputs = {
|
|
194
|
+
cross_feature_report_hash: sha256File(reportPath),
|
|
195
|
+
cross_feature_resolution_hash: sha256File(crossFeatureResolutionPath(root, featureId)),
|
|
196
|
+
};
|
|
197
|
+
const report = readJsonIfExists(reportPath);
|
|
198
|
+
const otherFeatures = new Set((report?.findings ?? []).map((f) => f.other_feature));
|
|
199
|
+
for (const other of otherFeatures) {
|
|
200
|
+
inputs[`${OTHER_FEATURE_SCAN_PREFIX}${other}`] = sha256File(specPath(root, other, 'brownfield-scan.json'));
|
|
201
|
+
inputs[`${OTHER_FEATURE_CONTRACT_PREFIX}${other}`] = sha256File(specPath(root, other, 'contracts', `${other}.schema.json`));
|
|
202
|
+
}
|
|
203
|
+
return inputs;
|
|
204
|
+
},
|
|
205
|
+
},
|
|
157
206
|
// The contract gate's token covers the emitted contract file's own hash (re-emitting after
|
|
158
207
|
// a re-scan invalidates it) and head_sha -- NOT the scan report's hash directly, since the
|
|
159
208
|
// contract is a derived artifact; if the scan changes but the contract hasn't been
|
|
@@ -290,6 +339,41 @@ export const GATE_DEFINITIONS = Object.freeze({
|
|
|
290
339
|
return inputs;
|
|
291
340
|
},
|
|
292
341
|
},
|
|
342
|
+
// D-patch-transactions: feature-scoped (unlike `stack` above) -- a patch transaction is
|
|
343
|
+
// proposed against one feature's own workflow, mirroring `dependencies`/`cross_feature`'s own
|
|
344
|
+
// reasoning, not a repo-wide fact like a stack choice. Deliberately does NOT reuse `stack`'s own
|
|
345
|
+
// `.sbf/stack.json` record: `cmdStackApply` treats `applied_files` as this choice's FULL
|
|
346
|
+
// desired-state file set and overwrites it wholesale on every `stack apply --apply` (confirmed
|
|
347
|
+
// live, `bin/bskel.mjs`'s own comment on that line) -- a config-applied file appended there
|
|
348
|
+
// would silently vanish from tracking on the next ordinary `stack apply`. This gate owns its
|
|
349
|
+
// own, separate record instead.
|
|
350
|
+
patch_transactions: {
|
|
351
|
+
name: 'patch_transactions',
|
|
352
|
+
scope: SCOPE.FEATURE,
|
|
353
|
+
verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
|
|
354
|
+
// D-ddl-apply: `config-apply` (a filesystem-targeting kind) still hashes both the applied
|
|
355
|
+
// target file AND the transaction record, exactly as before. `ddl-apply`'s target is a live
|
|
356
|
+
// database, not a file -- it deliberately contributes ONLY the transaction-record hash,
|
|
357
|
+
// never re-touching the live DB here. Reusing D-db-schema-plane's own already-established
|
|
358
|
+
// "a gate that can only ever be satisfied with a live DB connection is a different
|
|
359
|
+
// risk/availability class -- detect and warn, never gate on live state" precedent: staying
|
|
360
|
+
// fs-only here means this gate can never itself require DB connectivity to recompute, at
|
|
361
|
+
// the honestly-accepted cost that it cannot detect someone reverting DDL by hand outside
|
|
362
|
+
// this tool (see DECISIONS.md D-ddl-apply's EXIT list).
|
|
363
|
+
recompute: (root, featureId) => {
|
|
364
|
+
const inputs = {};
|
|
365
|
+
for (const txn of listTransactions(root, featureId)) {
|
|
366
|
+
if (txn.status !== 'applied') continue; // only a LIVE edit is drift-risk
|
|
367
|
+
if (txn.kind === 'config-apply') {
|
|
368
|
+
const abs = resolveWithinRoot(root, txn.target.file);
|
|
369
|
+
if (!abs) continue; // a path escaping the repo is not something this feature applied
|
|
370
|
+
inputs[`${APPLIED_TARGET_PREFIX}${txn.transaction_id}`] = sha256File(abs); // null == deleted -> stale
|
|
371
|
+
}
|
|
372
|
+
inputs[`${TRANSACTION_RECORD_PREFIX}${txn.transaction_id}`] = sha256File(transactionPath(root, featureId, txn.transaction_id));
|
|
373
|
+
}
|
|
374
|
+
return inputs;
|
|
375
|
+
},
|
|
376
|
+
},
|
|
293
377
|
// D-runtime-conformance-receipts: passed by `bskel observe import`, never by `observe emit`
|
|
294
378
|
// (emitting the checking infra is not evidence; importing real receipts is). Opt-in like
|
|
295
379
|
// handles/stack -- not every feature runs runtime observation. Staleness = the contract moved
|
|
@@ -315,7 +399,7 @@ export const GATE_DEFINITIONS = Object.freeze({
|
|
|
315
399
|
// test/gate-definitions.test.mjs asserts this stays exactly in sync with GATE_DEFINITIONS' own
|
|
316
400
|
// key set, so a gate added to one and not the other fails loudly instead of silently vanishing
|
|
317
401
|
// from `bskel verify` the way `stack` did before this module existed.
|
|
318
|
-
export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'contract', 'dependencies', 'handles', 'stack', 'conformance']);
|
|
402
|
+
export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'handles', 'stack', 'patch_transactions', 'conformance']);
|
|
319
403
|
|
|
320
404
|
export function getGateDefinition(name) {
|
|
321
405
|
return Object.hasOwn(GATE_DEFINITIONS, name) ? GATE_DEFINITIONS[name] : null;
|
package/lib/gates.mjs
CHANGED
|
@@ -36,7 +36,11 @@ export function computeToken(inputs) {
|
|
|
36
36
|
return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// D-gate-attestation-signing: exported once a second real consumer (lib/attest.mjs's
|
|
40
|
+
// canonicalize(), which needs the SAME deep-sort applied to an entire gate-export report, not
|
|
41
|
+
// just one gate's `inputs`) needed the identical function -- no behavior change to any existing
|
|
42
|
+
// caller in this file.
|
|
43
|
+
export function sortKeysDeep(value) {
|
|
40
44
|
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
41
45
|
if (value && typeof value === 'object') {
|
|
42
46
|
return Object.fromEntries(
|