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.
Files changed (48) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +738 -12
  3. package/contracts/completeness.mjs +10 -0
  4. package/contracts/emit.mjs +5 -1
  5. package/contracts/export.mjs +26 -3
  6. package/contracts/openapi.mjs +29 -3
  7. package/handles/_engine.mjs +79 -29
  8. package/handles/providers/java-spring/plan.mjs +22 -9
  9. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  10. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  11. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  12. package/handles/providers/typescript-express/emit.mjs +23 -23
  13. package/handles/providers/typescript-express/observe.mjs +101 -0
  14. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  15. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  16. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  17. package/lib/attest.mjs +40 -0
  18. package/lib/cli.mjs +169 -2
  19. package/lib/cross-feature-collisions.mjs +286 -0
  20. package/lib/diff.mjs +35 -0
  21. package/lib/field-dependencies.mjs +355 -0
  22. package/lib/fsutil.mjs +7 -2
  23. package/lib/gate-definitions.mjs +117 -1
  24. package/lib/gates.mjs +5 -1
  25. package/lib/http-server.mjs +358 -0
  26. package/lib/lock.mjs +68 -15
  27. package/lib/patch-kinds.mjs +52 -0
  28. package/lib/patch-transactions.mjs +206 -0
  29. package/lib/serve-ui.html +328 -0
  30. package/lib/workflow.mjs +39 -3
  31. package/package.json +5 -2
  32. package/scanners/adapters/java-spring.mjs +6 -0
  33. package/scanners/adapters/python-fastapi.mjs +9 -1
  34. package/scanners/adapters/typescript-express.mjs +6 -0
  35. package/scanners/db/ddl-apply.mjs +253 -0
  36. package/scanners/db/introspect.mjs +61 -32
  37. package/scanners/db/migrations.mjs +73 -18
  38. package/schemas/cross-feature-report.schema.json +66 -0
  39. package/schemas/cross-feature-resolution.schema.json +28 -0
  40. package/schemas/field-dependency.schema.json +49 -0
  41. package/schemas/gate-attestation.schema.json +22 -0
  42. package/schemas/gate-export.schema.json +58 -0
  43. package/schemas/patch-transaction.schema.json +182 -0
  44. package/schemas/scan-report.schema.json +6 -4
  45. package/schemas/stack-choice.schema.json +12 -1
  46. package/stack/apply.mjs +4 -1
  47. package/stack/catalog/ngrok.yml +8 -2
  48. 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
+ }