backend-skeleton 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +549 -63
  3. package/contracts/completeness.mjs +12 -1
  4. package/contracts/openapi.mjs +125 -18
  5. package/handles/providers/java-spring/ast-bridge.mjs +85 -1
  6. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
  7. package/handles/providers/java-spring/emit.mjs +126 -6
  8. package/handles/providers/java-spring/plan.mjs +220 -74
  9. package/handles/providers/java-spring/source-splice.mjs +477 -0
  10. package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
  11. package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
  12. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
  13. package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
  14. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
  15. package/lib/attest.mjs +59 -1
  16. package/lib/cli.mjs +125 -7
  17. package/lib/decision-log.mjs +58 -0
  18. package/lib/doctor.mjs +23 -0
  19. package/lib/exit-codes.mjs +17 -0
  20. package/lib/gate-definitions.mjs +65 -2
  21. package/lib/gate-export.mjs +250 -0
  22. package/lib/impact-export-graphify.mjs +145 -0
  23. package/lib/impact-graph.mjs +194 -0
  24. package/lib/impact-surface.mjs +158 -0
  25. package/lib/impact.mjs +334 -0
  26. package/lib/patch-kinds.mjs +24 -0
  27. package/lib/repo.mjs +46 -0
  28. package/lib/workflow.mjs +16 -0
  29. package/package.json +1 -1
  30. package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
  31. package/schemas/decision-event.schema.json +46 -0
  32. package/schemas/gate-attestation.schema.json +6 -1
  33. package/schemas/gate-export.schema.json +606 -22
  34. package/schemas/handles-plan.schema.json +32 -0
  35. package/schemas/impact-baseline.schema.json +59 -0
  36. package/schemas/impact-graph.schema.json +53 -0
  37. package/schemas/impact-report.schema.json +86 -0
  38. package/schemas/impact-resolution.schema.json +33 -0
  39. package/schemas/java-source-splice.schema.json +84 -0
  40. package/schemas/patch-transaction.schema.json +87 -2
@@ -0,0 +1,250 @@
1
+ // D-attestation-payload-completeness: pure report-construction logic for `bskel gate export`,
2
+ // pulled out of bin/bskel.mjs so it is unit-testable without spawning a CLI process -- the same
3
+ // split lib/verify.mjs and lib/gates.mjs already follow (CLI stays thin, real logic lives in lib/).
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { GATE_NAMES, gateScopeId } from './gate-definitions.mjs';
8
+ import { collectGateStatuses } from './verify.mjs';
9
+ import { getGate, historyPath } from './state.mjs';
10
+ import { requireNamedGate } from './gates.mjs';
11
+ import { sha256File } from './fsutil.mjs';
12
+ import { specPath, sbfPath } from './paths.mjs';
13
+ import { crossFeatureReportPath, crossFeatureResolutionPath } from './cross-feature-collisions.mjs';
14
+ import { dependenciesPath } from './field-dependencies.mjs';
15
+ import { impactBaselinePath } from './impact-surface.mjs';
16
+ import { impactReportPath, impactResolutionPath, loadImpactResolution } from './impact.mjs';
17
+ import { manifestPath } from './handles-manifest.mjs';
18
+ import { patchApprovalsPath, loadPatchApprovals } from './patch-approvals.mjs';
19
+ import { loadResolution } from '../contracts/completeness.mjs';
20
+ import { loadCrossFeatureResolution } from './cross-feature-collisions.mjs';
21
+ import { decisionLogPath, readDecisionLog } from './decision-log.mjs';
22
+ import { currentBranch, headSha, headTreeSha, worktreeStatus } from './repo.mjs';
23
+ import { validateAgainstSchema } from './schema-validate.mjs';
24
+ import { CANONICALIZATION_ID } from './attest.mjs';
25
+
26
+ export const EXPORT_SCHEMA_VERSION = 'sbf.gate-export/4';
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+ const SKILL_ROOT = path.resolve(__dirname, '..');
30
+
31
+ let cachedToolVersion = null;
32
+ // D-attestation-payload-completeness (K2): backend-skeleton's OWN package.json (this tool's
33
+ // version), never the TARGET repo's -- resolved from this module's own file location, the same
34
+ // derivation bin/bskel.mjs's SKILL_ROOT already uses. Memoized: it cannot change within one
35
+ // process, and building one report already calls `sha256File`/git a dozen times.
36
+ export function toolVersion() {
37
+ if (cachedToolVersion === null) {
38
+ cachedToolVersion = JSON.parse(fs.readFileSync(path.join(SKILL_ROOT, 'package.json'), 'utf8')).version;
39
+ }
40
+ return cachedToolVersion;
41
+ }
42
+
43
+ // S4 (D-gate-history), moved here from bin/bskel.mjs so both cmdGateHistory and
44
+ // buildGateExportReport read history through one implementation instead of two copies that could
45
+ // drift. Reads the append-only .sbf/<feature>.history.jsonl -- a corrupt/invalid line is skipped
46
+ // with a warning, not a hard failure, matching JSONL's own resilience rationale (see
47
+ // lib/state.mjs's appendGateEvent).
48
+ export function readGateHistory(root, featureId, gateName, { onWarning } = {}) {
49
+ const file = historyPath(root, featureId);
50
+ if (!fs.existsSync(file)) return [];
51
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
52
+ const events = [];
53
+ for (const [i, line] of lines.entries()) {
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(line);
57
+ } catch {
58
+ onWarning?.(`${file}:${i + 1}: not valid JSON, skipped`);
59
+ continue;
60
+ }
61
+ const { ok, errors } = validateAgainstSchema('gate-event.schema.json', parsed);
62
+ if (!ok) {
63
+ onWarning?.(`${file}:${i + 1}: does not match schemas/gate-event.schema.json, skipped`, errors);
64
+ continue;
65
+ }
66
+ if (parsed.gate === gateName) events.push(parsed);
67
+ }
68
+ return events;
69
+ }
70
+
71
+ // D-attestation-payload-completeness (K2/K8): the ONE place an attestation-bound artifact is
72
+ // declared -- adding a future artifact means adding one line here, the same single-source-of-
73
+ // truth argument D-gate-definitions made for GATE_NAMES. Each value is (root, featureId) => an
74
+ // absolute path; `test/gate-export-report.test.mjs` asserts this key set equals
75
+ // `schemas/gate-export.schema.json`'s own `artifacts.properties` key set, so the two cannot drift.
76
+ export const ARTIFACT_SOURCES = Object.freeze({
77
+ scan_report_hash: (root, featureId) => specPath(root, featureId, 'brownfield-scan.json'),
78
+ spec_hash: (root, featureId) => specPath(root, featureId, 'spec.md'),
79
+ contract_hash: (root, featureId) => specPath(root, featureId, 'contracts', `${featureId}.schema.json`),
80
+ contract_resolution_hash: (root, featureId) => specPath(root, featureId, 'contracts', `${featureId}.resolution.json`),
81
+ openapi_snapshot_hash: (root, featureId) => specPath(root, featureId, 'contracts', `${featureId}.openapi.snapshot.json`),
82
+ cross_feature_report_hash: (root, featureId) => crossFeatureReportPath(root, featureId),
83
+ cross_feature_resolution_hash: (root, featureId) => crossFeatureResolutionPath(root, featureId),
84
+ dependencies_hash: (root, featureId) => dependenciesPath(root, featureId),
85
+ rules_source_hash: (root, featureId) => specPath(root, featureId, 'rules.yaml'),
86
+ rules_compiled_hash: (root, featureId) => specPath(root, featureId, 'rules', `${featureId}.rules.json`),
87
+ conformance_report_hash: (root, featureId) => specPath(root, featureId, 'observe', `${featureId}.conformance-report.json`),
88
+ handles_manifest_hash: (root) => manifestPath(root),
89
+ stack_record_hash: (root) => sbfPath(root, 'stack.json'),
90
+ // D-cross-feature-impact-graph (IG4): additive, sbf.gate-export/2 -> /3 -- a /2 attestation
91
+ // still verifies (K7's own promise), it just won't have these three keys.
92
+ impact_baseline_hash: (root, featureId) => impactBaselinePath(root, featureId),
93
+ impact_report_hash: (root, featureId) => impactReportPath(root, featureId),
94
+ impact_resolution_hash: (root, featureId) => impactResolutionPath(root, featureId),
95
+ // D-decision-event-log (F3): patch-approvals.json was bound by NOTHING before this -- not a
96
+ // gate input, not an attestation artifact -- the exact same class of gap
97
+ // D-attestation-payload-completeness's finding #3 closed for .sbf/handles-manifest.json.
98
+ // sbf.gate-export/3 -> /4, additive (a /3 attestation still verifies, K7's own promise).
99
+ patch_approvals_hash: (root, featureId) => patchApprovalsPath(root, featureId),
100
+ });
101
+
102
+ export function collectArtifactHashes(root, featureId) {
103
+ const out = {};
104
+ for (const [key, resolvePath] of Object.entries(ARTIFACT_SOURCES)) {
105
+ out[key] = sha256File(resolvePath(root, featureId));
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // D-attestation-payload-completeness (K2): forced/revoked decisions rolled up from the gate
111
+ // records already in the payload -- zero new file reads. Waivers (contract_resolution/
112
+ // cross_feature_resolution) are represented by presence+hash only, never embedded content: the
113
+ // content is already bound via `artifacts` above, and embedding it again would duplicate that
114
+ // binding while ballooning the payload for no new guarantee.
115
+ // D-decision-event-log (D7): extended to cover all four spec-side decision files -- previously
116
+ // only 2 of 4 appeared in waiver_files, and NONE of the four's live entries were represented at
117
+ // all (only a hash). `records` mirrors gates.*.live's own "current vs live" distinction (K2):
118
+ // each kind's entries here are LIVE-evaluated (expired contract/impact entries excluded), never
119
+ // the raw stored array, so an expired-but-not-yet-renewed waiver never reads as still covering
120
+ // its warning inside a signed attestation. `event_counts` rolls up the decision log -- present
121
+ // even when a decision file itself is absent (an all-zero count is still informative: "no
122
+ // decisions of this kind were ever made here", distinct from "the file doesn't exist").
123
+ export function collectDecisions(root, featureId, gatesById, artifacts) {
124
+ const forced = [];
125
+ const revoked = [];
126
+ for (const [gate, entry] of Object.entries(gatesById)) {
127
+ const record = entry.current;
128
+ if (!record) continue;
129
+ if (record.forced) forced.push({ gate, scope: entry.scope, reason: record.reason ?? null, at: record.at ?? null });
130
+ if (record.status === 'revoked') revoked.push({ gate, scope: entry.scope, reason: record.reason ?? null, at: record.at ?? null });
131
+ }
132
+
133
+ const now = Date.now();
134
+ const contractResolution = loadResolution(root, featureId);
135
+ const liveContractWaivers = (contractResolution.waivers ?? [])
136
+ .filter((w) => !(typeof w.expires_at === 'string' && Date.parse(w.expires_at) <= now))
137
+ .map((w) => ({ code: w.code, subject: w.subject ?? null, reason: w.reason, at: w.at, expires_at: w.expires_at ?? null }));
138
+
139
+ const crossFeatureResolution = loadCrossFeatureResolution(root, featureId);
140
+ const liveCrossFeatureWaivers = (crossFeatureResolution.waivers ?? [])
141
+ .map((w) => ({ signal: w.signal, identifier: w.identifier, other_feature: w.other_feature, reason: w.reason, at: w.at }));
142
+
143
+ const impactResolution = loadImpactResolution(root, featureId);
144
+ const liveImpactDispositions = (impactResolution.dispositions ?? [])
145
+ .filter((d) => !(typeof d.expires_at === 'string' && Date.parse(d.expires_at) <= now))
146
+ .map((d) => ({ change_key: d.change_key, downstream_feature: d.downstream_feature, mode: d.mode, reason: d.reason, tracked_by: d.tracked_by ?? null, expires_at: d.expires_at ?? null, acknowledged: d.acknowledged ?? false }));
147
+
148
+ const patchApprovals = loadPatchApprovals(root, featureId);
149
+ const livePatchApprovals = (patchApprovals.approvals ?? [])
150
+ .map((a) => ({ resource: a.resource, field: a.field, strategy: a.strategy, reason: a.reason, at: a.at }));
151
+
152
+ const eventCounts = { contract_waiver: 0, cross_feature_waiver: 0, impact_disposition: 0, patch_approval: 0 };
153
+ for (const event of readDecisionLog(root, featureId)) {
154
+ if (Object.hasOwn(eventCounts, event.kind)) eventCounts[event.kind] += 1;
155
+ }
156
+
157
+ return {
158
+ forced,
159
+ revoked,
160
+ waiver_files: {
161
+ contract_resolution: { present: artifacts.contract_resolution_hash !== null, hash: artifacts.contract_resolution_hash },
162
+ cross_feature_resolution: { present: artifacts.cross_feature_resolution_hash !== null, hash: artifacts.cross_feature_resolution_hash },
163
+ impact_resolution: { present: artifacts.impact_resolution_hash !== null, hash: artifacts.impact_resolution_hash },
164
+ patch_approvals: { present: artifacts.patch_approvals_hash !== null, hash: artifacts.patch_approvals_hash },
165
+ },
166
+ records: {
167
+ contract_waivers: liveContractWaivers,
168
+ cross_feature_waivers: liveCrossFeatureWaivers,
169
+ impact_dispositions: liveImpactDispositions,
170
+ patch_approvals: livePatchApprovals,
171
+ },
172
+ event_counts: eventCounts,
173
+ };
174
+ }
175
+
176
+ // D-attestation-payload-completeness (K2/K3): pure derivation from `live` -- what a human or CI
177
+ // actually greps for, so they read one line instead of walking every gate.
178
+ export function buildVerdict(liveEntries) {
179
+ const blocking_gates = liveEntries.filter((g) => g.blocking).map((g) => g.gate);
180
+ const passing = liveEntries.filter((g) => g.status === 'pass' || g.status === 'pass (forced)').length;
181
+ return { blocking_gates, passing, total: liveEntries.length, ok: blocking_gates.length === 0 };
182
+ }
183
+
184
+ // D-attestation-payload-completeness (K2/K3): the report `bskel gate export` builds and (with
185
+ // --sign) signs. `gates[name].live` is RECOMPUTED at export time via lib/verify.mjs's
186
+ // collectGateStatuses() -- the exact same function `bskel verify` uses -- so a gate export and
187
+ // `bskel verify` can never disagree about the same repo's current state. `gates[name].current`
188
+ // stays the raw STORED record (unchanged from schema /1), so a reader sees both "what was last
189
+ // written" and "what is honestly true right now" side by side.
190
+ export function buildGateExportReport(root, featureId, { now = new Date(), dirtyAcknowledged = false, dirtyCap = 200 } = {}) {
191
+ const liveResults = collectGateStatuses(root, featureId, { getGate, requireNamedGate });
192
+ const liveByName = new Map(liveResults.map((g) => [g.gate, g]));
193
+
194
+ const gates = {};
195
+ for (const name of GATE_NAMES) {
196
+ const live = liveByName.get(name);
197
+ // collectGateStatuses()'s own `scope` field is the gate's DEFINITION scope type
198
+ // ('repo'|'feature'), not the resolved scope id -- gateScopeId() computes the real one
199
+ // (REPO_GATE_ID for a repo-scoped gate, or featureId itself), matching cmdGateExport's own
200
+ // pre-existing lookup exactly.
201
+ const scopeId = gateScopeId(name, featureId);
202
+ gates[name] = {
203
+ scope: scopeId,
204
+ current: getGate(root, scopeId, name),
205
+ history: readGateHistory(root, scopeId, name),
206
+ live: {
207
+ policy: live.policy,
208
+ status: live.status,
209
+ blocking: live.blocking,
210
+ ran: live.ran,
211
+ current_token: live.currentToken ?? null,
212
+ stale_reason: live.stale_reason ?? null,
213
+ changed_inputs: live.changed_inputs && live.changed_inputs.length > 0 ? live.changed_inputs : null,
214
+ },
215
+ };
216
+ }
217
+
218
+ const artifacts = collectArtifactHashes(root, featureId);
219
+ const decisions = collectDecisions(root, featureId, gates, artifacts);
220
+ const verdict = buildVerdict(Object.entries(gates).map(([gate, g]) => ({ gate, blocking: g.live.blocking, status: g.live.status })));
221
+
222
+ const status = worktreeStatus(root, { cap: dirtyCap });
223
+ const dirty = status === null ? null : status.count > 0;
224
+
225
+ return {
226
+ schema: EXPORT_SCHEMA_VERSION,
227
+ feature_id: featureId,
228
+ generated_at: now.toISOString(),
229
+ tool: {
230
+ name: 'bskel',
231
+ version: toolVersion(),
232
+ gate_names: [...GATE_NAMES],
233
+ canonicalization: CANONICALIZATION_ID,
234
+ },
235
+ git: {
236
+ branch: currentBranch(root),
237
+ head_sha: headSha(root),
238
+ dirty,
239
+ head_tree_sha: headTreeSha(root),
240
+ dirty_acknowledged: Boolean(dirtyAcknowledged),
241
+ dirty_file_count: status?.count ?? 0,
242
+ dirty_files_truncated: status?.truncated ?? false,
243
+ dirty_files: status?.entries ?? [],
244
+ },
245
+ artifacts,
246
+ gates,
247
+ decisions,
248
+ verdict,
249
+ };
250
+ }
@@ -0,0 +1,145 @@
1
+ // D-cross-feature-impact-graph (IG8/IG9): the ONE seam to the LLM-driven exploration layer -- writes
2
+ // graphify's own extraction file shape (graphify.build.build_from_json's input contract), bypassing
3
+ // its Steps 1-3 (install/detect/extract) entirely, so `bskel impact export --format graphify` needs
4
+ // no LLM call, no subagent, no network. `impact-atlas` (or any other consumer) runs `build_from_json`
5
+ // + `cluster` + `to_obsidian` on this file -- see DECISIONS.md's IG8 for the exact commands.
6
+ //
7
+ // The confidence mapping is the keystone: `proven` -> EXTRACTED, `heuristic` -> INFERRED. AMBIGUOUS
8
+ // is NEVER emitted -- this graph has no guessed edges (see lib/impact-graph.mjs's own header), so
9
+ // there is nothing honest to put there. A dedicated test (test/impact-export-graphify.test.mjs)
10
+ // asserts this file's output against graphify's own real REQUIRED_NODE_FIELDS/REQUIRED_EDGE_FIELDS/
11
+ // VALID_FILE_TYPES/VALID_CONFIDENCES constants, checked in as data -- the same "one declared place,
12
+ // asserted equal" device D-attestation-payload-completeness's K8 uses for ARTIFACT_SOURCES.
13
+ //
14
+ // IG9 (Focus+Context, Lamping/Rao/Pirolli CHI'95): implemented as a DATA PROJECTION, not a renderer
15
+ // -- `ring` (geodesic hop distance from --focus over the impact graph's own edges) and `detail`
16
+ // (progressively coarser as ring grows) are computed here and written into the export ONLY, never
17
+ // into anything a gate reads. Every downstream consumer (Obsidian's graph view, graphify's own
18
+ // --html, Mermaid, Neo4j) inherits the same compression for free.
19
+ const NODE_TYPE_TO_FILE_TYPE = { feature: 'document', operation: 'code', resource: 'code', table: 'document', field: 'code' };
20
+ const RELATION_CONFIDENCE_SCORE = { EXTRACTED: 1.0, INFERRED: 0.7 };
21
+
22
+ function graphifyConfidence(edgeConfidence) {
23
+ return edgeConfidence === 'proven' ? 'EXTRACTED' : 'INFERRED';
24
+ }
25
+
26
+ function sourceFileFor(node) {
27
+ if (node.file) return node.file;
28
+ if (node.type === 'feature') return `specs/${node.id}/feature.json`;
29
+ if (node.type === 'table') return '.sbf/impact-graph.json';
30
+ return '.sbf/impact-graph.json';
31
+ }
32
+
33
+ function nodeIdSafe(id) {
34
+ // graphify's own convention: lowercase, only [a-z0-9_] -- see its SKILL.md's Node ID format.
35
+ // bskel's own ids ("001-org::Organization", "001-org#getOrg") stay human-legible in `label`;
36
+ // this is only the graphify-facing id.
37
+ return id.toLowerCase().replace(/[^a-z0-9_]/g, '_');
38
+ }
39
+
40
+ // BFS hop distance from `focusId` over the graph's edges treated as undirected -- Focus+Context
41
+ // has no notion of edge direction, only "how far is this from what I'm looking at".
42
+ function hopDistances(graph, focusId) {
43
+ const adj = new Map();
44
+ const link = (a, b) => { if (!adj.has(a)) adj.set(a, new Set()); adj.get(a).add(b); };
45
+ for (const e of graph.edges) { link(e.source, e.target); link(e.target, e.source); }
46
+ const dist = new Map([[focusId, 0]]);
47
+ const queue = [focusId];
48
+ while (queue.length > 0) {
49
+ const cur = queue.shift();
50
+ for (const next of adj.get(cur) ?? []) {
51
+ if (dist.has(next)) continue;
52
+ dist.set(next, dist.get(cur) + 1);
53
+ queue.push(next);
54
+ }
55
+ }
56
+ return dist;
57
+ }
58
+
59
+ function detailForRing(ring) {
60
+ if (ring === 0) return 'full';
61
+ if (ring === 1) return 'resource';
62
+ if (ring === 2) return 'feature';
63
+ return 'collapsed';
64
+ }
65
+
66
+ export function toGraphifyExtraction(graph, { focus = null, rings = null } = {}) {
67
+ let nodes = graph.nodes;
68
+ let edges = graph.edges;
69
+ let ringOf = null;
70
+
71
+ if (focus) {
72
+ const dist = hopDistances(graph, focus);
73
+ ringOf = dist;
74
+ if (Number.isInteger(rings)) {
75
+ const kept = [];
76
+ let collapsedCount = 0;
77
+ for (const n of graph.nodes) {
78
+ const d = dist.has(n.id) ? dist.get(n.id) : Infinity;
79
+ if (d <= rings) kept.push(n);
80
+ else collapsedCount++;
81
+ }
82
+ const keptIds = new Set(kept.map((n) => n.id));
83
+ const collapsedId = '__collapsed__';
84
+ const redirected = [];
85
+ const seenRedirect = new Set();
86
+ for (const e of graph.edges) {
87
+ const sIn = keptIds.has(e.source);
88
+ const tIn = keptIds.has(e.target);
89
+ if (sIn && tIn) { redirected.push(e); continue; }
90
+ if (!sIn && !tIn) continue; // both collapsed -- drop, nothing new to show
91
+ const kept_ = sIn ? e.source : e.target;
92
+ const key = `${kept_}->${collapsedId}`;
93
+ if (seenRedirect.has(key)) continue;
94
+ seenRedirect.add(key);
95
+ redirected.push({ source: sIn ? kept_ : collapsedId, target: sIn ? collapsedId : kept_, relation: e.relation, confidence: e.confidence, basis: e.basis });
96
+ }
97
+ nodes = collapsedCount > 0
98
+ ? [...kept, { id: collapsedId, type: 'collapsed', label: `…${collapsedCount} more`, feature_id: null, file: null, attrs: { count: collapsedCount } }]
99
+ : kept;
100
+ edges = redirected;
101
+ }
102
+ }
103
+
104
+ const extractedNodes = nodes.map((n) => {
105
+ const ring = ringOf && n.id !== '__collapsed__' ? (ringOf.has(n.id) ? Math.min(ringOf.get(n.id), (rings ?? Infinity) + 1) : null) : null;
106
+ return {
107
+ id: nodeIdSafe(n.id),
108
+ label: n.label,
109
+ file_type: n.type === 'collapsed' ? 'document' : (NODE_TYPE_TO_FILE_TYPE[n.type] ?? 'document'),
110
+ source_file: n.type === 'collapsed' ? '.sbf/impact-graph.json' : sourceFileFor(n),
111
+ source_location: null,
112
+ sbf_type: n.type,
113
+ sbf_feature: n.feature_id,
114
+ ...(focus ? { ring, detail: n.id === '__collapsed__' ? 'collapsed' : detailForRing(ring ?? 0) } : {}),
115
+ ...(n.type === 'collapsed' ? { sbf_count: n.attrs.count } : {}),
116
+ };
117
+ });
118
+
119
+ const extractedEdges = edges.map((e) => {
120
+ const confidence = graphifyConfidence(e.confidence);
121
+ return {
122
+ source: nodeIdSafe(e.source),
123
+ target: nodeIdSafe(e.target),
124
+ relation: e.relation,
125
+ confidence,
126
+ confidence_score: RELATION_CONFIDENCE_SCORE[confidence],
127
+ source_file: e.basis?.artifact ?? '.sbf/impact-graph.json',
128
+ source_location: e.basis?.locator ?? null,
129
+ sbf_basis_sha256: e.basis?.artifact_sha256 ?? null,
130
+ };
131
+ });
132
+
133
+ return { nodes: extractedNodes, edges: extractedEdges, input_tokens: 0, output_tokens: 0 };
134
+ }
135
+
136
+ export function toMermaid(graph) {
137
+ const lines = ['graph LR'];
138
+ const safe = (id) => `"${id.replace(/"/g, '\'')}"`;
139
+ for (const n of graph.nodes) lines.push(` ${nodeIdSafe(n.id)}[${safe(n.label)}]`);
140
+ for (const e of graph.edges) {
141
+ const arrow = e.confidence === 'proven' ? '-->' : '-.->';
142
+ lines.push(` ${nodeIdSafe(e.source)} ${arrow}|${e.relation}| ${nodeIdSafe(e.target)}`);
143
+ }
144
+ return lines.join('\n');
145
+ }
@@ -0,0 +1,194 @@
1
+ // D-cross-feature-impact-graph: builds the deterministic reference-links graph -- exact identities
2
+ // only (contract operations, scan-report resourceTypes/tables, declared dependencies.json edges,
3
+ // cross-feature-report.json findings), every one already hashed by an existing gate. Zero new
4
+ // source-scanning: every fact here is read from a file some OTHER command already wrote and
5
+ // validated. No `calls` relation, no inferred/guessed edge -- service-to-service and dynamic-client
6
+ // dependency inference is Codex's own named boundary (see DECISIONS.md's EXIT list) and a missing
7
+ // edge here is honest; a guessed one would be gate fatigue.
8
+ //
9
+ // Deliberately NOT called from lib/gate-definitions.mjs's `impact.recompute()` -- this does real
10
+ // work (O(features x artifacts) file reads), which is exactly why the gate stays a pure
11
+ // sha256File() token over impact-baseline.json/impact-report.json/impact-resolution.json instead.
12
+ //
13
+ // Scope cut from the original design (disclosed, not silent): a `handle` node type (UUID field
14
+ // pointer, backed by `handles plan`'s live output) was planned but is NOT built in this pass --
15
+ // `bskel handles plan` never persists a handles-plan.json (verified live: cmdHandlesPlan's own
16
+ // comment says "that command never writes, dryRun always"), so producing a handle node here would
17
+ // mean calling provider.plan() live inside a graph builder, with its own adapter-capability-gating
18
+ // and error surface. Left as a named follow-up (see DECISIONS.md's EXIT list) rather than half-built.
19
+ import path from 'node:path';
20
+ import { readJsonIfExists, sha256File } from './fsutil.mjs';
21
+ import { specPath } from './paths.mjs';
22
+ import { listFeatures } from './featurelifecycle.mjs';
23
+ import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
24
+ import { loadFieldDependencies } from './field-dependencies.mjs';
25
+ import { loadCrossFeatureReport } from './cross-feature-collisions.mjs';
26
+
27
+ const IMPACT_GRAPH_SCHEMA = 'sbf.impact-graph/1';
28
+
29
+ function ownDisposedModule(root, featureId) {
30
+ const report = hydrateScanReportFilePaths(readJsonIfExists(specPath(root, featureId, 'brownfield-scan.json')), root);
31
+ if (!report) return null;
32
+ const moduleName = report.disposition?.module ?? report.related_modules?.[0]?.module;
33
+ if (!moduleName) return null;
34
+ return report.related_modules?.find((m) => m.module === moduleName) ?? null;
35
+ }
36
+
37
+ function ownClasses(root, featureId) {
38
+ const mod = ownDisposedModule(root, featureId);
39
+ return mod ? [...(mod.entities ?? []), ...(mod.dtos ?? [])] : [];
40
+ }
41
+
42
+ function loadOwnContract(root, featureId) {
43
+ return readJsonIfExists(specPath(root, featureId, 'contracts', `${featureId}.schema.json`));
44
+ }
45
+
46
+ function resourceNodeId(featureId, resourceType) {
47
+ return `${featureId}::${resourceType}`;
48
+ }
49
+
50
+ function tableNodeId(tableName) {
51
+ return `table::${tableName.toLowerCase()}`;
52
+ }
53
+
54
+ function operationNodeId(featureId, opId) {
55
+ return `${featureId}#${opId}`;
56
+ }
57
+
58
+ function fieldNodeId(featureId, resourceType, fieldName) {
59
+ return `${featureId}::${resourceType}.${fieldName}`;
60
+ }
61
+
62
+ // Parses a `db_foreign_key` finding's flattened `"a.b -> c.d"` identifier back into its four parts
63
+ // (the same shape flattenLiveForeignKeys()/findCollisions() in lib/cross-feature-collisions.mjs
64
+ // composed it from -- there is no richer, unflattened form persisted anywhere to read instead).
65
+ function parseFkIdentifier(identifier) {
66
+ const m = identifier.match(/^(.+)\.([^.]+) -> (.+)\.([^.]+)$/);
67
+ if (!m) return null;
68
+ return { table: m[1], column: m[2], referencesTable: m[3], referencesColumn: m[4] };
69
+ }
70
+
71
+ function confidenceOf(finding) {
72
+ return finding.confidence === 'high' ? 'proven' : 'heuristic';
73
+ }
74
+
75
+ // D-cross-feature-impact-graph (D2): the pure graph builder. Returns { schema, generated_at, nodes,
76
+ // edges } matching schemas/impact-graph.schema.json. `nowIso` is injected (never Date.now()/`new
77
+ // Date()` computed internally past this one seam) so callers -- and this module's own tests -- can
78
+ // pin a deterministic generated_at.
79
+ export function buildImpactGraph(root, { nowIso = new Date().toISOString() } = {}) {
80
+ const nodes = new Map();
81
+ const edges = [];
82
+ const addNode = (id, type, label, extra = {}) => {
83
+ if (!nodes.has(id)) nodes.set(id, { id, type, label, feature_id: extra.feature_id ?? null, file: extra.file ?? null, attrs: extra.attrs ?? {} });
84
+ return id;
85
+ };
86
+ const addEdge = (source, target, relation, confidence, basis) => {
87
+ edges.push({ source, target, relation, confidence, basis });
88
+ };
89
+ const relBasis = (root_, relPath, locator) => {
90
+ const abs = specPath(root_, ...relPath);
91
+ return { artifact: path.relative(root_, abs), artifact_sha256: sha256File(abs), locator };
92
+ };
93
+
94
+ const features = listFeatures(root);
95
+ const resourceTableOf = new Map(); // "<fid>::<Type>" -> table node id, for maps_to_table lookups below
96
+
97
+ for (const record of features) {
98
+ const fid = record.feature_id;
99
+ addNode(fid, 'feature', fid);
100
+
101
+ // operations, from the emitted contract
102
+ const contract = loadOwnContract(root, fid);
103
+ if (contract?.operations) {
104
+ const contractRel = path.relative(root, specPath(root, fid, 'contracts', `${fid}.schema.json`));
105
+ const contractSha = sha256File(specPath(root, fid, 'contracts', `${fid}.schema.json`));
106
+ for (const [opId, op] of Object.entries(contract.operations)) {
107
+ const nodeId = operationNodeId(fid, opId);
108
+ addNode(nodeId, 'operation', opId, { feature_id: fid, attrs: { verb: op.verb ?? null, path: op.path ?? null } });
109
+ addEdge(fid, nodeId, 'declares_operation', 'proven', { artifact: contractRel, artifact_sha256: contractSha, locator: `operations.${opId}` });
110
+ }
111
+ }
112
+
113
+ // resources (+ their table mapping), from the disposed scan-report module
114
+ for (const cls of ownClasses(root, fid)) {
115
+ const nodeId = resourceNodeId(fid, cls.className);
116
+ const rel = cls.file ? path.relative(root, cls.file) : null;
117
+ addNode(nodeId, 'resource', cls.className, { feature_id: fid, file: rel, attrs: { table: cls.table ?? null, table_source: cls.tableSource ?? null } });
118
+ const scanRel = path.relative(root, specPath(root, fid, 'brownfield-scan.json'));
119
+ const scanSha = sha256File(specPath(root, fid, 'brownfield-scan.json'));
120
+ addEdge(fid, nodeId, 'owns_resource', 'proven', { artifact: scanRel, artifact_sha256: scanSha, locator: `related_modules[].entities|dtos[className=${cls.className}]` });
121
+ if (cls.table) {
122
+ const tId = tableNodeId(cls.table);
123
+ addNode(tId, 'table', cls.table.toLowerCase());
124
+ const confidence = cls.tableSource === 'explicit' ? 'proven' : 'heuristic';
125
+ addEdge(nodeId, tId, 'maps_to_table', confidence, { artifact: scanRel, artifact_sha256: scanSha, locator: `related_modules[].entities[className=${cls.className}].table` });
126
+ resourceTableOf.set(nodeId, tId);
127
+ }
128
+ }
129
+
130
+ // fields, and derives_from edges -- only fields a declared dependency already named
131
+ const deps = loadFieldDependencies(root, fid);
132
+ if (deps.dependencies.length > 0) {
133
+ const depsRel = path.relative(root, specPath(root, fid, 'dependencies.json'));
134
+ const depsSha = sha256File(specPath(root, fid, 'dependencies.json'));
135
+ for (const dep of deps.dependencies) {
136
+ const targetId = fieldNodeId(fid, dep.target.resourceType, dep.target.fieldName);
137
+ const sourceId = fieldNodeId(dep.source.feature, dep.source.resourceType, dep.source.fieldName);
138
+ addNode(targetId, 'field', `${dep.target.resourceType}.${dep.target.fieldName}`, { feature_id: fid });
139
+ addNode(sourceId, 'field', `${dep.source.resourceType}.${dep.source.fieldName}`, { feature_id: dep.source.feature });
140
+ addEdge(targetId, sourceId, 'derives_from', 'proven', { artifact: depsRel, artifact_sha256: depsSha, locator: `dependencies[target.fieldName=${dep.target.fieldName}]` });
141
+ }
142
+ }
143
+ }
144
+
145
+ // cross-feature findings: fk_references + name_collides_with (resource_type/operation_id only --
146
+ // a `table` collision is already visible structurally, as two resources' maps_to_table edges
147
+ // converging on the same shared table:: node, so a redundant collision edge is skipped there).
148
+ for (const record of features) {
149
+ const fid = record.feature_id;
150
+ const report = loadCrossFeatureReport(root, fid);
151
+ if (!report?.findings) continue;
152
+ const reportRel = path.relative(root, specPath(root, fid, 'cross-feature-report.json'));
153
+ const reportSha = sha256File(specPath(root, fid, 'cross-feature-report.json'));
154
+
155
+ for (const finding of report.findings) {
156
+ if (finding.signal === 'db_foreign_key') {
157
+ const parsed = parseFkIdentifier(finding.identifier);
158
+ if (!parsed) continue;
159
+ const childId = tableNodeId(parsed.table);
160
+ const parentId = tableNodeId(parsed.referencesTable);
161
+ addNode(childId, 'table', parsed.table.toLowerCase());
162
+ addNode(parentId, 'table', parsed.referencesTable.toLowerCase());
163
+ addEdge(childId, parentId, 'fk_references', confidenceOf(finding), { artifact: reportRel, artifact_sha256: reportSha, locator: `findings[signal=db_foreign_key,identifier=${finding.identifier}]` });
164
+ } else if (finding.signal === 'resource_type') {
165
+ const ownId = resourceNodeId(fid, finding.identifier);
166
+ const otherId = resourceNodeId(finding.other_feature, finding.identifier);
167
+ if (nodes.has(ownId) && nodes.has(otherId)) {
168
+ addEdge(ownId, otherId, 'name_collides_with', confidenceOf(finding), { artifact: reportRel, artifact_sha256: reportSha, locator: `findings[signal=resource_type,identifier=${finding.identifier}]` });
169
+ }
170
+ } else if (finding.signal === 'operation_id') {
171
+ const ownId = operationNodeId(fid, finding.identifier);
172
+ const otherId = operationNodeId(finding.other_feature, finding.identifier);
173
+ if (nodes.has(ownId) && nodes.has(otherId)) {
174
+ addEdge(ownId, otherId, 'name_collides_with', confidenceOf(finding), { artifact: reportRel, artifact_sha256: reportSha, locator: `findings[signal=operation_id,identifier=${finding.identifier}]` });
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ return { schema: IMPACT_GRAPH_SCHEMA, generated_at: nowIso, nodes: [...nodes.values()], edges };
181
+ }
182
+
183
+ // D-cross-feature-impact-graph: one-hop reverse lookup -- every downstream feature whose
184
+ // dependencies.json names a (featureId, resourceType) pair on the SOURCE side, i.e. depends on it.
185
+ // Used by lib/impact.mjs's outbound-impact walk; kept here (not in lib/impact.mjs) since it is a
186
+ // pure graph query, not a check/accept/disposition operation.
187
+ export function downstreamFeaturesOf(graph, resourceOrOperationNodeId, relation) {
188
+ const out = new Set();
189
+ for (const edge of graph.edges) {
190
+ if (edge.relation !== relation) continue;
191
+ if (edge.target === resourceOrOperationNodeId) out.add({ featureNodeId: edge.source, edge });
192
+ }
193
+ return [...out];
194
+ }