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.
Files changed (37) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +331 -53
  3. package/contracts/openapi.mjs +125 -18
  4. package/handles/providers/java-spring/ast-bridge.mjs +85 -1
  5. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
  6. package/handles/providers/java-spring/emit.mjs +126 -6
  7. package/handles/providers/java-spring/plan.mjs +220 -74
  8. package/handles/providers/java-spring/source-splice.mjs +477 -0
  9. package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
  10. package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
  11. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
  12. package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
  13. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
  14. package/lib/attest.mjs +59 -1
  15. package/lib/cli.mjs +79 -7
  16. package/lib/doctor.mjs +23 -0
  17. package/lib/exit-codes.mjs +17 -0
  18. package/lib/gate-definitions.mjs +65 -2
  19. package/lib/gate-export.mjs +199 -0
  20. package/lib/impact-export-graphify.mjs +145 -0
  21. package/lib/impact-graph.mjs +194 -0
  22. package/lib/impact-surface.mjs +158 -0
  23. package/lib/impact.mjs +286 -0
  24. package/lib/patch-kinds.mjs +24 -0
  25. package/lib/repo.mjs +46 -0
  26. package/lib/workflow.mjs +16 -0
  27. package/package.json +1 -1
  28. package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
  29. package/schemas/gate-attestation.schema.json +6 -1
  30. package/schemas/gate-export.schema.json +530 -22
  31. package/schemas/handles-plan.schema.json +32 -0
  32. package/schemas/impact-baseline.schema.json +59 -0
  33. package/schemas/impact-graph.schema.json +53 -0
  34. package/schemas/impact-report.schema.json +86 -0
  35. package/schemas/impact-resolution.schema.json +33 -0
  36. package/schemas/java-source-splice.schema.json +84 -0
  37. package/schemas/patch-transaction.schema.json +87 -2
@@ -0,0 +1,477 @@
1
+ // D-java-source-splice: the "java-source-splice" kind for lib/patch-transactions.mjs -- in-file
2
+ // editing of real, hand-written Java source (a method body, a field initializer, or one new
3
+ // import), closing D-patch-transactions' own first-named EXIT item ("in-file source splicing").
4
+ //
5
+ // Node identity (Codex's own named central risk for this class of feature -- "incorrect node
6
+ // identity is the central risk") is established by THREE independent mechanisms, not one:
7
+ // 1. the real JavaParser+Symbol Solver AST helper (ast-bridge.mjs's runAstLocate), which
8
+ // resolves a member by its language-guaranteed identity: a type's fully-qualified name plus,
9
+ // for a method, its ERASED parameter types -- the exact rule javac itself uses to forbid two
10
+ // same-named methods sharing the same erased signature. This is authoritative.
11
+ // 2. a self-validating offset conversion: the AST reports 1-based line/column, this module
12
+ // converts to a byte offset itself and asserts `text.slice(start,end) === regionText`
13
+ // exactly -- an offset-math bug fails CLOSED (refuses) rather than silently mis-splicing.
14
+ // 3. an INDEPENDENT falsifier using scanners/adapters/_java-spring-analyzer.mjs's maskNonCode()
15
+ // -- confirms the located region genuinely opens with '{' and that the member's own name
16
+ // appears immediately before it, on masked (comment/string-safe) text. This exists because a
17
+ // first draft that used ONLY the regex-based analyzer as the *locator* was proven live (see
18
+ // DECISIONS.md D-java-source-splice) to pick the WRONG overload -- that toolkit is kept here
19
+ // only as a second opinion, never as the source of truth.
20
+ //
21
+ // Mirrors stack/config-apply.mjs's/scanners/db/ddl-apply.mjs's planner contract exactly (the same
22
+ // seven plan fields), and reuses lib/attest.mjs-established discipline nowhere directly (this
23
+ // kind has no attestation involvement) but the SAME "never trust a stored computation, always
24
+ // re-derive" posture those items established for this codebase generally.
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { sha256String, sha256File } from '../../../lib/fsutil.mjs';
28
+ import { unifiedDiff } from '../../../lib/diff.mjs';
29
+ import { assertContained } from '../../../stack/apply.mjs';
30
+ import { detectAstHelperAvailable, runAstLocate, runAstParse } from './ast-bridge.mjs';
31
+ import { maskNonCode, matchBalanced } from '../../../scanners/adapters/_java-spring-analyzer.mjs';
32
+ import { detectJavaSpringRoot } from '../../../scanners/adapters/java-spring.mjs';
33
+ import { loadManifest, BSKEL_GENERATED_MARKER } from '../../../lib/handles-manifest.mjs';
34
+ import { detectBuildCommand, runBuildCheck } from '../../../lib/verify.mjs';
35
+ import { readBlob } from '../../../lib/patch-transactions.mjs';
36
+
37
+ export class JavaSplicePlanError extends Error {}
38
+ export class JavaSpliceExecutionError extends Error {}
39
+
40
+ const DESTRUCTIVE_OPS = new Set(['replace-method-body', 'insert-method-body-prologue', 'replace-field-initializer']);
41
+ const SUPPORTED_OPS = new Set([...DESTRUCTIVE_OPS, 'add-import']);
42
+
43
+ // ---------------------------------------------------------------------------------------------
44
+ // Small pure helpers
45
+ // ---------------------------------------------------------------------------------------------
46
+
47
+ // 1-based line, 1-based column (JavaParser's own convention, confirmed live) -> 0-based string
48
+ // index. Never trusted on its own -- every caller immediately asserts the resulting slice equals
49
+ // the AST's own reported text (D5, mechanism 2).
50
+ export function lineColToOffset(text, line, col) {
51
+ let idx = 0;
52
+ let currentLine = 1;
53
+ while (currentLine < line) {
54
+ const nl = text.indexOf('\n', idx);
55
+ if (nl === -1) throw new JavaSplicePlanError(`internal error: line ${line} exceeds the file's actual line count`);
56
+ idx = nl + 1;
57
+ currentLine++;
58
+ }
59
+ return idx + (col - 1);
60
+ }
61
+
62
+ export function detectLineTerminator(text) {
63
+ const idx = text.indexOf('\n');
64
+ if (idx === -1) return '\n';
65
+ return text[idx - 1] === '\r' ? '\r\n' : '\n';
66
+ }
67
+
68
+ // D5, mechanism 3: an INDEPENDENT (non-AST) falsifier -- confirms the region genuinely opens a
69
+ // method body immediately after the member's own name, on masked (string/comment-safe) text, so
70
+ // a wrong-nesting-level AST result cannot silently pass. Deliberately narrow -- it is a second
71
+ // opinion, not a second locator.
72
+ export function independentFalsifierAgrees(maskedText, memberName, regionStartOffset, kind) {
73
+ if (kind === 'method' && maskedText[regionStartOffset] !== '{') return false;
74
+ const windowStart = Math.max(0, regionStartOffset - 400);
75
+ const window = maskedText.slice(windowStart, regionStartOffset);
76
+ const nameRe = new RegExp(`\\b${memberName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
77
+ return nameRe.test(window);
78
+ }
79
+
80
+ // D2: `insert-method-body-prologue`'s replacement is DERIVED, never supplied -- the original
81
+ // body's remainder (everything after its own leading "{") must survive as a verbatim suffix.
82
+ export function deriveProloguePrefix(originalRegionText, statements, terminator) {
83
+ if (originalRegionText === '{}' || originalRegionText.replace(/[{}\s]/g, '') === '') {
84
+ throw new JavaSplicePlanError('insert-method-body-prologue refuses an empty method body ("{}") -- there is no existing indentation to infer; use replace-method-body instead');
85
+ }
86
+ const lines = originalRegionText.split(terminator);
87
+ // lines[0] is "{" itself; the first non-blank line after it carries the indent to copy.
88
+ const indentLine = lines.slice(1).find((l) => l.trim().length > 0);
89
+ if (indentLine === undefined) {
90
+ throw new JavaSplicePlanError('insert-method-body-prologue could not find an indentation reference line in the existing body -- use replace-method-body instead');
91
+ }
92
+ const indent = indentLine.slice(0, indentLine.length - indentLine.trimStart().length);
93
+ const remainder = originalRegionText.slice(1); // drop only the leading "{"
94
+ const replacement = `{${terminator}${indent}${statements}${terminator}${remainder}`;
95
+ if (!replacement.endsWith(remainder)) {
96
+ throw new JavaSplicePlanError('internal error: insert-method-body-prologue built a replacement that does not end with the original body verbatim');
97
+ }
98
+ return replacement;
99
+ }
100
+
101
+ // D2 (add-import): the file's own "package ...; \n import ...; \n import ...;" prefix, found by
102
+ // plain text scanning (this edit has no per-member locator -- it targets the file's own import
103
+ // block, not a class member, so the AST "locate" mechanism does not apply to it). Handles the
104
+ // zero-existing-imports case (region ends right after the package statement, or at offset 0 for a
105
+ // default-package file).
106
+ export function findImportsRegion(text) {
107
+ const pkgMatch = text.match(/^\s*package\s+[\w.]+\s*;/m);
108
+ let end = pkgMatch ? pkgMatch.index + pkgMatch[0].length : 0;
109
+ const importRe = /^\s*import\s+(?:static\s+)?[\w.]+(?:\.\*)?\s*;/gm;
110
+ importRe.lastIndex = end;
111
+ let m;
112
+ let lastImportEnd = end;
113
+ while ((m = importRe.exec(text)) !== null) {
114
+ // Only count imports that are reasonably contiguous with what's already been scanned
115
+ // (allow blank lines/comments between them, but stop once we've clearly left the import
116
+ // block -- e.g. reached a type declaration). A simple, generous heuristic: keep advancing
117
+ // as long as nothing but whitespace/comments/imports appears between the previous end and
118
+ // this match.
119
+ const between = text.slice(lastImportEnd, m.index);
120
+ if (/\b(class|interface|enum|record|@interface)\b/.test(between)) break;
121
+ lastImportEnd = m.index + m[0].length;
122
+ }
123
+ return { start: 0, end: lastImportEnd };
124
+ }
125
+
126
+ export function isDuplicateImport(text, fqn) {
127
+ const re = new RegExp(`^\\s*import\\s+${fqn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*;`, 'm');
128
+ return re.test(text);
129
+ }
130
+
131
+ // D3: the Merkle roll-up -- a single hash covering every edit's own (op, locator, region_hash,
132
+ // signature_hash), fed into the ENGINE's one `preimage.region_hash` field so N edits are covered
133
+ // by the transaction engine's existing single-field TOCTOU check with zero engine change.
134
+ // `edits` here are the already-enriched {op, locator, region_hash, signature_hash} records (in
135
+ // the SAME order they were declared in the request document -- order-sensitive by design, so
136
+ // re-ordering two edits in a re-plan is itself detected as drift).
137
+ export function computeMerkleRegionHash(edits) {
138
+ return sha256String(JSON.stringify(
139
+ edits.map((e) => [e.op, e.locator ?? null, e.region_hash, e.signature_hash ?? null]),
140
+ ));
141
+ }
142
+
143
+ // Rung 13: throws JavaSplicePlanError naming the file if any two [start,end) ranges overlap.
144
+ // `ranges` need not be pre-sorted.
145
+ export function assertNoOverlaps(ranges, file) {
146
+ const sorted = [...ranges].sort((a, b) => a.start - b.start);
147
+ for (let i = 1; i < sorted.length; i++) {
148
+ if (sorted[i].start < sorted[i - 1].end) {
149
+ throw new JavaSplicePlanError(`two edits target overlapping regions of "${file}" -- refusing`);
150
+ }
151
+ }
152
+ }
153
+
154
+ // Applies `edits` (each carrying `_start`/`_end`/`_finalReplacement`) to `text` from the END of
155
+ // the file backward (highest start offset first) -- every earlier (lower-start) edit's own
156
+ // offsets stay valid throughout, since nothing before the current edit's start has been touched
157
+ // yet. `add-import`'s region always starts at/near offset 0, so it is always applied LAST here.
158
+ export function applyEditsDescending(text, edits) {
159
+ const descending = [...edits].sort((a, b) => b._start - a._start);
160
+ let rendered = text;
161
+ for (const e of descending) {
162
+ rendered = rendered.slice(0, e._start) + e._finalReplacement + rendered.slice(e._end);
163
+ }
164
+ return rendered;
165
+ }
166
+
167
+ // The SAME splice, built the opposite direction (lowest start offset first, single forward pass)
168
+ // -- an independent second construction, not a refactor of the first. Asserted equal to
169
+ // applyEditsDescending()'s own result by the caller (assertOnlyRegionsChanged's actual mechanism)
170
+ // so an off-by-one in EITHER loop is caught, not just a stray change outside the touched regions.
171
+ export function applyEditsAscending(text, edits) {
172
+ const ascending = [...edits].sort((a, b) => a._start - b._start);
173
+ let result = '';
174
+ let cursor = 0;
175
+ for (const e of ascending) {
176
+ result += text.slice(cursor, e._start) + e._finalReplacement;
177
+ cursor = e._end;
178
+ }
179
+ result += text.slice(cursor);
180
+ return result;
181
+ }
182
+
183
+ // D4's defensive assertion, named for what it proves: the only bytes that differ between
184
+ // `original` and `rendered` are inside the edits' own regions. Implemented by cross-checking two
185
+ // INDEPENDENTLY-constructed renders (forward vs backward splice) rather than diffing remainders --
186
+ // strictly stronger, since it also catches an off-by-one inside either construction itself, not
187
+ // only a stray change outside every touched region. Should be unreachable; throws if it isn't.
188
+ export function assertOnlyRegionsChanged(text, edits) {
189
+ const descending = applyEditsDescending(text, edits);
190
+ const ascending = applyEditsAscending(text, edits);
191
+ if (descending !== ascending) {
192
+ throw new JavaSplicePlanError('internal error: forward and backward edit application produced different results -- refusing rather than risk a corrupted splice (this should be unreachable; please report it)');
193
+ }
194
+ return descending;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------------------------
198
+ // Planner
199
+ // ---------------------------------------------------------------------------------------------
200
+
201
+ // `params` = { file (repo-relative), edits (the --splice-file document's own `edits[]`, already
202
+ // schema-validated by the caller at propose time -- re-plan at approve/apply reconstructs this
203
+ // same shape from the stored transaction's target.edits, see paramsFromTxn() in lib/patch-kinds.mjs) }.
204
+ export async function planJavaSourceSplice(repoRoot, { file, edits }) {
205
+ if (!Array.isArray(edits) || edits.length === 0) {
206
+ throw new JavaSplicePlanError('java-source-splice requires at least one edit');
207
+ }
208
+ for (const e of edits) {
209
+ if (!SUPPORTED_OPS.has(e.op)) {
210
+ throw new JavaSplicePlanError(`unsupported op "${e.op}" -- java-source-splice only supports: ${[...SUPPORTED_OPS].join(', ')}`);
211
+ }
212
+ }
213
+ const addImportEdits = edits.filter((e) => e.op === 'add-import');
214
+ if (addImportEdits.length > 1) {
215
+ throw new JavaSplicePlanError('at most one add-import edit is permitted per transaction (each would target the same file-prefix region, so a second one would overlap the first)');
216
+ }
217
+
218
+ const targetAbs = path.join(repoRoot, file);
219
+ assertContained(repoRoot, targetAbs, 'java-source-splice target file');
220
+ if (!file.endsWith('.java')) {
221
+ throw new JavaSplicePlanError(`"${file}" is not a .java file`);
222
+ }
223
+ if (!fs.existsSync(targetAbs)) {
224
+ throw new JavaSplicePlanError(`"${file}" does not exist -- nothing to splice`);
225
+ }
226
+
227
+ const text = fs.readFileSync(targetAbs, 'utf8');
228
+
229
+ // Rung: bskel-generated files are off-limits -- a later `handles emit` would silently
230
+ // overwrite a hand-spliced edit; the real fix is to edit the template or the resolver-
231
+ // ownership path instead, not to splice generated output.
232
+ const manifest = loadManifest(repoRoot);
233
+ const relForManifest = path.relative(repoRoot, targetAbs).split(path.sep).join('/');
234
+ if (text.includes(BSKEL_GENERATED_MARKER) || Object.hasOwn(manifest.files, relForManifest)) {
235
+ throw new JavaSplicePlanError(`"${file}" is bskel-generated (carries the "${BSKEL_GENERATED_MARKER}" marker, or is tracked in .sbf/handles-manifest.json) -- a later \`handles emit\` would silently overwrite a splice here; edit the template or the resolver-ownership path instead`);
236
+ }
237
+
238
+ const lineTerminator = detectLineTerminator(text);
239
+ if (lineTerminator === '\r\n') {
240
+ throw new JavaSplicePlanError(`"${file}" uses CRLF line endings -- refusing rather than risk mixed line endings after a splice. Convert the file to LF first.`);
241
+ }
242
+
243
+ const detection = detectAstHelperAvailable();
244
+ if (!detection.available) {
245
+ throw new JavaSplicePlanError(detection.reason);
246
+ }
247
+ const build = detectBuildCommand(repoRoot);
248
+ if (!build) {
249
+ throw new JavaSplicePlanError('no recognized build tool (gradlew/pom.xml/package.json) found -- a postcondition that could never run is not a postcondition');
250
+ }
251
+
252
+ const srcRoot = detectJavaSpringRoot(repoRoot);
253
+ if (!srcRoot) {
254
+ throw new JavaSplicePlanError(`could not find a Java "src/main/java" root above "${file}" (no build.gradle/pom.xml with a sibling src/main/java) -- java-source-splice only supports the standard Maven/Gradle layout`);
255
+ }
256
+
257
+ const locators = edits
258
+ .filter((e) => e.op !== 'add-import')
259
+ .map((e) => ({
260
+ type_fqn: e.locator.type_fqn,
261
+ member_kind: e.locator.member_kind,
262
+ member_name: e.locator.member_name,
263
+ erased_param_types: e.locator.erased_param_types ?? [],
264
+ }));
265
+
266
+ let astResult = { topLevelTypes: [], results: [] };
267
+ if (locators.length > 0) {
268
+ astResult = await runAstLocate(targetAbs, srcRoot, locators);
269
+ if (astResult.topLevelTypes.length !== 1) {
270
+ throw new JavaSplicePlanError(`"${file}" declares ${astResult.topLevelTypes.length} top-level type(s) (${astResult.topLevelTypes.join(', ') || 'none'}) -- java-source-splice V1 only supports a file with exactly one top-level type`);
271
+ }
272
+ }
273
+
274
+ const masked = maskNonCode(text);
275
+ let locatorResultIndex = 0;
276
+ const enrichedEdits = [];
277
+ const occupiedRanges = []; // {start, end} in ORIGINAL text, for overlap detection
278
+
279
+ for (const e of edits) {
280
+ if (e.op === 'add-import') {
281
+ const region = findImportsRegion(text);
282
+ const originalRegionText = text.slice(region.start, region.end);
283
+ const fqn = e.imports[0];
284
+ if (isDuplicateImport(text, fqn)) {
285
+ throw new JavaSplicePlanError(`"${file}" already imports "${fqn}" -- nothing to add`);
286
+ }
287
+ const finalReplacement = `${originalRegionText}${originalRegionText ? lineTerminator : ''}import ${fqn};`;
288
+ if (!finalReplacement.startsWith(originalRegionText)) {
289
+ throw new JavaSplicePlanError('internal error: add-import built a replacement that does not start with the existing imports verbatim');
290
+ }
291
+ occupiedRanges.push({ start: region.start, end: region.end });
292
+ enrichedEdits.push({
293
+ op: e.op,
294
+ imports: e.imports,
295
+ region_hash: sha256String(originalRegionText),
296
+ signature_hash: null,
297
+ located: { start: region.start, end: region.end, begin_line: null, end_line: null },
298
+ _start: region.start,
299
+ _end: region.end,
300
+ _finalReplacement: finalReplacement,
301
+ });
302
+ continue;
303
+ }
304
+
305
+ const result = astResult.results[locatorResultIndex++];
306
+ if (!result || !result.resolved) {
307
+ throw new JavaSplicePlanError(`could not locate ${e.locator.member_kind} "${e.locator.member_name}" in "${e.locator.type_fqn}": ${result?.error ?? 'no result returned by the AST helper'}`);
308
+ }
309
+ if ((result.unresolvedParamTypes ?? []).length > 0) {
310
+ throw new JavaSplicePlanError(`"${e.locator.member_name}" in "${e.locator.type_fqn}" has unresolvable parameter type(s) (${result.unresolvedParamTypes.join(', ')}) -- refusing to splice a member whose own signature this tool cannot fully resolve`);
311
+ }
312
+
313
+ const regionStart = lineColToOffset(text, result.beginLine, result.beginColumn);
314
+ const regionEnd = lineColToOffset(text, result.endLine, result.endColumn) + 1; // AST end is inclusive of the last char
315
+ const regionText = text.slice(regionStart, regionEnd);
316
+ if (regionText !== result.regionText) {
317
+ throw new JavaSplicePlanError(`internal error: offset-converted region text for "${e.locator.member_name}" does not match the AST helper's own reported text -- refusing rather than risk mis-splicing (this should be unreachable; please report it)`);
318
+ }
319
+
320
+ const sigStart = lineColToOffset(text, result.signatureBeginLine, result.signatureBeginColumn);
321
+ const sigEnd = lineColToOffset(text, result.signatureEndLine, result.signatureEndColumn) + 1;
322
+ const signatureText = text.slice(sigStart, sigEnd);
323
+ if (signatureText !== result.signatureText) {
324
+ throw new JavaSplicePlanError(`internal error: offset-converted signature text for "${e.locator.member_name}" does not match the AST helper's own reported text -- refusing rather than risk mis-splicing (this should be unreachable; please report it)`);
325
+ }
326
+
327
+ const kind = e.locator.member_kind === 'field' ? 'field' : 'method';
328
+ if (!independentFalsifierAgrees(masked, e.locator.member_name, regionStart, kind)) {
329
+ throw new JavaSplicePlanError(`the independent (non-AST) locator check disagrees with the AST helper for "${e.locator.member_name}" in "${e.locator.type_fqn}" -- refusing rather than trust a single mechanism for something this consequential`);
330
+ }
331
+
332
+ let finalReplacement;
333
+ if (e.op === 'replace-method-body') {
334
+ const candidate = e.replacement;
335
+ if (!candidate.startsWith('{') || !candidate.trimEnd().endsWith('}')) {
336
+ throw new JavaSplicePlanError(`replace-method-body's replacement for "${e.locator.member_name}" must start with "{" and end with "}"`);
337
+ }
338
+ if (candidate.includes('\r\n')) {
339
+ throw new JavaSplicePlanError(`replace-method-body's replacement for "${e.locator.member_name}" contains CRLF line endings, but "${file}" uses LF -- refusing to mix line endings`);
340
+ }
341
+ const closeIdx = matchBalanced(maskNonCode(candidate), 0, '{', '}');
342
+ if (closeIdx !== candidate.trimEnd().length - 1) {
343
+ throw new JavaSplicePlanError(`replace-method-body's replacement for "${e.locator.member_name}" is not brace-balanced`);
344
+ }
345
+ finalReplacement = candidate;
346
+ } else if (e.op === 'insert-method-body-prologue') {
347
+ finalReplacement = deriveProloguePrefix(regionText, e.statements, lineTerminator);
348
+ } else if (e.op === 'replace-field-initializer') {
349
+ finalReplacement = e.replacement;
350
+ }
351
+
352
+ occupiedRanges.push({ start: regionStart, end: regionEnd });
353
+ enrichedEdits.push({
354
+ op: e.op,
355
+ locator: e.locator,
356
+ ...(e.op !== 'insert-method-body-prologue' ? { replacement: e.replacement } : { statements: e.statements }),
357
+ region_hash: sha256String(regionText),
358
+ signature_hash: sha256String(signatureText),
359
+ located: { start: regionStart, end: regionEnd, begin_line: result.beginLine, end_line: result.endLine },
360
+ _start: regionStart,
361
+ _end: regionEnd,
362
+ _finalReplacement: finalReplacement,
363
+ });
364
+ }
365
+
366
+ assertNoOverlaps(occupiedRanges, file);
367
+ const rendered = assertOnlyRegionsChanged(text, enrichedEdits);
368
+
369
+ const parseResult = await runAstParse(rendered);
370
+ if (!parseResult.ok) {
371
+ throw new JavaSplicePlanError(`the proposed splice does not parse as valid Java:\n${(parseResult.problems ?? []).join('\n')}`);
372
+ }
373
+
374
+ const regionHash = computeMerkleRegionHash(enrichedEdits);
375
+
376
+ const publicEdits = enrichedEdits.map(({ _start, _end, _finalReplacement, ...rest }) => rest);
377
+ const memberSummary = publicEdits
378
+ .map((e) => (e.locator ? `${e.locator.member_name}` : `import ${e.imports[0]}`))
379
+ .join(', ');
380
+
381
+ return {
382
+ target: {
383
+ file,
384
+ source_root: path.relative(repoRoot, srcRoot).split(path.sep).join('/'),
385
+ line_terminator: lineTerminator,
386
+ edits: publicEdits,
387
+ },
388
+ preimage: { region_hash: regionHash, file_hash: sha256String(text) },
389
+ current_value: `${publicEdits.length} edit(s) to ${memberSummary}`,
390
+ proposed_value: unifiedDiff(file, text, rendered),
391
+ postcondition: { kind: 'java-compiles', build_tool: build.tool, build_command: `${build.cmd} ${build.args.join(' ')}` },
392
+ originalContent: text,
393
+ renderedContent: rendered,
394
+ };
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------------------------
398
+ // Executors
399
+ // ---------------------------------------------------------------------------------------------
400
+
401
+ // D6: write, then compile -- the filesystem transposition of ddl-apply's real Postgres
402
+ // BEGIN/COMMIT/ROLLBACK. A compile failure restores the ORIGINAL bytes from the CAS blob before
403
+ // throwing -- the transaction record itself stays "approved", not "applied", so a retry (a fresh
404
+ // propose against the still-unsplice-broken file) is the natural next step.
405
+ export async function executeJavaSpliceApply(root, featureId, txn, freshKindPlan) {
406
+ const targetAbs = path.join(root, txn.target.file);
407
+ fs.writeFileSync(targetAbs, freshKindPlan.renderedContent);
408
+ const buildResult = runBuildCheck(root);
409
+ if (!buildResult.ok) {
410
+ const original = readBlob(root, featureId, txn.preimage.file_hash);
411
+ fs.writeFileSync(targetAbs, original);
412
+ throw new JavaSpliceExecutionError(
413
+ `the splice was written but the project failed to compile -- "${txn.target.file}" has been restored to its original content.\n\n${buildResult.message}`,
414
+ );
415
+ }
416
+ return { postimage_file_hash: sha256String(freshKindPlan.renderedContent), build_tool: buildResult.tool };
417
+ }
418
+
419
+ // D7: byte-exact whole-file restore from the CAS blob, mirroring executeConfigRollback() exactly,
420
+ // plus a compile check afterward (recorded, not enforced -- restoring a KNOWN-GOOD prior state
421
+ // must never be refused just because something ELSE in the repo is currently broken).
422
+ export async function executeJavaSpliceRollback(root, featureId, txn, { force = false } = {}) {
423
+ const targetAbs = path.join(root, txn.target.file);
424
+ const currentHash = sha256File(targetAbs);
425
+ if (currentHash !== txn.apply.postimage_file_hash && !force) {
426
+ throw new JavaSpliceExecutionError(
427
+ `"${txn.target.file}" has changed since transaction "${txn.transaction_id}" applied -- rolling back would silently clobber that change; pass --force --reason if intentional`,
428
+ );
429
+ }
430
+ const original = readBlob(root, featureId, txn.preimage.file_hash);
431
+ fs.writeFileSync(targetAbs, original);
432
+ const buildResult = runBuildCheck(root);
433
+ return { compile_after_rollback: buildResult.ok ? 'ok' : 'failed' };
434
+ }
435
+
436
+ // Retyping which hand-written member(s) are being rewritten is the actual attention check --
437
+ // an add-import-only transaction is purely additive, so it falls back to the transaction id
438
+ // (ddl-apply's own non-destructive fallback, same reasoning).
439
+ export function requiredConfirmValue(txn) {
440
+ const destructive = txn.target.edits.filter((e) => DESTRUCTIVE_OPS.has(e.op));
441
+ if (destructive.length === 0) return txn.transaction_id;
442
+ const members = destructive.map((e) => `${e.locator.type_fqn.split('.').pop()}#${e.locator.member_name}`);
443
+ return [...new Set(members)].sort().join(',');
444
+ }
445
+
446
+ // Consulted by bin/bskel.mjs's cmdPatchApprove/cmdPatchApply only when replanTransaction() throws
447
+ // StaleTransactionError -- names which edit's stored hash no longer matches the fresh one, and
448
+ // which of region_hash/signature_hash moved, plus a whole-file diff against the fresh content
449
+ // recovered from the original preimage blob.
450
+ export function describeStaleness(root, txn, freshPlan) {
451
+ const lines = [];
452
+ const stored = txn.target.edits;
453
+ const fresh = freshPlan.target.edits;
454
+ for (let i = 0; i < Math.max(stored.length, fresh.length); i++) {
455
+ const s = stored[i];
456
+ const f = fresh[i];
457
+ if (!s || !f) {
458
+ lines.push(` edit ${i}: edit list shape changed`);
459
+ continue;
460
+ }
461
+ const what = s.locator ? `${s.locator.type_fqn}#${s.locator.member_name}` : `add-import ${s.imports?.[0]}`;
462
+ if (s.region_hash !== f.region_hash) lines.push(` edit ${i} (${what}): region content changed`);
463
+ if (s.signature_hash !== f.signature_hash) lines.push(` edit ${i} (${what}): signature changed (e.g. return type, modifiers)`);
464
+ }
465
+ let diffText = '';
466
+ try {
467
+ const original = readBlob(root, txn.feature_id, txn.preimage.file_hash);
468
+ const currentAbs = path.join(root, txn.target.file);
469
+ const current = fs.readFileSync(currentAbs, 'utf8');
470
+ diffText = unifiedDiff(txn.target.file, original, current);
471
+ } catch {
472
+ // best-effort diagnostics only
473
+ }
474
+ return [`transaction "${txn.transaction_id}"'s target has changed since it was proposed:`, ...lines, diffText]
475
+ .filter(Boolean)
476
+ .join('\n');
477
+ }
@@ -0,0 +1,30 @@
1
+ package {{BASE_PACKAGE}}.domain.{{MODULE}}.infrastructure;
2
+
3
+ import org.springframework.security.core.Authentication;
4
+
5
+ import java.util.UUID;
6
+
7
+ /**
8
+ * Generated by backend-skeleton ({@code bskel handles emit}) for feature {{FEATURE_ID}}.
9
+ *
10
+ * <p>D-resolver-policy-contract: {@code bskel} could not safely auto-derive a role-only
11
+ * authorization policy for {{RESOURCE_TYPE}} -- see the evidence below. <strong>Your application
12
+ * will not start until a {@code @Component} implementing this interface exists</strong> ({@link
13
+ * {{RESOURCE_TYPE}}Resolver} declares it as a required constructor argument, so Spring cannot
14
+ * construct that bean -- and therefore cannot map the {{RESOURCE_TYPE}} handle routes at all --
15
+ * without one). Return {@code false} to deny; a genuinely-public resource's escape hatch is
16
+ * {@code return true;} -- explicit, greppable, and reviewable, unlike a silently-materialized role
17
+ * check that happens to be wrong.
18
+ *
19
+ {{POLICY_EVIDENCE_JAVADOC}} */
20
+ public interface {{RESOURCE_TYPE}}AuthorizationPolicy {
21
+
22
+ /**
23
+ * @param action exactly one of {@code "fetch"}, {@code "patch"}, {@code "recover"}
24
+ * @param resourceUid the resource's own UUID, decoded from the handle
25
+ * @param pointer non-null only for a field-level (kind=f) handle -- see {@link
26
+ * {{RESOURCE_TYPE}}Resolver}'s sibling {@code ResourceResolver#fetch} javadoc
27
+ * @return {@code false} to deny (a 403 is raised); {@code true} to allow
28
+ */
29
+ boolean authorize(Authentication authentication, String action, UUID resourceUid, String pointer);
30
+ }
@@ -60,7 +60,7 @@ public class HandleController {
60
60
  HandleCodec.Decoded decoded = decodeOrThrow(handle);
61
61
  ResourceResolver resolver = resolverFor(decoded.type());
62
62
  Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
63
- requireAuthority(authentication, resolver.requiredAuthority());
63
+ authorizeOrThrow(resolver, authentication, "fetch", resolver.requiredAuthority(), decoded.uuid(), decoded.pointer());
64
64
  if (ENFORCE_REGISTRY) {
65
65
  requireRegisteredOrThrow(decoded);
66
66
  }
@@ -101,7 +101,7 @@ public class HandleController {
101
101
  // O5 (D-resolver-authorization-action-aware): the PATCH-specific authority, independently
102
102
  // derived from the entity's own UPDATE endpoint -- NOT resolver.requiredAuthority(), which
103
103
  // is fetch()/recover()'s own value and may legitimately require a different role.
104
- requireAuthority(authentication, resolver.requiredAuthorityForPatch());
104
+ authorizeOrThrow(resolver, authentication, "patch", resolver.requiredAuthorityForPatch(), decoded.uuid(), decoded.pointer());
105
105
  if (ENFORCE_REGISTRY) {
106
106
  requireRegisteredOrThrow(decoded);
107
107
  }
@@ -114,7 +114,8 @@ public class HandleController {
114
114
  public ResponseEntity<?> recover(@PathVariable String handle, @RequestParam(required = false) Instant at) {
115
115
  HandleCodec.Decoded decoded = decodeOrThrow(handle);
116
116
  ResourceResolver resolver = resolverFor(decoded.type());
117
- requireAuthority(SecurityContextHolder.getContext().getAuthentication(), resolver.requiredAuthority());
117
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
118
+ authorizeOrThrow(resolver, authentication, "recover", resolver.requiredAuthority(), decoded.uuid(), decoded.pointer());
118
119
 
119
120
  // D-security-9 / O3 (D-handle-registry-enforcement): recover() structurally REQUIRES a
120
121
  // registry row to find a snapshot's own primary key -- there is no "unenforced" mode for
@@ -218,4 +219,21 @@ public class HandleController {
218
219
  throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires authority " + requiredAuthority);
219
220
  }
220
221
  }
222
+
223
+ // D-resolver-policy-contract (PC4): the single call site fetch/patch/recover all route through.
224
+ // "role" mode (the default -- resolver.authorizationMode() unoverridden) keeps the historical
225
+ // requireAuthority() equality check, then still calls resolver.authorize() (a no-op default
226
+ // returning true). "delegated" mode SKIPS requireAuthority() entirely and resolver.authorize()
227
+ // -- backed by an injected AuthorizationPolicy bean for an unresolved resource, see
228
+ // ResourceResolverStub.java.tmpl -- is the whole gate. `action` is exactly "fetch"/"patch"/
229
+ // "recover"; `resourceUid`/`pointer` let a delegated policy make a field-level distinction
230
+ // fetch()/patch() themselves already make.
231
+ private void authorizeOrThrow(ResourceResolver resolver, Authentication authentication, String action, String requiredAuthority, UUID resourceUid, String pointer) {
232
+ if (!"delegated".equals(resolver.authorizationMode())) {
233
+ requireAuthority(authentication, requiredAuthority);
234
+ }
235
+ if (!resolver.authorize(authentication, action, resourceUid, pointer)) {
236
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "authorization policy denied " + action + " on " + resolver.type());
237
+ }
238
+ }
221
239
  }
@@ -90,4 +90,30 @@ public interface ResourceResolver {
90
90
 
91
91
  /** O4 (D-handle-lifecycle): the feature_uid this resolver was generated for, baked in the same way as {@link #contractRef()}. */
92
92
  UUID featureUid();
93
+
94
+ /**
95
+ * D-resolver-policy-contract (PC4): {@code "role"} (the default) means {@link HandleController}
96
+ * performs its historical {@code requireAuthority()}/{@code requiredAuthorityForPatch()}
97
+ * equality check before calling {@link #authorize}. {@code "delegated"} means it SKIPS that
98
+ * check entirely and {@link #authorize} is the whole gate. A {@code default} method (not
99
+ * abstract) so every resolver implemented before this existed keeps compiling and behaving
100
+ * identically -- overriding it is opt-in, never required.
101
+ */
102
+ default String authorizationMode() {
103
+ return "role";
104
+ }
105
+
106
+ /**
107
+ * D-resolver-policy-contract (PC4): a policy hook alongside the role check above.
108
+ * {@code action} is exactly one of {@code "fetch"}, {@code "patch"}, {@code "recover"}. Return
109
+ * {@code false} to deny (a 403 is raised); the {@code default} body returns {@code true} --
110
+ * the historical no-op, since in {@code "role"} mode {@link #requiredAuthority()}/
111
+ * {@link #requiredAuthorityForPatch()} already did the real check. A hand-written resolver
112
+ * that needs ownership/tenant scoping beyond a single role string overrides this (and usually
113
+ * also {@link #authorizationMode()} to return {@code "delegated"}), using {@code authentication}
114
+ * the same way {@link #fetch}'s own javadoc already describes.
115
+ */
116
+ default boolean authorize(Authentication authentication, String action, UUID resourceUid, String pointer) {
117
+ return true;
118
+ }
93
119
  }
@@ -51,4 +51,13 @@ final class {{RESOURCE_TYPE}}ResolverPolicy {
51
51
  static UUID featureUid() {
52
52
  return FEATURE_UID;
53
53
  }
54
+
55
+ // D-resolver-policy-contract (PC2): always regenerated (this file is always safe to
56
+ // regenerate), unconditionally -- "role" for a materialized resource, "delegated" for an
57
+ // unresolved one. {{RESOURCE_TYPE}}Resolver only overrides ResourceResolver#authorizationMode()
58
+ // to delegate here when delegated; a materialized resolver relies on the interface's own
59
+ // default ("role"), so this value is inspectable even though nothing calls it in that case.
60
+ static String authorizationMode() {
61
+ return "{{AUTHORIZATION_MODE}}";
62
+ }
54
63
  }
@@ -2,7 +2,7 @@ package {{BASE_PACKAGE}}.domain.{{MODULE}}.infrastructure;
2
2
 
3
3
  import {{BASE_PACKAGE}}.global.handle.ResourceResolver;
4
4
  import {{SERVICE_IMPORT}};
5
- {{PATCH_IMPORTS}}import lombok.RequiredArgsConstructor;
5
+ {{PATCH_IMPORTS}}{{POLICY_IMPORT}}import lombok.RequiredArgsConstructor;
6
6
  import org.springframework.security.core.Authentication;
7
7
  import org.springframework.stereotype.Component;
8
8
 
@@ -38,7 +38,7 @@ import java.util.UUID;
38
38
  public class {{RESOURCE_TYPE}}Resolver implements ResourceResolver {
39
39
 
40
40
  private final {{SERVICE_TYPE}} {{SERVICE_FIELD}};
41
- {{PATCH_FIELDS}}
41
+ {{PATCH_FIELDS}}{{POLICY_FIELD}}
42
42
  // D-resolver-policy-split: these five delegate to {{RESOURCE_TYPE}}ResolverPolicy (a separate
43
43
  // generated file) rather than implementing the values directly -- see that class's own
44
44
  // javadoc for why. Their TEXT never changes when the underlying value changes, which is what
@@ -83,4 +83,4 @@ public class {{RESOURCE_TYPE}}Resolver implements ResourceResolver {
83
83
  public UUID featureUid() {
84
84
  return {{RESOURCE_TYPE}}ResolverPolicy.featureUid();
85
85
  }
86
- }
86
+ {{POLICY_OVERRIDES}}}