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