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,21 +1,35 @@
1
1
  package com.backendskeleton.asthelper;
2
2
 
3
+ import com.github.javaparser.JavaParser;
3
4
  import com.github.javaparser.ParserConfiguration;
5
+ import com.github.javaparser.ParseResult;
6
+ import com.github.javaparser.Problem;
7
+ import com.github.javaparser.Range;
4
8
  import com.github.javaparser.StaticJavaParser;
5
9
  import com.github.javaparser.ast.CompilationUnit;
6
10
  import com.github.javaparser.ast.ImportDeclaration;
11
+ import com.github.javaparser.ast.body.FieldDeclaration;
12
+ import com.github.javaparser.ast.body.MethodDeclaration;
7
13
  import com.github.javaparser.ast.body.Parameter;
8
14
  import com.github.javaparser.ast.body.RecordDeclaration;
15
+ import com.github.javaparser.ast.body.TypeDeclaration;
16
+ import com.github.javaparser.ast.body.VariableDeclarator;
9
17
  import com.github.javaparser.ast.expr.AnnotationExpr;
18
+ import com.github.javaparser.ast.stmt.BlockStmt;
10
19
  import com.github.javaparser.ast.type.Type;
11
20
  import com.github.javaparser.symbolsolver.JavaSymbolSolver;
12
21
  import com.github.javaparser.symbolsolver.resolution.typesolvers.CombinedTypeSolver;
13
22
  import com.github.javaparser.symbolsolver.resolution.typesolvers.JavaParserTypeSolver;
14
23
  import com.github.javaparser.symbolsolver.resolution.typesolvers.ReflectionTypeSolver;
15
24
 
25
+ import java.io.BufferedReader;
26
+ import java.io.ByteArrayOutputStream;
16
27
  import java.io.File;
28
+ import java.io.InputStreamReader;
29
+ import java.nio.charset.StandardCharsets;
17
30
  import java.nio.file.Files;
18
31
  import java.nio.file.Path;
32
+ import java.util.ArrayList;
19
33
  import java.util.HashMap;
20
34
  import java.util.List;
21
35
  import java.util.Map;
@@ -50,6 +64,19 @@ public final class Main {
50
64
  }
51
65
 
