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,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
+ };