backend-skeleton 1.7.0 → 1.7.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/README.md CHANGED
@@ -602,9 +602,10 @@ string for anything missing.
602
602
  (entities come from `@Entity`/`@PrimaryGeneratedColumn`); no operation extraction — plain Express
603
603
  has no operationId concept, so pass `--openapi-file` for a contract. See
604
604
  `D-typescript-express-provider` in `DECISIONS.md`.
605
- - `javascript-express` — plain-JavaScript ESM Express with **no ORM** (raw `mysql2`/`mariadb`),
606
- including `serverless-http`/Lambda deployments. **Scanner only** — routes and their real absolute
607
- paths are resolved through a full mount-graph walk, but every capability is honestly `false`:
605
+ - `javascript-express` — plain-JavaScript Express, both ESM and CommonJS, with **no ORM** (raw
606
+ `mysql2`/`mariadb`), including `serverless-http`/Lambda deployments. **Scanner only** — routes
607
+ and their real absolute paths are resolved through a full mount-graph walk (including direct
608
+ CommonJS `require()` mounts and `module.exports`), but every capability is honestly `false`:
608
609
  there is no codegen provider, because raw SQL string literals carry no trustworthy
609
610
  table/primary-key/column-allow-list metadata. See `D-javascript-express-adapter` in `DECISIONS.md`
610
611
  for the measured reasoning.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
6
6
  "license": "AGPL-3.0-or-later",
@@ -1,5 +1,6 @@
1
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),
2
+ // Express (ESM or CommonJS), with NO ORM and NO TypeScript anywhere. Sibling of
3
+ // `typescript-express.mjs` (G5),
3
4
  // not a generalization of it: they share the low-level Express primitives (`_express-shared.mjs`)
4
5
  // and deliberately do NOT share endpoint/mount-tree extraction, because a plain-JS app's routing
5
6
  // is written differently in three ways that each break G5's own regexes (see below).
@@ -9,7 +10,7 @@
9
10
  // `typescript-express`'s detect() greps `-g '*.ts'` only, so a repo with zero `.ts` files fell all
10
11
  // the way through to the low-confidence `generic-grep` fallback.
11
12
  //
12
- // THREE real divergences from G5, each grounded in what plain-JS Express code actually looks like,
13
+ // FOUR real divergences from G5, each grounded in what plain-JS Express code actually looks like,
13
14
  // not anticipated defensively:
14
15
  // 1. `import express from 'express'; const r = express.Router()` is the dominant plain-JS idiom.
15
16
  // G5's detect() requires a NAMED `import { Router } from 'express'`, which a repo using only
@@ -22,6 +23,10 @@
22
23
  // application to a locally-declared Router (`app.use('/api', route)`) -- no import involved,
23
24
  // so G5's file-to-file edge model cannot represent it and would silently drop `/api` from
24
25
  // every route below it. Mount-tree nodes here are (file, variable) pairs, not files.
26
+ // 4. Express predates Node ESM by years, so real applications commonly use
27
+ // `require('express').Router()`, direct `router.use('/api', require('./api'))` mounts, and
28
+ // `module.exports = router`. The CommonJS path is first-class rather than falling through to
29
+ // generic-grep; `rtfeldman/node-express-realworld-example-app` exposed this gap with 19 routes.
25
30
  //
26
31
  // **`codegen.handles` is false, and that is the whole shipped scope.** There is no
27
32
  // `handles/providers/javascript-express/`. See D-javascript-express-adapter's EXCLUDED section in
@@ -46,55 +51,63 @@ import {
46
51
  expressDiagnostics,
47
52
  } from './_express-shared.mjs';
