backend-skeleton 1.0.0-beta.6 → 1.0.0-beta.8

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.
@@ -104,6 +104,22 @@ function writeUnit(target, content) {
104
104
  fs.writeFileSync(target, content);
105
105
  }
106
106
 
107
+ // O3 follow-up (D-handle-registry-enforcement, "Continued"): a coarse, per-resource, emit-time,
108
+ // source-only proxy for "has this resource's own create-flow ever been wired to register a
109
+ // HandleRegistry row" -- see DECISIONS.md for why this stays static and never queries a live
110
+ // target-app database (that's a separate, harder, still-open gap, not this). Requires the opening
111
+ // "(" so a bare comment/javadoc mention of the annotation's name (RecordHandleSnapshot.java.tmpl's
112
+ // own javadoc has one) doesn't count as "found". Deliberately biased toward a false-positive
113
+ // WARNING (nagging a resource that's actually registered some other way, e.g. a hand-written
114
+ // HandleService.register() call with no annotation) over a false-negative SILENCE (saying nothing
115
+ // about a resource that really can never bootstrap its first PATCH) -- see DECISIONS.md.
116
+ const RECORD_HANDLE_SNAPSHOT_RE = /@RecordHandleSnapshot\s*\(/;
117
+
118
+ function hasRecordHandleSnapshot(serviceFilePath) {
119
+ if (!serviceFilePath || !fs.existsSync(serviceFilePath)) return false;
120
+ return RECORD_HANDLE_SNAPSHOT_RE.test(fs.readFileSync(serviceFilePath, 'utf8'));
121
+ }
122
+
107
123
  // See DECISIONS.md D-handles-ownership for the full design; the conflict/manifest/force/orphan
108
124
  // logic itself now lives in handles/_engine.mjs (D-handles-providers, G4) -- this function's job
109
125
  // is purely to compute java-spring's own render/paths and hand them to emitUnits(). `force`/
@@ -249,15 +265,26 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
249
265
  if (computeDiff && migrationAction === 'update') migrationActionEntry.diff = unifiedDiff(migrationRelPath, migrationDiskContent, migrationContent);
250
266
  result.actions.push(migrationActionEntry);
251
267
 
252
- return {
253
- ...result,
254
- postEmitNotes: [
255
- 'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
256
- // O4 (D-handle-lifecycle): HandleAspect.java only actually intercepts anything once a
257
- // human applies @RecordHandleSnapshot to a real service method AND the target repo has
258
- // this dependency -- never auto-added to build.gradle, same "review and apply yourself"
259
- // boundary as the migration note above.
260
- 'NOT done automatically: HandleAspect.java requires spring-boot-starter-aop on your own build.gradle classpath (Spring AOP is not enabled by any other starter). Add it yourself before applying @RecordHandleSnapshot to any service method.',
261
- ],
262
- };
268
+ const postEmitNotes = [
269
+ 'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
270
+ // O4 (D-handle-lifecycle): HandleAspect.java only actually intercepts anything once a
271
+ // human applies @RecordHandleSnapshot to a real service method AND the target repo has
272
+ // this dependency -- never auto-added to build.gradle, same "review and apply yourself"
273
+ // boundary as the migration note above.
274
+ 'NOT done automatically: HandleAspect.java requires spring-boot-starter-aop on your own build.gradle classpath (Spring AOP is not enabled by any other starter). Add it yourself before applying @RecordHandleSnapshot to any service method.',
275
+ ];
276
+ // O3 follow-up (D-handle-registry-enforcement, "Continued"): per-resource, conditional on
277
+ // enforceRegistry actually being on -- see hasRecordHandleSnapshot() above.
278
+ if (enforceRegistry) {
279
+ for (const resource of plan.resources) {
280
+ if (!resource.willGenerateResolver) continue;
281
+ if (hasRecordHandleSnapshot(resource.service.file)) continue;
282
+ const relServiceFile = path.relative(repoRoot, resource.service.file);
283
+ postEmitNotes.push(
284
+ `${resource.type}: --enforce-registry is on, but no @RecordHandleSnapshot(...) was found anywhere in ${relServiceFile} -- this resource may never get its first HandleRegistry row, and every fetch()/patch() call against it will 404 until something registers it. Apply @RecordHandleSnapshot to ${resource.service.serviceType}'s own create-flow method (or call HandleService.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`,
285
+ );
286
+ }
287
+ }
288
+
289
+ return { ...result, postEmitNotes };
263
290
  }
@@ -40,6 +40,16 @@ function dottedModulePath(file, importRoot) {
40
40
  return rel.split(path.sep).join('.');
41
41
  }
42
42
 
