Merge branch 'main' into dependabot/github_actions/codeql-action-29ab28765e

This commit is contained in:
Gergő Magyar 2026-08-27 08:43:51 +01:00 committed by GitHub
commit 6517c00e0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2189 additions and 227 deletions

View file

@ -141,7 +141,7 @@ jobs:
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

View file

@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Setup Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build image (load locally for scan)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0

View file

@ -174,7 +174,7 @@ converging on the routes phase's `(method, url)` registry:
| Filesystem convention | path → URL, no parsing | Next.js `app/`, Expo, PHP |
| Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` |
| Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` |
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards and static data route tables** |
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS (`@Controller` + `@Get`/`@Post`/…; URLs are controller-relative — `setGlobalPrefix` and URI versioning live in the bootstrap file and are not applied), **JS/TS dispatch guards and static data route tables** |
The last row is the one whose name undersells it. A route is DECLARED by a
decorator, but it can also be **inferred** from a raw `node:http` server's own

View file

@ -36,7 +36,7 @@
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-i18next": "^17.0.12",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
@ -49,7 +49,7 @@
"devDependencies": {
"@babel/types": "^8.0.4",
"@playwright/test": "^1.62.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.2.0",
@ -57,7 +57,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.9.9",
"@vercel/node": "^5.10.1",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",
@ -246,9 +246,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -2091,9 +2091,9 @@
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
"integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2105,9 +2105,12 @@
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"node": ">=22",
"npm": ">=6",
"yarn": ">=1"
},
"peerDependencies": {
"@testing-library/dom": ">=10 <11"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
@ -2638,9 +2641,9 @@
}
},
"node_modules/@vercel/build-utils": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.0.5.tgz",
"integrity": "sha512-ChbTraIvChbcFXMwDPLE8MoWpNGSRhJ2cXsE0V3iJQIVYDRgjFoT6JzWfkuc7w/3ojLLr8eMoae7M1v6OXoC5Q==",
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.1.1.tgz",
"integrity": "sha512-kW9CeW0aokEBvX1rSgNyOKg90VyIQOmT0wBl7KXneM3Qs1+x4Puakqp97BdIgttWEtmN96UvdhQVG2bCA5JsPA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -2691,9 +2694,9 @@
}
},
"node_modules/@vercel/node": {
"version": "5.9.9",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.9.9.tgz",
"integrity": "sha512-jaMocJLa+rP3WpwYrbx2kUpHObjXK/JZOsbtmodDMAtfXbwl7niPNcEbdYYj/fBPSX8yRUXBF3tQsasocbjD5Q==",
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.10.1.tgz",
"integrity": "sha512-muj+t8sZ2XHQDkWcHxkql2rbvr/HhZOqYdZBG7pw8F5RLasL3o0gjHLXJKwHAEHp2I3fd3AgbMud2oz+hzeV0g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -2701,7 +2704,7 @@
"@edge-runtime/primitives": "4.1.0",
"@edge-runtime/vm": "3.2.0",
"@types/node": "20.11.0",
"@vercel/build-utils": "14.0.5",
"@vercel/build-utils": "14.1.1",
"@vercel/error-utils": "2.2.1",
"@vercel/nft": "1.10.0",
"@vercel/static-config": "3.4.1",
@ -7414,12 +7417,12 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.11",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
"version": "17.0.12",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},

View file