52
66
  public static void main(String[] args) throws Exception {
67
+ // D-java-source-splice: two new modes, dispatched on a literal args[0] match -- "locate" and
68
+ // "parse" can never collide with a real DTO file path (relative or absolute paths always
69
+ // contain a path separator or a file extension), so the pre-existing 2-positional-arg
70
+ // classify invocation below is completely unaffected and untouched.
71
+ if (args.length >= 1 && "locate".equals(args[0])) {
72
+ runLocate(args);
73
+ return;
74
+ }
75
+ if (args.length >= 2 && "parse".equals(args[0]) && "-".equals(args[1])) {
76
+ runParse();
77
+ return;
78
+ }
79
+
53
80
  if (args.length < 2) {
54
81
  System.err.println("usage: Main <dto-file-path> <src-root>");
55
82
  System.exit(2);
@@ -175,4 +202,384 @@ public final class Main {
175
202
  sb.append('"');
176
203
  return sb.toString();
177
204
  }
205
+
206
+ // ---------------------------------------------------------------------------------------
207
+ // D-java-source-splice: "locate" mode -- the authoritative node locator for the new
208
+ // java-source-splice patch kind. Resolves a method/field member inside a SINGLE top-level
209
+ // type by its erased parameter-type signature (a real javac uniqueness rule, not a
210
+ // heuristic -- two methods in one type can never share both a name and the same erased
211
+ // parameter types). Reports every top-level type in the file so the Node-side caller can
212
+ // refuse when the file declares more than one (V1 scope restriction, enforced on the Node
213
+ // side, not here). Every field on a locate result is line/column, 1-based, INCLUSIVE on
214
+ // both ends (JavaParser's own Range convention) -- the Node side converts to a byte offset
215
+ // itself and self-validates by slicing the real file text and asserting it equals
216
+ // regionText/signatureText exactly, so an offset-conversion bug here fails closed rather
217
+ // than silently corrupting a splice.
218
+ // ---------------------------------------------------------------------------------------
219
+
220
+ private static void runLocate(String[] args) throws Exception {
221
+ if (args.length < 4) {
222
+ System.err.println("usage: Main locate <java-file> <src-root> <locators-file>");
223
+ System.exit(2);
224
+ }
225
+ String javaFile = args[1];
226
+ String srcRoot = args[2];
227
+ String locatorsFile = args[3];
228
+
229
+ List<Locator> locators = readLocators(locatorsFile);
230
+
231
+ CombinedTypeSolver typeSolver = new CombinedTypeSolver();
232
+ typeSolver.add(new ReflectionTypeSolver());
233
+ typeSolver.add(new JavaParserTypeSolver(new File(srcRoot)));
234
+ StaticJavaParser.getParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_17);
235
+ StaticJavaParser.getParserConfiguration().setSymbolResolver(new JavaSymbolSolver(typeSolver));
236
+
237
+ // Read the RAW file text separately from parsing it -- confirmed live, not assumed: JavaParser
238
+ // Node#toString() invokes its own PrettyPrinter (default 4-space reformatting) rather than
239
+ // returning literal source bytes. A first draft used body.toString() for regionText/
240
+ // signatureText and it silently NORMALIZED the real file's tab indentation to spaces --
241
+ // caught immediately by the Node-side self-validating offset check (D5, mechanism 2) refusing
242
+ // with a "does not match" error, exactly the failure mode that check exists to catch. Every
243
+ // regionText/signatureText below is now a real substring of `rawText`, sliced by Range, never
244
+ // a re-printed node.
245
+ String rawText = Files.readString(Path.of(javaFile), StandardCharsets.UTF_8);
246
+
247
+ CompilationUnit cu = StaticJavaParser.parse(Path.of(javaFile));
248
+
249
+ List<String> topLevelTypeNames = new ArrayList<>();
250
+ for (TypeDeclaration<?> t : cu.getTypes()) {
251
+ topLevelTypeNames.add(fqnOf(t));
252
+ }
253
+
254
+ StringBuilder json = new StringBuilder();
255
+ json.append("{\"topLevelTypes\":[");
256
+ for (int i = 0; i < topLevelTypeNames.size(); i++) {
257
+ if (i > 0) json.append(',');
258
+ json.append(jsonString(topLevelTypeNames.get(i)));
259
+ }
260
+ json.append("],\"results\":[");
261
+ for (int i = 0; i < locators.size(); i++) {
262
+ if (i > 0) json.append(',');
263
+ appendLocateResult(json, cu, locators.get(i), rawText);
264
+ }
265
+ json.append("]}");
266
+ System.out.println(json);
267
+ }
268
+
269
+ /**
270
+ * 1-based line, 1-based column (JavaParser's own Range convention, both ends INCLUSIVE) -> a
271
+ * real substring of `text`. The Node-side `lineColToOffset()` is the same algorithm,
272
+ * independently implemented -- this is the Java-side half of D5's mechanism 2 (the
273
+ * self-validating offset conversion), not a shared implementation, so a bug in one cannot
274
+ * silently agree with a matching bug in the other.
275
+ */
276
+ private static String sliceByRange(String text, int beginLine, int beginColumn, int endLine, int endColumn) {
277
+ int start = lineColToIndex(text, beginLine, beginColumn);
278
+ int endInclusive = lineColToIndex(text, endLine, endColumn);
279
+ return text.substring(start, endInclusive + 1);
280
+ }
281
+
282
+ private static int lineColToIndex(String text, int line, int column) {
283
+ int idx = 0;
284
+ int currentLine = 1;
285
+ while (currentLine < line) {
286
+ int nl = text.indexOf('\n', idx);
287
+ if (nl < 0) throw new IllegalStateException("line " + line + " exceeds the file's actual line count");
288
+ idx = nl + 1;
289
+ currentLine++;
290
+ }
291
+ return idx + (column - 1);
292
+ }
293
+
294
+ private static String fqnOf(TypeDeclaration<?> t) {
295
+ return t.getFullyQualifiedName().orElse(t.getNameAsString());
296
+ }
297
+
298
+ /** One locator request read from the locators file -- see readLocators() for the on-disk shape. */
299
+ private static final class Locator {
300
+ String typeFqn;
301
+ String memberKind; // "method" | "field"
302
+ String memberName;
303
+ List<String> paramTypes; // method only; empty for field
304
+ }
305
+
306
+ /**
307
+ * Plain, delimiter-free line format (deliberately not JSON -- this helper has no JSON parser
308
+ * dependency, and this format needs none, since every field is a single line with no
309
+ * embedded newlines possible in a Java identifier/FQN):
310
+ * <pre>
311
+ * &lt;locator count N&gt;
312
+ * &lt;type_fqn&gt;
313
+ * &lt;member_kind&gt;
314
+ * &lt;member_name&gt;
315
+ * &lt;param count&gt;
316
+ * &lt;param type 1&gt;
317
+ * ...
318
+ * &lt;param type param-count&gt;
319
+ * (repeated N times)
320
+ * </pre>
321
+ */
322
+ private static List<Locator> readLocators(String locatorsFile) throws Exception {
323
+ List<String> lines = Files.readAllLines(Path.of(locatorsFile), StandardCharsets.UTF_8);
324
+ List<Locator> out = new ArrayList<>();
325
+ int i = 0;
326
+ int count = Integer.parseInt(lines.get(i++).trim());
327
+ for (int n = 0; n < count; n++) {
328
+ Locator loc = new Locator();
329
+ loc.typeFqn = lines.get(i++);
330
+ loc.memberKind = lines.get(i++);
331
+ loc.memberName = lines.get(i++);
332
+ int paramCount = Integer.parseInt(lines.get(i++).trim());
333
+ loc.paramTypes = new ArrayList<>();
334
+ for (int p = 0; p < paramCount; p++) loc.paramTypes.add(lines.get(i++));
335
+ out.add(loc);
336
+ }
337
+ return out;
338
+ }
339
+
340
+ /** Strips generic type arguments for an erasure-level comparison, e.g. "java.util.List<java.lang.String>" -> "java.util.List". Handles one level of nesting, sufficient for this kind's V1 scope (no nested-generic locator inputs are attempted). */
341
+ private static String eraseGenerics(String describedType) {
342
+ int lt = describedType.indexOf('<');
343
+ return lt < 0 ? describedType : describedType.substring(0, lt);
344
+ }
345
+
346
+ private static void appendLocateResult(StringBuilder json, CompilationUnit cu, Locator loc, String rawText) {
347
+ TypeDeclaration<?> target = null;
348
+ for (TypeDeclaration<?> t : cu.getTypes()) {
349
+ if (fqnOf(t).equals(loc.typeFqn)) {
350
+ target = t;
351
+ break;
352
+ }
353
+ }
354
+ if (target == null) {
355
+ appendUnresolved(json, "no top-level type named \"" + loc.typeFqn + "\" in this file");
356
+ return;
357
+ }
358
+
359
+ if ("field".equals(loc.memberKind)) {
360
+ appendFieldLocateResult(json, target, loc, rawText);
361
+ return;
362
+ }
363
+ appendMethodLocateResult(json, target, loc, rawText);
364
+ }
365
+
366
+ private static void appendMethodLocateResult(StringBuilder json, TypeDeclaration<?> target, Locator loc, String rawText) {
367
+ List<MethodDeclaration> candidates = new ArrayList<>();
368
+ for (Object member : target.getMembers()) {
369
+ if (member instanceof MethodDeclaration m && m.getNameAsString().equals(loc.memberName)) {
370
+ candidates.add(m);
371
+ }
372
+ }
373
+ if (candidates.isEmpty()) {
374
+ appendUnresolved(json, "no method named \"" + loc.memberName + "\" in \"" + loc.typeFqn + "\"");
375
+ return;
376
+ }
377
+
378
+ MethodDeclaration matched = null;
379
+ List<String> matchedErased = null;
380
+ List<String> matchedUnresolved = null;
381
+ int matchCount = 0;
382
+ for (MethodDeclaration m : candidates) {
383
+ List<Parameter> params = m.getParameters();
384
+ if (params.size() != loc.paramTypes.size()) continue;
385
+ List<String> erased = new ArrayList<>();
386
+ List<String> unresolved = new ArrayList<>();
387
+ boolean allResolved = true;
388
+ for (Parameter p : params) {
389
+ try {
390
+ erased.add(eraseGenerics(p.getType().resolve().describe()));
391
+ } catch (Exception e) {
392
+ erased.add(null);
393
+ unresolved.add(p.getNameAsString());
394
+ allResolved = false;
395
+ }
396
+ }
397
+ if (!allResolved) continue; // an unresolved param can never be asserted to MATCH a request
398
+ boolean paramsEqual = true;
399
+ for (int k = 0; k < erased.size(); k++) {
400
+ if (!erased.get(k).equals(loc.paramTypes.get(k))) {
401
+ paramsEqual = false;
402
+ break;
403
+ }
404
+ }
405
+ if (paramsEqual) {
406
+ matched = m;
407
+ matchedErased = erased;
408
+ matchedUnresolved = unresolved;
409
+ matchCount++;
410
+ }
411
+ }
412
+
413
+ if (matchCount == 0) {
414
+ // Report the nearest miss for diagnostics: if there's exactly one same-name candidate,
415
+ // show what its real (possibly unresolved) erased param types actually are.
416
+ if (candidates.size() == 1) {
417
+ MethodDeclaration only = candidates.get(0);
418
+ List<String> unresolved = new ArrayList<>();
419
+ List<String> erasedOrRaw = new ArrayList<>();
420
+ for (Parameter p : only.getParameters()) {
421
+ try {
422
+ erasedOrRaw.add(eraseGenerics(p.getType().resolve().describe()));
423
+ } catch (Exception e) {
424
+ erasedOrRaw.add(p.getType().asString());
425
+ unresolved.add(p.getNameAsString());
426
+ }
427
+ }
428
+ appendUnresolved(json, "\"" + loc.memberName + "\" was found but its parameter types do not match the given locator", erasedOrRaw, unresolved);
429
+ } else {
430
+ appendUnresolved(json, "\"" + loc.memberName + "\" has " + candidates.size() + " overload(s) but none match the given locator's parameter types");
431
+ }
432
+ return;
433
+ }
434
+ if (matchCount > 1) {
435
+ appendUnresolved(json, "\"" + loc.memberName + "\" locator matched " + matchCount + " overloads -- this should be impossible under javac's own erased-signature uniqueness rule; refusing rather than guessing");
436
+ return;
437
+ }
438
+
439
+ Optional<BlockStmt> bodyOpt = matched.getBody();
440
+ if (bodyOpt.isEmpty()) {
441
+ appendUnresolved(json, "\"" + loc.memberName + "\" has no body (abstract/interface method) -- nothing to splice");
442
+ return;
443
+ }
444
+ BlockStmt body = bodyOpt.get();
445
+ Range bodyRange = body.getRange().orElseThrow();
446
+ Range sigRange = signatureRange(matched.getRange().orElseThrow(), bodyRange);
447
+ String regionText = sliceByRange(rawText, bodyRange.begin.line, bodyRange.begin.column, bodyRange.end.line, bodyRange.end.column);
448
+ String signatureText = sliceByRange(rawText, sigRange.begin.line, sigRange.begin.column, sigRange.end.line, sigRange.end.column);
449
+
450
+ json.append("{\"resolved\":true")
451
+ .append(",\"beginLine\":").append(bodyRange.begin.line)
452
+ .append(",\"beginColumn\":").append(bodyRange.begin.column)
453
+ .append(",\"endLine\":").append(bodyRange.end.line)
454
+ .append(",\"endColumn\":").append(bodyRange.end.column)
455
+ .append(",\"regionText\":").append(jsonString(regionText))
456
+ .append(",\"signatureBeginLine\":").append(sigRange.begin.line)
457
+ .append(",\"signatureBeginColumn\":").append(sigRange.begin.column)
458
+ .append(",\"signatureEndLine\":").append(sigRange.end.line)
459
+ .append(",\"signatureEndColumn\":").append(sigRange.end.column)
460
+ .append(",\"signatureText\":").append(jsonString(signatureText))
461
+ .append(",\"erasedParamTypes\":[");
462
+ for (int k = 0; k < matchedErased.size(); k++) {
463
+ if (k > 0) json.append(',');
464
+ json.append(jsonString(matchedErased.get(k)));
465
+ }
466
+ json.append("],\"unresolvedParamTypes\":[");
467
+ for (int k = 0; k < matchedUnresolved.size(); k++) {
468
+ if (k > 0) json.append(',');
469
+ json.append(jsonString(matchedUnresolved.get(k)));
470
+ }
471
+ json.append("]}");
472
+ }
473
+
474
+ private static void appendFieldLocateResult(StringBuilder json, TypeDeclaration<?> target, Locator loc, String rawText) {
475
+ FieldDeclaration matched = null;
476
+ VariableDeclarator matchedVar = null;
477
+ int candidateFieldGroups = 0;
478
+ for (Object member : target.getMembers()) {
479
+ if (!(member instanceof FieldDeclaration f)) continue;
480
+ for (VariableDeclarator v : f.getVariables()) {
481
+ if (v.getNameAsString().equals(loc.memberName)) {
482
+ matched = f;
483
+ matchedVar = v;
484
+ candidateFieldGroups = f.getVariables().size();
485
+ }
486
+ }
487
+ }
488
+ if (matched == null) {
489
+ appendUnresolved(json, "no field named \"" + loc.memberName + "\" in \"" + loc.typeFqn + "\"");
490
+ return;
491
+ }
492
+ if (candidateFieldGroups != 1) {
493
+ appendUnresolved(json, "field \"" + loc.memberName + "\" is declared in a multi-variable declaration (e.g. \"int a, b;\") -- not supported, split it into its own declaration first");
494
+ return;
495
+ }
496
+ if (matchedVar.getInitializer().isEmpty()) {
497
+ appendUnresolved(json, "field \"" + loc.memberName + "\" has no initializer to replace");
498
+ return;
499
+ }
500
+
501
+ Range initRange = matchedVar.getInitializer().get().getRange().orElseThrow();
502
+ Range sigRange = signatureRange(matched.getRange().orElseThrow(), initRange);
503
+ String regionText = sliceByRange(rawText, initRange.begin.line, initRange.begin.column, initRange.end.line, initRange.end.column);
504
+ String signatureText = sliceByRange(rawText, sigRange.begin.line, sigRange.begin.column, sigRange.end.line, sigRange.end.column);
505
+
506
+ json.append("{\"resolved\":true")
507
+ .append(",\"beginLine\":").append(initRange.begin.line)
508
+ .append(",\"beginColumn\":").append(initRange.begin.column)
509
+ .append(",\"endLine\":").append(initRange.end.line)
510
+ .append(",\"endColumn\":").append(initRange.end.column)
511
+ .append(",\"regionText\":").append(jsonString(regionText))
512
+ .append(",\"signatureBeginLine\":").append(sigRange.begin.line)
513
+ .append(",\"signatureBeginColumn\":").append(sigRange.begin.column)
514
+ .append(",\"signatureEndLine\":").append(sigRange.end.line)
515
+ .append(",\"signatureEndColumn\":").append(sigRange.end.column)
516
+ .append(",\"signatureText\":").append(jsonString(signatureText))
517
+ .append(",\"erasedParamTypes\":[],\"unresolvedParamTypes\":[]}");
518
+ }
519
+
520
+ /**
521
+ * [declBegin, memberEnd) where memberEnd is the position just before the body/initializer
522
+ * starts -- i.e. everything from the first annotation/modifier through (and including) the
523
+ * bytes right up to the opening brace or "=". Position has no left(int) (only right(int)),
524
+ * so this shifts back one column directly; the degenerate column<=1 case (the body/
525
+ * initializer starting at column 1 of a line, which real Java source essentially never does)
526
+ * clamps to column 1 rather than going invalid -- harmless, since the Node-side
527
+ * self-validation assertion (slice === signatureText) is the actual safety net either way.
528
+ */
529
+ private static Range signatureRange(Range declRange, Range memberInnerRange) {
530
+ com.github.javaparser.Position b = memberInnerRange.begin;
531
+ com.github.javaparser.Position sigEnd = b.withColumn(Math.max(1, b.column - 1));
532
+ return new Range(declRange.begin, sigEnd);
533
+ }
534
+
535
+ private static void appendUnresolved(StringBuilder json, String error) {
536
+ appendUnresolved(json, error, List.of(), List.of());
537
+ }
538
+
539
+ private static void appendUnresolved(StringBuilder json, String error, List<String> erasedOrRaw, List<String> unresolved) {
540
+ json.append("{\"resolved\":false,\"error\":").append(jsonString(error))
541
+ .append(",\"erasedParamTypes\":[");
542
+ for (int k = 0; k < erasedOrRaw.size(); k++) {
543
+ if (k > 0) json.append(',');
544
+ json.append(jsonString(erasedOrRaw.get(k)));
545
+ }
546
+ json.append("],\"unresolvedParamTypes\":[");
547
+ for (int k = 0; k < unresolved.size(); k++) {
548
+ if (k > 0) json.append(',');
549
+ json.append(jsonString(unresolved.get(k)));
550
+ }
551
+ json.append("]}");
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------------------
555
+ // D-java-source-splice: "parse -" mode -- a plain syntax gate for a candidate rendered file,
556
+ // read from stdin (build.gradle's `run { standardInput = System.in }` already wires this),
557
+ // used at propose time on RENDERED content that has not been written to disk yet. Fresh,
558
+ // solver-free JavaParser instance -- a syntax check needs no classpath/src-root at all.
559
+ // ---------------------------------------------------------------------------------------
560
+
561
+ private static void runParse() throws Exception {
562
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
563
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
564
+ int c;
565
+ while ((c = reader.read()) != -1) buf.write(c);
566
+ }
567
+ String source = buf.toString(StandardCharsets.UTF_8);
568
+
569
+ JavaParser parser = new JavaParser(new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_17));
570
+ ParseResult<CompilationUnit> result = parser.parse(source);
571
+
572
+ if (result.isSuccessful()) {
573
+ System.out.println("{\"ok\":true}");
574
+ return;
575
+ }
576
+ StringBuilder json = new StringBuilder("{\"ok\":false,\"problems\":[");
577
+ List<Problem> problems = result.getProblems();
578
+ for (int i = 0; i < problems.size(); i++) {
579
+ if (i > 0) json.append(',');
580
+ json.append(jsonString(problems.get(i).getVerboseMessage()));
581
+ }
582
+ json.append("]}");
583
+ System.out.println(json);
584
+ }
178
585
  }