43
+ // O3 follow-up (D-handle-registry-enforcement, "Continued"): same static, coarse, per-resource
44
+ // presence check as java-spring's own -- see that file's identical comment for the false-
45
+ // positive/false-negative bias reasoning (unchanged here).
46
+ const RECORD_SNAPSHOT_RE = /@record_snapshot\s*\(/;
47
+
48
+ function hasRecordSnapshot(routeFilePath) {
49
+ if (!routeFilePath || !fs.existsSync(routeFilePath)) return false;
50
+ return RECORD_SNAPSHOT_RE.test(fs.readFileSync(routeFilePath, 'utf8'));
51
+ }
52
+
43
53
  // See DECISIONS.md D-handles-providers. G4 follow-up: migration.sql + a real recover() lifecycle
44
54
  // (tables.py/handle_service.py/record_snapshot.py) are now generated, mirroring java-spring's own
45
55
  // O4 work -- the EXCLUDED section's original "even Java hasn't got O4" reasoning is stale, see
@@ -197,5 +207,18 @@ export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter =
197
207
  // and apply yourself" boundary as the migration note above.
198
208
  postEmitNotes.push('NOT done automatically: applying @record_snapshot (handles/record_snapshot.py) to any of your own service functions. Codegen never touches existing business logic files.');
199
209
 
210
+ // O3 follow-up (D-handle-registry-enforcement, "Continued"): per-resource, conditional on
211
+ // enforceRegistry.
212
+ if (enforceRegistry) {
213
+ for (const resource of plan.resources) {
214
+ if (!resource.willGenerateResolver) continue;
215
+ if (hasRecordSnapshot(resource.fetchRoute.file)) continue;
216
+ const relRouteFile = path.relative(repoRoot, resource.fetchRoute.file);
217
+ postEmitNotes.push(
218
+ `${resource.type}: --enforce-registry is on, but no @record_snapshot(...) was found anywhere in ${relRouteFile} -- this resource may never get its first registry row, and every fetch/patch call against it will 404 until something registers it. Apply @record_snapshot to its own create-flow route function (or call handle_service.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`,
219
+ );
220
+ }
221
+ }
222
+
200
223
  return { ...result, postEmitNotes };
201
224
  }
