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
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { findMappingAnnotations, findMethodParams, maskNonCode, skipAnnotationsAndWhitespace } from '../../../scanners/adapters/_java-spring-analyzer.mjs';
4
+ import { findMappingAnnotations, findMethodParams, maskNonCode, matchBalanced } from '../../../scanners/adapters/_java-spring-analyzer.mjs';
5
+ import { lineNumberAt } from '../../../scanners/text-util.mjs';
5
6
  import { classifyDtoFields, splitTopLevelParams, extractTypeAndName } from './patch-strategy.mjs';
6
7
 
7
8
  // The "canonical fetch" for an entity: a GET endpoint whose path is exactly
@@ -25,7 +26,13 @@ function findFetchOperation(controllers, entityClassName) {
25
26
  if (ep.verb !== 'GET' || !ep.operationId) continue;
26
27
  const suffix = ep.path.slice(controller.basePath.length);
27
28
  if (/^\/\{[^/]+\}$/.test(suffix)) {
28
- return { operationId: ep.operationId, method: ep.method, path: ep.path, controllerFile: controller.file, controllerClassName: controller.className };
29
+ // X5 (D-route-expansion-provenance): threaded through so the caller can name the real
30
+ // cause when ep.method is null instead of a bare "method not found" message -- a 1:N
31
+ // framework-synthesized route (e.g. Spring Data REST) has a real operationId but no
32
+ // literal per-action method to correlate to. null on every endpoint in today's adapter
33
+ // (it never populates declarationIndex) -- forward-compatible only, not yet reachable.
34
+ const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
35
+ return { operationId: ep.operationId, method: ep.method, path: ep.path, controllerFile: controller.file, controllerClassName: controller.className, declaration };
29
36
  }
30
37
  }
31
38
  }
