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
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
|
253
|
-
// at runtime, never pinned in source -- see extractEndpoints. --openapi-file +
|
|
254
|
-
// trustworthy path (contracts/openapi.mjs's existing, adapter-agnostic
|
|
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
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
}
|