Skip to content

NestJS: @Controller/@Get decorator routes not extracted - 0 Route nodes, so cross-repo-intelligence finds no edges #1428

Closed

Description

@HungChu

Version

codebase-memory-mcp 0.9.0 (also measured on 0.9.1-rc.1 — see below)

Platform

Windows (x64)

Install channel

npm

Binary variant

standard

What happened, and what did you expect?

NestJS controllers produce no Route nodes at all. @Controller (class-level prefix) and @Get/@Post/@Patch/@Delete (method-level path + verb) are never composed into routes, so a NestJS service contributes zero server-side routes to the graph.

Expected: one Route node per handler, with the class prefix composed onto the method path (and, when present, the main.ts global prefix and URI version segment).

Actual: the Route label does not appear in get_graph_schema output for the project at all.

This is the same shape as the already-reported gaps for other decorator/annotation-routed frameworks — Java/Spring class-level @RequestMapping (#734), ASP.NET Core attribute routing (#1041), FastAPI router prefix (#678), Next.js App Router (#759) — so I am filing NestJS as its own data point rather than commenting on those. NestJS is the largest remaining decorator-routed server framework in the TS ecosystem, and #514 already notes NestJS DI defeats caller resolution, so NestJS support is partially on the radar already.

Consequence: cross-repo-intelligence matches Route ↔ Route, so for a NestJS-backend + Next.js-frontend fleet it can never produce an HTTP edge. On our 5-project store the pass reports projects_scanned: 5, total_cross_edges: 0 — and the empty result is indistinguishable from "there are genuinely no cross-service calls", which is the dangerous part for an agent consuming the graph.

Reproduction

Two throwaway projects, ~60 LOC total, no node_modules or build needed. All code below is written for this report — nothing proprietary.

1. nest-api/src/users/users.controller.ts

import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller({ path: 'users', version: '1' })   // also fails with plain @Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()        findAll() { return this.usersService.findAll(); }
  @Get(':id')   findOne(@Param('id') id: string) { return this.usersService.findOne(id); }
  @Post()       create(@Body() dto: { name: string }) { return this.usersService.create(dto); }
  @Patch(':id') update(@Param('id') id: string, @Body() dto: { name: string }) { return this.usersService.update(id, dto); }
  @Delete(':id') remove(@Param('id') id: string) { return this.usersService.remove(id); }
}

2. nest-api/src/health/health.controller.ts

import { Controller, Get } from '@nestjs/common';

@Controller('health')
export class HealthController {
  @Get() check() { return { status: 'ok' }; }
}

3. nest-api/src/main.ts — supplies the two app-level modifiers

import { NestFactory } from '@nestjs/core';
import { VersioningType } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api', { exclude: ['health'] });
  app.enableVersioning({ type: VersioningType.URI });
  await app.listen(3000);
}
bootstrap();

(plus a trivial app.module.ts wiring the two controllers, and a users.service.ts with the five methods)

4. web-client/src/api.ts — the caller side, for the cross-repo half

const BASE = 'http://localhost:3000';
export async function listUsers()          { return (await fetch(`${BASE}/api/v1/users`)).json(); }
export async function getUser(id: string)  { return (await fetch(`${BASE}/api/v1/users/${id}`)).json(); }
export async function createUser(name: string) {
  return (await fetch(`${BASE}/api/v1/users`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }),
  })).json();
}

5. Index both and inspect the schema

$ echo '{"repo_path":"<repro>/nest-api","name":"nest-api"}'     | cli index_repository
{… "nodes":39,"edges":66,"status":"indexed"}
$ echo '{"repo_path":"<repro>/web-client","name":"web-client"}' | cli index_repository
{… "nodes":9,"edges":8,"status":"indexed"}

$ echo '{"project":"nest-api"}' | cli get_graph_schema
# node_labels: Method(12), Decorator(7), File(5), Module(5), Class(4), Folder(3), Branch(1), Function(1), Project(1)
# → no "Route" label present

Expected 6 Route nodes:

Verb Effective path
GET /api/v1/users
GET /api/v1/users/:id
POST /api/v1/users
PATCH /api/v1/users/:id
DELETE /api/v1/users/:id
GET /health (excluded from the global prefix, unversioned)

Actual: 0. Note Decorator = 7, so the decorators themselves are seen — they are just not turned into routes.

web-client likewise yields no Route label, so the client side of the pair is not extracted either.

Logs

Composition rules needed for NestJS, for reference:

  • class prefix: @Controller('users') or @Controller({ path: 'users', version: '1' })
  • method verb + path: @Get(':id'), @Post(), @Patch(':id'), @Delete(':id')
  • global prefix from main.ts: app.setGlobalPrefix('api', { exclude: [...] })
  • URI versioning from main.ts: app.enableVersioning({ type: VersioningType.URI })
    When defaultVersion is NOT set, versioning is per-controller: a controller that declares
    version: '1' serves /api/v1/<path>, one that declares no version serves /api/<path>.
    Both shapes coexist in the same app, so the version segment cannot be assumed globally.

Project scale (if relevant)

Fixture above: 39 nodes / 66 edges / 5 files. Also observed on a real NestJS 11 service in our fleet at 3,289 nodes / 7,669 edges, where 22 @Controller + 131 method decorators likewise yield no route for any real endpoint path.

I checked 0.9.1-rc.1 as well, since its notes mention "JAX-RS class+method @Path composition" and "Laravel Route::prefix()->group() routes" — the same composition problem. Installed it into an isolated cache and re-indexed that service: its Route count went 21 → 20, so the rc's route work does not cover NestJS. (Those 20–21 Route nodes are all literal-derived rather than decorator-derived — one is a bare http://localhost:3000 base URL, with file_path: "" and start_line: 0. That part looks like #1248 / #598 and I am not re-reporting it here.)

