backend-skeleton 1.0.0-beta.8 → 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 +738 -12
- package/contracts/completeness.mjs +10 -0
- 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 +169 -2
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/field-dependencies.mjs +355 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +117 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +358 -0
- 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 +328 -0
- package/lib/workflow.mjs +39 -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/field-dependency.schema.json +49 -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 } },
|
|
@@ -228,6 +271,40 @@ export const COMMANDS = {
|
|
|
228
271
|
operation: { type: 'string', default: null, required: true },
|
|
229
272
|
},
|
|
230
273
|
},
|
|
274
|
+
'dependency declare': {
|
|
275
|
+
usage: 'bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."] [--json]',
|
|
276
|
+
options: {
|
|
277
|
+
feature: { type: 'string', default: null, required: true },
|
|
278
|
+
resource: { type: 'string', default: null, required: true },
|
|
279
|
+
field: { type: 'string', default: null, required: true },
|
|
280
|
+
'source-feature': { type: 'string', default: null, required: true },
|
|
281
|
+
'source-resource': { type: 'string', default: null, required: true },
|
|
282
|
+
'source-field': { type: 'string', default: null, required: true },
|
|
283
|
+
reason: { type: 'string', default: '' },
|
|
284
|
+
memo: { type: 'string', default: null },
|
|
285
|
+
json: { type: 'boolean', default: false },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
'dependency remove': {
|
|
289
|
+
usage: 'bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--json]',
|
|
290
|
+
options: {
|
|
291
|
+
feature: { type: 'string', default: null, required: true },
|
|
292
|
+
resource: { type: 'string', default: null, required: true },
|
|
293
|
+
field: { type: 'string', default: null, required: true },
|
|
294
|
+
'source-feature': { type: 'string', default: null, required: true },
|
|
295
|
+
'source-resource': { type: 'string', default: null, required: true },
|
|
296
|
+
'source-field': { type: 'string', default: null, required: true },
|
|
297
|
+
reason: { type: 'string', default: '' },
|
|
298
|
+
json: { type: 'boolean', default: false },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
'dependency list': {
|
|
302
|
+
usage: 'bskel dependency list --feature <id> [--json]',
|
|
303
|
+
options: {
|
|
304
|
+
feature: { type: 'string', default: null, required: true },
|
|
305
|
+
json: { type: 'boolean', default: false },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
231
308
|
'stack apply': {
|
|
232
309
|
usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--json]',
|
|
233
310
|
options: {
|
|
@@ -309,10 +386,11 @@ export const COMMANDS = {
|
|
|
309
386
|
},
|
|
310
387
|
},
|
|
311
388
|
'observe import': {
|
|
312
|
-
usage: 'bskel observe import --feature <id> --receipts <path> [--json]',
|
|
389
|
+
usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]',
|
|
313
390
|
options: {
|
|
314
391
|
feature: { type: 'string', default: null, required: true },
|
|
315
392
|
receipts: { type: 'string', default: null, required: true },
|
|
393
|
+
'fail-on-violation': { type: 'boolean', default: false },
|
|
316
394
|
json: { type: 'boolean', default: false },
|
|
317
395
|
},
|
|
318
396
|
},
|
|
@@ -376,6 +454,70 @@ export const COMMANDS = {
|
|
|
376
454
|
json: { type: 'boolean', default: false },
|
|
377
455
|
},
|
|
378
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
|
+
},
|
|
379
521
|
verify: {
|
|
380
522
|
usage: 'bskel verify --feature <id> [--build [--allow-skip-build]] [--json]',
|
|
381
523
|
options: {
|
|
@@ -409,6 +551,31 @@ export const COMMANDS = {
|
|
|
409
551
|
json: { type: 'boolean', default: false },
|
|
410
552
|
},
|
|
411
553
|
},
|
|
554
|
+
serve: {
|
|
555
|
+
usage: 'bskel serve [--port N] [--host <addr>] [--database-url-env <NAME> [--schema public] [--sign-key <path>] [--require-sign-key]] [--json]',
|
|
556
|
+
options: {
|
|
557
|
+
// min:0 (unlike stack apply --port's min:1) -- 0 is the standard "let the OS pick a free
|
|
558
|
+
// ephemeral port" sentinel, genuinely useful both for tests and for a user who doesn't care
|
|
559
|
+
// which port they get, not just a testing convenience.
|
|
560
|
+
port: { type: 'string', default: '4747', numeric: { min: 0, max: 65535 } },
|
|
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 },
|
|
576
|
+
json: { type: 'boolean', default: false },
|
|
577
|
+
},
|
|
578
|
+
},
|
|
412
579
|
};
|
|
413
580
|
|
|
414
581
|
function describeParseArgsError(err, spec) {
|
|
@@ -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
|
+
}
|