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,422 @@
|
|
|
1
|
+
// G6 (D-javascript-express-adapter): the fourth first-class scanner adapter -- plain-JavaScript
|
|
2
|
+
// ESM Express, with NO ORM and NO TypeScript anywhere. Sibling of `typescript-express.mjs` (G5),
|
|
3
|
+
// not a generalization of it: they share the low-level Express primitives (`_express-shared.mjs`)
|
|
4
|
+
// and deliberately do NOT share endpoint/mount-tree extraction, because a plain-JS app's routing
|
|
5
|
+
// is written differently in three ways that each break G5's own regexes (see below).
|
|
6
|
+
//
|
|
7
|
+
// This exists because a real production backend -- an `express.Router()` app on AWS Lambda
|
|
8
|
+
// (nodejs20.x) behind a one-line `serverless-http` wrapper -- was completely invisible to `bskel`:
|
|
9
|
+
// `typescript-express`'s detect() greps `-g '*.ts'` only, so a repo with zero `.ts` files fell all
|
|
10
|
+
// the way through to the low-confidence `generic-grep` fallback.
|
|
11
|
+
//
|
|
12
|
+
// THREE real divergences from G5, each grounded in what plain-JS Express code actually looks like,
|
|
13
|
+
// not anticipated defensively:
|
|
14
|
+
// 1. `import express from 'express'; const r = express.Router()` is the dominant plain-JS idiom.
|
|
15
|
+
// G5's detect() requires a NAMED `import { Router } from 'express'`, which a repo using only
|
|
16
|
+
// the default import never has. Both forms are accepted here.
|
|
17
|
+
// 2. The router variable is not always called `router`. G5 hardcodes the identifier in
|
|
18
|
+
// `/\brouter\.use\s*\(/` and `/\brouter\.(get|...)\(/`; the real target app's own entry file
|
|
19
|
+
// declares `const route = express.Router()`. This adapter binds whatever name the file
|
|
20
|
+
// actually declares.
|
|
21
|
+
// 3. The global path prefix routinely lives on an INTRA-FILE edge from the `express()`
|
|
22
|
+
// application to a locally-declared Router (`app.use('/api', route)`) -- no import involved,
|
|
23
|
+
// so G5's file-to-file edge model cannot represent it and would silently drop `/api` from
|
|
24
|
+
// every route below it. Mount-tree nodes here are (file, variable) pairs, not files.
|
|
25
|
+
//
|
|
26
|
+
// **`codegen.handles` is false, and that is the whole shipped scope.** There is no
|
|
27
|
+
// `handles/providers/javascript-express/`. See D-javascript-express-adapter's EXCLUDED section in
|
|
28
|
+
// DECISIONS.md for the measured reason (raw `mysql2`/`mariadb` SQL string literals carry no
|
|
29
|
+
// trustworthy table/primary-key/column-allow-list metadata), and D-fastapi-adapter (G2) for the
|
|
30
|
+
// precedent: a real scanner adapter with zero codegen is a legitimate shipped state.
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { lineNumberAt } from '../text-util.mjs';
|
|
34
|
+
import {
|
|
35
|
+
VERBS,
|
|
36
|
+
STRING_LITERAL_RE,
|
|
37
|
+
listRgFiles,
|
|
38
|
+
rgFilesMatching,
|
|
39
|
+
listCandidatePackageFiles,
|
|
40
|
+
declaresExpress,
|
|
41
|
+
readPackageJson,
|
|
42
|
+
matchBalancedParens,
|
|
43
|
+
splitTopLevelArgs,
|
|
44
|
+
joinPath,
|
|
45
|
+
maskJsComments,
|
|
46
|
+
expressDiagnostics,
|
|
47
|
+
} from './_express-shared.mjs';
|
|
48
|
+
|
|
49
|
+
// detect()'s ripgrep candidate filter -- deliberately just the `from 'express'` tail, not a whole
|
|
50
|
+
// import statement: rg matches line by line, so a clause spread over several lines would be missed
|
|
51
|
+
// by a fuller pattern. This is only a cheap pre-filter; the masked re-read in detect() is the real
|
|
52
|
+
// gate, so a false positive here costs nothing.
|
|
53
|
+
const FROM_EXPRESS_SRC = "from\\s*['\"]express['\"]";
|
|
54
|
+
const FROM_EXPRESS_RE = /\bfrom\s*['"]express['"]/g;
|
|
55
|
+
|
|
56
|
+
// The exact shapes an express import clause may legally take: `express`, `{ Router }`,
|
|
57
|
+
// `express, { Router }`. Anything else is REFUSED rather than parsed optimistically.
|
|
58
|
+
const IMPORT_CLAUSE_RE = /^(?:([\w$]+))?(?:\s*,\s*)?(?:\{([^}]*)\})?$/;
|
|
59
|
+
|
|
60
|
+
// Node's OWN module-resolution rule, not a heuristic: `.mjs` is unconditionally ESM; `.js` is ESM
|
|
61
|
+
// only when the nearest package.json says `"type": "module"`. A CommonJS app
|
|
62
|
+
// (`const express = require('express')`) is therefore out of scope BY CONSTRUCTION rather than by
|
|
63
|
+
// a separate exclusion check -- its files never match IMPORT_EXPRESS_SRC either way.
|
|
64
|
+
function esmExtensionsFor(pkg) {
|
|
65
|
+
return pkg?.type === 'module' ? ['*.js', '*.mjs'] : ['*.mjs'];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extensionSuffixes(globs) {
|
|
69
|
+
return globs.map((g) => g.replace(/^\*/, '')); // ['*.js','*.mjs'] -> ['.js','.mjs']
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Two independent signals required, the same combined bar java-spring ("build file AND src
|
|
73
|
+
// layout"), python-fastapi ("dependency declared AND source-confirmed") and typescript-express all
|
|
74
|
+
// use: (a) a package.json declares express, (b) at least one ESM source file under it both imports
|
|
75
|
+
// express and calls `Router()` / `<something>.Router()`. Walks the whole repo for candidate
|
|
76
|
+
// package.json files (not just repoRoot) for the same monorepo reason python-fastapi does.
|
|
77
|
+
export function detectJavaScriptExpressRoot(repoRoot) {
|
|
78
|
+
for (const pkgFile of listCandidatePackageFiles(repoRoot)) {
|
|
79
|
+
if (!declaresExpress(pkgFile)) continue;
|
|
80
|
+
const projectRoot = path.dirname(pkgFile);
|
|
81
|
+
const globs = esmExtensionsFor(readPackageJson(pkgFile));
|
|
82
|
+
// rg is a cheap candidate filter over raw bytes and can match inside a comment; the real
|
|
83
|
+
// gate is the masked re-read below, which is why detection needs both the import AND a
|
|
84
|
+
// Router() call to be genuine code.
|
|
85
|
+
const sourceFiles = rgFilesMatching(FROM_EXPRESS_SRC, globs, projectRoot);
|
|
86
|
+
// `\bRouter\s*\(` matches BOTH `Router(...)` and `express.Router(...)` -- there is a word
|
|
87
|
+
// boundary between `.` and `R`, and none inside `makeRouter(`. Not `\(\s*\)`: an options
|
|
88
|
+
// object (`Router({ mergeParams: true })`) is ordinary Express and must still detect.
|
|
89
|
+
const callsRouter = sourceFiles.some((f) => {
|
|
90
|
+
try {
|
|
91
|
+
const masked = maskJsComments(fs.readFileSync(f, 'utf8'));
|
|
92
|
+
return expressBindings(masked) !== null && /\bRouter\s*\(/.test(masked);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (callsRouter) return { projectRoot, globs };
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function listSourceFiles(projectRoot, globs) {
|
|
103
|
+
return listRgFiles(projectRoot, globs);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// What THIS file named its express bindings. `import express, { Router } from 'express'` yields
|
|
107
|
+
// {defaultName: 'express', hasNamedRouter: true}. Returns null when the file doesn't import
|
|
108
|
+
// express at all, which is how non-routing files are skipped without reading them twice.
|
|
109
|
+
//
|
|
110
|
+
// Anchors on `from 'express'` and scans BACKWARD to the nearest `import` keyword, rather than
|
|
111
|
+
// matching a whole `import ... from 'express'` statement forward. A forward
|
|
112
|
+
// `import\s+([^;]*?)\s*from\s*['"]express['"]` looks right and is wrong on two shapes that are
|
|
113
|
+
// both entirely ordinary: semicolon-less ESM (standard.js style), where `[^;]` runs straight
|
|
114
|
+
// through the PREVIOUS import statement and yields a clause like `cors from 'cors'\nimport
|
|
115
|
+
// express`; and a clause spread over several lines. The backward scan handles both, and the
|
|
116
|
+
// strict IMPORT_CLAUSE_RE shape check means an unparseable clause is REFUSED (skipped), never
|
|
117
|
+
// parsed optimistically into a wrong binding name.
|
|
118
|
+
function expressBindings(text) {
|
|
119
|
+
let defaultName = null;
|
|
120
|
+
let hasNamedRouter = false;
|
|
121
|
+
let found = false;
|
|
122
|
+
for (const m of text.matchAll(FROM_EXPRESS_RE)) {
|
|
123
|
+
const before = text.slice(0, m.index);
|
|
124
|
+
const importIdx = before.lastIndexOf('import');
|
|
125
|
+
if (importIdx === -1) continue; // e.g. `export * from 'express'` -- not an import binding
|
|
126
|
+
const clause = before.slice(importIdx + 'import'.length).replace(/\s+/g, ' ').trim();
|
|
127
|
+
const parsed = clause.match(IMPORT_CLAUSE_RE);
|
|
128
|
+
if (!parsed) continue;
|
|
129
|
+
found = true;
|
|
130
|
+
if (parsed[1]) defaultName = parsed[1];
|
|
131
|
+
// `Router as R` aliasing is deliberately NOT resolved -- a documented, narrow limitation
|
|
132
|
+
// (see D-javascript-express-adapter COST), not a silent guess at which local name means
|
|
133
|
+
// Router.
|
|
134
|
+
if (parsed[2] && parsed[2].split(',').some((s) => s.trim() === 'Router')) hasNamedRouter = true;
|
|
135
|
+
}
|
|
136
|
+
return found ? { defaultName, hasNamedRouter } : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Every locally-declared mountable value in this file, with what it is. Both kinds matter: an
|
|
140
|
+
// express() APPLICATION and an express.Router() both mount sub-routers with identical semantics
|
|
141
|
+
// (`app.use(path, router)` and `router.use(path, router)` are the same Express mechanism), and
|
|
142
|
+
// both can carry endpoints directly.
|
|
143
|
+
//
|
|
144
|
+
// Deliberately only `const`/`let`/`var` declarations with a direct call initializer -- a router
|
|
145
|
+
// returned from a factory function (`const r = buildRouter()`) is skipped, never guessed at, the
|
|
146
|
+
// same "bounded, not general" discipline every other cross-file resolution in this codebase uses.
|
|
147
|
+
function declaredMountables(text, bindings) {
|
|
148
|
+
const mountables = new Map(); // varName -> 'router' | 'app'
|
|
149
|
+
// `Router\s*\(` deliberately does NOT require empty parens: `Router({ mergeParams: true })` is
|
|
150
|
+
// completely ordinary Express, and requiring `()` dropped the declaration entirely -- which,
|
|
151
|
+
// here, means the file yields no routes at all rather than merely losing an option. Matching
|
|
152
|
+
// the opening paren is sufficient to identify the variable; the argument list is never read.
|
|
153
|
+
const routerDeclRe = /\b(?:const|let|var)\s+([\w$]+)\s*=\s*(?:[\w$]+\s*\.\s*)?Router\s*\(/g;
|
|
154
|
+
for (const m of text.matchAll(routerDeclRe)) {
|
|
155
|
+
// A bare `Router()` only counts when Router is genuinely imported from express; a
|
|
156
|
+
// `<name>.Router()` member call always counts (that IS the default-import idiom).
|
|
157
|
+
// NOT `$`-anchored: an earlier draft tested `/\.\s*Router\s*\($/` against a match that ends
|
|
158
|
+
// past the member call, so it classified EVERY `express.Router()` as a bare call -- which,
|
|
159
|
+
// with no named `Router` import in the file, dropped the declaration entirely and collapsed
|
|
160
|
+
// the whole mount graph. Found by running the real fixture, not by review.
|
|
161
|
+
const isMemberCall = /[\w$]\s*\.\s*Router\s*\(/.test(m[0]);
|
|
162
|
+
if (isMemberCall || bindings.hasNamedRouter) mountables.set(m[1], 'router');
|
|
163
|
+
}
|
|
164
|
+
if (bindings.defaultName) {
|
|
165
|
+
const appDeclRe = new RegExp(`\\b(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*${bindings.defaultName}\\s*\\(\\s*\\)`, 'g');
|
|
166
|
+
for (const m of text.matchAll(appDeclRe)) mountables.set(m[1], 'app');
|
|
167
|
+
}
|
|
168
|
+
return mountables;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function alternationOf(names) {
|
|
172
|
+
return names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// One node per (file, variable) pair -- see divergence #3 in this file's header.
|
|
176
|
+
function nodeKey(file, varName) {
|
|
177
|
+
return `${file}${varName}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// LOCAL endpoints only (verb/path/handler/line), with an EMPTY prefix -- exactly like G5, because
|
|
181
|
+
// no path prefix is ever visible at an Express route-registration call site. The mount-tree walk in
|
|
182
|
+
// scanJavaScriptExpress() joins the real prefix chain afterward.
|
|
183
|
+
function extractEndpoints(text, mountableNames) {
|
|
184
|
+
if (mountableNames.length === 0) return [];
|
|
185
|
+
const re = new RegExp(`\\b(${alternationOf(mountableNames)})\\.(${VERBS.join('|')})\\s*\\(`, 'gi');
|
|
186
|
+
const endpoints = [];
|
|
187
|
+
for (const m of text.matchAll(re)) {
|
|
188
|
+
const varName = m[1];
|
|
189
|
+
const verb = m[2].toUpperCase();
|
|
190
|
+
const openIdx = m.index + m[0].length - 1;
|
|
191
|
+
const closeIdx = matchBalancedParens(text, openIdx);
|
|
192
|
+
if (closeIdx === -1) continue;
|
|
193
|
+
const argsText = text.slice(openIdx + 1, closeIdx);
|
|
194
|
+
const pathMatch = argsText.match(STRING_LITERAL_RE);
|
|
195
|
+
if (!pathMatch) continue; // no path literal (built dynamically) -- skip rather than guess
|
|
196
|
+
|
|
197
|
+
const args = splitTopLevelArgs(argsText);
|
|
198
|
+
const lastArg = args[args.length - 1]?.trim();
|
|
199
|
+
// A bare identifier only -- an inline arrow-function handler has no name to correlate to a
|
|
200
|
+
// controller file, so it's skipped rather than guessed at (same discipline as G5's and
|
|
201
|
+
// FastAPI's own "no path literal -> skip").
|
|
202
|
+
const handlerMatch = lastArg?.match(/^([\w$]+)$/);
|
|
203
|
+
if (!handlerMatch) continue;
|
|
204
|
+
|
|
205
|
+
endpoints.push({ varName, verb, path: pathMatch[1], operationId: null, method: handlerMatch[1], line: lineNumberAt(text, m.index) });
|
|
206
|
+
}
|
|
207
|
+
return endpoints;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Resolves a relative ESM specifier the way Node itself would, plus the two extensionless forms
|
|
211
|
+
// people write anyway. Node's real ESM resolver requires the full extension (`./x.js`); a bundler-
|
|
212
|
+
// or TypeScript-influenced codebase often omits it, so both are probed. Never guesses: returns
|
|
213
|
+
// null if nothing on disk matches.
|
|
214
|
+
function resolveEsmImport(fromFile, specifier, suffixes) {
|
|
215
|
+
if (!specifier.startsWith('.')) return null; // only relative specifiers resolve mount edges
|
|
216
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
217
|
+
const candidates = [base];
|
|
218
|
+
for (const ext of suffixes) candidates.push(`${base}${ext}`, path.join(base, `index${ext}`));
|
|
219
|
+
for (const candidate of candidates) {
|
|
220
|
+
// isFile(), not existsSync() -- `./v1` names a DIRECTORY that exists, and treating it as the
|
|
221
|
+
// resolved module would silently produce a mount edge to nothing.
|
|
222
|
+
try {
|
|
223
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
224
|
+
} catch { /* not on disk */ }
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// `export default router;` -- which locally-declared mountable a file hands to whoever imports it.
|
|
230
|
+
function defaultExportedMountable(text, mountables) {
|
|
231
|
+
const m = text.match(/export\s+default\s+([\w$]+)\s*;?/);
|
|
232
|
+
return m && mountables.has(m[1]) ? m[1] : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Builds the mount graph over (file, variable) nodes. Two edge kinds, both from the same
|
|
236
|
+
// `X.use('/literal', Y)` call shape:
|
|
237
|
+
// - INTRA-FILE: Y is another mountable declared in this same file (`app.use('/api', route)`)
|
|
238
|
+
// - CROSS-FILE: Y is imported from a RELATIVE specifier whose file default-exports a mountable
|
|
239
|
+
// A computed/dynamic mount (`route.use(prefix, buildRouter())`), a bare/package specifier, or a
|
|
240
|
+
// single-argument `use()` (a middleware mount, not a prefixed module) is skipped, never guessed at.
|
|
241
|
+
function buildMountEdges(files, fileInfo, suffixes) {
|
|
242
|
+
const edges = []; // { from: nodeKey, to: nodeKey, prefix }
|
|
243
|
+
for (const file of files) {
|
|
244
|
+
const info = fileInfo.get(file);
|
|
245
|
+
if (!info || info.mountables.size === 0) continue;
|
|
246
|
+
const names = [...info.mountables.keys()];
|
|
247
|
+
const useRe = new RegExp(`\\b(${alternationOf(names)})\\.use\\s*\\(`, 'g');
|
|
248
|
+
for (const m of info.text.matchAll(useRe)) {
|
|
249
|
+
const fromVar = m[1];
|
|
250
|
+
const openIdx = m.index + m[0].length - 1;
|
|
251
|
+
const closeIdx = matchBalancedParens(info.text, openIdx);
|
|
252
|
+
if (closeIdx === -1) continue;
|
|
253
|
+
const args = splitTopLevelArgs(info.text.slice(openIdx + 1, closeIdx));
|
|
254
|
+
if (args.length !== 2) continue;
|
|
255
|
+
const pathMatch = args[0].match(STRING_LITERAL_RE);
|
|
256
|
+
const identMatch = args[1].match(/^([\w$]+)$/);
|
|
257
|
+
if (!pathMatch || !identMatch) continue;
|
|
258
|
+
const target = identMatch[1];
|
|
259
|
+
|
|
260
|
+
if (info.mountables.has(target)) {
|
|
261
|
+
edges.push({ from: nodeKey(file, fromVar), to: nodeKey(file, target), prefix: pathMatch[1] });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const importRe = new RegExp(`import\\s+${target}\\s*(?:,\\s*\\{[^}]*\\})?\\s*from\\s*["']([^"']+)["']`);
|
|
265
|
+
const importMatch = info.text.match(importRe);
|
|
266
|
+
if (!importMatch) continue;
|
|
267
|
+
const toFile = resolveEsmImport(file, importMatch[1], suffixes);
|
|
268
|
+
if (!toFile || !fileInfo.has(toFile)) continue;
|
|
269
|
+
const toInfo = fileInfo.get(toFile);
|
|
270
|
+
const toVar = defaultExportedMountable(toInfo.text, toInfo.mountables);
|
|
271
|
+
if (!toVar) continue;
|
|
272
|
+
edges.push({ from: nodeKey(file, fromVar), to: nodeKey(toFile, toVar), prefix: pathMatch[1] });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return edges;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Prefix chain from a mount-graph root down to `node`, or '' if `node` is itself a root. A node
|
|
279
|
+
// reachable through more than one edge uses whichever edge is found first -- a documented, narrow
|
|
280
|
+
// limitation rather than resolving every possible path.
|
|
281
|
+
//
|
|
282
|
+
// `seen` is NOT defensive boilerplate: intra-file edges make a genuine cycle representable
|
|
283
|
+
// (`a.use('/x', b); b.use('/y', a)` inside one file), which the file-to-file model G5 uses cannot
|
|
284
|
+
// express. Without it that shape is infinite recursion, not a wrong answer.
|
|
285
|
+
function prefixChainFor(node, edges, seen = new Set()) {
|
|
286
|
+
if (seen.has(node)) return '';
|
|
287
|
+
seen.add(node);
|
|
288
|
+
const incoming = edges.find((e) => e.to === node);
|
|
289
|
+
if (!incoming) return '';
|
|
290
|
+
return joinPath(prefixChainFor(incoming.from, edges, seen), incoming.prefix);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// `user.route.js` -> `user`. A trailing `.route`/`.routes`/`.router` segment is a near-universal
|
|
294
|
+
// naming convention for Express route files and carries no information; stripping it makes the
|
|
295
|
+
// module name match the resource the way `routes/v1/users.ts`'s bare stem already does for G5.
|
|
296
|
+
// This is a display/scoring LABEL only -- nothing downstream generates code from it (this adapter
|
|
297
|
+
// declares codegen.handles: false), so the cost of the convention being wrong somewhere is a
|
|
298
|
+
// slightly odd module name, never wrong output.
|
|
299
|
+
function moduleNameFor(file) {
|
|
300
|
+
const stem = path.basename(file, path.extname(file));
|
|
301
|
+
return stem.replace(/\.(routes?|router)$/i, '');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const API_SURFACE_SOURCE = 'route paths are resolved by walking the Express mount graph over (file, router-variable) ' +
|
|
305
|
+
'nodes -- both cross-file `use(\'/literal\', importedRouter)` edges (RELATIVE specifiers only) and intra-file ' +
|
|
306
|
+
'`app.use(\'/literal\', localRouter)` edges, where a global prefix usually lives. A computed/dynamic mount is ' +
|
|
307
|
+
'skipped, never guessed. Plain Express has no operationId concept at all, so they are never statically ' +
|
|
308
|
+
'derivable here. This adapter also reports NO persistence entities: the target stack calls a raw SQL driver ' +
|
|
309
|
+
'(mysql2/mariadb) directly, and a SQL string literal carries no trustworthy table/primary-key/column metadata ' +
|
|
310
|
+
'-- see D-javascript-express-adapter in DECISIONS.md. Pass a real OpenAPI document via `bskel contract emit ' +
|
|
311
|
+
'--openapi-file <path> --path-prefix <prefix>` for trustworthy operation identity, if this app has one.';
|
|
312
|
+
|
|
313
|
+
export function scanJavaScriptExpress(repoRoot, detection) {
|
|
314
|
+
const { projectRoot, globs } = detection;
|
|
315
|
+
const suffixes = extensionSuffixes(globs);
|
|
316
|
+
// Normalized to absolute up front: `rg --files` echoes back paths in whatever style its `dir`
|
|
317
|
+
// argument used, but `resolveEsmImport()` builds candidates with `path.resolve()`, which is
|
|
318
|
+
// ALWAYS absolute. With a relative repoRoot the two never compare equal, every cross-file mount
|
|
319
|
+
// edge is silently dropped, and every route loses its prefix while still looking successfully
|
|
320
|
+
// scanned. Real callers happen to pass an absolute repoRoot today (`git rev-parse
|
|
321
|
+
// --show-toplevel`), so this was latent rather than user-visible -- found by running the
|
|
322
|
+
// adapter directly against a relative fixture path. Sorting happens before this map, and
|
|
323
|
+
// resolve() prepends the same prefix to every entry, so O6 determinism is unaffected;
|
|
324
|
+
// `path.relative()` resolves both of its arguments, so `filesRead` stays repo-relative.
|
|
325
|
+
const files = listSourceFiles(projectRoot, globs).map((f) => path.resolve(f));
|
|
326
|
+
|
|
327
|
+
const fileInfo = new Map();
|
|
328
|
+
for (const file of files) {
|
|
329
|
+
// Masked ONCE, here -- every structural regex below (bindings, mountable declarations,
|
|
330
|
+
// endpoints, mount edges, default export) runs against the masked text, so prose about
|
|
331
|
+
// routing can never be mistaken for routing. String literals survive intact, so every path
|
|
332
|
+
// value is still read from the real source. See maskJsComments in _express-shared.mjs.
|
|
333
|
+
const text = maskJsComments(fs.readFileSync(file, 'utf8'));
|
|
334
|
+
const bindings = expressBindings(text);
|
|
335
|
+
fileInfo.set(file, { text, bindings, mountables: bindings ? declaredMountables(text, bindings) : new Map() });
|
|
336
|
+
}
|
|
337
|
+
const edges = buildMountEdges(files, fileInfo, suffixes);
|
|
338
|
+
|
|
339
|
+
const modules = new Map();
|
|
340
|
+
const moduleEntry = (name) => {
|
|
341
|
+
if (!modules.has(name)) modules.set(name, { module: name, controllers: [], entities: [], enums: [], dtos: [] });
|
|
342
|
+
return modules.get(name);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
for (const file of files) {
|
|
346
|
+
const info = fileInfo.get(file);
|
|
347
|
+
if (info.mountables.size === 0) continue;
|
|
348
|
+
const localEndpoints = extractEndpoints(info.text, [...info.mountables.keys()]);
|
|
349
|
+
if (localEndpoints.length === 0) continue;
|
|
350
|
+
|
|
351
|
+
// One controller per (file, router-variable): a file declaring two routers mounted at two
|
|
352
|
+
// different prefixes has two genuinely different base paths, and collapsing them onto the
|
|
353
|
+
// file would attribute the wrong absolute path to half its endpoints.
|
|
354
|
+
const byVar = new Map();
|
|
355
|
+
for (const ep of localEndpoints) {
|
|
356
|
+
if (!byVar.has(ep.varName)) byVar.set(ep.varName, []);
|
|
357
|
+
byVar.get(ep.varName).push(ep);
|
|
358
|
+
}
|
|
359
|
+
const moduleName = moduleNameFor(file);
|
|
360
|
+
for (const [varName, eps] of byVar) {
|
|
361
|
+
const prefix = prefixChainFor(nodeKey(file, varName), edges);
|
|
362
|
+
const endpoints = eps.map(({ varName: _v, ...ep }) => ({ ...ep, path: joinPath(prefix, ep.path) }));
|
|
363
|
+
const className = `${moduleName.charAt(0).toUpperCase()}${moduleName.slice(1)}${varName.charAt(0).toUpperCase()}${varName.slice(1)}`;
|
|
364
|
+
moduleEntry(moduleName).controllers.push({ className, basePath: prefix, operationIds: [], endpoints, file });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
modules: [...modules.values()],
|
|
370
|
+
pathPrefixSignals: [],
|
|
371
|
+
apiSurfaceSource: API_SURFACE_SOURCE,
|
|
372
|
+
filesRead: files.map((f) => path.relative(repoRoot, f)),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// G6 (D-javascript-express-adapter): adapter descriptor consumed by scanners/registry.mjs. `id`
|
|
377
|
+
// must equal this file's stem ("javascript-express").
|
|
378
|
+
//
|
|
379
|
+
// specificity 80 -- deliberately BELOW typescript-express's 85 (and java-spring's 100 /
|
|
380
|
+
// python-fastapi's 90). A repo containing both a `.ts` Express app and `.mjs` ESM sources could be
|
|
381
|
+
// detected by both Express adapters; the TypeScript one carries strictly more (real entity
|
|
382
|
+
// metadata and a working codegen provider), so it should win that overlap quietly rather than
|
|
383
|
+
// tripping runScan()'s same-specificity ambiguity error. Checkable via `bskel doctor`.
|
|
384
|
+
export const adapter = {
|
|
385
|
+
contract: 'sbf.adapter/1',
|
|
386
|
+
id: 'javascript-express',
|
|
387
|
+
title: 'JavaScript / Express (ESM, no ORM)',
|
|
388
|
+
specificity: 80,
|
|
389
|
+
// high, matching python-fastapi's own G2 shipping state: confidence describes trust in what the
|
|
390
|
+
// scan REPORTS (routes and their real absolute paths, resolved through a genuine mount-graph
|
|
391
|
+
// walk), not how many capabilities it can offer. generic-grep is `low` because it has no module
|
|
392
|
+
// inference and no prefix resolution at all -- this adapter has both.
|
|
393
|
+
confidence: 'high',
|
|
394
|
+
capabilities: {
|
|
395
|
+
// false: plain Express has no operationId concept at all. --openapi-file is the honest path
|
|
396
|
+
// forward for an app that has one; see CAPABILITY_SATISFIERS in scanners/capabilities.mjs.
|
|
397
|
+
'api.operations': false,
|
|
398
|
+
// false: contracts/emit.mjs's detectRequestBody() is a Java-only regex, and plain JS has no
|
|
399
|
+
// typed request-body convention to read instead. Costs only body:'unknown' (WARN, waivable).
|
|
400
|
+
'api.request-shape': false,
|
|
401
|
+
// false, and structurally so: this stack has no ORM, so this adapter reports zero entities
|
|
402
|
+
// -- there is nothing carrying a table name or primary key for a resolver to fetch BY. This
|
|
403
|
+
// is not "not implemented yet"; see D-javascript-express-adapter's EXCLUDED section for the
|
|
404
|
+
// measured reason raw SQL literals cannot supply it safely.
|
|
405
|
+
'resource.fetch': false,
|
|
406
|
+
// false: no handles/providers/javascript-express/ exists, by design. The biconditional test
|
|
407
|
+
// in test/handles-provider-registry.test.mjs enforces that this stays honest.
|
|
408
|
+
'codegen.handles': false,
|
|
409
|
+
},
|
|
410
|
+
detect: detectJavaScriptExpressRoot,
|
|
411
|
+
scan(repoRoot, detection) {
|
|
412
|
+
return scanJavaScriptExpress(repoRoot, detection);
|
|
413
|
+
},
|
|
414
|
+
listReadSet(repoRoot) {
|
|
415
|
+
const detection = detectJavaScriptExpressRoot(repoRoot);
|
|
416
|
+
if (!detection) return [];
|
|
417
|
+
return listSourceFiles(detection.projectRoot, detection.globs).map((f) => path.relative(repoRoot, f));
|
|
418
|
+
},
|
|
419
|
+
diagnostics(repoRoot) {
|
|
420
|
+
return expressDiagnostics(repoRoot);
|
|
421
|
+
},
|
|
422
|
+
};
|