backend-skeleton 1.4.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.
- package/README.md +113 -8
- package/bin/bskel.mjs +331 -53
- package/contracts/emit.mjs +22 -6
- package/contracts/openapi.mjs +125 -18
- package/handles/providers/java-spring/ast-bridge.mjs +85 -1
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
- package/handles/providers/java-spring/emit.mjs +126 -6
- package/handles/providers/java-spring/plan.mjs +244 -77
- package/handles/providers/java-spring/source-splice.mjs +477 -0
- package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
- package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
- package/handles/providers/typescript-express/plan.mjs +15 -2
- package/lib/attest.mjs +59 -1
- package/lib/cli.mjs +79 -7
- package/lib/doctor.mjs +23 -0
- package/lib/exit-codes.mjs +17 -0
- package/lib/gate-definitions.mjs +65 -2
- package/lib/gate-export.mjs +199 -0
- package/lib/impact-export-graphify.mjs +145 -0
- package/lib/impact-graph.mjs +194 -0
- package/lib/impact-surface.mjs +158 -0
- package/lib/impact.mjs +286 -0
- package/lib/patch-kinds.mjs +24 -0
- package/lib/repo.mjs +46 -0
- package/lib/workflow.mjs +16 -0
- package/package.json +1 -1
- package/scanners/adapters/_java-spring-analyzer.mjs +55 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +530 -22
- package/schemas/handles-plan.schema.json +32 -0
- package/schemas/impact-baseline.schema.json +59 -0
- package/schemas/impact-graph.schema.json +53 -0
- package/schemas/impact-report.schema.json +86 -0
- package/schemas/impact-resolution.schema.json +33 -0
- package/schemas/java-source-splice.schema.json +84 -0
- 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
|
+
}
|