@@ -7,6 +7,7 @@ import { computeCodegenNeeds, renderPatchFieldBody } from './patch-strategy.mjs'
7
7
  import { sha256File } from '../../../lib/fsutil.mjs';
8
8
  import { specPath } from '../../../lib/paths.mjs';
9
9
  import { loadFeatureFile } from '../../../lib/featurelifecycle.mjs';
10
+ import { policyRequiresResolution } from './plan.mjs';
10
11
 
11
12
  const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
12
13
  const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
@@ -17,6 +18,10 @@ const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'ResourceResolverStub.java.tm
17
18
  // this separate, always-safe-to-regenerate companion file rather than the same file as the
18
19
  // hand-editable patchField() body -- see the template's own javadoc and DECISIONS.md.
19
20
  const RESOLVER_POLICY_TEMPLATE = path.join(TEMPLATES_DIR, 'ResourceResolverPolicyStub.java.tmpl');
21
+ // D-resolver-policy-contract (PC4): only ever emitted for a resource whose authorizationMode is
22
+ // 'delegated' -- a generated interface bskel deliberately never implements (see the template's
23
+ // own javadoc for the startup-failure mechanism this relies on).
24
+ const AUTHZ_POLICY_TEMPLATE = path.join(TEMPLATES_DIR, 'AuthorizationPolicyStub.java.tmpl');
20
25
 
