backend-skeleton 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +331 -53
  3. package/contracts/emit.mjs +22 -6
  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 +244 -77
  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/handles/providers/typescript-express/plan.mjs +15 -2
  16. package/lib/attest.mjs +59 -1
  17. package/lib/cli.mjs +79 -7
  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 +199 -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 +286 -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 +55 -0
  31. package/scanners/adapters/java-spring.mjs +154 -3
  32. package/scanners/index.mjs +10 -0
  33. package/schemas/feature-contract.schema.json +12 -1
  34. package/schemas/gate-attestation.schema.json +6 -1
  35. package/schemas/gate-export.schema.json +530 -22
  36. package/schemas/handles-plan.schema.json +32 -0
  37. package/schemas/impact-baseline.schema.json +59 -0
  38. package/schemas/impact-graph.schema.json +53 -0
  39. package/schemas/impact-report.schema.json +86 -0
  40. package/schemas/impact-resolution.schema.json +33 -0
  41. package/schemas/java-source-splice.schema.json +84 -0
  42. 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
+ }
@@ -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.4.0",
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",
@@ -72,6 +72,55 @@ export function findClassOrRecordDeclaration(maskedText) {
72
72
  return { keyword: m[1], name: m[2], index: m.index };
73
73
  }
74
74
 
75
+ // D-spring-data-rest-adapter: interface-shaped counterpart to findClassOrRecordDeclaration()
76
+ // above -- CLASS_OR_RECORD_RE is hard-gated to `class`/`record`, so a Spring Data repository
77
+ // interface (`interface X extends JpaRepository<Widget, UUID>`) is never recognized by it. Only
78
+ // the single common shape "interface Name extends Super<A, B>" is matched -- a second
79
+ // extends-interface in a multi-interface list (e.g. `extends QuerydslPredicateExecutor<Widget>,
80
+ // JpaRepository<Widget, UUID>`) is not found; the caller is expected to validate `superName`
81
+ // against a known allow-list and fail closed rather than guess which listed interface is the real
82
+ // Spring Data supertype. Operates on masked text.
83
+ const INTERFACE_EXTENDS_RE = /(?:public\s+)?\binterface\s+(\w+)\s+extends\s+(\w+)/;
84
+
85
+ export function findInterfaceExtendsDeclaration(maskedText) {
86
+ const m = maskedText.match(INTERFACE_EXTENDS_RE);
87
+ if (!m) return null;
88
+ const afterSuper = m.index + m[0].length;
89
+ let typeArgsStart = null;
90
+ let typeArgsEnd = null;
91
+ if (maskedText[afterSuper] === '<') {
92
+ const close = matchBalanced(maskedText, afterSuper, '<', '>');
93
+ if (close !== -1) {
94
+ typeArgsStart = afterSuper + 1;
95
+ typeArgsEnd = close;
96
+ }
97
+ }
98
+ return { name: m[1], superName: m[2], index: m.index, typeArgsStart, typeArgsEnd };
99
+ }
100
+
101
+ // D-spring-data-rest-adapter: depth-tracked top-level comma split on '<'/'>' only -- same
102
+ // algorithm patch-strategy.mjs's own splitTopLevelParams() uses on '('/')' for a DTO constructor's
103
+ // argument list. Reimplemented here rather than imported: patch-strategy.mjs lives under
104
+ // handles/providers/java-spring/, a DOWNSTREAM consumer of this file -- importing from it here
105
+ // would invert the dependency direction this codebase's module layout otherwise keeps one-way.
106
+ export function splitTopLevelTypeArgs(argsText) {
107
+ const parts = [];
108
+ let depth = 0;
109
+ let start = 0;
110
+ for (let i = 0; i < argsText.length; i++) {
111
+ const ch = argsText[i];
112
+ if (ch === '<') depth++;
113
+ else if (ch === '>') depth--;
114
+ else if (ch === ',' && depth === 0) {
115
+ parts.push(argsText.slice(start, i).trim());
116
+ start = i + 1;
117
+ }
118
+ }
119
+ const last = argsText.slice(start).trim();
120
+ if (last !== '' || parts.length > 0) parts.push(last);
121
+ return parts.filter((p) => p !== '');
122
+ }
123
+
75
124
  const WHITESPACE_RE = /^\s*/;
76
125
  // A2 Phase 2 (D-java-ast-helper): `[\w.]+`, not `\w+` -- found live while building the real
77
126
  // JavaParser/Symbol-Solver AST cross-check. A fully-qualified annotation
@@ -262,6 +311,12 @@ export function findMappingAnnotations(text) {
262
311
  argsText: argsStart >= 0 ? text.slice(argsStart, argsEnd) : '',
263
312
  methodName: sig.methodName,
264
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,
265
320
  });
266
321
  }
267
322
  return results;