@ -46,7 +46,7 @@
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-i18next": "^17.0.12",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
@ -59,7 +59,7 @@
"devDependencies": {
"@babel/types": "^8.0.4",
"@playwright/test": "^1.62.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.2.0",
@ -67,7 +67,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.9.9",
"@vercel/node": "^5.10.1",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",

View file

@ -15,6 +15,8 @@ import {
DATA_ROUTE_TABLE_SOURCE,
scanDataRouteTables,
} from '../../../ingestion/route-extractors/data-route-table.js';
import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js';
import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
import {
buildJsRepoFacts,
extractJsModuleFacts,
@ -27,7 +29,8 @@ import {
/**
* Node.js / TypeScript HTTP plugin family. Handles:
* - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods
* - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods,
* delegated wholesale to the indexer's `extractNestRoutes`
* - Express `router.get(...)` / `app.post(...)` providers
* - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers
* - `axios.get(url)` / `axios.delete(url)` consumers
@ -42,34 +45,8 @@ import {
* same `scan` function but bind to different grammars.
*/
// ─── Provider: NestJS — class-level @Controller('prefix') ────────────
// In tree-sitter-typescript decorators are NOT children of
// class_declaration / method_definition — they're siblings in the
// surrounding class_body / program node. We therefore match the
// decorator standalone and walk to its related class/method in JS.
const NEST_CONTROLLER_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#eq? @dec "Controller")
arguments: (arguments . [(string) (template_string)] @prefix))) @ctrl_decorator
`,
};
// ─── Provider: NestJS — method-level @Get/@Post/... decorators ───────
// Matches either `@Get('path')` or `@Get()`. The `@path` capture is
// optional — when the first argument isn't a string, the plugin falls
// back to '/' for the method-level path.
const NEST_METHOD_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#match? @dec "^(Get|Post|Put|Delete|Patch)$")
arguments: (arguments) @args)) @method_decorator
`,
};
// NestJS providers are not queried here at all — see the `extractNestRoutes`
// call in `scanBundle`.
// ─── Provider: Express — router.get/app.post/... ─────────────────────
const EXPRESS_SPEC: PatternSpec<Record<string, never>> = {
@ -176,8 +153,6 @@ const AXIOS_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
};
interface NodePatternBundle {
controller: CompiledPatterns<Record<string, never>>;
methodDecorator: CompiledPatterns<Record<string, never>>;
express: CompiledPatterns<Record<string, never>>;
fetchNoOptions: CompiledPatterns<Record<string, never>>;
fetchWithOptions: CompiledPatterns<Record<string, never>>;
@ -195,8 +170,6 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
patterns: [spec],
} satisfies LanguagePatterns<Record<string, never>>);
return {
controller: mk(NEST_CONTROLLER_SPEC, 'nest-controller'),
methodDecorator: mk(NEST_METHOD_SPEC, 'nest-method-decorator'),
express: mk(EXPRESS_SPEC, 'express'),
fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'),
fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'),
@ -211,33 +184,6 @@ const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-http');
const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-http');
const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http');
const NEST_DECORATOR_TO_HTTP: Record<string, string> = {
Get: 'GET',
Post: 'POST',
Put: 'PUT',
Delete: 'DELETE',
Patch: 'PATCH',
};
/**
* Find the nearest enclosing class_declaration for a node, or null.
*/
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
if (cur.type === 'class_declaration') return cur;
cur = cur.parent;
}
return null;
}
function joinPath(prefix: string, sub: string): string {
const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
const cleanSub = sub.replace(/^\/+/, '');
if (!cleanPrefix) return `/${cleanSub}`;
return `/${cleanPrefix}/${cleanSub}`;
}
/**
* Walk `pair` children of an `object` literal and return the unquoted
* string/template_string value for the first pair whose key matches one
@ -260,68 +206,6 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string
return null;
}
/**
* For a standalone `decorator` node (child of class_body / program),
* find the related `class_declaration` node that it decorates. In
* tree-sitter-typescript the decorator is placed before the class
* declaration as a sibling (when decorating a class) or inside the
* class_body before a method_definition (when decorating a method);
* we walk the parent chain until we find the enclosing class.
*/
function findDecoratedClass(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null {
const parent = decoratorNode.parent;
if (!parent) return null;
// Case 1: decorator is a sibling of the class_declaration at program /
// export_statement level. Walk forward through siblings until we find
// the class_declaration this decorator belongs to.
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && child.id === decoratorNode.id) {
for (let j = i + 1; j < parent.namedChildCount; j++) {
const next = parent.namedChild(j);
if (!next) continue;
if (next.type === 'decorator') continue; // adjacent decorators stack
if (next.type === 'class_declaration') return next;
if (next.type === 'export_statement') {
// `export class Foo { ... }` wraps the declaration.
for (let k = 0; k < next.namedChildCount; k++) {
const inner = next.namedChild(k);
if (inner?.type === 'class_declaration') return inner;
}
}
break;
}
break;
}
}
// Case 2: decorator is inside a class_body (decorating a method) —
// walk up to the enclosing class_declaration.
return findEnclosingClass(decoratorNode);
}
/**
* For a method-level decorator node (child of class_body before a
* method_definition), find the method_definition it decorates.
*/
function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null {
const parent = decoratorNode.parent;
if (!parent || parent.type !== 'class_body') return null;
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && child.id === decoratorNode.id) {
for (let j = i + 1; j < parent.namedChildCount; j++) {
const next = parent.namedChild(j);
if (!next) continue;
if (next.type === 'decorator') continue;
if (next.type === 'method_definition') return next;
return null;
}
return null;
}
}
return null;
}
/**
* Map each named import's LOCAL binding to its DECLARED export name and source
* module, by walking the file's `import { x as y } from 'm'` statements. Lets
@ -589,57 +473,33 @@ function scanBundle(
// symbol resolves to the real definition rather than its local alias text.
const importMap = buildImportMap(tree);
// NestJS: collect `@Controller('prefix')` class decorators, keyed by
// the `class_declaration` they decorate.
const prefixByClassId = new Map<number, string>();
for (const match of runCompiledPatterns(bundle.controller, tree)) {
const prefixNode = match.captures.prefix;
const decoratorNode = match.captures.ctrl_decorator;
if (!prefixNode || !decoratorNode) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix === null) continue;
const classNode = findDecoratedClass(decoratorNode);
if (!classNode) continue;
prefixByClassId.set(classNode.id, prefix);
}
// NestJS: method-level @Get/@Post/... decorators. The decorator's
// arguments list may be empty (`@Get()`), a string (`@Get('path')`),
// or something else (which we skip).
for (const match of runCompiledPatterns(bundle.methodDecorator, tree)) {
const decNode = match.captures.dec;
const argsNode = match.captures.args;
const decoratorNode = match.captures.method_decorator;
if (!decNode || !argsNode || !decoratorNode) continue;
const httpMethod = NEST_DECORATOR_TO_HTTP[decNode.text];
if (!httpMethod) continue;
const methodNode = findDecoratedMethod(decoratorNode);
if (!methodNode) continue;
const enclosingClass = findEnclosingClass(methodNode);
// Only emit NestJS detections when the class actually has a
// @Controller decorator — without it, the match is almost certainly
// something else (e.g. an unrelated library using similar names).
if (!enclosingClass || !prefixByClassId.has(enclosingClass.id)) continue;
const prefix = prefixByClassId.get(enclosingClass.id) ?? '';
let rawPath = '/';
const firstArg = argsNode.namedChild(0);
if (firstArg && (firstArg.type === 'string' || firstArg.type === 'template_string')) {
const unquoted = unquoteLiteral(firstArg.text);
if (unquoted !== null) rawPath = unquoted;
}
// Get the method name from the decorated method_definition.
const methodNameNode = methodNode.childForFieldName('name');
const name = methodNameNode?.text ?? null;
// NestJS: delegated to the indexer's extractor rather than re-queried here.
// Two independent readings of the same decorators is how the layers drift:
// the local scan saw only `class_declaration` (never `abstract class`), only
// five of the nine verbs, only a positional string `@Controller('x')`, and
// — worst — INVENTED `/` for a method path it could not read, so
// `@Get(ROUTES.SEARCH)` became a `GET /venues` contract that the graph, which
// correctly drops it, has no Route node for. "A missing route is a coverage
// limit; an invented one is a lie" (ARCHITECTURE.md). Calling the extractor
// makes that divergence structurally impossible, exactly as the
// `scanDataRouteTables` call below already does for static route tables.
//
// `filePath` rides only on the returned struct and never reaches the
// `HttpDetection`, so a bare `scan(tree)` with no `fileRel` passes '' rather
// than losing the routes. `lineOffset` is 0: the group scanner parses whole
// files, so `lineNumber` is already the absolute 1-based line this
// `HttpDetection.line` wants.
for (const route of extractNestRoutes(tree, fileRel ?? '', 0)) {
out.push({
role: 'provider',
framework: 'nest',
method: httpMethod,
path: joinPath(prefix, rawPath),
name,
line: methodNode.startPosition.row + 1,
method: route.httpMethod,
// The prefix travels separately at the ingestion layer, so the join is
// ours to do — with ingestion's own joiner, so the two layers cannot
// disagree about the URL either.
path: normalizeExtractedRoutePath(route.routePath, route.prefix ?? null),
name: route.handlerName ?? null,
line: route.lineNumber,
confidence: 0.8,
});
}

View file

@ -126,11 +126,13 @@ import {
} from './javascript/index.js';
import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js';
import { extractNestRoutes } from '../route-extractors/nest.js';
import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
...extractDispatchGuardRoutes(...args),
...extractDataRouteTableRoutes(...args),
...extractNestRoutes(...args),
];
/**

View file

@ -195,6 +195,45 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
const allFetchCalls = [...parseFetchCalls];
const routeRegistry = new Map<string, RouteEntry>();
/**
* Registry keys written straight from the file list below, never through
* `addRoute`. `resolveRouteHandlerSymbols` walks only `extractedRoutes` and
* `decoratorRoutes`, so it never sees these URLs and its `claimed` set never
* contains them which means a handler stamped on one of these keys was
* resolved for a DIFFERENT route.
*
* That is reachable, and it fabricates rather than omits (#3049). A
* method-agnostic route (`@All`, a Django function view, a verb-less
* dispatch guard) keys by URL alone via `routeNodeKey`, so it collides with
* a file-convention route at the same URL. It claims the key unopposed in
* `claim()`, then loses first-writer-wins here in `addRoute` and is dropped
* as a duplicate and without this guard the surviving file-convention node
* would read that handler and present another application's controller
* method as its own. `api_impact` is documented to be run BEFORE editing a
* route handler, so it would answer with a handler from the wrong app.
*
* Dropping the losing route is a separate and deliberate consequence of
* URL-only identity; this only stops the false attribution.
*
* Membership is recorded AT the pre-seeding `set`, mirroring `claim()` in
* call-processor.ts, which writes `claimed` and its result map together
* rather than re-deriving either by rescanning. Identifying pre-seeded
* entries by matching `entry.source` against a list of source strings would
* spell them a second time, away from the sites that produce them and a
* fourth pre-seeded source added later would then reopen #3049 in silence.
* `addRoute` deliberately does NOT record here: its routes ARE
* handler-resolved, so suppressing them would widen the guard into a bug of
* its own.
*
* Each `add` below sits inside its own `!routeRegistry.has(key)` gate, as
* every `routeRegistry.set` in this phase does: the map is write-once per
* key, so a losing candidate cannot record a key it did not claim and no
* later writer can take a recorded key away. Key-membership is therefore
* equivalent to source-matching by construction pre-seeded routes carry no
* verb and `routeNodeKey(undefined, url) === url` which is why the
* two-candidates-one-URL case needs no fixture to settle it.
*/
const preSeededKeys = new Set<string>();
// Detect Expo Router app/ roots vs Next.js app/ roots (monorepo-safe)
const expoAppRoots = new Set<string>();
@ -217,32 +256,33 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
}
}
// One writer for every pre-seeded route, so recording membership cannot be
// forgotten. Inlining `has` / `set` / `add` at each site made the invariant
// a convention three call sites had to remember — and a fourth source that
// forgot the `add` would reopen #3049 exactly as silently as the source-set
// it replaced. This is the shape `claim()` in call-processor.ts uses for the
// same reason: one helper writes the collection and its key set together.
const preSeed = (url: string, entry: Omit<RouteEntry, 'url'>): boolean => {
if (routeRegistry.has(url)) return false;
routeRegistry.set(url, { ...entry, url });
preSeededKeys.add(url);
return true;
};
for (const p of allPaths) {
if (expoAppPaths.has(p)) {
const expoURL = expoFileToRouteURL(p);
if (expoURL && !routeRegistry.has(expoURL)) {
routeRegistry.set(expoURL, {
filePath: p,
source: 'expo-filesystem-route',
url: expoURL,
});
if (expoURL && preSeed(expoURL, { filePath: p, source: 'expo-filesystem-route' })) {
continue;
}
}
const nextjsURL = nextjsFileToRouteURL(p);
if (nextjsURL && !routeRegistry.has(nextjsURL)) {
routeRegistry.set(nextjsURL, {
filePath: p,
source: 'nextjs-filesystem-route',
url: nextjsURL,
});
if (nextjsURL && preSeed(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' })) {
continue;
}
if (p.endsWith('.php')) {
const phpURL = phpFileToRouteURL(p);
if (phpURL && !routeRegistry.has(phpURL)) {
routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route', url: phpURL });
}
if (phpURL) preSeed(phpURL, { filePath: p, source: 'php-file-route' });
}
}
@ -311,7 +351,11 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
const { source: routeSource, method: routeMethod, url } = entry;
const handlerPath = handlerPathFor(routeKey, entry);
const content = handlerContents.get(handlerPath);
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
// A pre-seeded route can never legitimately appear in
// `routeHandlerSymbols`, so a key that does is a route that LOST (#3049).
const handlerSymbolId = preSeededKeys.has(routeKey)
? undefined
: routeHandlerSymbols.get(routeKey);
const analysisContent =
entry.source === DATA_ROUTE_TABLE_SOURCE && content
? handlerSymbolContent(

View file

@ -116,7 +116,14 @@ function decodeJavaScriptStringLiteral(raw: string): string | null {
return decoded;
}
function plainString(node: SyntaxNode): string | null {
/**
* A `string`/`template_string` node's decoded value, or `null` when it is not a
* readable literal (an interpolated template, an unterminated escape). Shared
* with the NestJS extractor so both agree on what a readable literal is
* notably that escapes must be DECODED, not dropped, because tree-sitter splits
* a literal around every `escape_sequence`.
*/
export function plainString(node: SyntaxNode): string | null {
if (node.type === 'string') return decodeJavaScriptStringLiteral(node.text);
if (
node.type === 'template_string' &&
@ -129,7 +136,12 @@ function plainString(node: SyntaxNode): string | null {
return null;
}
function propertyName(node: SyntaxNode): string | null {
/**
* A property key's name, for the spellings that carry one `{ path: … }` and
* `{ 'path': … }`. A computed key (`{ [KEY]: … }`) has none. Shared with the
* NestJS extractor, which reads `@Controller({ path: … })` the same way.
*/
export function propertyName(node: SyntaxNode): string | null {
if (node.type === 'identifier' || node.type === 'property_identifier') return node.text;
if (node.type === 'string') return plainString(node);
return null;

View file

@ -0,0 +1,548 @@
/**
* NestJS decorator routes for the indexer.
*
* A NestJS endpoint is declared across two decorators: `@Controller('venues')`
* on the class supplies the prefix, and `@Get('search')` on a method supplies
* the verb and the remainder. Neither half is a route on its own, which is why
* a pattern that only looks at one of them finds nothing.
*
* Until this existed, TypeScript's `extractDecoratorRoutes` hook was dispatch
* guards plus static data route tables only, so a NestJS repo produced
* essentially no `Route` nodes. That is not a quiet gap: `route_map`,
* `api_impact` and `shape_check` all read `Route` nodes and answer "no routes
* matching " when there are none so `api_impact`, whose documented job is to
* be run BEFORE modifying a route handler, reported every live endpoint as
* non-existent, and a not-found reads as a safe change (#3009).
*
* The extraction mirrors `spring.ts`, which solves the identical shape for
* `@RequestMapping` + `@GetMapping`: collect class-level prefixes keyed by class
* node id, then walk method decorators and attach the prefix of their enclosing
* class. As there, the prefix travels on `ExtractedDecoratorRoute.prefix` and
* the routes phase performs the join via `normalizeExtractedRoutePath`, so
* NestJS routes are keyed identically to every other framework's.
*
* The multi-path form `@Get(['a', 'b'])` mounts the handler at BOTH paths, so
* it emits both routes: N paths is N elements of the returned
* `ExtractedDecoratorRoute[]`, which is already how this layer spells N routes
* the same representation `spring.ts` reaches for `@GetMapping({"/a","/b"})`,
* and the reason neither needs a special case downstream. The CLASS-level array
* (`@Controller(['a', 'b'])`) is DECLINED rather than cross-multiplied over the
* class's methods, again matching `spring.ts`: there an array-form class prefix
* only ever suppresses the class, with the cross-product tracked in #2280.
*
* Known limitation: the URLs produced here are CONTROLLER-RELATIVE. A global
* prefix (`app.setGlobalPrefix('api')`) and URI versioning are applied by the
* bootstrap file, not by any decorator this file can see, so neither is
* reflected a route served at `/api/v1/venues/search` is stored as
* `/venues/search`. The module's "drop rather than guess" floor is unavailable
* for it: the evidence lives in a different file, so honouring it would mean
* dropping every Nest route in every repo. `spring.ts` has the same hole for
* `server.servlet.context-path`; `ExtractedDecoratorRoute.prefix` is the
* channel a cross-file follow-up would use, the way FastAPI resolves its mount.
*/
import type Parser from 'tree-sitter';
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
import { plainString, propertyName } from './data-route-table.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
/**
* NestJS method decorators HTTP verb. A Map rather than an object literal
* because the lookup key is an arbitrary decorator name read out of source: a
* plain object answers `@toString()` with `Object.prototype.toString`, which is
* truthy and would be emitted verbatim as the route's httpMethod.
*/
const NEST_METHOD_DECORATORS: ReadonlyMap<string, string> = new Map([
['Get', 'GET'],
['Post', 'POST'],
['Put', 'PUT'],
['Patch', 'PATCH'],
['Delete', 'DELETE'],
['Head', 'HEAD'],
['Options', 'OPTIONS'],
['All', '*'],
// `@Sse` mounts a real GET endpoint that streams; it is as much a route as
// `@Get`. `@Search` is deliberately absent — `normalizeRouteMethod` rejects
// SEARCH as non-standard and would key the route by URL alone, colliding
// with every other verb on that path.
['Sse', 'GET'],
]);
/**
* Class node types that can carry a `@Controller`. `export abstract class C`
* parses as `abstract_class_declaration`, a DIFFERENT node type and a
* decorated abstract base sharing CRUD routes with its subclasses is ordinary
* Nest, so matching `class_declaration` alone silently drops the whole
* controller rather than one route.
*/
const CLASS_DECLARATION_TYPES: ReadonlySet<string> = new Set([
'class_declaration',
'abstract_class_declaration',
]);
/**
* Cheap parse-free gate. Every JS/TS file in every repo reaches this hook, so
* skip the walk unless the file could plausibly declare a controller. A file
* without the substring cannot produce a route here, because a `@Controller`
* decorator is REQUIRED before any method decorator is believed (see below).
*/
const CONTROLLER_HINT = '@Controller';
/** The decorator's name — `Controller` for `@Controller('x')`, `Get` for `@Get()`. */
function decoratorName(decorator: Parser.SyntaxNode): string | null {
const inner = decorator.namedChild(0);
if (!inner) return null;
// `@Get()` is a call_expression; a bare `@Injectable` is a plain identifier.
if (inner.type === 'identifier') return inner.text;
if (inner.type === 'call_expression') {
const fn = inner.childForFieldName('function');
return fn?.type === 'identifier' ? fn.text : null;
}
return null;
}
/**
* The literal path(s) a decorator call mounts, one entry per path or `['']`
* when the decorator takes no argument (`@Controller()` / `@Get()` both legal
* and both meaning "no path segment of my own").
*
* A list rather than a single string because `@Get(['a', 'b'])` mounts the
* handler at two URLs, and two routes is what the caller's output contract
* already says that in: `ExtractedDecoratorRoute[]`. No new field, and no
* special case at the emit site the same shape `spring.ts` gets for free from
* a query that matches one element at a time.
*
* Returns `null` when an argument IS present but is not a readable literal.
* That is deliberately distinct from `['']`: a computed prefix
* (`@Controller(ROUTES.VENUES)`) whose value we cannot read must drop the route
* rather than silently mount it at the wrong URL. `route_map` presents its
* output as fact, and a wrong path is worse than a missing one. `[]` is a third
* answer and means neither of those: `@Get([])` is legal, knowably mounts
* nothing, and so emits nothing it must never be read as the unknowable case,
* which is the one that suppresses a whole controller.
*
* Reading one literal is delegated to `plainString`, the same judge the
* data-route-table extractor uses, so both agree on what is readable. Filtering
* `string_fragment` children and joining them looks equivalent and is not:
* tree-sitter SPLITS a literal around each `escape_sequence`, and the join then
* DELETES the escape rather than decoding it. `@Get(':id(\\d+)')` the ordinary
* spelling of a Nest regex param, whose value is `:id(\d+)` came out as
* `:id(d+)`, and `@Get('/v\u0069ews')` came out as `/vews`. Both are paths the
* app never serves, i.e. the wrong-URL outcome the paragraph above forbids.
*/
function decoratorLiteralPaths(decorator: Parser.SyntaxNode): readonly string[] | null {
const call = decorator.namedChild(0);
// A Nest route decorator is a FACTORY: `@Get()` invokes it and returns the
// decorator that registers the route. A BARE `@Get` is the factory itself,
// never applied, so Nest registers nothing — emitting a route for it would
// publish a URL the app does not serve. The same holds one level up for a
// bare `@Controller`, which registers no controller.
//
// `@Get()` with no ARGUMENT is different and still a real pathless route:
// what distinguishes them is the call, not the argument list. That case falls
// through to the `!first` branch below.
if (call?.type !== 'call_expression') return null;
const first = call.childForFieldName('arguments')?.namedChild(0);
if (!first) return [''];
// The object form belongs to `@Controller` alone — a verb decorator takes
// `string | string[]`, so Nest mounts nothing for `@Get({ path: 'a' })`.
// Reading it as a route would mint a URL the app never serves, which is the
// invented fact this module refuses; an unreadable shape drops instead.
if (first.type === 'object' && decoratorName(decorator) !== 'Controller') return null;
return literalPaths(first);
}
/**
* The paths carried by one decorator ARGUMENT node, split out from
* {@link decoratorLiteralPaths} only so the object form can re-enter it: Nest
* accepts an array inside `{ path: … }` as well, and reusing the same judge is
* what keeps `@Controller({ path: ['a', 'b'] })` from being read by a second,
* laxer set of rules that has drifted from this one.
*/
function literalPaths(node: Parser.SyntaxNode): readonly string[] | null {
// `@Controller({ path: 'cats', version: '1' })` is the documented form for
// URI/header versioning, and its path is a plain literal sitting right there.
// Worth reading rather than dropping, because the asymmetry is severe: an
// unreadable METHOD path costs one route, an unreadable PREFIX costs every
// route on the class.
if (node.type === 'object') {
// But a `path` pair only PROVES the mount when nothing else in the object
// can replace it, and the first match proves nothing on its own:
// `{ path: 'cats', ...options }` mounts wherever `options.path` says, and
// `{ path: 'cats', path: 'dogs' }` mounts at `dogs` — last write wins in
// both. Either one publishes `/cats`, a URL the app never serves, and it
// looks exactly like a correct one, which is the wrong-answer-dressed-as-
// fact this module refuses. So the object is read only when EVERY member is
// a named, non-repeated pair. That whole-entry fail-closed walk is the
// shape `routeFromObject` uses in `data-route-table.ts`.
const values = new Map<string, Parser.SyntaxNode>();
for (const child of node.namedChildren) {
// Skipped FIRST. A comment between two pairs is ordinary formatting; run
// through the not-a-pair test below it would refuse the object and cost
// the class every route it has, over a comment.
if (child.type === 'comment') continue;
// `spread_element` (`{ ...options }`), `shorthand_property_identifier`
// (`{ path }`) and `method_definition` (`{ getFoo() {} }`) all land here
// — probed and identical across the three grammars this extractor runs
// under. None offers a key/value this file can read, and the first can
// introduce or overwrite `path` from a value declared elsewhere.
if (child.type !== 'pair') return null;
const key = child.childForFieldName('key');
const value = child.childForFieldName('value');
if (key === null || value === null) return null;
// Compared through `propertyName`, the same judge used to READ the key —
// so `{ path: … }` and `{ 'path': … }` are one key and collide as
// duplicates. Comparing raw key text instead makes them two distinct
// keys, and `{ path: 'cats', 'path': 'dogs' }` silently mounts the loser.
const name = propertyName(key);
// No readable name means a computed key (`{ [dynamicKey]: 'b' }`), which
// could evaluate to `path` and take the mount with it — refused, not
// ignored. A repeated key is refused wherever it appears, not only on
// `path`: a duplicate anywhere is evidence the object is not the fixed
// literal it reads as, and cost is one controller against a wrong URL.
if (name === null || values.has(name)) return null;
values.set(name, value);
}
// Deliberately NOT `containsExecutingExpression` (data-route-table.ts): that
// guards whole-entry declarativeness for a static route table, a different
// invariant. Here only `path` has to be provable, so a non-literal value on
// an unrelated key — `{ path: 'a', scope: Scope.REQUEST }`, ordinary Nest —
// stays benign and keeps its controller.
const path = values.get('path');
// A missing `path` keeps the existing drop and must never read as `''`:
// `@Controller({ version: '1' })` mounts at a prefix this decorator does
// not state, and `''` would publish every one of its methods at the root.
return path === undefined ? null : literalPaths(path);
}
// `array` is the node type in all three grammars this extractor runs under —
// tree-sitter-typescript's `typescript` and `tsx`, and tree-sitter-javascript
// — probed rather than assumed, because a name that differs in one of them
// would silently restore the old drop for that grammar alone.
if (node.type === 'array') {
const paths: string[] = [];
for (const element of node.namedChildren) {
const value = plainString(element);
// One unreadable element poisons the whole array. Emitting the readable
// ones would present a partial mapping as a complete one — the endpoint
// behind `ROUTES.ADMIN` would be missing from a controller that otherwise
// looks fully covered, which is the same wrong-answer-dressed-as-fact this
// module refuses above, only harder to notice.
if (value === null) return null;
paths.push(value);
}
return paths;
}
const value = plainString(node);
return value === null ? null : [value];
}
/**
* Decorators that immediately precede `node` among its parent's named children.
* In tree-sitter-typescript a decorator is a SIBLING placed before the thing it
* decorates at `export_statement`/`program` level for a class and
* decorators stack.
*
* Walks the sibling chain rather than indexing into `parent.namedChildren`,
* which is the same uncached-getter trap {@link collectClassRoutes} documents:
* a class's parent is usually `program`, so reading the list marshals every
* top-level statement in the file, once per class. That is quadratic in
* top-level statements measured 200ms for a file of 800 classes, against
* 0.9ms for this form.
*/
function precedingDecorators(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
const out: Parser.SyntaxNode[] = [];
for (let sibling = node.previousNamedSibling; sibling; sibling = sibling.previousNamedSibling) {
// A comment between the decorators and the thing they decorate is ordinary
// (`@Post('x')` then a JSDoc block then the method) and must not terminate
// the stack — doing so makes the whole decorated route invisible.
if (sibling.type === 'comment') continue;
if (sibling.type !== 'decorator') break;
out.push(sibling);
}
return out;
}
/**
* Leading `decorator` children of a node, stopping at the first child that is
* neither a decorator nor a comment. Comments are skipped for the same reason
* as in {@link precedingDecorators}: a doc block sitting between `@Controller`
* and the class must not hide the decorator.
*/
function leadingDecorators(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
const out: Parser.SyntaxNode[] = [];
for (const child of node.namedChildren) {
if (child.type === 'comment') continue;
if (child.type !== 'decorator') break;
out.push(child);
}
return out;
}
/**
* Cap on the decorator text quoted in the dropped-controller log. Long enough
* to identify the shape, short enough not to dump a wrapped multi-line
* decorator into the operator's terminal.
*/
const DROPPED_CONTROLLER_LOG_LIMIT = 160;
/**
* Every decorator attached to a class, across the two shapes the grammar
* produces which differ by whether the class is exported:
*
* `@Controller('a') class A {}` decorator is a CHILD of class_declaration
* `@Controller('a') export class A {}` decorator is a child of export_statement,
* i.e. a SIBLING of the class_declaration
*
* Checking only one of them silently drops half of all controllers, so collect
* from both, plus the sibling position for the class itself. There is no fourth
* source: both grammars fold a class's decorators INTO the `export_statement`
* production, so an `export_statement` never has one as a preceding sibling.
*/
function classDecorators(classNode: Parser.SyntaxNode): Parser.SyntaxNode[] {
const out = [...leadingDecorators(classNode), ...precedingDecorators(classNode)];
const wrapper = classNode.parent;
if (wrapper?.type === 'export_statement') out.push(...leadingDecorators(wrapper));
return out;
}
/**
* The `@Controller(...)` prefix for a class, or undefined when it has none.
* One string, not a list: a class-level array (`@Controller(['a', 'b'])`) is
* DECLINED here, exactly as `spring.ts` declines an array-form
* `@RequestMapping` it detects the shape only to suppress the class, leaving
* the prefix × method cross-product to #2280. Collapsing to `null` is that
* suppression, and this parity is deliberate, not an oversight: the two
* extractors solve the same shape and should not disagree about which half of
* it is supported.
*/
function controllerPrefix(
classNode: Parser.SyntaxNode,
filePath: string,
): string | null | undefined {
for (const decorator of classDecorators(classNode)) {
if (decoratorName(decorator) !== 'Controller') continue;
const paths = decoratorLiteralPaths(decorator);
// `@Controller([])` lands here too and needs no answer of its own: a
// controller mounted at no path serves no route, so "emit nothing for this
// class" is what both readings of it come to.
if (paths === null || paths.length !== 1) {
// The single funnel for EVERY whole-controller drop — an unreadable
// constant (`@Controller(ROUTES.VENUES)`), a multi-path array, an
// unreadable array element, and an options object whose `path` another
// member could override all return null here. Reporting at the refusal
// sites instead would make the rarest cause the loudest, and leave the
// motivating one from this module's own header silent.
//
// `isDev` at `info`, not `debug`: the logger's base level IS `info`, so
// an isDev-gated `debug` is gated twice and stays silent in exactly the
// dev run it exists for. Same shape the routes phase uses.
if (isDev) {
const shape = decorator.text.replace(/\s+/g, ' ');
logger.info(
`🗺️ NestJS: dropped @Controller in ${filePath} — its prefix is not provable: ${
shape.length > DROPPED_CONTROLLER_LOG_LIMIT
? `${shape.slice(0, DROPPED_CONTROLLER_LOG_LIMIT)}`
: shape
}`,
);
}
return null;
}
return paths[0];
}
return undefined;
}
/**
* Extract NestJS routes from one parsed TypeScript/JavaScript file.
*
* A method decorator is only believed when its enclosing class carries a
* `@Controller`. `@Get`/`@Post`/`@Delete` are common identifiers, and without
* that requirement any unrelated library using the same decorator names would
* mint phantom endpoints.
*/
export function extractNestRoutes(
tree: Parser.Tree,
filePath: string,
lineOffset = 0,
): ExtractedDecoratorRoute[] {
if (!tree.rootNode.text.includes(CONTROLLER_HINT)) return [];
const out: ExtractedDecoratorRoute[] = [];
const visit = (node: Parser.SyntaxNode): void => {
if (CLASS_DECLARATION_TYPES.has(node.type)) {
const prefix = controllerPrefix(node, filePath);
// `undefined` — not a controller at all. `null` — a controller whose
// prefix could not be read, so its routes' URLs are unknowable.
if (prefix !== undefined) {
if (prefix !== null) collectClassRoutes(node, prefix, filePath, lineOffset, out);
return; // a controller's methods are handled here; don't re-walk them
}
}
for (const child of node.namedChildren) visit(child);
};
visit(tree.rootNode);
return out;
}
/**
* Modifiers that take a `method_definition` out of Nest's handler set.
*
* Nest's `RequestMapping` writes the handler onto the class PROTOTYPE's
* `descriptor.value`, and `RouterExplorer` scans prototype instance methods for
* that metadata. A `static` method lives on the constructor and is never
* scanned; an accessor's descriptor carries `get`/`set` and no `value` to
* register. A verb decorator on any of the three therefore mounts NOTHING, so a
* route minted from one is a URL the app does not serve the invented fact
* this module refuses everywhere else.
*/
const NON_HANDLER_MODIFIERS: ReadonlySet<string> = new Set(['static', 'get', 'set']);
/** Longest entry above — the cheap gate that keeps `.trim()` off a method body. */
const LONGEST_NON_HANDLER_MODIFIER = 6;
/**
* Whether Nest could register this `method_definition` as a request handler.
*
* Reads `children`, NOT `namedChildren`, and that is the whole difficulty:
* `static`, `get` and `set` are ANONYMOUS tokens in all three grammars this
* extractor runs under, so they never appear among named children. A static
* method, a getter, a setter and a plain method expose the IDENTICAL
* `namedChildren` (`property_identifier`, `formal_parameters`,
* `statement_block`) probed, not assumed so the module's usual
* `namedChildren` idiom cannot see the modifier at all and every one of the
* three reads as an ordinary handler.
*
* Matches on child TEXT, not node type, and skips the `name` field the same
* two rules `hasKeyword` in `field-extractors/configs/helpers.ts` applies, and
* that the TS/JS captures and method extractor already use for this question.
* The text rule is load-bearing: `static` reaches the tree as an anonymous
* token in some grammar versions and a keyword node in others, so a
* `child.type === 'static'` test silently stops firing on a grammar bump here
* that would readmit exactly the phantom routes this function removes, with the
* suite still green. Skipping `name` is what keeps a method literally called
* `get()` or `static()` from reading as a modifier.
*
* Open-coded rather than calling `hasKeyword` three times, which was measured
* at 13.96us per method against 4.30us here: that helper takes ONE keyword, so
* three keywords is three full passes, and it calls `.text.trim()` on every
* child including `statement_block` the whole method body. `.some()` does not
* rescue it, since a real handler matches nothing and pays all three. The
* length guard keeps `.trim()` off a multi-KB body; no modifier exceeds it.
*
* The scan is bounded by one method's own children (a handful), so it is not
* the uncached-getter trap {@link collectClassRoutes} documents that one bites
* when a PARENT's child list is re-marshalled once per member.
*/
function isRequestHandler(member: Parser.SyntaxNode): boolean {
const nameNode = member.childForFieldName('name');
for (const child of member.children) {
if (child === nameNode) continue;
const text = child.text;
if (text.length <= LONGEST_NON_HANDLER_MODIFIER && NON_HANDLER_MODIFIERS.has(text.trim())) {
return false;
}
}
return true;
}
function collectClassRoutes(
classNode: Parser.SyntaxNode,
prefix: string,
filePath: string,
lineOffset: number,
out: ExtractedDecoratorRoute[],
): void {
const body = classNode.childForFieldName('body');
if (!body) return;
// ONE forward pass over the body, accumulating the decorator run and flushing
// it at each method. Calling `precedingDecorators` per method instead is
// quadratic in methods-per-controller for a reason that is invisible in the
// source: `namedChildren` is an UNCACHED getter in node-tree-sitter, so every
// call re-marshals the entire class body into fresh JS objects before the
// `findIndex`. Measured here, 800 methods cost 362ms (450us/method, up from
// 42us/method at 50); a single pass is flat. `spring.ts` never had this
// because a Java annotation is a child of the declaration it annotates.
const pending: Parser.SyntaxNode[] = [];
for (const member of body.namedChildren) {
if (member.type === 'decorator') {
pending.push(member);
continue;
}
// Same reason as in `precedingDecorators`: a JSDoc block between a
// decorator stack and its method must not hide the route (a real
// controller shape, pinned by the suite). Known limitation of that skip: a
// decorator ORPHANED by a commented-out handler is then absorbed onto the
// NEXT method, minting a phantom route with the wrong handler. There is no
// AST fix — an orphan followed by a comment is indistinguishable from a
// stack whose method happens to be documented — and losing every
// documented route is the worse trade, so it is made deliberately.
if (member.type === 'comment') continue;
// tree-sitter-javascript makes a method decorator a CHILD of the
// `method_definition`, not a preceding sibling as in tree-sitter-typescript
// — and this extractor is registered on the JavaScript provider too, which
// already advertises `framework: 'nestjs'`. Reading only siblings meant
// every `.js` Nest controller emitted nothing. On TypeScript the first
// named child is the method name, so `leadingDecorators` contributes
// nothing there and no route is collected twice.
//
// A static member, a getter and a setter are decorated exactly like a
// handler and registered as none, so they contribute no decorators (see
// `isRequestHandler`). They still fall THROUGH to the `pending.length = 0`
// below rather than `continue` past it: skipping the clear would hand their
// decorator run to the next method, trading a phantom route for a
// misattributed one — the strictly worse of the two, since it corrupts a
// route that is otherwise correct.
const decorators =
member.type === 'method_definition' && isRequestHandler(member)
? [...pending, ...leadingDecorators(member)]
: [];
for (const decorator of decorators) {
const name = decoratorName(decorator);
if (name === null) continue;
const httpMethod = NEST_METHOD_DECORATORS.get(name);
if (httpMethod === undefined) continue;
const routePaths = decoratorLiteralPaths(decorator);
if (routePaths === null) continue; // unreadable → skip
const handlerName = member.childForFieldName('name')?.text;
// One route per path. `@Get(['a', 'b'])` mounts the handler at both, and
// everything else about the two is identical — same verb, same handler,
// same line — so the loop is the whole of the multi-path support. An
// empty array falls out as zero iterations without a special case.
for (const routePath of routePaths) {
out.push({
filePath,
// A pathless `@Get()` is the controller's index route and carries no
// segment of its own. Emit '/' rather than '': `claim()` in
// call-processor short-circuits on a falsy routePath, so an empty
// string would still produce the Route node but silently lose its
// handler symbol — the route would exist with nothing attached to it.
// Both spellings normalize to the same URL against the prefix.
routePath: routePath === '' ? '/' : routePath,
httpMethod,
decoratorName: name,
lineNumber: member.startPosition.row + 1 + lineOffset,
prefix: prefix === '' ? null : prefix,
...(handlerName === undefined ? {} : { handlerName }),
});
}
}
// Anything that is not a decorator or a comment ends the run — including
// the method that just consumed it, so a decorated FIELD's stack is never
// absorbed onto the method after it.
pending.length = 0;
}
}

View file

@ -586,8 +586,71 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// detects the changed analyzer build in run-analyze.ts. Both guards are
// required; a parse-cache bump alone must never be read as a graph rebuild.
// Version 75 is intentionally skipped because concurrent PR #3017 claims it.
//
// 74 -> 75 adds #3009's NestJS decorator routes to the JS/TS decoratorRoutes
// channel. Same reasoning as 69: a warm pre-feature cache replays unchanged
// worker results, which for every already-indexed NestJS repo means replaying
// the empty route set this change exists to fix — the fix would appear to do
// nothing until something else invalidated the cache.
//
// 75, not 71 (this branch's original claim) and not 74: origin/main cascaded
// past both while this PR was open. #2980 took 71, #3046 claims 73, and the
// Convex endpoint-metadata change took 74 (skipping 73 for exactly that
// reason). 75 is the next free value above origin/main AND above every
// in-flight claim — the rule the ledger states, not "one above main". Every
// open PR touching gitnexus/src/storage/parse-cache.ts was scanned at this
// merge: #3046 (73) and #1616 (a stale 2) are the only other claimants.
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 76;
//
// 75 -> 76 for the NestJS multi-path (array) form: `@Get(['a','b'])` now yields
// one `decoratorRoutes` entry per path where it previously yielded none. That
// changes worker output for the same file, and 75 was already claimed earlier
// on this same branch — so a warm cache written by a dev or CI build at v75,
// before the array form landed, would replay the pre-feature captures under a
// BYTE-IDENTICAL `PARSE_CACHE_VERSION` and the array form would be inert. The
// package version is untouched here, so it cannot rescue that case. Same-branch
// re-bumping is unusual, but the ledger's rule is about what a warm cache can
// replay, not about how the value was reached.
//
// 76 -> 77 because 76 was NOT free. While this branch sat in review, origin/main
// advanced to ac68f5254 and #3046 took 76 — the value this branch already held.
// `gitnexus/package.json` is 1.6.9 on both sides, so `PARSE_CACHE_VERSION` was
// the byte-identical string `76+1.6.9` on two branches that changed
// incompatible worker output. Every warm cache would have been reused across
// both features, making both inert while every test stayed green.
//
// The sharpest part: #3046 skipped 75 BECAUSE this branch held it, then took
// 76 — and this branch had meanwhile moved 75 -> 76 for the array form. Two
// PRs each doing the bookkeeping correctly still collided, because each
// re-checked once and neither re-checked after the other moved. That is what
// the re-check line below is for, and why it says AT MERGE rather than
// when you pick the number.
//
// 77 is free at this merge: origin/main is 76, and the open PRs touching this
// constant are #2840 (a stale 71) and #1616 (a stale 2). Scan with the contents
// API at each PR head, not `gh pr diff` — that exits non-zero on an
// inaccessible fork and prints nothing, so a grep over its output skips the PR
// silently. #2840 was missed exactly that way this round.
//
// WHY THIS IS STILL A HAND-PICKED NUMBER, when `SCHEMA_FINGERPRINT` next door
// is a derived sha256 that cannot collide. The derivation exists and already
// runs: `resolveAnalyzerRunnerIdentity` computes `build.digest` over the
// analyzer build tree on every analyze. It is not used here because it moves on
// ANY build change — a comment-only edit, this paragraph included — so every
// dev and CI rebuild would force a full re-parse of every repo. That is the
// expensive half of the trade, and `PARSE_CACHE_VERSION` already carries the
// package version, so this counter's only job is separating builds that SHARE
// one: dev, CI, unreleased. Exactly the population a whole-build digest would
// punish, and exactly the population that hits the exact-clash failure.
//
// So the counter stays, and the open cost is that a bump nobody makes is
// invisible: a capture change with no bump ships inert and no check can see it.
// #2860 (a base-branch CI comparison) closes the "not greater than main" axis
// only. A digest over just the determinant subset — the language queries plus
// `route-extractors/` and `workers/` module content — would close the missing-
// bump axis without invalidating on unrelated churn, and is the real follow-up.
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 77;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,26 @@
/**
* A NestJS controller mounting a METHOD-AGNOSTIC route at `/api/widgets` the
* same URL `app/api/widgets/route.ts` produces as a Next.js filesystem route.
*
* `@All` maps to httpMethod '*', which `routeNodeKey` keys by URL alone, so
* this route collides with the filesystem one. It is the shape behind #3049.
*
* `@Get('gadgets')` is the NON-colliding companion, and it is here for a
* reason the `@All` route cannot serve: because the `@All` route loses its key
* to the filesystem node and is dropped as a duplicate, it leaves NO trace in
* the graph at all so the whole of this file could stop being extracted
* without a single assertion changing. `GET /api/gadgets` is the only witness
* this fixture can offer that NestJS extraction ran (see the test's comment).
*/
@Controller('api')
export class WidgetsController {
@All('widgets')
handleEveryVerb() {
return 'nest handler';
}
@Get('gadgets')
listGadgets() {
return '[]';
}
}

View file

@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
/**
* A controller with no prefix of its own. `@Controller()` is legal NestJS and
* means "mount at the root", so the method path must reach the graph bare
* this is the fixture half of the extractor's `prefix: '' -> null` mapping.
*/
@Controller()
export class HealthController {
@Get('health')
health(): string {
return 'ok';
}
}

View file

@ -0,0 +1,16 @@
import { Controller, Get } from '@nestjs/common';
const ROUTE_PREFIXES = { legacy: 'legacy' };
/**
* The prefix is not a literal, so the URLs of this controller's methods are
* unknowable here. `route_map` presents its output as fact: a missing route is
* recoverable, a route mounted at the wrong URL is not.
*/
@Controller(ROUTE_PREFIXES.legacy)
export class LegacyController {
@Get('reports')
legacyReports(): string {
return 'legacy';
}
}

View file

@ -0,0 +1,35 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { VenuesService } from './venues.service';
import type { Venue } from './venues.service';
/**
* The shape #3009 is about: the URL is split across two decorators. The class
* decorator carries the prefix and the method decorator carries the verb plus
* the remainder, so neither half is a route on its own.
*/
@Controller('venues')
export class VenuesController {
constructor(private readonly venues: VenuesService) {}
// Pathless index route: its URL is the controller prefix and nothing else.
@Get()
findAll(): Venue[] {
return this.venues.listVenues();
}
@Get('search')
search(@Query('q') term: string): Venue[] {
return this.venues.searchVenues(term);
}
// Same URL as findAll(), different verb — two Route nodes, not one.
@Post()
create(@Body() input: Venue): Venue {
return this.venues.insertVenue(input);
}
@Delete(':id')
remove(@Param('id') id: string): void {
this.venues.deleteVenue(id);
}
}

View file

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
export interface Venue {
id: string;
name: string;
}
/**
* Not a controller. It exists so each handler body calls a real symbol, and so
* the `@Controller` file gate is shown skipping a decorated class that declares
* no routes.
*/
@Injectable()
export class VenuesService {
private readonly rows: Venue[] = [];
listVenues(): Venue[] {
return this.rows;
}
searchVenues(term: string): Venue[] {
return this.rows.filter((row) => row.name.includes(term));
}
insertVenue(input: Venue): Venue {
this.rows.push(input);
return input;
}
deleteVenue(id: string): void {
const index = this.rows.findIndex((row) => row.id === id);
if (index !== -1) this.rows.splice(index, 1);
}
}

View file

@ -12,6 +12,8 @@
* Fixture: `test/fixtures/multi-verb-route-app/`
* - ItemController.java: GET /api/items, POST /api/items, GET /api/widgets
* - app/api/widgets/route.ts: Next.js filesystem route /api/widgets
* - api/widgetsController.ts: NestJS `@All` method-agnostic /api/widgets,
* plus `@Get('gadgets')` GET /api/gadgets (the non-colliding witness)
* - web/itemsClient.ts: verb-less fetch() consumers of both URLs
*/
import { describe, it, expect, beforeAll } from 'vitest';
@ -81,6 +83,56 @@ describe('Multi-verb Route node identity (#2289)', () => {
expect(decoratorNode!.properties.method).toBe('GET');
});
it("never stamps a losing route's handler onto a filesystem node (#3049)", () => {
// The NestJS `@All('widgets')` route keys by URL alone (routeNodeKey drops
// '*'), so it collides with the filesystem route, claims the key unopposed
// in claim(), and is then dropped here by first-writer-wins. Its handler
// must not survive that loss: a Next.js Route node carrying a NestJS
// controller method is a fabricated fact, not a missing one, and
// api_impact is documented to be run BEFORE editing a route handler.
const fsNode = routeNode(generateId('Route', '/api/widgets'));
expect(fsNode, 'filesystem Route node /api/widgets should exist').toBeTruthy();
expect(fsNode!.properties.handlerSymbolId).toBeUndefined();
});
it('extracts a NON-colliding NestJS route (the witness that #3049 suppressed a REAL claim)', () => {
// Load-bearing for the assertion above it, which cannot stand alone:
// `handlerSymbolId === undefined` passes identically whether the guard
// correctly dropped a real NestJS `@All` claim or whether NestJS
// extraction produced nothing at all. Disproved by experiment — commenting
// out `...extractNestRoutes(...args)` in `languages/typescript.ts` and
// rebuilding `dist/` left the whole suite green.
//
// Asserting that the `WidgetsController` class and its `handleEveryVerb`
// method exist does NOT repair that, and is the first thing the next
// reader will try: those nodes come from the definitions phase, which runs
// regardless, while `extractNestRoutes` is wired only as the
// `decoratorRoutes` hook and contributes no class or method node.
// `@All('widgets')` also cannot witness its own extraction — it collides
// on `routeNodeKey`, is dropped as a duplicate, and leaves no graph trace.
// A second route at a URL nothing else claims is the only evidence the
// graph can carry, so this node existing IS the proof the `@All` claim the
// guard suppressed was real.
const gadgets = routeNode(routeId('GET', '/api/gadgets'));
expect(gadgets, 'NestJS GET /api/gadgets Route node should exist').toBeTruthy();
const handler = result.graph.getNode(String(gadgets!.properties.handlerSymbolId));
expect(String(handler?.properties.name)).toBe('listGadgets');
});
it('still resolves a same-URL route that did NOT lose its key', () => {
// The guard above is scoped to pre-seeded sources, so the Java
// `GET /api/widgets` node — a different key, resolved for itself — keeps
// its handler. Without this, the fix reads as "filesystem collisions are
// handler-less" when the real rule is "a route that lost donates nothing".
const decoratorNode = routeNode(routeId('GET', '/api/widgets'));
const handler = result.graph.getNode(String(decoratorNode!.properties.handlerSymbolId));
expect(String(handler?.properties.name)).toBe('getWidgets');
});
it('connects a verb-less fetch() consumer to every Route node at the URL', () => {
const consumerFileId = generateId('File', 'web/itemsClient.ts');
// Collect FETCHES targets without a test-level conditional: pass the

View file

@ -0,0 +1,216 @@
/**
* End-to-end coverage of NestJS `@Controller` + `@Get`/`@Post`/`@Delete` route
* ingestion (#3009).
*
* The unit suite (`test/unit/nest-decorator-routes.test.ts`) pins what the
* extractor RETURNS. Nothing there proves the return value becomes anything:
* the reported symptom was not a wrong `ExtractedDecoratorRoute`, it was
* `api_impact` whose documented job is to be run BEFORE modifying a route
* handler reporting every live endpoint as non-existent, because the graph
* held no `Route` nodes at all. That is the tier this file covers: the routes
* phase performing the prefix/path join, `claim()` in call-processor resolving
* `handlerName` to a real symbol UID, and `prefix: null` surviving both.
*
* The fixture lives at `test/fixtures/nest-route-app/`, mirroring
* `spring-route-app/` Spring solves the identical two-decorator shape and
* `nest.ts` is modelled on it.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'nest-route-app');
// Compared against POSIX-normalized graph paths (see `routes()`), so these stay
// forward-slashed rather than going through `path.join` — the Windows shard
// would otherwise compare backslashes against the graph's forward slashes.
const CONTROLLER_FILE = 'src/venues/venues.controller.ts';
const HEALTH_FILE = 'src/health/health.controller.ts';
interface RouteView {
/** `${method} ${url}` — the `routeNodeKey` identity, as a sortable string. */
readonly identity: string;
/** The handler the route resolved to, or the literal `'undefined'`. */
readonly handler: string;
readonly handlerLabel: string;
readonly handlerFile: string;
}
describe('NestJS decorator route ingestion pipeline', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
}, 120_000);
const nodes = (): readonly GraphNode[] => {
const out: GraphNode[] = [];
result.graph.forEachNode((node) => void out.push(node));
return out;
};
const relationships = (): readonly GraphRelationship[] => {
const out: GraphRelationship[] = [];
result.graph.forEachRelationship((rel) => void out.push(rel));
return out;
};
const routeNodes = (): readonly GraphNode[] => nodes().filter((node) => node.label === 'Route');
/**
* Unresolved fields are stringified rather than branched on, so a route that
* lost its handler reads as the literal `'undefined'` in the diff instead of
* quietly skipping an assertion.
*/
const routes = (): readonly RouteView[] =>
routeNodes()
.map((node) => {
const handler = result.graph.getNode(String(node.properties.handlerSymbolId));
return {
identity: `${String(node.properties.method)} ${String(node.properties.name)}`,
handler: String(handler?.properties.name),
handlerLabel: String(handler?.label),
handlerFile: String(handler?.properties.filePath).replaceAll('\\', '/'),
};
})
.sort((a, b) => a.identity.localeCompare(b.identity));
const identities = (): readonly string[] => routes().map((route) => route.identity);
it('emits Route nodes at all — the reported symptom was zero', () => {
// Its own case because every expectation below is satisfiable by an empty
// graph in the ways that matter least: `not.toContain` passes vacuously on
// an empty array, and a full-table `toEqual` against `[]` reports a missing
// element rather than "the extractor never ran".
expect(routeNodes().length).toBeGreaterThan(0);
});
it('mounts a pathless @Get() at the controller prefix WITH its handler attached', () => {
// The reason `nest.ts` emits '/' instead of '' for a pathless decorator:
// `claim()` short-circuits on a falsy `routePath`, so '' would still create
// this Route node and silently drop `handlerSymbolId`. The unit test can
// only see `routePath === '/'` at the extractor boundary; the guard being
// load-bearing is observable here and nowhere else.
expect(routes().find((route) => route.identity === 'GET /venues')).toEqual({
identity: 'GET /venues',
handler: 'findAll',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
});
});
it('joins each @Controller prefix with its method paths and resolves every handler', () => {
// Two invariants, one table, because a projection of this assertion can
// only fail where this one already does. The join is what the extractor
// cannot do alone — it ships `prefix` and the routes phase folds it in via
// `normalizeExtractedRoutePath`, so these read `/venues/search`, not
// `search`. And `claim()` is first-writer-wins per `(method, url)`, so a
// mis-keyed route surfaces as a handler donated to the wrong URL rather
// than as an absence.
expect(routes()).toEqual([
{
identity: 'DELETE /venues/:id',
handler: 'remove',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
},
{
identity: 'GET /health',
handler: 'health',
handlerLabel: 'Method',
handlerFile: HEALTH_FILE,
},
{
identity: 'GET /venues',
handler: 'findAll',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
},
{
identity: 'GET /venues/search',
handler: 'search',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
},
{
identity: 'POST /venues',
handler: 'create',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
},
]);
});
it('splits one URL into one Route node per verb', () => {
// `@Get()` and `@Post()` on the same controller share a URL. `routeNodeKey`
// makes them distinct identities; collapsing them would drop a live
// endpoint and hand its handler to the survivor.
expect(
routes()
.filter((route) => route.identity.endsWith(' /venues'))
.map((route) => `${route.identity} -> ${route.handler}`),
).toEqual(['GET /venues -> findAll', 'POST /venues -> create']);
});
it('carries a prefix-less @Controller() through to a bare URL', () => {
// `@Controller()` maps to `prefix: null`, and null must reach the join as
// "no prefix" rather than as the string 'null' or a leading empty segment.
expect(identities()).toContain('GET /health');
expect(identities().filter((id) => id.includes('//'))).toEqual([]);
});
it('records per-decorator provenance on the HANDLES_ROUTE edge', () => {
// Nest routes are DECLARED by an annotation, so they take the generic
// `decorator-<name>` source rather than a bespoke one — the same channel
// Spring and FastAPI use, which is the point of modelling on `spring.ts`.
const routeIds = new Set(routeNodes().map((node) => node.id));
expect(
[
...new Set(
relationships()
.filter((rel) => rel.type === 'HANDLES_ROUTE' && routeIds.has(rel.targetId))
.map((rel) => String(rel.reason)),
),
].sort(),
).toEqual(['decorator-Delete', 'decorator-Get', 'decorator-Post']);
});
describe('precision — what must NOT become a route', () => {
// Absence assertions are satisfied just as well by a file that was never
// read, so prove ingestion first; otherwise this whole block is decoration.
it('ingested the unreadable-prefix controller', () => {
expect(
nodes()
.filter((node) => String(node.properties.filePath ?? '').endsWith('legacy.controller.ts'))
.map((node) => String(node.properties.name)),
).toEqual(expect.arrayContaining(['LegacyController', 'legacyReports']));
});
it('drops a controller whose prefix is not a literal', () => {
// `@Controller(ROUTE_PREFIXES.legacy)` — the URL is unknowable, and
// `route_map` presents its output as fact. Emitting `/reports` here would
// be a wrong route, which is worse than a missing one.
expect(identities().filter((id) => id.includes('reports'))).toEqual([]);
expect(identities().filter((id) => id.includes('legacy'))).toEqual([]);
});
it('ingested the decorated non-controller service', () => {
expect(
nodes()
.filter((node) => String(node.properties.filePath ?? '').endsWith('venues.service.ts'))
.map((node) => String(node.properties.name)),
).toEqual(expect.arrayContaining(['VenuesService', 'listVenues', 'searchVenues']));
});
it('does not mint routes from a class with no @Controller', () => {
// Verb decorators are believed only inside a `@Controller` class; without
// that gate any library sharing the names would mint phantom endpoints.
expect(routes().filter((route) => route.handlerFile.endsWith('venues.service.ts'))).toEqual(
[],
);
});
});
});

View file

@ -0,0 +1,289 @@
/**
* Parity guard for the two NestJS route layers.
*
* GitNexus reads `@Controller` / `@Get` decorators for two consumers: the
* indexer's `route-extractors/nest.ts` (which mints graph `Route` nodes) and
* the group layer's `http-patterns/node.ts` (which mints cross-repo HTTP
* contracts). They used to be two independent tree-sitter scans, and that is
* the shape #2265 already showed to be a slow leak: there the group query
* matched Spring's array form `@GetMapping({"/a","/b"})` and ingestion's did
* not, so the graph silently under-covered what the contracts claimed. Nest had
* the same divergence pointing the other way and worse the group scan
* INVENTED `/` for any method path it could not read, so `@Get(ROUTES.SEARCH)`
* became a `GET /venues` contract with no Route node behind it. "A missing
* route is a coverage limit; an invented one is a lie" (ARCHITECTURE.md).
*
* The group layer now CALLS `extractNestRoutes` instead of re-querying the
* decorators, so the two cannot disagree by construction. What this file
* guards is that the call stays wired and keeps its `HttpDetection` shape: the
* assertions below pair each group result with the value computed straight from
* `extractNestRoutes` + `normalizeExtractedRoutePath`, and also pin the literal
* expected URLs, so a mutual regression cannot pass by having both sides go
* quiet together.
*/
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import JavaScript from 'tree-sitter-javascript';
import TypeScript from 'tree-sitter-typescript';
import {
JAVASCRIPT_HTTP_PLUGIN,
TYPESCRIPT_HTTP_PLUGIN,
} from '../../../src/core/group/extractors/http-patterns/node.js';
import type { HttpLanguagePlugin } from '../../../src/core/group/extractors/http-patterns/types.js';
import { extractNestRoutes } from '../../../src/core/ingestion/route-extractors/nest.js';
import { normalizeExtractedRoutePath } from '../../../src/core/ingestion/route-extractors/route-path.js';
// Compiled tree-sitter queries are grammar-bound, so a plugin must be driven
// with a tree parsed by ITS grammar.
interface Lang {
readonly parser: Parser;
readonly plugin: HttpLanguagePlugin;
}
function lang(grammar: unknown, plugin: HttpLanguagePlugin): Lang {
const parser = new Parser();
parser.setLanguage(grammar as Parameters<Parser['setLanguage']>[0]);
return { parser, plugin };
}
const TS = lang(TypeScript.typescript, TYPESCRIPT_HTTP_PLUGIN);
const JS = lang(JavaScript, JAVASCRIPT_HTTP_PLUGIN);
/** `METHOD /full/url` pairs the GROUP layer reports as NestJS providers. */
function groupPairs(src: string, target: Lang = TS): string[] {
return target.plugin
.scan(target.parser.parse(src))
.filter((d) => d.role === 'provider' && d.framework === 'nest')
.map((d) => `${d.method} ${d.path}`)
.sort();
}
/**
* The same pairs computed straight from the indexer's extractor, joining the
* prefix the way the routes phase does. This is the reference the group layer
* must equal and, since the group layer now calls the same function, the
* assertion is really "the call is still there and still joins the prefix".
*/
function ingestionPairs(src: string, target: Lang = TS): string[] {
return extractNestRoutes(target.parser.parse(src), 'venues.controller.ts')
.map((r) => `${r.httpMethod} ${normalizeExtractedRoutePath(r.routePath, r.prefix ?? null)}`)
.sort();
}
/** A minimal `@Controller('venues')` wrapping the given class-body members. */
function venuesController(members: string): string {
return `
import { Controller, Get, Post, Put, Patch, Delete, Head, Options, All, Sse } from '@nestjs/common';
@Controller('venues')
export class VenuesController {
${members}
}
`;
}
describe('NestJS route parity — group node.ts delegates to ingestion nest.ts', () => {
const VERB_CASES: ReadonlyArray<readonly [string, string]> = [
['Get', 'GET'],
['Post', 'POST'],
['Put', 'PUT'],
['Patch', 'PATCH'],
['Delete', 'DELETE'],
// The four the group layer's own query never listed: its verb set stopped
// at Patch, so every @Head/@Options/@All/@Sse endpoint was invisible to
// contract matching while sitting in the graph as a Route node.
['Head', 'HEAD'],
['Options', 'OPTIONS'],
['All', '*'],
// @Sse mounts a real streaming GET; '*' is the method-agnostic spelling
// `findMatchingKeys` already understands from Spring's @RequestMapping.
['Sse', 'GET'],
];
it.each(VERB_CASES)('pins the verb @%s → %s', (decorator, method) => {
const src = venuesController(` @${decorator}('slots')\n handler() {}`);
expect(groupPairs(src)).toEqual([`${method} /venues/slots`]);
expect(ingestionPairs(src)).toEqual(groupPairs(src));
});
it('pins an abstract controller — a node type the group query never matched', () => {
// `export abstract class C` parses as `abstract_class_declaration`, a
// DIFFERENT node type from `class_declaration`. A decorated abstract base
// sharing CRUD routes with its subclasses is ordinary Nest, and the old
// group query dropped the WHOLE controller for it, not one route.
const src = `
import { Controller, Get } from '@nestjs/common';
@Controller('venues')
export abstract class BaseVenuesController {
@Get('list')
list() {}
}
`;
expect(groupPairs(src)).toEqual(['GET /venues/list']);
expect(ingestionPairs(src)).toEqual(groupPairs(src));
});
it('pins the object-form @Controller({ path }) — the documented versioning shape', () => {
// The old group query required a positional `(string)`/`(template_string)`
// argument, so `@Controller({ path: 'cats', version: '1' })` — the form the
// Nest docs give for URI/header versioning — suppressed the whole class.
const src = `
import { Controller, Get } from '@nestjs/common';
@Controller({ path: 'venues', version: '1' })
export class VenuesController {
@Get('list')
list() {}
}
`;
expect(groupPairs(src)).toEqual(['GET /venues/list']);
expect(ingestionPairs(src)).toEqual(groupPairs(src));
});
it('pins the argument-less @Controller() — routes mount at the root', () => {
// Legal Nest, and the old group query's mandatory prefix argument made the
// class invisible rather than rooting its methods at '/'.
const src = `
import { Controller, Get } from '@nestjs/common';
@Controller()
export class HealthController {
@Get('health')
health() {}
}
`;
expect(groupPairs(src)).toEqual(['GET /health']);
expect(ingestionPairs(src)).toEqual(groupPairs(src));
});
it('pins a pathless @Get() at the controller prefix with no trailing slash', () => {
// The group layer's local `joinPath('venues', '/')` returned '/venues/';
// the graph stored '/venues'. Both contract-id generation
// (`normalizeHttpPath`) and match-time canonicalization
// (`normalizeContractId`) strip a trailing slash, so this was a latent
// divergence rather than a live mismatch — but it is one fewer way for the
// two layers to describe the same endpoint differently.
const src = venuesController(' @Get()\n index() {}');
expect(groupPairs(src)).toEqual(['GET /venues']);
expect(ingestionPairs(src)).toEqual(groupPairs(src));
});
it('decodes an escaped literal identically in both layers', () => {
// tree-sitter SPLITS a string around each `escape_sequence`. The group
// layer's `unquoteLiteral` (`raw.slice(1, -1)`) left the backslash in place,
// so the ordinary spelling of a Nest regex param came out as a path the app
// never serves. `plainString` decodes it.
const src = venuesController(String.raw` @Get(':id(\\d+)')` + '\n byId() {}');
expect(groupPairs(src)).toEqual([String.raw`GET /venues/:id(\d+)`]);
expect(ingestionPairs(src)).toEqual(groupPairs(src));
});
it('emits NOTHING for an unreadable method path instead of inventing the prefix', () => {
// The precision case. `@Get(ROUTES.SEARCH)` is not readable from this file,
// and the old group scan answered it with a fabricated `GET /venues` — a
// contract that exact-matches any consumer of the controller root and has
// no Route node behind it. The readable sibling proves the controller is
// still SEEN, so this is a dropped route rather than a dropped class.
const src = `
import { Controller, Get } from '@nestjs/common';
import { ROUTES } from './routes.js';
@Controller('venues')
export class VenuesController {
@Get('list')
list() {}
@Get(ROUTES.SEARCH)
search() {}
}
`;
expect(groupPairs(src)).toEqual(['GET /venues/list']);
expect(ingestionPairs(src)).toEqual(groupPairs(src));
});
it('reads a JavaScript Nest controller, whose decorators sit under the method', () => {
// tree-sitter-javascript makes a method decorator a CHILD of the
// `method_definition`; tree-sitter-typescript makes it a preceding SIBLING.
// The group scan only ever walked siblings, so every `.js` Nest controller
// emitted zero contracts.
const src = `
const { Controller, Get } = require('@nestjs/common');
@Controller('venues')
class VenuesController {
@Get('search')
search() {}
}
`;
expect(groupPairs(src, JS)).toEqual(['GET /venues/search']);
expect(ingestionPairs(src, JS)).toEqual(groupPairs(src, JS));
});
it('agrees with the indexer on the full (method, URL) set for one mixed fixture', () => {
// The genuine parity assertion (#2265's lesson): one fixture, both layers,
// set equality — plus the literal expectation, so the two cannot agree by
// both returning nothing.
const src = `
import { Controller, Get, Post, Delete, All, Sse } from '@nestjs/common';
import { ROUTES } from './routes.js';
@Controller({ path: 'venues' })
export abstract class VenuesController {
@Get()
index() {}
@Get(':id')
byId() {}
@Post('/')
create() {}
@Delete(ROUTES.PURGE)
purge() {}
@All('proxy')
proxy() {}
@Sse('events')
events() {}
}
@Controller()
export class RootController {
@Get('healthz')
healthz() {}
}
`;
const expected = [
'* /venues/proxy',
'GET /healthz',
'GET /venues',
'GET /venues/:id',
'GET /venues/events',
'POST /venues',
].sort();
expect(groupPairs(src)).toEqual(expected);
expect(ingestionPairs(src)).toEqual(expected);
});
it('carries the handler name, 1-based line and provider confidence onto the detection', () => {
// The fields the contract extractor resolves a symbol from. `lineNumber` is
// already 1-based at the ingestion layer, so the delegation must NOT add
// one again — `list()` is on line 7 of this source, the same line the
// replaced code reported via `methodNode.startPosition.row + 1`.
const src = venuesController(" @Get('list')\n list() {}");
expect(TS.plugin.scan(TS.parser.parse(src)).filter((d) => d.framework === 'nest')).toEqual([
{
role: 'provider',
framework: 'nest',
method: 'GET',
path: '/venues/list',
name: 'list',
line: 7,
confidence: 0.8,
},
]);
});
});

View file

@ -222,22 +222,32 @@ describe('PARSE_CACHE_VERSION', () => {
// adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so it is
// the next free value after both cache payload changes.
// Moved 70 -> 71 for #2980's Java constant-route capture set (moduleConstants
// + routePathOperands). This branch first argued no bump was needed because
// "the ledger already sits at 70, whose capture set post-dates and includes
// this harvest" — it does not: 70 was cut by fe3d7e56b for #2417/#2891, an
// ancestor of this PR's base. Leaving it made PARSE_CACHE_VERSION byte-
// identical across the merge, so every same-package-version warm cache
// replayed pre-feature captures and the feature was inert. 71 is the next
// free value above every claim at this merge — origin/main is 70 and open
// PR #3017 already claims 71, so 71 would have collided.
it('pins SCHEMA_BUMP to 76 so v74 caches cannot retain pre-#3041 identities', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(76);
// + routePathOperands). 72 -> 74 added import-proven Convex endpoint metadata,
// skipping 73 because open PR #3046 claims it.
// Version 75 adds #3009's NestJS decorator routes to the same JS/TS
// decoratorRoutes channel, so a warm pre-feature cache cannot replay the empty
// route set that change fixes. This branch originally claimed 71; origin/main
// cascaded past it (71 to #2980, 74 to Convex) while the PR was open, so 71
// would now be BELOW main and the reuse gate would never fire. 75 is the next
// free value above origin/main and above every in-flight claim (#3046 at 73,
// #1616 at a stale 2) — the rule, re-applied at merge, not at authoring time.
// Moved 75 -> 76 within this same branch for the NestJS array form, then
// 76 -> 77 because 76 turned out not to be free: origin/main reached 76 via
// #3046 while this branch was in review, and package.json is 1.6.9 on both
// sides, so the cache key was the byte-identical `76+1.6.9` on two branches
// with incompatible worker output. #3046 had skipped 75 precisely because
// this branch held it. Two PRs each doing the bookkeeping correctly still
// collided, because each re-checked once and neither re-checked after the
// other moved — which is why the rule is re-applied AT MERGE, not when the
// number is picked.
it('pins SCHEMA_BUMP to 77 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(77);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
// while being wrong, which is exactly how the 37/38 exact clashes landed.
// Every nearby historical or in-flight value is rejected, including 69,
// which carried the route-table payload before this merge.
for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75]) {
for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}
});

View file

@ -0,0 +1,738 @@
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import JavaScript from 'tree-sitter-javascript';
import { extractNestRoutes } from '../../src/core/ingestion/route-extractors/nest.js';
import { normalizeExtractedRoutePath } from '../../src/core/ingestion/route-extractors/route-path.js';
const tsParser = new Parser();
tsParser.setLanguage(TypeScript.typescript);
// The extractor is registered on the JavaScript provider too, and the two
// grammars place a method decorator differently, so both must be exercised.
const jsParser = new Parser();
jsParser.setLanguage(JavaScript);
const extract = (source: string) =>
extractNestRoutes(tsParser.parse(source), 'src/x.controller.ts');
const extractJs = (source: string) =>
extractNestRoutes(jsParser.parse(source), 'src/x.controller.js');
/** What the routes phase will key the Route node by: verb + joined path. */
const format = (routes: ReturnType<typeof extract>) =>
routes.map(
(r) => `${r.httpMethod} ${normalizeExtractedRoutePath(r.routePath, r.prefix ?? null)}`,
);
const urls = (source: string) => format(extract(source));
const jsUrls = (source: string) => format(extractJs(source));
describe('NestJS decorator routes', () => {
it('joins the controller prefix with each method path', () => {
expect(
urls(`
@Controller('venues')
export class VenueController {
@Get()
findAll() {}
@Get('search')
search() {}
@Post(':id/follow')
follow(@Param('id') id: string) {}
@Delete(':id')
remove() {}
}
`),
).toEqual([
'GET /venues',
'GET /venues/search',
'POST /venues/:id/follow',
'DELETE /venues/:id',
]);
});
it("emits '/' rather than '' for a pathless @Get, so the handler still resolves", () => {
// call-processor's claim() short-circuits on a falsy routePath, so ''
// would create the Route node but silently lose its handler symbol.
// Both spellings normalize to the same URL.
const [route] = extract(`
@Controller('venues')
export class VenueController {
@Get()
findAll() {}
}
`);
expect(route.routePath).toBe('/');
expect(normalizeExtractedRoutePath(route.routePath, route.prefix ?? null)).toBe('/venues');
});
it('handles a prefixless @Controller()', () => {
expect(
urls(`
@Controller()
export class AppController {
@Get('health')
health() {}
}
`),
).toEqual(['GET /health']);
});
it('captures the handler method name for symbol resolution', () => {
const routes = extract(`
@Controller('users')
export class UserController {
@Patch(':id')
updateOne() {}
}
`);
expect(routes).toHaveLength(1);
expect(routes[0]).toMatchObject({
httpMethod: 'PATCH',
routePath: ':id',
prefix: 'users',
decoratorName: 'Patch',
handlerName: 'updateOne',
filePath: 'src/x.controller.ts',
});
});
it('supports a non-exported controller and all verbs', () => {
expect(
urls(`
@Controller('a')
class A {
@Put('p') p() {}
@Head('h') h() {}
@Options('o') o() {}
@All('any') any() {}
}
`),
).toEqual(['PUT /a/p', 'HEAD /a/h', 'OPTIONS /a/o', '* /a/any']);
});
it('applies each controller its own prefix when a file declares several', () => {
expect(
urls(`
@Controller('one')
export class One { @Get('x') x() {} }
@Controller('two')
export class Two { @Get('y') y() {} }
`),
).toEqual(['GET /one/x', 'GET /two/y']);
});
it('carries stacked decorators through to the route', () => {
expect(
urls(`
@Controller('secure')
export class SecureController {
@UseGuards(AuthGuard)
@Get('me')
me() {}
}
`),
).toEqual(['GET /secure/me']);
});
it('sees through a comment between the decorators and the method', () => {
// Found on a real controller: four stacked decorators, then a JSDoc block,
// then the method. Breaking the backward walk at the comment made the
// entire route invisible.
expect(
urls(`
@Controller('dev')
export class DevController {
@Post('simulate-expiry')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'x' })
/**
* Simulate an expiry.
*/
simulateExpiry() {}
}
`),
).toEqual(['POST /dev/simulate-expiry']);
});
it('sees through a comment between @Controller and the class', () => {
expect(
urls(`
@Controller('docs')
/** The controller. */
export class DocsController {
@Get('x') x() {}
}
`),
).toEqual(['GET /docs/x']);
});
// ─── Precision guards ──────────────────────────────────────────────
it('ignores verb-named decorators on a class that is not a @Controller', () => {
// `Get`/`Post` are ordinary identifiers; without the @Controller
// requirement any library reusing those names mints phantom endpoints.
// The unrelated controller is what makes this test reach the per-class
// check: without a `@Controller` anywhere the file short-circuits at the
// parse-free substring gate and the assertion proves nothing.
expect(
urls(`
@Controller('y')
export class RealController {
@Get('real')
real() {}
}
@Injectable()
export class NotAController {
@Get('looks-like-a-route')
nope() {}
}
`),
).toEqual(['GET /y/real']);
});
it.each([
{ label: 'a constant it cannot read', argument: 'ROUTES.SEARCH' },
{ label: 'an interpolated template', argument: '`${prefix}/search`' },
{ label: 'an array with one element it cannot read', argument: "['a', ROUTES.ADMIN]" },
])('drops a route whose path is $label', ({ argument }) => {
// A wrong URL is worse than a missing one — route_map presents this as fact.
// The array row is why one bad element poisons the whole array rather than
// emitting its readable siblings: a half-mapped controller reads as a fully
// mapped one, which is the same lie with less to notice.
expect(
extract(`
@Controller('x')
export class C {
@Get(${argument})
search() {}
}
`),
).toEqual([]);
});
it('drops every route of a controller whose prefix cannot be read', () => {
expect(
extract(`
@Controller(BASE_PATH)
export class C {
@Get('search')
search() {}
}
`),
).toEqual([]);
});
it('returns nothing for a file with no @Controller at all', () => {
expect(extract(`export function get() { return 1; }`)).toEqual([]);
});
// ─── Literal decoding ──────────────────────────────────────────────
it('decodes an escape in a path instead of deleting it', () => {
// The source below spells the Nest regex param the way a controller does,
// `@Get(':id(\\d+)')`, whose runtime value is `:id(\d+)`. tree-sitter SPLITS
// that literal around the escape_sequence, so keeping only the
// string_fragment children and joining them yielded `:id(d+)` — a URL the
// app never serves, i.e. the wrong-path outcome this module calls worse
// than a missing one.
const [route] = extract(`
@Controller('users')
export class UserController {
@Get(':id(\\\\d+)')
one() {}
}
`);
expect(route.routePath).toBe(':id(\\d+)');
});
it('decodes a unicode escape rather than dropping its payload', () => {
expect(
urls(`
@Controller('v')
export class C {
@Get('/v\\u0069ews')
views() {}
}
`),
).toEqual(['GET /v/views']);
});
it("treats an empty @Controller('') as carrying no prefix", () => {
const [route] = extract(`
@Controller('')
export class C {
@Get('a') a() {}
}
`);
expect(route.prefix).toBeNull();
expect(normalizeExtractedRoutePath(route.routePath, route.prefix ?? null)).toBe('/a');
});
// ─── Multi-path (array form) ───────────────────────────────────────
it('emits one route per path for the array form', () => {
// `@Get(['a','b'])` mounts the handler at BOTH URLs, so both are routes.
// N paths needs no new field to say so: N routes is what an
// ExtractedDecoratorRoute[] already is, the same representation spring.ts
// uses for `@GetMapping({"/a","/b"})`.
const routes = extract(`
@Controller('x')
export class C {
@Get(['a', 'b'])
search() {}
}
`);
expect(format(routes)).toEqual(['GET /x/a', 'GET /x/b']);
// Everything other than the path is the same route twice — in particular
// the handler, or only one of the two URLs would resolve to a symbol.
expect(routes.map((r) => r.handlerName)).toEqual(['search', 'search']);
});
it('reads a single-element array as that one path', () => {
expect(
urls(`
@Controller(['a'])
export class C {
@Get(['b']) b() {}
}
`),
).toEqual(['GET /a/b']);
});
it('emits nothing for an empty array path, and drops only the route it skips', () => {
// `@Get([])` is legal and mounts no URL. It is neither a pathless `@Get()`
// nor an unreadable path: reading it as the first would mint `GET /x`, a
// URL the app does not serve.
expect(
extract(`
@Controller('x')
export class C {
@Get([]) none() {}
}
`),
).toEqual([]);
// Whichever the reason a path yields no route — knowably empty, or
// unreadable — it costs exactly its own route and not the controller's
// others, which is what makes a per-decorator skip safe.
expect(
urls(`
@Controller('x')
export class C {
@Get([]) none() {}
@Get(ROUTES.ADMIN) admin() {}
@Get('a') a() {}
}
`),
).toEqual(['GET /x/a']);
});
it('decodes escapes inside array elements too', () => {
// Each element goes through the same `plainString` a scalar path does, so
// the split-around-escape_sequence trap cannot come back on this arm alone.
expect(
extract(`
@Controller('u')
export class C {
@Get([':id(\\\\d+)', '/v\\u0069ews'])
one() {}
}
`).map((r) => r.routePath),
).toEqual([':id(\\d+)', '/views']);
});
// ─── Controller shapes ─────────────────────────────────────────────
it('extracts routes from an abstract controller base class', () => {
// `export abstract class` parses as abstract_class_declaration, a separate
// node type — and a decorated abstract base sharing CRUD routes with its
// subclasses is ordinary Nest, so missing it drops the whole controller.
expect(
urls(`
@Controller('base')
export abstract class BaseController {
@Get('a') a() {}
}
`),
).toEqual(['GET /base/a']);
});
it('reads the path out of the object form used for URI versioning', () => {
expect(
urls(`
@Controller({ path: 'cats', version: '1' })
export class CatsController {
@Get('breeds') breeds() {}
}
`),
).toEqual(['GET /cats/breeds']);
});
it('reads a quoted path key, rather than dropping the class over the quotes', () => {
expect(
urls(`
@Controller({ 'path': 'cats' })
export class CatsController {
@Get('breeds') breeds() {}
}
`),
).toEqual(['GET /cats/breeds']);
});
it.each([
{ label: 'no path key', argument: "{ version: '1' }" },
{ label: 'a computed path', argument: '{ path: BASE_PATH }' },
{ label: 'a computed path key', argument: "{ [PATH_KEY]: 'cats' }" },
])('still drops a controller whose object form has $label', ({ argument }) => {
expect(
extract(`
@Controller(${argument})
export class C {
@Get('a') a() {}
}
`),
).toEqual([]);
});
// A `path` pair proves the mount point only when nothing ELSE in the object
// can replace it. `{ path: 'cats', ...options }` reads as `cats` under a
// first-match scan and mounts wherever `options.path` says at runtime;
// `{ path: 'cats', path: 'dogs' }` mounts at `dogs`. Both are the wrong-URL
// outcome this module calls worse than a missing one, and both are silent —
// a published `/cats` looks exactly like a correct one. So the object is read
// only when every member is a named, non-repeated pair: the whole-entry
// fail-closed shape `routeFromObject` uses in data-route-table.ts.
it.each([
// `options.path` overrides the pair above it, so the extracted prefix and
// the served prefix disagree with nothing in the file to say so.
{ label: 'a trailing spread', argument: "{ path: 'cats', ...options }" },
// Deterministically SAFE under JS evaluation order — a later `path` pair
// always wins over an earlier spread — and refused anyway. Reading member
// order as proof makes the verdict turn on which side of the spread the
// author happened to type `path`, and how often each spelling occurs in
// real controllers is unmeasured. Meanwhile the two failure directions are
// not symmetric: reading it wrong publishes a URL the app never serves, and
// `route_map`/`api_impact` present that as fact, while refusing omits a
// route that is still findable in source.
{ label: 'a leading spread', argument: "{ ...options, path: 'cats' }" },
{ label: 'nothing but a spread', argument: '{ ...options }' },
// Last write wins at runtime, so a first-match scan names the loser.
{ label: 'a repeated path key', argument: "{ path: 'cats', path: 'dogs' }" },
// Visible only through `propertyName`: compared as raw key text, `path` and
// `'path'` are two different keys and the duplicate check never fires.
{
label: 'a repeated path key in its quoted spelling',
argument: "{ path: 'cats', 'path': 'dogs' }",
},
// Refusal is on ANY repeated key, not only `path` — a duplicate anywhere is
// evidence the object is not the fixed literal it reads as.
{
label: 'a repeated key other than path',
argument: "{ path: 'a', version: '1', version: '2' }",
},
// A computed key could evaluate to `path` and take the mount with it.
{ label: 'a computed key beside the path', argument: "{ path: 'a', [dynamicKey]: 'b' }" },
// `shorthand_property_identifier` and `method_definition`; neither is a
// `pair`, so neither offers a key/value this file can read.
{ label: 'a shorthand property', argument: '{ path }' },
{ label: 'a method', argument: "{ path: 'a', getFoo() {} }" },
])('refuses an object form whose path another member could override: $label', ({ argument }) => {
expect(
extract(`
@Controller(${argument})
export class C {
@Get('b') b() {}
}
`),
).toEqual([]);
});
it.each([
// A comment between two pairs is ordinary formatting. It has to be skipped
// BEFORE the not-a-pair test above, or the refusal fires on it and costs
// the controller every route it has.
{
label: 'a comment between its pairs',
argument: "{ path: 'a', /* URI versioning */ version: '1' }",
},
// Only `path` has to be provable. A non-literal value on an unrelated key
// is benign: `containsExecutingExpression` in data-route-table.ts refuses
// these, but it guards whole-entry declarativeness for a static route
// table — a different invariant from "can this member move the mount".
{
label: 'non-literal values on keys other than path',
argument: "{ path: 'a', host: 'x', scope: Scope.REQUEST, durable: true }",
},
])('still reads the prefix out of an object form with $label', ({ argument }) => {
expect(
urls(`
@Controller(${argument})
export class C {
@Get('b') b() {}
}
`),
).toEqual(['GET /a/b']);
});
it.each([
{ label: 'a single path', argument: "{ path: 'a' }" },
{ label: 'an array of paths', argument: "{ path: ['a', 'b'] }" },
// The verb gate short-circuits on the object form before the object is
// walked at all, so the class-form refusal never gets a say here.
{ label: 'an object the class form would also refuse', argument: "{ path: 'a', ...options }" },
])('mints nothing from the object form on a VERB decorator ($label)', ({ argument }) => {
// `@Controller` takes the object form; `@Get` and friends take
// `string | string[]`. Nest mounts nothing here, so emitting a route would
// invent a URL — the failure this module exists to avoid, and the reason
// the class prefix below is deliberately readable: the route is dropped
// because the METHOD path is unreadable, not because the class was.
expect(
extract(`
@Controller('x')
export class C {
@Get(${argument}) a() {}
}
`),
).toEqual([]);
});
it.each([
{ label: 'the bare array form', argument: "['a', 'b']" },
{ label: 'an array inside the object form', argument: "{ path: ['a', 'b'] }" },
])('declines a controller whose prefix is multi-path: $label', ({ argument }) => {
// Deliberate parity with spring.ts, which detects an array-form class
// @RequestMapping only to SUPPRESS that class, leaving the prefix x method
// cross-product to #2280. The method path here is perfectly readable, so
// the alternative is not "drop one route" but "publish it under one of the
// two prefixes, or none" — URLs the application does not serve.
expect(
extract(`
@Controller(${argument})
export class C {
@Get('a') a() {}
}
`),
).toEqual([]);
});
it('extracts from a .js controller, where a decorator is a CHILD of the method', () => {
// tree-sitter-javascript nests a method decorator inside method_definition
// rather than placing it before as a sibling. The same extractor serves the
// JavaScript provider, so reading siblings only meant every .js Nest
// controller emitted nothing while the wiring claimed nestjs coverage.
expect(
jsUrls(`
@Controller('venues')
export class VenueController {
@UseGuards(AuthGuard)
@Get('search')
search() {}
}
`),
).toEqual(['GET /venues/search']);
});
// ─── Verb coverage ─────────────────────────────────────────────────
it('treats @Sse as the GET endpoint it mounts', () => {
expect(
urls(`
@Controller('events')
export class EventsController {
@Sse('stream') stream() {}
}
`),
).toEqual(['GET /events/stream']);
});
it('emits nothing for a decorator that mounts no endpoint', () => {
// Paired with the @Sse case above: without it, an unsupported route
// decorator and a non-route decorator are the same silent [].
expect(
extract(`
@Controller('events')
export class EventsController {
@UseGuards(AuthGuard) guarded() {}
}
`),
).toEqual([]);
});
it.each(['toString', 'constructor'])(
'does not mint a route for a decorator named @%s',
(name) => {
// The verb table is looked up by decorator name, so a plain object would
// answer `Object.prototype.toString` here — truthy, and emitted verbatim
// as the route's httpMethod.
expect(
extract(`
@Controller('x')
export class C {
@${name}() f() {}
}
`),
).toEqual([]);
},
);
it("does not carry a decorated property's decorators onto the next method", () => {
// The decorator run is accumulated in one forward pass over the class body;
// a non-method member must reset it, the way the backward walk used to stop.
expect(
urls(`
@Controller('di')
export class C {
@Inject(SERVICE)
private readonly svc: Service;
@Get('a') a() {}
}
`),
).toEqual(['GET /di/a']);
});
// A Nest route decorator is a FACTORY: `@Get()` invokes it and returns the
// decorator that registers the route. A bare `@Get` is the factory itself,
// never applied to anything, so Nest registers nothing — emitting a route for
// it publishes a URL the app does not serve. `@Get()` with no argument IS a
// real pathless route and must keep working; the difference is the call, not
// the argument list.
it.each([
{ label: 'a bare verb decorator', member: '@Get a() {}' },
{
label: 'a bare verb decorator beside a real one',
member: "@Get a() {}\n @Post('b') b() {}",
},
])('mints no route for $label', ({ member }) => {
expect(
urls(`
@Controller('x')
export class C {
${member}
}
`).filter((route) => route.startsWith('GET')),
).toEqual([]);
});
it('drops a class whose @Controller is bare rather than invoked', () => {
// Same rule one level up: an uninvoked `@Controller` registers no
// controller, so its methods are not routes either.
expect(
extract(`
@Controller
export class C {
@Get('a') a() {}
}
`),
).toEqual([]);
});
it('still emits a pathless route for an INVOKED decorator with no argument', () => {
// The control: `@Get()` differs from `@Get` by the call, and only the call.
expect(
urls(`
@Controller('x')
export class C {
@Get() a() {}
}
`),
).toEqual(['GET /x']);
});
// ─── Members Nest never registers as handlers ──────────────────────
/** Routes a controller emits when `member` is its only member. */
const memberUrls = (member: string) =>
urls(`
@Controller('v')
export class C {
${member}
}
`);
/** A non-handler followed by a real one — the shape both arms below need. */
const STATIC_THEN_INSTANCE = `
@Controller('v')
export class C {
@Get('s')
static s() {}
@Get('i')
i() {}
}
`;
// Nest's `RequestMapping` writes the handler onto the class PROTOTYPE's
// `descriptor.value`, and `RouterExplorer` scans prototype instance methods
// for that metadata. A `static` method lives on the constructor and is never
// scanned; an accessor's descriptor carries `get`/`set` and no `value` to
// register. A verb decorator on any of the three mounts NOTHING, so a route
// minted from one is a URL the app does not serve — the same
// wrong-answer-dressed-as-fact the object-form refusals above exist for.
it.each([
{ label: 'a static method', member: "@Get('s') static s() {}" },
{ label: 'a getter', member: "@Get('s') get s(): string { return ''; }" },
{ label: 'a setter', member: "@Get('s') set s(v: string) {}" },
])('mints nothing for $label, which Nest never registers as a handler', ({ member }) => {
expect(memberUrls(member)).toEqual([]);
});
// The other half of the modifier check, and the half a mutation can actually
// reach: these modifiers must NOT reject. `async` matters most — it is the
// dominant shape of a real Nest handler, so widening the exclusion set to
// include it would silently delete most routes in most Nest repos while the
// table above stayed green. `override` and an accessibility modifier are the
// other tokens that sit in the same position on a `method_definition`, and a
// method merely NAMED `get`/`set`/`static` is a property_identifier, not a
// modifier — it must survive too.
it.each([
{ label: 'an async method', member: "@Get('s') async s() {}" },
{ label: 'a public method', member: "@Get('s') public s() {}" },
{ label: 'a protected method', member: "@Get('s') protected s() {}" },
{
label: 'an async method with an accessibility modifier',
member: "@Get('s') public async s() {}",
},
{ label: 'a method named get', member: "@Get('s') get() {}" },
{ label: 'a method named static', member: "@Get('s') static() {}" },
// The control for the mints-nothing table above: identical source minus the
// modifier, which is what makes those three empty results evidence of the
// check rather than of a fixture that happens to parse to nothing.
{ label: 'a plain instance method', member: "@Get('s') s() {}" },
])('still emits the route for $label', ({ member }) => {
expect(memberUrls(member)).toEqual(['GET /v/s']);
});
it('drops a decorated static method under the JavaScript grammar too', () => {
// tree-sitter-javascript makes a method decorator a CHILD of
// `method_definition`, so `children` reads `decorator | static |
// property_identifier | …` and the modifier is NOT at a fixed index — the
// check has to test every child's type. The instance method beside it is
// the in-fixture control: its route proves this .js arm still measures
// something rather than passing on a fixture that parses to nothing.
expect(jsUrls(STATIC_THEN_INSTANCE)).toEqual(['GET /v/i']);
});
it("does not donate a non-handler member's decorator run to the method after it", () => {
// Pins the UNCONDITIONAL `pending.length = 0` at the end of the member
// loop, NOT the modifier check: a non-handler must fall through to that
// clear rather than `continue` past it. Deliberately green before the
// modifier check existed too — there the static member consumed the run
// into its own (wrong) route and then cleared it — so this goes red only
// if a future edit adds the early `continue`. Asserted over the routes
// attributed to `i`, because the whole-output form would instead be
// measuring the modifier check the table above already covers.
const routes = extract(STATIC_THEN_INSTANCE);
expect(format(routes.filter((route) => route.handlerName === 'i'))).toEqual(['GET /v/i']);
});
});