backend-skeleton 1.0.0-beta.8 → 1.0.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/bskel.mjs CHANGED
@@ -31,6 +31,11 @@ import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.
31
31
  import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
32
32
  import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
33
33
  import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
34
+ import { createHttpServer } from '../lib/http-server.mjs';
35
+ import {
36
+ resolveClassFile, listDownstreamDependents, DependencyOperationError,
37
+ declareDependency, removeDependency, buildDependencyListReport,
38
+ } from '../lib/field-dependencies.mjs';
34
39
  import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting } from '../new/index.mjs';
35
40
  import {
36
41
  requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
@@ -77,6 +82,9 @@ function usage() {
77
82
  bskel contract validate --feature <id> --file <envelope.json>
78
83
  bskel contract tool-schema --feature <id> --operation <operationId>
79
84
  bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..." [--expires <Nd>]
85
+ bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
86
+ bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
87
+ bskel dependency list --feature <id> [--json]
80
88
  bskel stack apply --choice <id> [--apply] [--port N] [--json]
81
89
  bskel catalog lint [<choice>] [--json]
82
90
  bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
@@ -95,6 +103,7 @@ function usage() {
95
103
  bskel gate show [<name>] [--feature <id>]
96
104
  bskel gate export --feature <id> [--out <path>] [--json]
97
105
  bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
106
+ bskel serve [--port N] [--host <addr>] [--json]
98
107
  `);
99
108
  }
100
109
 
@@ -1055,6 +1064,7 @@ function cmdContractEmit(args) {
1055
1064
  console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current warning (kept as-is, not auto-removed):`);
1056
1065
  for (const w of evaluation.staleWaivers) console.error(` ${w.code} (${w.subject ?? '*'})`);
1057
1066
  }
1067
+ for (const n of describeDownstreamImpact(root, flags.feature)) console.error(`\nnote: ${n}`);
1058
1068
  if (evaluation.blocking) {
1059
1069
  if (evaluation.status === 'blocked') {
1060
1070
  console.error(`\nblocked: this contract has zero operations and cannot be waived -- fix --module/--terms, or run \`bskel gate force contract --feature ${flags.feature} --reason "..."\` if this module genuinely has no HTTP surface (yet).`);
@@ -1363,6 +1373,123 @@ function cmdContractWaive(args) {
1363
1373
  process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
1364
1374
  }
1365
1375
 
1376
+ // D-dependency-propagation-notice: called from cmdContractEmit/cmdHandlesEmit to warn a SOURCE
1377
+ // feature, at the moment its own generated artifacts are refreshed, that other features declared a
1378
+ // dependency on one of its fields. Only surfaces a note when the dependent's OWN `dependencies` gate
1379
+ // is actually stale AND that staleness is attributable to THIS featureId specifically (a
1380
+ // `source_field_file:<featureId>:` key in its changed_inputs) -- a dependent that's stale for some
1381
+ // OTHER, unrelated reason must not be misattributed to this feature's own change. When
1382
+ // changed_inputs can't explain the staleness (NO_RECORDED_INPUTS/RECORDED_INPUTS_MISMATCH), the note
1383
+ // is skipped rather than guessed -- this is a best-effort nudge, never the source of truth for
1384
+ // whether something is actually stale (bskel verify/status on the dependent feature itself remains
1385
+ // that source of truth).
1386
+ function describeDownstreamImpact(root, featureId) {
1387
+ const byDependent = new Map();
1388
+ for (const { dependentFeature, dep } of listDownstreamDependents(root, featureId)) {
1389
+ if (!byDependent.has(dependentFeature)) byDependent.set(dependentFeature, []);
1390
+ byDependent.get(dependentFeature).push(dep);
1391
+ }
1392
+ const notes = [];
1393
+ const prefix = `source_field_file:${featureId}:`;
1394
+ for (const [dependentFeature, deps] of byDependent) {
1395
+ const gate = requireNamedGate(root, 'dependencies', dependentFeature);
1396
+ if (gate.status !== 'stale') continue;
1397
+ if (!(gate.changed_inputs ?? []).some((k) => k.startsWith(prefix))) continue;
1398
+ const list = deps.map((d) => `${d.target.resourceType}.${d.target.fieldName} <- ${d.source.resourceType}.${d.source.fieldName}`).join('; ');
1399
+ notes.push(
1400
+ `downstream impact: feature "${dependentFeature}" depends on this feature's field(s) (${list}), and that dependency just went stale -- ` +
1401
+ `review with \`bskel dependency list --feature ${dependentFeature} --json\`, then re-run \`bskel dependency declare ...\` once the change is accounted for.`,
1402
+ );
1403
+ }
1404
+ return notes;
1405
+ }
1406
+
1407
+ // D-http-serving-layer: cmdDependencyDeclare/Remove/List are now thin CLI wrappers over
1408
+ // lib/field-dependencies.mjs's declareDependency/removeDependency/buildDependencyListReport --
1409
+ // lib/http-server.mjs's POST/DELETE/GET handlers call the SAME functions, so the CLI and HTTP
1410
+ // surfaces can never diverge on what these operations actually do. A thrown DependencyOperationError
1411
+ // carries the exact (exitCode, reasonCode) this CLI path always used -- fail() is called with those
1412
+ // verbatim, so this refactor is behavior-preserving (verified: test/dependency-cli.test.mjs, written
1413
+ // before this refactor existed, passes unchanged).
1414
+ function cmdDependencyDeclare(args) {
1415
+ const flags = parseCommand('dependency declare', args);
1416
+ if (flags.help) { console.log(renderCommandHelp('dependency declare')); process.exit(0); }
1417
+ setContext('dependency declare', flags);
1418
+ const root = requireRepoRoot();
1419
+ let result;
1420
+ try {
1421
+ result = declareDependency(root, {
1422
+ feature: flags.feature, resource: flags.resource, field: flags.field,
1423
+ sourceFeature: flags['source-feature'], sourceResource: flags['source-resource'], sourceField: flags['source-field'],
1424
+ reason: flags.reason, memo: flags.memo,
1425
+ });
1426
+ } catch (err) {
1427
+ if (err instanceof DependencyOperationError) fail(err.exitCode, err.reasonCode, err.message);
1428
+ throw err;
1429
+ }
1430
+
1431
+ if (flags.json) {
1432
+ console.log(JSON.stringify(result, null, 2));
1433
+ } else if (!flags.quiet) {
1434
+ console.log(`declared: ${flags.resource}.${flags.field} <- ${flags['source-feature']}/${flags['source-resource']}.${flags['source-field']}`);
1435
+ console.log(`gate: dependencies -> ${result.gate.status}`);
1436
+ }
1437
+ process.exit(EXIT.PASS);
1438
+ }
1439
+
1440
+ function cmdDependencyRemove(args) {
1441
+ const flags = parseCommand('dependency remove', args);
1442
+ if (flags.help) { console.log(renderCommandHelp('dependency remove')); process.exit(0); }
1443
+ setContext('dependency remove', flags);
1444
+ const root = requireRepoRoot();
1445
+ let result;
1446
+ try {
1447
+ result = removeDependency(root, {
1448
+ feature: flags.feature, resource: flags.resource, field: flags.field,
1449
+ sourceFeature: flags['source-feature'], sourceResource: flags['source-resource'], sourceField: flags['source-field'],
1450
+ reason: flags.reason,
1451
+ });
1452
+ } catch (err) {
1453
+ if (err instanceof DependencyOperationError) fail(err.exitCode, err.reasonCode, err.message);
1454
+ throw err;
1455
+ }
1456
+
1457
+ if (flags.json) {
1458
+ console.log(JSON.stringify(result, null, 2));
1459
+ } else if (!flags.quiet) {
1460
+ console.log(`removed: ${flags.resource}.${flags.field} <- ${flags['source-feature']}/${flags['source-resource']}.${flags['source-field']}`);
1461
+ console.log(`gate: dependencies -> ${result.gate.status}`);
1462
+ }
1463
+ process.exit(EXIT.PASS);
1464
+ }
1465
+
1466
+ function cmdDependencyList(args) {
1467
+ const flags = parseCommand('dependency list', args);
1468
+ if (flags.help) { console.log(renderCommandHelp('dependency list')); process.exit(0); }
1469
+ setContext('dependency list', flags);
1470
+ const root = requireRepoRoot();
1471
+ let report;
1472
+ try {
1473
+ report = buildDependencyListReport(root, flags.feature);
1474
+ } catch (err) {
1475
+ if (err instanceof DependencyOperationError) fail(err.exitCode, err.reasonCode, err.message);
1476
+ throw err;
1477
+ }
1478
+
1479
+ if (flags.json) {
1480
+ console.log(JSON.stringify(report, null, 2));
1481
+ } else {
1482
+ console.log(`dependencies -- feature ${flags.feature} (gate: ${report.gate.status})`);
1483
+ for (const r of report.dependencies) {
1484
+ const tNote = r.target_resolved ? 'ok' : `UNRESOLVED:${r.target_unresolved_reason}`;
1485
+ const sNote = r.source_resolved ? 'ok' : `UNRESOLVED:${r.source_unresolved_reason}`;
1486
+ console.log(` ${r.target.resourceType}.${r.target.fieldName} [${tNote}] <- ${r.source.feature}/${r.source.resourceType}.${r.source.fieldName} [${sNote}]`);
1487
+ }
1488
+ if (report.dependencies.length === 0) console.log(' (none declared)');
1489
+ }
1490
+ process.exit(0);
1491
+ }
1492
+
1366
1493
  // D-contract-history: a derived VIEW over the contract file's own git history in whatever repo
1367
1494
  // bskel is invoked in -- reads, never writes. Deliberately does NOT try to correlate a commit to
1368
1495
  // a specific `.sbf/<feature>.history.jsonl` gate-pass event: that file is per-machine, gitignored,
@@ -1990,6 +2117,11 @@ function cmdHandlesEmit(args) {
1990
2117
  // D4: dryRun never marks the gate passed -- nothing real happened this run.
1991
2118
  const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
1992
2119
 
2120
+ // D-dependency-propagation-notice: appended here (not inside any provider's own emit.mjs) so it
2121
+ // applies uniformly regardless of which provider ran -- inherits the same --json/text-mode
2122
+ // visibility every provider-authored postEmitNote already has, no special-casing needed.
2123
+ postEmitNotes.push(...describeDownstreamImpact(root, flags.feature));
2124
+
1993
2125
  if (flags.json) {
1994
2126
  console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, gate: gateState?.gates.handles ?? null, check: dryRun, postEmitNotes }, null, 2));
1995
2127
  } else if (!flags.quiet) {
@@ -2603,6 +2735,39 @@ function cmdDoctor(args) {
2603
2735
  process.exit(allOk ? 0 : 1);
2604
2736
  }
2605
2737
 
2738
+ // D-http-serving-layer: starts a real, long-running HTTP server (lib/http-server.mjs) -- unlike
2739
+ // every other command in this file, success here does NOT process.exit(); the server's own open
2740
+ // socket keeps the event loop alive until Ctrl+C (SIGINT) or SIGTERM. Every route handler calls
2741
+ // straight into the same lib/ functions the CLI commands use -- no separate business logic lives in
2742
+ // the HTTP layer itself.
2743
+ async function cmdServe(args) {
2744
+ const flags = parseCommand('serve', args);
2745
+ if (flags.help) { console.log(renderCommandHelp('serve')); process.exit(0); }
2746
+ setContext('serve', flags);
2747
+ const root = requireRepoRoot();
2748
+ const port = Number.parseInt(flags.port, 10);
2749
+
2750
+ let started;
2751
+ try {
2752
+ started = await createHttpServer(root, { host: flags.host, port });
2753
+ } catch (err) {
2754
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not start server: ${err.message}`);
2755
+ }
2756
+
2757
+ if (flags.json) {
2758
+ console.log(JSON.stringify({ listening: started.url, host: started.host, port: started.port, repo: root }));
2759
+ } else if (!flags.quiet) {
2760
+ console.log(`bskel serve -- listening on ${started.url}`);
2761
+ console.log(` UI: ${started.url}/`);
2762
+ console.log(` API: ${started.url}/api/...`);
2763
+ console.log('press Ctrl+C to stop');
2764
+ }
2765
+
2766
+ const shutdown = () => started.server.close(() => process.exit(0));
2767
+ process.on('SIGINT', shutdown);
2768
+ process.on('SIGTERM', shutdown);
2769
+ }
2770
+
2606
2771
  // P2 (D-greenfield-bootstrap): the one path into this tool that doesn't require an existing git
2607
2772
  // repo (contrast requireRepoRoot(), used by nearly everything else) -- `bskel new` is what CREATES
2608
2773
  // one. `--stack`'s two choices come from new/index.mjs's plain dispatch map, not a dynamic
@@ -2850,6 +3015,16 @@ async function dispatchCommand(cmd, rest) {
2850
3015
  process.exit(14);
2851
3016
  break;
2852
3017
  }
3018
+ case 'dependency': {
3019
+ const sub = rest[0];
3020
+ const subArgs = rest.slice(1);
3021
+ if (sub === 'declare') return cmdDependencyDeclare(subArgs);
3022
+ if (sub === 'remove') return cmdDependencyRemove(subArgs);
3023
+ if (sub === 'list') return cmdDependencyList(subArgs);
3024
+ usage();
3025
+ process.exit(14);
3026
+ break;
3027
+ }
2853
3028
  case 'stack': {
2854
3029
  if (rest[0] === 'apply') return cmdStackApply(rest.slice(1));
2855
3030
  usage();
@@ -2903,6 +3078,8 @@ async function dispatchCommand(cmd, rest) {
2903
3078
  case 'doctor':
2904
3079
  cmdDoctor(rest);
2905
3080
  break;
3081
+ case 'serve':
3082
+ return cmdServe(rest);
2906
3083
  case 'new':
2907
3084
  return cmdNew(rest);
2908
3085
  default:
@@ -18,6 +18,16 @@ export const COMPLETENESS = Object.freeze({ COMPLETE: 'complete', PARTIAL: 'part
18
18
  // CONTRACT_EMPTY both mean the endpoint loop in buildContract() never ran at all) -- so gating
19
19
  // waivers on `completeness === 'blocked'` in cmdContractWaive is sufficient to keep them
20
20
  // unwaivable; there is no case where either fires with operations > 0.
21
+ //
22
+ // `waivable` only has functional meaning for ERROR-severity codes -- `bin/bskel.mjs`'s
23
+ // `cmdContractWaive` filters to `severity === 'error'` before anything else, and
24
+ // `evaluateResolution()` below only ever considers ERROR-severity warnings when computing
25
+ // `unwaived`/`blocking` in the first place, by design (a WARN never blocks completeness, waived
26
+ // or not). Every WARN-severity code below is marked `waivable: true` anyway -- read that as "this
27
+ // finding is conceptually the kind of thing a human might choose to acknowledge," not as "this can
28
+ // currently be passed to `bskel contract waive`" -- it can't, and isn't meant to. This is
29
+ // deliberate (`test/contract-completeness.test.mjs` asserts the exact shape below, repeatedly,
30
+ // across every OpenAPI-passthrough item that's added a WARN code), not an unnoticed inconsistency.
21
31
  export const WARNING_CODES = Object.freeze({
22
32
  CONTRACT_NO_MODULE: { severity: SEVERITY.ERROR, waivable: false },
23
33
  CONTRACT_EMPTY: { severity: SEVERITY.ERROR, waivable: false },
package/lib/cli.mjs CHANGED
@@ -228,6 +228,40 @@ export const COMMANDS = {
228
228
  operation: { type: 'string', default: null, required: true },
229
229
  },
230
230
  },
231
+ 'dependency declare': {
232
+ usage: 'bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."] [--json]',
233
+ options: {
234
+ feature: { type: 'string', default: null, required: true },
235
+ resource: { type: 'string', default: null, required: true },
236
+ field: { type: 'string', default: null, required: true },
237
+ 'source-feature': { type: 'string', default: null, required: true },
238
+ 'source-resource': { type: 'string', default: null, required: true },
239
+ 'source-field': { type: 'string', default: null, required: true },
240
+ reason: { type: 'string', default: '' },
241
+ memo: { type: 'string', default: null },
242
+ json: { type: 'boolean', default: false },
243
+ },
244
+ },
245
+ 'dependency remove': {
246
+ usage: 'bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--json]',
247
+ options: {
248
+ feature: { type: 'string', default: null, required: true },
249
+ resource: { type: 'string', default: null, required: true },
250
+ field: { type: 'string', default: null, required: true },
251
+ 'source-feature': { type: 'string', default: null, required: true },
252
+ 'source-resource': { type: 'string', default: null, required: true },
253
+ 'source-field': { type: 'string', default: null, required: true },
254
+ reason: { type: 'string', default: '' },
255
+ json: { type: 'boolean', default: false },
256
+ },
257
+ },
258
+ 'dependency list': {
259
+ usage: 'bskel dependency list --feature <id> [--json]',
260
+ options: {
261
+ feature: { type: 'string', default: null, required: true },
262
+ json: { type: 'boolean', default: false },
263
+ },
264
+ },
231
265
  'stack apply': {
232
266
  usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--json]',
233
267
  options: {
@@ -409,6 +443,17 @@ export const COMMANDS = {
409
443
  json: { type: 'boolean', default: false },
410
444
  },
411
445
  },
446
+ serve: {
447
+ usage: 'bskel serve [--port N] [--host <addr>] [--json]',
448
+ options: {
449
+ // min:0 (unlike stack apply --port's min:1) -- 0 is the standard "let the OS pick a free
450
+ // ephemeral port" sentinel, genuinely useful both for tests and for a user who doesn't care
451
+ // which port they get, not just a testing convenience.
452
+ port: { type: 'string', default: '4747', numeric: { min: 0, max: 65535 } },
453
+ host: { type: 'string', default: '127.0.0.1' },
454
+ json: { type: 'boolean', default: false },
455
+ },
456
+ },
412
457
  };
413
458
 
414
459
  function describeParseArgsError(err, spec) {
@@ -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
+ }
@@ -22,6 +22,7 @@ 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';
25
26
 
26
27
  // S2: prefix for stack's per-applied-file input keys -- lib/gates.mjs's diffInputs() compares
27
28
  // top-level keys only, so a manifest-shaped input (one hash per applied file) has to flatten
@@ -41,6 +42,12 @@ const SOURCE_FILE_PREFIX = 'source_file:';
41
42
  // to the DISPOSED module specifically (a narrower set than SOURCE_FILE_PREFIX's whole-adapter
42
43
  // read-set), so the `contract` gate stops being sensitive to every Java file in the repo.
43
44
  const MODULE_FILE_PREFIX = 'module_file:';
45
+ // D-field-dependency: flattened per-declared-dependency file tokens, one key per DISTINCT
46
+ // {feature, resourceType} the feature's own dependencies.json actually references (deduped --
47
+ // two fields on the same class collapse to one key, since this gate has no field-level parser and
48
+ // would otherwise just repeat the identical file hash N times with zero added diagnostic value).
49
+ const TARGET_FIELD_FILE_PREFIX = 'target_field_file:';
50
+ const SOURCE_FIELD_FILE_PREFIX = 'source_field_file:';
44
51
 
45
52
  // The preflight and stack gates are repo-scoped, not feature-scoped -- preflight runs before a
46
53
  // feature_id exists at all, and a stack choice is a project-wide decision, not per-feature.
@@ -210,6 +217,31 @@ export const GATE_DEFINITIONS = Object.freeze({
210
217
  return inputs;
211
218
  },
212
219
  },
220
+ // D-field-dependency: a resolved file gets its real content hash; an UNRESOLVABLE one gets a
221
+ // labeled sentinel string instead of a bare null -- unlike contract.recompute()'s own bare-null
222
+ // precedent (safe there only because the path itself is always deterministic via specPath()),
223
+ // here "no file resolves at all" (a renamed/deleted class) is a distinct failure mode from "a
224
+ // known file was deleted" (also a real, legitimate null from sha256File), and a human reading
225
+ // `changed_inputs` deserves to know which. Both are equally fail-closed: neither can coincide
226
+ // with a previously-stored good hash.
227
+ dependencies: {
228
+ name: 'dependencies',
229
+ scope: SCOPE.FEATURE,
230
+ verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
231
+ recompute: (root, featureId) => {
232
+ const depsPath = dependenciesPath(root, featureId);
233
+ const inputs = { dependencies_hash: sha256File(depsPath) };
234
+ const doc = readJsonIfExists(depsPath);
235
+ const fileTokenFor = (resolution) => (resolution.file ? sha256File(resolution.file) : `unresolved:${resolution.reason}`);
236
+ for (const dep of doc?.dependencies ?? []) {
237
+ const targetKey = `${TARGET_FIELD_FILE_PREFIX}${dep.target.resourceType}`;
238
+ if (!(targetKey in inputs)) inputs[targetKey] = fileTokenFor(resolveClassFile(root, featureId, dep.target.resourceType));
239
+ const sourceKey = `${SOURCE_FIELD_FILE_PREFIX}${dep.source.feature}:${dep.source.resourceType}`;
240
+ if (!(sourceKey in inputs)) inputs[sourceKey] = fileTokenFor(resolveClassFile(root, dep.source.feature, dep.source.resourceType));
241
+ }
242
+ return inputs;
243
+ },
244
+ },
213
245
  // Staleness = the generated Java (or the contract it was generated from) has moved since
214
246
  // emit -- NOT "does the migration still match the DB schema" (unknowable without a live DB
215
247
  // connection this tool deliberately never opens on its own, see D-migration-scope). Note
@@ -283,7 +315,7 @@ export const GATE_DEFINITIONS = Object.freeze({
283
315
  // test/gate-definitions.test.mjs asserts this stays exactly in sync with GATE_DEFINITIONS' own
284
316
  // key set, so a gate added to one and not the other fails loudly instead of silently vanishing
285
317
  // from `bskel verify` the way `stack` did before this module existed.
286
- export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'contract', 'handles', 'stack', 'conformance']);
318
+ export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'contract', 'dependencies', 'handles', 'stack', 'conformance']);
287
319
 
288
320
  export function getGateDefinition(name) {
289
321
  return Object.hasOwn(GATE_DEFINITIONS, name) ? GATE_DEFINITIONS[name] : null;
@@ -0,0 +1,172 @@
1
+ // D-http-serving-layer: a native node:http server (zero new dependencies -- this package ships no
2
+ // web framework) exposing read/write JSON endpoints over the field-dependency data model, plus a
3
+ // minimal bundled sanity-check UI page. Every route handler calls straight into the SAME lib/
4
+ // functions bin/bskel.mjs's CLI commands call (declareDependency/removeDependency/
5
+ // buildDependencyListReport/listFeatures/computeWorkflowState) -- there is no second copy of any
6
+ // business logic here, only HTTP transport (routing, CORS, JSON (de)serialization). See
7
+ // DECISIONS.md's D-http-serving-layer for the full design and the CORS-asymmetry security reasoning.
8
+ import http from 'node:http';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { listFeatures } from './featurelifecycle.mjs';
13
+ import { computeWorkflowState } from './workflow.mjs';
14
+ import { isValidFeatureId } from './featureid.mjs';
15
+ import {
16
+ buildDependencyListReport, buildDependencyGraph, declareDependency, removeDependency, DependencyOperationError,
17
+ } from './field-dependencies.mjs';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const UI_HTML_PATH = path.join(__dirname, 'serve-ui.html');
21
+
22
+ // GET/HEAD/their own OPTIONS preflight get the wildcard; POST/DELETE never do -- an unrestricted
23
+ // Access-Control-Allow-Origin on a mutating route would let any website a user's browser has open
24
+ // silently mutate their repo via a background fetch. The bundled UI page (served BY this same
25
+ // server) is same-origin and completely unaffected -- CORS only ever applies cross-origin.
26
+ const CORS_METHODS = new Set(['GET', 'HEAD']);
27
+
28
+ function sendJson(res, status, body, { cors = false } = {}) {
29
+ const payload = JSON.stringify(body, null, 2);
30
+ const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(payload) };
31
+ if (cors) headers['Access-Control-Allow-Origin'] = '*';
32
+ res.writeHead(status, headers);
33
+ res.end(payload);
34
+ }
35
+
36
+ function sendError(res, err, { cors = false } = {}) {
37
+ if (err instanceof DependencyOperationError) {
38
+ sendJson(res, err.httpStatus, { error: err.message, reasonCode: err.reasonCode }, { cors });
39
+ return;
40
+ }
41
+ // D-http-serving-layer: everything user-input-shaped throws DependencyOperationError (see
42
+ // requireValidFeatureIdOr400 in lib/field-dependencies.mjs) -- reaching here means a genuine,
43
+ // unexpected failure (e.g. a filesystem error), so 500 is the honest answer, not a guess.
44
+ sendJson(res, 500, { error: err.message ?? 'internal error' }, { cors });
45
+ }
46
+
47
+ // Bounds the body a single request can force this process to buffer -- a local,
48
+ // single-user dev server still shouldn't let an unbounded body exhaust memory.
49
+ const MAX_BODY_BYTES = 1_000_000;
50
+
51
+ function readJsonBody(req) {
52
+ return new Promise((resolve, reject) => {
53
+ const chunks = [];
54
+ let size = 0;
55
+ req.on('data', (chunk) => {
56
+ size += chunk.length;
57
+ if (size > MAX_BODY_BYTES) {
58
+ reject(new DependencyOperationError(`request body exceeds ${MAX_BODY_BYTES} bytes`, { httpStatus: 413, reasonCode: 'BAD_ARGS' }));
59
+ req.destroy();
60
+ return;
61
+ }
62
+ chunks.push(chunk);
63
+ });
64
+ req.on('end', () => {
65
+ if (chunks.length === 0) { resolve({}); return; }
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
69
+ } catch {
70
+ reject(new DependencyOperationError('request body is not valid JSON', { httpStatus: 400, reasonCode: 'BAD_ARGS' }));
71
+ return;
72
+ }
73
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
74
+ reject(new DependencyOperationError('request body must be a JSON object', { httpStatus: 400, reasonCode: 'BAD_ARGS' }));
75
+ return;
76
+ }
77
+ resolve(parsed);
78
+ });
79
+ req.on('error', reject);
80
+ });
81
+ }
82
+
83
+ let uiHtmlCache = null;
84
+ function serveUiPage(res) {
85
+ if (uiHtmlCache === null) uiHtmlCache = fs.readFileSync(UI_HTML_PATH, 'utf8');
86
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(uiHtmlCache) });
87
+ res.end(uiHtmlCache);
88
+ }
89
+
90
+ const FEATURE_STATUS_RE = /^\/api\/features\/([^/]+)\/status$/;
91
+ const FEATURE_DEPENDENCIES_RE = /^\/api\/features\/([^/]+)\/dependencies$/;
92
+
93
+ async function handleRequest(root, req, res) {
94
+ let url;
95
+ try {
96
+ url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
97
+ } catch {
98
+ sendJson(res, 400, { error: 'invalid request URL' });
99
+ return;
100
+ }
101
+ const { pathname } = url;
102
+ const method = req.method ?? 'GET';
103
+ const cors = CORS_METHODS.has(method);
104
+
105
+ if (method === 'OPTIONS') {
106
+ const headers = { 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' };
107
+ // Only ever grant a preflight for GET-eligible routes -- a POST/DELETE preflight gets no
108
+ // Access-Control-Allow-Origin, so the browser refuses to send the real cross-origin write.
109
+ if (pathname === '/' || pathname.startsWith('/api/')) headers['Access-Control-Allow-Origin'] = '*';
110
+ res.writeHead(204, headers);
111
+ res.end();
112
+ return;
113
+ }
114
+
115
+ try {
116
+ if (method === 'GET' && pathname === '/') { serveUiPage(res); return; }
117
+ if (method === 'GET' && pathname === '/api/health') { sendJson(res, 200, { status: 'ok', repo: root }, { cors }); return; }
118
+ if (method === 'GET' && pathname === '/api/features') { sendJson(res, 200, { features: listFeatures(root) }, { cors }); return; }
119
+ if (method === 'GET' && pathname === '/api/graph') { sendJson(res, 200, buildDependencyGraph(root), { cors }); return; }
120
+
121
+ const statusMatch = pathname.match(FEATURE_STATUS_RE);
122
+ if (method === 'GET' && statusMatch) {
123
+ const featureId = decodeURIComponent(statusMatch[1]);
124
+ if (!isValidFeatureId(featureId)) { sendJson(res, 400, { error: `invalid feature id "${featureId}"` }, { cors }); return; }
125
+ sendJson(res, 200, computeWorkflowState(root, featureId), { cors });
126
+ return;
127
+ }
128
+
129
+ const depsMatch = pathname.match(FEATURE_DEPENDENCIES_RE);
130
+ if (depsMatch) {
131
+ const featureId = decodeURIComponent(depsMatch[1]);
132
+ if (!isValidFeatureId(featureId)) { sendJson(res, 400, { error: `invalid feature id "${featureId}"` }, { cors }); return; }
133
+
134
+ if (method === 'GET') { sendJson(res, 200, buildDependencyListReport(root, featureId), { cors }); return; }
135
+ if (method === 'POST') {
136
+ const body = await readJsonBody(req);
137
+ const result = declareDependency(root, { feature: featureId, ...body });
138
+ sendJson(res, 201, result); // no CORS -- mutating route, same-origin only
139
+ return;
140
+ }
141
+ if (method === 'DELETE') {
142
+ const body = await readJsonBody(req);
143
+ const result = removeDependency(root, { feature: featureId, ...body });
144
+ sendJson(res, 200, result); // no CORS -- mutating route
145
+ return;
146
+ }
147
+ }
148
+
149
+ sendJson(res, 404, { error: `not found: ${method} ${pathname}` }, { cors });
150
+ } catch (err) {
151
+ sendError(res, err, { cors });
152
+ }
153
+ }
154
+
155
+ // Default host 127.0.0.1 (loopback only) -- explicit --host opt-in required to expose beyond it,
156
+ // matching this project's established "safe default, explicit override" convention (e.g.
157
+ // --enforce-registry). Returns once the server has actually bound (server.address() is real), not
158
+ // merely once listen() was called -- callers (bin/bskel.mjs's cmdServe, tests) need the REAL bound
159
+ // port when 0 was requested.
160
+ export function createHttpServer(root, { host = '127.0.0.1', port = 4747 } = {}) {
161
+ const server = http.createServer((req, res) => {
162
+ handleRequest(root, req, res).catch((err) => sendError(res, err));
163
+ });
164
+ return new Promise((resolve, reject) => {
165
+ server.once('error', reject);
166
+ server.listen(port, host, () => {
167
+ server.removeListener('error', reject);
168
+ const addr = server.address();
169
+ resolve({ server, host: addr.address, port: addr.port, url: `http://${addr.address}:${addr.port}` });
170
+ });
171
+ });
172
+ }
@@ -0,0 +1,117 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>bskel serve</title>
6
+ <style>
7
+ :root { color-scheme: light dark; }
8
+ body { font-family: ui-monospace, Menlo, Consolas, monospace; max-width: 960px; margin: 2rem auto; padding: 0 1rem; line-height: 1.4; }
9
+ h1 { font-size: 1.1rem; }
10
+ h2 { font-size: 1rem; margin-top: 2rem; }
11
+ table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
12
+ th, td { text-align: left; padding: 0.3rem 0.6rem; border-bottom: 1px solid #8884; font-size: 0.85rem; }
13
+ .resolution-synced { color: #2a2; }
14
+ .resolution-stale { color: #c90; }
15
+ .resolution-unresolved { color: #d33; }
16
+ form { margin-top: 0.5rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; max-width: 720px; }
17
+ form label { display: flex; flex-direction: column; font-size: 0.75rem; gap: 0.15rem; }
18
+ form input { font: inherit; padding: 0.25rem; }
19
+ form button { grid-column: 1 / -1; padding: 0.4rem; font: inherit; cursor: pointer; }
20
+ #formStatus { font-size: 0.85rem; margin-top: 0.4rem; }
21
+ .muted { opacity: 0.6; }
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <h1>bskel serve -- functionality check</h1>
26
+ <p class="muted">Not a redesign of the original Fieldwire mockup -- this exists to prove the API actually works, nothing more.</p>
27
+
28
+ <h2>Nodes</h2>
29
+ <table id="nodesTable"><thead><tr><th>feature</th><th>resourceType</th><th>file</th><th>resolved</th></tr></thead><tbody></tbody></table>
30
+
31
+ <h2>Wires</h2>
32
+ <table id="wiresTable"><thead><tr><th>target</th><th>source</th><th>resolution</th><th>memo</th></tr></thead><tbody></tbody></table>
33
+
34
+ <h2>Declare a new dependency</h2>
35
+ <form id="declareForm">
36
+ <label>feature <input name="feature" required></label>
37
+ <label>resource <input name="resource" required></label>
38
+ <label>field <input name="field" required></label>
39
+ <label>source feature <input name="sourceFeature" required></label>
40
+ <label>source resource <input name="sourceResource" required></label>
41
+ <label>source field <input name="sourceField" required></label>
42
+ <label style="grid-column: 1 / -1">reason <input name="reason" required></label>
43
+ <button type="submit">Declare</button>
44
+ </form>
45
+ <div id="formStatus"></div>
46
+
47
+ <script>
48
+ // D-http-serving-layer: `reason`/`memo` (and every other string field) are free text a human types
49
+ // via `bskel dependency declare --reason/--memo` or this page's own POST form -- never assumed safe
50
+ // to inject as markup. Every cell here is built via textContent, never innerHTML/insertAdjacentHTML,
51
+ // so a value like `<script>...` or `<img onerror=...>` renders as inert text, not executes.
52
+ function td(text) {
53
+ const cell = document.createElement('td');
54
+ cell.textContent = text;
55
+ return cell;
56
+ }
57
+
58
+ async function loadGraph() {
59
+ const res = await fetch('/api/graph');
60
+ const graph = await res.json();
61
+
62
+ const nodesBody = document.querySelector('#nodesTable tbody');
63
+ nodesBody.replaceChildren();
64
+ for (const n of graph.nodes) {
65
+ const tr = document.createElement('tr');
66
+ tr.append(td(n.feature), td(n.resourceType), td(n.file ?? '(unresolved)'), td(String(n.resolved)));
67
+ nodesBody.appendChild(tr);
68
+ }
69
+
70
+ const wiresBody = document.querySelector('#wiresTable tbody');
71
+ wiresBody.replaceChildren();
72
+ for (const w of graph.wires) {
73
+ const tr = document.createElement('tr');
74
+ const target = `${w.feature}/${w.target.resourceType}.${w.target.fieldName}`;
75
+ const source = `${w.source.feature}/${w.source.resourceType}.${w.source.fieldName}`;
76
+ const resolutionCell = td(`${w.resolution}${w.unresolvedReason ? ` (${w.unresolvedReason})` : ''}`);
77
+ resolutionCell.className = `resolution-${w.resolution}`;
78
+ tr.append(td(target), td(source), resolutionCell, td(w.hasMemo ? w.memo : ''));
79
+ wiresBody.appendChild(tr);
80
+ }
81
+ }
82
+
83
+ document.getElementById('declareForm').addEventListener('submit', async (ev) => {
84
+ ev.preventDefault();
85
+ const form = new FormData(ev.target);
86
+ const feature = form.get('feature');
87
+ const statusEl = document.getElementById('formStatus');
88
+ statusEl.textContent = 'declaring...';
89
+ try {
90
+ const res = await fetch(`/api/features/${encodeURIComponent(feature)}/dependencies`, {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({
94
+ resource: form.get('resource'), field: form.get('field'),
95
+ sourceFeature: form.get('sourceFeature'), sourceResource: form.get('sourceResource'), sourceField: form.get('sourceField'),
96
+ reason: form.get('reason'),
97
+ }),
98
+ });
99
+ const body = await res.json();
100
+ if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
101
+ statusEl.textContent = `declared, gate: ${body.gate.status}`;
102
+ ev.target.reset();
103
+ await loadGraph();
104
+ } catch (err) {
105
+ statusEl.textContent = `error: ${err.message}`;
106
+ }
107
+ });
108
+
109
+ loadGraph().catch((err) => {
110
+ const p = document.createElement('p');
111
+ p.style.color = '#d33';
112
+ p.textContent = `failed to load /api/graph: ${err.message}`;
113
+ document.body.appendChild(p);
114
+ });
115
+ </script>
116
+ </body>
117
+ </html>
package/lib/workflow.mjs CHANGED
@@ -32,8 +32,16 @@ const ESTABLISH_COMMAND = {
32
32
  preflight: () => 'bskel preflight',
33
33
  scan: (id) => `bskel scan --feature ${id} --terms <a,b,c>`,
34
34
  contract: (id) => `bskel contract emit --feature ${id}`,
35
+ // D-field-dependency: this and `conformance` immediately below were BOTH missing before this
36
+ // item -- found live while adding this one, same crash class, fixed together. Neither had ever
37
+ // been exercised through `next` on a stale REQUIRED_WHEN_PRESENT gate (no test covered it) --
38
+ // without an entry here, `computeWorkflowState()` calls `ESTABLISH_COMMAND[gateName](featureId)`
39
+ // directly on the first real stale occurrence and throws a raw TypeError instead of a clean
40
+ // stale report.
41
+ dependencies: (id) => `bskel dependency declare --feature ${id} --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."`,
35
42
  handles: (id) => `bskel handles plan --feature ${id} # then: bskel handles emit --feature ${id}`,
36
43
  stack: () => 'bskel stack apply --choice <id> --apply',
44
+ conformance: (id) => `bskel observe emit --feature ${id} # then: run the target app, then bskel observe import --feature ${id} --receipts <path>`,
37
45
  };
38
46
 
39
47
  // awaiting_disposition needs a genuinely different remediation per gate, not a re-run --
@@ -54,7 +62,7 @@ function awaitingDispositionCommand(gateName, featureId) {
54
62
  // opposed to being a pure read (bskel verify, bskel status, bskel next itself). Matched by the
55
63
  // command's own leading "bskel <verb...>" prefix so this can't silently drift from the actual
56
64
  // command names above.
57
- const MUTATING_PREFIXES = ['bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive', 'bskel gate force', 'bskel handles emit', 'bskel handles plan', 'bskel stack apply'];
65
+ const MUTATING_PREFIXES = ['bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive', 'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit', 'bskel handles plan', 'bskel stack apply'];
58
66
 
59
67
  function action(command, reason) {
60
68
  return { command, reason, mutating: MUTATING_PREFIXES.some((p) => command.startsWith(p)) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.0.0-beta.8",
3
+ "version": "1.0.0-beta.9",
4
4
  "type": "module",
5
5
  "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
6
6
  "license": "AGPL-3.0-or-later",
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:field-dependency:1",
4
+ "title": "backend-skeleton field-dependency declarations",
5
+ "description": "Validates specs/<feature_id>/dependencies.json, written by `bskel dependency declare`/`bskel dependency remove` -- see D-field-dependency in DECISIONS.md. Each entry declares that ONE field on this feature's own resource is derived from a field on some (possibly the same) feature's resource. Deliberately no synthetic id: an entry is addressed by its own natural compound key (target.resourceType/fieldName + source.feature/resourceType/fieldName), matching contract-resolution.schema.json's {code,subject} and patch-approvals.schema.json's {resource,field} precedent. This is a data-model-only slice -- nothing yet reads this file to generate code; the `dependencies` gate (lib/gate-definitions.mjs) only tracks whether it and what it points at have moved.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "feature_id", "dependencies"],
9
+ "properties": {
10
+ "schema": { "const": "sbf.field-dependency/1" },
11
+ "feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
12
+ "dependencies": {
13
+ "description": "One entry per declared edge. `target` is always a resource on THIS file's own feature_id (never repeated per-entry); `source` names its own feature explicitly since it may differ (cross-feature) or be the same (same-feature dependencies are supported by construction, not a special case).",
14
+ "type": "array",
15
+ "items": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "required": ["target", "source", "reason", "at"],
19
+ "properties": {
20
+ "target": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "required": ["resourceType", "fieldName"],
24
+ "properties": {
25
+ "resourceType": { "type": "string", "minLength": 1 },
26
+ "fieldName": { "type": "string", "minLength": 1 }
27
+ }
28
+ },
29
+ "source": {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "required": ["feature", "resourceType", "fieldName"],
33
+ "properties": {
34
+ "feature": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
35
+ "resourceType": { "type": "string", "minLength": 1 },
36
+ "fieldName": { "type": "string", "minLength": 1 }
37
+ }
38
+ },
39
+ "reason": { "type": "string" },
40
+ "memo": {
41
+ "description": "Optional free-text intent, e.g. captured from a future UI's own resolve-decision flow. Inert documentation only in this slice -- nothing reads it yet.",
42
+ "type": "string"
43
+ },
44
+ "at": { "type": "string", "format": "date-time" }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }