backend-skeleton 1.1.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.
@@ -172,11 +172,18 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
172
172
  for (const entity of targetModule.entities) {
173
173
  if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
174
174
  const fetchRoute = findFetchRoute(targetModule.controllers, entity.className);
175
- const handlerFile = fetchRoute ? resolveHandlerFile(fetchRoute.file, fetchRoute.method, srcRoot) : null;
175
+ // D-typescript-express-inline-handlers: `method === null` means the scanner found a real
176
+ // route but its handler is an inline function expression, not a named export -- there is
177
+ // genuinely nothing for resolveHandlerFile()'s import/barrel-hop search to correlate to, so
178
+ // this is checked explicitly (a clear, named reason) rather than relying on the incidental
179
+ // fact that a regex built from the literal string "null" also happens not to match anything.
180
+ const handlerFile = fetchRoute && fetchRoute.method ? resolveHandlerFile(fetchRoute.file, fetchRoute.method, srcRoot) : null;
176
181
  const selectFields = handlerFile ? findSelectAllowList(handlerFile) : null;
177
182
 
178
183
  if (!fetchRoute) {
179
184
  notes.push(`${entity.className}: no single-resource GET route found on a router whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
185
+ } else if (!fetchRoute.method) {
186
+ notes.push(`${entity.className}: the single-resource GET route's handler is an inline function expression, not a named export -- nothing to correlate to a defining file, resolver NOT generated.`);
180
187
  } else if (!handlerFile) {
181
188
  notes.push(`${entity.className}: could not resolve ${fetchRoute.method}'s own defining file (import, or one barrel hop, from ${path.relative(repoRoot, fetchRoute.file)}) -- resolver NOT generated.`);
182
189
  } else if (!selectFields) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.1.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",
@@ -149,7 +149,22 @@ const MAPPING_VERBS = ['Get', 'Post', 'Put', 'Patch', 'Delete'];
149
149
  const MAPPING_ANNOTATION_RE = new RegExp(`@(?:(${MAPPING_VERBS.join('|')})Mapping|RequestMapping)\\b`, 'g');
150
150
  const REQUEST_MAPPING_RE = /@RequestMapping\b/g;
151
151
  const CLASS_OR_RECORD_START_RE = /^(?:public\s+)?(?:class|record)\b/;
152
- 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/;
153
168
 
154
169
  // True when `class`/`record` is the next real declaration after `index`, ONE OR MORE further
155
170
  // annotations allowed in between (e.g. a real oracle shape: `@RequestMapping(...)
@@ -23,7 +23,8 @@ const EXCLUDE_GLOBS = ['!**/.venv/**', '!**/site-packages/**', '!**/node_modules
23
23
  // `fastapi==0.100.0`.
24
24
  const FASTAPI_DEP_RE = /(?:^|[\s"'[])fastapi(?:\[[^\]]*\])?(?:[\s"',\]=<>~!;]|$)/mi;
25
25
 
26
- const VERB_DECORATOR_RE = /@\w+\.(get|post|put|patch|delete)\s*\(/gi;
26
+ const VERB_DECORATOR_RE = /@(\w+)\.(get|post|put|patch|delete)\s*\(/gi;
27
+ const ROUTER_DECL_RE = /(\w+)\s*=\s*APIRouter\s*\(/g;
27
28
  const CLASS_RE = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm;
28
29
  const INCLUDE_ROUTER_RE = /include_router\s*\(/g;
29
30
 
@@ -104,24 +105,42 @@ function capitalize(s) {
104
105
  // resolved against a global prefix applied elsewhere (e.g. `include_router(prefix=...)`) -- that
105
106
  // asymmetry is exactly what `pathPrefixSignals`/`unknowns` exists to flag, same role java-spring's
106
107
  // detectGlobalPathPrefixSignals plays for `configurePathMatch`/`context-path`.
107
- function extractBasePath(text) {
108
- const m = text.match(/APIRouter\s*\(/);
109
- if (!m) return '';
110
- const openIdx = m.index + m[0].length - 1;
111
- const closeIdx = matchBalancedParens(text, openIdx);
112
- if (closeIdx === -1) return '';
113
- const prefixMatch = text.slice(openIdx + 1, closeIdx).match(/prefix\s*=\s*["']([^"']*)["']/);
114
- return prefixMatch ? prefixMatch[1] : '';
108
+ //
109
+ // D-fastapi-multi-router-per-file: a real dogfooding find against `polarsource/polar` (a 400+
110
+ // route production FastAPI monorepo) -- 3 real files (`checkout/endpoints.py`,
111
+ // `member/endpoints.py`, `auth/oauth2/router.py`) declare MORE THAN ONE `<var> = APIRouter(...)`
112
+ // in the same file (e.g. member.py's `router = APIRouter(prefix="/members")` plus a second,
113
+ // distinct `customer_members_router = APIRouter(prefix="/customers")` for a nested customer-scoped
114
+ // resource). The single-basePath-per-file design used to always read only the FIRST `APIRouter(`
115
+ // occurrence and apply it to every decorator in the file regardless of which router variable
116
+ // actually decorates it -- silently discarding the second router's own real prefix. Returns
117
+ // Map<varName, prefix> for every declared router in the file so each decorator can look up its
118
+ // OWN router's prefix instead.
119
+ function extractRouterPrefixes(text) {
120
+ const prefixes = new Map();
121
+ for (const m of text.matchAll(ROUTER_DECL_RE)) {
122
+ const openIdx = m.index + m[0].length - 1;
123
+ const closeIdx = matchBalancedParens(text, openIdx);
124
+ if (closeIdx === -1) continue;
125
+ const prefixMatch = text.slice(openIdx + 1, closeIdx).match(/prefix\s*=\s*["']([^"']*)["']/);
126
+ prefixes.set(m[1], prefixMatch ? prefixMatch[1] : '');
127
+ }
128
+ return prefixes;
115
129
  }
116
130
 
117
131
  // `operationId` is always null -- see the adapter's own `api.operations: false` and
118
132
  // D-fastapi-adapter in DECISIONS.md: FastAPI generates operation ids at request-handling time
119
133
  // (per-project, sometimes via a custom `generate_unique_id_function`), never pinned in source the
120
134
  // way `@Operation(operationId=...)` is for Java, so there is nothing honest to statically correlate.
135
+ //
136
+ // D-fastapi-multi-router-per-file: `routerVar` (the exact identifier before `.get`/`.post`/...) is
137
+ // now captured per endpoint so the caller can resolve basePath per-router-variable rather than
138
+ // once for the whole file -- see extractRouterPrefixes() above.
121
139
  function extractEndpoints(text) {
122
140
  const endpoints = [];
123
141
  for (const m of text.matchAll(VERB_DECORATOR_RE)) {
124
- const verb = m[1].toUpperCase();
142
+ const routerVar = m[1];
143
+ const verb = m[2].toUpperCase();
125
144
  const openIdx = m.index + m[0].length - 1;
126
145
  const closeIdx = matchBalancedParens(text, openIdx);
127
146
  if (closeIdx === -1) continue;
@@ -134,11 +153,19 @@ function extractEndpoints(text) {
134
153
  const funcMatch = afterDecoratorRe.exec(text);
135
154
  if (!funcMatch) continue;
136
155
 
137
- endpoints.push({ verb, path: pathMatch[1], operationId: null, method: funcMatch[1], line: lineNumberAt(text, m.index) });
156
+ endpoints.push({ verb, path: pathMatch[1], operationId: null, method: funcMatch[1], routerVar, line: lineNumberAt(text, m.index) });
138
157
  }
139
158
  return endpoints;
140
159
  }
141
160
 
161
+ // snake_case/mixed identifier -> PascalCase, e.g. "customer_members_router" -> "CustomerMembersRouter",
162
+ // "inner_router" -> "InnerRouter". Only used for a NON-default router variable name (see
163
+ // scanPythonFastApi below) -- the common single-router-per-file case keeps its existing
164
+ // `${capitalize(moduleName)}Router` className exactly as before, byte-for-byte.
165
+ function pascalCase(identifier) {
166
+ return identifier.split('_').filter(Boolean).map(capitalize).join('');
167
+ }
168
+
142
169
  // SQLModel `class X(<bases>, table=True):` -- table name is the lowercased class name (SQLModel's
143
170
  // own default when no explicit `__tablename__` is declared; cross-checked against the real
144
171
  // oracle's own Alembic migration, `op.create_table("user", ...)`/`op.create_table("item", ...)`,
@@ -249,9 +276,10 @@ function extractIncludeRouterPrefixSignals(repoRoot, files) {
249
276
  return signals;
250
277
  }
251
278
 
252
- // D-fastapi-adapter: paths are router-local (see extractBasePath); FastAPI generates operation ids
253
- // at runtime, never pinned in source -- see extractEndpoints. --openapi-file + --path-prefix is the
254
- // trustworthy path (contracts/openapi.mjs's existing, adapter-agnostic reconciliation).
279
+ // D-fastapi-adapter: paths are router-local (see extractRouterPrefixes); FastAPI generates
280
+ // operation ids at runtime, never pinned in source -- see extractEndpoints. --openapi-file +
281
+ // --path-prefix is the trustworthy path (contracts/openapi.mjs's existing, adapter-agnostic
282
+ // reconciliation).
255
283
  const API_SURFACE_SOURCE = 'router-local paths only (this scan does not resolve a global prefix applied via ' +
256
284
  'include_router(prefix=...) beyond a simple literal/single-variable lookup -- see unknowns below if one ' +
257
285
  'was found) -- FastAPI generates operation ids at request-handling time (per-project, sometimes via a ' +
@@ -277,10 +305,27 @@ export function scanPythonFastApi(repoRoot, projectRoot) {
277
305
  // login.py, which declares `APIRouter(tags=["login"])` with no prefix at all, so a
278
306
  // prefix-derived name fails on a real file while the filename stem works for every one.
279
307
  const moduleName = path.basename(file, '.py');
280
- const basePath = extractBasePath(text);
281
- const endpoints = extractEndpoints(text).map((ep) => ({ ...ep, path: joinPath(basePath, ep.path) }));
282
- if (endpoints.length > 0) {
283
- moduleEntry(moduleName).controllers.push({ className: `${capitalize(moduleName)}Router`, basePath, operationIds: [], endpoints, file });
308
+ const routerPrefixes = extractRouterPrefixes(text);
309
+ const rawEndpoints = extractEndpoints(text);
310
+
311
+ // D-fastapi-multi-router-per-file: group by the router variable each decorator actually
312
+ // belongs to (not the file as a whole) -- a decorator on a router variable this file never
313
+ // itself declares (e.g. imported from elsewhere, or a same-file `include_router()` alias)
314
+ // falls back to '' rather than guessing, same as the pre-fix single-router behavior.
315
+ const byRouterVar = new Map();
316
+ for (const ep of rawEndpoints) {
317
+ if (!byRouterVar.has(ep.routerVar)) byRouterVar.set(ep.routerVar, []);
318
+ byRouterVar.get(ep.routerVar).push(ep);
319
+ }
320
+
321
+ for (const [routerVar, eps] of byRouterVar) {
322
+ const basePath = routerPrefixes.get(routerVar) ?? '';
323
+ const endpoints = eps.map((ep) => ({ verb: ep.verb, path: joinPath(basePath, ep.path), operationId: ep.operationId, method: ep.method, line: ep.line }));
324
+ // the common case (single router per file, conventionally named "router") keeps the
325
+ // existing className exactly as before -- only a second/other-named router variable
326
+ // gets a distinguishing className derived from its own identifier.
327
+ const className = routerVar === 'router' ? `${capitalize(moduleName)}Router` : `${pascalCase(routerVar)}`;
328
+ moduleEntry(moduleName).controllers.push({ className, basePath, operationIds: [], endpoints, file });
284
329
  }
285
330
  }
286
331
 
@@ -89,6 +89,20 @@ function listTypeScriptFiles(projectRoot) {
89
89
  // of `router.use('/literal', subRouter)` mount edges from a graph root down to the leaf file. This
90
90
  // extracts just the LOCAL endpoints (verb/path/handler/line) with an EMPTY prefix -- the mount-tree
91
91
  // walk in scanTypeScriptExpress() below joins the real prefix chain afterward.
92
+ //
93
+ // D-typescript-express-inline-handlers: an inline function expression (`router.get('/x', async
94
+ // (req, res) => {...})`) is a DIFFERENT shape from a bare-identifier handler reference
95
+ // (`router.get('/x', show)`) -- confirmed live, dogfooding against a real, popular repo
96
+ // (gothinkster/node-express-realworld-example-app, 3,796 real GitHub stars): ALL 19 of its real
97
+ // route registrations use this inline form, and the bare-identifier-only gate this comment used to
98
+ // describe found ZERO of them (verdict: greenfield on a repo with a real, complete REST API).
99
+ // `method: null` (not a synthesized/guessed name) marks this case -- there is no export to
100
+ // correlate an inline handler to, and `handles/providers/typescript-express/plan.mjs`'s own
101
+ // `resolveHandlerFile()` already has a real, tested `null`-propagates-to-"resolver not generated"
102
+ // path for exactly this "handler correlates to nothing" case (see its own Update note in
103
+ // DECISIONS.md) -- this is NOT a new failure mode, just a new, real way to reach the existing one.
104
+ const INLINE_HANDLER_RE = /^(?:async\s+)?(?:\([^)]*\)|[$\w]+)\s*(?::[^=]*)?=>|^(?:async\s+)?function\b/;
105
+
92
106
  function extractEndpoints(text) {
93
107
  const endpoints = [];
94
108
  for (const m of text.matchAll(VERB_CALL_RE)) {
@@ -102,13 +116,14 @@ function extractEndpoints(text) {
102
116
 
103
117
  const args = splitTopLevelArgs(argsText);
104
118
  const lastArg = args[args.length - 1]?.trim();
105
- // A bare identifier only -- an inline arrow-function handler has no name to correlate to a
106
- // controller file, so it's skipped rather than guessed at (same discipline as FastAPI's own
107
- // "no path literal -> skip").
108
119
  const handlerMatch = lastArg?.match(/^(\w+)$/);
109
- if (!handlerMatch) continue;
120
+ const isInlineHandler = !handlerMatch && lastArg && INLINE_HANDLER_RE.test(lastArg);
121
+ // Neither a bare identifier nor a recognizable inline function expression (e.g. a member
122
+ // expression like `controller.show`, or something built dynamically) -- skip rather than
123
+ // guess, same discipline as the missing-path-literal case just above.
124
+ if (!handlerMatch && !isInlineHandler) continue;
110
125
 
111
- endpoints.push({ verb, path: pathMatch[1], operationId: null, method: handlerMatch[1], line: lineNumberAt(text, m.index) });
126
+ endpoints.push({ verb, path: pathMatch[1], operationId: null, method: handlerMatch ? handlerMatch[1] : null, line: lineNumberAt(text, m.index) });
112
127
  }
113
128
  return endpoints;
114
129
  }