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
@@ -0,0 +1,355 @@
1
+ // D-field-dependency: declares that a field on one feature's resource is derived from a field on
2
+ // some (possibly the same) feature's resource, tracked via the same disk-hash gate mechanism every
3
+ // other gate in this project uses. See DECISIONS.md for the full design.
4
+ //
5
+ // Zero new source-scanning logic: a resource "field" resolves to a FILE the same way
6
+ // lib/gate-definitions.mjs's `contract.recompute()` already resolves one -- via a feature's own
7
+ // persisted brownfield-scan.json `related_modules[].{entities,dtos}[]`, both already `{className,
8
+ // file}` on every adapter (D-gate-precision "Continued (part 3)", commit a8d647b). This module's
9
+ // resolveClassFile() is the ONE function both `bskel dependency declare`'s validation and the
10
+ // `dependencies` gate's recompute() call -- never two separately-maintained copies, so the token
11
+ // that gets passed and the token later required can never diverge.
12
+ import path from 'node:path';
13
+ import { readJsonIfExists, writeFileAtomic } from './fsutil.mjs';
14
+ import { specPath } from './paths.mjs';
15
+ import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
16
+ import { listFeatures, loadFeatureFile } from './featurelifecycle.mjs';
17
+ import { passNamedGate, requireNamedGate } from './gates.mjs';
18
+ import { withLockSync } from './lock.mjs';
19
+ import { requireValidFeatureId, slugWords } from './featureid.mjs';
20
+ import { EXIT_CODES } from './exit-codes.mjs';
21
+
22
+ const DEPENDENCIES_SCHEMA = 'sbf.field-dependency/1';
23
+
24
+ // D-http-serving-layer: thrown by declareDependency/removeDependency/buildDependencyListReport
25
+ // instead of calling bin/bskel.mjs's fail() (which calls process.exit() and can't be shared between
26
+ // a CLI caller and an HTTP caller). Carries both an HTTP status AND the existing CLI exit-code/reason
27
+ // vocabulary (lib/exit-codes.mjs) so a caller on either side derives its own response shape from the
28
+ // SAME thrown error, rather than the CLI/HTTP paths each re-deciding "what does this failure mean"
29
+ // independently and risking disagreement.
30
+ export class DependencyOperationError extends Error {
31
+ constructor(message, { httpStatus = 400, exitCode = EXIT_CODES.BAD_ARGS, reasonCode = 'BAD_ARGS' } = {}) {
32
+ super(message);
33
+ this.name = 'DependencyOperationError';
34
+ this.httpStatus = httpStatus;
35
+ this.exitCode = exitCode;
36
+ this.reasonCode = reasonCode;
37
+ }
38
+ }
39
+
40
+ // D-http-serving-layer: requireValidFeatureId() throws a plain Error (it's a low-level primitive
41
+ // shared by many OTHER commands too, so its own throw shape is deliberately left unchanged) -- an
42
+ // uncaught plain Error reaching lib/http-server.mjs's handler would map to a misleading 500 instead
43
+ // of the 400 a malformed feature id actually deserves. This rewraps it as a DependencyOperationError
44
+ // right at the point of use, matching this module's own consistent error vocabulary end to end.
45
+ function requireValidFeatureIdOr400(id) {
46
+ try {
47
+ requireValidFeatureId(id);
48
+ } catch (err) {
49
+ throw new DependencyOperationError(err.message, { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
50
+ }
51
+ }
52
+
53
+ // A resolveClassFile() failure, translated into the (httpStatus, exitCode, reasonCode) triple --
54
+ // 'no_scan_report' is a real prerequisite-not-established state (409/NOT_PASSED, matching the exact
55
+ // ternary bin/bskel.mjs's cmdDependencyDeclare used before this was extracted); every other reason is
56
+ // a genuine bad argument (400/BAD_ARGS).
57
+ function resolutionFailureErrorOptions(resolution) {
58
+ return resolution.reason === 'no_scan_report'
59
+ ? { httpStatus: 409, exitCode: EXIT_CODES.NOT_PASSED, reasonCode: 'MISSING_ARTIFACT' }
60
+ : { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' };
61
+ }
62
+
63
+ // D-field-dependency: shared error-message builder for resolveClassFile()'s own failure reasons --
64
+ // used by both declare (target and source resolution) so the two error paths never phrase the same
65
+ // underlying failure differently. Mirrors requireWarningCode's "known codes: ..." naming convention
66
+ // for the one case (class_not_found) where naming the real alternatives is actionable. Moved here
67
+ // (was bin/bskel.mjs) alongside declareDependency, its only caller.
68
+ export function describeResolutionFailure(featureId, resourceType, resolution) {
69
+ switch (resolution.reason) {
70
+ case 'no_scan_report':
71
+ return `no brownfield-scan.json for feature "${featureId}" -- run \`bskel scan --feature ${featureId} --terms <a,b,c>\` first`;
72
+ case 'no_disposition':
73
+ return `feature "${featureId}" has no scan disposition yet -- run \`bskel scan disposition --feature ${featureId} --mode reuse|extend|replace|parallel --note "..."\` first`;
74
+ case 'module_not_found':
75
+ return `feature "${featureId}"'s disposed module no longer appears in its own scan report -- re-run \`bskel scan\`/\`bskel scan disposition\``;
76
+ case 'class_not_found':
77
+ return `no resource type "${resourceType}" found in feature "${featureId}"'s disposed module -- known classes: ${resolution.knownClasses?.join(', ') || '(none)'}`;
78
+ default:
79
+ return `could not resolve "${resourceType}" on feature "${featureId}"`;
80
+ }
81
+ }
82
+
83
+ export function dependenciesPath(root, featureId) {
84
+ return specPath(root, featureId, 'dependencies.json');
85
+ }
86
+
87
+ export function loadFieldDependencies(root, featureId) {
88
+ const path = dependenciesPath(root, featureId);
89
+ const parsed = readJsonIfExists(path);
90
+ if (parsed === null) return { schema: DEPENDENCIES_SCHEMA, feature_id: featureId, dependencies: [] };
91
+ const { ok, errors } = validateAgainstSchema('field-dependency.schema.json', parsed);
92
+ if (!ok) {
93
+ throw new Error(`${path}: does not match schemas/field-dependency.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
94
+ }
95
+ return parsed;
96
+ }
97
+
98
+ export function saveFieldDependencies(root, featureId, doc) {
99
+ const { ok, errors } = validateAgainstSchema('field-dependency.schema.json', doc);
100
+ if (!ok) {
101
+ throw new Error(`refusing to write invalid field dependencies for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
102
+ }
103
+ writeFileAtomic(dependenciesPath(root, featureId), `${JSON.stringify(doc, null, 2)}\n`);
104
+ return doc;
105
+ }
106
+
107
+ // The one, shared identity key -- edge-level, not target-level, since a target field CAN
108
+ // legitimately have more than one source (e.g. a computed/concatenated field) -- unlike
109
+ // patch-approvals' {resource,field} key, which is 1:1 by construction.
110
+ export function dependencyKey(dep) {
111
+ return `${dep.target.resourceType}::${dep.target.fieldName}->${dep.source.feature}::${dep.source.resourceType}::${dep.source.fieldName}`;
112
+ }
113
+
114
+ // Resolves a {featureId, resourceType} pair to the real source file backing it, via that
115
+ // feature's own disposed module's entities/dtos -- exactly the lookup contract.recompute() already
116
+ // does for its own module_file: tokens, just reusable across features instead of within one.
117
+ // Deliberately excludes controllers/enums: a controller isn't "a resource with fields" in the
118
+ // relevant sense, and an enum's "fields" are its constants, a structurally different concept this
119
+ // slice doesn't address.
120
+ export function resolveClassFile(root, featureId, resourceType) {
121
+ const reportPath = specPath(root, featureId, 'brownfield-scan.json');
122
+ const report = readJsonIfExists(reportPath);
123
+ if (!report) return { file: null, reason: 'no_scan_report' };
124
+ const moduleName = report.disposition?.module ?? report.related_modules?.[0]?.module;
125
+ if (!moduleName) return { file: null, reason: 'no_disposition' };
126
+ const mod = report.related_modules?.find((m) => m.module === moduleName);
127
+ if (!mod) return { file: null, reason: 'module_not_found' };
128
+ const candidates = [...(mod.entities ?? []), ...(mod.dtos ?? [])];
129
+ const match = candidates.find((item) => item.className === resourceType);
130
+ if (!match?.file) return { file: null, reason: 'class_not_found', knownClasses: candidates.map((c) => c.className) };
131
+ return { file: match.file, reason: null };
132
+ }
133
+
134
+ // D-dependency-propagation-notice: the reverse of resolveClassFile()'s own forward lookup -- "who
135
+ // else declared a dependency ON this feature" instead of "what does this feature's dependency point
136
+ // at". Used by `contract emit`/`handles emit` (bin/bskel.mjs's describeDownstreamImpact()) to warn
137
+ // the SOURCE side that other features are relying on what it's about to re-derive. One level only --
138
+ // no recursive graph walk, matching this whole feature's own explicit non-goal of full cycle
139
+ // detection (see D-field-dependency). listFeatures() is lib/featurelifecycle.mjs's schema-validated,
140
+ // archived-filtering version (not lib/workflow.mjs's bare directory scan) -- an archived feature's
141
+ // stale dependency isn't worth nagging a human about.
142
+ export function listDownstreamDependents(root, featureId) {
143
+ const dependents = [];
144
+ for (const record of listFeatures(root)) {
145
+ if (record.feature_id === featureId) continue;
146
+ const doc = loadFieldDependencies(root, record.feature_id);
147
+ for (const dep of doc.dependencies) {
148
+ if (dep.source.feature === featureId) dependents.push({ dependentFeature: record.feature_id, dep });
149
+ }
150
+ }
151
+ return dependents;
152
+ }
153
+
154
+ // D-http-serving-layer: the mutation core of `bskel dependency declare`, extracted so `bin/bskel.mjs`'s
155
+ // cmdDependencyDeclare (CLI) and lib/http-server.mjs's POST handler call the IDENTICAL code -- never
156
+ // two separately-maintained copies of "what does declaring a dependency actually do" that could
157
+ // drift, the same principle resolveClassFile()'s own header comment already establishes for itself.
158
+ // Throws DependencyOperationError on any failure; the CLI wrapper maps that back to fail(), the HTTP
159
+ // handler maps it to a JSON error response -- both derive their own response shape from the SAME
160
+ // thrown error rather than re-deciding independently.
161
+ export function declareDependency(root, { feature, resource, field, sourceFeature, sourceResource, sourceField, reason, memo }) {
162
+ requireValidFeatureIdOr400(feature);
163
+ requireValidFeatureIdOr400(sourceFeature); // path-injection defense, same as every --feature flag (D-security-3)
164
+ if (!reason || !reason.trim()) {
165
+ throw new DependencyOperationError('bskel dependency declare requires --reason "..." -- every dependency must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
166
+ }
167
+ if (feature === sourceFeature && resource === sourceResource && field === sourceField) {
168
+ throw new DependencyOperationError(`"${resource}.${field}" cannot depend on itself`, { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
169
+ }
170
+
171
+ const target = resolveClassFile(root, feature, resource);
172
+ if (!target.file) {
173
+ throw new DependencyOperationError(describeResolutionFailure(feature, resource, target), resolutionFailureErrorOptions(target));
174
+ }
175
+ const source = resolveClassFile(root, sourceFeature, sourceResource);
176
+ if (!source.file) {
177
+ throw new DependencyOperationError(describeResolutionFailure(sourceFeature, sourceResource, source), resolutionFailureErrorOptions(source));
178
+ }
179
+
180
+ const dep = {
181
+ target: { resourceType: resource, fieldName: field },
182
+ source: { feature: sourceFeature, resourceType: sourceResource, fieldName: sourceField },
183
+ reason,
184
+ ...(memo ? { memo } : {}),
185
+ at: new Date().toISOString(),
186
+ };
187
+
188
+ // S5 (D-persistence-integrity): same load-modify-save-under-one-lock shape cmdContractWaive
189
+ // already uses, for the identical reason -- closes the lost-update race between this function's
190
+ // own load and its save. Safe under concurrent HTTP requests too: withLockSync is fully
191
+ // synchronous (fs.mkdirSync + a blocking retry loop, no `await` anywhere inside), and Node's
192
+ // single-threaded event loop means a request handler runs to completion without ever yielding to
193
+ // a second concurrently-arriving request -- verified directly against lib/lock.mjs, not assumed.
194
+ const updated = withLockSync(root, 'state', () => {
195
+ const current = loadFieldDependencies(root, feature);
196
+ const key = dependencyKey(dep);
197
+ const next = {
198
+ schema: 'sbf.field-dependency/1',
199
+ feature_id: feature,
200
+ dependencies: [...current.dependencies.filter((d) => dependencyKey(d) !== key), dep],
201
+ };
202
+ saveFieldDependencies(root, feature, next);
203
+ return next;
204
+ });
205
+ const gateState = passNamedGate(root, 'dependencies', feature, { dependency_count: updated.dependencies.length });
206
+ return { dependency: dep, gate: gateState.gates.dependencies };
207
+ }
208
+
209
+ // D-http-serving-layer: the mutation core of `bskel dependency remove`, mirroring declareDependency's
210
+ // own shared-primitive rationale above.
211
+ export function removeDependency(root, { feature, resource, field, sourceFeature, sourceResource, sourceField, reason }) {
212
+ requireValidFeatureIdOr400(feature);
213
+ requireValidFeatureIdOr400(sourceFeature);
214
+ if (!reason || !reason.trim()) {
215
+ throw new DependencyOperationError('bskel dependency remove requires --reason "..." -- every removal must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
216
+ }
217
+
218
+ const targetKey = dependencyKey({
219
+ target: { resourceType: resource, fieldName: field },
220
+ source: { feature: sourceFeature, resourceType: sourceResource, fieldName: sourceField },
221
+ });
222
+
223
+ const updated = withLockSync(root, 'state', () => {
224
+ const current = loadFieldDependencies(root, feature);
225
+ const match = current.dependencies.find((d) => dependencyKey(d) === targetKey);
226
+ if (!match) {
227
+ const known = current.dependencies.map((d) => `${d.target.resourceType}.${d.target.fieldName} <- ${d.source.feature}/${d.source.resourceType}.${d.source.fieldName}`);
228
+ throw new DependencyOperationError(
229
+ `no declared dependency matches "${resource}.${field} <- ${sourceFeature}/${sourceResource}.${sourceField}" -- currently declared: ${known.join('; ') || '(none)'}`,
230
+ { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' },
231
+ );
232
+ }
233
+ const next = {
234
+ schema: 'sbf.field-dependency/1',
235
+ feature_id: feature,
236
+ dependencies: current.dependencies.filter((d) => dependencyKey(d) !== targetKey),
237
+ };
238
+ saveFieldDependencies(root, feature, next);
239
+ return next;
240
+ });
241
+ const gateState = passNamedGate(root, 'dependencies', feature, { dependency_count: updated.dependencies.length });
242
+ return { removed: true, gate: gateState.gates.dependencies };
243
+ }
244
+
245
+ // D-http-serving-layer: the read core of `bskel dependency list`, mirroring the two mutation
246
+ // functions' own shared-primitive rationale. Read-only, gate-independent like cmdHandlesAudit --
247
+ // always resolves current state (even past whatever token the gate itself last stored) so a diverged
248
+ // dependency is visible here immediately, not only after the next explicit `gate require`.
249
+ export function buildDependencyListReport(root, featureId) {
250
+ requireValidFeatureIdOr400(featureId);
251
+ const record = loadFeatureFile(root, featureId);
252
+ if (!record) {
253
+ throw new DependencyOperationError(
254
+ `no feature.json at specs/${featureId}/ -- run \`bskel feature init --slug ${slugWords(featureId).join('-')}\` first (or hand-write specs/${featureId}/feature.json with a minted feature_uid)`,
255
+ { httpStatus: 404, exitCode: EXIT_CODES.NOT_PASSED, reasonCode: 'MISSING_ARTIFACT' },
256
+ );
257
+ }
258
+ const doc = loadFieldDependencies(root, featureId);
259
+
260
+ const rows = doc.dependencies.map((dep) => {
261
+ const t = resolveClassFile(root, featureId, dep.target.resourceType);
262
+ const s = resolveClassFile(root, dep.source.feature, dep.source.resourceType);
263
+ return {
264
+ ...dep,
265
+ target_resolved: Boolean(t.file),
266
+ target_file: t.file ? path.relative(root, t.file) : null,
267
+ target_unresolved_reason: t.reason,
268
+ source_resolved: Boolean(s.file),
269
+ source_file: s.file ? path.relative(root, s.file) : null,
270
+ source_unresolved_reason: s.reason,
271
+ };
272
+ });
273
+ const gateResult = requireNamedGate(root, 'dependencies', featureId);
274
+
275
+ return {
276
+ schema: 'sbf.dependency-list/1',
277
+ feature_id: featureId,
278
+ dependencies: rows,
279
+ gate: { status: gateResult.status, code: gateResult.code, changed_inputs: gateResult.changed_inputs ?? null },
280
+ };
281
+ }
282
+
283
+ // D-http-serving-layer: these two prefixes MUST stay byte-identical to lib/gate-definitions.mjs's own
284
+ // TARGET_FIELD_FILE_PREFIX/SOURCE_FIELD_FILE_PREFIX constants -- duplicated here (2 short string
285
+ // literals) rather than exported+imported, the same tradeoff lib/workflow.mjs's own
286
+ // awaitingDispositionCommand() already documents for itself ("duplicated here rather than
287
+ // exported+imported... for two lines of text"). If gate-definitions.mjs's own tokens ever change,
288
+ // this must change with them.
289
+ const TARGET_FIELD_FILE_PREFIX = 'target_field_file:';
290
+ const SOURCE_FIELD_FILE_PREFIX = 'source_field_file:';
291
+
292
+ // D-http-serving-layer: the repo-wide aggregate lib/http-server.mjs's `GET /api/graph` serves --
293
+ // every distinct {feature, resourceType} pair that participates in ANY declared dependency (a
294
+ // resource is only "on the graph" because it has a real wire, not an independent "list every scanned
295
+ // class" feature nothing else asks for), plus one wire per declared dependency with a HONEST 3-value
296
+ // `resolution` ('synced'/'stale'/'unresolved') grounded in what's actually computable -- this does
297
+ // NOT recreate the original Fieldwire UI mockup's 4-state vocabulary (its 'conflict' state meant a
298
+ // type-mismatch/propagation decision this backend has no data for). Per-edge attribution reuses the
299
+ // SAME changed_inputs-prefix-matching precision bin/bskel.mjs's describeDownstreamImpact() (Slice 2)
300
+ // already established, so a feature stale for one dependency's reason never marks an unrelated
301
+ // dependency 'stale' too.
302
+ export function buildDependencyGraph(root) {
303
+ const nodes = new Map();
304
+ const wires = [];
305
+
306
+ const nodeFor = (featureId, resourceType) => {
307
+ const key = `${featureId}::${resourceType}`;
308
+ if (!nodes.has(key)) {
309
+ const r = resolveClassFile(root, featureId, resourceType);
310
+ nodes.set(key, { id: key, feature: featureId, resourceType, file: r.file ? path.relative(root, r.file) : null, resolved: Boolean(r.file) });
311
+ }
312
+ return key;
313
+ };
314
+
315
+ for (const record of listFeatures(root)) {
316
+ const doc = loadFieldDependencies(root, record.feature_id);
317
+ if (doc.dependencies.length === 0) continue;
318
+ const gate = requireNamedGate(root, 'dependencies', record.feature_id);
319
+ const changedInputs = new Set(gate.changed_inputs ?? []);
320
+
321
+ for (const dep of doc.dependencies) {
322
+ const targetNode = nodeFor(record.feature_id, dep.target.resourceType);
323
+ const sourceNode = nodeFor(dep.source.feature, dep.source.resourceType);
324
+ const t = resolveClassFile(root, record.feature_id, dep.target.resourceType);
325
+ const s = resolveClassFile(root, dep.source.feature, dep.source.resourceType);
326
+
327
+ let resolution = 'synced';
328
+ let unresolvedSide = null;
329
+ let unresolvedReason = null;
330
+ if (!t.file) { resolution = 'unresolved'; unresolvedSide = 'target'; unresolvedReason = t.reason; }
331
+ else if (!s.file) { resolution = 'unresolved'; unresolvedSide = 'source'; unresolvedReason = s.reason; }
332
+ else if (gate.status === 'stale') {
333
+ const targetKey = `${TARGET_FIELD_FILE_PREFIX}${dep.target.resourceType}`;
334
+ const sourceKey = `${SOURCE_FIELD_FILE_PREFIX}${dep.source.feature}:${dep.source.resourceType}`;
335
+ if (changedInputs.has(targetKey) || changedInputs.has(sourceKey)) resolution = 'stale';
336
+ }
337
+
338
+ wires.push({
339
+ id: `${targetNode}->${sourceNode}::${dep.target.fieldName}->${dep.source.fieldName}`,
340
+ feature: record.feature_id,
341
+ target: dep.target,
342
+ source: dep.source,
343
+ reason: dep.reason,
344
+ memo: dep.memo ?? null,
345
+ hasMemo: Boolean(dep.memo),
346
+ at: dep.at,
347
+ resolution,
348
+ unresolvedSide,
349
+ unresolvedReason,
350
+ });
351
+ }
352
+ }
353
+
354
+ return { nodes: [...nodes.values()], wires };
355
+ }
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
 
@@ -22,6 +22,9 @@ import { sha256File, fileMode, readJsonIfExists, resolveWithinRoot } from './fsu
22
22
  import { specPath, sbfPath } from './paths.mjs';
23
23
  import { ADAPTERS, adapterById } from '../scanners/registry.mjs';
24
24
  import { loadManifest } from './handles-manifest.mjs';
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';
25
28
 
26
29
  // S2: prefix for stack's per-applied-file input keys -- lib/gates.mjs's diffInputs() compares
27
30
  // top-level keys only, so a manifest-shaped input (one hash per applied file) has to flatten
@@ -41,6 +44,26 @@ const SOURCE_FILE_PREFIX = 'source_file:';
41
44
  // to the DISPOSED module specifically (a narrower set than SOURCE_FILE_PREFIX's whole-adapter
42
45
  // read-set), so the `contract` gate stops being sensitive to every Java file in the repo.
43
46
  const MODULE_FILE_PREFIX = 'module_file:';
47
+ // D-field-dependency: flattened per-declared-dependency file tokens, one key per DISTINCT
48
+ // {feature, resourceType} the feature's own dependencies.json actually references (deduped --
49
+ // two fields on the same class collapse to one key, since this gate has no field-level parser and
50
+ // would otherwise just repeat the identical file hash N times with zero added diagnostic value).
51
+ const TARGET_FIELD_FILE_PREFIX = 'target_field_file:';
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:';
44
67
 
45
68
  // The preflight and stack gates are repo-scoped, not feature-scoped -- preflight runs before a
46
69
  // feature_id exists at all, and a stack choice is a project-wide decision, not per-feature.
@@ -147,6 +170,39 @@ export const GATE_DEFINITIONS = Object.freeze({
147
170
  return inputs;
148
171
  },
149
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
+ },
150
206
  // The contract gate's token covers the emitted contract file's own hash (re-emitting after
151
207
  // a re-scan invalidates it) and head_sha -- NOT the scan report's hash directly, since the
152
208
  // contract is a derived artifact; if the scan changes but the contract hasn't been
@@ -210,6 +266,31 @@ export const GATE_DEFINITIONS = Object.freeze({
210
266
  return inputs;
211
267
  },
212
268
  },
269
+ // D-field-dependency: a resolved file gets its real content hash; an UNRESOLVABLE one gets a
270
+ // labeled sentinel string instead of a bare null -- unlike contract.recompute()'s own bare-null
271
+ // precedent (safe there only because the path itself is always deterministic via specPath()),
272
+ // here "no file resolves at all" (a renamed/deleted class) is a distinct failure mode from "a
273
+ // known file was deleted" (also a real, legitimate null from sha256File), and a human reading
274
+ // `changed_inputs` deserves to know which. Both are equally fail-closed: neither can coincide
275
+ // with a previously-stored good hash.
276
+ dependencies: {
277
+ name: 'dependencies',
278
+ scope: SCOPE.FEATURE,
279
+ verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
280
+ recompute: (root, featureId) => {
281
+ const depsPath = dependenciesPath(root, featureId);
282
+ const inputs = { dependencies_hash: sha256File(depsPath) };
283
+ const doc = readJsonIfExists(depsPath);
284
+ const fileTokenFor = (resolution) => (resolution.file ? sha256File(resolution.file) : `unresolved:${resolution.reason}`);
285
+ for (const dep of doc?.dependencies ?? []) {
286
+ const targetKey = `${TARGET_FIELD_FILE_PREFIX}${dep.target.resourceType}`;
287
+ if (!(targetKey in inputs)) inputs[targetKey] = fileTokenFor(resolveClassFile(root, featureId, dep.target.resourceType));
288
+ const sourceKey = `${SOURCE_FIELD_FILE_PREFIX}${dep.source.feature}:${dep.source.resourceType}`;
289
+ if (!(sourceKey in inputs)) inputs[sourceKey] = fileTokenFor(resolveClassFile(root, dep.source.feature, dep.source.resourceType));
290
+ }
291
+ return inputs;
292
+ },
293
+ },
213
294
  // Staleness = the generated Java (or the contract it was generated from) has moved since
214
295
  // emit -- NOT "does the migration still match the DB schema" (unknowable without a live DB
215
296
  // connection this tool deliberately never opens on its own, see D-migration-scope). Note
@@ -258,6 +339,41 @@ export const GATE_DEFINITIONS = Object.freeze({
258
339
  return inputs;
259
340
  },
260
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
+ },
261
377
  // D-runtime-conformance-receipts: passed by `bskel observe import`, never by `observe emit`
262
378
  // (emitting the checking infra is not evidence; importing real receipts is). Opt-in like
263
379
  // handles/stack -- not every feature runs runtime observation. Staleness = the contract moved
@@ -283,7 +399,7 @@ export const GATE_DEFINITIONS = Object.freeze({
283
399
  // test/gate-definitions.test.mjs asserts this stays exactly in sync with GATE_DEFINITIONS' own
284
400
  // key set, so a gate added to one and not the other fails loudly instead of silently vanishing
285
401
  // from `bskel verify` the way `stack` did before this module existed.
286
- export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'contract', 'handles', 'stack', 'conformance']);
402
+ export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'handles', 'stack', 'patch_transactions', 'conformance']);
287
403
 
288
404
  export function getGateDefinition(name) {
289
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(