Confirmations

  • I searched existing issues and this is not a duplicate.
  • My reproduction uses shareable code (a dummy snippet or a public OSS repository), not proprietary code.

Activity

  1. HungChu commented on Aug 5, 2026

    @HungChu
    Author

    Sequencing note — please do not fix this before route-ownership resolution.

    A finding in #1133 changes what "fixing this issue" would do. Investigating that thread, @junk151516 traced a set of 117 healthy-looking CROSS_HTTP_CALLS edges and found they were path-string collisions: the matcher emits a cross-repo edge even when the route is defined inside the calling project itself, and nothing in the output distinguishes that from a call that genuinely leaves the service.

    That interacts with this issue in a way worth being explicit about:

    • Today, the extraction gap reported here makes NestJS fleets accidentally immune. No Route nodes ⇒ no collisions ⇒ total_cross_edges: 0. Useless, but visibly useless.
    • If this issue is fixed first, those fleets flip directly from "silently zero" to "silently wrong" — with no state in between where a user would notice something changed for the worse. Zero edges look like nothing happened; a few dozen plausible edges read as an architectural fact.

    The exposure is not exotic for NestJS specifically. The idiomatic layout is one @Controller('<resource>') per resource under a shared global prefix, so any two NestJS services in one store that expose a same-named resource (/api/v1/users, /api/v1/tasks, /health) collide on path alone. Our own fleet is four NestJS services on a /api/v1/<resource> convention with overlapping resource vocabulary — it would start producing false edges the moment this extraction gap closes, unless ownership resolution lands first or alongside.

    So: happy to see this fixed, but the safe order is caller-project-preference first (@junk151516's preferred fix in #1133 — emit a cross-repo edge only when the path is not defined locally), then framework route extraction. If they ship in the other order, the composite behaviour is worse than the current bug, and harder to detect.

    No change to the repro or measurements above.

  2. added
    bugSomething isn't working
    priority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.
    parsing/qualityGraph extraction bugs, false positives, missing edges
    on Sep 5, 2026
  3. DeusData commented on Sep 25, 2026

    @DeusData
    Owner

    Thank you so much, @HungChu. This is an exemplary report: a minimal, shareable repro, the exact composition rules, and the sequencing note about the caller-project preference, which really matters here.

    The root cause was that the decorator route walk only understood Python-style decorator calls. A TypeScript decorator is a different node in the syntax tree, so @Get(':id') was never looked at, and Nest's slash-less paths and the class-level @Controller prefix weren't handled either. The fix in #2320 composes @Controller (string, array or { path } form) with the method verb decorators into ordinary Route nodes with HANDLES edges, so your fixture goes from 0 to 6 routes (/users, /users/:id, /health, …).

    Following your note, the caller-local floor from #1459 (#2319) lands first, so cross-repo edges don't turn silently wrong. The global prefix and URI version from main.ts are app-level, cross-file settings; we're tracking those separately rather than guessing at them.

  4. added 2 commits that reference this issue on Sep 30, 2026
  5. DeusData commented on Oct 1, 2026

    @DeusData
    Owner

    Thank you again for reporting this, @HungChu! The fix is now on main in f67243f (merged via #2320), and it will ship in the next release.

  6. junk151516 commented on Oct 2, 2026

    @junk151516
    Contributor

    Verified f67243f on Windows: built from source with MSYS2/CLANG64, indexed into an isolated CBM_CACHE_DIR + CBM_RUNTIME_DIR, with v0.11.0 as the control.

    The fix works for what it claims. The fixture from this issue goes from 0 to 6 Route nodes on f67243f (v0.11.0: 0), each with a HANDLES edge from the right method:

    handler route
    findAll / create GET / POST /users
    findOne / update / remove GET / PATCH / DELETE /users/:id
    check GET /health

    Extra cases not in the PR's test, all behaving as the PR description says:

    • @Controller() + @Get('ping') → GET /ping
    • @All('any') → ANY /any
    • @Get('ghost') inside a class with no @Controller → no route (nothing invented)
    • @Controller(['items','products']) + @Get('list') → only /items/list (first entry, as documented)

    A literal-URL client (fetch('http://localhost:3000/users'), fetch('/health')) now gets 2 CROSS_HTTP_CALLS to nest-api (findAll, check); the same client against v0.11.0 gets 0.

    But the reporter's end-to-end case still yields total_cross_edges: 0, for two reasons outside this fix:

    1. Global prefix / URI version not applied. This is acknowledged and tracked separately, so it's just a note: the routes are /users, and the app actually serves /api/v1/users. On the idiomatic Nest layout (a global prefix in main.ts), a correct client call still won't match.
    2. Client side: a template literal over a module-level constant is not extracted. fetch(`${BASE}/api/v1/users`) with const BASE = 'http://localhost:3000' produces no Route/HTTP_CALLS in web-client. Even after I rewrote the calls to `${BASE}/users` so the path matches, there were still 0 cross edges. This looks like JS/TS: fetch/axios URLs built from template literals over module-level constants are not extracted as HTTP_CALLS #1147 (still open), not a gap in this fix.

    One small thing, possibly worth a follow-up: a trailing slash is kept verbatim. @Put('/slash/:x/') → /slash/:x/ (qualified name __route__PUT__/slash/{}/). Nest serves that as /slash/:x, so a caller to /slash/1 may not match. I haven't tested the cross-repo side of this.

    Also, #2165 (open) describes the same @Controller/@Get gap that this fix closes, so it may be a duplicate now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingparsing/qualityGraph extraction bugs, false positives, missing edgespriority/highNeeds near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.ux/behaviorDisplay bugs, docs, adoption UXwindowsWindows-specific issues

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions