diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c92c1d8..44d0122a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's ` items={…} />` or excalidraw's ` …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading. - In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading. - In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading. +- An Express route keeps its named handler when a middleware before it contains an arrow function. `rateLimit({ keyGenerator: (req) => req.ip })` or `(req, res, next) => next()` no longer hides the handler at the end of the route (re-index to update an existing project). - A TypeScript type re-exported through a barrel (`export type { Foo } from`, `export { type Foo } from`, `export type * from`) now links to its real declaration instead of a same-named type elsewhere in the project, and import lists with comments, string names, or a JSDoc `@import` are read correctly. Re-index to update an existing project. - Git sync hooks now refresh the project they were installed for, so a project inside a larger repository stays current after commit, pull and checkout; installing them no longer breaks an existing hook written in another language, and runs even when your own hook ends with `exit`. - In TypeScript and JavaScript, a call through a field the calling class inherits, like `this._store.add(…)` in a class whose base class declares `_store`, now links to the method of the field's class, with the base class found the way the subclass's file imports it, under its own name, an alias or a re-export. Before, such a call linked only when some other class with the calling class's name happened to declare the field, which could be another app's class: that last resort now needs a type the calling file imports or declares. A call on a field or variable whose type comes from a package outside your project, like `TelemetryReporter` from `@vscode/extension-telemetry`, no longer links to a project class that only shares the type's name, unless a file in your project declares that package (`declare module '…'`). Re-index TypeScript and JavaScript projects after upgrading. diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 940c5a006..d5ef9cec2 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import type { FrameworkResolver, UnresolvedRef } from '../src/resolution/types'; import type { Node } from '../src/types'; @@ -245,6 +245,186 @@ describe('expressResolver.extract', () => { const { nodes, references } = expressResolver.extract!('routes.ts', src); expect(references[0].referenceName).toBe('list'); }); + + // Only the handler — the LAST argument — decides whether the route has an + // inline body. An arrow inside a middleware argument used to make the whole + // route "inline", attributing the middleware's calls to it and dropping the + // named handler. + const refsOf = (src: string) => + expressResolver.extract!('routes.ts', src).references.map((r) => `${r.referenceKind}:${r.referenceName}`); + + it('keeps the named handler when a middleware argument holds an arrow', () => { + const refs = refsOf(`router.get('/users', rateLimit({ keyGenerator: (req) => lookup(req.ip) }), getUsers);\n`); + expect(refs).toContain('references:getUsers'); + expect(refs).not.toContain('calls:lookup'); + }); + + it('keeps the named handler after an inline arrow middleware', () => { + const refs = refsOf(`router.get('/x', (req, res, next) => audit(req, next), getUsers);\n`); + expect(refs).toContain('references:getUsers'); + expect(refs).not.toContain('calls:audit'); + }); + + it('still reads an inline handler, wrapped or after named middleware', () => { + expect(refsOf(`router.post('/x', asyncHandler(async (req, res) => { createItem(req.body); }));\n`)).toContain('calls:createItem'); + const afterAuth = refsOf(`router.post('/x', auth, async (req, res) => { createItem(req.body); });\n`); + expect(afterAuth).toContain('calls:createItem'); + expect(afterAuth).not.toContain('references:auth'); + }); + + it('reads a `function` expression handler', () => { + expect(refsOf(`router.get('/x', function (req, res) { listUsers(); });\n`)).toContain('calls:listUsers'); + }); + + it('a `function` handler whose body holds an arrow keeps every call', () => { + const plain = refsOf(`router.get('/x', function (req, res) { const u = findUser(req.id); res.json(u.items.map((i) => shape(i))); });\n`); + expect(plain).toContain('calls:findUser'); + expect(plain).toContain('calls:shape'); + const chained = refsOf(`router.route('/x').get(function (req, res) { const a = load(); a.forEach((x) => shape(x)); });\n`); + expect(chained).toContain('calls:load'); + expect(chained).toContain('calls:shape'); + }); + + it('a `function` elsewhere in the argument is not the handler', () => { + expect(refsOf(`router.get('/x', (req, res, cb = function () {}) => { svc(); });\n`)).toContain('calls:svc'); + expect(refsOf(`router.get('/x', wrap(ctrl.list, { onError: function (e) { report(e); } }));\n`)).not.toContain('calls:report'); + expect(refsOf(`router.get('/x', asyncHandler(function (req, res) { svc(); }));\n`)).toContain('calls:svc'); + }); + + it('a `function` callback before the handler inside a wrapper is not the handler', () => { + const refs = refsOf(`router.get('/x', withErrors(function onErr(e) { report(e); }, async (req, res) => { svc(); }));\n`); + expect(refs).toContain('calls:svc'); + expect(refs).not.toContain('calls:report'); + }); + + it('a regex literal in an argument does not throw off the split', () => { + expect(refsOf(`router.get('/x', body('tags').matches(/^[^\\]]+$/), getUsers);\n`)).toContain('references:getUsers'); + expect(refsOf(`router.get('/x', check('q').matches(/\\[/), getUsers);\n`)).toContain('references:getUsers'); + const inline = refsOf(`router.get('/x', async (req, res) => { const re = /[^]]/, out = compute(req); save(out); });\n`); + expect(inline).toContain('calls:compute'); + expect(inline).toContain('calls:save'); + }); + + it('keywords in a handler body are not calls', () => { + const refs = refsOf(`router.get('/x', function (req, res) { if (ok(req)) { for (const a of b) run(a); } items.forEach(function (i) { shape(i); }); });\n`); + expect(refs).toEqual(expect.arrayContaining(['calls:ok', 'calls:run', 'calls:shape'])); + expect(refs.filter((r) => /^calls:(if|for|function|while|switch|catch|return)$/.test(r))).toEqual([]); + }); + + it('a JSX closing tag is not a regex', () => { + const refs = expressResolver.extract!('server.tsx', `app.get('/a', (req, res) => { res.send(renderToString(
{a()}
)); });\napp.get('/b', (req, res) => { res.send(renderPage(<>{b()})); });\n`).references.map((r) => `${r.referenceKind}:${r.referenceName}`); + expect(refs).toEqual(expect.arrayContaining(['calls:renderToString', 'calls:a', 'calls:renderPage', 'calls:b'])); + }); + + it('a self-closing JSX tag after an attribute, and division after `}`, are not regexes', () => { + const tsx = (src: string) => + expressResolver.extract!('server.tsx', src).references.map((r) => `${r.referenceKind}:${r.referenceName}`); + expect(tsx(`app.get('*', (req, res) => {\n const html = renderToString();\n sendPage(res, html);\n});\n`)).toEqual( + expect.arrayContaining(['calls:renderToString', 'calls:sendPage']) + ); + expect(refsOf(`router.get('/x', (req, res) => { const v = { a: 1 }\n/ 2; svc(v); });\n`)).toContain('calls:svc'); + }); + + it('division after `!` or `++` is not a regex', () => { + expect(refsOf(`router.get('/x', rateLimit({ max: cfg.max! / 2 }), getUsers);\n`)).toContain('references:getUsers'); + expect(refsOf(`router.get('/x', limiter(hits++ / 2), getUsers);\n`)).toContain('references:getUsers'); + }); + + it('an `async function` handler whose body has a `}` regex and an arrow keeps every call', () => { + const refs = refsOf(`router.route('/x').get(async function (req, res) { const s = t.replace(/}/g, ''); load(); items.map((i) => shape(i)); });\n`); + expect(refs).toContain('calls:load'); + expect(refs).toContain('calls:shape'); + }); + + it('`async` and a nested `function` declaration name are not calls; a regex with `)` is skipped', () => { + const refs = refsOf(`router.get('/x', function (req, res) { function helper(a) { return a; } Promise.all(items.map(async (i) => save(i))); });\n`); + expect(refs).toContain('calls:save'); + expect(refs).not.toContain('calls:async'); + expect(refs).not.toContain('calls:helper'); + }); + + it('regex literals in handlers do not cost the route its edges', () => { + expect(refsOf(`router.get('/u/:id', async (req, res) => { if (!/^\\d+/.test(req.params.id)) return res.sendStatus(400); const user = await userService.findById(req.params.id); res.json(user); });\n`)).toContain('calls:userService.findById'); + const chained = expressResolver.extract!('routes.ts', `router.route('/:id').get((req, res) => { if (!/^\\w+/.test(req.params.id)) return bad(res); load(req); }).put(protect, updateThing);\n`); + expect(chained.nodes.map((n) => n.name)).toEqual(['GET /:id', 'PUT /:id']); + expect(chained.references.map((r) => `${r.referenceKind}:${r.referenceName}`)).toEqual(expect.arrayContaining(['calls:load', 'references:updateThing'])); + expect(refsOf(`app.get('/echo', (req, res) => { res.send(String(req.query.q).replace(//g, '>')); audit(req); });\n`)).toContain('calls:audit'); + }); + + it('a call inside a template interpolation in the body still counts', () => { + expect(refsOf('router.get(\'/x\', (req, res) => { res.send(`

${fmtUser(req.user)}

`); });\n')).toContain('calls:fmtUser'); + }); + + it('a division that looks like a regex opener does not cost the route', () => { + expect(refsOf(`router.get('/stats', async (req, res) => { const pct = Math.round((a - b) / total * 100) / 100; res.json({ pct: summarize(pct) }); });\n`)).toContain('calls:summarize'); + const chained = expressResolver.extract!('routes.ts', `router.route('/s').get((req, res) => { const m = Math.floor((t1 - t0) / 1000) / 60; res.json(fmt(m)); }).post(protect, createS);\n`); + expect(chained.nodes.map((n) => n.name)).toEqual(['GET /s', 'POST /s']); + expect(chained.references.map((r) => `${r.referenceKind}:${r.referenceName}`)).toEqual(expect.arrayContaining(['calls:fmt', 'references:createS'])); + const tsx = expressResolver.extract!('server.tsx', `app.get('/list', (req, res) => { res.send(renderToString(
    {items.map((x) => (
  • {x}
  • ))}
)); audit(req); });\n`).references.map((r) => `${r.referenceKind}:${r.referenceName}`); + expect(tsx).toEqual(expect.arrayContaining(['calls:renderToString', 'calls:audit'])); + }); + + it('a division after `)` with a `/` in a later string does not cost the route', () => { + const refs = refsOf(`router.get('/s', (req, res) => { res.json({ pct: Math.round(s.done) / s.total, next: '/stats/2' }); audit(req); });\n`); + expect(refs).toEqual(expect.arrayContaining(['calls:Math.round', 'calls:audit'])); + expect(refsOf(`router.get('/p', (req, res) => { const n = Math.ceil(count(req)) / size; res.redirect('/p/' + n); after(req); });\n`)).toContain('calls:after'); + expect(refsOf(`router.get('/c', cfg(f(a) / 2, 'x/y'), handler);\n`)).toContain('references:handler'); + }); + + it('a callback inside middleware, or a middleware that calls `next()`, is not the handler', () => { + expect(refsOf(`router.get('/admin', authorize(function (user, req) { return user.isAdmin; }), adminCtrl.index);\n`)).toContain('references:index'); + expect(refsOf(`router.get('/referrals', function (req, res, next) { check(); next(); }, getReferrals);\n`)).toContain('references:getReferrals'); + expect(refsOf(`router.get('/cfg', (req, res, next) => { load(); next(); }, overrideConfig);\n`)).toContain('references:overrideConfig'); + }); + + it('the last argument decides: middleware before a named handler, guards, factories, generics', () => { + expect(refsOf(`router.get('/admin', authorize(function (user, req) { return user.isAdmin; }), adminCtrl.index);\n`)).toContain('references:index'); + expect(refsOf(`router.get('/u/:id', function (req, res, next) { User.findById(id, function (err, user) { req.user = user; next(err); }); }, userCtrl.show);\n`)).toContain('references:show'); + expect(refsOf(`router.get('/x', responseTime(function (req, res, time) { stats.timing(req.path, time); }), ctrl.list);\n`)).toContain('references:list'); + expect(refsOf(`router.get('/errors', function (req, res, next) { auth(req); next(); }, getErrors);\n`)).toContain('references:getErrors'); + expect(refsOf(`app.use('/api', function (req, res, next) { if (!ok) return res.sendStatus(403); authorize(req, next); }, apiRouter);\n`)).toContain('references:apiRouter'); + expect(refsOf(`router.post('/g', auth, asyncHandler(async (req, res) => { doIt(); }));\n`)).toContain('calls:doIt'); + expect(refsOf(`router.post('/d', asyncHandler(async (req, res) => { saveFile(req.file); }));\n`)).toContain('calls:saveFile'); + }); + + it('JSX apostrophes, a trailing options object, a callback before a named handler', () => { + const tsx = expressResolver.extract!('server.tsx', `app.get('/x', (req, res) => {\n res.send(render(

Don't

));\n res.send(render(

won't

));\n load();\n});\n`).references.map((r) => `${r.referenceKind}:${r.referenceName}`); + expect(tsx).toContain('calls:load'); + expect(refsOf(`router.get('/o', (req, res) => { svc.list(); res.json(1) }, { cache: true });\n`)).toContain('calls:svc.list'); + expect(refsOf(`router.get('/w', withErrors(function onErr(e) { report(e); }, ctrl.list));\n`)).not.toContain('calls:report'); + // An array of handlers: its last element is the handler. + expect(refsOf(`router.post('/x', auth, [validate, ctrl.create]);\n`)).toEqual(['references:create']); + }); + + it('a regex that ends its line without a semicolon is still a regex', () => { + expect(refsOf(`router.get('/re', (req, res) => {\n const re = /[}]/\n run(re)\n})\n`)).toContain('calls:run'); + }); + + it('a backtick in an earlier regex does not cost later routes', () => { + const refs = refsOf("const esc = (s) => s.replace(/`/g, 'x');\nrouter.get('/d', async (req, res) => { await svc.doThing(); res.end(); });\nrouter.get('/e', ctrl.list);\n"); + expect(refs).toEqual(expect.arrayContaining(['calls:svc.doThing', 'references:list'])); + }); + + it('a division after a non-null `!` is not a regex, even with a path string later on the line', () => { + expect(refsOf(`app.get('/x', (req, res) => {\n const r = done! / total!; res.redirect(r ? '/:id' : '/');\n svc.run(r);\n});\n`)).toContain('calls:svc.run'); + expect(refsOf(`app.get('/y', (req, res) => {\n const r = sum! / count!; const parts = csv.split(/,/);\n svc.run(parts);\n});\n`)).toContain('calls:svc.run'); + }); + + it('a method named like a keyword is still a call', () => { + const refs = refsOf(`router.delete('/users/:id', async (req, res) => { await userService.delete(req.params.id); cache .with(k); });\n`); + expect(refs).toContain('calls:userService.delete'); + expect(refs).toContain('calls:cache.with'); + expect(refsOf(`router.get('/x', (req, res) => { return of(load()); });\n`)).toContain('calls:of'); + }); + + it('chained routes: the handler decides too', () => { + const refs = refsOf( + `router.route('/items').get(rateLimit({ keyGenerator: (r) => lookup(r.ip) }), getItem).post(async (req, res) => { createItem(); });\n` + ); + expect(refs).toContain('references:getItem'); + expect(refs).not.toContain('calls:lookup'); + expect(refs).toContain('calls:createItem'); + }); }); import { nestjsResolver } from '../src/resolution/frameworks/nestjs'; @@ -2034,3 +2214,37 @@ export class UsersController { expect(references.map((r) => r.referenceName)).toEqual(['real']); }); }); + +describe('Express mounts with a division in a middleware argument', () => { + let dir: string | undefined; + afterEach(() => { + if (dir) require('fs').rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it('still composes the mount prefix (`x! / 2` is not a regex)', async () => { + const fs = await import('fs'); + const os = await import('os'); + const path = await import('path'); + const { CodeGraph } = await import('../src'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-express-mount-')); + dir = root; + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ dependencies: { express: '4' } })); + fs.mkdirSync(path.join(root, 'routes')); + fs.writeFileSync( + path.join(root, 'app.ts'), + "import express from 'express';\nimport apiRouter from './routes/api';\nconst app = express();\n" + + "app.use('/api', rateLimit({ max: cfg.max! / 2 }), apiRouter);\n" + ); + fs.writeFileSync( + path.join(root, 'routes', 'api.ts'), + "import { Router } from 'express';\nconst router = Router();\nfunction listUsers(req, res) {}\nrouter.get('/users', listUsers);\nexport default router;\n" + ); + const cg = await CodeGraph.init(root, { index: true }); + try { + expect(cg.getNodesByKind('route').map((n) => n.name)).toContain('GET /api/users'); + } finally { + cg.destroy(); + } + }); +}); diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index c2e1f579a..06294099c 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -6,7 +6,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; -import { stripCommentsForRegex } from '../strip-comments'; +import { REGEX_START_BEFORE, stripCommentsForRegex } from '../strip-comments'; import { resolveImportPath } from '../import-resolver'; import { dependsOn } from './package-deps'; @@ -49,6 +49,11 @@ const RESERVED_CALLS = new Set([ 'Date', 'Math', 'JSON', 'Promise', 'require', 'fail', 'redirect', ]); +// Keywords a `name(` scan over a handler body would otherwise read as calls. +const JS_KEYWORDS = new Set([ + 'if', 'for', 'while', 'switch', 'function', 'async', 'return', 'typeof', 'await', 'new', 'do', 'else', 'with', 'yield', 'void', 'delete', 'in', 'super', 'import', +]); + /** * The calls an inline handler's body makes, each once, framework noise aside. * A member call keeps its receiver (`userService.find`, `c.text`) so it @@ -65,6 +70,10 @@ function handlerCallNames(body: string): string[] { const method = cm[2]!; const receiver = cm[1]!.replace(/\s|\?/g, '').replace(/\.$/, ''); if (!receiver && /\.\s*$/.test(body.slice(0, cm.index))) continue; + // A keyword is only a keyword when it isn't a member: `if (` is not a + // call, `userService.delete(` is. `function helper(` declares helper. + if (!receiver && JS_KEYWORDS.has(method)) continue; + if (!receiver && /\bfunction\s*\*?\s*$/.test(body.slice(Math.max(0, cm.index - 12), cm.index))) continue; const name = receiver ? `${receiver}.${method}` : method; if (seen.has(name) || RESERVED_CALLS.has(method)) continue; seen.add(name); @@ -188,6 +197,10 @@ export const expressResolver: FrameworkResolver = { const now = Date.now(); const lang = detectLanguage(filePath); const safe = stripCommentsForRegex(content, lang); + // Brackets and commas are counted on `structure`, where string and regex + // contents are blanked: `/[^]]/`, `/}/` or `'a, b)'` must not end an + // argument or a body early. Same offsets as `safe`, which the route path + // and reply calls are still read from. // Match the route head up to the first arg: (app|router).METHOD('/path', // (NOT the whole call — handlers are often inline arrows whose `)`/`{}` the // old single-regex couldn't span, so inline-handler routes connected to nothing.) @@ -215,46 +228,9 @@ export const expressResolver: FrameworkResolver = { // The full argument list = balanced parens from the route call's open paren. const openParen = safe.indexOf('(', match.index); - const closeParen = openParen >= 0 ? matchDelim(safe, openParen, '(', ')') : -1; - const args = closeParen > openParen ? safe.slice(openParen + 1, closeParen) : ''; - const inline = inlineHandlerBody(args); - - if (inline) { - // Inline handler (`router.post('/x', async (req,res) => {…})`, - // `app.get('/x', function (req, res) {…})`). It is anonymous, so its - // body — the actual request→service flow — would be lost. Attribute the - // body's calls to the route node as `calls` edges so - // `trace(route, service)` connects. - const bodyStart = openParen + 1 + inline.start; - for (const name of handlerCallNames(inline.body)) { - references.push({ - fromNodeId: routeNode.id, - referenceName: name, - referenceKind: 'calls', - line, - column: 0, - filePath, - language: lang, - }); - } - references.push(...replyRefs(safe, bodyStart, bodyStart + inline.body.length, routeNode.id, filePath, lang)); - } else { - // Named handler: the LAST comma-separated arg (earlier ones are middleware). - const parts = args.split(',').map((s) => s.trim()).filter(Boolean); - const last = parts[parts.length - 1]; - const handlerName = last ? extractTailIdent(last) : null; - if (handlerName) { - references.push({ - fromNodeId: routeNode.id, - referenceName: handlerName, - referenceKind: 'references', - line, - column: 0, - filePath, - language: lang, - }); - } - } + const call = openParen >= 0 ? scanCall(safe, openParen) : null; + const args = call ? call.structure.slice(1, -1) : ''; + references.push(...handlerRefs(safe, openParen + 1, args, routeNode.id, line, filePath, lang)); } // The chained form: `router.route('/:id').get(getProduct).put(protect, updateProduct)` // — one path, several methods, each with its own handler. One route node @@ -267,11 +243,12 @@ export const expressResolver: FrameworkResolver = { const link = /^\s*\.\s*(get|post|put|patch|delete|all)\s*\(/.exec(safe.slice(at, at + 64)); if (!link) break; const openParen = at + link[0].length - 1; - const closeParen = matchDelim(safe, openParen, '(', ')'); - if (closeParen < 0) break; + const call = scanCall(safe, openParen); + if (!call) break; + const closeParen = call.close; const method = link[1]!; const line = safe.slice(0, openParen).split('\n').length; - const args = safe.slice(openParen + 1, closeParen); + const args = call.structure.slice(1, -1); const routeNode: Node = { id: `route:${filePath}:${line}:${method.toUpperCase()}:${routePath}`, kind: 'route', @@ -286,21 +263,7 @@ export const expressResolver: FrameworkResolver = { updatedAt: now, }; nodes.push(routeNode); - const inline = inlineHandlerBody(args); - if (inline) { - for (const name of handlerCallNames(inline.body)) { - references.push({ fromNodeId: routeNode.id, referenceName: name, referenceKind: 'calls', line, column: 0, filePath, language: lang }); - } - const bodyStart = openParen + 1 + inline.start; - references.push(...replyRefs(safe, bodyStart, bodyStart + inline.body.length, routeNode.id, filePath, lang)); - } else { - const parts = splitTopLevel(args).map((s) => s.trim()).filter(Boolean); - const last = parts[parts.length - 1]; - const handlerName = last ? extractTailIdent(last) : null; - if (handlerName) { - references.push({ fromNodeId: routeNode.id, referenceName: handlerName, referenceKind: 'references', line, column: 0, filePath, language: lang }); - } - } + references.push(...handlerRefs(safe, openParen + 1, args, routeNode.id, line, filePath, lang)); at = closeParen + 1; } } @@ -340,10 +303,13 @@ export const expressResolver: FrameworkResolver = { let m: RegExpExecArray | null; while ((m = mount.exec(safe)) !== null) { const open = safe.indexOf('(', m.index); - const close = open >= 0 ? matchDelim(safe, open, '(', ')') : -1; - if (close < 0) continue; - const args = splitTopLevel(safe.slice(open + 1, close)); - const last = args[args.length - 1]?.trim() ?? ''; + const call = open >= 0 ? scanCall(safe, open) : null; + if (!call) continue; + // Split where the blanked structure says, but read the target from + // `safe`: a `require('./users')` target needs its string. + const parts = topLevelArgs(call.structure.slice(1, -1)); + const lastPart = parts[parts.length - 1]; + const last = lastPart ? safe.slice(open + 1 + lastPart.start, call.close).trim() : ''; const target = mountTarget(last, safe, file, lang, context); if (!target || target === file) continue; const list = mounts.get(file) ?? []; @@ -395,10 +361,115 @@ export const expressResolver: FrameworkResolver = { }, }; -/** Top-level comma split of an argument list, strings and brackets respected. */ -function splitTopLevel(args: string): string[] { + +/** + * What ends a regex literal: its flags, then a member access, a separator or + * a closing bracket, an operator, or the end of the line. + */ +const REGEX_END_AFTER = /^[dgimsuyv]*[ \t]*(?:\.\s*(?:test|exec|source|flags|global|lastIndex)\b|[,;)\]}:?]|&&|\|\||\r?\n|$)/; + +/** + * The call whose `(` is at `open`, scanned from there to its matching `)`: + * its close, and its text with string contents and regex-literal bodies + * blanked (offsets kept) so `/[^]]/`, `/}/`, `'a, b)'` or a backtick in a regex + * can't end an argument or a body early. Scanned per call, from the call's own + * paren — never from the top of the file — so nothing earlier in the file can + * throw it off. + */ +function scanCall(safe: string, open: number): { close: number; structure: string } | null { const out: string[] = []; let depth = 0; + for (let i = open; i < safe.length; i++) { + const c = safe[i]!; + if ((c === '"' || c === "'") && !closesOnLine(safe, i)) { + // JSX text (`

Don't

`), not a string: an apostrophe with no + // closing quote on its line must not blank the `)` after it. + out.push(c); + continue; + } + if (c === '"' || c === "'" || c === '`') { + out.push(c); + for (i++; i < safe.length && safe[i] !== c; i++) { + if (c !== '`' && safe[i] === '\n') break; + if (safe[i] === '\\' && i + 1 < safe.length) { + out.push(' '); + i++; + } + out.push(safe[i] === '\n' ? '\n' : ' '); + } + if (i < safe.length) out.push(safe[i]!); + continue; + } + if (c === '/' && safe[i + 1] !== '/' && safe[i + 1] !== '*') { + const end = regexLiteralEndAt(safe, i); + if (end > i) { + out.push('/', ...' '.repeat(end - i - 1), '/'); + i = end; + continue; + } + } + out.push(c); + if (c === '(') depth++; + else if (c === ')') { + depth--; + if (depth === 0) return { close: i, structure: out.join('') }; + } + } + return null; +} + +/** Whether the quote at `i` has an unescaped closing quote on the same line. */ +function closesOnLine(s: string, i: number): boolean { + const q = s[i]; + for (let k = i + 1; k < s.length && s[k] !== '\n'; k++) { + if (s[k] === '\\') k++; + else if (s[k] === q) return true; + } + return false; +} + +/** + * The closing `/` of the regex literal the `/` at `i` opens, or -1 when it + * divides. A regex opens after punctuation or a keyword (REGEX_START_BEFORE) + * but not after `)`, `]`, a postfix `!` (`done! / total`), `++` or `--`; it + * closes on the same line; and what follows the close must end a regex — + * flags, then `.test(`, `,`, `;`, `)`, … — since treating a division as one + * (`(a - b) / total * 100) / 100`, JSX `…`) would hide a bracket. + */ +function regexLiteralEndAt(safe: string, i: number): number { + let p = i - 1; + while (p >= 0 && (safe[p] === ' ' || safe[p] === '\t')) p--; + if (p >= 0 && (safe[p] === ')' || safe[p] === ']' || safe[p] === '}')) return -1; + if (p >= 1 && safe[p] === '!' && /[\w$)\]]/.test(safe[p - 1]!)) return -1; + if (p >= 1 && (safe[p] === '+' || safe[p] === '-') && safe[p - 1] === safe[p]) return -1; + if (!REGEX_START_BEFORE.test(safe.slice(Math.max(0, i - 32), i))) return -1; + let end = i + 1; + let inClass = false; + for (; end < safe.length && safe[end] !== '\n'; end++) { + if (safe[end] === '\\') { end++; continue; } + if (safe[end] === '[') inClass = true; + else if (safe[end] === ']') inClass = false; + else if (safe[end] === '/' && !inClass) break; + } + if (end >= safe.length || safe[end] !== '/') return -1; + return REGEX_END_AFTER.test(safe.slice(end + 1, end + 24)) ? end : -1; +} + + + + +function functionBodyEnd(text: string, fnAt: number): number { + const paramsOpen = text.indexOf('(', fnAt); + const paramsClose = paramsOpen >= 0 ? matchDelim(text, paramsOpen, '(', ')') : -1; + if (paramsClose < 0) return -1; + const braceAt = text.indexOf('{', paramsClose); + return braceAt >= 0 ? matchDelim(text, braceAt, '{', '}') : -1; +} + +/** `args` split at its top-level commas, each piece with its offset into `args`. */ +function topLevelArgs(args: string): Array<{ text: string; start: number }> { + const out: Array<{ text: string; start: number }> = []; + let depth = 0; let start = 0; for (let i = 0; i < args.length; i++) { const ch = args[i]; @@ -411,57 +482,125 @@ function splitTopLevel(args: string): string[] { } continue; } + if (ch === '<' && i > 0 && /[\w$]/.test(args[i - 1]!)) { + // A type-argument list (`asyncHandler(`): its commas split nothing. + const generic = /^<[^()<>]*(?:<[^()<>]*>[^()<>]*)*>\s*\(/.exec(args.slice(i)); + if (generic) { + i += generic[0].length - 2; + continue; + } + } if (ch === '(' || ch === '[' || ch === '{') depth++; else if (ch === ')' || ch === ']' || ch === '}') depth--; else if (ch === ',' && depth === 0) { - out.push(args.slice(start, i)); + out.push({ text: args.slice(start, i), start }); start = i + 1; } } - out.push(args.slice(start)); + out.push({ text: args.slice(start), start }); return out; } /** - * The body of a handler written inline as a registration's LAST argument — an - * arrow (`async (req, res) => {…}`, `req => …`) or a function expression - * (`function (req, res) {…}`, the form Express's own examples use), bare or - * inside a wrapper call (`asyncHandler(async (req, res) => {…})`). `start` is - * the body's offset into `args`. Null when the last argument names a handler - * instead: an inline middleware before a named handler is not the handler. + * What a route call's handler contributes. The handler is the LAST argument — + * the ones before it are middleware, and an arrow inside one of those + * (`rateLimit({ keyGenerator: (req) => req.ip })`, `(req, res, next) => next()`) + * says nothing about the handler. A handler that is a function — an arrow, a + * `function` expression, or one wrapped in a call (`asyncHandler(async (req, + * res) => …)`) — is anonymous, so its body's calls are attributed to the route + * as `calls` edges and `trace(route, service)` connects: the body is the + * balanced `{…}` after the arrow or parameter list, or an arrow's expression + * tail. Anything else is a named handler: a `references` edge to its last name + * (`listUsers`, `userController.list`). + * + * `argsStart` is where `args` begins in `safe`, for the reply-call offsets. */ -function inlineHandlerBody(args: string): { body: string; start: number } | null { - // A trailing comma (Prettier's default) leaves an empty last part. - const parts = splitTopLevel(args); - let lastStart = args.length; - let last = ''; - for (let i = parts.length - 1, end = args.length; i >= 0; i--) { - const start = end - parts[i]!.length; - if (parts[i]!.trim() !== '') { - last = parts[i]!; - lastStart = start; - break; +function handlerRefs( + safe: string, + argsStart: number, + args: string, + fromNodeId: string, + line: number, + filePath: string, + language: 'typescript' | 'javascript' +): UnresolvedRef[] { + // A trailing options object (`…, handler, { cache: true }`) is not the + // handler; a trailing array of handlers (`[validate, ctrl.create]`) is read + // as its last element. + const parts = topLevelArgs(args).filter((a) => a.text.trim()); + while (parts.length > 1 && /^\s*\{/.test(parts[parts.length - 1]!.text)) parts.pop(); + const lastPart = parts[parts.length - 1]; + const arrayAt = lastPart ? lastPart.text.search(/\S/) : -1; + if (lastPart && lastPart.text[arrayAt] === '[') { + const close = matchDelim(lastPart.text, arrayAt, '[', ']'); + const inner = close > arrayAt ? topLevelArgs(lastPart.text.slice(arrayAt + 1, close)).filter((a) => a.text.trim()).pop() : undefined; + if (inner) parts[parts.length - 1] = { text: inner.text, start: lastPart.start + arrayAt + 1 + inner.start }; + else parts.pop(); + } + const handler = parts[parts.length - 1]; + if (!handler) return []; + const text = handler.text; + // A `function` is the handler's own only when it opens the argument — + // directly, or inside wrapper calls (`asyncHandler(function …)`); then its + // body starts after its parameter list, not after an arrow inside it + // (`items.map((i) => …)`). A `function` anywhere else (a default parameter, + // a wrapper's options object) is not the handler. + let arrowAt = text.indexOf('=>'); + const opening = /^\s*(?:[A-Za-z_$][\w$.]*\s*\(\s*)*(?:async\s+)?function\b[^(]*\(/.exec(text); + let fnAt = opening ? opening[0].lastIndexOf('function') : -1; + if (fnAt >= 0) { + // Inside a wrapper, a `function` followed by an arrow after its body is a + // callback argument (`withErrors(function onErr(e) {…}, async (req, res) => …)`): + // the arrow is the handler. + const fnBodyEnd = functionBodyEnd(text, fnAt); + const insideWrapper = opening![0].slice(0, fnAt).includes('('); + const laterArrow = fnBodyEnd >= 0 && insideWrapper ? text.indexOf('=>', fnBodyEnd) : -1; + if (laterArrow >= 0) { + arrowAt = laterArrow; + fnAt = -1; + } else if (insideWrapper && fnBodyEnd >= 0 && !/^[\s)]*$/.test(text.slice(fnBodyEnd + 1))) { + // A callback followed by more arguments (`withErrors(function onErr(e) + // {…}, ctrl.list)`) is not the handler. + arrowAt = -1; + fnAt = -1; + } else { + arrowAt = -1; } - end = start - 1; } - const arrowAt = last.indexOf('=>'); - const fn = /(?:^|[^\w$.])function\b\s*\*?\s*[\w$]*\s*\(/.exec(last); - if (fn && (arrowAt < 0 || fn.index < arrowAt)) { - const open = fn.index + fn[0].length - 1; - const close = matchDelim(last, open, '(', ')'); - const brace = close > open ? last.indexOf('{', close) : -1; - const end = brace >= 0 ? matchDelim(last, brace, '{', '}') : -1; - if (end < 0) return null; - return { body: last.slice(brace + 1, end), start: lastStart + brace + 1 }; + + if (arrowAt < 0 && fnAt < 0) { + const handlerName = extractTailIdent(text.trim()) ?? extractTailIdent(text.slice(text.lastIndexOf(',') + 1).trim()); + return handlerName + ? [{ fromNodeId, referenceName: handlerName, referenceKind: 'references', line, column: 0, filePath, language }] + : []; } - if (arrowAt < 0) return null; - const afterArrow = last.slice(arrowAt + 2); - const braceAt = afterArrow.indexOf('{'); - if (braceAt >= 0 && afterArrow.slice(0, braceAt).trim() === '') { - const end = matchDelim(afterArrow, braceAt, '{', '}'); - if (end > braceAt) return { body: afterArrow.slice(braceAt + 1, end), start: lastStart + arrowAt + 2 + braceAt + 1 }; + + let bodyFrom = arrowAt + 2; + if (arrowAt < 0) { + const paramsOpen = text.indexOf('(', fnAt); + const paramsClose = matchDelim(text, paramsOpen, '(', ')'); + bodyFrom = paramsClose > paramsOpen ? paramsClose + 1 : text.length; } - return { body: afterArrow, start: lastStart + arrowAt + 2 }; + const afterParams = text.slice(bodyFrom); + let body = afterParams; + let bodyStart = argsStart + handler.start + bodyFrom; + const braceAt = afterParams.indexOf('{'); + if (braceAt >= 0 && afterParams.slice(0, braceAt).trim() === '') { + const end = matchDelim(afterParams, braceAt, '{', '}'); + if (end > braceAt) { + body = afterParams.slice(braceAt + 1, end); + bodyStart += braceAt + 1; + } + } + + // Positions came from the blanked structure; the calls are read from `safe` + // at the same offsets, so a call inside a template interpolation counts. + const source = safe.slice(bodyStart, bodyStart + body.length); + const refs: UnresolvedRef[] = handlerCallNames(source).map((name) => ( + { fromNodeId, referenceName: name, referenceKind: 'calls', line, column: 0, filePath, language } + )); + refs.push(...replyRefs(safe, bodyStart, bodyStart + body.length, fromNodeId, filePath, language)); + return refs; } /** `/api` + `/users` → `/api/users`; `/api/` + `/` → `/api`. */ diff --git a/src/resolution/strip-comments.ts b/src/resolution/strip-comments.ts index 341cf7032..104a034de 100644 --- a/src/resolution/strip-comments.ts +++ b/src/resolution/strip-comments.ts @@ -23,6 +23,13 @@ * framework extractors scan for. */ +/** + * Text before a `/` (up to ~32 chars) after which that `/` can open a regex + * literal rather than divide: expression-start punctuation or a keyword. + * A heuristic — callers that act on a guess must tolerate it being wrong. + */ +export const REGEX_START_BEFORE = /(?:^|[=(:,)!&|?;{}\[\]+*%~^<>-]|\b(?:return|throw|case|yield|await|else|do|typeof|void|delete|new|in|of|instanceof))\s*$/; + /** * Blank string contents while preserving quotes and offsets. Template * interpolations are blanked too; callers checking executable expressions @@ -38,7 +45,7 @@ export function blankStringContents(text: string, regexLiterals?: number[]): str const c = text[i]!; // A quote inside a JS regex is data, not the beginning of a string. // Expression-start punctuation and keywords distinguish these from division. - if (c === '/' && /(?:^|[=(:,)!&|?;{}\[\]+*%~^<>-]|\b(?:return|throw|case|yield|await|else|do|typeof|void|delete|new|in|of|instanceof))\s*$/.test(text.slice(Math.max(0, i - 32), i))) { + if (c === '/' && REGEX_START_BEFORE.test(text.slice(Math.max(0, i - 32), i))) { let end = i + 1; let inClass = false; for (; end < n && text[end] !== '\n'; end++) {