@@ -120,21 +127,62 @@ function planPatchable({ javaSrcRoot, module: moduleName, controllers, entityCla
120
127
  return { patchable: classified.fields, updateOperation, updateDtoFile, dtoTypeName, notes };
121
128
  }
122
129
 
123
- // A2 Phase 1 (D-java-analyzer): this used to duplicate scanners/adapters/java-spring.mjs's own
124
- // (then-brittle) mapping regex, kept separate only because THIS function needs each match's
125
- // source *position* (to locate the region immediately above one specific method), not just the
126
- // endpoint list plan.mjs already has -- the earlier comment here explicitly earmarked "a
127
- // different catalog item's territory" for whoever eventually fixed the regex itself. That's this
128
- // item: findMappingAnnotations() (shared with the scanner) now owns the actual matching, this
129
- // file only maps its richer records down to the {index, methodName} shape findRequiredAuthority()
130
- // below already consumes -- findRequiredAuthority()/extractPreAuthorize()/classBodyStart() are
131
- // completely unchanged, D-security-7's own region-carving logic untouched.
130
+ // D-resolver-policy-contract (PC1/PC2/PC3): replaces the old extractPreAuthorize()/
131
+ // findRequiredAuthority()/methodMappingBoundaries() with an explicit refusal ladder producing a
132
+ // full policy RECORD (not just a scalar authority string) -- see DECISIONS.md's
133
+ // D-resolver-policy-contract for the WHY: a @PreAuthorize("hasRole('USER')") sitting next to a
134
+ // @PostAuthorize("returnObject.ownerId == authentication.name") ownership check used to
135
+ // auto-materialize ROLE_USER and silently ignore the ownership check entirely -- a real IDOR bskel
136
+ // itself would introduce. The provably-safe set (hasRole('X') / hasAuthority('X') alone, nothing
137
+ // else in the region) is UNCHANGED from before (O5) -- this item only adds refusal rungs in FRONT
138
+ // of it, so every case that materialized correctly before still does (frozen, not widened).
139
+ //
140
+ // O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): hasRole('X') and
141
+ // hasAuthority('X') are NOT interchangeable at the Spring Security level -- hasRole('X') checks
142
+ // for the granted authority "ROLE_X" (an implicit prefix Spring itself applies), hasAuthority('X')
143
+ // checks for "X" verbatim. The returned `authority` string is the LITERAL granted-authority value
144
+ // the generated code must match, decided HERE (plan time), not left for the template to re-derive.
132
145
  const HAS_ROLE_RE = /@PreAuthorize\(\s*"hasRole\('([^']+)'\)"\s*\)/;
133
146
  const HAS_AUTHORITY_RE = /@PreAuthorize\(\s*"hasAuthority\('([^']+)'\)"\s*\)/;
134
- const PRE_AUTH_RE = /@PreAuthorize\(/;
135
147
 
136
- function methodMappingBoundaries(text) {
137
- return findMappingAnnotations(text).map((m) => ({ index: m.index, methodName: m.methodName }));
148
+ // D-resolver-policy-contract (PC3): companion annotations that can carry authorization logic
149
+ // @PreAuthorize's own simple-shape check can never see (ownership/SpEL/etc.) -- their mere
150
+ // PRESENCE in a method's or class's authorization region refuses auto-materialization outright,
151
+ // regardless of whether a perfectly-shaped @PreAuthorize ALSO sits in the same region. Verified
152
+ // live: none of these five appear anywhere in this repository today (grep, zero hits) -- so this
153
+ // rung changes nothing for any case this codebase currently exercises; it exists for target repos
154
+ // that DO use them.
155
+ const COMPANION_AUTHZ_RE = /@(PostAuthorize|Secured|RolesAllowed|PreFilter|PostFilter)\s*\(/g;
156
+ const PRE_AUTHORIZE_SCAN_RE = /@PreAuthorize\s*\(/g;
157
+ const EVIDENCE_TEXT_MAX = 200;
158
+
159
+ // D-resolver-policy-contract (PC2): the 8 evidence.kind values the ladder below can produce, in
160
+ // the exact order DECISIONS.md documents them. Exported so schemas/handles-plan.schema.json's own
161
+ // enum and test/handles-policy-contract.test.mjs can be asserted to never drift apart silently.
162
+ export const POLICY_EVIDENCE_KINDS = Object.freeze([
163
+ 'endpoint-absent',
164
+ 'endpoint-method-absent',
165
+ 'companion-annotation-present',
166
+ 'pre-authorize-ambiguous',
167
+ 'pre-authorize-unrecognized',
168
+ 'authorization-annotation-absent',
169
+ 'pre-authorize-has-role',
170
+ 'pre-authorize-has-authority',
171
+ ]);
172
+
173
+ // D-resolver-policy-contract (PC2 follow-up, found live): an unresolved record whose
174
+ // evidence.kind is 'endpoint-absent'/'endpoint-method-absent' means the underlying route
175
+ // STRUCTURALLY DOES NOT EXIST for this action -- e.g. a read-only resource with no PATCH/PUT
176
+ // endpoint at all. That is the SAME "nothing to protect" case this codebase has silently accepted
177
+ // via TODO_ROLE forever (planHandles()'s own `requiredAuthorityForPatch ?? 'TODO_ROLE'`, unnoted
178
+ // when no update endpoint exists) -- NOT the gap this item exists to close (an endpoint that DOES
179
+ // exist but couldn't be safely verified). Found live: without this exclusion, willGenerateResolver
180
+ // (which never depends on the UPDATE endpoint existing at all -- a read-only resource is a normal,
181
+ // common shape) would delegate/block emit for every read-only resource, a real regression this
182
+ // project's own real fixtures caught immediately. Exported so emit.mjs's unresolvedPolicies gate
183
+ // uses the exact same rule -- one place decides "does this unresolved record need a human."
184
+ export function policyRequiresResolution(p) {
185
+ return p.status === 'unresolved' && p.evidence.kind !== 'endpoint-absent' && p.evidence.kind !== 'endpoint-method-absent';
138
186
  }
139
187
 
140
188
  // Index just after the class body's opening brace -- the lower bound for a method-level search
@@ -147,56 +195,146 @@ function classBodyStart(text) {
147
195
  return m ? m.index + m[0].length : 0;
148
196
  }
149
197
 
150
- // Returns { authority, unsupported } for an @PreAuthorize search over one region of source text.
151
- // `unsupported: true` means an @PreAuthorize annotation IS present but isn't one of the two
152
- // simple shapes this regex-based scanner understands (hasAnyRole, hasAnyAuthority, SpEL, etc.) --
153
- // the caller must fail closed (TODO_ROLE) rather than silently treating it as "no authority
154
- // found" and falling back to a weaker/wrong source.
155
- //
156
- // O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): hasRole('X') and
157
- // hasAuthority('X') are NOT interchangeable at the Spring Security level -- hasRole('X') checks
158
- // for the granted authority "ROLE_X" (an implicit prefix Spring itself applies), hasAuthority('X')
159
- // checks for "X" verbatim. The returned `authority` string is the LITERAL granted-authority value
160
- // the generated code must match, decided HERE (plan time), not left for the template to re-derive
161
- // -- HandleController.java.tmpl's requireAuthority() does one plain equality check regardless of
162
- // which shape produced the value. This is the real discriminant this item's own EXIT note named:
163
- // widening the regex alone, without this prefix decision, would generate an incorrect check.
164
- function extractPreAuthorize(region) {
165
- if (!PRE_AUTH_RE.test(region)) return null;
166
- const hasRoleMatch = region.match(HAS_ROLE_RE);
167
- if (hasRoleMatch) return { authority: `ROLE_${hasRoleMatch[1]}`, unsupported: false };
168
- const hasAuthorityMatch = region.match(HAS_AUTHORITY_RE);
169
- if (hasAuthorityMatch) return { authority: hasAuthorityMatch[1], unsupported: false };
170
- return { authority: null, unsupported: true };
198
+ function normalizeEvidenceText(s) {
199
+ const collapsed = s.replace(/\s+/g, ' ').trim();
200
+ return collapsed.length > EVIDENCE_TEXT_MAX ? `${collapsed.slice(0, EVIDENCE_TEXT_MAX)}…` : collapsed;
201
+ }
202
+
203
+ // Finds every match of a global annotation-start regex (`re`, must end in a literal `\(` so its
204
+ // own match always ends exactly on the opening paren) whose `@` sits within [start, end) of
205
+ // `masked`, resolving each one's balanced `(...)` via matchBalanced() and slicing the ORIGINAL
206
+ // (unmasked) `text` for `.text` -- values are never read off masked/blanked content, matching
207
+ // this analyzer's own established convention.
208
+ function collectAnnotations(masked, text, re, start, end) {
209
+ const found = [];
210
+ re.lastIndex = start;
211
+ let m;
212
+ while ((m = re.exec(masked))) {
213
+ if (m.index >= end) break;
214
+ const openParen = m.index + m[0].length - 1;
215
+ const close = matchBalanced(masked, openParen, '(', ')');
216
+ if (close === -1) continue; // malformed -- skip, don't misattribute
217
+ found.push({ index: m.index, text: normalizeEvidenceText(text.slice(m.index, close + 1)) });
218
+ }
219
+ return found;
220
+ }
221
+
222
+ function evidenceRecord({ kind, scope, file, line, symbol, text }) {
223
+ return { kind, scope: scope ?? null, file: file ?? null, line: line ?? null, symbol: symbol ?? null, text: text ?? null };
224
+ }
225
+
226
+ function policyRecord({ action, mode, status, authority, evidence, reason }) {
227
+ return { action, mode, status, authority: authority ?? null, evidence, reason: reason ?? null };
228
+ }
229
+
230
+ // Runs the companion-annotation / @PreAuthorize-count / exact-shape rungs (D3's rungs 2-4) over
231
+ // one region ([start, end) of `masked`/`text`). Returns null (not a refusal, just "nothing here")
232
+ // when the region has neither a companion annotation nor any @PreAuthorize at all -- the caller
233
+ // tries the next scope (method -> class) before finally refusing with
234
+ // 'authorization-annotation-absent'.
235
+ function derivePolicyFromRegion({ masked, text, start, end, scope, action, controllerFile, controllerFileRel, symbol }) {
236
+ const companions = collectAnnotations(masked, text, COMPANION_AUTHZ_RE, start, end);
237
+ if (companions.length > 0) {
238
+ const line = lineNumberAt(text, companions[0].index);
239
+ const names = companions.map((c) => c.text).join(', ');
240
+ return policyRecord({
241
+ action, mode: 'delegated', status: 'unresolved',
242
+ evidence: evidenceRecord({ kind: 'companion-annotation-present', scope, file: controllerFileRel, line, symbol, text: companions.map((c) => c.text).join(' | ') }),
243
+ reason: `${controllerFileRel}:${line}: ${symbol} carries ${names} -- this scanner cannot safely verify what a companion authorization annotation enforces (ownership/tenant checks are common here), so authorization is NOT auto-materialized even though a @PreAuthorize may also be present. Implement the generated AuthorizationPolicy interface by hand, using the annotation text above as the specification.`,
244
+ });
245
+ }
246
+ const preAuths = collectAnnotations(masked, text, PRE_AUTHORIZE_SCAN_RE, start, end);
247
+ if (preAuths.length > 1) {
248
+ const line = lineNumberAt(text, preAuths[0].index);
249
+ return policyRecord({
250
+ action, mode: 'delegated', status: 'unresolved',
251
+ evidence: evidenceRecord({ kind: 'pre-authorize-ambiguous', scope, file: controllerFileRel, line, symbol, text: preAuths.map((p) => p.text).join(' | ') }),
252
+ reason: `${controllerFileRel}:${line}: ${symbol} carries ${preAuths.length} @PreAuthorize annotations in the same ${scope}-level region -- ambiguous which one governs ${action}. Write authorize() by hand.`,
253
+ });
254
+ }
255
+ if (preAuths.length === 1) {
256
+ const p = preAuths[0];
257
+ const line = lineNumberAt(text, p.index);
258
+ const hasRoleMatch = p.text.match(HAS_ROLE_RE);
259
+ if (hasRoleMatch) {
260
+ return policyRecord({
261
+ action, mode: 'role', status: 'materialized', authority: `ROLE_${hasRoleMatch[1]}`,
262
+ evidence: evidenceRecord({ kind: 'pre-authorize-has-role', scope, file: controllerFileRel, line, symbol, text: p.text }),
263
+ });
264
+ }
265
+ const hasAuthorityMatch = p.text.match(HAS_AUTHORITY_RE);
266
+ if (hasAuthorityMatch) {
267
+ return policyRecord({
268
+ action, mode: 'role', status: 'materialized', authority: hasAuthorityMatch[1],
269
+ evidence: evidenceRecord({ kind: 'pre-authorize-has-authority', scope, file: controllerFileRel, line, symbol, text: p.text }),
270
+ });
271
+ }
272
+ return policyRecord({
273
+ action, mode: 'delegated', status: 'unresolved',
274
+ evidence: evidenceRecord({ kind: 'pre-authorize-unrecognized', scope, file: controllerFileRel, line, symbol, text: p.text }),
275
+ reason: `${controllerFileRel}:${line}: ${symbol}'s @PreAuthorize (${p.text}) is not exactly hasRole('X') or hasAuthority('X') -- this scanner does not evaluate SpEL (hasAnyRole/hasAnyAuthority/compound expressions included). Write authorize() by hand.`,
276
+ });
277
+ }
278
+ return null;
171
279
  }
172
280
 
173
- // D-security-7: a controller with more than one method can require DIFFERENT roles per method --
174
- // the previous version always used the file's FIRST @PreAuthorize match, which for a controller
175
- // whose first-declared method happens to carry a weaker role than the actual fetch method being
176
- // planned would silently generate a resolver enforcing that weaker role instead. Found by the
177
- // Codex security review. Now searches method-level first: the region from the previous method's
178
- // mapping annotation (exclusive) up to this method's mapping annotation (exclusive) is exactly
179
- // the span that can only contain this method's own annotations, never the previous method's (its
180
- // own @PreAuthorize, if any, sits before that boundary). Only falls back to a genuine class-level
181
- // @PreAuthorize -- the region before the FIRST method mapping in the file -- when the method
182
- // level has nothing at all.
183
- function findRequiredAuthority(controllerFilePath, methodName) {
184
- if (!controllerFilePath || !methodName || !fs.existsSync(controllerFilePath)) {
185
- return { authority: null, unsupported: false };
281
+ // D-resolver-policy-contract (PC3): the refusal ladder's entry point. `operation` is whatever
282
+ // findFetchOperation()/findUpdateOperation() returned (or null). Modeled directly on
283
+ // scanners/adapters/java-spring.mjs's extractRepositoryResource() -- an ordered sequence of
284
+ // checks, each non-matching rung returning an explicit, reasoned refusal rather than a best-effort
285
+ // guess. The companion-annotation rung runs BEFORE any @PreAuthorize matching, on purpose: this is
286
+ // the rung that closes the @PostAuthorize-ownership-check gap named in DECISIONS.md's WHY.
287
+ function derivePolicy({ action, operation, repoRoot }) {
288
+ const rel = (p) => (repoRoot && p ? path.relative(repoRoot, p) : p);
289
+
290
+ if (!operation) {
291
+ return policyRecord({
292
+ action, mode: 'delegated', status: 'unresolved',
293
+ evidence: evidenceRecord({ kind: 'endpoint-absent' }),
294
+ reason: `no ${action === 'fetch' ? 'single-resource GET' : 'single-resource PATCH/PUT'} endpoint found for this action -- write authorize() by hand once one exists`,
295
+ });
296
+ }
297
+ if (!operation.method || !operation.controllerFile) {
298
+ const declNote = operation.declaration
299
+ ? ` -- it was expanded from ${operation.declaration.label ?? operation.declaration.rule}, a framework-synthesized route with no literal per-action source method to correlate to`
300
+ : ' -- no literal per-action source method exists to correlate to';
301
+ return policyRecord({
302
+ action, mode: 'delegated', status: 'unresolved',
303
+ evidence: evidenceRecord({ kind: 'endpoint-method-absent', file: rel(operation.controllerFile) }),
304
+ reason: `matched endpoint (${action} ${operation.path ?? ''})${declNote}. Write authorize() by hand.`,
305
+ });
306
+ }
307
+
308
+ const controllerFile = operation.controllerFile;
309
+ const controllerFileRel = rel(controllerFile);
310
+ const symbol = `${operation.controllerClassName}.${operation.method}`;
311
+ const text = fs.readFileSync(controllerFile, 'utf8');
312
+ const masked = maskNonCode(text);
313
+ const mappings = findMappingAnnotations(text);
314
+ const target = mappings.find((m) => m.methodName === operation.method);
315
+ if (!target) {
316
+ return policyRecord({
317
+ action, mode: 'delegated', status: 'unresolved',
318
+ evidence: evidenceRecord({ kind: 'endpoint-method-absent', file: controllerFileRel, symbol }),
319
+ reason: `${controllerFileRel}: could not re-locate ${symbol} via its own mapping annotation -- write authorize() by hand.`,
320
+ });
186
321
  }
187
- const text = fs.readFileSync(controllerFilePath, 'utf8');
188
- const boundaries = methodMappingBoundaries(text);
189
- const target = boundaries.find((b) => b.methodName === methodName);
190
- if (!target) return { authority: null, unsupported: false };
191
-
192
- const priorBoundaries = boundaries.filter((b) => b.index < target.index);
193
- const methodRegionStart = priorBoundaries.length > 0 ? priorBoundaries[priorBoundaries.length - 1].index : classBodyStart(text);
194
- const methodLevel = extractPreAuthorize(text.slice(methodRegionStart, target.index));
195
- if (methodLevel) return methodLevel;
196
-
197
- const classRegion = text.slice(0, boundaries[0].index);
198
- const classLevel = extractPreAuthorize(classRegion);
199
- return classLevel ?? { authority: null, unsupported: false };
322
+ const priorSameFile = mappings.filter((m) => m.index < target.index);
323
+ const methodStart = priorSameFile.length > 0 ? priorSameFile[priorSameFile.length - 1].signatureIndex : classBodyStart(text);
324
+ const methodEnd = target.signatureIndex;
325
+ const classRegionEnd = mappings[0].index;
326
+
327
+ const methodResult = derivePolicyFromRegion({ masked, text, start: methodStart, end: methodEnd, scope: 'method', action, controllerFile, controllerFileRel, symbol });
328
+ if (methodResult) return methodResult;
329
+
330
+ const classResult = derivePolicyFromRegion({ masked, text, start: 0, end: classRegionEnd, scope: 'class', action, controllerFile, controllerFileRel, symbol });
331
+ if (classResult) return classResult;
332
+
333
+ return policyRecord({
334
+ action, mode: 'delegated', status: 'unresolved',
335
+ evidence: evidenceRecord({ kind: 'authorization-annotation-absent', file: controllerFileRel, symbol }),
336
+ reason: `no @PreAuthorize (method- or class-level) found for ${symbol} -- authorization may be enforced elsewhere (service layer, a SecurityFilterChain) that this scanner cannot see. Write authorize() by hand.`,
337
+ });
200
338
  }
201
339
 
202
340
  // Heuristic (this codebase's convention, verified for Organization -> OrganizationService, not
@@ -251,7 +389,11 @@ function countServiceMethodParams(serviceFilePath, methodName) {
251
389
  return argsText === '' ? 0 : countTopLevelCommas(argsText) + 1;
252
390
  }
253
391
 
254
- export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter }) {
392
+ // D-resolver-policy-contract (PC2): `repoRoot` defaults to `javaSrcRoot` so existing direct
393
+ // callers (test/handles-plan.test.mjs among them) keep working unmodified -- derivePolicy()'s
394
+ // evidence.file is only genuinely repo-relative when the real repoRoot is threaded through by
395
+ // plan() below; a caller that omits it still gets a (less pretty, still correct) path.
396
+ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter, repoRoot = javaSrcRoot }) {
255
397
  const targetModule = moduleName
256
398
  ? scanReport.related_modules.find((m) => m.module === moduleName)
257
399
  : scanReport.related_modules[0];
@@ -282,8 +424,14 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
282
424
  }
283
425
 
284
426
  const fetchOp = findFetchOperation(targetModule.controllers, entity.className);
285
- const authorityResult = findRequiredAuthority(fetchOp?.controllerFile ?? null, fetchOp?.method ?? null);
286
- const requiredAuthority = authorityResult.authority;
427
+ // X5 (D-route-expansion-provenance): a real, already-fail-closed case, checked explicitly
428
+ // instead of relying on findRequiredAuthority()/countServiceMethodParams()'s own `!methodName`
429
+ // guards to silently swallow it -- those guards return safely (no crash) either way, but
430
+ // without this check the notes below would read literally "...found for X.null" / "could not
431
+ // find a null(...) method", which is confusing, not honest. See D-resolver-scope.
432
+ const fetchOpMissingMethod = Boolean(fetchOp && !fetchOp.method);
433
+ const fetchPolicy = derivePolicy({ action: 'fetch', operation: fetchOp, repoRoot });
434
+ const requiredAuthority = fetchPolicy.authority;
287
435
  const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
288
436
  const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
289
437
 
@@ -295,20 +443,26 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
295
443
  // requiredAuthorityForPatch is meaningful even when resolver codegen itself ends up
296
444
  // blocked, exactly like requiredAuthority already is unconditional above.
297
445
  const updateOpForAuthority = findUpdateOperation(targetModule.controllers, entity.className);
298
- const patchAuthorityResult = findRequiredAuthority(updateOpForAuthority?.controllerFile ?? null, updateOpForAuthority?.method ?? null);
299
- const requiredAuthorityForPatch = patchAuthorityResult.authority;
446
+ const patchPolicy = derivePolicy({ action: 'patch', operation: updateOpForAuthority, repoRoot });
447
+ const requiredAuthorityForPatch = patchPolicy.authority;
300
448
 
301
449
  if (!fetchOp) {
302
450
  notes.push(`${entity.className}: no single-resource GET endpoint found on a controller whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
303
- } else if (authorityResult.unsupported) {
304
- notes.push(`${entity.className}: @PreAuthorize found on ${fetchOp.controllerClassName}.${fetchOp.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands (e.g. hasAnyRole/SpEL) -- requiredAuthority() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
305
- } else if (!requiredAuthority) {
306
- notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${fetchOp.controllerClassName}.${fetchOp.method} -- requiredAuthority() defaults to "TODO_ROLE", fix before relying on it`);
451
+ } else if (fetchOpMissingMethod) {
452
+ const declaration = fetchOp.declaration;
453
+ const declNote = declaration
454
+ ? ` -- it was expanded from ${declaration.label ?? declaration.rule} at ${path.relative(javaSrcRoot, fetchOp.controllerFile)}:${declaration.line} (rule: ${declaration.rule}); the framework generates this handler at runtime, so no literal per-action source method exists to correlate to`
455
+ : ' -- no literal per-action source method exists to correlate to';
456
+ notes.push(`${entity.className}: the matched endpoint (GET ${fetchOp.path})${declNote}. Resolver NOT generated -- this is a structural boundary of static-scan-based handles codegen, not a bug. See D-resolver-scope.`);
457
+ } else if (fetchPolicy.reason) {
458
+ // D-resolver-policy-contract (PC2): the ladder's own reason IS the note now -- see
459
+ // derivePolicy()'s per-rung wording (companion-annotation-present, pre-authorize-
460
+ // ambiguous/unrecognized, authorization-annotation-absent all produce their own precise
461
+ // explanation, replacing the old two-case unsupported/absent note here).
462
+ notes.push(fetchPolicy.reason);
307
463
  }
308
- if (updateOpForAuthority && patchAuthorityResult.unsupported) {
309
- notes.push(`${entity.className}: @PreAuthorize found on ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands -- requiredAuthorityForPatch() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
310
- } else if (updateOpForAuthority && !requiredAuthorityForPatch) {
311
- notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
464
+ if (updateOpForAuthority && patchPolicy.reason) {
465
+ notes.push(patchPolicy.reason);
312
466
  }
313
467
  if (!service) {
314
468
  // pkIsNonUuid already explained the real reason above -- this note would be true but
@@ -316,12 +470,15 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
316
470
  if (!pkIsNonUuid) {
317
471
  notes.push(`${entity.className}: no ${entity.className}Service found under domain/${targetModule.module}/application/ or ${targetModule.module}/ -- resolver NOT generated for this entity (would produce a broken import). Emit it by hand once the right service is identified.`);
318
472
  }
319
- } else if (fetchOp && serviceParamCount !== 1) {
473
+ } else if (fetchOp && !fetchOpMissingMethod && serviceParamCount !== 1) {
320
474
  const reason = serviceParamCount === null
321
475
  ? `could not find a ${fetchOp.method}(...) method on ${service.serviceType} to confirm its argument count`
322
476
  : `${service.serviceType}.${fetchOp.method} takes ${serviceParamCount} argument(s), not the single resource UUID the generated resolver always passes`;
323
477
  notes.push(`${entity.className}: ${reason} -- resolver NOT generated (would either fail to compile or silently call the wrong overload and drop a required scoping argument, e.g. an organization/cohort id). Wire it by hand -- ResourceResolver#fetch/#patchField receive the request's Authentication (D-resolver-authentication-context) for exactly this case, e.g. deriving a tenant/org id the same way the resource's own controller already does.`);
324
478
  }
479
+ // X5: fetchOpMissingMethod already pushed its own single, clear note above -- suppressing
480
+ // this one avoids a second, confusing "could not find a null(...) method" note for the same
481
+ // root cause.
325
482
 
326
483
  // A3 (D-patch-strategy): only worth computing once fetch()/the resolver itself is actually
327
484
  // going to be generated -- an entity with no resolver has nowhere for patchField() codegen
@@ -376,6 +533,16 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
376
533
  // endpoint's own @PreAuthorize, not copied from requiredAuthority above -- see the
377
534
  // computation and its own notes earlier in this loop.
378
535
  requiredAuthorityForPatch: requiredAuthorityForPatch ?? 'TODO_ROLE',
536
+ // D-resolver-policy-contract (PC2): the source of truth -- requiredAuthority/
537
+ // requiredAuthorityForPatch above are its legacy scalar PROJECTION, kept for every
538
+ // existing consumer (test files, templates) to keep working unmodified. Exactly two
539
+ // records, action 'fetch' then 'patch' -- 'recover' has no record of its own (it is
540
+ // governed by the 'fetch' record; HandleController#recover uses requiredAuthority()).
541
+ policies: [fetchPolicy, patchPolicy],
542
+ // D-resolver-policy-contract (PC2): one resource = one resolver bean = one mode --
543
+ // if EITHER action is unresolved, the whole resolver is delegated (fail-closed by
544
+ // construction: a resource can't be "half-delegated").
545
+ authorizationMode: (policyRequiresResolution(fetchPolicy) || policyRequiresResolution(patchPolicy)) ? 'delegated' : 'role',
379
546
  service,
380
547
  willGenerateResolver: Boolean(fetchOp && service && serviceParamCount === 1),
381
548
  });
@@ -439,7 +606,7 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
439
606
  throw new Error('could not detect the base package (no *Application.java found under src/main/java) -- is this a Spring Boot project?');
440
607
  }
441
608
  const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
442
- const inner = planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter });
609
+ const inner = planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter, repoRoot });
443
610
  return {
444
611
  schema: 'sbf.handles-plan/1',
445
612
  provider: 'java-spring',
@@ -447,7 +614,7 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
447
614
  module: inner.module,
448
615
  resources: inner.resources.map((r) => ({
449
616
  ...r,
450
- readPath: (r.service && r.fetchOperation) ? `${r.service.serviceType}.${r.fetchOperation.method}()` : null,
617
+ readPath: (r.service && r.fetchOperation && r.fetchOperation.method) ? `${r.service.serviceType}.${r.fetchOperation.method}()` : null,
451
618
  })),
452
619
  notes: inner.notes,
453
620
  };