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.
- package/README.md +113 -8
- package/bin/bskel.mjs +549 -63
- package/contracts/completeness.mjs +12 -1
- 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 +220 -74
- 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/lib/attest.mjs +59 -1
- package/lib/cli.mjs +125 -7
- package/lib/decision-log.mjs +58 -0
- 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 +250 -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 +334 -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 +6 -0
- package/schemas/decision-event.schema.json +46 -0
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +606 -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,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
|
+
}
|