21
26
  const INFRA_FILES = [
22
27
  { template: 'HandleCodec.java.tmpl', target: 'global/handle/HandleCodec.java' },
@@ -128,6 +133,56 @@ function buildPatchFields() {
128
133
  return '\tprivate final Validator validator;\n\tprivate final ObjectMapper objectMapper;\n';
129
134
  }
130
135
 
136
+ // D-resolver-policy-contract (PC4): the three ResourceResolverStub.java.tmpl token builders below
137
+ // all render '' for a materialized resource -- byte-identical output to before this item existed.
138
+ // Only a 'delegated' resource (see plan.mjs's authorizationMode) gets non-empty values.
139
+ function buildPolicyImport(basePackage, module, resourceType) {
140
+ return `import ${basePackage}.domain.${module}.infrastructure.${resourceType}AuthorizationPolicy;\n`;
141
+ }
142
+
143
+ function buildPolicyField(resourceType) {
144
+ return `\tprivate final ${resourceType}AuthorizationPolicy authorizationPolicy;\n`;
145
+ }
146
+
147
+ function buildPolicyOverrides(resourceType) {
148
+ return [
149
+ '',
150
+ '\t@Override',
151
+ '\tpublic String authorizationMode() {',
152
+ `\t\treturn ${resourceType}ResolverPolicy.authorizationMode();`,
153
+ '\t}',
154
+ '',
155
+ '\t@Override',
156
+ '\tpublic boolean authorize(Authentication authentication, String action, UUID resourceUid, String pointer) {',
157
+ '\t\treturn authorizationPolicy.authorize(authentication, action, resourceUid, pointer);',
158
+ '\t}',
159
+ '',
160
+ ].join('\n');
161
+ }
162
+
163
+ function escapeJavadoc(s) {
164
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
165
+ }
166
+
167
+ // The AuthorizationPolicyStub.java.tmpl's own javadoc body -- one block per policies[] record that
168
+ // actually NEEDS resolution (policyRequiresResolution() -- excludes the benign 'endpoint-absent'/
169
+ // 'endpoint-method-absent' case, e.g. a read-only resource's nonexistent PATCH endpoint, so the
170
+ // generated javadoc names only the real reason this file exists, never a distracting non-issue).
171
+ // Only ever called for a resource that already has at least one such record (see the 'delegated'
172
+ // gate below) -- the '' fallback exists for defensive completeness, not an expected path.
173
+ function buildPolicyEvidenceJavadoc(policies) {
174
+ const lines = [];
175
+ for (const p of policies) {
176
+ if (!policyRequiresResolution(p)) continue;
177
+ const loc = p.evidence.file ? `${p.evidence.file}${p.evidence.line ? `:${p.evidence.line}` : ''}` : '(no source location)';
178
+ lines.push(` * <p><b>${escapeJavadoc(p.action)}</b>: {@code ${escapeJavadoc(p.evidence.kind)}} at ${escapeJavadoc(loc)}`);
179
+ if (p.evidence.symbol) lines.push(` * ({@code ${escapeJavadoc(p.evidence.symbol)}})`);
180
+ if (p.evidence.text) lines.push(` * <br>source: {@code ${escapeJavadoc(p.evidence.text)}}`);
181
+ if (p.reason) lines.push(` * <br>${escapeJavadoc(p.reason)}`);
182
+ }
183
+ return lines.length > 0 ? `${lines.join('\n')}\n` : '';
184
+ }
185
+
131
186
  // The set of {resource, field} approvals whose recorded strategy still matches what the
132
187
  // classifier computes RIGHT NOW -- a stale approval (the DTO changed since approval) is excluded
133
188
  // here, not just skipped at codegen time, so callers never even have to reason about staleness
@@ -217,6 +272,11 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
217
272
  const { needsValidation, needsPatchFieldImport } = computeCodegenNeeds(patchable, approvedFields);
218
273
  const serviceField = lowerFirst(resource.service.serviceType);
219
274
  const sharedVars = { BASE_PACKAGE: basePackage, MODULE: plan.module, RESOURCE_TYPE: resource.type, FEATURE_ID: featureId };
275
+ // D-resolver-policy-contract (PC4): true only when plan.mjs's own refusal ladder
276
+ // (derivePolicy(), see plan.mjs) couldn't safely auto-derive a role-only check for
277
+ // fetch AND/OR patch -- one resource = one resolver bean = one mode, so either action
278
+ // being unresolved delegates the whole resolver (fail-closed by construction).
279
+ const delegated = resource.authorizationMode === 'delegated';
220
280
  // D-resolver-policy-split: the resolver's own vars no longer include the four
221
281
  // live-derived/security-relevant tokens -- those move to policyVars below, into a
222
282
  // separate generated file, specifically so a hand-edited patchField() (which stales
@@ -238,6 +298,11 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
238
298
  approvedFields,
239
299
  blockedReason: resource.updateServiceBlockedReason,
240
300
  }),
