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.
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- 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
|
+
};
|