From 0684cc02a46016ed9c9b00f3594c18e423d285bf Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 22:54:28 -0600 Subject: [PATCH 1/2] feat(routing): discover static routes across HTTP frameworks --- CHANGELOG.md | 2 + __tests__/http-routing.test.ts | 335 +++++++++ __tests__/nuxt-routes.test.ts | 114 ++++ docs/design/PLAN-framework-routing.md | 11 + docs/design/framework-coverage.md | 34 + .../content/docs/guides/framework-routes.md | 16 +- src/resolution/frameworks/express.ts | 4 + src/resolution/frameworks/http-routing.ts | 633 ++++++++++++++++++ src/resolution/frameworks/index.ts | 3 + src/resolution/frameworks/vue-router.ts | 2 +- src/resolution/frameworks/vue.ts | 116 +++- 11 files changed, 1236 insertions(+), 34 deletions(-) create mode 100644 __tests__/http-routing.test.ts create mode 100644 __tests__/nuxt-routes.test.ts create mode 100644 docs/design/PLAN-framework-routing.md create mode 100644 src/resolution/frameworks/http-routing.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 46416774c..59cd81086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- Endpoint discovery now recognizes literal routes in Hono, Elysia, Fastify, Hyper-Express, Koa router, H3, Bun, Effect v4 and option-free Vixeny builders, and correctly reads Nuxt 4 page groups and server route methods after re-indexing. + - **Markdown is indexed, and a documentation question gets the section, not the graph.** Every `.md` file's headings, sections, tables and links are nodes (the extractor from #361), and a doc-shaped `codegraph_explore` query that names a markdown file now renders that file's best sections first and whole — the top three by idf-weighted line hits, a heading the query covers word for word counted as named, 8k characters per file — with the blast-radius, relationships and "additional files" blocks held back unless a code file rendered too. Measured on a 109-file docs corpus under headless Claude Code, 36 cells over three rounds: the right file and section in every call, median 1 tool call against 4 for Grep-then-Read, 36 of 36 correct. Code answers keep their shape: markdown nodes leave a subgraph the doc tier did not seed, a markdown body is never mistaken for a generated-file header, and the explore budget tiers count code files only, so a README-heavy repo does not cross a breakpoint. The server instructions say markdown is indexed, which the branch's own text still denied. (#361, #1439) - **Your earlier agent sessions are searchable: `codegraph sessions` and the `codegraph_sessions` tool.** A code graph answers "how does X work"; it cannot answer "why is X like this" or "what did the last session decide about Y" — that history lives in the transcripts the agent already wrote, hundreds of megabytes of JSONL nobody greps. CodeGraph now indexes the prose of a project's Claude Code sessions (`~/.claude/projects//`: prompts, replies and compaction summaries; tool calls, results and thinking stay out) into an FTS5 table with porter stemming and BM25 rank, in its own `.codegraph/sessions.db` beside the graph. The index refreshes on each call for files whose size or mtime moved, so a query after one live session costs tens of milliseconds and the first index of a few hundred transcripts about a second. A hit names its session id, title, role, time and the matching passage; `role`, `sinceDays`, `session` (an id prefix) and `any` (OR the words) narrow or widen it. The tool joins `codegraph_explore` in the default MCP surface — a different question over a different corpus, so it cannot steer a mis-pick against explore — and the CLI command prints the same text for subagents without MCP. `"sessions": false` in `codegraph.json` opts a project out; `CODEGRAPH_SESSIONS_DIR` points at another transcript directory. Readers are one module per agent host, Claude Code first. diff --git a/__tests__/http-routing.test.ts b/__tests__/http-routing.test.ts new file mode 100644 index 000000000..30d30e0a1 --- /dev/null +++ b/__tests__/http-routing.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { extractHttpRoutes, httpRoutingResolver } from '../src/resolution/frameworks/http-routing'; +import { expressResolver } from '../src/resolution/frameworks/express'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); +}); + +const extract = (source: string) => extractHttpRoutes('server.ts', source); +const names = (source: string) => + extract(source) + .nodes.map((n) => n.name) + .sort(); + +describe('HTTP framework declarations', () => { + it.each([ + [ + 'Hono', + "import { Hono as Web } from 'hono'; const api = new Web(); api.get('/users/:id', auth, handler)", + 'GET /users/:id', + ], + [ + 'Elysia', + "import { Elysia } from 'elysia'; new Elysia().get('/users', handler, { beforeHandle: auth })", + 'GET /users', + ], + [ + 'Fastify', + "import Fastify from 'fastify'; const api = Fastify(); api.get('/users', { schema: {} }, handler)", + 'GET /users', + ], + [ + 'Hyper-Express', + "import HyperExpress from 'hyper-express'; const api = new HyperExpress.Server(); api.get('/users', { max_body_length: 10 }, auth, handler)", + 'GET /users', + ], + [ + 'Koa', + "import Router from '@koa/router'; const api = new Router(); api.get('users', '/users', auth, handler)", + 'GET /users', + ], + [ + 'H3', + "import { H3 } from 'h3'; new H3().get('/users', handler, { meta: { tag: 'users' } })", + 'GET /users', + ], + [ + 'Vixeny', + "import { wrap } from 'vixeny'; wrap()().get({path:'/users', f:handler})", + 'GET /users', + ], + ['Bun', "Bun.serve({routes:{'/users':handler}})", 'ANY /users'], + [ + 'Effect', + "import { HttpRouter as HTTP } from 'effect/unstable/http'; HTTP.add('GET', '/users', handler)", + 'GET /users', + ], + ])('%s finds its handler, not middleware or trailing options', (_framework, source, route) => { + const result = extract(source); + expect(result.nodes.map((n) => n.name)).toEqual([route]); + expect(result.references).toEqual([ + expect.objectContaining({ + fromNodeId: result.nodes[0].id, + referenceName: 'handler', + referenceKind: 'references', + }), + ]); + }); + + it('supports CommonJS factory and namespace imports', () => { + expect(names("const fastify = require('fastify')(); fastify.head('/ready', handler)")).toEqual([ + 'HEAD /ready', + ]); + expect(names("const { Hono: Web } = require('hono'); new Web().get('/x', handler)")).toEqual([ + 'GET /x', + ]); + expect( + names( + "const HyperExpress = require('hyper-express'); new HyperExpress.Router().post('/x', handler)", + ), + ).toEqual(['POST /x']); + expect( + names( + "import * as HTTP from 'effect/unstable/http/HttpRouter'; HTTP.route('HEAD','/x',handler)", + ), + ).toEqual(['HEAD /x']); + }); + + it('reads literal method/path arrays and fluent chains', () => { + expect( + names( + `import {Hono} from 'hono'; new Hono().on(['GET','POST'], ['/a','/b'], handler).delete('/c', handler)`, + ), + ).toEqual(['DELETE /c', 'GET /a', 'GET /b', 'POST /a', 'POST /b']); + expect( + names( + "import Hyper from 'hyper-express'; new Hyper.Router().route('/x').get(handler).post(handler)", + ), + ).toEqual(['GET /x', 'POST /x']); + expect( + names("import {H3} from 'h3'; new H3().on('PATCH','/x',handler).all('/any',handler)"), + ).toEqual(['ANY /any', 'PATCH /x']); + }); + + it('reads Fastify options objects, shorthand handlers, and method definitions', () => { + const result = extract(`import Fastify from 'fastify'; const api=Fastify(); +api.route({method:['GET','HEAD'],url:'/a',handler}); +api.post('/b',{handler}); +api.route({method:'PUT',url:'/c',handler(req,reply){ save(req); }});`); + expect(result.nodes.map((n) => n.name)).toEqual(['GET /a', 'HEAD /a', 'POST /b', 'PUT /c']); + expect(result.references.map((r) => r.referenceName)).toEqual([ + 'handler', + 'handler', + 'handler', + 'save', + ]); + }); + + it('reads Bun method tables, imported serve, and static responses without invented handlers', () => { + const result = extract(`import {serve as start} from 'bun'; start({routes:{ +'/x': {GET: handler, POST(req){ save(req); }}, +'/health': new Response('ok'), '/files/*': {dir:'./public'}, '/fallback': false +}});`); + expect(result.nodes.map((n) => n.name)).toEqual([ + 'GET /x', + 'POST /x', + 'ANY /health', + 'ANY /files/*', + ]); + expect(result.references.map((r) => r.referenceName)).toEqual(['handler', 'save']); + }); + + it('finds Bun declarations assigned to a mutable server handle', () => { + expect( + names(`let server; beforeAll(() => { server = Bun.serve({ routes: { '/x': handler } }); });`), + ).toEqual(['ANY /x']); + }); + + it('keeps static Elysia responses and uses the f property of Vixeny route objects', () => { + const result = extract(`import {Elysia} from 'elysia'; import {wrap} from 'vixeny'; +new Elysia().get('/text', 'ok').route('PUT','/object',{ok:true},{beforeHandle:auth}); +wrap()().route({method:'PATCH',path:'/v',f:handler,resolve:{x:{f:unrelated}}});`); + expect(result.nodes.map((n) => n.name)).toEqual(['GET /text', 'PUT /object', 'PATCH /v']); + expect(result.references.map((r) => r.referenceName)).toEqual(['handler']); + }); + + it('attributes calls from inline handlers to their own endpoint at the actual call location', () => { + const result = extract(`import {Hono} from 'hono'; const api=new Hono(); +api.get('/a', () => { + return load(); +}); +api.post('/b', function () { return save(); });`); + expect(result.references.map((r) => [r.referenceName, r.line])).toEqual([ + ['load', 3], + ['save', 5], + ]); + expect(result.references[0].fromNodeId).toBe(result.nodes[0].id); + expect(result.references[1].fromNodeId).toBe(result.nodes[1].id); + }); + + it('composes same-file mounts without also publishing the relative child routes', () => { + expect( + names(`import {Hono} from 'hono'; const child=new Hono(); child.get('/x', handler); +const api=new Hono().basePath('/api'); api.route('/v1',child); api.route('/v2',child);`), + ).toEqual(['GET /api/v1/x', 'GET /api/v2/x']); + expect( + names(`import Router from '@koa/router'; const child=new Router(); child.get('/x',handler); +const api=new Router({prefix:'/api'}); api.use('/v1',child.routes());`), + ).toEqual(['GET /api/v1/x']); + expect( + names(`import Hyper from 'hyper-express'; const child=new Hyper.Router(); child.get('/x',handler); +const api=new Hyper.Server(); api.use('/api',child);`), + ).toEqual(['GET /api/x']); + }); + + it('merges Hono root paths and snapshots basePath aliases when mounting', () => { + expect( + names(`import {Hono} from 'hono'; const child=new Hono(); child.get('/',handler); +const api=child.basePath('/v1'); api.get('/b',handler); +const root=new Hono(); root.route('/book',child); child.get('/late',handler);`), + ).toEqual(['GET /book', 'GET /book/v1/b']); + }); + + it('applies scoped group/register prefixes and isolates callback parameters', () => { + expect( + names( + `import {Elysia} from 'elysia'; new Elysia({prefix:'/api'}).group('/v1', app => app.get('/x',handler)).get('/y',handler)`, + ), + ).toEqual(['GET /api/v1/x', 'GET /api/y']); + expect( + names( + `import Fastify from 'fastify'; const api=Fastify(); api.register(async (instance) => { instance.get('/x',handler) }, {prefix:'/api'});`, + ), + ).toEqual(['GET /api/x']); + expect( + names( + `import {wrap} from 'vixeny'; wrap({wrap:{startsWith:'/api'}})().get({path:'/x',f:handler})`, + ), + ).toEqual([]); + }); + + it('does not turn comments, strings, regexes, or unrelated methods into endpoints', () => { + expect( + names(`import {Hono} from 'hono'; const api=new Hono(); +// api.get('/comment',handler) +const text="api.get('/string',handler)"; +const pattern=/api.get('regex',handler)/; +new Map().get('/map'); const unrelated={get(){}}; unrelated.get('/fake',handler); +api.get('/real',handler);`), + ).toEqual(['GET /real']); + }); + + it('rejects shadowed constructors/receivers and mutable bindings', () => { + expect( + names(`import {Hono} from 'hono'; const api=new Hono(); +function unrelated(api) { api.get('/fake',handler) } +function factory(Hono) { new Hono().get('/fake2',handler) } +function rest(...api) { api.get('/rest',handler) } +try {} catch (api) { api.get('/catch',handler) } +{ const api=new Map(); api.get('/fake3',handler) } +let changing=new Hono(); changing=other; changing.get('/fake4',handler); +api.get('/real',handler);`), + ).toEqual(['GET /real']); + expect(names(`function boot(Bun) { Bun.serve({routes:{'/fake':handler}}) }`)).toEqual([]); + }); + + it('leaves dynamic paths, prefixes, and spread options unresolved', () => { + expect( + names(`import {Elysia} from 'elysia'; import Fastify from 'fastify'; +new Elysia({prefix:env.PREFIX}).get('/x',handler); +new Elysia().group(prefix, app=>app.get('/y',handler)); +new Elysia().get('/'+id,handler); +new Elysia({...config}).get('/unknown-prefix',handler); +Fastify().register(app=>app.get('/unknown-prefix',handler), options); +Fastify().route({method:'GET',url:'/x',handler,...options});`), + ).toEqual([]); + }); + + it('does not bind member handlers or callback-local calls to unrelated global names', () => { + const result = extract(`import {Hono} from 'hono'; const api=new Hono(); +api.get('/member', controller.handler); +api.get('/parameter', (load) => load()); +api.get('/local', () => { const load=other; return load(); });`); + expect(result.nodes).toHaveLength(3); + expect(result.references).toEqual([]); + }); + + it('does not propagate router identity through unrelated return values or shadowed require', () => { + expect( + names(`import {Hono} from 'hono'; import Router from '@koa/router'; +new Hono().request('/').get('/fake',handler); +new Router().routes().get('/fake',handler); +function build(require) { const api=require('fastify')(); api.get('/fake',handler); }`), + ).toEqual([]); + }); + + it('does not reinterpret framework lookups, middleware, or unsupported methods', () => { + expect( + names(`import Router from '@koa/router'; import {H3} from 'h3'; import {wrap} from 'vixeny'; import {HttpRouter} from 'effect/unstable/http'; +const koa=new Router(); koa.route('name'); koa.on('error',handler); +new H3().use('/middleware',handler); wrap()().patch({path:'/fake',f:handler}); +HttpRouter.add('HEAD','/unsupported',handler);`), + ).toEqual([]); + }); + + it('keeps Express extraction from duplicating foreign route calls in a mixed file', () => { + const source = + "import {Hono} from 'hono'; import express from 'express'; const app=new Hono(); const router=express.Router(); app.get('/hono',handler); router.get('/express',handler)"; + expect(extract(source).nodes.map((n) => n.name)).toEqual(['GET /hono']); + expect(expressResolver.extract!('server.ts', source).nodes.map((n) => n.name)).toEqual([ + 'GET /express', + ]); + const chain = + "import Hyper from 'hyper-express'; const router=new Hyper.Router(); router.route('/x').get(handler)"; + expect(expressResolver.extract!('server.ts', chain).nodes).toEqual([]); + }); +}); + +describe('HTTP routes through indexing and resolution', () => { + let cg: CodeGraph | undefined; + let dir: string | undefined; + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it.each([ + ['hono', "import {Hono} from 'hono'; new Hono().get('/x',handler);"], + ['fastify', "import Fastify from 'fastify'; const app=Fastify(); app.get('/x',handler);"], + ['elysia', "import {Elysia} from 'elysia'; new Elysia().get('/x',handler);"], + ['hyper-express', "import Hyper from 'hyper-express'; new Hyper.Server().get('/x',handler);"], + ['@koa/router', "import Router from '@koa/router'; new Router().get('/x',handler);"], + ['h3', "import {H3} from 'h3'; new H3().get('/x',handler);"], + ['vixeny', "import {wrap} from 'vixeny'; wrap()().get({path:'/x',f:handler});"], + [ + 'effect', + "import {HttpRouter} from 'effect/unstable/http'; HttpRouter.add('GET','/x',handler);", + ], + ['@types/bun', "Bun.serve({routes:{'/x':{GET:handler}}});"], + ])('%s emits exactly one route and resolves its imported handler', async (pkg, source) => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-http-route-')); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: { [pkg]: '*' } }), + ); + fs.writeFileSync(path.join(dir, 'handler.ts'), 'export function handler() { return 1; }'); + fs.writeFileSync(path.join(dir, 'server.ts'), "import {handler} from './handler';\n" + source); + cg = await CodeGraph.init(dir, { index: true }); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name)).toEqual(['GET /x']); + const handler = cg.getNodesByKind('function').find((n) => n.name === 'handler'); + expect(handler).toBeDefined(); + expect(cg.getOutgoingEdges(routes[0].id)).toContainEqual( + expect.objectContaining({ target: handler!.id, kind: 'references' }), + ); + }); + + it('detects a standalone Bun server without a dependency manifest', () => { + expect( + httpRoutingResolver.detect({ + getAllFiles: () => ['server.ts'], + fileExists: () => false, + readFile: (f: string) => (f === 'server.ts' ? "Bun.serve({routes:{'/':handler}})" : null), + } as any), + ).toBe(true); + }); +}); diff --git a/__tests__/nuxt-routes.test.ts b/__tests__/nuxt-routes.test.ts new file mode 100644 index 000000000..359e61c1d --- /dev/null +++ b/__tests__/nuxt-routes.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { vueResolver } from '../src/resolution/frameworks/vue'; +import { vueRouteTable } from '../src/resolution/frameworks/vue-router'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'javascript']); +}); + +describe('Nuxt default file routes', () => { + it.each([ + ['pages/index.vue', '/'], + ['app/pages/index.vue', '/'], + ['app/pages/users/index.vue', '/users'], + ['app/pages/(marketing)/about.vue', '/about'], + ['app/pages/users/[id].vue', '/users/:id'], + ['app/pages/[[slug]].vue', '/:slug?'], + ['app/pages/[...slug].vue', '/*slug'], + ['apps/site/app/pages/users-[group]/[id].vue', '/users-:group/:id'], + ['app\\pages\\index.vue', '/'], + ['server/api/index.get.ts', 'GET /api'], + ['server/api/users/[id].post.ts', 'POST /api/users/:id'], + ['server/api/users/index.ts', 'ANY /api/users'], + ['server/routes/health.get.js', 'GET /health'], + ['server/routes/index.ts', 'ANY /'], + ['server/routes/files/[...path].ts', 'ANY /files/*path'], + ['server/api/[...].ts', 'ANY /api/*'], + ])('%s becomes %s', (file, route) => { + expect( + vueResolver.extract!(file, 'export default defineEventHandler(() => "ok")').nodes.map( + (n) => n.name, + ), + ).toEqual([route]); + }); + + it.each([ + 'server/api/README.md', + 'server/api/types.d.ts', + 'server/utils/health.ts', + 'components/Index.vue', + ])('does not turn %s into an endpoint', (file) => { + expect(vueResolver.extract!(file, '').nodes).toEqual([]); + }); + + it('binds a default export and a wrapped handler without treating wrapper options as handlers', () => { + for (const source of [ + 'export default handler', + 'export default defineEventHandler(handler)', + 'export default eventHandler(handler)', + 'export default defineEventHandler({ onRequest: middleware, handler })', + ]) { + const result = vueResolver.extract!('server/api/x.get.ts', source); + expect(result.references).toEqual([ + expect.objectContaining({ + fromNodeId: result.nodes[0].id, + referenceName: 'handler', + referenceKind: 'references', + }), + ]); + } + for (const source of [ + 'export default defineEventHandler(() => load())', + 'export default defineEventHandler({ onRequest: middleware, handler() { return load(); } })', + ]) { + expect(vueResolver.extract!('server/api/x.ts', source).references).toEqual([ + expect.objectContaining({ referenceName: 'load', referenceKind: 'calls' }), + ]); + } + }); + + it('includes root-level pages in the Vue navigation route table', () => { + const nodes = vueResolver.extract!('pages/index.vue', '').nodes; + const table = vueRouteTable({ getNodesByKind: () => nodes } as any); + expect(table.byRoot.get('')?.exact.get('/')).toBe(nodes[0]); + }); +}); + +describe('Nuxt server routes through indexing', () => { + let graph: CodeGraph | undefined; + let dir: string | undefined; + afterEach(() => { + graph?.close(); + graph = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it('resolves a method-qualified endpoint to its imported handler', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nuxt-routes-')); + fs.mkdirSync(path.join(dir, 'server/api'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: { nuxt: '^4.0.0' } }), + ); + fs.writeFileSync(path.join(dir, 'handler.ts'), 'export function handler() { return 1; }'); + fs.writeFileSync( + path.join(dir, 'server/api/users.get.ts'), + "import { handler } from '../../handler'; export default defineEventHandler(handler);", + ); + graph = await CodeGraph.init(dir, { index: true }); + const routes = graph.getNodesByKind('route'); + expect(routes.map((n) => n.name)).toEqual(['GET /api/users']); + const handler = graph.getNodesByKind('function').find((n) => n.name === 'handler'); + expect(handler).toBeDefined(); + expect(graph.getOutgoingEdges(routes[0].id)).toContainEqual( + expect.objectContaining({ target: handler!.id, kind: 'references' }), + ); + }); +}); diff --git a/docs/design/PLAN-framework-routing.md b/docs/design/PLAN-framework-routing.md new file mode 100644 index 000000000..ffb950807 --- /dev/null +++ b/docs/design/PLAN-framework-routing.md @@ -0,0 +1,11 @@ +Status: 3/5 — validating examples, controls and build + +- [x] 1 Update fork/consolidated — 0c4664a; upstream main and latest integrated PR heads included; only Markdown language docs change the tree. +- [x] 2 Add static HTTP routing for Hono, Elysia, Fastify, Hyper-Express, Koa, H3, Bun, Effect v4, Vixeny — 33 focused extraction and end-to-end tests pass; TypeScript passes. +- [x] 3 Repair Nuxt default file routes and verify existing Next Pages support — 115 focused HTTP, Nuxt, Next and Vue tests pass; build passes. +- [ ] 4 Validate real examples, unaffected controls, full suite, build, and update coverage/docs — gate: recorded outcomes, no unsupported coverage claims. +- [ ] 5 Review and open PR against fork/consolidated — gate: final diff and remote PR verified. + +Acceptance: literal routes produce method-qualified endpoint nodes and correct handler references; unrelated methods, shadowed bindings, and computed paths produce no fabricated routes. Preserve existing Express and Next behavior. No new dependencies, execution of application code, or arbitrary dynamic path evaluation. + +New file justification: `src/resolution/frameworks/http-routing.ts` owns import-aware HTTP declarations across these frameworks. `express.ts` implements Express-specific text matching and middleware/mount rules; extending it would conflate incompatible APIs. Use the existing tree-sitter parser, not another parser or scanner. Merge back only if the existing Express implementation adopts the same binding-aware extraction. Dedicated tests belong in `__tests__/http-routing.test.ts`. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index abf6a2346..558f163e1 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -52,6 +52,40 @@ despite where it lives) and the object-literal walker in --- +## Static JavaScript HTTP declarations + +Added 2026-09-06 in `frameworks/http-routing.ts`: Hono, Elysia, Fastify, +Hyper-Express, Koa router, H3, Bun, Effect v4 and option-free Vixeny builders. +These produce method-qualified endpoint nodes, named-handler references and +direct calls from inline handlers. They do not add Screens navigation. +`http-routing.test.ts` covers all nine through full indexing and imported-handler +resolution, as well as false-positive controls, prefixes and same-file mounts. + +Nuxt file routing in `frameworks/vue.ts` now includes root index pages, Nuxt 4 +route groups, server method suffixes, `server/routes/` and server catch-all +segments. `nuxt-routes.test.ts` checks extraction and imported-handler resolution; +the existing Next Pages/App Router and Vue navigation tests remain controls. +This update does not re-verify the older coverage rows above. + +Untouched official source checks (routes and indexing, no application execution): + +| Framework | Pinned source | Scope | +|---|---|---| +| Hono | [examples basic](https://github.com/honojs/examples/blob/3b0b62875a0e1265763fea1c6388866d5697ef81/basic/src/index.ts) | 16 registrations, including 3 mounted paths | +| Fastify | [winston logger](https://github.com/fastify/example/blob/d3032da0b307afa8749e967aa0dbdf239c348341/winston-logger/winston-logger.js) | `GET /hello` | +| Elysia | [CORS example](https://github.com/elysiajs/elysia-cors/blob/58adc6030a3c790e2494e2e8bd45dd7938b9b024/example/index.ts) | `POST /` | +| H3 | [router example](https://github.com/h3js/h3/blob/a5fdc86a6075506d71510aa5208739aa0b2bec29/examples/router.mjs) | 6 explicit methods at `/` | +| Bun | [serve route tests](https://github.com/oven-sh/bun/blob/d316760e8cae0d69ae927898d5afc933ecf34671/test/js/bun/http/bun-serve-routes.test.ts) | Extraction of 9 declarations starting in lines 1–135 | +| Effect v4 | [HTTP server tests](https://github.com/Effect-TS/effect-smol/blob/3a1128c7684e04d34d9f541f77adaac38a513056/packages/platform-node/test/NodeHttpServer.test.ts) | Extraction of 3 declarations starting in lines 1–90 | + +These are bounded fixtures, not whole-framework recall measurements. Hyper-Express, +Koa and Vixeny have synthetic indexing tests only: the inspected official fixtures +import relative framework source, which the package-provenance reader deliberately +does not infer. Dynamic paths, cross-file mounts, runtime mutation and plugin +factories remain outside coverage. Vixeny options require terminal-operation +dataflow and are omitted. See the [route guide](../../site/src/content/docs/guides/framework-routes.md) +for the supported declaration shapes and Nuxt configuration limits. + ## What is left Ordered by cost-to-value. Each row says what is missing, not merely that diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index 05b1d583b..95b45eda5 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -11,6 +11,15 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Flask** | `@app.route('/path', methods=[...])`, blueprint routes | | **FastAPI** | `@app.get(...)`, `@router.post(...)`, all standard methods | | **Express** | `app.get(...)`, `router.post(...)` with middleware chains | +| **Hono** | Imported `Hono` instances, method/path arrays, `basePath()` and same-file `.route()` mounts | +| **Elysia** | Imported `Elysia` instances, method calls, `.route()`, literal constructor prefixes and `.group()` callbacks | +| **Fastify** | Imported factories, shorthand methods, `.route({ method, url, handler })`, inline `.register()` callbacks with literal prefixes | +| **Hyper-Express** | Imported `Server` / `Router`, method calls, `.route(path)` chains and same-file `.use()` mounts | +| **Koa router** | `@koa/router` / `koa-router` instances, named routes, literal prefixes and same-file `.use(path, child.routes())` mounts | +| **H3** | Imported `H3`, `createRouter` / `createApp`, method calls, `.on()` / `.all()` and same-file router mounts | +| **Bun** | `Bun.serve()` or imported `serve()` with a literal `routes` table; direct handlers, method tables and static responses | +| **Effect v4** | `HttpRouter.add(method, path, handler)` / `.route()` from `effect/unstable/http` or its `HttpRouter` submodule | +| **Vixeny** | Option-free `wrap()()` builders with `.get/.post/.put/.delete` or `.route({ method, path, f })` | | **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` | | **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax | | **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` | @@ -22,7 +31,12 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **ASP.NET** | `[HttpGet("/x")]` attributes on action methods | | **Vapor** | `app.get("x", use: handler)` | | **React Router** / **SvelteKit** | Route component nodes | -| **Vue Router** / **Nuxt** | `pages/` file-based routes, `server/api/` endpoints, route middleware | +| **Next.js** | App Router and Pages Router pages; `app/api/**/route.ts` method exports and `pages/api/**` default handlers | +| **Vue Router** / **Nuxt** | Vue route tables; `.vue` pages in `pages/` or Nuxt 4 `app/pages/`, dynamic/optional/catch-all segments and route groups; `server/api/` and `server/routes/` with method suffixes; route middleware | | **Astro** | `src/pages/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) | Route resolution is automatic — there's nothing to configure. If a framework file is recognized, its routes appear in the graph after the next index or sync. + +The JavaScript HTTP readers require a recognized package import (ES modules or CommonJS), except for the global `Bun.serve`. They follow immutable local router bindings and literal declarations, without executing your application. Named handlers produce references; direct calls inside inline handlers produce call edges. Static responses have an endpoint without an invented handler. Member handlers remain unresolved by this reader. + +Computed paths, spread configuration, cross-file mounts, plugin factories, mutable router aliases, and runtime method replacement are outside this static reading. Imports and captured router bindings must precede their use in source. Vixeny builders with options are omitted because their effective paths depend on the terminal operation. Nuxt custom route configuration, page metadata overrides, non-Vue page extensions, and custom server handler wrappers are not interpreted. Re-index after upgrading to add the new endpoints to an existing graph. diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index 8980d29e2..7e353f5ac 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -9,6 +9,7 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from import { stripCommentsForRegex } from '../strip-comments'; import { resolveImportPath } from '../import-resolver'; import { dependsOn } from './package-deps'; +import { extractHttpRoutes } from './http-routing'; function extractTailIdent(expr: string): string | null { const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, ''); @@ -157,12 +158,14 @@ export const expressResolver: FrameworkResolver = { const now = Date.now(); const lang = detectLanguage(filePath); const safe = stripCommentsForRegex(content, lang); + const foreignCalls = extractHttpRoutes(filePath, content).callStarts; // 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.) const head = /\b(app|router)\s*\.\s*(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g; let match: RegExpExecArray | null; while ((match = head.exec(safe)) !== null) { + if (foreignCalls.has(match.index)) continue; const method = match[2]!; const routePath = match[3]!; if (method === 'use' && !routePath.startsWith('/')) continue; @@ -246,6 +249,7 @@ export const expressResolver: FrameworkResolver = { // per method, at the line of its `.method(`, bound like the plain form. const chainHead = /\b(?:app|router)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"]\s*\)/g; while ((match = chainHead.exec(safe)) !== null) { + if (foreignCalls.has(match.index)) continue; const routePath = match[1]!; let at = match.index + match[0].length; for (;;) { diff --git a/src/resolution/frameworks/http-routing.ts b/src/resolution/frameworks/http-routing.ts new file mode 100644 index 000000000..7e298d839 --- /dev/null +++ b/src/resolution/frameworks/http-routing.ts @@ -0,0 +1,633 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { detectLanguage, getParser } from '../../extraction/grammars'; +import type { Node } from '../../types'; +import type { FrameworkExtractionResult, FrameworkResolver, UnresolvedRef } from '../types'; +import { dependsOn } from './package-deps'; + +type Framework = 'hono' | 'elysia' | 'fastify' | 'hyper-express' | 'koa' | 'h3' | 'vixeny'; +type Binding = + | Router + | { kind: 'module'; source: string } + | { kind: 'factory'; framework: Framework } + | { kind: 'serve' } + | { kind: 'effect' } + | { kind: 'wrap'; prefix: string | null } + | { kind: 'middleware'; router: Router }; +type Scope = Map; +interface Router { + kind: 'router'; + framework: Framework; + prefix: string | null; + mounts: { parent: Router; path: string | null; before?: number }[]; + routePath?: string | null; +} +interface PendingRoute { + router: Router; + method: string; + path: string; + site: SyntaxNode; + handler: SyntaxNode | null; +} + +const PACKAGES = [ + 'hono', + 'elysia', + 'fastify', + 'hyper-express', + '@koa/router', + 'koa-router', + 'h3', + 'vixeny', + 'effect', + '@types/bun', +]; +const METHODS = new Set([ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', + 'TRACE', + 'CONNECT', +]); +const FUNCTIONS = new Set([ + 'arrow_function', + 'function_expression', + 'function_declaration', + 'generator_function', + 'method_definition', +]); +const SOURCE_HINT = + /\bBun\s*\.\s*serve\b|['"](?:hono|elysia|fastify|hyper-express|@koa\/router|koa-router|h3|vixeny|bun|effect\/unstable\/http(?:\/HttpRouter)?)['"]/; + +function field(node: SyntaxNode, name: string): SyntaxNode | null { + return node.childForFieldName(name); +} +function unwrap(node: SyntaxNode | null): SyntaxNode | null { + while ( + node && + [ + 'parenthesized_expression', + 'as_expression', + 'satisfies_expression', + 'non_null_expression', + ].includes(node.type) + ) + node = node.namedChildren[0] ?? null; + return node; +} +function literal(node: SyntaxNode | null): string | null { + node = unwrap(node); + if (!node || !['string', 'template_string'].includes(node.type)) return null; + if ( + node.namedChildren.some((n) => n.type === 'template_substitution') || + node.text.includes('\\') + ) + return null; + return node.text.slice(1, -1); +} +function strings(node: SyntaxNode | null): string[] { + if (node?.type === 'array') { + const items = node.namedChildren.map(literal); + return items.every((s) => s !== null) ? (items as string[]) : []; + } + const s = literal(node); + return s === null ? [] : [s]; +} +function key(node: SyntaxNode | null): string | null { + return node && + ['property_identifier', 'identifier', 'shorthand_property_identifier'].includes(node.type) + ? node.text + : literal(node); +} +function properties(node: SyntaxNode | null): Map { + const out = new Map(); + node = unwrap(node); + if (node?.type !== 'object') return out; + for (const child of node.namedChildren) { + // A spread or computed key can replace any preceding field. + if (child.type === 'spread_element' || field(child, 'key')?.type === 'computed_property_name') + return new Map(); + const name = key(field(child, 'key') ?? field(child, 'name') ?? child); + const value = child.type === 'pair' ? field(child, 'value') : child; + if (name && value) out.set(name, value); + } + return out; +} +function optionPrefix(node: SyntaxNode | null): string | null { + if (!node) return ''; + node = unwrap(node); + if ( + node?.type !== 'object' || + node.namedChildren.some( + (n) => n.type === 'spread_element' || field(n, 'key')?.type === 'computed_property_name', + ) + ) + return null; + const value = properties(node).get('prefix'); + return value ? literal(value) : ''; +} +function join(prefix: string | null, path: string | null): string | null { + if (prefix === null || path === null) return null; + return prefix.replace(/\/$/, '') + (path.startsWith('/') ? path : '/' + path); +} +function library(source: string, name: string): Binding | null { + if (source === 'bun') return name === 'serve' ? { kind: 'serve' } : null; + if ( + (source === 'effect/unstable/http' && name === 'HttpRouter') || + (source === 'effect/unstable/http/HttpRouter' && name === '*') + ) + return { kind: 'effect' }; + const framework: Framework | undefined = ( + { + hono: 'hono', + elysia: 'elysia', + fastify: 'fastify', + 'hyper-express': 'hyper-express', + '@koa/router': 'koa', + 'koa-router': 'koa', + h3: 'h3', + vixeny: 'vixeny', + } as Record + )[source]; + if (!framework) return null; + const constructors: Record = { + hono: ['Hono'], + elysia: ['Elysia', 'default'], + fastify: ['fastify', 'default'], + 'hyper-express': ['Server', 'Router'], + koa: ['default', 'Router'], + h3: ['H3', 'createRouter', 'createApp'], + vixeny: ['wrap'], + }; + return constructors[framework].includes(name) ? { kind: 'factory', framework } : null; +} + +/** Source-level declarations only; the same hook runs after native and WASM extraction. */ +export function extractHttpRoutes( + filePath: string, + source: string, +): FrameworkExtractionResult & { callStarts: Set } { + const result = { + nodes: [] as Node[], + references: [] as UnresolvedRef[], + callStarts: new Set(), + }; + if (!/\.(?:[cm]?[jt]s|[jt]sx)$/.test(filePath) || !SOURCE_HINT.test(source)) return result; + const language = detectLanguage(filePath); + const parser = getParser(language); + if (!parser) throw new Error(`HTTP routing requires the loaded ${language} grammar`); + const tree = parser.parse(source); + if (!tree) return result; + const pending: PendingRoute[] = []; + const scopes: Scope[] = [new Map([['Bun', { kind: 'module', source: 'bun' }]])]; + const scope = () => scopes[scopes.length - 1]!; + const lookup = (name: string): Binding | null => { + for (let i = scopes.length - 1; i >= 0; i--) + if (scopes[i]!.has(name)) return scopes[i]!.get(name) ?? null; + return null; + }; + const router = (framework: Framework, prefix: string | null = ''): Router => ({ + kind: 'router', + framework, + prefix, + mounts: [], + }); + function bindPattern(node: SyntaxNode | null, value: Binding | null = null): void { + if (!node) return; + if (node.type === 'identifier') { + scope().set(node.text, value); + return; + } + if ( + ['required_parameter', 'optional_parameter', 'assignment_pattern', 'rest_pattern'].includes( + node.type, + ) + ) { + bindPattern( + field(node, 'pattern') ?? field(node, 'left') ?? node.namedChildren[0] ?? null, + value, + ); + } else if (node.type === 'object_pattern' || node.type === 'array_pattern') { + for (const child of node.namedChildren) { + const name = field(child, 'key')?.text ?? child.text; + bindPattern( + field(child, 'value') ?? child, + value?.kind === 'module' ? library(value.source, name) : null, + ); + if (child.type === 'shorthand_property_identifier_pattern') + scope().set(child.text, value?.kind === 'module' ? library(value.source, name) : null); + } + } + } + function predeclare(block: SyntaxNode): void { + for (const raw of block.namedChildren) { + const node = raw.type === 'export_statement' ? (field(raw, 'declaration') ?? raw) : raw; + if (['lexical_declaration', 'variable_declaration'].includes(node.type)) { + for (const d of node.namedChildren) bindPattern(field(d, 'name')); + } else if (['function_declaration', 'class_declaration'].includes(node.type)) + bindPattern(field(node, 'name')); + } + } + function visitFunction(node: SyntaxNode, first: Binding | null = null): void { + scopes.push(new Map()); + const parameters = + field(node, 'parameters')?.namedChildren ?? + [field(node, 'parameter')].filter((n): n is SyntaxNode => !!n); + parameters.forEach((p, i) => bindPattern(p, i === 0 ? first : null)); + bindPattern(field(node, 'name')); + const body = field(node, 'body'); + if (body) { + predeclare(body); + visit(body, false); + } + scopes.pop(); + } + function add( + r: Router, + methods: string[], + paths: string[], + site: SyntaxNode, + handler: SyntaxNode | null, + ): void { + for (let method of methods) { + method = method.toUpperCase(); + if (method === '*' || method === 'ALL' || method === 'ANY') method = 'ANY'; + if (!METHODS.has(method) && method !== 'ANY') continue; + for (const path of paths) { + if (path.startsWith('/') || path === '*') + pending.push({ router: r, method, path, site, handler }); + } + } + } + function member(node: SyntaxNode): { object: SyntaxNode; name: string } | null { + const object = field(node, 'object'); + const name = key(field(node, 'property')); + return object && name ? { object, name } : null; + } + function evalCall(node: SyntaxNode): Binding | null { + const callee = field(node, node.type === 'new_expression' ? 'constructor' : 'function'); + const args = field(node, 'arguments')?.namedChildren ?? []; + if (!callee) return null; + if (callee.text === 'require' && !scopes.some((s) => s.has('require'))) { + const source = literal(args[0] ?? null); + return source && SOURCE_HINT.test(JSON.stringify(source)) ? { kind: 'module', source } : null; + } + const m = callee.type === 'member_expression' ? member(callee) : null; + const owner = m ? evaluate(m.object) : null; + const binding = m + ? owner?.kind === 'module' + ? library(owner.source, m.name) + : null + : evaluate(callee); + if ( + binding?.kind === 'factory' || + (binding?.kind === 'module' && + ['fastify', '@koa/router', 'koa-router', 'elysia'].includes(binding.source)) + ) { + const f = + binding.kind === 'factory' + ? binding.framework + : (library(binding.source, 'default') as { framework: Framework }).framework; + if (f === 'vixeny') { + // Vixeny's wrap options depend on the terminal operation (unwrap vs + // compose). Only the option-free builder has an unambiguous path here. + return { kind: 'wrap', prefix: args.length ? null : '' }; + } + return router(f, ['elysia', 'koa'].includes(f) ? optionPrefix(args[0] ?? null) : ''); + } + if (binding?.kind === 'wrap') return router('vixeny', binding.prefix); + if (binding?.kind === 'serve') { + const routes = properties(properties(args[0] ?? null).get('routes') ?? null); + const root = router('h3'); + for (const [path, value] of routes) { + if (value.type === 'false' || value.type === 'undefined') continue; + const entries = properties(value); + if (value.type === 'object' && !entries.has('dir')) { + for (const [method, handler] of entries) + if (/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(method)) + add(root, [method], [path], value, handler); + } else add(root, ['ANY'], [path], value, value); + } + return null; + } + if (owner?.kind === 'effect' && m && ['add', 'route'].includes(m.name)) { + const allowed = + m.name === 'add' + ? /^(GET|POST|PATCH|PUT|DELETE|OPTIONS|\*)$/ + : /^(GET|POST|PATCH|PUT|DELETE|OPTIONS|HEAD|TRACE|\*)$/; + add( + router('h3'), + strings(args[0] ?? null).filter((s) => allowed.test(s)), + strings(args[1] ?? null), + node, + args[2] ?? null, + ); + return null; + } + if (owner?.kind === 'router' && m) { + result.callStarts.add(node.startIndex); + const r = owner; + const f = r.framework; + const method = m.name; + if (method === 'basePath' && f === 'hono') + return { ...r, prefix: join(r.prefix, literal(args[0] ?? null)) }; + if (method === 'prefix' && f === 'koa') { + r.prefix = literal(args[0] ?? null); + return r; + } + if ((method === 'group' && f === 'elysia') || (method === 'register' && f === 'fastify')) { + const callback = f === 'elysia' ? args[args.length - 1] : args[0]; + const child = router(f); + const prefix = f === 'elysia' ? literal(args[0] ?? null) : optionPrefix(args[1] ?? null); + child.mounts.push({ parent: r, path: prefix }); + if (callback && FUNCTIONS.has(callback.type)) visitFunction(callback, child); + return r; + } + if ( + (method === 'route' && f === 'hono') || + (method === 'use' && ['koa', 'hyper-express'].includes(f)) || + (method === 'mount' && f === 'h3') + ) { + const value = args[1] ? evaluate(args[1]) : null; + const child = value?.kind === 'middleware' ? value.router : value; + if (child?.kind === 'router') + child.mounts.push({ + parent: r, + path: literal(args[0] ?? null), + before: f === 'hono' ? pending.length : undefined, + }); + return r; + } + if (method === 'routes' && f === 'koa') return { kind: 'middleware', router: r }; + if (method === 'route' && f === 'hyper-express') + return { ...r, routePath: literal(args[0] ?? null) }; + if (f === 'vixeny') { + if (['get', 'post', 'put', 'delete', 'route'].includes(method)) { + const opts = properties(args[0] ?? null); + add( + r, + method === 'route' ? strings(opts.get('method') ?? null) : [method], + strings(opts.get('path') ?? null), + node, + opts.get('f') ?? null, + ); + } + return ['get', 'post', 'put', 'delete', 'route'].includes(method) ? r : null; + } + if (method === 'route' && f === 'fastify') { + const opts = properties(args[0] ?? null); + add( + r, + strings(opts.get('method') ?? null), + strings(opts.get('url') ?? opts.get('path') ?? null), + node, + opts.get('handler') ?? null, + ); + } else if ( + (method === 'on' && ['hono', 'h3'].includes(f)) || + (method === 'route' && f === 'elysia') + ) { + add( + r, + strings(args[0] ?? null), + strings(args[1] ?? null), + node, + f === 'hono' ? (args[args.length - 1] ?? null) : (args[2] ?? null), + ); + } else if ( + METHODS.has(method.toUpperCase()) || + method === 'all' || + (method === 'any' && f === 'hyper-express') || + (method === 'del' && f === 'koa') + ) { + const pathIndex = + f === 'koa' && args[1] && ['string', 'template_string', 'array'].includes(args[1].type) + ? 1 + : 0; + const paths = + r.routePath !== undefined + ? r.routePath === null + ? [] + : [r.routePath] + : strings(args[pathIndex] ?? null); + let handler = ['elysia', 'h3'].includes(f) ? args[pathIndex + 1] : args[args.length - 1]; + if (f === 'fastify' && handler?.type === 'object') + handler = properties(handler).get('handler'); + add(r, [method === 'del' ? 'DELETE' : method], paths, node, handler ?? null); + } else if (method !== 'use') return null; + // Inspect inline handler bodies in their own scope, never as router callbacks. + for (const arg of args) if (FUNCTIONS.has(arg.type)) visitFunction(arg); + return r; + } + for (const arg of args) evaluate(arg); + return null; + } + function evaluate(raw: SyntaxNode): Binding | null { + const node = unwrap(raw)!; + if (node.type === 'identifier') return lookup(node.text); + if (node.type === 'call_expression' || node.type === 'new_expression') return evalCall(node); + if (node.type === 'member_expression') { + const m = member(node); + const value = m ? evaluate(m.object) : null; + return value?.kind === 'module' ? library(value.source, m!.name) : null; + } + if (FUNCTIONS.has(node.type)) { + visitFunction(node); + return null; + } + if ( + ['assignment_expression', 'augmented_assignment_expression', 'update_expression'].includes( + node.type, + ) + ) { + const left = field(node, 'left') ?? field(node, 'argument'); + const right = field(node, 'right'); + if (right) evaluate(right); + if (left?.type === 'identifier') + for (let i = scopes.length - 1; i >= 0; i--) + if (scopes[i]!.has(left.text)) { + scopes[i]!.set(left.text, null); + break; + } + return null; + } + for (const child of node.namedChildren) visit(child); + return null; + } + function visit(node: SyntaxNode, newScope = true): void { + if (node.type === 'catch_clause') { + scopes.push(new Map()); + bindPattern(field(node, 'parameter')); + const body = field(node, 'body'); + if (body) visit(body, false); + scopes.pop(); + return; + } + if (node.type === 'import_statement') { + if (/^import\s+type\b/.test(node.text)) return; + const source = literal(field(node, 'source')); + if (!source) return; + const clause = node.namedChildren.find((n) => n.type === 'import_clause'); + for (const item of clause?.namedChildren ?? []) { + if (item.type === 'identifier') + scope().set(item.text, library(source, 'default') ?? { kind: 'module', source }); + if (item.type === 'namespace_import') + scope().set( + item.namedChildren[0]!.text, + library(source, '*') ?? { kind: 'module', source }, + ); + if (item.type === 'named_imports') + for (const spec of item.namedChildren) { + const name = field(spec, 'name'); + const alias = field(spec, 'alias') ?? name; + if (name && alias) + scope().set( + alias.text, + /^type\s/.test(spec.text) ? null : library(source, name.text), + ); + } + } + return; + } + if (node.type === 'program' || node.type === 'statement_block') { + if (newScope) scopes.push(new Map()); + predeclare(node); + for (const child of node.namedChildren) visit(child); + if (newScope) scopes.pop(); + return; + } + if (node.type === 'variable_declarator') { + const value = field(node, 'value'); + const binding = value ? evaluate(value) : null; + bindPattern(field(node, 'name'), node.parent?.text.startsWith('const ') ? binding : null); + return; + } + evaluate(node); + } + function prefixes(r: Router, index: number, seen = new Set()): string[] { + if (seen.has(r) || r.prefix === null) return []; + if (!r.mounts.length) return [r.prefix]; + const next = new Set(seen).add(r); + return r.mounts.flatMap((m) => + m.before !== undefined && index >= m.before + ? [] + : prefixes(m.parent, index, next).flatMap((p) => { + const prefix = join(join(p, m.path), r.prefix); + return prefix === null ? [] : [prefix.replace(/\/$/, '')]; + }), + ); + } + try { + visit(tree.rootNode, false); + const emitted = new Set(); + for (const [index, entry] of pending.entries()) + for (const prefix of prefixes(entry.router, index)) { + const path = + entry.router.framework === 'hono' && prefix && entry.path === '/' + ? prefix + : join(prefix, entry.path)!; + const line = entry.site.startPosition.row + 1; + const name = `${entry.method} ${path}`; + const id = `route:${filePath}:${line}:${entry.site.startPosition.column}:${name}`; + if (emitted.has(id)) continue; + emitted.add(id); + const node: Node = { + id, + kind: 'route', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language, + startLine: line, + endLine: entry.site.endPosition.row + 1, + startColumn: entry.site.startPosition.column, + endColumn: entry.site.endPosition.column, + updatedAt: Date.now(), + }; + result.nodes.push(node); + result.references.push(...httpHandlerReferences(node, entry.handler)); + } + return result; + } finally { + tree.delete(); + } +} + +/** Bind a named handler, or the direct calls made by an anonymous handler. */ +export function httpHandlerReferences(route: Node, raw: SyntaxNode | null): UnresolvedRef[] { + const references: UnresolvedRef[] = []; + const reference = (target: SyntaxNode, kind: 'references' | 'calls'): void => { + const name = target.text; + if (!name || !/^[A-Za-z_$][\w$]*$/.test(name)) return; + references.push({ + fromNodeId: route.id, + referenceName: name, + referenceKind: kind, + filePath: route.filePath, + language: route.language, + line: target.startPosition.row + 1, + column: target.startPosition.column, + }); + }; + const handler = unwrap(raw); + if (!handler) return references; + if (['identifier', 'shorthand_property_identifier'].includes(handler.type)) + reference(handler, 'references'); + if (!FUNCTIONS.has(handler.type)) return references; + const locals = new Set(); + const namesIn = (node: SyntaxNode | null): void => { + if (!node) return; + if (['identifier', 'shorthand_property_identifier_pattern'].includes(node.type)) + locals.add(node.text); + else for (const child of node.namedChildren) namesIn(child); + }; + namesIn(field(handler, 'parameters') ?? field(handler, 'parameter')); + namesIn(field(handler, 'name')); + const declarations = (node: SyntaxNode): void => { + if ( + node.type === 'variable_declarator' || + node.type === 'function_declaration' || + node.type === 'class_declaration' + ) + namesIn(field(node, 'name')); + if (node.type === 'catch_clause') namesIn(field(node, 'parameter')); + if (!FUNCTIONS.has(node.type)) for (const child of node.namedChildren) declarations(child); + }; + const calls = (node: SyntaxNode): void => { + if (FUNCTIONS.has(node.type)) return; + if (node.type === 'call_expression') { + const callee = field(node, 'function'); + // The normal extraction pass retains member receivers. A bare member + // name here could otherwise resolve to an unrelated same-named function. + if (callee?.type === 'identifier' && !locals.has(callee.text)) reference(callee, 'calls'); + } + for (const child of node.namedChildren) calls(child); + }; + const body = field(handler, 'body'); + if (body) { + declarations(body); + calls(body); + } + return references; +} + +export const httpRoutingResolver: FrameworkResolver = { + name: 'http-routing', + languages: ['javascript', 'typescript', 'jsx', 'tsx'], + detect(context) { + return ( + dependsOn(context, ...PACKAGES) || + context.fileExists('bun.lock') || + context.fileExists('bun.lockb') || + context.fileExists('bunfig.toml') || + context + .getAllFiles() + .some((f) => /\.[cm]?[jt]sx?$/.test(f) && SOURCE_HINT.test(context.readFile(f) ?? '')) + ); + }, + resolve: () => null, + extract: extractHttpRoutes, +}; diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 4c96e186e..6983cacc0 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -9,6 +9,7 @@ import type { Language } from '../../types'; import { drupalResolver } from './drupal'; import { laravelResolver } from './laravel'; import { expressResolver } from './express'; +import { httpRoutingResolver } from './http-routing'; import { nestjsResolver } from './nestjs'; import { reactResolver } from './react'; import { nextjsResolver } from './nextjs'; @@ -45,6 +46,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ drupalResolver, // JavaScript/TypeScript expressResolver, + httpRoutingResolver, nestjsResolver, reactResolver, // React Router — `` routes are `reactResolver`'s; `history.push('/x')` / `navigate('/x')` → navigates edges @@ -152,6 +154,7 @@ export function registerFrameworkResolver(resolver: FrameworkResolver): void { export { drupalResolver } from './drupal'; export { laravelResolver, FACADE_MAPPINGS } from './laravel'; export { expressResolver } from './express'; +export { httpRoutingResolver } from './http-routing'; export { nestjsResolver } from './nestjs'; export { reactResolver } from './react'; export { reactRouterResolver } from './react-router'; diff --git a/src/resolution/frameworks/vue-router.ts b/src/resolution/frameworks/vue-router.ts index 1a9884e8b..0bb3d1b80 100644 --- a/src/resolution/frameworks/vue-router.ts +++ b/src/resolution/frameworks/vue-router.ts @@ -175,7 +175,7 @@ function isVueConfigRoute(node: Node): boolean { function isNuxtPage(node: Node): boolean { return ( node.language === 'vue' && - node.filePath.includes('/pages/') && + /(?:^|\/)pages\//.test(node.filePath.replace(/\\/g, '/')) && node.id === `route:${node.filePath}:${node.name}:1` ); } diff --git a/src/resolution/frameworks/vue.ts b/src/resolution/frameworks/vue.ts index c830885be..e5595b101 100644 --- a/src/resolution/frameworks/vue.ts +++ b/src/resolution/frameworks/vue.ts @@ -7,6 +7,8 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { detectLanguage, getParser } from '../../extraction/grammars'; +import { httpHandlerReferences } from './http-routing'; /** * Vue 3 compiler macros — compiler-provided, not user code @@ -69,13 +71,7 @@ const NUXT_AUTO_IMPORTS = new Set([ /** * Nuxt virtual module prefixes (auto-import namespaces) */ -const NUXT_VIRTUAL_MODULES = [ - '#imports', - '#components', - '#app', - '#build', - '#head', -]; +const NUXT_VIRTUAL_MODULES = ['#imports', '#components', '#app', '#build', '#head']; export const vueResolver: FrameworkResolver = { name: 'vue', @@ -187,12 +183,13 @@ export const vueResolver: FrameworkResolver = { return null; }, - extract(filePath: string, _content: string) { + extract(filePath: string, content: string) { const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); // Normalize to forward slashes - const normalized = filePath.replace(/\\/g, '/'); + const normalized = '/' + filePath.replace(/\\/g, '/').replace(/^\/+/, ''); // Detect Nuxt page routes (pages/ directory) const pagesIndex = normalized.indexOf('/pages/'); @@ -215,28 +212,77 @@ export const vueResolver: FrameworkResolver = { } } - // Detect Nuxt API routes (server/api/ directory) - const apiIndex = normalized.indexOf('/server/api/'); - if (apiIndex !== -1) { - const afterApi = normalized.substring(apiIndex + '/server/api/'.length); - const routeName = afterApi - .replace(/\.[^/.]+$/, '') // Remove extension - .replace(/\/index$/, ''); // index -> parent path - const apiRoute = '/api/' + routeName; - - nodes.push({ + // Nitro reserves method suffixes and index names in both server directories. + const server = /\/server\/(api|routes)\/(.+)\.(?:[cm]?[jt]s)$/.exec(normalized); + if (server && !/\.d\.[cm]?ts$/.test(normalized)) { + const method = /\.(get|post|put|patch|delete|head|options|connect|trace)$/.exec(server[2]!); + const routeName = server[2]! + .replace(/\.(get|post|put|patch|delete|head|options|connect|trace)$/, '') + .replace(/(^|\/)index$/, '') + .replace(/\[\.\.\.([^\]]*)\]/g, '*$1') + .replace(/\[([^\]]+)\]/g, ':$1'); + const apiRoute = + (server[1] === 'api' ? '/api' : '') + (routeName ? '/' + routeName : '') || '/'; + const name = `${method?.[1]?.toUpperCase() ?? 'ANY'} ${apiRoute}`; + const node: Node = { id: `route:${filePath}:${apiRoute}:1`, kind: 'route', - name: apiRoute, - qualifiedName: `${filePath}::route:${apiRoute}`, + name, + qualifiedName: `${filePath}::${name}`, filePath, startLine: 1, - endLine: 1, + endLine: content.split('\n').length, startColumn: 0, endColumn: 0, - language: normalized.endsWith('.vue') ? 'vue' : 'typescript', + language: detectLanguage(filePath), updatedAt: now, - }); + }; + nodes.push(node); + const parser = getParser(node.language); + const tree = parser?.parse(content); + if (tree) + try { + for (const statement of tree.rootNode.namedChildren) { + if ( + statement.type !== 'export_statement' || + !statement.children.some((child) => child.type === 'default') + ) + continue; + let handler = + statement.childForFieldName('value') ?? statement.childForFieldName('declaration'); + if ( + handler?.type === 'call_expression' && + ['defineEventHandler', 'eventHandler'].includes( + handler.childForFieldName('function')?.text ?? '', + ) + ) { + handler = handler.childForFieldName('arguments')?.namedChildren[0] ?? null; + if (handler?.type === 'object') { + const properties = handler.namedChildren; + const property = [...properties] + .reverse() + .find( + (child) => + ( + child.childForFieldName('key')?.text ?? + child.childForFieldName('name')?.text ?? + child.text + ).replace(/^['"]|['"]$/g, '') === 'handler', + ); + handler = properties.some( + (child) => + child.type === 'spread_element' || + child.childForFieldName('key')?.type === 'computed_property_name', + ) + ? null + : (property?.childForFieldName('value') ?? property ?? null); + } + } + references.push(...httpHandlerReferences(node, handler)); + } + } finally { + tree.delete(); + } } // Detect Nuxt middleware (middleware/ directory) @@ -260,7 +306,7 @@ export const vueResolver: FrameworkResolver = { }); } - return { nodes, references: [] }; + return { nodes, references }; }, }; @@ -277,7 +323,7 @@ function isPascalCase(str: string): boolean { function resolveComponent( name: string, fromFile: string, - context: ResolutionContext + context: ResolutionContext, ): string | null { // Collect ALL basename matches first. The previous version returned the // FIRST `Button.vue` found anywhere in the tree (its same-directory pass @@ -314,16 +360,22 @@ function filePathToNuxtRoute(normalized: string, afterPagesStart: number): strin const afterPages = normalized.substring(afterPagesStart); // Remove the .vue extension - const withoutExt = afterPages.replace(/\.vue$/, ''); + const withoutExt = afterPages + .replace(/\.vue$/, '') + .split('/') + .filter((part) => !/^\([^/]+\)$/.test(part)) + .join('/'); // Remove /index suffix (index.vue -> parent route) - const withoutIndex = withoutExt.replace(/\/index$/, ''); + const withoutIndex = withoutExt.replace(/(^|\/)index$/, ''); // Convert Nuxt param syntax [param] to :param - let route = '/' + withoutIndex - .replace(/\[\.\.\.([^\]]+)\]/g, '*$1') // [...slug] -> *slug (catch-all) - .replace(/\[{2}([^\]]+)\]{2}/g, ':$1?') // [[optional]] -> :optional? - .replace(/\[([^\]]+)\]/g, ':$1'); // [param] -> :param + let route = + '/' + + withoutIndex + .replace(/\[\.\.\.([^\]]+)\]/g, '*$1') // [...slug] -> *slug (catch-all) + .replace(/\[{2}([^\]]+)\]{2}/g, ':$1?') // [[optional]] -> :optional? + .replace(/\[([^\]]+)\]/g, ':$1'); // [param] -> :param if (route === '/') return '/'; // Remove trailing slash From 05b3fc9dd00bd6e5a5d472458ffd354aa8ce6d85 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 22:57:31 -0600 Subject: [PATCH 2/2] docs(routing): record validation and pull request --- docs/design/PLAN-framework-routing.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design/PLAN-framework-routing.md b/docs/design/PLAN-framework-routing.md index ffb950807..39a7fab84 100644 --- a/docs/design/PLAN-framework-routing.md +++ b/docs/design/PLAN-framework-routing.md @@ -1,10 +1,10 @@ -Status: 3/5 — validating examples, controls and build +Status: 5/5 — complete; PR #2 targets fork/consolidated - [x] 1 Update fork/consolidated — 0c4664a; upstream main and latest integrated PR heads included; only Markdown language docs change the tree. - [x] 2 Add static HTTP routing for Hono, Elysia, Fastify, Hyper-Express, Koa, H3, Bun, Effect v4, Vixeny — 33 focused extraction and end-to-end tests pass; TypeScript passes. - [x] 3 Repair Nuxt default file routes and verify existing Next Pages support — 115 focused HTTP, Nuxt, Next and Vue tests pass; build passes. -- [ ] 4 Validate real examples, unaffected controls, full suite, build, and update coverage/docs — gate: recorded outcomes, no unsupported coverage claims. -- [ ] 5 Review and open PR against fork/consolidated — gate: final diff and remote PR verified. +- [x] 4 Validate real examples, unaffected controls, full suite, build, and update coverage/docs — build passes; full suite with matching native kernel: 4,342 pass, 46 skip; 116 focused routing tests pass; six official-source fixtures match their documented scope. +- [x] 5 Review and open PR against fork/consolidated — findings fixed and rechecked; implementation 0684cc0; [PR #2](https://github.com/bompus/codegraph/pull/2) base/head verified, mergeable. Acceptance: literal routes produce method-qualified endpoint nodes and correct handler references; unrelated methods, shadowed bindings, and computed paths produce no fabricated routes. Preserve existing Express and Next behavior. No new dependencies, execution of application code, or arbitrary dynamic path evaluation.