backend-skeleton 1.5.0 → 1.6.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 (37) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +331 -53
  3. package/contracts/openapi.mjs +125 -18
  4. package/handles/providers/java-spring/ast-bridge.mjs +85 -1
  5. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
  6. package/handles/providers/java-spring/emit.mjs +126 -6
  7. package/handles/providers/java-spring/plan.mjs +220 -74
  8. package/handles/providers/java-spring/source-splice.mjs +477 -0
  9. package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
  10. package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
  11. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
  12. package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
  13. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
  14. package/lib/attest.mjs +59 -1
  15. package/lib/cli.mjs +79 -7
  16. package/lib/doctor.mjs +23 -0
  17. package/lib/exit-codes.mjs +17 -0
  18. package/lib/gate-definitions.mjs +65 -2
  19. package/lib/gate-export.mjs +199 -0
  20. package/lib/impact-export-graphify.mjs +145 -0
  21. package/lib/impact-graph.mjs +194 -0
  22. package/lib/impact-surface.mjs +158 -0
  23. package/lib/impact.mjs +286 -0
  24. package/lib/patch-kinds.mjs +24 -0
  25. package/lib/repo.mjs +46 -0
  26. package/lib/workflow.mjs +16 -0
  27. package/package.json +1 -1
  28. package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
  29. package/schemas/gate-attestation.schema.json +6 -1
  30. package/schemas/gate-export.schema.json +530 -22
  31. package/schemas/handles-plan.schema.json +32 -0
  32. package/schemas/impact-baseline.schema.json +59 -0
  33. package/schemas/impact-graph.schema.json +53 -0
  34. package/schemas/impact-report.schema.json +86 -0
  35. package/schemas/impact-resolution.schema.json +33 -0
  36. package/schemas/java-source-splice.schema.json +84 -0
  37. package/schemas/patch-transaction.schema.json +87 -2
@@ -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
+ }
@@ -0,0 +1,158 @@
1
+ // D-cross-feature-impact-graph (D3): a feature's own public-surface projection + field-level change
2
+ // detection, entirely git-independent (specs/ is commonly gitignored -- see D-contract-history's
3
+ // own finding, which is why `bskel contract history`'s git-diff approach cannot be reused here).
4
+ // *_shape_hash reuses lib/attest.mjs's canonicalization (K1) verbatim -- one implementation of
5
+ // "what does it mean for two JSON values to be the same", not a second one that could disagree.
6
+ import { readJsonIfExists, sha256File, sha256String, writeFileAtomic } from './fsutil.mjs';
7
+ import { specPath } from './paths.mjs';
8
+ import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
9
+ import { canonicalize, assertCanonicalizable } from './attest.mjs';
10
+ import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
11
+ import { loadFieldDependencies, listDownstreamDependents } from './field-dependencies.mjs';
12
+
13
+ const BASELINE_SCHEMA = 'sbf.impact-baseline/1';
14
+
15
+ function shapeHash(schema) {
16
+ if (schema === undefined || schema === null || schema === false) return null;
17
+ assertCanonicalizable(schema);
18
+ return sha256String(canonicalize(schema));
19
+ }
20
+
21
+ function ownDisposedModule(root, featureId) {
22
+ const report = hydrateScanReportFilePaths(readJsonIfExists(specPath(root, featureId, 'brownfield-scan.json')), root);
23
+ if (!report) return null;
24
+ const moduleName = report.disposition?.module ?? report.related_modules?.[0]?.module;
25
+ if (!moduleName) return null;
26
+ return report.related_modules?.find((m) => m.module === moduleName) ?? null;
27
+ }
28
+
29
+ // D-cross-feature-impact-graph (D3): the current-moment surface for one feature, built entirely
30
+ // from the already-emitted contract + already-persisted scan report + already-declared
31
+ // dependencies.json -- zero new source scanning, zero live DB, zero LLM.
32
+ export function computeSurface(root, featureId) {
33
+ const contract = readJsonIfExists(specPath(root, featureId, 'contracts', `${featureId}.schema.json`));
34
+ const operations = {};
35
+ for (const [opId, op] of Object.entries(contract?.operations ?? {})) {
36
+ operations[opId] = {
37
+ verb: op.verb ?? null,
38
+ path: op.path ?? null,
39
+ request_shape_hash: shapeHash(op.requestBodySchema),
40
+ response_shape_hash: shapeHash(op.responseSchema),
41
+ error_shape_hash: shapeHash(op.errorSchema),
42
+ };
43
+ }
44
+
45
+ const mod = ownDisposedModule(root, featureId);
46
+ const classes = mod ? [...(mod.entities ?? []), ...(mod.dtos ?? [])] : [];
47
+ const resources = {};
48
+ for (const cls of classes) {
49
+ resources[cls.className] = {
50
+ file_sha256: cls.file ? sha256File(cls.file) : null,
51
+ table: cls.table ?? null,
52
+ table_source: cls.tableSource ?? null,
53
+ };
54
+ }
55
+
56
+ // Deliberately sparse (D3's own stated limitation, mirroring D-field-dependency's EXIT): only
57
+ // fields SOMETHING has already named -- there is no per-adapter field enumerator to draw the
58
+ // full set from. Two directions, both needed: (a) fields THIS feature's own dependencies.json
59
+ // names as a TARGET (so removing/moving the file it depends on shows up on ITS OWN surface, for
60
+ // symmetry/debugging) and (b) fields OTHER features' dependencies.json name as THIS feature's
61
+ // SOURCE (via listDownstreamDependents() -- the exact reverse lookup describeDownstreamImpact()
62
+ // already uses) -- (b) is the one that actually matters for impact detection: it is what lets a
63
+ // change on the UPSTREAM/source feature's own `impact check` see "a field of mine that a
64
+ // downstream feature depends on just moved", not just the downstream feature seeing its own
65
+ // dependency go stale. Missing (b) would mean `field_source_moved` could only ever appear on the
66
+ // declaring (downstream) feature's own surface -- structurally unable to catch the upstream
67
+ // change this whole item exists to gate on. Found and fixed while writing this module's own
68
+ // headline test.
69
+ const deps = loadFieldDependencies(root, featureId);
70
+ const fields = {};
71
+ for (const dep of deps.dependencies) {
72
+ const resolved = classes.find((c) => c.className === dep.target.resourceType);
73
+ fields[`${dep.target.resourceType}.${dep.target.fieldName}`] = {
74
+ source_file_sha256: resolved?.file ? sha256File(resolved.file) : null,
75
+ };
76
+ }
77
+ for (const { dep } of listDownstreamDependents(root, featureId)) {
78
+ const key = `${dep.source.resourceType}.${dep.source.fieldName}`;
79
+ if (key in fields) continue;
80
+ const resolved = classes.find((c) => c.className === dep.source.resourceType);
81
+ fields[key] = { source_file_sha256: resolved?.file ? sha256File(resolved.file) : null };
82
+ }
83
+
84
+ return { operations, resources, fields };
85
+ }
86
+
87
+ export function impactBaselinePath(root, featureId) {
88
+ return specPath(root, featureId, 'impact-baseline.json');
89
+ }
90
+
91
+ export function loadBaseline(root, featureId) {
92
+ const p = impactBaselinePath(root, featureId);
93
+ const parsed = readJsonIfExists(p);
94
+ if (parsed === null) return null;
95
+ const { ok, errors } = validateAgainstSchema('impact-baseline.schema.json', parsed);
96
+ if (!ok) throw new Error(`${p}: does not match schemas/impact-baseline.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
97
+ return parsed;
98
+ }
99
+
100
+ export function saveBaseline(root, featureId, surface, { capturedAt = new Date().toISOString() } = {}) {
101
+ const doc = { schema: BASELINE_SCHEMA, feature_id: featureId, captured_at: capturedAt, surface };
102
+ const { ok, errors } = validateAgainstSchema('impact-baseline.schema.json', doc);
103
+ if (!ok) throw new Error(`refusing to write an invalid impact baseline for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
104
+ writeFileAtomic(impactBaselinePath(root, featureId), `${JSON.stringify(doc, null, 2)}\n`);
105
+ return doc;
106
+ }
107
+
108
+ // D-cross-feature-impact-graph (D3): field-level change KINDS, not a full structural diff --
109
+ // naming what changed inside a subtree would need a real schema differ this project doesn't have
110
+ // (see D3's own EXIT, framing a future structural differ as an additive refinement). Deliberately
111
+ // no field_added/field_removed for the SAME reason computeSurface()'s `fields` is sparse: this
112
+ // project cannot honestly enumerate a resource's full field set, so it cannot honestly claim one
113
+ // was added or removed -- only that the file backing an ALREADY-DECLARED field moved
114
+ // (`field_source_moved`).
115
+ export function diffSurface(before, after) {
116
+ const b = before ?? { operations: {}, resources: {}, fields: {} };
117
+ const changes = [];
118
+
119
+ const opIds = new Set([...Object.keys(b.operations ?? {}), ...Object.keys(after.operations ?? {})]);
120
+ for (const opId of opIds) {
121
+ const prev = b.operations?.[opId];
122
+ const next = after.operations?.[opId];
123
+ if (!next) { changes.push({ kind: 'operation_removed', subject: opId, from: prev, to: null }); continue; }
124
+ if (!prev) { changes.push({ kind: 'operation_added', subject: opId, from: null, to: next }); continue; }
125
+ if (prev.verb !== next.verb) changes.push({ kind: 'operation_verb_changed', subject: opId, from: prev.verb, to: next.verb });
126
+ if (prev.path !== next.path) changes.push({ kind: 'operation_path_changed', subject: opId, from: prev.path, to: next.path });
127
+ if (prev.request_shape_hash !== next.request_shape_hash) changes.push({ kind: 'operation_request_shape_changed', subject: opId, from: prev.request_shape_hash, to: next.request_shape_hash });
128
+ if (prev.response_shape_hash !== next.response_shape_hash) changes.push({ kind: 'operation_response_shape_changed', subject: opId, from: prev.response_shape_hash, to: next.response_shape_hash });
129
+ if (prev.error_shape_hash !== next.error_shape_hash) changes.push({ kind: 'operation_error_shape_changed', subject: opId, from: prev.error_shape_hash, to: next.error_shape_hash });
130
+ }
131
+
132
+ const resourceTypes = new Set([...Object.keys(b.resources ?? {}), ...Object.keys(after.resources ?? {})]);
133
+ for (const type of resourceTypes) {
134
+ const prev = b.resources?.[type];
135
+ const next = after.resources?.[type];
136
+ if (!next) { changes.push({ kind: 'resource_removed', subject: type, from: prev, to: null }); continue; }
137
+ if (!prev) continue; // a brand-new resource has no downstream yet (nothing could depend on it before it existed)
138
+ if (prev.table !== next.table) changes.push({ kind: 'resource_table_changed', subject: type, from: prev.table, to: next.table });
139
+ }
140
+
141
+ const fieldKeys = new Set([...Object.keys(b.fields ?? {}), ...Object.keys(after.fields ?? {})]);
142
+ for (const key of fieldKeys) {
143
+ const prev = b.fields?.[key];
144
+ const next = after.fields?.[key];
145
+ if (!prev || !next) continue; // add/remove of a declared-dependency field is covered by the dependency's own gate, not this one
146
+ if (prev.source_file_sha256 !== next.source_file_sha256) changes.push({ kind: 'field_source_moved', subject: key, from: prev.source_file_sha256, to: next.source_file_sha256 });
147
+ }
148
+
149
+ return changes.map((c) => ({ ...c, change_key: changeKey(c) }));
150
+ }
151
+
152
+ // The anti-rubber-stamp key: embeds the NEW hash, so a disposition recorded for one shape can
153
+ // never cover a LATER, different change to the same subject -- there is no wildcard by
154
+ // construction, matching cross-feature-resolution.schema.json's own stated discipline.
155
+ export function changeKey(change) {
156
+ const digest = sha256String(canonicalize({ kind: change.kind, subject: change.subject, to: change.to }));
157
+ return `${change.kind}:${change.subject}:${digest.slice(0, 12)}`;
158
+ }