backend-skeleton 1.1.0 → 1.2.0

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.
@@ -23,7 +23,14 @@ 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;
28
+
29
+ // D-fastapi-generic-module-name: real evidence only (polarsource/polar), not a speculative list --
30
+ // same "validated against a real oracle" precedent as KNOWN_DTO_SUFFIXES below. 57 real files
31
+ // literally named `endpoints.py`, 1 named `router.py`; no other generic stem (`routes`, `views`,
32
+ // `api`) has ever been observed.
33
+ const GENERIC_ROUTER_STEMS = new Set(['endpoints', 'router']);
27
34
  const CLASS_RE = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm;
28
35
  const INCLUDE_ROUTER_RE = /include_router\s*\(/g;
29
36
 
@@ -104,24 +111,42 @@ function capitalize(s) {
104
111
  // resolved against a global prefix applied elsewhere (e.g. `include_router(prefix=...)`) -- that
105
112
  // asymmetry is exactly what `pathPrefixSignals`/`unknowns` exists to flag, same role java-spring's
106
113
  // 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] : '';
114
+ //
115
+ // D-fastapi-multi-router-per-file: a real dogfooding find against `polarsource/polar` (a 400+
116
+ // route production FastAPI monorepo) -- 3 real files (`checkout/endpoints.py`,
117
+ // `member/endpoints.py`, `auth/oauth2/router.py`) declare MORE THAN ONE `<var> = APIRouter(...)`
118
+ // in the same file (e.g. member.py's `router = APIRouter(prefix="/members")` plus a second,
119
+ // distinct `customer_members_router = APIRouter(prefix="/customers")` for a nested customer-scoped
120
+ // resource). The single-basePath-per-file design used to always read only the FIRST `APIRouter(`
121
+ // occurrence and apply it to every decorator in the file regardless of which router variable
122
+ // actually decorates it -- silently discarding the second router's own real prefix. Returns
123
+ // Map<varName, prefix> for every declared router in the file so each decorator can look up its
124
+ // OWN router's prefix instead.
125
+ function extractRouterPrefixes(text) {
126
+ const prefixes = new Map();
127
+ for (const m of text.matchAll(ROUTER_DECL_RE)) {
128
+ const openIdx = m.index + m[0].length - 1;
129
+ const closeIdx = matchBalancedParens(text, openIdx);
130
+ if (closeIdx === -1) continue;
131
+ const prefixMatch = text.slice(openIdx + 1, closeIdx).match(/prefix\s*=\s*["']([^"']*)["']/);
132
+ prefixes.set(m[1], prefixMatch ? prefixMatch[1] : '');
133
+ }
134
+ return prefixes;
115
135
  }
116
136
 
117
137
  // `operationId` is always null -- see the adapter's own `api.operations: false` and
118
138
  // D-fastapi-adapter in DECISIONS.md: FastAPI generates operation ids at request-handling time
119
139
  // (per-project, sometimes via a custom `generate_unique_id_function`), never pinned in source the
120
140
  // way `@Operation(operationId=...)` is for Java, so there is nothing honest to statically correlate.
141
+ //
142
+ // D-fastapi-multi-router-per-file: `routerVar` (the exact identifier before `.get`/`.post`/...) is
143
+ // now captured per endpoint so the caller can resolve basePath per-router-variable rather than
144
+ // once for the whole file -- see extractRouterPrefixes() above.
121
145
  function extractEndpoints(text) {
122
146
  const endpoints = [];
123
147
  for (const m of text.matchAll(VERB_DECORATOR_RE)) {
124
- const verb = m[1].toUpperCase();
148
+ const routerVar = m[1];
149
+ const verb = m[2].toUpperCase();
125
150
  const openIdx = m.index + m[0].length - 1;
126
151
  const closeIdx = matchBalancedParens(text, openIdx);
127
152
  if (closeIdx === -1) continue;
@@ -134,11 +159,19 @@ function extractEndpoints(text) {
134
159
  const funcMatch = afterDecoratorRe.exec(text);
135
160
  if (!funcMatch) continue;
136
161
 
137
- endpoints.push({ verb, path: pathMatch[1], operationId: null, method: funcMatch[1], line: lineNumberAt(text, m.index) });
162
+ endpoints.push({ verb, path: pathMatch[1], operationId: null, method: funcMatch[1], routerVar, line: lineNumberAt(text, m.index) });
138
163
  }
139
164
  return endpoints;
140
165
  }
141
166
 
167
+ // snake_case/mixed identifier -> PascalCase, e.g. "customer_members_router" -> "CustomerMembersRouter",
168
+ // "inner_router" -> "InnerRouter". Only used for a NON-default router variable name (see
169
+ // scanPythonFastApi below) -- the common single-router-per-file case keeps its existing
170
+ // `${capitalize(moduleName)}Router` className exactly as before, byte-for-byte.
171
+ function pascalCase(identifier) {
172
+ return identifier.split('_').filter(Boolean).map(capitalize).join('');
173
+ }
174
+
142
175
  // SQLModel `class X(<bases>, table=True):` -- table name is the lowercased class name (SQLModel's
143
176
  // own default when no explicit `__tablename__` is declared; cross-checked against the real
144
177
  // oracle's own Alembic migration, `op.create_table("user", ...)`/`op.create_table("item", ...)`,
@@ -249,9 +282,10 @@ function extractIncludeRouterPrefixSignals(repoRoot, files) {
249
282
  return signals;
250
283
  }
251
284
 
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).
285
+ // D-fastapi-adapter: paths are router-local (see extractRouterPrefixes); FastAPI generates
286
+ // operation ids at runtime, never pinned in source -- see extractEndpoints. --openapi-file +
287
+ // --path-prefix is the trustworthy path (contracts/openapi.mjs's existing, adapter-agnostic
288
+ // reconciliation).
255
289
  const API_SURFACE_SOURCE = 'router-local paths only (this scan does not resolve a global prefix applied via ' +
256
290
  'include_router(prefix=...) beyond a simple literal/single-variable lookup -- see unknowns below if one ' +
257
291
  'was found) -- FastAPI generates operation ids at request-handling time (per-project, sometimes via a ' +
@@ -259,8 +293,59 @@ const API_SURFACE_SOURCE = 'router-local paths only (this scan does not resolve
259
293
  'document via `bskel contract emit --openapi-file <path> --path-prefix <prefix>` for trustworthy ' +
260
294
  'operation identity and schemas.';
261
295
 
296
+ // D-fastapi-generic-module-name: real polarsource/polar dogfooding found `path.basename(file,
297
+ // '.py')` (the module-name rule below) collapses to the literal string "endpoints"/"router" for
298
+ // every file using that repo's own real, common convention -- name every router file generically,
299
+ // carry the real domain identity in the PARENT DIRECTORY instead (`organization/endpoints.py`,
300
+ // `member/endpoints.py`). Two CLOSED passes, not interleaved -- a generic file's directory-derived
301
+ // candidate must never be adopted before every non-generic file's own literal name is known, or a
302
+ // later-discovered collision could arrive too late (the wrong merge would already be written).
303
+ // Pass 1: every non-generic-stem file's own literal name is `reserved`, unchanged from today.
304
+ // Pass 2: a generic-stem file's candidate (its own parent directory's basename) is adopted only if
305
+ // it collides with neither `reserved` nor another generic file's already-`claimed` candidate --
306
+ // real evidence found 9 such real collisions in polar alone (e.g. `subscription/endpoints.py`'s
307
+ // candidate "subscription" collides with the real, unrelated, non-generic
308
+ // `customer_portal/endpoints/subscription.py`). On any collision, the file keeps its old literal
309
+ // generic name -- never guess into a silent wrong merge. `files` is already sorted by full path
310
+ // (`listRgFiles`), so processing order (and therefore first-claim-wins) is deterministic.
311
+ function resolveGenericModuleNames(files, readFile, projectRoot) {
312
+ const routerFiles = [];
313
+ const reserved = new Set();
314
+ for (const file of files) {
315
+ if (!/APIRouter\s*\(/.test(readFile(file))) continue;
316
+ const stem = path.basename(file, '.py');
317
+ routerFiles.push(file);
318
+ if (!GENERIC_ROUTER_STEMS.has(stem)) reserved.add(stem);
319
+ }
320
+
321
+ const moduleNameByFile = new Map();
322
+ const claimed = new Set();
323
+ for (const file of routerFiles) {
324
+ const stem = path.basename(file, '.py');
325
+ if (!GENERIC_ROUTER_STEMS.has(stem)) {
326
+ moduleNameByFile.set(file, stem);
327
+ continue;
328
+ }
329
+ const dir = path.dirname(file);
330
+ const candidate = dir !== projectRoot ? path.basename(dir) : null;
331
+ if (candidate && !reserved.has(candidate) && !claimed.has(candidate)) {
332
+ claimed.add(candidate);
333
+ moduleNameByFile.set(file, candidate);
334
+ } else {
335
+ moduleNameByFile.set(file, stem); // collision or no meaningful parent -- keep the old literal name
336
+ }
337
+ }
338
+ return moduleNameByFile;
339
+ }
340
+
262
341
  export function scanPythonFastApi(repoRoot, projectRoot) {
263
342
  const files = listPythonFiles(projectRoot);
343
+ const fileTextCache = new Map();
344
+ const readFile = (f) => {
345
+ if (!fileTextCache.has(f)) fileTextCache.set(f, fs.readFileSync(f, 'utf8'));
346
+ return fileTextCache.get(f);
347
+ };
348
+ const moduleNameByFile = resolveGenericModuleNames(files, readFile, projectRoot);
264
349
  const modules = new Map();
265
350
  const moduleEntry = (name) => {
266
351
  if (!modules.has(name)) modules.set(name, { module: name, controllers: [], entities: [], enums: [], dtos: [] });
@@ -270,17 +355,36 @@ export function scanPythonFastApi(repoRoot, projectRoot) {
270
355
  const allEntities = [];
271
356
  const allDtos = [];
272
357
  for (const file of files) {
273
- const text = fs.readFileSync(file, 'utf8');
358
+ const text = readFile(file);
274
359
 
275
360
  if (/APIRouter\s*\(/.test(text)) {
276
361
  // module = filename stem, NOT the router's own prefix -- verified against the real oracle's
277
362
  // login.py, which declares `APIRouter(tags=["login"])` with no prefix at all, so a
278
363
  // prefix-derived name fails on a real file while the filename stem works for every one.
279
- 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 });
364
+ // D-fastapi-generic-module-name: for a GENERIC stem (`endpoints`/`router`), this is the
365
+ // collision-safe, directory-derived name instead -- see resolveGenericModuleNames() above.
366
+ const moduleName = moduleNameByFile.get(file);
367
+ const routerPrefixes = extractRouterPrefixes(text);
368
+ const rawEndpoints = extractEndpoints(text);
369
+
370
+ // D-fastapi-multi-router-per-file: group by the router variable each decorator actually
371
+ // belongs to (not the file as a whole) -- a decorator on a router variable this file never
372
+ // itself declares (e.g. imported from elsewhere, or a same-file `include_router()` alias)
373
+ // falls back to '' rather than guessing, same as the pre-fix single-router behavior.
374
+ const byRouterVar = new Map();
375
+ for (const ep of rawEndpoints) {
376
+ if (!byRouterVar.has(ep.routerVar)) byRouterVar.set(ep.routerVar, []);
377
+ byRouterVar.get(ep.routerVar).push(ep);
378
+ }
379
+
380
+ for (const [routerVar, eps] of byRouterVar) {
381
+ const basePath = routerPrefixes.get(routerVar) ?? '';
382
+ const endpoints = eps.map((ep) => ({ verb: ep.verb, path: joinPath(basePath, ep.path), operationId: ep.operationId, method: ep.method, line: ep.line }));
383
+ // the common case (single router per file, conventionally named "router") keeps the
384
+ // existing className exactly as before -- only a second/other-named router variable
385
+ // gets a distinguishing className derived from its own identifier.
386
+ const className = routerVar === 'router' ? `${capitalize(moduleName)}Router` : `${pascalCase(routerVar)}`;
387
+ moduleEntry(moduleName).controllers.push({ className, basePath, operationIds: [], endpoints, file });
284
388
  }
285
389
  }
286
390
 
@@ -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
  }
@@ -265,7 +265,7 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
265
265
  }
266
266
 
267
267
  return {
268
- schema: 'sbf.scan-report/1',
268
+ schema: 'sbf.scan-report/2',
269
269
  terms,
270
270
  adapter,
271
271
  confidence,
@@ -30,7 +30,9 @@
30
30
  "matched": { "type": "integer", "minimum": 0, "description": "receipts whose contract_ref equals the CURRENT contract hash -- these count as evidence for the current contract." },
31
31
  "stale_contract_ref": { "type": "integer", "minimum": 0, "description": "receipts recorded against a contract_ref that no longer matches -- kept on record for audit/trend purposes, not counted as current evidence." },
32
32
  "violations": { "type": "integer", "minimum": 0 },
33
- "unsupported": { "type": "integer", "minimum": 0 }
33
+ "unsupported": { "type": "integer", "minimum": 0 },
34
+ "unsigned": { "type": "integer", "minimum": 0, "description": "receipts with no signature field -- computed regardless of --pubkey, for forward visibility." },
35
+ "signature_invalid": { "type": "integer", "minimum": 0, "description": "receipts whose signature field failed verification -- only ever non-zero when --pubkey was given; meaningless (always 0) otherwise. Excluded from matched/violations, same as stale_contract_ref." }
34
36
  }
35
37
  },
36
38
  "by_operation": {
@@ -44,6 +46,15 @@
44
46
  "violations": { "type": "integer", "minimum": 0 }
45
47
  }
46
48
  }
49
+ },
50
+ "verification": {
51
+ "type": "object",
52
+ "additionalProperties": false,
53
+ "description": "Whether signature verification was even attempted at generation time -- without this, signature_invalid: 0 is ambiguous between 'verified, zero invalid' and 'never checked'. See D-runtime-conformance-receipts.",
54
+ "properties": {
55
+ "pubkey_given": { "type": "boolean" },
56
+ "require_signature": { "type": "boolean" }
57
+ }
47
58
  }
48
59
  }
