backend-skeleton 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -8
- package/bin/bskel.mjs +331 -53
- 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 +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 +6 -0
- 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
package/lib/impact.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
|
|
17
|
+
const REPORT_SCHEMA = 'sbf.impact-report/1';
|
|
18
|
+
const RESOLUTION_SCHEMA = 'sbf.impact-resolution/1';
|
|
19
|
+
|
|
20
|
+
export class ImpactOperationError extends Error {
|
|
21
|
+
constructor(message, { httpStatus = 400, exitCode = EXIT_CODES.BAD_ARGS, reasonCode = 'BAD_ARGS' } = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ImpactOperationError';
|
|
24
|
+
this.httpStatus = httpStatus;
|
|
25
|
+
this.exitCode = exitCode;
|
|
26
|
+
this.reasonCode = reasonCode;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireValidOr400(id) {
|
|
31
|
+
try {
|
|
32
|
+
requireValidFeatureId(id);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new ImpactOperationError(err.message, { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function impactReportPath(root, featureId) {
|
|
39
|
+
return specPath(root, featureId, 'impact-report.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function impactResolutionPath(root, featureId) {
|
|
43
|
+
return specPath(root, featureId, 'impact-resolution.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function loadImpactResolution(root, featureId) {
|
|
47
|
+
const p = impactResolutionPath(root, featureId);
|
|
48
|
+
const parsed = readJsonIfExists(p);
|
|
49
|
+
if (parsed === null) return { schema: RESOLUTION_SCHEMA, feature_id: featureId, dispositions: [] };
|
|
50
|
+
const { ok, errors } = validateAgainstSchema('impact-resolution.schema.json', parsed);
|
|
51
|
+
if (!ok) throw new Error(`${p}: does not match schemas/impact-resolution.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function saveImpactResolution(root, featureId, doc) {
|
|
56
|
+
const { ok, errors } = validateAgainstSchema('impact-resolution.schema.json', doc);
|
|
57
|
+
if (!ok) throw new Error(`refusing to write an invalid impact resolution for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
58
|
+
writeFileAtomic(impactResolutionPath(root, featureId), `${JSON.stringify(doc, null, 2)}\n`);
|
|
59
|
+
return doc;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadImpactReport(root, featureId) {
|
|
63
|
+
return readJsonIfExists(impactReportPath(root, featureId));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function saveImpactReport(root, featureId, doc) {
|
|
67
|
+
const { ok, errors } = validateAgainstSchema('impact-report.schema.json', doc);
|
|
68
|
+
if (!ok) throw new Error(`refusing to write an invalid impact report for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
69
|
+
writeFileAtomic(impactReportPath(root, featureId), `${JSON.stringify(doc, null, 2)}\n`);
|
|
70
|
+
return doc;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Every OTHER feature's owner set for a graph node -- a table node's owner is derived from whoever
|
|
74
|
+
// maps_to_table it (reverse lookup); every other node type already carries its own feature_id.
|
|
75
|
+
function ownerFeaturesOf(graph, nodeId) {
|
|
76
|
+
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
77
|
+
if (!node) return new Set();
|
|
78
|
+
if (node.feature_id) return new Set([node.feature_id]);
|
|
79
|
+
if (node.type === 'table') {
|
|
80
|
+
const owners = new Set();
|
|
81
|
+
for (const e of graph.edges) {
|
|
82
|
+
if (e.relation === 'maps_to_table' && e.target === nodeId) {
|
|
83
|
+
const r = graph.nodes.find((n) => n.id === e.source);
|
|
84
|
+
if (r?.feature_id) owners.add(r.feature_id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return owners;
|
|
88
|
+
}
|
|
89
|
+
return new Set();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function changeSubjectNodeId(featureId, change) {
|
|
93
|
+
if (change.kind.startsWith('operation_')) return `${featureId}#${change.subject}`;
|
|
94
|
+
if (change.kind === 'field_source_moved') return `${featureId}::${change.subject}`;
|
|
95
|
+
// resource_removed / resource_table_changed -- subject is a bare resourceType
|
|
96
|
+
return `${featureId}::${change.subject}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// D-cross-feature-impact-graph: walks the graph from ONE change's own node outward one hop, over
|
|
100
|
+
// the three relations that can name a real downstream consumer. `derives_from` only counts when
|
|
101
|
+
// the changed node is the DEPENDED-ON side (edge.target) -- something reading FROM it, not the
|
|
102
|
+
// other way around.
|
|
103
|
+
function findOutboundImpacts(graph, subjectNodeId, ownFeatureId) {
|
|
104
|
+
const impacts = [];
|
|
105
|
+
for (const e of graph.edges) {
|
|
106
|
+
let counterpartId = null;
|
|
107
|
+
if (e.relation === 'derives_from' && e.target === subjectNodeId) counterpartId = e.source;
|
|
108
|
+
else if (e.relation === 'fk_references' && (e.source === subjectNodeId || e.target === subjectNodeId)) counterpartId = e.source === subjectNodeId ? e.target : e.source;
|
|
109
|
+
else if (e.relation === 'name_collides_with' && (e.source === subjectNodeId || e.target === subjectNodeId)) counterpartId = e.source === subjectNodeId ? e.target : e.source;
|
|
110
|
+
else continue;
|
|
111
|
+
for (const feat of ownerFeaturesOf(graph, counterpartId)) {
|
|
112
|
+
if (feat === ownFeatureId) continue;
|
|
113
|
+
impacts.push({ downstream_feature: feat, via: e.relation, basis: e.basis, confidence: e.confidence });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return impacts;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isDispositionActive(d, nowMs) {
|
|
120
|
+
if (d.mode === 'waive') {
|
|
121
|
+
if (!d.expires_at) return false;
|
|
122
|
+
return Date.parse(d.expires_at) > nowMs;
|
|
123
|
+
}
|
|
124
|
+
if (d.mode === 'migrate') return true; // migrate stays "recorded" indefinitely -- clearing the BLOCK is acknowledged, tracked separately
|
|
125
|
+
return true; // compatible
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findDisposition(resolution, changeKey, downstreamFeature, nowMs) {
|
|
129
|
+
return (resolution.dispositions ?? []).find((d) => d.change_key === changeKey && d.downstream_feature === downstreamFeature && isDispositionActive(d, nowMs));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// D-cross-feature-impact-graph (D5): read-mostly -- computes the current surface, diffs it against
|
|
133
|
+
// impact-baseline.json, walks the graph for each change, and writes impact-report.json. Never
|
|
134
|
+
// advances the baseline (only acceptImpact() does).
|
|
135
|
+
export function checkImpact(root, featureId, { now = new Date() } = {}) {
|
|
136
|
+
requireValidOr400(featureId);
|
|
137
|
+
const graph = buildImpactGraph(root, { nowIso: now.toISOString() });
|
|
138
|
+
const baseline = loadBaseline(root, featureId);
|
|
139
|
+
const currentSurface = computeSurface(root, featureId);
|
|
140
|
+
const changes = diffSurface(baseline?.surface ?? null, currentSurface);
|
|
141
|
+
const resolution = loadImpactResolution(root, featureId);
|
|
142
|
+
|
|
143
|
+
const outbound = [];
|
|
144
|
+
for (const change of changes) {
|
|
145
|
+
const subjectNodeId = changeSubjectNodeId(featureId, change);
|
|
146
|
+
for (const impact of findOutboundImpacts(graph, subjectNodeId, featureId)) {
|
|
147
|
+
const disp = findDisposition(resolution, change.change_key, impact.downstream_feature, now.getTime());
|
|
148
|
+
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 });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// inbound: every OTHER feature's own resolution naming THIS feature as downstream, mode migrate, not yet acknowledged
|
|
153
|
+
const inbound = [];
|
|
154
|
+
for (const other of listFeatures(root)) {
|
|
155
|
+
if (other.feature_id === featureId) continue;
|
|
156
|
+
const otherResolution = loadImpactResolution(root, other.feature_id);
|
|
157
|
+
for (const d of otherResolution.dispositions ?? []) {
|
|
158
|
+
if (d.mode === 'migrate' && d.downstream_feature === featureId) {
|
|
159
|
+
inbound.push({ upstream_feature: other.feature_id, change_key: d.change_key, mode: 'migrate', tracked_by: d.tracked_by, acknowledged: Boolean(d.acknowledged) });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const unknowns = [];
|
|
165
|
+
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`);
|
|
166
|
+
|
|
167
|
+
const report = {
|
|
168
|
+
schema: REPORT_SCHEMA,
|
|
169
|
+
feature_id: featureId,
|
|
170
|
+
generated_at: now.toISOString(),
|
|
171
|
+
baseline: { present: Boolean(baseline), captured_at: baseline?.captured_at ?? null },
|
|
172
|
+
changes,
|
|
173
|
+
outbound,
|
|
174
|
+
inbound,
|
|
175
|
+
unknowns,
|
|
176
|
+
};
|
|
177
|
+
saveImpactReport(root, featureId, report);
|
|
178
|
+
return { report, evaluation: evaluateImpacts(report) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// D-cross-feature-impact-graph (D6): only a PROVEN, undisposed outbound impact blocks -- the
|
|
182
|
+
// gate-fatigue control Codex's own review named as the risk to answer directly. An unacknowledged
|
|
183
|
+
// inbound migrate obligation also blocks (the two-sided handshake).
|
|
184
|
+
export function evaluateImpacts(report) {
|
|
185
|
+
const blockingOutbound = report.outbound.filter((o) => o.confidence === 'proven' && !o.disposition);
|
|
186
|
+
const blockingInbound = report.inbound.filter((i) => !i.acknowledged);
|
|
187
|
+
return {
|
|
188
|
+
blocking: blockingOutbound.length > 0 || blockingInbound.length > 0,
|
|
189
|
+
blockingOutbound,
|
|
190
|
+
blockingInbound,
|
|
191
|
+
heuristicOutbound: report.outbound.filter((o) => o.confidence === 'heuristic'),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// D-cross-feature-impact-graph (D5): the DECIDE half -- refuses (AWAITING_DISPOSITION) if any
|
|
196
|
+
// proven outbound impact is undisposed or any inbound migration is unacknowledged; otherwise
|
|
197
|
+
// atomically rewrites impact-baseline.json from the CURRENT surface. On a feature with no prior
|
|
198
|
+
// baseline, `checkImpact` above already reports every operation as `operation_added` with zero
|
|
199
|
+
// downstream impacts (nothing can depend on what didn't exist), so this captures trivially -- no
|
|
200
|
+
// bootstrap special case.
|
|
201
|
+
export function acceptImpact(root, featureId, { now = new Date() } = {}) {
|
|
202
|
+
requireValidOr400(featureId);
|
|
203
|
+
return withLockSync(root, 'state', () => {
|
|
204
|
+
const { report, evaluation } = checkImpact(root, featureId, { now });
|
|
205
|
+
if (evaluation.blocking) {
|
|
206
|
+
throw new ImpactOperationError(
|
|
207
|
+
`"${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)`,
|
|
208
|
+
{ httpStatus: 409, exitCode: EXIT_CODES.AWAITING_DISPOSITION, reasonCode: 'GATE_AWAITING_DISPOSITION' },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const surface = computeSurface(root, featureId);
|
|
212
|
+
const baseline = saveBaseline(root, featureId, surface, { capturedAt: now.toISOString() });
|
|
213
|
+
return { baseline, report };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// D-cross-feature-impact-graph (D6): records one of {compatible, migrate, waive} for exactly one
|
|
218
|
+
// {change_key, downstream_feature} pair -- never a wildcard. `migrate` requires --tracked-by
|
|
219
|
+
// (non-empty); `waive` requires --expires-days (a positive integer), reusing the SAME decay
|
|
220
|
+
// posture D-waiver-expiry already ships for `contract waive`.
|
|
221
|
+
export function recordDisposition(root, { feature, changeKey, downstreamFeature, mode, reason, trackedBy = null, expiresDays = null, now = new Date() }) {
|
|
222
|
+
requireValidOr400(feature);
|
|
223
|
+
requireValidOr400(downstreamFeature);
|
|
224
|
+
if (!reason || !reason.trim()) {
|
|
225
|
+
throw new ImpactOperationError('bskel impact disposition requires --reason "..." -- every disposition must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
226
|
+
}
|
|
227
|
+
if (!['compatible', 'migrate', 'waive'].includes(mode)) {
|
|
228
|
+
throw new ImpactOperationError(`--mode must be one of compatible|migrate|waive (got "${mode}")`, { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
229
|
+
}
|
|
230
|
+
if (mode === 'migrate' && (!trackedBy || !trackedBy.trim())) {
|
|
231
|
+
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' });
|
|
232
|
+
}
|
|
233
|
+
let expiresAt = null;
|
|
234
|
+
if (mode === 'waive') {
|
|
235
|
+
if (!(Number.isInteger(expiresDays) && expiresDays > 0)) {
|
|
236
|
+
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' });
|
|
237
|
+
}
|
|
238
|
+
expiresAt = new Date(now.getTime() + expiresDays * 24 * 60 * 60 * 1000).toISOString();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return withLockSync(root, 'state', () => {
|
|
242
|
+
const current = loadImpactResolution(root, feature);
|
|
243
|
+
const next = {
|
|
244
|
+
schema: RESOLUTION_SCHEMA,
|
|
245
|
+
feature_id: feature,
|
|
246
|
+
dispositions: [
|
|
247
|
+
...current.dispositions.filter((d) => !(d.change_key === changeKey && d.downstream_feature === downstreamFeature)),
|
|
248
|
+
{
|
|
249
|
+
change_key: changeKey,
|
|
250
|
+
downstream_feature: downstreamFeature,
|
|
251
|
+
mode,
|
|
252
|
+
reason,
|
|
253
|
+
tracked_by: mode === 'migrate' ? trackedBy : null,
|
|
254
|
+
expires_at: expiresAt,
|
|
255
|
+
acknowledged: false,
|
|
256
|
+
acknowledged_at: null,
|
|
257
|
+
acknowledged_reason: null,
|
|
258
|
+
at: now.toISOString(),
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
return saveImpactResolution(root, feature, next);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// D-cross-feature-impact-graph (D6): the DOWNSTREAM feature's own acknowledgement of a `migrate`
|
|
267
|
+
// obligation another feature recorded against it -- flips `acknowledged` on the UPSTREAM feature's
|
|
268
|
+
// own resolution record (that's where the obligation lives; ack doesn't create a new file).
|
|
269
|
+
export function acknowledgeInbound(root, { feature, from, changeKey, reason, now = new Date() }) {
|
|
270
|
+
requireValidOr400(feature);
|
|
271
|
+
requireValidOr400(from);
|
|
272
|
+
if (!reason || !reason.trim()) {
|
|
273
|
+
throw new ImpactOperationError('bskel impact ack requires --reason "..." -- every acknowledgement must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
274
|
+
}
|
|
275
|
+
return withLockSync(root, 'state', () => {
|
|
276
|
+
const upstreamResolution = loadImpactResolution(root, from);
|
|
277
|
+
const match = upstreamResolution.dispositions.find((d) => d.mode === 'migrate' && d.downstream_feature === feature && d.change_key === changeKey);
|
|
278
|
+
if (!match) {
|
|
279
|
+
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' });
|
|
280
|
+
}
|
|
281
|
+
match.acknowledged = true;
|
|
282
|
+
match.acknowledged_at = now.toISOString();
|
|
283
|
+
match.acknowledged_reason = reason;
|
|
284
|
+
return saveImpactResolution(root, from, upstreamResolution);
|
|
285
|
+
});
|
|
286
|
+
}
|
package/lib/patch-kinds.mjs
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.6.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;
|
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
"required": ["algorithm", "value"],
|
|
16
16
|
"properties": {
|
|
17
17
|
"algorithm": { "const": "ed25519" },
|
|
18
|
-
"value": { "type": "string", "description": "base64-encoded raw Ed25519 signature bytes over canonicalize(report)." }
|
|
18
|
+
"value": { "type": "string", "description": "base64-encoded raw Ed25519 signature bytes over canonicalize(report)." },
|
|
19
|
+
"key_id": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"pattern": "^ed25519:[0-9a-f]{32}$",
|
|
22
|
+
"description": "D-attestation-payload-completeness (K6): a SELECTION HINT, not a trust claim. It sits OUTSIDE the signed bytes (only `report` is signed) and is therefore attacker-modifiable -- `bskel attest verify` uses it only to produce a better error message when the wrong --pubkey is supplied. Verification depends solely on the signature over canonicalize(report)."
|
|
23
|
+
}
|
|
19
24
|
}
|
|
20
25
|
}
|
|
21
26
|
}
|