301
+ // D-resolver-policy-contract (PC4): all three render '' for a materialized
302
+ // resource -- byte-identical output to before this item existed.
303
+ POLICY_IMPORT: delegated ? buildPolicyImport(basePackage, plan.module, resource.type) : '',
304
+ POLICY_FIELD: delegated ? buildPolicyField(resource.type) : '',
305
+ POLICY_OVERRIDES: delegated ? buildPolicyOverrides(resource.type) : '',
241
306
  };
242
307
  const policyVars = {
243
308
  ...sharedVars,
@@ -247,8 +312,11 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
247
312
  REQUIRED_AUTHORITY_PATCH: resource.requiredAuthorityForPatch,
248
313
  CONTRACT_REF: contractRef,
249
314
  FEATURE_UID: featureUid,
315
+ // D-resolver-policy-contract (PC2): always regenerated, regardless of mode -- see
316
+ // ResourceResolverPolicyStub.java.tmpl's own comment for why this is safe.
317
+ AUTHORIZATION_MODE: resource.authorizationMode ?? 'role',
250
318
  };
251
- return [
319
+ const units = [
252
320
  {
253
321
  id: 'ResourceResolverStub.java.tmpl',
254
322
  resourceType: resource.type,
@@ -274,15 +342,32 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
274
342
  }),
275
343
  },