48
53
 
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
+ // detect()'s ripgrep candidate filter. The ESM half is deliberately just the `from 'express'`
55
+ // tail, not a whole import statement: rg matches line by line, so a clause spread over several
56
+ // lines would be missed by a fuller pattern. The CommonJS half recognizes the module load itself,
57
+ // whether it is assigned (`const express = require(...)`) or immediately dereferenced
58
+ // (`require('express').Router()`). This is only a cheap pre-filter; the masked re-read plus the
59
+ // file's real Node module kind is the gate, so a false positive here costs nothing.
60
+ const EXPRESS_MODULE_SRC = "(?:from\\s*['\"]express['\"]|require\\s*\\(\\s*['\"]express['\"]\\s*\\))";
54
61
  const FROM_EXPRESS_RE = /\bfrom\s*['"]express['"]/g;
62
+ const REQUIRE_EXPRESS_RE = /\brequire\s*\(\s*['"]express['"]\s*\)/g;
55
63
 
56
64
  // The exact shapes an express import clause may legally take: `express`, `{ Router }`,
57
65
  // `express, { Router }`. Anything else is REFUSED rather than parsed optimistically.
58
66
  const IMPORT_CLAUSE_RE = /^(?:([\w$]+))?(?:\s*,\s*)?(?:\{([^}]*)\})?$/;
59
67
 
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'];
68
+ // Node's own top-level module-kind rule for the package root this adapter detected. `.mjs` and
69
+ // `.cjs` are unconditional; `.js` follows package.json's `type`. This keeps an `import` written in
70
+ // a non-module `.js` file from being treated as live ESM while still supporting mixed packages
71
+ // containing both explicit extensions.
72
+ function moduleKindFor(file, packageType) {
73
+ if (path.extname(file) === '.mjs') return 'esm';
74
+ if (path.extname(file) === '.cjs') return 'commonjs';
75
+ return packageType === 'module' ? 'esm' : 'commonjs';
66
76
  }
67
77
 
68
78
  function extensionSuffixes(globs) {
69
- return globs.map((g) => g.replace(/^\*/, '')); // ['*.js','*.mjs'] -> ['.js','.mjs']
79
+ return globs.map((g) => g.replace(/^\*/, '')); // ['*.js','*.mjs','*.cjs'] -> suffixes
70
80
  }
71
81
 
72
82
  // Two independent signals required, the same combined bar java-spring ("build file AND src
73
83
  // 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
84
+ // use: (a) a package.json declares express, (b) at least one JavaScript source file under it both
85
+ // loads express in the syntax valid for that file's Node module kind and calls `Router()` /
86
+ // `<something>.Router()`. Walks the whole repo for candidate
76
87
  // package.json files (not just repoRoot) for the same monorepo reason python-fastapi does.
77
88
  export function detectJavaScriptExpressRoot(repoRoot) {
78
89
  for (const pkgFile of listCandidatePackageFiles(repoRoot)) {
79
90
  if (!declaresExpress(pkgFile)) continue;
80
91
  const projectRoot = path.dirname(pkgFile);
81
- const globs = esmExtensionsFor(readPackageJson(pkgFile));
92
+ const packageType = readPackageJson(pkgFile)?.type ?? 'commonjs';
93
+ const globs = ['*.js', '*.mjs', '*.cjs'];
82
94
  // rg is a cheap candidate filter over raw bytes and can match inside a comment; the real
83
95
  // gate is the masked re-read below, which is why detection needs both the import AND a
84
96
  // Router() call to be genuine code.
85
- const sourceFiles = rgFilesMatching(FROM_EXPRESS_SRC, globs, projectRoot);
97
+ const sourceFiles = rgFilesMatching(EXPRESS_MODULE_SRC, globs, projectRoot);
86
98
  // `\bRouter\s*\(` matches BOTH `Router(...)` and `express.Router(...)` -- there is a word
87
99
  // boundary between `.` and `R`, and none inside `makeRouter(`. Not `\(\s*\)`: an options
88
100
  // object (`Router({ mergeParams: true })`) is ordinary Express and must still detect.
89
101
  const callsRouter = sourceFiles.some((f) => {
90
102
  try {
91
103
  const masked = maskJsComments(fs.readFileSync(f, 'utf8'));
92
- return expressBindings(masked) !== null && /\bRouter\s*\(/.test(masked);
104
+ const kind = moduleKindFor(f, packageType);
105
+ return expressBindings(masked, kind) !== null && /\bRouter\s*\(/.test(masked);
93
106
  } catch {
94
107
  return false;
95
108
  }
96
109
  });
97
- if (callsRouter) return { projectRoot, globs };
110
+ if (callsRouter) return { projectRoot, globs, packageType };
98
111
  }
99
112
  return null;
100
113
  }
@@ -103,9 +116,10 @@ function listSourceFiles(projectRoot, globs) {
103
116
  return listRgFiles(projectRoot, globs);
104
117
  }
105
118
 
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.
119
+ // What THIS file named its express bindings. `import express, { Router } from 'express'` and
120
+ // `const express = require('express')` both yield defaultNames containing `express`; destructuring
121
+ // `Router` sets hasNamedRouter, and `require('express').Router()` sets hasDirectRouter. Returns
122
+ // null when the file does not load express in the syntax valid for its Node module kind.
109
123
  //
110
124
  // Anchors on `from 'express'` and scans BACKWARD to the nearest `import` keyword, rather than
111
125
  // matching a whole `import ... from 'express'` statement forward. A forward
@@ -115,25 +129,37 @@ function listSourceFiles(projectRoot, globs) {
115
129
  // express`; and a clause spread over several lines. The backward scan handles both, and the
116
130
  // strict IMPORT_CLAUSE_RE shape check means an unparseable clause is REFUSED (skipped), never
117
131
  // parsed optimistically into a wrong binding name.
118
- function expressBindings(text) {
119
- let defaultName = null;
132
+ function expressBindings(text, moduleKind) {
133
+ const defaultNames = new Set();
120
134
  let hasNamedRouter = false;
135
+ let hasDirectRouter = false;
121
136
  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;
137
+ if (moduleKind === 'esm') {
138
+ for (const m of text.matchAll(FROM_EXPRESS_RE)) {
139
+ const before = text.slice(0, m.index);
140
+ const importIdx = before.lastIndexOf('import');
141
+ if (importIdx === -1) continue; // e.g. `export * from 'express'` -- not a binding
142
+ const clause = before.slice(importIdx + 'import'.length).replace(/\s+/g, ' ').trim();
143
+ const parsed = clause.match(IMPORT_CLAUSE_RE);
144
+ if (!parsed) continue;
145
+ found = true;
146
+ if (parsed[1]) defaultNames.add(parsed[1]);
147
+ // `Router as R` aliasing is deliberately NOT resolved -- a documented, narrow
148
+ // limitation, not a silent guess at which local name means Router.
149
+ if (parsed[2] && parsed[2].split(',').some((s) => s.trim() === 'Router')) hasNamedRouter = true;
150
+ }
151
+ } else {
152
+ for (const m of text.matchAll(REQUIRE_EXPRESS_RE)) {
153
+ found = true;
154
+ const beforeLine = text.slice(Math.max(text.lastIndexOf('\n', m.index) + 1, 0), m.index);
155
+ const defaultMatch = beforeLine.match(/([\w$]+)\s*=\s*$/);
156
+ if (defaultMatch) defaultNames.add(defaultMatch[1]);
157
+ if (/\{\s*Router\s*\}\s*=\s*$/.test(beforeLine)) hasNamedRouter = true;
158
+ const after = text.slice(m.index + m[0].length);
159
+ if (/^\s*\.\s*Router\s*\(/.test(after)) hasDirectRouter = true;
160
+ }
135
161
  }
136
- return found ? { defaultName, hasNamedRouter } : null;
162
+ return found ? { defaultNames, hasNamedRouter, hasDirectRouter } : null;
137
163
  }
138
164
 
139
165
  // Every locally-declared mountable value in this file, with what it is. Both kinds matter: an
@@ -150,7 +176,7 @@ function declaredMountables(text, bindings) {
150
176
  // completely ordinary Express, and requiring `()` dropped the declaration entirely -- which,
151
177
  // here, means the file yields no routes at all rather than merely losing an option. Matching
152
178
  // 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;
179
+ const routerDeclRe = /\b(?:const|let|var)\s+([\w$]+)\s*=\s*((?:[\w$]+\s*\.\s*)?Router|require\s*\(\s*['"]express['"]\s*\)\s*\.\s*Router)\s*\(/g;
154
180
  for (const m of text.matchAll(routerDeclRe)) {
155
181
  // A bare `Router()` only counts when Router is genuinely imported from express; a
156
182
  // `<name>.Router()` member call always counts (that IS the default-import idiom).
@@ -158,11 +184,15 @@ function declaredMountables(text, bindings) {
158
184
  // past the member call, so it classified EVERY `express.Router()` as a bare call -- which,
159
185
  // with no named `Router` import in the file, dropped the declaration entirely and collapsed
160
186
  // 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');
187
+ const initializer = m[2];
188
+ const memberMatch = initializer.match(/^([\w$]+)\s*\.\s*Router$/);
189
+ const isBoundMemberCall = memberMatch && bindings.defaultNames.has(memberMatch[1]);
190
+ const isDirectRequireCall = /^require\b/.test(initializer) && bindings.hasDirectRouter;
191
+ const isBareCall = initializer === 'Router' && bindings.hasNamedRouter;
192
+ if (isBoundMemberCall || isDirectRequireCall || isBareCall) mountables.set(m[1], 'router');
163
193
  }
164
- if (bindings.defaultName) {
165
- const appDeclRe = new RegExp(`\\b(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*${bindings.defaultName}\\s*\\(\\s*\\)`, 'g');
194
+ for (const defaultName of bindings.defaultNames) {
195
+ const appDeclRe = new RegExp(`\\b(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*${defaultName}\\s*\\(\\s*\\)`, 'g');
166
196
  for (const m of text.matchAll(appDeclRe)) mountables.set(m[1], 'app');
167
197
  }
168
198
  return mountables;
@@ -179,7 +209,11 @@ function nodeKey(file, varName) {
179
209
 
180
210
  // LOCAL endpoints only (verb/path/handler/line), with an EMPTY prefix -- exactly like G5, because
181
211
  // 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.
212
+ // scanJavaScriptExpress() joins the real prefix chain afterward. Inline handlers are real route
213
+ // declarations too; they carry method:null rather than a fabricated name, matching the existing
214
+ // typescript-express contract.
215
+ const INLINE_HANDLER_RE = /^(?:async\s+)?(?:\([^)]*\)|[$\w]+)\s*(?::[^=]*)?=>|^(?:async\s+)?function\b/;
216
+
183
217
  function extractEndpoints(text, mountableNames) {
184
218
  if (mountableNames.length === 0) return [];
185
219
  const re = new RegExp(`\\b(${alternationOf(mountableNames)})\\.(${VERBS.join('|')})\\s*\\(`, 'gi');
@@ -196,22 +230,19 @@ function extractEndpoints(text, mountableNames) {
196
230
 
197
231
  const args = splitTopLevelArgs(argsText);
198
232
  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
233
  const handlerMatch = lastArg?.match(/^([\w$]+)$/);
203
- if (!handlerMatch) continue;
234
+ const isInlineHandler = !handlerMatch && lastArg && INLINE_HANDLER_RE.test(lastArg);
235
+ if (!handlerMatch && !isInlineHandler) continue;
204
236
 
205
- endpoints.push({ varName, verb, path: pathMatch[1], operationId: null, method: handlerMatch[1], line: lineNumberAt(text, m.index) });
237
+ endpoints.push({ varName, verb, path: pathMatch[1], operationId: null, method: handlerMatch ? handlerMatch[1] : null, line: lineNumberAt(text, m.index) });
206
238
  }
207
239
  return endpoints;
208
240
  }
209
241
 
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) {
242
+ // Resolves a relative ESM import or CommonJS require. Explicit files are accepted first; the
243
+ // extension/index probes cover ordinary CommonJS (`require('./routes')`) and the extensionless ESM
244
+ // people write through bundlers. Never guesses beyond the adapter's three JavaScript suffixes.
245
+ function resolveRelativeModule(fromFile, specifier, suffixes) {
215
246
  if (!specifier.startsWith('.')) return null; // only relative specifiers resolve mount edges
216
247
  const base = path.resolve(path.dirname(fromFile), specifier);
217
248
  const candidates = [base];
@@ -234,11 +265,12 @@ function resolveEsmImport(fromFile, specifier, suffixes) {
234
265
  // export-prefixed declaration (`export const router = Router();`, ordinary and common) previously
235
266
  // had no path to being recognized as this file's "the" exported mountable at all.
236
267
  //
237
- // Three real ways a module hands a locally-declared mountable to whoever imports it -- `export
238
- // { router as r }` aliasing is deliberately NOT resolved, same restraint as this file's own
239
- // `Router as R` import-aliasing decision (D-javascript-express-adapter COST): a documented, narrow
240
- // limitation, not a silent guess at which local name an alias refers to.
268
+ // ESM's three real hand-off forms plus CommonJS's direct `module.exports = router`. ESM aliasing
269
+ // (`export { router as r }`) is deliberately NOT resolved, same restraint as this file's own
270
+ // `Router as R` import-aliasing decision: a documented, narrow limitation, not a silent guess.
241
271
  function exportedMountableName(text, mountables) {
272
+ const commonJsMatch = text.match(/\bmodule\s*\.\s*exports\s*=\s*([\w$]+)\s*;?/);
273
+ if (commonJsMatch && mountables.has(commonJsMatch[1])) return commonJsMatch[1];
242
274
  const defaultMatch = text.match(/export\s+default\s+([\w$]+)\s*;?/);
243
275
  if (defaultMatch && mountables.has(defaultMatch[1])) return defaultMatch[1];
244
276
  const namedMatch = text.match(/export\s*\{\s*([\w$]+)\s*\}/);
@@ -248,12 +280,12 @@ function exportedMountableName(text, mountables) {
248
280
  return null;
249
281
  }
250
282
 
251
- // `import target from '...'` (default) OR `import { target } from '...'` (named, unaliased) --
252
- // two real ways an imported mountable's LOCAL name reaches this file. `import { target as alias }`
253
- // is deliberately not resolved, same restraint as exportedMountableName's own aliasing decision
254
- // above -- a bare, unaliased single-name clause only, matching this file's existing default-import
255
- // regex's own narrow scope (never a general multi-specifier import-clause parser).
283
+ // ESM default/named imports OR a CommonJS `const target = require('./relative')`. Alias forms stay
284
+ // deliberately unresolved; this is only the source lookup for an already-observed mount target.
256
285
  function importSourceFor(text, target) {
286
+ const requireRe = new RegExp(`(?:\\b(?:const|let|var)\\s+|[,;]\\s*)${target}\\s*=\\s*require\\s*\\(\\s*["']([^"']+)["']\\s*\\)`);
287
+ const requireMatch = text.match(requireRe);
288
+ if (requireMatch) return requireMatch[1];
257
289
  const defaultImportRe = new RegExp(`import\\s+${target}\\s*(?:,\\s*\\{[^}]*\\})?\\s*from\\s*["']([^"']+)["']`);
258
290
  const defaultMatch = text.match(defaultImportRe);
259
291
  if (defaultMatch) return defaultMatch[1];
@@ -262,12 +294,13 @@ function importSourceFor(text, target) {
262
294
  return namedMatch ? namedMatch[1] : null;
263
295
  }
264
296
 
265
- // Builds the mount graph over (file, variable) nodes. Two edge kinds, both from the same
266
- // `X.use('/literal', Y)` call shape:
297
+ // Builds the mount graph over (file, variable) nodes. Two edge kinds, from either
298
+ // `X.use('/literal', Y)`, `X.use('/literal', require('./y'))`, or `X.use(require('./y'))`:
267
299
  // - INTRA-FILE: Y is another mountable declared in this same file (`app.use('/api', route)`)
268
300
  // - CROSS-FILE: Y is imported from a RELATIVE specifier whose file default-exports a mountable
269
- // A computed/dynamic mount (`route.use(prefix, buildRouter())`), a bare/package specifier, or a
270
- // single-argument `use()` (a middleware mount, not a prefixed module) is skipped, never guessed at.
301
+ // A computed/dynamic mount (`route.use(prefix, buildRouter())`) or a bare/package specifier is
302
+ // skipped, never guessed. A one-argument identifier remains middleware and is skipped; only a
303
+ // one-argument relative `require()` is unambiguously a CommonJS sub-router hand-off.
271
304
  function buildMountEdges(files, fileInfo, suffixes) {
272
305
  const edges = []; // { from: nodeKey, to: nodeKey, prefix }
273
306
  for (const file of files) {
@@ -281,24 +314,40 @@ function buildMountEdges(files, fileInfo, suffixes) {
281
314
  const closeIdx = matchBalancedParens(info.text, openIdx);
282
315
  if (closeIdx === -1) continue;
283
316
  const args = splitTopLevelArgs(info.text.slice(openIdx + 1, closeIdx));
284
- if (args.length !== 2) continue;
285
- const pathMatch = args[0].match(STRING_LITERAL_RE);
286
- const identMatch = args[1].match(/^([\w$]+)$/);
287
- if (!pathMatch || !identMatch) continue;
288
- const target = identMatch[1];
317
+ let prefix;
318
+ let targetExpression;
319
+ if (args.length === 2) {
320
+ const pathMatch = args[0].match(STRING_LITERAL_RE);
321
+ if (!pathMatch) continue;
322
+ prefix = pathMatch[1];
323
+ targetExpression = args[1].trim();
324
+ } else if (args.length === 1) {
325
+ prefix = '';
326
+ targetExpression = args[0].trim();
327
+ } else {
328
+ continue;
329
+ }
330
+
331
+ const identMatch = targetExpression.match(/^([\w$]+)$/);
332
+ const directRequireMatch = targetExpression.match(/^require\s*\(\s*["']([^"']+)["']\s*\)$/);
333
+ if (!identMatch && !directRequireMatch) continue;
334
+ const target = identMatch?.[1] ?? null;
289
335
 
290
- if (info.mountables.has(target)) {
291
- edges.push({ from: nodeKey(file, fromVar), to: nodeKey(file, target), prefix: pathMatch[1] });
336
+ if (target && info.mountables.has(target)) {
337
+ edges.push({ from: nodeKey(file, fromVar), to: nodeKey(file, target), prefix });
292
338
  continue;
293
339
  }
294
- const importSource = importSourceFor(info.text, target);
340
+ // A one-argument identifier is ordinary middleware, not a router mount. Reaching this
341
+ // branch means it was not a local mountable; only a real import/require binding can turn
342
+ // it into a cross-file edge.
343
+ const importSource = directRequireMatch?.[1] ?? (target ? importSourceFor(info.text, target) : null);
295
344
  if (!importSource) continue;
296
- const toFile = resolveEsmImport(file, importSource, suffixes);
345
+ const toFile = resolveRelativeModule(file, importSource, suffixes);
297
346
  if (!toFile || !fileInfo.has(toFile)) continue;
298
347
  const toInfo = fileInfo.get(toFile);
299
348
  const toVar = exportedMountableName(toInfo.text, toInfo.mountables);
300
349
  if (!toVar) continue;
301
- edges.push({ from: nodeKey(file, fromVar), to: nodeKey(toFile, toVar), prefix: pathMatch[1] });
350
+ edges.push({ from: nodeKey(file, fromVar), to: nodeKey(toFile, toVar), prefix });
302
351
  }
303
352
  }
304
353
  return edges;
@@ -331,7 +380,8 @@ function moduleNameFor(file) {
331
380
  }
332
381
 
333
382
  const API_SURFACE_SOURCE = 'route paths are resolved by walking the Express mount graph over (file, router-variable) ' +
334
- 'nodes -- both cross-file `use(\'/literal\', importedRouter)` edges (RELATIVE specifiers only) and intra-file ' +
383
+ 'nodes -- ESM imports and CommonJS require()/module.exports are both supported; cross-file ' +
384
+ '`use(\'/literal\', importedRouter)` / `use(\'/literal\', require(\'./router\'))` edges (RELATIVE specifiers only) and intra-file ' +
335
385
  '`app.use(\'/literal\', localRouter)` edges, where a global prefix usually lives. A computed/dynamic mount is ' +
336
386
  'skipped, never guessed. Plain Express has no operationId concept at all, so they are never statically ' +
337
387
  'derivable here. This adapter also reports NO persistence entities: the target stack calls a raw SQL driver ' +
@@ -340,10 +390,10 @@ const API_SURFACE_SOURCE = 'route paths are resolved by walking the Express moun
340
390
  '--openapi-file <path> --path-prefix <prefix>` for trustworthy operation identity, if this app has one.';
341
391
 
342
392
  export function scanJavaScriptExpress(repoRoot, detection) {
343
- const { projectRoot, globs } = detection;
393
+ const { projectRoot, globs, packageType = 'commonjs' } = detection;
344
394
  const suffixes = extensionSuffixes(globs);
345
395
  // Normalized to absolute up front: `rg --files` echoes back paths in whatever style its `dir`
346
- // argument used, but `resolveEsmImport()` builds candidates with `path.resolve()`, which is
396
+ // argument used, but `resolveRelativeModule()` builds candidates with `path.resolve()`, which is
347
397
  // ALWAYS absolute. With a relative repoRoot the two never compare equal, every cross-file mount
348
398
  // edge is silently dropped, and every route loses its prefix while still looking successfully
349
399
  // scanned. Real callers happen to pass an absolute repoRoot today (`git rev-parse
@@ -360,7 +410,7 @@ export function scanJavaScriptExpress(repoRoot, detection) {
360
410
  // routing can never be mistaken for routing. String literals survive intact, so every path
361
411
  // value is still read from the real source. See maskJsComments in _express-shared.mjs.
362
412
  const text = maskJsComments(fs.readFileSync(file, 'utf8'));
363
- const bindings = expressBindings(text);
413
+ const bindings = expressBindings(text, moduleKindFor(file, packageType));
364
414
  fileInfo.set(file, { text, bindings, mountables: bindings ? declaredMountables(text, bindings) : new Map() });
365
415
  }
366
416
  const edges = buildMountEdges(files, fileInfo, suffixes);
@@ -413,7 +463,7 @@ export function scanJavaScriptExpress(repoRoot, detection) {
413
463
  export const adapter = {
414
464
  contract: 'sbf.adapter/2',
415
465
  id: 'javascript-express',
416
- title: 'JavaScript / Express (ESM, no ORM)',
466
+ title: 'JavaScript / Express (ESM/CommonJS, no ORM)',
417
467
  specificity: 80,
418
468
  // high, matching python-fastapi's own G2 shipping state: confidence describes trust in what the
419
469
  // scan REPORTS (routes and their real absolute paths, resolved through a genuine mount-graph