backend-skeleton 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +549 -63
  3. package/contracts/completeness.mjs +12 -1
  4. package/contracts/openapi.mjs +125 -18
  5. package/handles/providers/java-spring/ast-bridge.mjs +85 -1
  6. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
  7. package/handles/providers/java-spring/emit.mjs +126 -6
  8. package/handles/providers/java-spring/plan.mjs +220 -74
  9. package/handles/providers/java-spring/source-splice.mjs +477 -0
  10. package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
  11. package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
  12. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
  13. package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
  14. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
  15. package/lib/attest.mjs +59 -1
  16. package/lib/cli.mjs +125 -7
  17. package/lib/decision-log.mjs +58 -0
  18. package/lib/doctor.mjs +23 -0
  19. package/lib/exit-codes.mjs +17 -0
  20. package/lib/gate-definitions.mjs +65 -2
  21. package/lib/gate-export.mjs +250 -0
  22. package/lib/impact-export-graphify.mjs +145 -0
  23. package/lib/impact-graph.mjs +194 -0
  24. package/lib/impact-surface.mjs +158 -0
  25. package/lib/impact.mjs +334 -0
  26. package/lib/patch-kinds.mjs +24 -0
  27. package/lib/repo.mjs +46 -0
  28. package/lib/workflow.mjs +16 -0
  29. package/package.json +1 -1
  30. package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
  31. package/schemas/decision-event.schema.json +46 -0
  32. package/schemas/gate-attestation.schema.json +6 -1
  33. package/schemas/gate-export.schema.json +606 -22
  34. package/schemas/handles-plan.schema.json +32 -0
  35. package/schemas/impact-baseline.schema.json +59 -0
  36. package/schemas/impact-graph.schema.json +53 -0
  37. package/schemas/impact-report.schema.json +86 -0
  38. package/schemas/impact-resolution.schema.json +33 -0
  39. package/schemas/java-source-splice.schema.json +84 -0
  40. package/schemas/patch-transaction.schema.json +87 -2
@@ -0,0 +1,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
+ }
package/lib/impact.mjs ADDED
@@ -0,0 +1,334 @@
1
+ // D-cross-feature-impact-graph (D5/D6): `bskel impact check` (report, read-mostly) / `bskel impact
2
+ // accept` (decide, advances the baseline) / `bskel impact disposition` (compatible|migrate|waive) /
3
+ // `bskel impact ack` (clears a migrate obligation on the DOWNSTREAM side). Mirrors
4
+ // lib/cross-feature-collisions.mjs's own check/waive split and lib/field-dependencies.mjs's
5
+ // DependencyOperationError pattern exactly -- one shared error vocabulary a CLI caller (and a
6
+ // future HTTP caller, per D-http-serving-layer) both derive their own response shape from.
7
+ import { readJsonIfExists, sha256File, writeFileAtomic } from './fsutil.mjs';
8
+ import { specPath } from './paths.mjs';
9
+ import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
10
+ import { listFeatures } from './featurelifecycle.mjs';
11
+ import { withLockSync } from './lock.mjs';
12
+ import { requireValidFeatureId } from './featureid.mjs';
13
+ import { EXIT_CODES } from './exit-codes.mjs';
14
+ import { buildImpactGraph } from './impact-graph.mjs';
15
+ import { computeSurface, loadBaseline, saveBaseline, diffSurface } from './impact-surface.mjs';
16
+ import { appendDecisionEvent } from './decision-log.mjs';
17
+
18
+ const REPORT_SCHEMA = 'sbf.impact-report/1';
19
+ const RESOLUTION_SCHEMA = 'sbf.impact-resolution/1';
20
+
21
+ export class ImpactOperationError extends Error {
22
+ constructor(message, { httpStatus = 400, exitCode = EXIT_CODES.BAD_ARGS, reasonCode = 'BAD_ARGS' } = {}) {
23
+ super(message);
24
+ this.name = 'ImpactOperationError';
25
+ this.httpStatus = httpStatus;
26
+ this.exitCode = exitCode;
27
+ this.reasonCode = reasonCode;
28
+ }
29
+ }
30
+
31
+ function requireValidOr400(id) {
32
+ try {
33
+ requireValidFeatureId(id);
34
+ } catch (err) {
35
+ throw new ImpactOperationError(err.message, { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
36
+ }
37
+ }
38
+
39
+ export function impactReportPath(root, featureId) {
40
+ return specPath(root, featureId, 'impact-report.json');
41
+ }
42
+
43
+ export function impactResolutionPath(root, featureId) {
44
+ return specPath(root, featureId, 'impact-resolution.json');
45
+ }
46
+
47
+ export function loadImpactResolution(root, featureId) {
48
+ const p = impactResolutionPath(root, featureId);
49
+ const parsed = readJsonIfExists(p);
50
+ if (parsed === null) return { schema: RESOLUTION_SCHEMA, feature_id: featureId, dispositions: [] };
51
+ const { ok, errors } = validateAgainstSchema('impact-resolution.schema.json', parsed);
52
+ if (!ok) throw new Error(`${p}: does not match schemas/impact-resolution.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
53
+ return parsed;
54
+ }
55
+
56
+ function saveImpactResolution(root, featureId, doc) {
57
+ const { ok, errors } = validateAgainstSchema('impact-resolution.schema.json', doc);
58
+ if (!ok) throw new Error(`refusing to write an invalid impact resolution for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
59
+ writeFileAtomic(impactResolutionPath(root, featureId), `${JSON.stringify(doc, null, 2)}\n`);
60
+ return doc;
61
+ }
62
+
63
+ function loadImpactReport(root, featureId) {
64
+ return readJsonIfExists(impactReportPath(root, featureId));
65
+ }
66
+
67
+ function saveImpactReport(root, featureId, doc) {
68
+ const { ok, errors } = validateAgainstSchema('impact-report.schema.json', doc);
69
+ if (!ok) throw new Error(`refusing to write an invalid impact report for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
70
+ writeFileAtomic(impactReportPath(root, featureId), `${JSON.stringify(doc, null, 2)}\n`);
71
+ return doc;
72
+ }
73
+
74
+ // Every OTHER feature's owner set for a graph node -- a table node's owner is derived from whoever
75
+ // maps_to_table it (reverse lookup); every other node type already carries its own feature_id.
76
+ function ownerFeaturesOf(graph, nodeId) {
77
+ const node = graph.nodes.find((n) => n.id === nodeId);
78
+ if (!node) return new Set();
79
+ if (node.feature_id) return new Set([node.feature_id]);
80
+ if (node.type === 'table') {
81
+ const owners = new Set();
82
+ for (const e of graph.edges) {
83
+ if (e.relation === 'maps_to_table' && e.target === nodeId) {
84
+ const r = graph.nodes.find((n) => n.id === e.source);
85
+ if (r?.feature_id) owners.add(r.feature_id);
86
+ }
87
+ }
88
+ return owners;
89
+ }
90
+ return new Set();
91
+ }
92
+
93
+ function changeSubjectNodeId(featureId, change) {
94
+ if (change.kind.startsWith('operation_')) return `${featureId}#${change.subject}`;
95
+ if (change.kind === 'field_source_moved') return `${featureId}::${change.subject}`;
96
+ // resource_removed / resource_table_changed -- subject is a bare resourceType
97
+ return `${featureId}::${change.subject}`;
98
+ }
99
+
100
+ // D-cross-feature-impact-graph: walks the graph from ONE change's own node outward one hop, over
101
+ // the three relations that can name a real downstream consumer. `derives_from` only counts when
102
+ // the changed node is the DEPENDED-ON side (edge.target) -- something reading FROM it, not the
103
+ // other way around.
104
+ function findOutboundImpacts(graph, subjectNodeId, ownFeatureId) {
105
+ const impacts = [];
106
+ for (const e of graph.edges) {
107
+ let counterpartId = null;
108
+ if (e.relation === 'derives_from' && e.target === subjectNodeId) counterpartId = e.source;
109
+ else if (e.relation === 'fk_references' && (e.source === subjectNodeId || e.target === subjectNodeId)) counterpartId = e.source === subjectNodeId ? e.target : e.source;
110
+ else if (e.relation === 'name_collides_with' && (e.source === subjectNodeId || e.target === subjectNodeId)) counterpartId = e.source === subjectNodeId ? e.target : e.source;
111
+ else continue;
112
+ for (const feat of ownerFeaturesOf(graph, counterpartId)) {
113
+ if (feat === ownFeatureId) continue;
114
+ impacts.push({ downstream_feature: feat, via: e.relation, basis: e.basis, confidence: e.confidence });
115
+ }
116
+ }
117
+ return impacts;
118
+ }
119
+
120
+ function isDispositionActive(d, nowMs) {
121
+ if (d.mode === 'waive') {
122
+ if (!d.expires_at) return false;
123
+ return Date.parse(d.expires_at) > nowMs;
124
+ }
125
+ if (d.mode === 'migrate') return true; // migrate stays "recorded" indefinitely -- clearing the BLOCK is acknowledged, tracked separately
126
+ return true; // compatible
127
+ }
128
+
129
+ function findDisposition(resolution, changeKey, downstreamFeature, nowMs) {
130
+ return (resolution.dispositions ?? []).find((d) => d.change_key === changeKey && d.downstream_feature === downstreamFeature && isDispositionActive(d, nowMs));
131
+ }
132
+
133
+ // D-cross-feature-impact-graph (D5): read-mostly -- computes the current surface, diffs it against
134
+ // impact-baseline.json, walks the graph for each change, and writes impact-report.json. Never
135
+ // advances the baseline (only acceptImpact() does).
136
+ export function checkImpact(root, featureId, { now = new Date() } = {}) {
137
+ requireValidOr400(featureId);
138
+ const graph = buildImpactGraph(root, { nowIso: now.toISOString() });
139
+ const baseline = loadBaseline(root, featureId);
140
+ const currentSurface = computeSurface(root, featureId);
141
+ const changes = diffSurface(baseline?.surface ?? null, currentSurface);
142
+ const resolution = loadImpactResolution(root, featureId);
143
+
144
+ const outbound = [];
145
+ for (const change of changes) {
146
+ const subjectNodeId = changeSubjectNodeId(featureId, change);
147
+ for (const impact of findOutboundImpacts(graph, subjectNodeId, featureId)) {
148
+ const disp = findDisposition(resolution, change.change_key, impact.downstream_feature, now.getTime());
149
+ outbound.push({ change_key: change.change_key, downstream_feature: impact.downstream_feature, via: impact.via, basis: impact.basis, confidence: impact.confidence, disposition: disp?.mode ?? null });
150
+ }
151
+ }
152
+
153
+ // inbound: every OTHER feature's own resolution naming THIS feature as downstream, mode migrate, not yet acknowledged
154
+ const inbound = [];
155
+ for (const other of listFeatures(root)) {
156
+ if (other.feature_id === featureId) continue;
157
+ const otherResolution = loadImpactResolution(root, other.feature_id);
158
+ for (const d of otherResolution.dispositions ?? []) {
159
+ if (d.mode === 'migrate' && d.downstream_feature === featureId) {
160
+ inbound.push({ upstream_feature: other.feature_id, change_key: d.change_key, mode: 'migrate', tracked_by: d.tracked_by, acknowledged: Boolean(d.acknowledged) });
161
+ }
162
+ }
163
+ }
164
+
165
+ const unknowns = [];
166
+ if (!baseline) unknowns.push(`no impact-baseline.json for "${featureId}" yet -- every operation/resource reports as newly added, run \`bskel impact accept --feature ${featureId}\` to capture the first baseline`);
167
+
168
+ const report = {
169
+ schema: REPORT_SCHEMA,
170
+ feature_id: featureId,
171
+ generated_at: now.toISOString(),
172
+ baseline: { present: Boolean(baseline), captured_at: baseline?.captured_at ?? null },
173
+ changes,
174
+ outbound,
175
+ inbound,
176
+ unknowns,
177
+ };
178
+ saveImpactReport(root, featureId, report);
179
+ return { report, evaluation: evaluateImpacts(report) };
180
+ }
181
+
182
+ // D-cross-feature-impact-graph (D6): only a PROVEN, undisposed outbound impact blocks -- the
183
+ // gate-fatigue control Codex's own review named as the risk to answer directly. An unacknowledged
184
+ // inbound migrate obligation also blocks (the two-sided handshake).
185
+ export function evaluateImpacts(report) {
186
+ const blockingOutbound = report.outbound.filter((o) => o.confidence === 'proven' && !o.disposition);
187
+ const blockingInbound = report.inbound.filter((i) => !i.acknowledged);
188
+ return {
189
+ blocking: blockingOutbound.length > 0 || blockingInbound.length > 0,
190
+ blockingOutbound,
191
+ blockingInbound,
192
+ heuristicOutbound: report.outbound.filter((o) => o.confidence === 'heuristic'),
193
+ };
194
+ }
195
+
196
+ // D-cross-feature-impact-graph (D5): the DECIDE half -- refuses (AWAITING_DISPOSITION) if any
197
+ // proven outbound impact is undisposed or any inbound migration is unacknowledged; otherwise
198
+ // atomically rewrites impact-baseline.json from the CURRENT surface. On a feature with no prior
199
+ // baseline, `checkImpact` above already reports every operation as `operation_added` with zero
200
+ // downstream impacts (nothing can depend on what didn't exist), so this captures trivially -- no
201
+ // bootstrap special case.
202
+ export function acceptImpact(root, featureId, { now = new Date() } = {}) {
203
+ requireValidOr400(featureId);
204
+ return withLockSync(root, 'state', () => {
205
+ const { report, evaluation } = checkImpact(root, featureId, { now });
206
+ if (evaluation.blocking) {
207
+ throw new ImpactOperationError(
208
+ `"${featureId}" has ${evaluation.blockingOutbound.length} undisposed proven outbound impact(s) and ${evaluation.blockingInbound.length} unacknowledged inbound migration(s) -- run \`bskel impact disposition\`/\`bskel impact ack\` first, or \`bskel gate force impact --feature ${featureId} --reason "..."\` to override (recorded in signed attestations)`,
209
+ { httpStatus: 409, exitCode: EXIT_CODES.AWAITING_DISPOSITION, reasonCode: 'GATE_AWAITING_DISPOSITION' },
210
+ );
211
+ }
212
+ const surface = computeSurface(root, featureId);
213
+ const baseline = saveBaseline(root, featureId, surface, { capturedAt: now.toISOString() });
214
+ return { baseline, report };
215
+ });
216
+ }
217
+
218
+ // D-cross-feature-impact-graph (D6): records one of {compatible, migrate, waive} for exactly one
219
+ // {change_key, downstream_feature} pair -- never a wildcard. `migrate` requires --tracked-by
220
+ // (non-empty); `waive` requires --expires-days (a positive integer), reusing the SAME decay
221
+ // posture D-waiver-expiry already ships for `contract waive`.
222
+ export function recordDisposition(root, { feature, changeKey, downstreamFeature, mode, reason, trackedBy = null, expiresDays = null, now = new Date() }) {
223
+ requireValidOr400(feature);
224
+ requireValidOr400(downstreamFeature);
225
+ if (!reason || !reason.trim()) {
226
+ throw new ImpactOperationError('bskel impact disposition requires --reason "..." -- every disposition must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
227
+ }
228
+ if (!['compatible', 'migrate', 'waive'].includes(mode)) {
229
+ throw new ImpactOperationError(`--mode must be one of compatible|migrate|waive (got "${mode}")`, { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
230
+ }
231
+ if (mode === 'migrate' && (!trackedBy || !trackedBy.trim())) {
232
+ throw new ImpactOperationError('--mode migrate requires --tracked-by "<issue/PR reference>" -- a migrate disposition with nothing to track is a no-op that looks like a decision', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
233
+ }
234
+ let expiresAt = null;
235
+ if (mode === 'waive') {
236
+ if (!(Number.isInteger(expiresDays) && expiresDays > 0)) {
237
+ throw new ImpactOperationError('--mode waive requires --expires-days <N> (a positive integer) -- same decay posture as `contract waive --expires`', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
238
+ }
239
+ expiresAt = new Date(now.getTime() + expiresDays * 24 * 60 * 60 * 1000).toISOString();
240
+ }
241
+
242
+ return withLockSync(root, 'state', () => {
243
+ const current = loadImpactResolution(root, feature);
244
+ const at = now.toISOString();
245
+ const next = {
246
+ schema: RESOLUTION_SCHEMA,
247
+ feature_id: feature,
248
+ dispositions: [
249
+ ...current.dispositions.filter((d) => !(d.change_key === changeKey && d.downstream_feature === downstreamFeature)),
250
+ {
251
+ change_key: changeKey,
252
+ downstream_feature: downstreamFeature,
253
+ mode,
254
+ reason,
255
+ tracked_by: mode === 'migrate' ? trackedBy : null,
256
+ expires_at: expiresAt,
257
+ acknowledged: false,
258
+ acknowledged_at: null,
259
+ acknowledged_reason: null,
260
+ at,
261
+ },
262
+ ],
263
+ };
264
+ const saved = saveImpactResolution(root, feature, next);
265
+ appendDecisionEvent(root, feature, {
266
+ kind: 'impact_disposition', action: 'record', at, reason, feature_id: feature,
267
+ subject: { change_key: changeKey, downstream_feature: downstreamFeature },
268
+ mode, expires_at: expiresAt, tracked_by: mode === 'migrate' ? trackedBy : null,
269
+ });
270
+ return saved;
271
+ });
272
+ }
273
+
274
+ // D-cross-feature-impact-graph (D6): the DOWNSTREAM feature's own acknowledgement of a `migrate`
275
+ // obligation another feature recorded against it -- flips `acknowledged` on the UPSTREAM feature's
276
+ // own resolution record (that's where the obligation lives; ack doesn't create a new file).
277
+ export function acknowledgeInbound(root, { feature, from, changeKey, reason, now = new Date() }) {
278
+ requireValidOr400(feature);
279
+ requireValidOr400(from);
280
+ if (!reason || !reason.trim()) {
281
+ throw new ImpactOperationError('bskel impact ack requires --reason "..." -- every acknowledgement must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
282
+ }
283
+ return withLockSync(root, 'state', () => {
284
+ const upstreamResolution = loadImpactResolution(root, from);
285
+ const match = upstreamResolution.dispositions.find((d) => d.mode === 'migrate' && d.downstream_feature === feature && d.change_key === changeKey);
286
+ if (!match) {
287
+ throw new ImpactOperationError(`no "migrate" disposition from "${from}" targeting "${feature}" for change_key "${changeKey}" -- run \`bskel impact check --feature ${feature}\` to see current inbound obligations`, { httpStatus: 404, exitCode: EXIT_CODES.NOT_PASSED, reasonCode: 'MISSING_ARTIFACT' });
288
+ }
289
+ match.acknowledged = true;
290
+ match.acknowledged_at = now.toISOString();
291
+ match.acknowledged_reason = reason;
292
+ const saved = saveImpactResolution(root, from, upstreamResolution);
293
+ appendDecisionEvent(root, from, {
294
+ kind: 'impact_disposition', action: 'acknowledge', at: match.acknowledged_at, reason, feature_id: from,
295
+ subject: { change_key: changeKey, downstream_feature: feature },
296
+ mode: match.mode, expires_at: match.expires_at ?? null, tracked_by: match.tracked_by ?? null,
297
+ });
298
+ return saved;
299
+ });
300
+ }
301
+
302
+ // D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke`'s own precedent --
303
+ // removes the entry (its absence IS the state, matching every other withdraw in this item) and
304
+ // appends a `withdraw` event carrying the reason. Never a snapshot restore: a disposition has no
305
+ // "previous value" worth restoring once the change it covered has moved on -- see
306
+ // D-decision-event-log's own Q4 answer in DECISIONS.md for why this is the correct
307
+ // generalization of `gate revoke`, not of `lib/patch-transactions.mjs`'s preimage-blob rollback.
308
+ export function withdrawDisposition(root, { feature, changeKey, downstreamFeature, reason, now = new Date() }) {
309
+ requireValidOr400(feature);
310
+ requireValidOr400(downstreamFeature);
311
+ if (!reason || !reason.trim()) {
312
+ throw new ImpactOperationError('bskel impact disposition --withdraw requires --reason "..." -- every withdrawal must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
313
+ }
314
+ return withLockSync(root, 'state', () => {
315
+ const current = loadImpactResolution(root, feature);
316
+ const match = current.dispositions.find((d) => d.change_key === changeKey && d.downstream_feature === downstreamFeature);
317
+ if (!match) {
318
+ throw new ImpactOperationError(`no disposition recorded for change_key "${changeKey}" -> "${downstreamFeature}" on feature "${feature}" -- nothing to withdraw`, { httpStatus: 404, exitCode: EXIT_CODES.NOT_PASSED, reasonCode: 'MISSING_ARTIFACT' });
319
+ }
320
+ const at = now.toISOString();
321
+ const next = {
322
+ schema: RESOLUTION_SCHEMA,
323
+ feature_id: feature,
324
+ dispositions: current.dispositions.filter((d) => !(d.change_key === changeKey && d.downstream_feature === downstreamFeature)),
325
+ };
326
+ const saved = saveImpactResolution(root, feature, next);
327
+ appendDecisionEvent(root, feature, {
328
+ kind: 'impact_disposition', action: 'withdraw', at, reason, feature_id: feature,
329
+ subject: { change_key: changeKey, downstream_feature: downstreamFeature },
330
+ mode: match.mode, expires_at: match.expires_at ?? null, tracked_by: match.tracked_by ?? null,
331
+ });
332
+ return saved;
333
+ });
334
+ }
@@ -9,6 +9,13 @@
9
9
  import { loadCatalogEntry } from '../stack/apply.mjs';
10
10
  import { planConfigApply, executeConfigApply, executeConfigRollback } from '../stack/config-apply.mjs';
11
11
  import { planDdlApply, executeDdlApply, executeDdlRollback, requiredConfirmValue } from '../scanners/db/ddl-apply.mjs';
12
+ import {
13
+ planJavaSourceSplice,
14
+ executeJavaSpliceApply,
15
+ executeJavaSpliceRollback,
16
+ requiredConfirmValue as javaSpliceRequiredConfirmValue,
17
+ describeStaleness as javaSpliceDescribeStaleness,
18
+ } from '../handles/providers/java-spring/source-splice.mjs';
12
19
 
13
20
  const PATCH_KINDS = {
14
21
  'config-apply': {
@@ -32,6 +39,23 @@ const PATCH_KINDS = {
32
39
  // for one that drops a table -- see requiredConfirmValue()'s own comment for why.
33
40
  requiredConfirmValue,
34
41
  },
42
+ // D-java-source-splice: params: {file, edits}. planFresh re-derives everything (node identity,
43
+ // hashes, rendered content) from the current on-disk file every time -- never trusts a stored
44
+ // render, exactly like the other two kinds.
45
+ 'java-source-splice': {
46
+ planFresh: (root, params) => planJavaSourceSplice(root, params),
47
+ paramsFromTxn: (txn) => ({
48
+ file: txn.target.file,
49
+ edits: txn.target.edits.map(({ op, locator, replacement, statements, imports }) => ({ op, locator, replacement, statements, imports })),
50
+ }),
51
+ apply: executeJavaSpliceApply,
52
+ rollback: executeJavaSpliceRollback,
53
+ requiredConfirmValue: javaSpliceRequiredConfirmValue,
54
+ // Optional per-kind hook (absent on the other two kinds): consulted by bin/bskel.mjs's
55
+ // cmdPatchApprove/cmdPatchApply only when replanTransaction() throws StaleTransactionError,
56
+ // to name which edit moved and why -- never changes the engine's own pass/fail decision.
57
+ describeStaleness: javaSpliceDescribeStaleness,
58
+ },
35
59
  };
36
60
 
37
61
  export const PATCH_KIND_NAMES = Object.freeze(Object.keys(PATCH_KINDS));
package/lib/repo.mjs CHANGED
@@ -98,3 +98,49 @@ export function isDirty(cwd = process.cwd()) {
98
98
  return null;
99
99
  }
100
100
  }
101
+
102
+ // D-attestation-payload-completeness (K2): the CONTENT identity of HEAD, distinct from its commit
103
+ // identity (headSha() above) -- `git rev-parse HEAD^{tree}`. Two commits with identical tree
104
+ // content (e.g. one only changes the commit message, or is an empty --amend) share this value,
105
+ // which is the right identity for "what code was this attestation actually about". null on
106
+ // failure, same convention as every other helper in this file.
107
+ export function headTreeSha(cwd = process.cwd()) {
108
+ try {
109
+ return git(['rev-parse', 'HEAD^{tree}'], cwd);
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // D-attestation-payload-completeness (K2): a sorted, capped, structured view of
116
+ // `git status --porcelain=v1 -z` -- makes `isDirty()`'s bare boolean actionable inside a signed
117
+ // payload (D4's dirty-tree refusal needs to SHOW what's dirty, not just assert that it is).
118
+ // Returns null on git failure, matching isDirty()'s own null-on-failure posture. `-z` + NUL-split
119
+ // is used (not the newline-delimited default) specifically because a renamed/copied path's
120
+ // porcelain line legitimately contains no separator between old and new path other than NUL.
121
+ export function worktreeStatus(cwd = process.cwd(), { cap = 200 } = {}) {
122
+ let raw;
123
+ try {
124
+ raw = execFileSync('git', ['status', '--porcelain=v1', '-z'], { cwd, encoding: 'utf8' });
125
+ } catch {
126
+ return null;
127
+ }
128
+ const fields = raw.split('\0').filter((f) => f.length > 0);
129
+ const entries = [];
130
+ for (let i = 0; i < fields.length; i++) {
131
+ const field = fields[i];
132
+ const status = field.slice(0, 2);
133
+ const path = field.slice(3);
134
+ // A rename/copy status ('R'/'C' in either column) is followed by a SEPARATE NUL-delimited
135
+ // field holding the ORIGINAL path -- consumed here (and dropped) so it isn't misread as its
136
+ // own status line; only the new path is reported, since `status` already discloses the move.
137
+ if (status[0] === 'R' || status[0] === 'C' || status[1] === 'R' || status[1] === 'C') i++;
138
+ entries.push({ status, path });
139
+ }
140
+ // Locale-independent, deterministic ordering -- Array.prototype.sort()'s default (UTF-16 code
141
+ // unit comparison) rather than localeCompare(), which varies by ICU data/host locale and would
142
+ // make the same repo state canonicalize to different signed bytes on different machines.
143
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
144
+ const truncated = entries.length > cap;
145
+ return { count: entries.length, truncated, entries: entries.slice(0, cap) };
146
+ }
package/lib/workflow.mjs CHANGED
@@ -44,6 +44,10 @@ const ESTABLISH_COMMAND = {
44
44
  // directly on the first real stale occurrence and throws a raw TypeError instead of a clean
45
45
  // stale report.
46
46
  dependencies: (id) => `bskel dependency declare --feature ${id} --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."`,
47
+ // D-cross-feature-impact-graph (IG4): wired in the SAME commit as the `impact` gate itself,
48
+ // per every warning above in this file -- this exact gap has now been found live at least four
49
+ // times for other gates and is the single most reliably-recurring mistake in this module.
50
+ impact: (id) => `bskel impact check --feature ${id} # then: bskel impact accept --feature ${id}`,
47
51
  // D-business-rules (R7): wired in the SAME commit as the `rules` gate itself, per the repeated
48
52
  // warnings above -- this exact gap has now been found live three times (dependencies,
49
53
  // conformance, and the cross_feature near-miss) and is the single most reliably-recurring
@@ -72,6 +76,13 @@ function awaitingDispositionCommand(gateName, featureId) {
72
76
  if (gateName === 'cross_feature') {
73
77
  return `bskel scan cross-feature-waive --feature ${featureId} --signal resource_type|table|operation_id --identifier <name> --other-feature <id> --reason "..." # or: bskel gate force cross_feature --feature ${featureId} --reason "..." if intentional`;
74
78
  }
79
+ // D-cross-feature-impact-graph (IG5/IG6): `impact` reaches awaiting_disposition from
80
+ // `checkImpact()` finding an undisposed proven outbound impact or an unacknowledged inbound
81
+ // migration -- `bskel impact check`'s own output (not `bskel gate show`) names the exact
82
+ // change_key/downstream_feature pairs a human needs to pass to `disposition`/`ack`.
83
+ if (gateName === 'impact') {
84
+ return `bskel impact check --feature ${featureId} --json # then: bskel impact disposition --feature ${featureId} --change <change_key> --downstream <id> --mode compatible|migrate|waive --reason "..." # or: bskel gate force impact --feature ${featureId} --reason "..." if intentional`;
85
+ }
75
86
  return `bskel gate force ${gateName} --feature ${featureId} --reason "..."`;
76
87
  }
77
88
 
@@ -98,6 +109,11 @@ const MUTATING_PREFIXES = [
98
109
  // `rules emit` writes generated source. `rules list`/`rules explain` are read-only and
99
110
  // correctly absent -- the same per-verb split `pattern list|show|suggest` was checked against.
100
111
  'bskel rules check', 'bskel rules emit',
112
+ // D-cross-feature-impact-graph (IG4/IG5/IG6): `impact check` writes impact-report.json and can
113
+ // pass the gate; `impact accept` writes impact-baseline.json; `disposition`/`ack` write
114
+ // impact-resolution.json. `impact export` is deliberately absent -- read-only, spawns nothing,
115
+ // touches no gate (see IG8/IG9's own "never read back by a gate" guarantee).
116
+ 'bskel impact check', 'bskel impact accept', 'bskel impact disposition', 'bskel impact ack',
101
117
  ];
102
118
 
103
119
  // Exported (not just inlined into action()) so lib/workflow.mjs's own test suite can assert the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
6
6
  "license": "AGPL-3.0-or-later",
@@ -311,6 +311,12 @@ export function findMappingAnnotations(text) {
311
311
  argsText: argsStart >= 0 ? text.slice(argsStart, argsEnd) : '',
312
312
  methodName: sig.methodName,
313
313
  methodLine: lineNumberAt(text, atIndex),
314
+ // D-resolver-policy-contract (PC8): the position right after ALL of this method's own
315
+ // annotations end (already computed above as `afterAnnotations`, just returned here too)
316
+ // -- the only way to carve a method region that contains every annotation regardless of
317
+ // source order (e.g. `@GetMapping(...) @PreAuthorize(...)`, where @PreAuthorize follows
318
+ // the mapping annotation instead of preceding it).
319
+ signatureIndex: afterAnnotations,
314
320
  });
315
321
  }
316
322
  return results;