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
@@ -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
@@ -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": "8" },
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"
@@ -15,7 +15,12 @@
15
15
  "required": ["algorithm", "value"],
16
16
  "properties": {
17
17
  "algorithm": { "const": "ed25519" },
18
- "value": { "type": "string", "description": "base64-encoded raw Ed25519 signature bytes over canonicalize(report)." }
18
+ "value": { "type": "string", "description": "base64-encoded raw Ed25519 signature bytes over canonicalize(report)." },
19
+ "key_id": {
20
+ "type": "string",
21
+ "pattern": "^ed25519:[0-9a-f]{32}$",
22
+ "description": "D-attestation-payload-completeness (K6): a SELECTION HINT, not a trust claim. It sits OUTSIDE the signed bytes (only `report` is signed) and is therefore attacker-modifiable -- `bskel attest verify` uses it only to produce a better error message when the wrong --pubkey is supplied. Verification depends solely on the signature over canonicalize(report)."
23
+ }
19
24
  }
20
25
  }
21
26
  }