backend-skeleton 1.0.0 → 1.1.1

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 (50) hide show
  1. package/README.md +66 -4
  2. package/bin/bskel.mjs +125 -18
  3. package/contracts/export.mjs +39 -4
  4. package/contracts/openapi.mjs +292 -27
  5. package/contracts/validate.mjs +23 -4
  6. package/handles/_engine.mjs +75 -32
  7. package/handles/capability-codec.mjs +94 -0
  8. package/handles/codec.mjs +13 -3
  9. package/handles/providers/java-spring/emit.mjs +78 -33
  10. package/handles/providers/java-spring/observe.mjs +4 -3
  11. package/handles/providers/java-spring/plan.mjs +51 -7
  12. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  13. package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
  14. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  15. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  16. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
  17. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  18. package/handles/providers/java-spring.mjs +8 -0
  19. package/handles/providers/python-fastapi/emit.mjs +21 -26
  20. package/handles/providers/python-fastapi/observe.mjs +6 -5
  21. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  22. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
  23. package/handles/providers/python-fastapi.mjs +3 -3
  24. package/handles/providers/typescript-express/emit.mjs +135 -46
  25. package/handles/providers/typescript-express/observe.mjs +7 -6
  26. package/handles/providers/typescript-express/plan.mjs +8 -1
  27. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  28. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  29. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  30. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  31. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  32. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  33. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  34. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  35. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  36. package/handles/providers/typescript-express.mjs +7 -4
  37. package/lib/cli.mjs +11 -2
  38. package/lib/exit-codes.mjs +21 -0
  39. package/lib/verify.mjs +23 -6
  40. package/package.json +5 -2
  41. package/scanners/adapters/_java-spring-analyzer.mjs +25 -2
  42. package/scanners/adapters/java-spring.mjs +108 -10
  43. package/scanners/adapters/javascript-express.mjs +46 -13
  44. package/scanners/adapters/python-fastapi.mjs +63 -18
  45. package/scanners/adapters/typescript-express.mjs +33 -7
  46. package/schemas/feature-contract.schema.json +3 -3
  47. package/schemas/handles-plan.schema.json +2 -0
  48. package/schemas/oracle-manifest.schema.json +58 -0
  49. package/schemas/stack-record.schema.json +6 -1
  50. package/stack/apply.mjs +47 -6
@@ -1,20 +1,29 @@
1
1
  // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
2
  //
3
3
  // In-process registry mapping a handle `type` string to its resolver instance -- deliberately NOT
4
- // a database table. This 1st-slice scope carries no recover()/snapshot lifecycle equivalent (no
5
- // sbf_handle/sbf_handle_snapshot table) -- mirrors java-spring/python-fastapi's own pre-O4/
6
- // pre-follow-up state, not a gap specific to this provider. See D-typescript-express-provider in
7
- // DECISIONS.md.
4
+ // a database table (that's HandleRegistry in handleEntities.ts, an unrelated concept that only
5
+ // shares the word "registry" -- see that file's own header). See D-typescript-express-provider /
6
+ // D-typescript-express-registry-parity in DECISIONS.md.
8
7
 
9
8
  // Unlike the Python provider's ResourceResolver (which threads a per-request SQLAlchemy `session`
10
- // through every method, matching FastAPI's own dependency-injection convention), this interface
11
- // takes NO dataSource/session parameter at all -- TypeORM's `DataSource` is an app-wide singleton
12
- // instantiated once at startup (`export const AppDataSource = new DataSource(...)`), not a
13
- // per-request-injected object the way SQLAlchemy's `Session` is. Each generated resolver imports
14
- // its own DataSource reference directly (see resolver.ts.tmpl) -- a genuine simplification from
15
- // this ecosystem's own real pattern, not an arbitrary deviation from the Python provider's shape.
9
+ // argument through every METHOD CALL, matching FastAPI's own dependency-injection convention),
10
+ // none of fetch()/checkAccess()/patchField()/toPublic() below take a dataSource/session parameter
11
+ // -- TypeORM's `DataSource` is an app-wide singleton instantiated once at startup (`export const
12
+ // AppDataSource = new DataSource(...)`), not a per-request-injected object the way SQLAlchemy's
13
+ // `Session` is. Each generated resolver still imports its own DataSource reference directly (see
14
+ // resolver.ts.tmpl) and exposes it as the `dataSource` FIELD below (not a method parameter) --
15
+ // D-typescript-express-registry-parity's own infrastructure (recordSnapshotWrapper.ts, router.ts's
16
+ // recover()/enforcement logic) reads it from there rather than needing a separate reference of
17
+ // its own. `featureUid`/`contractRef` are the other two live-derived values
18
+ // D-resolver-policy-split exists for (see <Type>Policy.ts.tmpl). All three fields are read by
19
+ // that infrastructure only, never by fetch()/checkAccess()/patchField()/toPublic().
20
+ import type { DataSource } from 'typeorm';
21
+
16
22
  export interface ResourceResolver {
17
23
  type: string;
24
+ featureUid: string;
25
+ contractRef: string;
26
+ dataSource: DataSource;
18
27
  fetch(resourceUid: string): Promise<unknown>;
19
28
  checkAccess(obj: unknown): void;
20
29
  patchField(obj: unknown, pointer: string, value: unknown): void;
@@ -24,9 +24,22 @@
24
24
  import { {{MODEL}} } from '{{MODEL_IMPORT_PATH}}';
25
25
  import { {{DATA_SOURCE_NAME}} } from '{{DATA_SOURCE_IMPORT_PATH}}';
26
26
  import { register, HandleAccessDeniedError, HandleNotImplementedError, type ResourceResolver } from '../registry';
27
+ import { {{RESOURCE_TYPE}}Policy } from './{{RESOURCE_TYPE_CAMEL}}Policy';
27
28
 
28
29
  const {{RESOURCE_TYPE}}Resolver: ResourceResolver = {
30
+ ...{{RESOURCE_TYPE}}Policy,
31
+ // Redundant with the spread above (Policy already carries `type`) -- restated explicitly so
32
+ // this file's own orphan-scan (emit.mjs's resourceTypeOf(), a content-read, not a filename
33
+ // guess) can still find a real `type: 'X'` field without needing to open the sibling Policy
34
+ // file too. A literal, not derived from anything -- if this and the Policy value ever
35
+ // disagreed, the object-literal spread order means this line always wins at runtime, matching
36
+ // what the orphan scan would read.
29
37
  type: '{{RESOURCE_TYPE}}',
38
+ // D-typescript-express-registry-parity: exposed here, not in the policy file -- this is an
39
+ // OBJECT REFERENCE (the same app-wide DataSource singleton fetch() already imports below), not
40
+ // a plain regenerable string value the way contractRef/featureUid are. Read by recordSnapshotWrapper.ts
41
+ // and router.ts's recover()/enforcement logic so they never need their own separate DataSource.
42
+ dataSource: {{DATA_SOURCE_NAME}},
30
43
 
31
44
  async fetch(resourceUid: string): Promise<unknown> {
32
45
  return {{DATA_SOURCE_NAME}}.getRepository({{MODEL}}).findOne({ where: { {{ID_FIELD}}: resourceUid } });
@@ -0,0 +1,20 @@
1
+ // Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
2
+ //
3
+ // D-typescript-express-registry-parity (mirrors java-spring's ResourceResolverPolicyStub.java.tmpl
4
+ // / D-resolver-policy-split): machine-owned companion to {{RESOURCE_TYPE}}Resolver -- every value
5
+ // here is derived directly from the current contract/scan and is safe to regenerate on every
6
+ // `bskel handles emit` run, even when the sibling resolver file has diverged (e.g. a hand-finished
7
+ // patchField()) and is refusing to regenerate. This is a SEPARATE file, not a section of
8
+ // resolver.ts, so a hand-edit to checkAccess()/patchField() there never blocks a live-derived
9
+ // value here (contractRef/featureUid) from updating -- both used to live in one file for this
10
+ // provider's own 1st slice, and classifyFile()'s conflict detection is file-level.
11
+ //
12
+ // Nothing here is ever meant to be hand-edited -- to change one of these values, change the
13
+ // SOURCE it was derived from (the feature contract, etc.) and re-run `bskel handles emit`. A
14
+ // hand-edit here will be silently overwritten on the next run, by design (see the "provably
15
+ // untouched -> safe to regenerate" path in lib/handles-manifest.mjs's classifyFile()).
16
+ export const {{RESOURCE_TYPE}}Policy = {
17
+ type: '{{RESOURCE_TYPE}}',
18
+ contractRef: '{{CONTRACT_REF}}',
19
+ featureUid: '{{FEATURE_UID}}',
20
+ } as const;
@@ -9,8 +9,38 @@
9
9
  // against still pins this version) does NOT auto-catch a rejected Promise inside an async route
10
10
  // handler the way Express 5 does, and that can't be assumed of an arbitrary target app either way.
11
11
  import { Router, type Request, type Response } from 'express';
12
- import { decodeHandle, resolveJsonPointer } from './codec';
13
- import { resolverFor, HandleAccessDeniedError, HandleNotImplementedError } from './registry';
12
+ import { decodeHandle, resolveJsonPointer, deriveHandleUid, type DecodedHandle } from './codec';
13
+ import { resolverFor, HandleAccessDeniedError, HandleNotImplementedError, type ResourceResolver } from './registry';
14
+ import { HandleRegistry, HandleSnapshot } from './handleEntities';
15
+
16
+ // O3 (D-handle-registry-enforcement, part 2 of 2), D-typescript-express-registry-parity: opt-in,
17
+ // defaults false -- baked in at `bskel handles emit --enforce-registry on` time, matching every
18
+ // other per-target-repo codegen constant this project bakes in (e.g. featureUid/contractRef on
19
+ // each resolver). `registerHandle()` is only ever called by generated code wrapped with
20
+ // recordSnapshot(...) (an app that opted in) or a target app's own hand-written calls -- an app
21
+ // that mints handles through neither path gets fully working fetch()/patch() with zero registry
22
+ // involvement today; flipping this on unconditionally would 404 every one of their existing
23
+ // handles. Stays a deliberate, informed choice a target app's own maintainer makes.
24
+ const ENFORCE_REGISTRY = {{ENFORCE_REGISTRY}};
25
+
26
+ export class HandleNotRegisteredError extends Error {}
27
+
28
+ // D-handle-registry-enforcement's own bootstrapping-trap fix, ported here from day one (not
29
+ // rediscovered): always derives and looks up the PARENT RESOURCE's own row (kind='r',
30
+ // pointer=null), regardless of the requested handle's own kind/pointer -- `resourceType` is still
31
+ // exactly cross-checked (the real D-security-9 protection: an attacker-controlled `type` can't
32
+ // claim a different, more sensitive resource's registration by UUID coincidence), but kind/pointer
33
+ // are not, since every real registration in this system is resource-level. Structurally correct,
34
+ // not a workaround: revocation is inherently a resource-level concept, matching how fetch()'s own
35
+ // kind=f path already fetches the WHOLE resource and narrows client-side with a pointer.
36
+ async function requireRegisteredOrThrow(resolver: ResourceResolver, decoded: DecodedHandle): Promise<HandleRegistry> {
37
+ const resourceHandleUid = deriveHandleUid('r', decoded.type, decoded.uuid, null);
38
+ const registry = await resolver.dataSource.getRepository(HandleRegistry).findOne({ where: { handleUid: resourceHandleUid } });
39
+ if (registry == null || registry.resourceType !== decoded.type || registry.revokedAt != null) {
40
+ throw new HandleNotRegisteredError('no active registration for this handle');
41
+ }
42
+ return registry;
43
+ }
14
44
 
15
45
  export const router = Router();
16
46
 
@@ -38,6 +68,9 @@ router.get('/handles/:handle', async (req: Request, res: Response) => {
38
68
  }
39
69
 
40
70
  try {
71
+ if (ENFORCE_REGISTRY) {
72
+ await requireRegisteredOrThrow(resolver, decoded);
73
+ }
41
74
  const obj = await resolver.fetch(decoded.uuid);
42
75
  if (obj == null) {
43
76
  res.status(404).json({ detail: 'resource not found' });
@@ -65,6 +98,10 @@ router.get('/handles/:handle', async (req: Request, res: Response) => {
65
98
  res.status(403).json({ detail: exc.message });
66
99
  return;
67
100
  }
101
+ if (exc instanceof HandleNotRegisteredError) {
102
+ res.status(404).json({ detail: exc.message });
103
+ return;
104
+ }
68
105
  throw exc;
69
106
  }
70
107
  });
@@ -100,6 +137,9 @@ router.patch('/handles/:handle', async (req: Request, res: Response) => {
100
137
  }
101
138
 
102
139
  try {
140
+ if (ENFORCE_REGISTRY) {
141
+ await requireRegisteredOrThrow(resolver, decoded);
142
+ }
103
143
  const obj = await resolver.fetch(decoded.uuid);
104
144
  if (obj == null) {
105
145
  res.status(404).json({ detail: 'resource not found' });
@@ -117,6 +157,77 @@ router.patch('/handles/:handle', async (req: Request, res: Response) => {
117
157
  res.status(501).json({ detail: exc.message });
118
158
  return;
119
159
  }
160
+ if (exc instanceof HandleNotRegisteredError) {
161
+ res.status(404).json({ detail: exc.message });
162
+ return;
163
+ }
164
+ throw exc;
165
+ }
166
+ });
167
+
168
+ // D-handle-lifecycle (O4), D-typescript-express-registry-parity: recover() structurally REQUIRES
169
+ // a registry row to find a snapshot's own primary key -- there is no "unenforced" mode for it,
170
+ // unlike fetch()/patch() above. Always calls the same shared cross-check those two now optionally
171
+ // call, so the two paths can never silently drift apart. Unlike fetch()/patch(), this does NOT
172
+ // call resolver.fetch() first -- recovering history of a since-deleted resource is legitimate, so
173
+ // this never requires the live resource to still exist.
174
+ router.get('/handles/:handle/recover', async (req: Request, res: Response) => {
175
+ if (typeof req.params.handle !== 'string') {
176
+ res.status(400).json({ detail: 'malformed handle path segment' });
177
+ return;
178
+ }
179
+ let decoded;
180
+ try {
181
+ decoded = decodeHandle(req.params.handle);
182
+ } catch (exc) {
183
+ res.status(400).json({ detail: (exc as Error).message });
184
+ return;
185
+ }
186
+
187
+ const resolver = resolverFor(decoded.type);
188
+ if (!resolver) {
189
+ res.status(404).json({ detail: `no resolver registered for handle type "${decoded.type}"` });
190
+ return;
191
+ }
192
+
193
+ let registry: HandleRegistry;
194
+ try {
195
+ registry = await requireRegisteredOrThrow(resolver, decoded);
196
+ } catch (exc) {
197
+ if (exc instanceof HandleNotRegisteredError) {
198
+ res.status(404).json({ detail: exc.message });
199
+ return;
200
+ }
120
201
  throw exc;
121
202
  }
203
+
204
+ const atParam = typeof req.query.at === 'string' ? req.query.at : null;
205
+ const at = atParam != null ? new Date(atParam) : null;
206
+ if (at != null && Number.isNaN(at.getTime())) {
207
+ res.status(400).json({ detail: `"at" query parameter is not a valid date: "${atParam}"` });
208
+ return;
209
+ }
210
+
211
+ const qb = resolver.dataSource.getRepository(HandleSnapshot)
212
+ .createQueryBuilder('snapshot')
213
+ .where('snapshot.handle_uid = :handleUid', { handleUid: registry.handleUid })
214
+ .orderBy('snapshot.recorded_at', 'DESC');
215
+ if (at != null) qb.andWhere('snapshot.recorded_at <= :at', { at });
216
+ const snapshot = await qb.getOne();
217
+
218
+ if (snapshot == null) {
219
+ res.status(404).json({ detail: `no snapshot recorded for this handle${at != null ? ` at or before ${at.toISOString()}` : ''}` });
220
+ return;
221
+ }
222
+
223
+ res.status(200).json({
224
+ handle: req.params.handle,
225
+ recordedAt: snapshot.recordedAt,
226
+ operationId: snapshot.operationId,
227
+ // snapshot.payload is already a native object/array/scalar (a real jsonb column) -- no
228
+ // manual re-parse step exists here for the double-encoding bug Java once had to be possible
229
+ // in the first place.
230
+ payload: snapshot.payload,
231
+ schemaDrift: registry.contractRef !== snapshot.contractHash,
232
+ });
122
233
  });
@@ -9,10 +9,13 @@ export const provider = {
9
9
  id: 'typescript-express',
10
10
  title: 'TypeScript / Express / TypeORM',
11
11
  requiresCapabilities: ['resource.fetch'],
12
- // No migration.sql -- this 1st-slice provider generates no schema-owning artifact (no
13
- // recover(), no sbf_handle table), matching java-spring/python-fastapi's own pre-O4/
14
- // pre-follow-up state, not a gap specific to this provider.
15
- outputs: { spec: [] },
12
+ // D-typescript-express-registry-parity: migration.sql is manifest-tracked now (like every
13
+ // other provider's, per D-write-safety-phase0), so this declaration is NOT for idempotence
14
+ // exclusion -- it's lib/verify.mjs's S6 safety net (a `handles ran` check that fires even with
15
+ // no manifest entry at all, e.g. a `gate force`d handles gate). See java-spring.mjs's own
16
+ // identical comment for the full reasoning; checkArtifacts() dedupes against the manifest-
17
+ // based check by path, so this never produces a duplicate once a real manifest entry exists.
18
+ outputs: { spec: ['handles/migration.sql'] },
16
19
  plan,
17
20
  emit(args) {
18
21
  return emitTypeScriptExpress(args);
package/lib/cli.mjs CHANGED
@@ -306,11 +306,15 @@ export const COMMANDS = {
306
306
  },
307
307
  },
308
308
  'stack apply': {
309
- usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--json]',
309
+ usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]',
310
310
  options: {
311
311
  choice: { type: 'string', default: null },
312
312
  apply: { type: 'boolean', default: false },
313
313
  port: { type: 'string', default: '8080', numeric: { min: 1, max: 65535 } },
314
+ // D-write-safety-phase0 (item 2): mirrors handles emit's own --force/--reason exactly --
315
+ // a file that diverged from what `stack apply` itself last wrote is refused without this.
316
+ force: { type: 'boolean', default: false },
317
+ reason: { type: 'string', default: '' },
314
318
  json: { type: 'boolean', default: false },
315
319
  },
316
320
  },
@@ -359,11 +363,16 @@ export const COMMANDS = {
359
363
  // purpose IS the live query). Reuses `--resource type1,type2`'s exact multi-value convention
360
364
  // from `handles plan`/`handles emit` rather than a new singular flag name.
361
365
  'handles audit': {
362
- usage: 'bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]',
366
+ usage: 'bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]',
363
367
  options: {
364
368
  feature: { type: 'string', default: null, required: true },
365
369
  'database-url-env': { type: 'string', default: null, required: true },
366
370
  resource: { type: 'string', default: '' },
371
+ // D-write-safety-phase1 (item 3): both opt-in, only consulted when --check-registry-coverage
372
+ // is set -- the base command stays gate-independent and scan-report-independent otherwise
373
+ // (see this command's own doc comment in bin/bskel.mjs for why that matters).
374
+ module: { type: 'string', default: null },
375
+ 'check-registry-coverage': { type: 'boolean', default: false },
367
376
  json: { type: 'boolean', default: false },
368
377
  },
369
378
  },
@@ -22,6 +22,23 @@ export const EXIT_CODES = Object.freeze({
22
22
  LOW_CONFIDENCE_SCAN: 16,
23
23
  MISSING_CAPABILITY: 17,
24
24
  REFRESH_FAILED: 18,
25
+ // D-write-safety-phase0 (item 2): `stack apply --apply` refusing to overwrite a file that
26
+ // diverged from what backend-skeleton itself last wrote (without --force --reason) --
27
+ // deliberately its own code, not a reuse of HANDLES_CONFLICT, since this is a different write
28
+ // surface (stack/apply.mjs, not handles/_engine.mjs) and D-stable-api-contract's exit-code
29
+ // promise is additive-safe for new codes.
30
+ STACK_CONFLICT: 19,
31
+ // D-write-safety-phase1 (item 1): `handles emit --enforce-registry on` refusing to proceed
32
+ // because the target java-spring repo's build file has no `spring-boot-starter-aop` --
33
+ // HandleAspect.java cannot intercept anything without it, so writing the resolvers would be
34
+ // silently unusable. Not a reuse of MISSING_CAPABILITY(17), which means "the ADAPTER doesn't
35
+ // declare this capability at all" -- this repo's adapter does, it's just one dependency short.
36
+ HANDLES_MISSING_DEPENDENCY: 20,
37
+ // D-write-safety-phase1 (item 2): `handles emit --enforce-registry on` refusing to proceed
38
+ // because a resource has no @RecordHandleSnapshot/@record_snapshot anywhere the static check
39
+ // can find -- distinct from HANDLES_CONFLICT(15), which is about a GENERATED file diverging;
40
+ // this is about a hand-written file bskel never touches lacking an annotation it can't add.
41
+ HANDLES_REGISTRATION_GAP: 21,
25
42
  });
26
43
 
27
44
  // `reason` values a `sbf.cli-diagnostic/1` envelope (lib/cli.mjs) can carry. Deliberately does
@@ -55,6 +72,10 @@ export const EXIT_REASONS = Object.freeze({
55
72
  ADAPTER_UNAVAILABLE: EXIT_CODES.NOT_PASSED,
56
73
  PROVIDER_UNAVAILABLE: EXIT_CODES.NOT_PASSED,
57
74
  UNKNOWN_OPERATION: EXIT_CODES.NOT_PASSED,
75
+ // D-openapi-cyclic-refs: `contract tool-schema`'s own refusal when the operation's payload
76
+ // schema is genuinely recursive -- Anthropic's tool-use input_schema explicitly does not
77
+ // support recursive schemas (a real, documented API limitation, not a bskel-side gap).
78
+ RECURSIVE_SCHEMA_UNSUPPORTED: EXIT_CODES.NOT_PASSED,
58
79
  SCAN_FAILED: EXIT_CODES.NOT_PASSED,
59
80
  PLAN_FAILED: EXIT_CODES.NOT_PASSED,
60
81
  });
package/lib/verify.mjs CHANGED
@@ -110,24 +110,33 @@ function handlesOutputsFor(root, featureId) {
110
110
  // has ever run at all, independent of its current pass/stale status -- a stale or forced handles
111
111
  // gate still implies every one of its expected outputs should exist. A provider with zero
112
112
  // spec-scoped outputs simply produces no artifact items here at all, which is correct: there is
113
- // nothing to check (no provider currently declares an empty outputs.spec -- java-spring and
114
- // python-fastapi both emit migration.sql, G4's D-handles-providers follow-up -- but the code
115
- // path stays general for whatever provider comes next).
113
+ // nothing to check (no provider currently declares a non-empty outputs.spec any more --
114
+ // D-write-safety-phase0 (item 1) moved java-spring/python-fastapi's migration.sql onto manifest
115
+ // tracking, the same mechanism handlesManifestChecks() below already covers, so this loop is now
116
+ // live only for the fallback case below or a future provider with a genuinely untracked output).
116
117
  export function checkArtifacts(root, featureId, gates = []) {
117
118
  const checks = [];
118
119
  const contractPath = specPath(root, featureId, 'contracts', `${featureId}.schema.json`);
119
120
  checks.push({ artifact: 'contract', path: path.relative(root, contractPath), exists: fs.existsSync(contractPath) });
120
121
 
121
122
  const handlesRan = gates.find((g) => g.gate === 'handles')?.ran ?? false;
123
+ const manifestChecks = handlesManifestChecks(root, featureId, handlesRan);
124
+ // D-write-safety-phase0 (item 1): a path already covered by the manifest-based check above must
125
+ // not also get the legacy existence-only check below -- migration.sql is now BOTH manifest-
126
+ // tracked (owner: featureId) AND still named in DEFAULT_HANDLES_OUTPUTS's fallback list, so
127
+ // without this guard a scan-report-missing/corrupt repo would report it twice.
128
+ const manifestCheckedPaths = new Set(manifestChecks.map((c) => c.path));
122
129
  for (const relOutput of handlesOutputsFor(root, featureId)) {
123
130
  const outputPath = specPath(root, featureId, ...relOutput.split('/'));
131
+ const outputRelPath = path.relative(root, outputPath);
132
+ if (manifestCheckedPaths.has(outputRelPath)) continue;
124
133
  const outputExists = fs.existsSync(outputPath);
125
134
  if (handlesRan || outputExists) {
126
135
  const label = path.basename(relOutput, path.extname(relOutput));
127
- checks.push({ artifact: `handles ${label}`, path: path.relative(root, outputPath), exists: outputExists });
136
+ checks.push({ artifact: `handles ${label}`, path: outputRelPath, exists: outputExists });
128
137
  }
129
138
  }
130
- checks.push(...handlesManifestChecks(root, featureId, handlesRan));
139
+ checks.push(...manifestChecks);
131
140
  return checks;
132
141
  }
133
142
 
@@ -138,6 +147,11 @@ export function checkArtifacts(root, featureId, gates = []) {
138
147
  // nothing regenerates it implicitly, and the feature doesn't compile without it. Same mechanism
139
148
  // and reasoning as the migration.sql check above (S6) -- existence only, at verify time, entirely
140
149
  // outside the gate token.
150
+ // D-write-safety-phase0 (item 1): manifest 'kind' values -> the label bskel verify's report shows.
151
+ // Anything not listed falls back to 'handles resolver' (the historical default before 'migration'
152
+ // existed) -- see the artifact: line below.
153
+ const HANDLES_ARTIFACT_LABELS = { infra: 'handles infra', migration: 'handles migration' };
154
+
141
155
  function handlesManifestChecks(root, featureId, handlesRan) {
142
156
  let manifest;
143
157
  try {
@@ -162,7 +176,10 @@ function handlesManifestChecks(root, featureId, handlesRan) {
162
176
  .map(([relPath, e]) => {
163
177
  const abs = resolveWithinRoot(root, relPath);
164
178
  return {
165
- artifact: e.kind === 'infra' ? 'handles infra' : 'handles resolver',
179
+ // D-write-safety-phase0 (item 1): was a binary ternary (infra vs. everything else
180
+ // called "resolver") -- widened once migration.sql started manifest-tracking as its
181
+ // own kind: 'migration', which is neither infra (repo-owned) nor a resolver.
182
+ artifact: HANDLES_ARTIFACT_LABELS[e.kind] ?? 'handles resolver',
166
183
  path: relPath,
167
184
  exists: abs !== null && fs.existsSync(abs),
168
185
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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",
@@ -38,6 +38,7 @@
38
38
  "test:java-compile": "node scripts/java-compile-smoke.mjs",
39
39
  "test:python-import": "node scripts/python-import-smoke.mjs",
40
40
  "test:db-introspect": "node scripts/db-introspect-smoke.mjs",
41
+ "test:registry-coverage": "node scripts/handles-registry-coverage-smoke.mjs",
41
42
  "test:ddl-apply": "node scripts/ddl-apply-smoke.mjs",
42
43
  "test:cross-feature-fk": "node scripts/cross-feature-fk-smoke.mjs",
43
44
  "test:java-integration": "node scripts/java-integration-smoke.mjs",
@@ -45,7 +46,9 @@
45
46
  "test:python-integration": "node scripts/python-integration-smoke.mjs",
46
47
  "test:typescript-compile": "node scripts/typescript-typecheck-smoke.mjs",
47
48
  "test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs",
48
- "test:all-smoke": "npm test && npm run test:pack && npm run test:java-compile && npm run test:java-integration && npm run test:python-import && npm run test:python-integration && npm run test:db-introspect && npm run test:ddl-apply && npm run test:cross-feature-fk && npm run test:java-ast && npm run test:typescript-compile"
49
+ "test:shadow-validation": "node scripts/shadow-validation-smoke.mjs",
50
+ "test:oracle-corpus": "node scripts/shadow-validation-smoke.mjs --manifest test/fixtures/oracle-manifest.json",
51
+ "test:all-smoke": "npm test && npm run test:pack && npm run test:java-compile && npm run test:java-integration && npm run test:python-import && npm run test:python-integration && npm run test:db-introspect && npm run test:registry-coverage && npm run test:ddl-apply && npm run test:cross-feature-fk && npm run test:java-ast && npm run test:typescript-compile"
49
52
  },
50
53
  "dependencies": {
51
54
  "ajv": "^8.20.0",
@@ -53,7 +53,15 @@ export function matchBalanced(text, openIndex, openChar, closeChar) {
53
53
  return -1;
54
54
  }
55
55
 
56
- const CLASS_OR_RECORD_RE = /(?:public\s+)?(class|record)\s+(\w+)/;
56
+ // D-entity-id-field-inheritance: `\b` before `(class|record)` -- found live, not anticipated,
57
+ // while verifying a real cross-file inheritance walk against `@MappedSuperclass`-annotated Java
58
+ // (spring-petclinic's own `BaseEntity`). Without it, `class`/`record` could match as a SUBSTRING of
59
+ // a preceding identifier that happens to end the same way -- `@MappedSuperclass\npublic class
60
+ // BaseEntity` matched "class" inside "Superclass" itself (no `\w`-to-non-`\w` transition exists
61
+ // between "Super" and "class", so no real word boundary there either way), then captured the next
62
+ // bare word ("public") as if it were the class name. `\s+` alone was never sufficient protection --
63
+ // it only requires whitespace AFTER the match, never a real word start before it.
64
+ const CLASS_OR_RECORD_RE = /(?:public\s+)?\b(class|record)\s+(\w+)/;
57
65
 
58
66
  // Adds `record` support and makes `public` optional (a package-private class is still a legal
59
67
  // Spring bean) in the one place both extractController() and extractEntity() already duplicate
@@ -141,7 +149,22 @@ const MAPPING_VERBS = ['Get', 'Post', 'Put', 'Patch', 'Delete'];
141
149
  const MAPPING_ANNOTATION_RE = new RegExp(`@(?:(${MAPPING_VERBS.join('|')})Mapping|RequestMapping)\\b`, 'g');
142
150
  const REQUEST_MAPPING_RE = /@RequestMapping\b/g;
143
151
  const CLASS_OR_RECORD_START_RE = /^(?:public\s+)?(?:class|record)\b/;
144
- const REQUEST_MAPPING_METHOD_RE = /\bmethod\s*=\s*RequestMethod\.(\w+)\b/;
152
+ // D-java-spring-static-import-method: `RequestMethod.` prefix made optional -- confirmed live,
153
+ // dogfooding against a real, popular repo (gothinkster/spring-boot-realworld-example-app, 1,584
154
+ // real GitHub stars): its UsersApi.java (register + login, 2 of the RealWorld spec's most
155
+ // fundamental endpoints) uses `import static ... RequestMethod.POST;` then bare
156
+ // `@RequestMapping(path = "/users", method = POST)` -- a real, common Java style (static-import a
157
+ // single enum constant to cut the qualifier) the original prefix-required regex silently missed
158
+ // (0/2 endpoints on this file; the OTHER 15/17 endpoints in this same corpus, using
159
+ // @PostMapping/@GetMapping shorthand elsewhere, were unaffected and already correct). The verb
160
+ // alternation is restricted to Spring's own real `RequestMethod` enum's 8 actual values (GET,
161
+ // HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE) rather than a bare `\w+` -- narrower than
162
+ // "any identifier", so an unrelated `method = someVariable` still correctly fails to match rather
163
+ // than being misread as a verb. Verified live: the existing multi-verb array form (`method =
164
+ // {RequestMethod.GET, RequestMethod.POST}`, still deliberately unresolved/skipped) does NOT
165
+ // accidentally partial-match here -- the array's own `{` breaks the match before any verb name is
166
+ // reached, same as before this change.
167
+ const REQUEST_MAPPING_METHOD_RE = /\bmethod\s*=\s*(?:RequestMethod\.)?(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE)\b/;
145
168
 
146
169
  // True when `class`/`record` is the next real declaration after `index`, ONE OR MORE further
147
170
  // annotations allowed in between (e.g. a real oracle shape: `@RequestMapping(...)
@@ -45,10 +45,43 @@ function listJavaFiles(srcRoot) {
45
45
  return out.split('\n').filter(Boolean).sort();
46
46
  }
47
47
 
48
- function moduleOf(filePath, srcRoot) {
48
+ // D-module-attribution-base-package: found live via a real-world corpus check (spring-projects/
49
+ // spring-petclinic, the canonical public Spring Boot reference app) -- `domain/<module>/...` is
50
+ // Team-IZ-Backend's OWN package convention, not a general Spring Boot one; petclinic's real
51
+ // packages (`org.springframework.samples.petclinic.owner`, `...vet`, `...system`) never contain a
52
+ // `domain` segment at all, so every entity there collapsed into `_unknown`. Finds the app's real
53
+ // base package via its `@SpringBootApplication` class -- the actual framework-defined component-
54
+ // scan root, not a second guessed folder name -- and returns the first path segment immediately
55
+ // below it as a fallback module name. A single targeted `rg` search (not a second full-file read
56
+ // pass), matching this file's own `listJavaFiles()` pattern for rg-invocation-with-graceful-empty-
57
+ // result.
58
+ function findBasePackage(srcRoot) {
59
+ let out;
60
+ try {
61
+ out = execFileSync('rg', ['-l', '--fixed-strings', '@SpringBootApplication', '-g', '*.java', srcRoot], { encoding: 'utf8' });
62
+ } catch {
63
+ return null; // rg exits 1 on "no match" -- not an error, just no @SpringBootApplication class found
64
+ }
65
+ const file = out.split('\n').filter(Boolean).sort()[0];
66
+ if (!file) return null;
67
+ const text = fs.readFileSync(file, 'utf8');
68
+ const pkg = text.match(/(?:^|\n)\s*package\s+([\w.]+)\s*;/);
69
+ return pkg ? pkg[1].split('.') : null;
70
+ }
71
+
72
+ function moduleOf(filePath, srcRoot, basePackageParts) {
49
73
  const parts = path.relative(srcRoot, filePath).split(path.sep);
50
74
  const domainIdx = parts.indexOf('domain');
51
- return domainIdx >= 0 && parts[domainIdx + 1] ? parts[domainIdx + 1] : null;
75
+ if (domainIdx >= 0 && parts[domainIdx + 1]) return parts[domainIdx + 1];
76
+ // Fallback: the first segment directly under the app's own base package (e.g. petclinic's
77
+ // `owner`/`vet`/`system`) -- only when that segment is itself a subpackage (a directory), never
78
+ // the base package's own top-level file (the `@SpringBootApplication` class itself, or any
79
+ // other file living directly in the base package with no feature module of its own).
80
+ if (basePackageParts && basePackageParts.length && parts.length > basePackageParts.length + 1) {
81
+ const matchesBasePackage = basePackageParts.every((seg, i) => parts[i] === seg);
82
+ if (matchesBasePackage) return parts[basePackageParts.length];
83
+ }
84
+ return null;
52
85
  }
53
86
 
54
87
  function extractQuotedOrValue(argsText) {
@@ -132,12 +165,46 @@ function extractController(text, filePath) {
132
165
  return { className, basePath, operationIds, endpoints, file: filePath, line: classLine };
133
166
  }
134
167
 
135
- function extractEntity(text, filePath) {
168
+ // D-entity-id-field-inheritance: found live against a real corpus check (spring-projects/
169
+ // spring-petclinic) -- `Owner extends Person extends BaseEntity`, and `@Id` lives on `BaseEntity`
170
+ // (a `@MappedSuperclass`), the standard, textbook JPA pattern for sharing an id/audit-field base
171
+ // across entities. A single-file-only `@Id` search misses it entirely for every entity built this
172
+ // way. `classIndex` (simple class name -> that file's own text, built once per scan in
173
+ // scanJavaSpring()) lets this walk the real `extends` chain instead of guessing.
174
+ function extendsClauseName(maskedText) {
175
+ const m = maskedText.match(/\bclass\s+\w+\s+extends\s+(\w+)/);
176
+ return m ? m[1] : null;
177
+ }
178
+
179
+ // Depth-capped as insurance against a pathological/malformed input, not because real compilable
180
+ // Java can have circular inheritance (it can't) -- same "not expected to trigger, cheap insurance"
181
+ // reasoning javascript-express.mjs's own mount-graph cycle guard (`seen`) already uses for an
182
+ // analogous risk.
183
+ // D-write-safety-phase1 (item 4a): now also captures the declared TYPE, not just the field name --
184
+ // mirrors typescript-express.mjs's own already-established `idFieldIsUuid` precedent
185
+ // (scanners/adapters/typescript-express.mjs:216), which java-spring never had. Returns
186
+ // `{field, type} | null` (was a bare string | null) -- see extractEntity() below for how both
187
+ // existing and new consumers keep working from this.
188
+ function findIdField(text, classIndex, depth = 0) {
189
+ const direct = text.match(/@Id\b[\s\S]{0,200}?private\s+(\S+)\s+(\w+)\s*;/);
190
+ if (direct) return { field: direct[2], type: direct[1] };
191
+ if (depth >= 10) return null;
192
+ const superName = extendsClauseName(maskNonCode(text));
193
+ if (!superName || !classIndex.has(superName)) return null;
194
+ return findIdField(classIndex.get(superName), classIndex, depth + 1);
195
+ }
196
+
197
+ // Bare and fully-qualified both count -- a real repo can `import java.util.UUID;` or spell it out
198
+ // inline (`private java.util.UUID id;`), and this is a text-based scan with no import resolution.
199
+ function isUuidType(type) {
200
+ return type === 'UUID' || type === 'java.util.UUID';
201
+ }
202
+
203
+ function extractEntity(text, filePath, classIndex) {
136
204
  if (!/@Entity\b/.test(text)) return null;
137
205
  const masked = maskNonCode(text);
138
206
  const classDecl = findClassOrRecordDeclaration(masked);
139
207
  const tableMatch = text.match(/@Table\(\s*name\s*=\s*"([^"]+)"/);
140
- const idFieldMatch = text.match(/@Id\b[\s\S]{0,200}?private\s+\S+\s+(\w+)\s*;/);
141
208
  return {
142
209
  className: classDecl ? classDecl.name : path.basename(filePath, '.java'),
143
210
  table: tableMatch ? tableMatch[1] : null,
@@ -147,7 +214,20 @@ function extractEntity(text, filePath) {
147
214
  // two adapters that DO guess (python-fastapi/typescript-express) -- cross-feature collision
148
215
  // detection reads this field, not each adapter's own null-vs-guessed convention.
149
216
  tableSource: tableMatch ? 'explicit' : null,
150
- idField: idFieldMatch ? idFieldMatch[1] : null,
217
+ ...(() => {
218
+ const id = findIdField(text, classIndex);
219
+ return {
220
+ idField: id?.field ?? null,
221
+ // D-write-safety-phase1 (item 4a): idFieldIsUuid mirrors typescript-express.mjs's own
222
+ // field name exactly, for the same reason it exists there -- the handles subsystem's
223
+ // entire identity model is UUID-addressable (fetch(UUID resourceUid), sbf1_ handle
224
+ // tokens encode a UUID), so a non-UUID PK means "no resolver, and say why" rather than
225
+ // silence. idFieldType is the extra, java-spring-specific detail (the actual declared
226
+ // type string) that lets the diagnostic name it, not just say "not UUID".
227
+ idFieldType: id?.type ?? null,
228
+ idFieldIsUuid: id ? isUuidType(id.type) : null,
229
+ };
230
+ })(),
151
231
  file: filePath,
152
232
  line: classDecl ? lineNumberAt(text, classDecl.index) : null,
153
233
  };
@@ -239,6 +319,24 @@ export function scanJavaSpring(repoRoot) {
239
319
  const srcRoot = detectJavaSpringRoot(repoRoot);
240
320
  if (!srcRoot) return null;
241
321
 
322
+ const basePackage = findBasePackage(srcRoot);
323
+
324
+ // D-entity-id-field-inheritance: built once, not per-entity -- a single pass over the same
325
+ // files this function was already about to read anyway (no new file I/O), so
326
+ // extractEntity()'s idField search can walk a real `extends` chain across files.
327
+ const files = listJavaFiles(srcRoot);
328
+ const fileTexts = new Map();
329
+ const classIndex = new Map();
330
+ for (const file of files) {
331
+ const text = fs.readFileSync(file, 'utf8');
332
+ fileTexts.set(file, text);
333
+ const decl = findClassOrRecordDeclaration(maskNonCode(text));
334
+ // First file wins on a same-simple-name collision across packages -- `files` is already
335
+ // sorted (listJavaFiles()'s own O6 determinism guarantee), so this is deterministic, not
336
+ // silently random; a documented, bounded limitation, not a general symbol resolver.
337
+ if (decl && !classIndex.has(decl.name)) classIndex.set(decl.name, text);
338
+ }
339
+
242
340
  const modules = new Map();
243
341
  const moduleEntry = (name) => {
244
342
  const key = name ?? '_unknown';
@@ -246,16 +344,16 @@ export function scanJavaSpring(repoRoot) {
246
344
  return modules.get(key);
247
345
  };
248
346
 
249
- for (const file of listJavaFiles(srcRoot)) {
250
- const text = fs.readFileSync(file, 'utf8');
251
- const mod = moduleOf(file, srcRoot);
347
+ for (const file of files) {
348
+ const text = fileTexts.get(file);
349
+ const mod = moduleOf(file, srcRoot, basePackage);
252
350
 
253
351
  if (/@RestController\b/.test(text)) {
254
352
  const controller = extractController(text, file);
255
353
  if (controller) moduleEntry(mod).controllers.push(controller);
256
354
  }
257
355
  if (/@Entity\b/.test(text)) {
258
- const entity = extractEntity(text, file);
356
+ const entity = extractEntity(text, file, classIndex);
259
357
  if (entity) moduleEntry(mod).entities.push(entity);
260
358
  }
261
359
  if (mod && file.includes(`${path.sep}domain${path.sep}`) && /public\s+enum\s+\w+/.test(text)) {
@@ -271,7 +369,7 @@ export function scanJavaSpring(repoRoot) {
271
369
  // paths) -- this is what lib/gate-definitions.mjs's `scan` gate hashes to detect real content
272
370
  // drift, and every other manifest-shaped gate input in this codebase (stack's `applied_file:`)
273
371
  // is repo-relative too.
274
- const filesRead = listJavaFiles(srcRoot).map((f) => path.relative(repoRoot, f));
372
+ const filesRead = files.map((f) => path.relative(repoRoot, f));
275
373
  return { srcRoot, modules: [...modules.values()], pathPrefixSignals: detectGlobalPathPrefixSignals(repoRoot), filesRead };
276
374
  }
277
375