backend-skeleton 1.4.0 → 1.5.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/contracts/emit.mjs +22 -6
- package/handles/providers/java-spring/plan.mjs +24 -3
- package/handles/providers/typescript-express/plan.mjs +15 -2
- package/package.json +1 -1
- package/scanners/adapters/_java-spring-analyzer.mjs +49 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
package/contracts/emit.mjs
CHANGED
|
@@ -15,12 +15,12 @@ import { pathPrefixCandidates, unreflectedPathPrefixes } from './export.mjs';
|
|
|
15
15
|
// a path param. Direction stays one-way (openapi.mjs imports from emit.mjs, never the reverse).
|
|
16
16
|
export const BARE_UUID_PATTERN = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$';
|
|
17
17
|
|
|
18
|
-
// A7/A8/A9/A10: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
|
|
18
|
+
// A7/A8/A9/A10/X2: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
|
|
19
19
|
// const -- bin/bskel.mjs's loadContract() imports this too, so the friendly "re-emit with the
|
|
20
|
-
// current bskel" message and the value actually written here cannot drift apart. Bumped "
|
|
21
|
-
// for this item (
|
|
22
|
-
//
|
|
23
|
-
export const CONTRACT_SCHEMA_VERSION = '
|
|
20
|
+
// current bskel" message and the value actually written here cannot drift apart. Bumped "8" -> "9"
|
|
21
|
+
// for this item (the `expansion` field, D-route-expansion-provenance) -- again cheap, the friendly
|
|
22
|
+
// re-emit pre-check needs zero code change.
|
|
23
|
+
export const CONTRACT_SCHEMA_VERSION = '9';
|
|
24
24
|
|
|
25
25
|
// A9 (D-openapi-path-params): `sourcePathParamSchemas` (a Map<name, schema>, contracts/openapi.mjs's
|
|
26
26
|
// applyPathParameterSchemas -- present only for a matched/adopted operation whose source document
|
|
@@ -66,7 +66,11 @@ function pathParamsSchema(routePath, sourcePathParamSchemas = null) {
|
|
|
66
66
|
// Object>>`) -- findMethodParams() shares the same balanced-delimiter analyzer that fixes the
|
|
67
67
|
// scanner's identical GenericWithSpaceController case.
|
|
68
68
|
function detectRequestBody(filePath, methodName) {
|
|
69
|
-
|
|
69
|
+
// X5 (D-route-expansion-provenance): explicit guard, not the incidental fact that a regex built
|
|
70
|
+
// from the literal string "null" also happens not to match anything -- a null methodName means
|
|
71
|
+
// there is genuinely no literal per-action source method to look in (see the same reasoning
|
|
72
|
+
// D-typescript-express-inline-handlers already established for resolveHandlerFile()).
|
|
73
|
+
if (!filePath || !methodName || !fs.existsSync(filePath)) return null;
|
|
70
74
|
const text = fs.readFileSync(filePath, 'utf8');
|
|
71
75
|
const params = findMethodParams(text, methodName);
|
|
72
76
|
if (params === null) return null;
|
|
@@ -377,6 +381,15 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
377
381
|
}));
|
|
378
382
|
}
|
|
379
383
|
const { pathParams, pathParamsHeuristic } = pathParamsSchema(route, pathParamSchemas);
|
|
384
|
+
// X2 (D-route-expansion-provenance): present only when the scan adapter recorded which
|
|
385
|
+
// multi-route declaration this endpoint came from (ep.declarationIndex) AND that
|
|
386
|
+
// declaration actually resolves on the owning controller -- omitted for every ordinary
|
|
387
|
+
// 1:1 endpoint, the same "absent unless it applies" discipline A9's pathParamsHeuristic
|
|
388
|
+
// already established. No adapter populates declarationIndex yet (forward-compatible
|
|
389
|
+
// shape only) -- see test/contract.test.mjs's expansion-field tests for a hand-built
|
|
390
|
+
// fixture exercising this.
|
|
391
|
+
const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
|
|
392
|
+
const expansion = declaration ? { rule: declaration.rule, declarationLine: declaration.line, label: declaration.label ?? null } : null;
|
|
380
393
|
operations[operationId] = {
|
|
381
394
|
verb,
|
|
382
395
|
path: route,
|
|
@@ -397,6 +410,9 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
397
410
|
...(sourceRequestBody ? { sourceRequestBody } : {}),
|
|
398
411
|
// A9: omitted (not []) when every segment resolved from source, or the route has none.
|
|
399
412
|
...(pathParamsHeuristic ? { pathParamsHeuristic } : {}),
|
|
413
|
+
// X2: omitted entirely when this endpoint wasn't expanded from a multi-route
|
|
414
|
+
// declaration -- see the computation above.
|
|
415
|
+
...(expansion ? { expansion } : {}),
|
|
400
416
|
// A10: omitted entirely when --descriptions was not passed, the source had none, or
|
|
401
417
|
// it failed the length cap -- same "omitted, never null/false" discipline as every
|
|
402
418
|
// other field above.
|
|
@@ -25,7 +25,13 @@ function findFetchOperation(controllers, entityClassName) {
|
|
|
25
25
|
if (ep.verb !== 'GET' || !ep.operationId) continue;
|
|
26
26
|
const suffix = ep.path.slice(controller.basePath.length);
|
|
27
27
|
if (/^\/\{[^/]+\}$/.test(suffix)) {
|
|
28
|
-
|
|
28
|
+
// X5 (D-route-expansion-provenance): threaded through so the caller can name the real
|
|
29
|
+
// cause when ep.method is null instead of a bare "method not found" message -- a 1:N
|
|
30
|
+
// framework-synthesized route (e.g. Spring Data REST) has a real operationId but no
|
|
31
|
+
// literal per-action method to correlate to. null on every endpoint in today's adapter
|
|
32
|
+
// (it never populates declarationIndex) -- forward-compatible only, not yet reachable.
|
|
33
|
+
const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
|
|
34
|
+
return { operationId: ep.operationId, method: ep.method, path: ep.path, controllerFile: controller.file, controllerClassName: controller.className, declaration };
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
37
|
}
|
|
@@ -282,6 +288,12 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
282
288
|
}
|
|
283
289
|
|
|
284
290
|
const fetchOp = findFetchOperation(targetModule.controllers, entity.className);
|
|
291
|
+
// X5 (D-route-expansion-provenance): a real, already-fail-closed case, checked explicitly
|
|
292
|
+
// instead of relying on findRequiredAuthority()/countServiceMethodParams()'s own `!methodName`
|
|
293
|
+
// guards to silently swallow it -- those guards return safely (no crash) either way, but
|
|
294
|
+
// without this check the notes below would read literally "...found for X.null" / "could not
|
|
295
|
+
// find a null(...) method", which is confusing, not honest. See D-resolver-scope.
|
|
296
|
+
const fetchOpMissingMethod = Boolean(fetchOp && !fetchOp.method);
|
|
285
297
|
const authorityResult = findRequiredAuthority(fetchOp?.controllerFile ?? null, fetchOp?.method ?? null);
|
|
286
298
|
const requiredAuthority = authorityResult.authority;
|
|
287
299
|
const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
|
|
@@ -300,6 +312,12 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
300
312
|
|
|
301
313
|
if (!fetchOp) {
|
|
302
314
|
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`);
|
|
315
|
+
} else if (fetchOpMissingMethod) {
|
|
316
|
+
const declaration = fetchOp.declaration;
|
|
317
|
+
const declNote = declaration
|
|
318
|
+
? ` -- 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`
|
|
319
|
+
: ' -- no literal per-action source method exists to correlate to';
|
|
320
|
+
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.`);
|
|
303
321
|
} else if (authorityResult.unsupported) {
|
|
304
322
|
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
323
|
} else if (!requiredAuthority) {
|
|
@@ -316,12 +334,15 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
316
334
|
if (!pkIsNonUuid) {
|
|
317
335
|
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
336
|
}
|
|
319
|
-
} else if (fetchOp && serviceParamCount !== 1) {
|
|
337
|
+
} else if (fetchOp && !fetchOpMissingMethod && serviceParamCount !== 1) {
|
|
320
338
|
const reason = serviceParamCount === null
|
|
321
339
|
? `could not find a ${fetchOp.method}(...) method on ${service.serviceType} to confirm its argument count`
|
|
322
340
|
: `${service.serviceType}.${fetchOp.method} takes ${serviceParamCount} argument(s), not the single resource UUID the generated resolver always passes`;
|
|
323
341
|
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
342
|
}
|
|
343
|
+
// X5: fetchOpMissingMethod already pushed its own single, clear note above -- suppressing
|
|
344
|
+
// this one avoids a second, confusing "could not find a null(...) method" note for the same
|
|
345
|
+
// root cause.
|
|
325
346
|
|
|
326
347
|
// A3 (D-patch-strategy): only worth computing once fetch()/the resolver itself is actually
|
|
327
348
|
// going to be generated -- an entity with no resolver has nowhere for patchField() codegen
|
|
@@ -447,7 +468,7 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
|
|
|
447
468
|
module: inner.module,
|
|
448
469
|
resources: inner.resources.map((r) => ({
|
|
449
470
|
...r,
|
|
450
|
-
readPath: (r.service && r.fetchOperation) ? `${r.service.serviceType}.${r.fetchOperation.method}()` : null,
|
|
471
|
+
readPath: (r.service && r.fetchOperation && r.fetchOperation.method) ? `${r.service.serviceType}.${r.fetchOperation.method}()` : null,
|
|
451
472
|
})),
|
|
452
473
|
notes: inner.notes,
|
|
453
474
|
};
|
|
@@ -57,7 +57,13 @@ function findFetchRoute(controllers, entityClassName) {
|
|
|
57
57
|
if (ep.verb !== 'GET') continue;
|
|
58
58
|
const suffix = ep.path.slice(controller.basePath.length);
|
|
59
59
|
if (/^\/:[^/(]+(\([^)]*\))?$/.test(suffix)) {
|
|
60
|
-
|
|
60
|
+
// X5 (D-route-expansion-provenance): threaded through so the caller can distinguish
|
|
61
|
+
// "inline arrow handler" (this provider's real, current no-method case) from "1:N
|
|
62
|
+
// framework-synthesized route" (a declaration is present) in its note text. null on
|
|
63
|
+
// every endpoint in today's adapter (it never populates declarationIndex) --
|
|
64
|
+
// forward-compatible only, not yet reachable.
|
|
65
|
+
const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
|
|
66
|
+
return { method: ep.method, path: ep.path, file: controller.file, line: ep.line, controllerClassName: controller.className, declaration };
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
69
|
}
|
|
@@ -183,7 +189,14 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
|
|
|
183
189
|
if (!fetchRoute) {
|
|
184
190
|
notes.push(`${entity.className}: no single-resource GET route found on a router whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
|
|
185
191
|
} else if (!fetchRoute.method) {
|
|
186
|
-
|
|
192
|
+
// X5 (D-route-expansion-provenance): a declaration means this route was expanded from a
|
|
193
|
+
// 1:N framework construct (no literal per-action method exists at all, not yet reachable
|
|
194
|
+
// in this adapter); no declaration keeps this provider's own real, current cause (an
|
|
195
|
+
// inline arrow-function handler) unchanged.
|
|
196
|
+
const note = fetchRoute.declaration
|
|
197
|
+
? `the matched endpoint (GET ${fetchRoute.path}) was expanded from ${fetchRoute.declaration.label ?? fetchRoute.declaration.rule} at ${path.relative(repoRoot, fetchRoute.file)}:${fetchRoute.declaration.line} (rule: ${fetchRoute.declaration.rule}) -- the framework generates this handler at runtime, so no literal per-action source method exists to correlate to. Resolver NOT generated -- this is a structural boundary of static-scan-based handles codegen, not a bug. See D-resolver-scope.`
|
|
198
|
+
: 'the single-resource GET route\'s handler is an inline function expression, not a named export -- nothing to correlate to a defining file, resolver NOT generated.';
|
|
199
|
+
notes.push(`${entity.className}: ${note}`);
|
|
187
200
|
} else if (!handlerFile) {
|
|
188
201
|
notes.push(`${entity.className}: could not resolve ${fetchRoute.method}'s own defining file (import, or one barrel hop, from ${path.relative(repoRoot, fetchRoute.file)}) -- resolver NOT generated.`);
|
|
189
202
|
} else if (!selectFields) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-skeleton",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -72,6 +72,55 @@ export function findClassOrRecordDeclaration(maskedText) {
|
|
|
72
72
|
return { keyword: m[1], name: m[2], index: m.index };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// D-spring-data-rest-adapter: interface-shaped counterpart to findClassOrRecordDeclaration()
|
|
76
|
+
// above -- CLASS_OR_RECORD_RE is hard-gated to `class`/`record`, so a Spring Data repository
|
|
77
|
+
// interface (`interface X extends JpaRepository<Widget, UUID>`) is never recognized by it. Only
|
|
78
|
+
// the single common shape "interface Name extends Super<A, B>" is matched -- a second
|
|
79
|
+
// extends-interface in a multi-interface list (e.g. `extends QuerydslPredicateExecutor<Widget>,
|
|
80
|
+
// JpaRepository<Widget, UUID>`) is not found; the caller is expected to validate `superName`
|
|
81
|
+
// against a known allow-list and fail closed rather than guess which listed interface is the real
|
|
82
|
+
// Spring Data supertype. Operates on masked text.
|
|
83
|
+
const INTERFACE_EXTENDS_RE = /(?:public\s+)?\binterface\s+(\w+)\s+extends\s+(\w+)/;
|
|
84
|
+
|
|
85
|
+
export function findInterfaceExtendsDeclaration(maskedText) {
|
|
86
|
+
const m = maskedText.match(INTERFACE_EXTENDS_RE);
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
const afterSuper = m.index + m[0].length;
|
|
89
|
+
let typeArgsStart = null;
|
|
90
|
+
let typeArgsEnd = null;
|
|
91
|
+
if (maskedText[afterSuper] === '<') {
|
|
92
|
+
const close = matchBalanced(maskedText, afterSuper, '<', '>');
|
|
93
|
+
if (close !== -1) {
|
|
94
|
+
typeArgsStart = afterSuper + 1;
|
|
95
|
+
typeArgsEnd = close;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { name: m[1], superName: m[2], index: m.index, typeArgsStart, typeArgsEnd };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// D-spring-data-rest-adapter: depth-tracked top-level comma split on '<'/'>' only -- same
|
|
102
|
+
// algorithm patch-strategy.mjs's own splitTopLevelParams() uses on '('/')' for a DTO constructor's
|
|
103
|
+
// argument list. Reimplemented here rather than imported: patch-strategy.mjs lives under
|
|
104
|
+
// handles/providers/java-spring/, a DOWNSTREAM consumer of this file -- importing from it here
|
|
105
|
+
// would invert the dependency direction this codebase's module layout otherwise keeps one-way.
|
|
106
|
+
export function splitTopLevelTypeArgs(argsText) {
|
|
107
|
+
const parts = [];
|
|
108
|
+
let depth = 0;
|
|
109
|
+
let start = 0;
|
|
110
|
+
for (let i = 0; i < argsText.length; i++) {
|
|
111
|
+
const ch = argsText[i];
|
|
112
|
+
if (ch === '<') depth++;
|
|
113
|
+
else if (ch === '>') depth--;
|
|
114
|
+
else if (ch === ',' && depth === 0) {
|
|
115
|
+
parts.push(argsText.slice(start, i).trim());
|
|
116
|
+
start = i + 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const last = argsText.slice(start).trim();
|
|
120
|
+
if (last !== '' || parts.length > 0) parts.push(last);
|
|
121
|
+
return parts.filter((p) => p !== '');
|
|
122
|
+
}
|
|
123
|
+
|
|
75
124
|
const WHITESPACE_RE = /^\s*/;
|
|
76
125
|
// A2 Phase 2 (D-java-ast-helper): `[\w.]+`, not `\w+` -- found live while building the real
|
|
77
126
|
// JavaParser/Symbol-Solver AST cross-check. A fully-qualified annotation
|
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { lineNumberAt, listRgFiles, byShallowestThenName, binaryAvailable } from '../text-util.mjs';
|
|
9
|
-
import { maskNonCode, findClassOrRecordDeclaration, findClassLevelMappingArgs, findMappingAnnotations } from './_java-spring-analyzer.mjs';
|
|
9
|
+
import { maskNonCode, findClassOrRecordDeclaration, findClassLevelMappingArgs, findMappingAnnotations, matchBalanced, findInterfaceExtendsDeclaration, splitTopLevelTypeArgs } from './_java-spring-analyzer.mjs';
|
|
10
10
|
|
|
11
11
|
const JAVA_BUILD_FILE_GLOBS = ['build.gradle', 'build.gradle.kts', 'pom.xml'];
|
|
12
12
|
|
|
@@ -36,6 +36,23 @@ export function detectJavaSpringRoot(repoRoot) {
|
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// D-spring-data-rest-adapter: literal substring grep, the same shape as
|
|
40
|
+
// handles/providers/java-spring/emit.mjs's hasSpringAopDependency() -- but here, at SCAN time, for
|
|
41
|
+
// a different reason: @Entity/@RestController pair with spring-boot-starter-data-jpa/-web,
|
|
42
|
+
// dependencies virtually every real Spring Boot app already has, so this adapter has never needed
|
|
43
|
+
// to cross-check a dependency before trusting an annotation. @RepositoryRestResource is unusually
|
|
44
|
+
// easy to add decoratively (copied from a tutorial) without the actual
|
|
45
|
+
// spring-boot-starter-data-rest starter, and without it ALL 6 synthesized routes would be
|
|
46
|
+
// fictional, not just one field -- severe enough to warrant a check no other annotation in this
|
|
47
|
+
// adapter needs. Same version-drift fragility hasSpringAopDependency's own history found for a
|
|
48
|
+
// different artifact name (D-handles-pilot-cohort) is inherited here, not re-solved.
|
|
49
|
+
export function hasSpringDataRestDependency(repoRoot) {
|
|
50
|
+
for (const buildFile of listRgFiles(repoRoot, JAVA_BUILD_FILE_GLOBS)) {
|
|
51
|
+
if (fs.readFileSync(buildFile, 'utf8').includes('spring-boot-starter-data-rest')) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
// O6: `rg --files` (no `--sort`) is explicitly unordered/parallel by ripgrep's own docs -- two
|
|
40
57
|
// runs against an unchanged repo can return files in a different order, which without this sort
|
|
41
58
|
// would propagate into non-deterministic controller/entity/module array order in every scan
|
|
@@ -165,6 +182,128 @@ function extractController(text, filePath) {
|
|
|
165
182
|
return { className, basePath, operationIds, endpoints, file: filePath, line: classLine };
|
|
166
183
|
}
|
|
167
184
|
|
|
185
|
+
const SPRING_DATA_REPOSITORY_SUPERTYPES = new Set(['JpaRepository', 'CrudRepository', 'PagingAndSortingRepository']);
|
|
186
|
+
|
|
187
|
+
// D-spring-data-rest-adapter: Spring Data REST auto-generates a full CRUD REST API from a
|
|
188
|
+
// repository interface annotated @RepositoryRestResource -- a real, common pattern extractController()
|
|
189
|
+
// above is blind to (it only ever recognizes @RestController classes, and interface declarations
|
|
190
|
+
// aren't recognized anywhere else in this file either). Mirrors extractController()'s shape/style,
|
|
191
|
+
// but gated on a completely different annotation and declaration keyword. Populates
|
|
192
|
+
// controller.declarations[]/endpoint.declarationIndex (see D-route-expansion-provenance) -- this
|
|
193
|
+
// is the first adapter to do so. Every skip below is a deliberate "don't guess" refusal, not a
|
|
194
|
+
// missing feature -- see this item's own DECISIONS.md entry for the full rationale per case.
|
|
195
|
+
function extractRepositoryResource(text, filePath, hasDataRestDependency) {
|
|
196
|
+
if (!/@RepositoryRestResource\b/.test(text)) return { controller: null, note: null };
|
|
197
|
+
|
|
198
|
+
const masked = maskNonCode(text);
|
|
199
|
+
const decl = findInterfaceExtendsDeclaration(masked);
|
|
200
|
+
if (!decl || !SPRING_DATA_REPOSITORY_SUPERTYPES.has(decl.superName)) {
|
|
201
|
+
return { controller: null, note: null };
|
|
202
|
+
}
|
|
203
|
+
const declLine = lineNumberAt(text, decl.index);
|
|
204
|
+
|
|
205
|
+
if (!hasDataRestDependency) {
|
|
206
|
+
return {
|
|
207
|
+
controller: null,
|
|
208
|
+
note: `${filePath}:${declLine}: @RepositoryRestResource found on ${decl.name}, but no spring-boot-starter-data-rest dependency was found in this repo's build.gradle/build.gradle.kts/pom.xml -- Spring Data REST would not actually be active, so no routes are synthesized for this repository.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (decl.typeArgsStart == null || decl.typeArgsEnd == null) {
|
|
213
|
+
return {
|
|
214
|
+
controller: null,
|
|
215
|
+
note: `${filePath}:${declLine}: @RepositoryRestResource found on ${decl.name}, but its "extends ${decl.superName}<...>" generic type arguments could not be parsed -- the entity type is unknown, so no routes are synthesized.`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const typeArgs = splitTopLevelTypeArgs(masked.slice(decl.typeArgsStart, decl.typeArgsEnd));
|
|
219
|
+
if (typeArgs.length < 2) {
|
|
220
|
+
return {
|
|
221
|
+
controller: null,
|
|
222
|
+
note: `${filePath}:${declLine}: @RepositoryRestResource found on ${decl.name}, but "extends ${decl.superName}<...>" does not declare both an entity and id type -- no routes are synthesized.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const entityName = typeArgs[0];
|
|
226
|
+
|
|
227
|
+
// The annotation's own argument text -- scoped so a positional bare-quote grab (like
|
|
228
|
+
// extractQuotedOrValue() elsewhere in this file) can't accidentally pick a DIFFERENT string
|
|
229
|
+
// attribute (@RepositoryRestResource also has e.g. collectionResourceRel) instead of `path`.
|
|
230
|
+
const annotationMatch = masked.match(/@RepositoryRestResource\s*\(/);
|
|
231
|
+
let explicitPath = null;
|
|
232
|
+
if (annotationMatch) {
|
|
233
|
+
const openParen = annotationMatch.index + annotationMatch[0].length - 1;
|
|
234
|
+
const closeParen = matchBalanced(masked, openParen, '(', ')');
|
|
235
|
+
if (closeParen !== -1) {
|
|
236
|
+
const argsText = text.slice(openParen + 1, closeParen);
|
|
237
|
+
const pathMatch = argsText.match(/path\s*=\s*"([^"]*)"/);
|
|
238
|
+
if (pathMatch) explicitPath = pathMatch[1];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!explicitPath) {
|
|
242
|
+
return {
|
|
243
|
+
controller: null,
|
|
244
|
+
note: `${filePath}:${declLine}: @RepositoryRestResource on ${decl.name} has no explicit path="..." attribute -- Spring's default (an English-pluralized entity name) is not guessed here. Add path="..." and re-scan.`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Any interface-body override of an inherited CRUD action (e.g. `@Override @RestResource(exported
|
|
249
|
+
// = false) void deleteById(...)`) changes which of the 6 standard routes are actually exposed --
|
|
250
|
+
// safely determining WHICH action(s) that affects is out of scope, so the whole repository is
|
|
251
|
+
// refused rather than risk a partially-wrong route set (a resolver pointing at something that
|
|
252
|
+
// 404s/405s in the real app is worse than no resolver at all).
|
|
253
|
+
const bodyOpenBrace = masked.indexOf('{', decl.index);
|
|
254
|
+
const bodyCloseBrace = bodyOpenBrace !== -1 ? matchBalanced(masked, bodyOpenBrace, '{', '}') : -1;
|
|
255
|
+
const bodyText = bodyOpenBrace !== -1 && bodyCloseBrace !== -1 ? masked.slice(bodyOpenBrace, bodyCloseBrace) : '';
|
|
256
|
+
if (/@RestResource\b/.test(bodyText)) {
|
|
257
|
+
return {
|
|
258
|
+
controller: null,
|
|
259
|
+
note: `${filePath}:${declLine}: ${decl.name} overrides at least one CRUD method with @RestResource(...) -- which specific action(s) that suppresses or renames can't be safely determined by static scanning, so no routes are synthesized for this repository at all (a partially-correct route set is worse than none).`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const basePath = `/${explicitPath.replace(/^\/+|\/+$/g, '')}`;
|
|
264
|
+
const declarations = [{
|
|
265
|
+
rule: 'java-spring:repository-rest-resource-crud',
|
|
266
|
+
line: declLine,
|
|
267
|
+
label: `@RepositoryRestResource(path="${explicitPath}") on ${decl.name}`,
|
|
268
|
+
}];
|
|
269
|
+
// D-spring-data-rest-adapter (SR2): operationId is bskel's OWN synthesized, self-describing
|
|
270
|
+
// label -- not a verified claim about what a real springdoc-generated document would call the
|
|
271
|
+
// same operation (that was never measured). A real --openapi-file document using a different
|
|
272
|
+
// name for the same route degrades to the existing CONTRACT_OPENAPI_MISSING_OPERATION warning
|
|
273
|
+
// every other operationId mismatch already produces, not a new failure mode. The synthesized,
|
|
274
|
+
// truthy operationId is what lets handles/providers/java-spring/plan.mjs's findFetchOperation()
|
|
275
|
+
// find these routes at all -- that function needed zero code changes (see SR3).
|
|
276
|
+
const ROUTES = [
|
|
277
|
+
{ verb: 'GET', path: basePath, suffix: 'CollectionResource' },
|
|
278
|
+
{ verb: 'POST', path: basePath, suffix: 'CollectionResource' },
|
|
279
|
+
{ verb: 'GET', path: `${basePath}/{id}`, suffix: 'ItemResource' },
|
|
280
|
+
{ verb: 'PUT', path: `${basePath}/{id}`, suffix: 'ItemResource' },
|
|
281
|
+
{ verb: 'PATCH', path: `${basePath}/{id}`, suffix: 'ItemResource' },
|
|
282
|
+
{ verb: 'DELETE', path: `${basePath}/{id}`, suffix: 'ItemResource' },
|
|
283
|
+
];
|
|
284
|
+
const endpoints = ROUTES.map((r) => ({
|
|
285
|
+
verb: r.verb,
|
|
286
|
+
path: r.path,
|
|
287
|
+
operationId: `${r.verb.toLowerCase()}${entityName}${r.suffix}`,
|
|
288
|
+
method: null,
|
|
289
|
+
line: declLine,
|
|
290
|
+
declarationIndex: 0,
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
controller: {
|
|
295
|
+
className: decl.name,
|
|
296
|
+
basePath,
|
|
297
|
+
operationIds: endpoints.map((e) => e.operationId),
|
|
298
|
+
endpoints,
|
|
299
|
+
declarations,
|
|
300
|
+
file: filePath,
|
|
301
|
+
line: declLine,
|
|
302
|
+
},
|
|
303
|
+
note: null,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
168
307
|
// D-entity-id-field-inheritance: found live against a real corpus check (spring-projects/
|
|
169
308
|
// spring-petclinic) -- `Owner extends Person extends BaseEntity`, and `@Id` lives on `BaseEntity`
|
|
170
309
|
// (a `@MappedSuperclass`), the standard, textbook JPA pattern for sharing an id/audit-field base
|
|
@@ -344,6 +483,13 @@ export function scanJavaSpring(repoRoot) {
|
|
|
344
483
|
return modules.get(key);
|
|
345
484
|
};
|
|
346
485
|
|
|
486
|
+
// D-spring-data-rest-adapter (SR5): computed once per scan, not per file -- a repo-wide,
|
|
487
|
+
// multi-module-aware check (same discovery detectJavaSpringRoot() already uses), not the
|
|
488
|
+
// single-root-file-only shape handles/providers/java-spring/emit.mjs's own
|
|
489
|
+
// hasSpringAopDependency() has.
|
|
490
|
+
const hasDataRestDependency = hasSpringDataRestDependency(repoRoot);
|
|
491
|
+
const repositoryResourceNotes = [];
|
|
492
|
+
|
|
347
493
|
for (const file of files) {
|
|
348
494
|
const text = fileTexts.get(file);
|
|
349
495
|
const mod = moduleOf(file, srcRoot, basePackage);
|
|
@@ -356,6 +502,11 @@ export function scanJavaSpring(repoRoot) {
|
|
|
356
502
|
const entity = extractEntity(text, file, classIndex);
|
|
357
503
|
if (entity) moduleEntry(mod).entities.push(entity);
|
|
358
504
|
}
|
|
505
|
+
if (/@RepositoryRestResource\b/.test(text)) {
|
|
506
|
+
const { controller, note } = extractRepositoryResource(text, file, hasDataRestDependency);
|
|
507
|
+
if (controller) moduleEntry(mod).controllers.push(controller);
|
|
508
|
+
if (note) repositoryResourceNotes.push(note);
|
|
509
|
+
}
|
|
359
510
|
if (mod && file.includes(`${path.sep}domain${path.sep}`) && /public\s+enum\s+\w+/.test(text)) {
|
|
360
511
|
const en = extractDomainEnum(text, file);
|
|
361
512
|
if (en) moduleEntry(mod).enums.push(en);
|
|
@@ -370,7 +521,7 @@ export function scanJavaSpring(repoRoot) {
|
|
|
370
521
|
// drift, and every other manifest-shaped gate input in this codebase (stack's `applied_file:`)
|
|
371
522
|
// is repo-relative too.
|
|
372
523
|
const filesRead = files.map((f) => path.relative(repoRoot, f));
|
|
373
|
-
return { srcRoot, modules: [...modules.values()], pathPrefixSignals: detectGlobalPathPrefixSignals(repoRoot), filesRead };
|
|
524
|
+
return { srcRoot, modules: [...modules.values()], pathPrefixSignals: detectGlobalPathPrefixSignals(repoRoot), filesRead, repositoryResourceNotes };
|
|
374
525
|
}
|
|
375
526
|
|
|
376
527
|
// G1: adapter descriptor consumed by scanners/registry.mjs -- see D-adapter-registry in
|
|
@@ -397,7 +548,7 @@ export const adapter = {
|
|
|
397
548
|
detect: detectJavaSpringRoot,
|
|
398
549
|
scan(repoRoot, _detection) {
|
|
399
550
|
const result = scanJavaSpring(repoRoot);
|
|
400
|
-
return { modules: result.modules, pathPrefixSignals: result.pathPrefixSignals, filesRead: result.filesRead };
|
|
551
|
+
return { modules: result.modules, pathPrefixSignals: result.pathPrefixSignals, filesRead: result.filesRead, repositoryResourceNotes: result.repositoryResourceNotes };
|
|
401
552
|
},
|
|
402
553
|
// S2 (D-gate-precision, continued): reuses the EXACT same listJavaFiles() call scan() itself
|
|
403
554
|
// makes -- no separate file-walking logic -- so the `scan` gate's staleness token can re-derive
|
package/scanners/index.mjs
CHANGED
|
@@ -218,6 +218,10 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
|
|
|
218
218
|
const confidence = chosen.confidence;
|
|
219
219
|
const modules = result.modules;
|
|
220
220
|
const pathPrefixSignals = result.pathPrefixSignals ?? [];
|
|
221
|
+
// D-spring-data-rest-adapter (SR4): the first per-adapter diagnostic-message passthrough into
|
|
222
|
+
// unknowns[] -- optional (`?? []`) so an adapter that doesn't populate it degrades to "nothing
|
|
223
|
+
// to report" rather than throwing, the same discipline apiSurfaceSource/filesRead already use.
|
|
224
|
+
const repositoryResourceNotes = result.repositoryResourceNotes ?? [];
|
|
221
225
|
const apiSurfaceSource = result.apiSurfaceSource ?? DEFAULT_API_SURFACE_SOURCE;
|
|
222
226
|
// S2 (D-gate-precision, continued): the adapter's own real read-set, persisted so
|
|
223
227
|
// lib/gate-definitions.mjs's `scan` gate can hash it for a precise staleness token instead of
|
|
@@ -301,6 +305,12 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
|
|
|
301
305
|
if (dbSchema?.live) {
|
|
302
306
|
unknowns.push(...computeDbDrift(dbSchema.live.tables, relatedModules));
|
|
303
307
|
}
|
|
308
|
+
// D-spring-data-rest-adapter (SR4): each entry names a repository the adapter recognized
|
|
309
|
+
// (found @RepositoryRestResource) but refused to synthesize routes for, and why -- a
|
|
310
|
+
// deliberate "don't guess" boundary, never silent.
|
|
311
|
+
if (repositoryResourceNotes.length > 0) {
|
|
312
|
+
unknowns.push(...repositoryResourceNotes);
|
|
313
|
+
}
|
|
304
314
|
// A1 §7: this scan can't correct a global path prefix (only --openapi-file's real-document
|
|
305
315
|
// reconciliation can, see D-openapi-reconciliation) -- but it CAN tell a user who doesn't know
|
|
306
316
|
// that flag exists that the defect is likely present, before they ever emit a wrong contract.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"required": ["sbf_contract", "feature_id", "feature_uid", "source", "operations", "warnings", "completeness"],
|
|
9
9
|
"properties": {
|
|
10
|
-
"sbf_contract": { "const": "
|
|
10
|
+
"sbf_contract": { "const": "9" },
|
|
11
11
|
"feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
12
12
|
"feature_uid": { "type": "string", "format": "uuid" },
|
|
13
13
|
"source": {
|
|
@@ -104,6 +104,17 @@
|
|
|
104
104
|
"type": "array",
|
|
105
105
|
"items": { "type": "string" }
|
|
106
106
|
},
|
|
107
|
+
"expansion": {
|
|
108
|
+
"description": "X2 (D-route-expansion-provenance): present only when this operation's scan endpoint was expanded from a multi-route declaration recognized by the scan adapter (e.g. a future Rails/Laravel-style resource declaration, or a Spring Data REST @RepositoryRestResource) -- omitted for every ordinary 1:1 declared endpoint. `rule` is a namespaced <adapter-id>:<rule-slug> naming the expansion pattern; `declarationLine` is the single source line of the declaration itself (not this operation's own route); `label` is an optional human-readable description, null when not worth composing one.",
|
|
109
|
+
"type": "object",
|
|
110
|
+
"additionalProperties": false,
|
|
111
|
+
"required": ["rule", "declarationLine"],
|
|
112
|
+
"properties": {
|
|
113
|
+
"rule": { "type": "string", "pattern": "^[a-z0-9-]+:[a-z0-9-]+$" },
|
|
114
|
+
"declarationLine": { "type": "integer", "minimum": 1 },
|
|
115
|
+
"label": { "type": ["string", "null"] }
|
|
116
|
+
}
|
|
117
|
+
},
|
|
107
118
|
"sourceDescription": {
|
|
108
119
|
"description": "A10: the operation's `description`, copied verbatim from a real source document. Present only when `contract emit --descriptions` (opt-in, unlike every other source-backed field in this schema) was passed AND the source document declared one for this exact operation AND it did not exceed the length cap.",
|
|
109
120
|
"type": "string"
|