backend-skeleton 1.0.0-beta.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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
@@ -0,0 +1,348 @@
1
+ // G2: the second first-class adapter, alongside java-spring.mjs (generic-grep.mjs is deliberately
2
+ // a shallow fallback, not a real second adapter -- see D-generic-grep-reconnaissance). Same
3
+ // philosophy as java-spring.mjs: ripgrep-for-discovery + regex-for-structure, deliberately no
4
+ // Python `ast`-module/interpreter shell-out -- see D-fastapi-adapter in DECISIONS.md for why that
5
+ // overrides CATALOG.md's own "Python AST" wording (measured against a real FastAPI repo with zero
6
+ // accuracy loss, and this CLI's only external binary dependency stays `rg`, not a second one).
7
+ //
8
+ // Verified against a real cloned oracle before this file was written (fastapi/full-stack-fastapi-
9
+ // template): running the PRE-G2 scanner against it reported `greenfield` -- 23 real routes existed
10
+ // and were entirely missed by score. That false negative, not portability alone, is this item's
11
+ // real motivation. See D-fastapi-adapter in DECISIONS.md for the full reproduction.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { lineNumberAt } from '../text-util.mjs';
16
+
17
+ const PROJECT_FILE_GLOBS = ['pyproject.toml', 'requirements*.txt'];
18
+ const EXCLUDE_GLOBS = ['!**/.venv/**', '!**/site-packages/**', '!**/node_modules/**', '!**/__pycache__/**'];
19
+
20
+ // Bounded, not a TOML/requirements.txt parser (A2's "good-enough regex, not a real parser"
21
+ // philosophy for Java, applied here) -- must reject `fastapi` as a substring of a longer
22
+ // identifier and still match real forms like `"fastapi[standard]>=0.141.1,<1.0.0"` and
23
+ // `fastapi==0.100.0`.
24
+ const FASTAPI_DEP_RE = /(?:^|[\s"'[])fastapi(?:\[[^\]]*\])?(?:[\s"',\]=<>~!;]|$)/mi;
25
+
26
+ const VERB_DECORATOR_RE = /@\w+\.(get|post|put|patch|delete)\s*\(/gi;
27
+ const CLASS_RE = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm;
28
+ const INCLUDE_ROUTER_RE = /include_router\s*\(/g;
29
+
30
+ function listRgFiles(dir, globs) {
31
+ try {
32
+ const out = execFileSync('rg', ['--files', ...globs.flatMap((g) => ['-g', g]), ...EXCLUDE_GLOBS.flatMap((g) => ['-g', g]), dir], { encoding: 'utf8' });
33
+ return out.split('\n').filter(Boolean).sort(); // O6: rg --files order isn't guaranteed -- see java-spring.mjs's listJavaFiles.
34
+ } catch {
35
+ return []; // rg exits 1 on "no files matched" -- not an error, just nothing to report
36
+ }
37
+ }
38
+
39
+ function byShallowestThenName(a, b) {
40
+ const depthA = a.split(path.sep).length;
41
+ const depthB = b.split(path.sep).length;
42
+ return depthA !== depthB ? depthA - depthB : a.localeCompare(b);
43
+ }
44
+
45
+ function listCandidateProjectFiles(repoRoot) {
46
+ return listRgFiles(repoRoot, PROJECT_FILE_GLOBS).sort(byShallowestThenName);
47
+ }
48
+
49
+ // Returns the Python project root (the analogue of java-spring's srcRoot) or null. Two independent
50
+ // signals, both required, mirroring java-spring's own "build file AND src layout" combined bar:
51
+ // (a) a dependency declaration naming fastapi, (b) at least one .py file that actually imports or
52
+ // instantiates it. Genuinely different from java-spring in one respect: this walks the WHOLE repo
53
+ // for candidate project files rather than assuming repoRoot itself is the project root, because a
54
+ // real target (the oracle) is a monorepo whose FastAPI project lives under backend/ -- verified to
55
+ // resolve correctly.
56
+ export function detectPythonFastApiRoot(repoRoot) {
57
+ const depFile = listCandidateProjectFiles(repoRoot).find((f) => {
58
+ try {
59
+ return FASTAPI_DEP_RE.test(fs.readFileSync(f, 'utf8'));
60
+ } catch {
61
+ return false;
62
+ }
63
+ });
64
+ if (!depFile) return null;
65
+
66
+ const projectRoot = path.dirname(depFile);
67
+ const sourceFiles = (() => {
68
+ try {
69
+ return execFileSync('rg', [
70
+ '-l', '-e', 'from\\s+fastapi\\s+import', '-e', 'import\\s+fastapi\\b', '-e', 'FastAPI\\(',
71
+ '-g', '*.py', ...EXCLUDE_GLOBS.flatMap((g) => ['-g', g]),
72
+ projectRoot,
73
+ ], { encoding: 'utf8' }).split('\n').filter(Boolean);
74
+ } catch {
75
+ return [];
76
+ }
77
+ })();
78
+ return sourceFiles.length > 0 ? projectRoot : null;
79
+ }
80
+
81
+ function listPythonFiles(projectRoot) {
82
+ return listRgFiles(projectRoot, ['*.py']);
83
+ }
84
+
85
+ // Walks forward from `openIndex` (text[openIndex] must be '(') tracking paren depth, and returns
86
+ // the index of the matching close paren, or -1. Needed because a decorator's own kwargs routinely
87
+ // nest parens (`dependencies=[Depends(get_current_active_superuser)]`, confirmed in the real
88
+ // oracle) -- a non-greedy `[\s\S]*?\)` regex would stop at the FIRST close paren (Depends(...)'s
89
+ // own), truncating the argument text before response_model=/the path literal are even reached.
90
+ function matchBalancedParens(text, openIndex) {
91
+ let depth = 0;
92
+ for (let i = openIndex; i < text.length; i++) {
93
+ if (text[i] === '(') depth++;
94
+ else if (text[i] === ')') {
95
+ depth--;
96
+ if (depth === 0) return i;
97
+ }
98
+ }
99
+ return -1;
100
+ }
101
+
102
+
103
+ function joinPath(base, segment) {
104
+ const b = (base || '').replace(/\/$/, '');
105
+ const s = (segment || '').replace(/^\//, '');
106
+ return s ? `${b}/${s}` : (b || '/');
107
+ }
108
+
109
+ function capitalize(s) {
110
+ return s.length > 0 ? s[0].toUpperCase() + s.slice(1) : s;
111
+ }
112
+
113
+ // `router = APIRouter(prefix="/items", tags=["items"])` -- balanced-paren scan (not a simple
114
+ // regex) for the same nested-paren reason as endpoint extraction. Router-local only, never
115
+ // resolved against a global prefix applied elsewhere (e.g. `include_router(prefix=...)`) -- that
116
+ // asymmetry is exactly what `pathPrefixSignals`/`unknowns` exists to flag, same role java-spring's
117
+ // detectGlobalPathPrefixSignals plays for `configurePathMatch`/`context-path`.
118
+ function extractBasePath(text) {
119
+ const m = text.match(/APIRouter\s*\(/);
120
+ if (!m) return '';
121
+ const openIdx = m.index + m[0].length - 1;
122
+ const closeIdx = matchBalancedParens(text, openIdx);
123
+ if (closeIdx === -1) return '';
124
+ const prefixMatch = text.slice(openIdx + 1, closeIdx).match(/prefix\s*=\s*["']([^"']*)["']/);
125
+ return prefixMatch ? prefixMatch[1] : '';
126
+ }
127
+
128
+ // `operationId` is always null -- see the adapter's own `api.operations: false` and
129
+ // D-fastapi-adapter in DECISIONS.md: FastAPI generates operation ids at request-handling time
130
+ // (per-project, sometimes via a custom `generate_unique_id_function`), never pinned in source the
131
+ // way `@Operation(operationId=...)` is for Java, so there is nothing honest to statically correlate.
132
+ function extractEndpoints(text) {
133
+ const endpoints = [];
134
+ for (const m of text.matchAll(VERB_DECORATOR_RE)) {
135
+ const verb = m[1].toUpperCase();
136
+ const openIdx = m.index + m[0].length - 1;
137
+ const closeIdx = matchBalancedParens(text, openIdx);
138
+ if (closeIdx === -1) continue;
139
+ const argsText = text.slice(openIdx + 1, closeIdx);
140
+ const pathMatch = argsText.match(/^\s*["']([^"']*)["']/); // first positional arg
141
+ if (!pathMatch) continue; // no path literal (e.g. built dynamically) -- skip rather than guess
142
+
143
+ const afterDecoratorRe = /\s*(?:async\s+)?def\s+(\w+)\s*\(/y;
144
+ afterDecoratorRe.lastIndex = closeIdx + 1;
145
+ const funcMatch = afterDecoratorRe.exec(text);
146
+ if (!funcMatch) continue;
147
+
148
+ endpoints.push({ verb, path: pathMatch[1], operationId: null, method: funcMatch[1], line: lineNumberAt(text, m.index) });
149
+ }
150
+ return endpoints;
151
+ }
152
+
153
+ // SQLModel `class X(<bases>, table=True):` -- table name is the lowercased class name (SQLModel's
154
+ // own default when no explicit `__tablename__` is declared; cross-checked against the real
155
+ // oracle's own Alembic migration, `op.create_table("user", ...)`/`op.create_table("item", ...)`,
156
+ // not assumed). `idField` search is scoped to just this class's body (from its own `:` to the next
157
+ // top-level `class` or end of file) -- a file-wide search would find the WRONG class's primary key
158
+ // when more than one table class lives in the same file (the real oracle's models.py has several).
159
+ function extractTableEntities(text, file) {
160
+ const classMatches = [...text.matchAll(CLASS_RE)];
161
+ const entities = [];
162
+ for (let i = 0; i < classMatches.length; i++) {
163
+ const m = classMatches[i];
164
+ if (!/table\s*=\s*True/.test(m[2])) continue;
165
+ const bodyStart = m.index + m[0].length;
166
+ const bodyEnd = i + 1 < classMatches.length ? classMatches[i + 1].index : text.length;
167
+ const body = text.slice(bodyStart, bodyEnd);
168
+ const idMatch = body.match(/(\w+)\s*:[^=\n]+=\s*Field\([^)]*primary_key\s*=\s*True/);
169
+ entities.push({ className: m[1], table: m[1].toLowerCase(), idField: idMatch ? idMatch[1] : null, file, line: lineNumberAt(text, m.index) });
170
+ }
171
+ return entities;
172
+ }
173
+
174
+ // A1 §7 equivalent for FastAPI: `include_router(router, prefix=X)` applies a prefix the
175
+ // per-router-file scan above cannot see (each file is read independently, with no idea another
176
+ // file mounts it under a further prefix). Two-step resolution when X is a variable/attribute
177
+ // reference (e.g. `prefix=settings.API_V1_STR`), mirroring java-spring's
178
+ // `configurePathMatch`->`addPathPrefix` two-step exactly: find the literal assignment elsewhere in
179
+ // the project. This function can't CORRECT anything (only --openapi-file's real-document
180
+ // reconciliation can) -- it exists so scanners/index.mjs's `unknowns` note points a user who
181
+ // doesn't know --openapi-file/--path-prefix exist at the real defect.
182
+ function extractIncludeRouterPrefixSignals(repoRoot, files) {
183
+ const signals = [];
184
+ const fileTextCache = new Map();
185
+ const readFile = (f) => {
186
+ if (!fileTextCache.has(f)) fileTextCache.set(f, fs.readFileSync(f, 'utf8'));
187
+ return fileTextCache.get(f);
188
+ };
189
+
190
+ for (const file of files) {
191
+ const text = readFile(file);
192
+ for (const m of text.matchAll(INCLUDE_ROUTER_RE)) {
193
+ const openIdx = m.index + m[0].length - 1;
194
+ const closeIdx = matchBalancedParens(text, openIdx);
195
+ if (closeIdx === -1) continue;
196
+ const argsText = text.slice(openIdx + 1, closeIdx);
197
+
198
+ const literalMatch = argsText.match(/prefix\s*=\s*["']([^"']*)["']/);
199
+ if (literalMatch) {
200
+ signals.push({ kind: 'include_router-prefix', file: path.relative(repoRoot, file), prefix: literalMatch[1] });
201
+ continue;
202
+ }
203
+ const varMatch = argsText.match(/prefix\s*=\s*([\w.]+)/);
204
+ if (!varMatch) continue;
205
+ const varName = varMatch[1].split('.').pop();
206
+ const assignRe = new RegExp(`\\b${varName}\\s*:?[^=\\n]*=\\s*["']([^"']*)["']`);
207
+ for (const otherFile of files) {
208
+ const assign = readFile(otherFile).match(assignRe);
209
+ if (assign) {
210
+ signals.push({ kind: 'include_router-prefix', file: path.relative(repoRoot, file), prefix: assign[1], via: varMatch[1] });
211
+ break;
212
+ }
213
+ }
214
+ }
215
+ }
216
+ return signals;
217
+ }
218
+
219
+ // D-fastapi-adapter: paths are router-local (see extractBasePath); FastAPI generates operation ids
220
+ // at runtime, never pinned in source -- see extractEndpoints. --openapi-file + --path-prefix is the
221
+ // trustworthy path (contracts/openapi.mjs's existing, adapter-agnostic reconciliation).
222
+ const API_SURFACE_SOURCE = 'router-local paths only (this scan does not resolve a global prefix applied via ' +
223
+ 'include_router(prefix=...) beyond a simple literal/single-variable lookup -- see unknowns below if one ' +
224
+ 'was found) -- FastAPI generates operation ids at request-handling time (per-project, sometimes via a ' +
225
+ 'custom generate_unique_id_function), so they are never statically derivable here. Pass a real OpenAPI ' +
226
+ 'document via `bskel contract emit --openapi-file <path> --path-prefix <prefix>` for trustworthy ' +
227
+ 'operation identity and schemas.';
228
+
229
+ export function scanPythonFastApi(repoRoot, projectRoot) {
230
+ const files = listPythonFiles(projectRoot);
231
+ const modules = new Map();
232
+ const moduleEntry = (name) => {
233
+ if (!modules.has(name)) modules.set(name, { module: name, controllers: [], entities: [], enums: [], dtos: [] });
234
+ return modules.get(name);
235
+ };
236
+
237
+ const allEntities = [];
238
+ for (const file of files) {
239
+ const text = fs.readFileSync(file, 'utf8');
240
+
241
+ if (/APIRouter\s*\(/.test(text)) {
242
+ // module = filename stem, NOT the router's own prefix -- verified against the real oracle's
243
+ // login.py, which declares `APIRouter(tags=["login"])` with no prefix at all, so a
244
+ // prefix-derived name fails on a real file while the filename stem works for every one.
245
+ const moduleName = path.basename(file, '.py');
246
+ const basePath = extractBasePath(text);
247
+ const endpoints = extractEndpoints(text).map((ep) => ({ ...ep, path: joinPath(basePath, ep.path) }));
248
+ if (endpoints.length > 0) {
249
+ moduleEntry(moduleName).controllers.push({ className: `${capitalize(moduleName)}Router`, basePath, operationIds: [], endpoints, file });
250
+ }
251
+ }
252
+
253
+ allEntities.push(...extractTableEntities(text, file));
254
+ }
255
+
256
+ // Entity -> module assignment: this repo's real layout has no domain/<module>/ folder (all
257
+ // SQLModel classes live in one flat models.py), so java-spring's path-segment convention
258
+ // (moduleOf()) doesn't apply. Narrow name-match instead of general pluralization: `Item`
259
+ // attaches to a real `items` module, `User` to `users` -- exact singular or singular+'s' only.
260
+ // An unmatched table class (no route module shares its name) goes to a `_models` bucket rather
261
+ // than being silently dropped.
262
+ for (const entity of allEntities) {
263
+ const lower = entity.className.toLowerCase();
264
+ const candidates = new Set([lower, `${lower}s`]);
265
+ const targetModule = [...modules.keys()].find((name) => candidates.has(name));
266
+ moduleEntry(targetModule ?? '_models').entities.push(entity);
267
+ }
268
+
269
+ return {
270
+ modules: [...modules.values()],
271
+ pathPrefixSignals: extractIncludeRouterPrefixSignals(repoRoot, files),
272
+ apiSurfaceSource: API_SURFACE_SOURCE,
273
+ // S2 (D-gate-precision, continued): repo-relative, matching every other manifest-shaped
274
+ // gate input in this codebase.
275
+ filesRead: files.map((f) => path.relative(repoRoot, f)),
276
+ };
277
+ }
278
+
279
+ // G2: adapter descriptor consumed by scanners/registry.mjs -- see D-adapter-registry (G1) and
280
+ // D-fastapi-adapter (G2) in DECISIONS.md. `id` must equal this file's stem ("python-fastapi").
281
+ //
282
+ // specificity 90, deliberately BELOW java-spring's 100 (not equal) -- same class of signal
283
+ // (dependency declaration AND source-confirmed, exactly java-spring's "build file AND src layout"
284
+ // bar), but a polyglot monorepo containing BOTH a Spring build file+src/main/java AND a FastAPI
285
+ // pyproject.toml must resolve deterministically rather than hitting runScan()'s "ambiguous adapter
286
+ // selection" hard error, which is what specificity 100 would cause. java-spring still wins that
287
+ // tie -- both adapters now declare codegen.handles:true (G4), so the actual reason is just "one of
288
+ // them has to win, and it must be the same one every time" -- not a functional gap on this side
289
+ // anymore, a real, documented trade-off (see DECISIONS.md), checkable via `bskel doctor`.
290
+ export const adapter = {
291
+ contract: 'sbf.adapter/1',
292
+ id: 'python-fastapi',
293
+ title: 'Python / FastAPI',
294
+ specificity: 90,
295
+ confidence: 'high',
296
+ capabilities: {
297
+ // false: FastAPI generates operation ids at runtime (per-project, sometimes customized) --
298
+ // never statically derivable from source. --openapi-file is the honest path forward; see
299
+ // CAPABILITY_SATISFIERS in scanners/capabilities.mjs.
300
+ 'api.operations': false,
301
+ // false: contracts/emit.mjs's detectRequestBody() is a Java-only regex -- declaring true
302
+ // would be dishonest. Costs only `body:'unknown'` (WARN, waivable); the real request schema
303
+ // still arrives via --openapi-file's existing, adapter-agnostic schema projection (A2).
304
+ 'api.request-shape': false,
305
+ // true: table/idField ARE genuinely extracted and cross-checked against the real oracle's
306
+ // own Alembic migration. `table` itself is no longer load-bearing for codegen (the Python
307
+ // provider re-derives SQLModel's read path from idField + the class itself, not the table
308
+ // name) -- idField and the entity's own GET route are what `handles plan`/`emit` actually
309
+ // depend on now.
310
+ 'resource.fetch': true,
311
+ // true (G4): handles/providers/python-fastapi/ is a real, executed-and-verified codegen
312
+ // provider -- see D-handles-providers in DECISIONS.md for what it generates, what it always
313
+ // stubs (check_access, patch_field), and what it deliberately excludes (recover(), migration).
314
+ 'codegen.handles': true,
315
+ },
316
+ detect: detectPythonFastApiRoot,
317
+ scan(repoRoot, detection) {
318
+ return scanPythonFastApi(repoRoot, detection);
319
+ },
320
+ // S2 (D-gate-precision, continued): same listPythonFiles() call scan() itself makes, via the
321
+ // same detectPythonFastApiRoot() this adapter's own detect() already uses.
322
+ listReadSet(repoRoot) {
323
+ const projectRoot = detectPythonFastApiRoot(repoRoot);
324
+ if (!projectRoot) return [];
325
+ return listPythonFiles(projectRoot).map((f) => path.relative(repoRoot, f));
326
+ },
327
+ diagnostics(repoRoot) {
328
+ const messages = [];
329
+ const depFiles = listCandidateProjectFiles(repoRoot);
330
+ if (depFiles.length === 0) {
331
+ messages.push({ level: 'info', code: 'no-python-project-file', message: `none of ${PROJECT_FILE_GLOBS.join(', ')} found` });
332
+ } else if (!depFiles.some((f) => {
333
+ try { return FASTAPI_DEP_RE.test(fs.readFileSync(f, 'utf8')); } catch { return false; }
334
+ })) {
335
+ messages.push({ level: 'info', code: 'fastapi-not-a-dependency', message: `found ${depFiles.length} Python project file(s), but none declare a fastapi dependency` });
336
+ }
337
+ let rgOk = true;
338
+ try {
339
+ execFileSync('rg', ['--version'], { stdio: 'pipe' });
340
+ } catch {
341
+ rgOk = false;
342
+ }
343
+ if (!rgOk) {
344
+ messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it and will throw, not degrade, if it is missing' });
345
+ }
346
+ return messages;
347
+ },
348
+ };
@@ -0,0 +1,299 @@
1
+ // G5 (D-typescript-express-provider): the third scanner adapter, alongside java-spring.mjs (G1)
2
+ // and python-fastapi.mjs (G2) -- same philosophy (ripgrep-for-discovery + regex-for-structure,
3
+ // no real TS AST parser/tsc shell-out). Unlike G2, no framework-maintained reference oracle
4
+ // exists for Express (deliberately unopinionated framework, confirmed via real research before
5
+ // this file was written) -- verified instead against the best-validated real community boilerplate
6
+ // found (`mkosir/typeorm-express-typescript`, 461 stars/149 forks, not a fork itself, freshly
7
+ // cloned and read). See D-typescript-express-provider in DECISIONS.md for why this item's
8
+ // verification confidence is honestly, permanently weaker than G2's own.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { lineNumberAt } from '../text-util.mjs';
12
+ // G6: these were this file's own private helpers until `javascript-express.mjs` needed the exact
13
+ // same ones -- moved verbatim to `_express-shared.mjs` (a `_`-prefixed shared helper, the same
14
+ // convention `_java-spring-analyzer.mjs` uses) rather than copy-pasted. No behavior change; see
15
+ // D-javascript-express-adapter in DECISIONS.md for why only these primitives are shared and the
16
+ // mount-tree/endpoint logic deliberately is not.
17
+ import {
18
+ VERBS,
19
+ STRING_LITERAL_RE,
20
+ listRgFiles,
21
+ rgFilesMatching,
22
+ listCandidatePackageFiles,
23
+ declaresExpress,
24
+ matchBalancedParens,
25
+ splitTopLevelArgs,
26
+ joinPath,
27
+ maskJsComments,
28
+ expressDiagnostics,
29
+ } from './_express-shared.mjs';
30
+
31
+ const VERB_CALL_RE = new RegExp(`\\brouter\\.(${VERBS.join('|')})\\s*\\(`, 'gi');
32
+ const ROUTER_USE_RE = /\brouter\.use\s*\(/g;
33
+ const ENTITY_CLASS_RE = /@Entity\s*\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)\s*\n?\s*export\s+class\s+(\w+)/g;
34
+
35
+ // Two independent signals required, mirroring java-spring's "build file AND src layout" /
36
+ // python-fastapi's "dependency declared AND source-confirmed" combined bar: (a) package.json
37
+ // declares express, (b) at least one .ts file actually imports Router from 'express' and calls
38
+ // Router(). Walks the whole repo for candidate package.json files (not just repoRoot) the same
39
+ // way python-fastapi does, for the same monorepo reason.
40
+ export function detectTypeScriptExpressRoot(repoRoot) {
41
+ const pkgFile = listCandidatePackageFiles(repoRoot).find((f) => declaresExpress(f));
42
+ if (!pkgFile) return null;
43
+
44
+ const projectRoot = path.dirname(pkgFile);
45
+ const sourceFiles = rgFilesMatching("import\\s*\\{[^}]*\\bRouter\\b[^}]*\\}\\s*from\\s*['\"]express['\"]", ['*.ts'], projectRoot);
46
+ if (sourceFiles.length === 0) return null;
47
+ // G6: `\bRouter\s*\(`, not `\bRouter\s*\(\s*\)` -- `Router({ mergeParams: true })` is ordinary
48
+ // Express, and requiring empty parens made this whole adapter fail to detect a repo whose
49
+ // routers all pass options. A strict widening of the SECOND half of an already-conjunctive
50
+ // signal (the first half still requires a named `Router` import from 'express'), and there is
51
+ // no word boundary inside `makeRouter(`, so this cannot match an unrelated factory.
52
+ const callsRouter = sourceFiles.some((f) => {
53
+ try {
54
+ return /\bRouter\s*\(/.test(maskJsComments(fs.readFileSync(f, 'utf8')));
55
+ } catch {
56
+ return false;
57
+ }
58
+ });
59
+ return callsRouter ? projectRoot : null;
60
+ }
61
+
62
+ function listTypeScriptFiles(projectRoot) {
63
+ return listRgFiles(projectRoot, ['*.ts']);
64
+ }
65
+
66
+ // No path prefix is ever visible at a route-registration call site in this idiom (unlike
67
+ // `@RequestMapping`/`APIRouter(prefix=...)`) -- confirmed in the real oracle: `routes/v1/users.ts`
68
+ // itself declares no base path anywhere; the real absolute path only exists as the concatenation
69
+ // of `router.use('/literal', subRouter)` mount edges from a graph root down to the leaf file. This
70
+ // extracts just the LOCAL endpoints (verb/path/handler/line) with an EMPTY prefix -- the mount-tree
71
+ // walk in scanTypeScriptExpress() below joins the real prefix chain afterward.
72
+ function extractEndpoints(text) {
73
+ const endpoints = [];
74
+ for (const m of text.matchAll(VERB_CALL_RE)) {
75
+ const verb = m[1].toUpperCase();
76
+ const openIdx = m.index + m[0].length - 1;
77
+ const closeIdx = matchBalancedParens(text, openIdx);
78
+ if (closeIdx === -1) continue;
79
+ const argsText = text.slice(openIdx + 1, closeIdx);
80
+ const pathMatch = argsText.match(STRING_LITERAL_RE);
81
+ if (!pathMatch) continue; // no path literal (e.g. built dynamically) -- skip rather than guess
82
+
83
+ const args = splitTopLevelArgs(argsText);
84
+ const lastArg = args[args.length - 1]?.trim();
85
+ // A bare identifier only -- an inline arrow-function handler has no name to correlate to a
86
+ // controller file, so it's skipped rather than guessed at (same discipline as FastAPI's own
87
+ // "no path literal -> skip").
88
+ const handlerMatch = lastArg?.match(/^(\w+)$/);
89
+ if (!handlerMatch) continue;
90
+
91
+ endpoints.push({ verb, path: pathMatch[1], operationId: null, method: handlerMatch[1], line: lineNumberAt(text, m.index) });
92
+ }
93
+ return endpoints;
94
+ }
95
+
96
+ // Resolves a bare specifier's own file on disk, extension-probed the same way Node's own resolver
97
+ // would for a relative TS import (`./x` -> `./x.ts` or `./x/index.ts`). Returns null, never
98
+ // guesses, if neither exists.
99
+ function resolveRelativeImport(fromFile, specifier) {
100
+ if (!specifier.startsWith('.')) return null; // only relative imports resolve mount edges -- see below
101
+ const base = path.resolve(path.dirname(fromFile), specifier);
102
+ for (const candidate of [`${base}.ts`, path.join(base, 'index.ts')]) {
103
+ if (fs.existsSync(candidate)) return candidate;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ // Builds the router mount-tree: for every file with `export default router` (or `export default
109
+ // <name>` where <name> was assigned `= Router()`), finds every `router.use('/literal', identifier)`
110
+ // edge and resolves `identifier` via THAT FILE'S OWN relative `import` statement only -- bare/
111
+ // baseUrl-relative specifiers (`'controllers/users'`) are deliberately not resolved here, only for
112
+ // router-to-router mounts, which the real oracle confirms are always relative (`import v1 from
113
+ // './v1/'`). A file with no incoming edge is a root. Bounded, not general: a computed/dynamic mount
114
+ // (`router.use(prefix, buildRouter())`) is skipped, never guessed at.
115
+ function buildMountEdges(files, fileTexts) {
116
+ const edges = []; // { fromFile, toFile, prefix }
117
+ for (const file of files) {
118
+ const text = fileTexts.get(file);
119
+ for (const m of text.matchAll(ROUTER_USE_RE)) {
120
+ const openIdx = m.index + m[0].length - 1;
121
+ const closeIdx = matchBalancedParens(text, openIdx);
122
+ if (closeIdx === -1) continue;
123
+ const args = splitTopLevelArgs(text.slice(openIdx + 1, closeIdx));
124
+ if (args.length !== 2) continue; // single-arg router.use(subRouter) is a page/catch-all mount, not a prefixed module
125
+ const pathMatch = args[0].match(STRING_LITERAL_RE);
126
+ const identMatch = args[1].match(/^(\w+)$/);
127
+ if (!pathMatch || !identMatch) continue;
128
+
129
+ const importRe = new RegExp(`import\\s+${identMatch[1]}\\s+from\\s*["']([^"']+)["']`);
130
+ const importMatch = text.match(importRe);
131
+ if (!importMatch) continue;
132
+ const toFile = resolveRelativeImport(file, importMatch[1]);
133
+ if (!toFile || !files.includes(toFile)) continue;
134
+
135
+ edges.push({ fromFile: file, toFile, prefix: pathMatch[1] });
136
+ }
137
+ }
138
+ return edges;
139
+ }
140
+
141
+ // Prefix chain from a mount-tree root down to `file`, or '' if `file` is itself a root (no
142
+ // incoming edge) -- a file mounted through more than one path (unusual, not seen in the real
143
+ // oracle) uses whichever edge is found first, a documented, narrow limitation rather than
144
+ // resolving every possible path.
145
+ function prefixChainFor(file, edges) {
146
+ const incoming = edges.find((e) => e.toFile === file);
147
+ if (!incoming) return '';
148
+ return joinPath(prefixChainFor(incoming.fromFile, edges), incoming.prefix);
149
+ }
150
+
151
+ // `@Entity('users') export class User { @PrimaryGeneratedColumn() id: number; ... }` -- table name
152
+ // is the lowercased class name when @Entity() carries no literal argument (TypeORM's own default,
153
+ // mirroring SQLModel's identical default-naming precedent already used for python-fastapi). idField
154
+ // search is scoped to just this class's body (its own `{` to the matching `}`) so a file with more
155
+ // than one entity class never finds the WRONG class's primary key.
156
+ function extractTableEntities(text, file) {
157
+ const entities = [];
158
+ for (const m of text.matchAll(ENTITY_CLASS_RE)) {
159
+ const bodyOpen = text.indexOf('{', m.index + m[0].length);
160
+ if (bodyOpen === -1) continue;
161
+ let depth = 0;
162
+ let bodyClose = -1;
163
+ for (let i = bodyOpen; i < text.length; i++) {
164
+ if (text[i] === '{') depth++;
165
+ else if (text[i] === '}') {
166
+ depth--;
167
+ if (depth === 0) { bodyClose = i; break; }
168
+ }
169
+ }
170
+ if (bodyClose === -1) continue;
171
+ const body = text.slice(bodyOpen, bodyClose);
172
+ // @PrimaryGeneratedColumn('uuid') id: string; vs. the bare/default form (an auto-incrementing
173
+ // integer, TypeORM's own default with no argument). This handle system's own token format
174
+ // (kind:type:UUID[:pointer], see handles/codec.mjs's HANDLE_RE) can only ever address a
175
+ // UUID-shaped resource identifier -- an integer primary key genuinely cannot be reached
176
+ // through it, not a TypeScript-specific limitation. Found live via a real `tsc --noEmit` type
177
+ // error before this distinction was tracked at all (the real oracle's own User entity uses
178
+ // the bare/integer form).
179
+ // `!` after the identifier (TypeScript's definite-assignment assertion, `id!: string;`) is
180
+ // real, common TypeORM+strict-mode syntax -- found live when the fixture's own entity used it
181
+ // (strict mode's strictPropertyInitialization otherwise rejects a decorator-initialized class
182
+ // field with no constructor assignment) and a first regex draft without `!?` silently failed
183
+ // to find the id field at all.
184
+ const idMatch = body.match(/@PrimaryGeneratedColumn\s*\(([^)]*)\)\s*\n?\s*(\w+)\s*!?\s*:/);
185
+ const className = m[2];
186
+ entities.push({
187
+ className,
188
+ table: m[1] || className.toLowerCase(),
189
+ idField: idMatch ? idMatch[2] : null,
190
+ idFieldIsUuid: idMatch ? /['"]uuid['"]/.test(idMatch[1]) : false,
191
+ file,
192
+ line: lineNumberAt(text, m.index),
193
+ });
194
+ }
195
+ return entities;
196
+ }
197
+
198
+ const API_SURFACE_SOURCE = 'route paths are resolved by walking the router mount-tree (router.use(\'/literal\', ' +
199
+ 'subRouter) edges through RELATIVE imports only -- a computed/dynamic mount is skipped, never guessed) -- ' +
200
+ 'plain Express has no operationId concept at all (weaker than FastAPI, which at least generates one at ' +
201
+ 'runtime), so they are never statically derivable here. Pass a real OpenAPI document via ' +
202
+ '`bskel contract emit --openapi-file <path> --path-prefix <prefix>` for trustworthy operation identity, ' +
203
+ 'if this target app has one (most plain Express apps do not auto-generate one the way FastAPI does).';
204
+
205
+ export function scanTypeScriptExpress(repoRoot, projectRoot) {
206
+ const files = listTypeScriptFiles(projectRoot);
207
+ // G6: masked, the same way javascript-express.mjs masks its own sources. Without this a
208
+ // commented-out `// router.get('/old', oldHandler)` -- or prose quoting a route registration --
209
+ // is extracted and reported as a LIVE endpoint. Same defect class A2 Phase 1's `maskNonCode()`
210
+ // fixed for Java (D-java-analyzer's phantom-operationId bug); found while building G6's
211
+ // adapter, where a fixture's own header comment collapsed the entire mount graph. String
212
+ // literals are left intact, so every path/table VALUE this adapter reports is unchanged.
213
+ const fileTexts = new Map(files.map((f) => [f, maskJsComments(fs.readFileSync(f, 'utf8'))]));
214
+ const edges = buildMountEdges(files, fileTexts);
215
+
216
+ const modules = new Map();
217
+ const moduleEntry = (name) => {
218
+ if (!modules.has(name)) modules.set(name, { module: name, controllers: [], entities: [], enums: [], dtos: [] });
219
+ return modules.get(name);
220
+ };
221
+
222
+ const allEntities = [];
223
+ for (const file of files) {
224
+ const text = fileTexts.get(file);
225
+ // G6: `\bRouter\s*\(` -- see detectTypeScriptExpressRoot above. Same widening for the same
226
+ // reason: a router declared as `Router({ mergeParams: true })` is ordinary Express, and
227
+ // this per-file gate previously skipped its whole file.
228
+ if (/\bRouter\s*\(/.test(text) && /\brouter\.\w+\s*\(/.test(text)) {
229
+ const localEndpoints = extractEndpoints(text);
230
+ if (localEndpoints.length > 0) {
231
+ const prefix = prefixChainFor(file, edges);
232
+ const moduleName = path.basename(file, '.ts');
233
+ const endpoints = localEndpoints.map((ep) => ({ ...ep, path: joinPath(prefix, ep.path) }));
234
+ const className = `${moduleName.charAt(0).toUpperCase()}${moduleName.slice(1)}Router`;
235
+ moduleEntry(moduleName).controllers.push({ className, basePath: prefix, operationIds: [], endpoints, file });
236
+ }
237
+ }
238
+ allEntities.push(...extractTableEntities(text, file));
239
+ }
240
+
241
+ // Entity -> module assignment: narrow name-match (exact singular or singular+'s'), same
242
+ // precedent as python-fastapi's own -- an unmatched entity goes to a `_models` bucket rather
243
+ // than being silently dropped.
244
+ for (const entity of allEntities) {
245
+ const lower = entity.className.toLowerCase();
246
+ const candidates = new Set([lower, `${lower}s`]);
247
+ const targetModule = [...modules.keys()].find((name) => candidates.has(name));
248
+ moduleEntry(targetModule ?? '_models').entities.push(entity);
249
+ }
250
+
251
+ return {
252
+ modules: [...modules.values()],
253
+ pathPrefixSignals: [],
254
+ apiSurfaceSource: API_SURFACE_SOURCE,
255
+ filesRead: files.map((f) => path.relative(repoRoot, f)),
256
+ };
257
+ }
258
+
259
+ // G5 (D-typescript-express-provider): adapter descriptor consumed by scanners/registry.mjs. `id`
260
+ // must equal this file's stem ("typescript-express").
261
+ //
262
+ // specificity 85 -- distinct from java-spring's 100 and python-fastapi's 90, same combined-signal
263
+ // strength as both (package.json dependency AND source-confirmed), a real documented trade-off
264
+ // (not an inherent "TypeScript signals are weaker" claim) so a polyglot repo's adapter selection
265
+ // stays deterministic, checkable via `bskel doctor`.
266
+ export const adapter = {
267
+ contract: 'sbf.adapter/1',
268
+ id: 'typescript-express',
269
+ title: 'TypeScript / Express / TypeORM',
270
+ specificity: 85,
271
+ confidence: 'high',
272
+ capabilities: {
273
+ // false: plain Express has no operationId concept at all. --openapi-file is the honest path
274
+ // forward for an app that has one; see CAPABILITY_SATISFIERS in scanners/capabilities.mjs.
275
+ 'api.operations': false,
276
+ // false: contracts/emit.mjs's detectRequestBody() is a Java-only regex -- declaring true
277
+ // would be dishonest. Costs only body:'unknown' (WARN, waivable).
278
+ 'api.request-shape': false,
279
+ // true: table/idField are genuinely, statically extracted from @Entity()/
280
+ // @PrimaryGeneratedColumn(). An app using Prisma/Sequelize/Drizzle instead of TypeORM simply
281
+ // yields zero entities at scan time, not a detect() failure or a capability lie.
282
+ 'resource.fetch': true,
283
+ // true (G5): handles/providers/typescript-express/ is a real, executed-and-verified codegen
284
+ // provider -- see D-typescript-express-provider in DECISIONS.md.
285
+ 'codegen.handles': true,
286
+ },
287
+ detect: detectTypeScriptExpressRoot,
288
+ scan(repoRoot, detection) {
289
+ return scanTypeScriptExpress(repoRoot, detection);
290
+ },
291
+ listReadSet(repoRoot) {
292
+ const projectRoot = detectTypeScriptExpressRoot(repoRoot);
293
+ if (!projectRoot) return [];
294
+ return listTypeScriptFiles(projectRoot).map((f) => path.relative(repoRoot, f));
295
+ },
296
+ diagnostics(repoRoot) {
297
+ return expressDiagnostics(repoRoot);
298
+ },
299
+ };