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.
- package/README.md +113 -8
- package/bin/bskel.mjs +331 -53
- package/contracts/emit.mjs +22 -6
- package/contracts/openapi.mjs +125 -18
- package/handles/providers/java-spring/ast-bridge.mjs +85 -1
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
- package/handles/providers/java-spring/emit.mjs +126 -6
- package/handles/providers/java-spring/plan.mjs +244 -77
- package/handles/providers/java-spring/source-splice.mjs +477 -0
- package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
- package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
- package/handles/providers/typescript-express/plan.mjs +15 -2
- package/lib/attest.mjs +59 -1
- package/lib/cli.mjs +79 -7
- package/lib/doctor.mjs +23 -0
- package/lib/exit-codes.mjs +17 -0
- package/lib/gate-definitions.mjs +65 -2
- package/lib/gate-export.mjs +199 -0
- package/lib/impact-export-graphify.mjs +145 -0
- package/lib/impact-graph.mjs +194 -0
- package/lib/impact-surface.mjs +158 -0
- package/lib/impact.mjs +286 -0
- package/lib/patch-kinds.mjs +24 -0
- package/lib/repo.mjs +46 -0
- package/lib/workflow.mjs +16 -0
- package/package.json +1 -1
- package/scanners/adapters/_java-spring-analyzer.mjs +55 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +530 -22
- package/schemas/handles-plan.schema.json +32 -0
- package/schemas/impact-baseline.schema.json +59 -0
- package/schemas/impact-graph.schema.json +53 -0
- package/schemas/impact-report.schema.json +86 -0
- package/schemas/impact-resolution.schema.json +33 -0
- package/schemas/java-source-splice.schema.json +84 -0
- package/schemas/patch-transaction.schema.json +87 -2
|
@@ -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
|
+
* <locator count N>
|
|
312
|
+
* <type_fqn>
|
|
313
|
+
* <member_kind>
|
|
314
|
+
* <member_name>
|
|
315
|
+
* <param count>
|
|
316
|
+
* <param type 1>
|
|
317
|
+
* ...
|
|
318
|
+
* <param type param-count>
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
}
|