49
60
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "urn:sbf:observe-receipt:1",
4
4
  "title": "backend-skeleton runtime contract-conformance receipt",
5
- "description": "One JSONL line ContractObservationAspect logs per @ObserveContract-annotated call. Verdict-only, by design -- never carries an observed request/response VALUE, only pointers + constraint kinds. See DECISIONS.md D-runtime-conformance-receipts. `bskel observe import --receipts <path>` reads a stream of these.",
5
+ "description": "One JSONL line ContractObservationAspect logs per @ObserveContract-annotated call. Verdict-only, by design -- never carries an observed request/response VALUE, only pointers + constraint kinds. See DECISIONS.md D-runtime-conformance-receipts. `bskel observe import --receipts <path>` reads a stream of these. `signature` is optional -- unsigned receipts stay valid; `bskel observe import --pubkey <path>` opts into verifying it where present. See the D-runtime-conformance-receipts 'cryptographic receipt attestation' Update note.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["feature_id", "feature_uid", "operation_id", "contract_ref", "verb", "recorded_at", "violations"],
@@ -27,6 +27,15 @@
27
27
  "message": { "type": "string", "maxLength": 300 }
28
28
  }
29
29
  }
30
+ },
31
+ "signature": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "required": ["algorithm", "value"],
35
+ "properties": {
36
+ "algorithm": { "const": "ed25519" },
37
+ "value": { "type": "string", "description": "base64-encoded raw Ed25519 signature bytes over the canonicalized receipt (this field excluded)." }
38
+ }
30
39
  }
31
40
  }
32
41
  }
@@ -6,7 +6,7 @@
6
6
  "additionalProperties": false,
7
7
  "required": ["schema", "terms", "adapter", "verdict", "related_modules", "collisions", "unknowns", "files_read"],
8
8
  "properties": {
9
- "schema": { "const": "sbf.scan-report/1" },
9
+ "schema": { "const": "sbf.scan-report/2" },
10
10
  "feature_id": { "type": "string" },
11
11
  "terms": { "type": "array", "items": { "type": "string" } },
12
12
  "adapter": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },