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

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.
@@ -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.7",
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",