276
344
  ];
345
+ if (delegated) {
346
+ const authzVars = { ...sharedVars, POLICY_EVIDENCE_JAVADOC: buildPolicyEvidenceJavadoc(resource.policies ?? []) };
347
+ units.push({
348
+ id: 'AuthorizationPolicyStub.java.tmpl',
349
+ resourceType: resource.type,
350
+ module: plan.module,
351
+ templatePath: AUTHZ_POLICY_TEMPLATE,
352
+ targetAbs: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure', `${resource.type}AuthorizationPolicy.java`),
353
+ rendered: render(AUTHZ_POLICY_TEMPLATE, authzVars),
354
+ pristineRenderFor: (ownerId) => render(AUTHZ_POLICY_TEMPLATE, { ...authzVars, FEATURE_ID: ownerId }),
355
+ });
356
+ }
357
+ return units;
277
358
  });
278
359
 
279
360
  const orphanScan = (!resourceFilter && plan.module) ? {
280
361
  dir: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure'),
281
362
  module: plan.module,
282
- // D-resolver-policy-split: recognize both generated suffixes -- longest-first so
283
- // 'FooResolverPolicy.java' isn't misclassified as a plain '...Policy' resource type.
284
- matchesFile: (file) => file.endsWith('ResolverPolicy.java') || file.endsWith('Resolver.java'),
285
- resourceTypeOf: (file, _content) => file.replace(/ResolverPolicy\.java$|Resolver\.java$/, ''),
363
+ // D-resolver-policy-split / D-resolver-policy-contract (PC11): recognize all three
364
+ // generated suffixes -- longest-first so 'FooAuthorizationPolicy.java'/'FooResolverPolicy.java'
365
+ // are never misclassified as a plain '...Policy'/'...Resolver' resource type. A resource
366
+ // that transitions unresolved -> materialized leaves its AuthorizationPolicy.java as a
367
+ // reported orphan (never auto-deleted, same never-delete bias D-migration-scope/
368
+ // D-config-patch already take for generated files bskel stops needing).
369
+ matchesFile: (file) => file.endsWith('AuthorizationPolicy.java') || file.endsWith('ResolverPolicy.java') || file.endsWith('Resolver.java'),
370
+ resourceTypeOf: (file, _content) => file.replace(/AuthorizationPolicy\.java$|ResolverPolicy\.java$|Resolver\.java$/, ''),
286
371
  } : null;
287
372
 
288
373
  // D-write-safety-phase0 (item 1): migration.sql used to be regenerated fresh every run,
@@ -331,5 +416,40 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
331
416
  }
332
417
  }
333
418
 
334
- return { ...result, postEmitNotes, registrationGaps };
419
+ // D-resolver-policy-contract (PC9): unconditional -- unlike registrationGaps above, this is NOT
420
+ // gated on enforceRegistry (an orthogonal axis: registry enforcement is about revocation/
421
+ // lifecycle and produces 404s; this is about authorization and produces a 403 or, for an
422
+ // unresolved resource, a boot failure). Ships default-on: bin/bskel.mjs blocks the gate on this
423
+ // unless --force --reason acknowledges it (same acknowledgement mechanism as registrationGaps).
424
+ const unresolvedPolicies = [];
425
+ for (const resource of plan.resources) {
426
+ if (!resource.willGenerateResolver) continue;
427
+ for (const p of resource.policies ?? []) {
428
+ // D-resolver-policy-contract (PC2 follow-up): excludes 'endpoint-absent'/
429
+ // 'endpoint-method-absent' -- see plan.mjs's policyRequiresResolution() for why (a
430
+ // read-only resource with no PATCH endpoint at all is a normal, common shape with
431
+ // nothing to protect, not the gap this item exists to close).
432
+ if (!policyRequiresResolution(p)) continue;
433
+ unresolvedPolicies.push({
434
+ resourceType: resource.type,
435
+ action: p.action,
436
+ kind: p.evidence.kind,
437
+ file: p.evidence.file,
438
+ line: p.evidence.line,
439
+ note: p.reason,
440
+ });
441
+ }
442
+ }
443
+ if (unresolvedPolicies.length > 0) {
444
+ const byResource = new Map();
445
+ for (const u of unresolvedPolicies) {
446
+ if (!byResource.has(u.resourceType)) byResource.set(u.resourceType, []);
447
+ byResource.get(u.resourceType).push(u.action);
448
+ }
449
+ for (const [resourceType, actions] of byResource) {
450
+ postEmitNotes.push(`${resourceType}: authorization could not be safely auto-derived for ${actions.join('/')} -- ${resourceType}AuthorizationPolicy.java was generated but is NOT implemented by anything. Your application will not start until a @Component implementing it exists. See that file's own javadoc for the exact evidence and reason.`);
451
+ }
452
+ }
453
+
454
+ return { ...result, postEmitNotes, registrationGaps, unresolvedPolicies };
335
455
  }