backend-skeleton 1.0.0-beta.7 → 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 +177 -0
- package/contracts/completeness.mjs +10 -0
- package/handles/providers/java-spring/emit.mjs +38 -11
- package/handles/providers/python-fastapi/emit.mjs +23 -0
- package/lib/cli.mjs +45 -0
- package/lib/field-dependencies.mjs +355 -0
- package/lib/gate-definitions.mjs +33 -1
- package/lib/http-server.mjs +172 -0
- package/lib/serve-ui.html +117 -0
- package/lib/workflow.mjs +9 -1
- package/package.json +1 -1
- package/schemas/field-dependency.schema.json +49 -0
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 },
|
|
@@ -104,6 +104,22 @@ function writeUnit(target, content) {
|
|
|
104
104
|
fs.writeFileSync(target, content);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// O3 follow-up (D-handle-registry-enforcement, "Continued"): a coarse, per-resource, emit-time,
|
|
108
|
+
// source-only proxy for "has this resource's own create-flow ever been wired to register a
|
|
109
|
+
// HandleRegistry row" -- see DECISIONS.md for why this stays static and never queries a live
|
|
110
|
+
// target-app database (that's a separate, harder, still-open gap, not this). Requires the opening
|
|
111
|
+
// "(" so a bare comment/javadoc mention of the annotation's name (RecordHandleSnapshot.java.tmpl's
|
|
112
|
+
// own javadoc has one) doesn't count as "found". Deliberately biased toward a false-positive
|
|
113
|
+
// WARNING (nagging a resource that's actually registered some other way, e.g. a hand-written
|
|
114
|
+
// HandleService.register() call with no annotation) over a false-negative SILENCE (saying nothing
|
|
115
|
+
// about a resource that really can never bootstrap its first PATCH) -- see DECISIONS.md.
|
|
116
|
+
const RECORD_HANDLE_SNAPSHOT_RE = /@RecordHandleSnapshot\s*\(/;
|
|
117
|
+
|
|
118
|
+
function hasRecordHandleSnapshot(serviceFilePath) {
|
|
119
|
+
if (!serviceFilePath || !fs.existsSync(serviceFilePath)) return false;
|
|
120
|
+
return RECORD_HANDLE_SNAPSHOT_RE.test(fs.readFileSync(serviceFilePath, 'utf8'));
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
// See DECISIONS.md D-handles-ownership for the full design; the conflict/manifest/force/orphan
|
|
108
124
|
// logic itself now lives in handles/_engine.mjs (D-handles-providers, G4) -- this function's job
|
|
109
125
|
// is purely to compute java-spring's own render/paths and hand them to emitUnits(). `force`/
|
|
@@ -249,15 +265,26 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
249
265
|
if (computeDiff && migrationAction === 'update') migrationActionEntry.diff = unifiedDiff(migrationRelPath, migrationDiskContent, migrationContent);
|
|
250
266
|
result.actions.push(migrationActionEntry);
|
|
251
267
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
268
|
+
const postEmitNotes = [
|
|
269
|
+
'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
|
|
270
|
+
// O4 (D-handle-lifecycle): HandleAspect.java only actually intercepts anything once a
|
|
271
|
+
// human applies @RecordHandleSnapshot to a real service method AND the target repo has
|
|
272
|
+
// this dependency -- never auto-added to build.gradle, same "review and apply yourself"
|
|
273
|
+
// boundary as the migration note above.
|
|
274
|
+
'NOT done automatically: HandleAspect.java requires spring-boot-starter-aop on your own build.gradle classpath (Spring AOP is not enabled by any other starter). Add it yourself before applying @RecordHandleSnapshot to any service method.',
|
|
275
|
+
];
|
|
276
|
+
// O3 follow-up (D-handle-registry-enforcement, "Continued"): per-resource, conditional on
|
|
277
|
+
// enforceRegistry actually being on -- see hasRecordHandleSnapshot() above.
|
|
278
|
+
if (enforceRegistry) {
|
|
279
|
+
for (const resource of plan.resources) {
|
|
280
|
+
if (!resource.willGenerateResolver) continue;
|
|
281
|
+
if (hasRecordHandleSnapshot(resource.service.file)) continue;
|
|
282
|
+
const relServiceFile = path.relative(repoRoot, resource.service.file);
|
|
283
|
+
postEmitNotes.push(
|
|
284
|
+
`${resource.type}: --enforce-registry is on, but no @RecordHandleSnapshot(...) was found anywhere in ${relServiceFile} -- this resource may never get its first HandleRegistry row, and every fetch()/patch() call against it will 404 until something registers it. Apply @RecordHandleSnapshot to ${resource.service.serviceType}'s own create-flow method (or call HandleService.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { ...result, postEmitNotes };
|
|
263
290
|
}
|
|
@@ -40,6 +40,16 @@ function dottedModulePath(file, importRoot) {
|
|
|
40
40
|
return rel.split(path.sep).join('.');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// O3 follow-up (D-handle-registry-enforcement, "Continued"): same static, coarse, per-resource
|
|
44
|
+
// presence check as java-spring's own -- see that file's identical comment for the false-
|
|
45
|
+
// positive/false-negative bias reasoning (unchanged here).
|
|
46
|
+
const RECORD_SNAPSHOT_RE = /@record_snapshot\s*\(/;
|
|
47
|
+
|
|
48
|
+
function hasRecordSnapshot(routeFilePath) {
|
|
49
|
+
if (!routeFilePath || !fs.existsSync(routeFilePath)) return false;
|
|
50
|
+
return RECORD_SNAPSHOT_RE.test(fs.readFileSync(routeFilePath, 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
// See DECISIONS.md D-handles-providers. G4 follow-up: migration.sql + a real recover() lifecycle
|
|
44
54
|
// (tables.py/handle_service.py/record_snapshot.py) are now generated, mirroring java-spring's own
|
|
45
55
|
// O4 work -- the EXCLUDED section's original "even Java hasn't got O4" reasoning is stale, see
|
|
@@ -197,5 +207,18 @@ export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter =
|
|
|
197
207
|
// and apply yourself" boundary as the migration note above.
|
|
198
208
|
postEmitNotes.push('NOT done automatically: applying @record_snapshot (handles/record_snapshot.py) to any of your own service functions. Codegen never touches existing business logic files.');
|
|
199
209
|
|
|
210
|
+
// O3 follow-up (D-handle-registry-enforcement, "Continued"): per-resource, conditional on
|
|
211
|
+
// enforceRegistry.
|
|
212
|
+
if (enforceRegistry) {
|
|
213
|
+
for (const resource of plan.resources) {
|
|
214
|
+
if (!resource.willGenerateResolver) continue;
|
|
215
|
+
if (hasRecordSnapshot(resource.fetchRoute.file)) continue;
|
|
216
|
+
const relRouteFile = path.relative(repoRoot, resource.fetchRoute.file);
|
|
217
|
+
postEmitNotes.push(
|
|
218
|
+
`${resource.type}: --enforce-registry is on, but no @record_snapshot(...) was found anywhere in ${relRouteFile} -- this resource may never get its first registry row, and every fetch/patch call against it will 404 until something registers it. Apply @record_snapshot to its own create-flow route function (or call handle_service.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
200
223
|
return { ...result, postEmitNotes };
|
|
201
224
|
}
|
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
|
+
}
|
package/lib/gate-definitions.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|