@@ -196,9 +196,10 @@ export const GATE_DEFINITIONS = Object.freeze({
196
196
  };
197
197
  const moduleName = report?.disposition?.module ?? report?.related_modules?.[0]?.module;
198
198
  const mod = report?.related_modules?.find((m) => m.module === moduleName);
199
- // DTOs deliberately excluded -- scanJavaSpring() stores them as bare class-name
200
- // strings today, no `.file` field (Part 1's own named, out-of-scope gap).
201
- for (const item of [...(mod?.controllers ?? []), ...(mod?.entities ?? []), ...(mod?.enums ?? [])]) {
199
+ // DTOs now included -- every adapter pushes {className, file} objects for dtos, the
200
+ // same shape controllers/entities/enums already use. See D-gate-precision "Continued
201
+ // (part 3)" in DECISIONS.md.
202
+ for (const item of [...(mod?.controllers ?? []), ...(mod?.entities ?? []), ...(mod?.enums ?? []), ...(mod?.dtos ?? [])]) {
202
203
  if (!item.file) continue;
203
204
  // related_modules[].{controllers,entities,enums}[].file are stored ABSOLUTE
204
205
  // (unlike Part 1's own repo-relative files_read) -- confirmed live against a real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.8",
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",
@@ -257,7 +257,7 @@ export function scanJavaSpring(repoRoot) {
257
257
  if (en) moduleEntry(mod).enums.push(en);
258
258
  }
259
259
  if (mod && file.includes(`${path.sep}presentation${path.sep}dto${path.sep}`)) {
260
- moduleEntry(mod).dtos.push(path.basename(file, '.java'));
260
+ moduleEntry(mod).dtos.push({ className: path.basename(file, '.java'), file });
261
261
  }
262
262
  }
263
263
 
@@ -160,6 +160,42 @@ function extractTableEntities(text, file) {
160
160
  return entities;
161
161
  }
162
162
 
163
+ // D-gate-precision (Continued, part 3): the DTO/request-response-shape counterpart to
164
+ // extractTableEntities() -- a SQLModel-family class WITHOUT `table=True` (`ItemBase(SQLModel)`,
165
+ // `ItemPublic(ItemBase)`, `ItemCreate(ItemBase)`), the real convention confirmed against this
166
+ // project's own committed fixture and the real oracle it mirrors (fastapi/full-stack-fastapi-
167
+ // template). Deliberately a separate function, not merged into extractTableEntities() -- zero
168
+ // regression risk to existing entity extraction. One narrow, named exclusion beyond "not
169
+ // table=True": `class Settings(BaseSettings):` (pydantic-settings' own well-known config-class
170
+ // base) is not API request/response surface -- excluding it is a concrete, common false-positive
171
+ // fix, not a hypothetical one. No broader positive allowlist (e.g. requiring `SQLModel`/`BaseModel`
172
+ // literally in the base list) is layered on top: the real oracle's own `ItemPublic(ItemBase)` shape
173
+ // (a DTO extending ANOTHER DTO, not SQLModel/BaseModel directly) would fail such an allowlist,
174
+ // trading a bounded, low-cost false positive for a worse false negative (a missed real DTO change)
175
+ // -- the same "prefer false positive over false negative" call this exact drift-detection mechanism
176
+ // already makes for controllers/entities/enums.
177
+ function extractDtoClasses(text, file) {
178
+ const dtos = [];
179
+ for (const m of text.matchAll(CLASS_RE)) {
180
+ if (/table\s*=\s*True/.test(m[2])) continue; // has its own entity extractor above
181
+ if (/\bBaseSettings\b/.test(m[2])) continue; // pydantic-settings config, not API surface
182
+ dtos.push({ className: m[1], file, line: lineNumberAt(text, m.index) });
183
+ }
184
+ return dtos;
185
+ }
186
+
187
+ // Bounded, non-exhaustive allowlist of DTO class-name suffixes -- validated against this project's
188
+ // own committed fixture (ItemBase/ItemCreate/ItemPublic/ItemsPublic, all correctly stripping to
189
+ // "item"/"items", the real "items" module). A DTO whose class name uses a different convention
190
+ // (Schema, Payload, Out) lands in the `_dtos` bucket below instead -- named, accepted, untracked,
191
+ // same shape as an unmatched entity's `_models` fallback. Purely additive to extend: a future
192
+ // suffix added here can only ever gain coverage, never lose it.
193
+ const KNOWN_DTO_SUFFIXES = ['Base', 'Create', 'Update', 'Public', 'Read', 'Response', 'Request', 'In', 'Out'];
194
+ function stripKnownDtoSuffix(className) {
195
+ const suffix = KNOWN_DTO_SUFFIXES.find((s) => className.length > s.length && className.endsWith(s));
196
+ return suffix ? className.slice(0, -suffix.length) : className;
197
+ }
198
+
163
199
  // A1 §7 equivalent for FastAPI: `include_router(router, prefix=X)` applies a prefix the
164
200
  // per-router-file scan above cannot see (each file is read independently, with no idea another
165
201
  // file mounts it under a further prefix). Two-step resolution when X is a variable/attribute
@@ -224,6 +260,7 @@ export function scanPythonFastApi(repoRoot, projectRoot) {
224
260
  };
225
261
 
226
262
  const allEntities = [];
263
+ const allDtos = [];
227
264
  for (const file of files) {
228
265
  const text = fs.readFileSync(file, 'utf8');
229
266
 
@@ -240,6 +277,7 @@ export function scanPythonFastApi(repoRoot, projectRoot) {
240
277
  }
241
278
 
242
279
  allEntities.push(...extractTableEntities(text, file));
280
+ allDtos.push(...extractDtoClasses(text, file));
243
281
  }
244
282
 
245
283
  // Entity -> module assignment: this repo's real layout has no domain/<module>/ folder (all
@@ -255,6 +293,18 @@ export function scanPythonFastApi(repoRoot, projectRoot) {
255
293
  moduleEntry(targetModule ?? '_models').entities.push(entity);
256
294
  }
257
295
 
296
+ // DTO -> module assignment: same narrow name-match as entities, suffix-stripped first via the
297
+ // bounded allowlist above (ItemPublic -> "item" -> matches "items"). Unmatched (unlisted suffix,
298
+ // or no correlation at all) lands in a separate `_dtos` bucket -- not merged into entities' own
299
+ // `_models` -- and is therefore NOT tracked by any feature's contract gate; a real, named,
300
+ // accepted limitation (see D-gate-precision "Continued (part 3)" in DECISIONS.md).
301
+ for (const dto of allDtos) {
302
+ const lower = stripKnownDtoSuffix(dto.className).toLowerCase();
303
+ const candidates = new Set([lower, `${lower}s`]);
304
+ const targetModule = [...modules.keys()].find((name) => candidates.has(name));
305
+ moduleEntry(targetModule ?? '_dtos').dtos.push(dto);
306
+ }
307
+
258
308
  return {
259
309
  modules: [...modules.values()],
260
310
  pathPrefixSignals: extractIncludeRouterPrefixSignals(repoRoot, files),
@@ -32,6 +32,15 @@ const VERB_CALL_RE = new RegExp(`\\brouter\\.(${VERBS.join('|')})\\s*\\(`, 'gi')
32
32
  const ROUTER_USE_RE = /\brouter\.use\s*\(/g;
33
33
  const ENTITY_CLASS_RE = /@Entity\s*\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)\s*\n?\s*export\s+class\s+(\w+)/g;
34
34
 
35
+ // D-gate-precision (Continued, part 3): a pure PATH-CONVENTION heuristic, mirroring java-spring's
36
+ // own identical solution to the identical problem (`.../presentation/dto/`) rather than inventing
37
+ // syntactic DTO detection this ecosystem doesn't have a reliable single marker for -- plain
38
+ // `interface`, `type` aliases, class-validator classes, Zod schemas, and undecorated classes are
39
+ // all real conventions, and this adapter's own `api.request-shape: false` capability already names
40
+ // why no such regex is attempted here. One entry per FILE under a `dto/` directory, not per
41
+ // exported symbol -- same file-level granularity java's own DTO tracking already settled for.
42
+ const DTO_DIR_SEGMENT = `${path.sep}dto${path.sep}`;
43
+
35
44
  // Two independent signals required, mirroring java-spring's "build file AND src layout" /
36
45
  // python-fastapi's "dependency declared AND source-confirmed" combined bar: (a) package.json
37
46
  // declares express, (b) at least one .ts file actually imports Router from 'express' and calls
@@ -220,6 +229,7 @@ export function scanTypeScriptExpress(repoRoot, projectRoot) {
220
229
  };
221
230
 
222
231
  const allEntities = [];
232
+ const allDtos = [];
223
233
  for (const file of files) {
224
234
  const text = fileTexts.get(file);
225
235
  // G6: `\bRouter\s*\(` -- see detectTypeScriptExpressRoot above. Same widening for the same
@@ -235,6 +245,9 @@ export function scanTypeScriptExpress(repoRoot, projectRoot) {
235
245
  moduleEntry(moduleName).controllers.push({ className, basePath: prefix, operationIds: [], endpoints, file });
236
246
  }
237
247
  }
248
+ if (file.includes(DTO_DIR_SEGMENT)) {
249
+ allDtos.push({ className: path.basename(file, '.ts'), file }); // no `line` -- path-based, no content parsed
250
+ }
238
251
  allEntities.push(...extractTableEntities(text, file));
239
252
  }
240
253
 
@@ -248,6 +261,19 @@ export function scanTypeScriptExpress(repoRoot, projectRoot) {
248
261
  moduleEntry(targetModule ?? '_models').entities.push(entity);
249
262
  }
250
263
 
264
+ // DTO -> module assignment: same narrow name-match as entities, with a trailing literal
265
+ // `Dto`/`DTO` stripped first -- the one near-definitional, cross-project-safe TS DTO marker (a
266
+ // DTO's own name almost universally contains it). An action-prefixed name (`CreateUserDto.ts`,
267
+ // a common real-world shape) still will NOT exact-match after stripping ("createuser" !=
268
+ // "user"/"users") and lands in `_dtos` -- honestly uncovered rather than guessed at (see
269
+ // D-gate-precision "Continued (part 3)" in DECISIONS.md).
270
+ for (const dto of allDtos) {
271
+ const lower = dto.className.replace(/Dto$/i, '').toLowerCase();
272
+ const candidates = new Set([lower, `${lower}s`]);
273
+ const targetModule = [...modules.keys()].find((name) => candidates.has(name));
274
+ moduleEntry(targetModule ?? '_dtos').dtos.push(dto);
275
+ }
276
+
251
277
  return {
252
278
  modules: [...modules.values()],
253
279
  pathPrefixSignals: [],
@@ -70,7 +70,7 @@ export function renderScanMarkdown(report) {
70
70
  lines.push(`- Enum \`${en.name}\`: ${en.constants.join(', ')}`);
71
71
  }
72
72
  if (mod.dtos.length > 0) {
73
- lines.push(`- DTOs: ${mod.dtos.join(', ')}`);
73
+ lines.push(`- DTOs: ${mod.dtos.map((d) => d.className).join(', ')}`);
74
74
  }
75
75
  lines.push('');
76
76
  }
@@ -25,7 +25,7 @@
25
25
  "controllers": { "type": "array" },
26
26
  "entities": { "type": "array" },
27
27
  "enums": { "type": "array" },
28
- "dtos": { "type": "array", "items": { "type": "string" } },
28
+ "dtos": { "type": "array" },
29
29
  "evidence": {
30
30
  "description": "D-scanner-evidence: every {signal, term, value, weight, file, line} match that contributed to `score` -- capped per signal type AT COLLECTION TIME (not filtered afterward), so this array's size stays bounded regardless of repo size. See `capped_signals` for which signal types actually hit that cap.",
31
31
  "type": "array",