fix(group+ingestion): support NestJS multi-path routes, and make the group layer read them through the indexer's extractor

Three related changes, closing the last two review findings and the divergence
they exposed.

1. Multi-path (array-form) decorators
------------------------------------

`@Get(['a','b'])` mounts one handler at two URLs; the extractor dropped it. The
recorded objection was that supporting it forces `decoratorLiteralArg` from
`string | null` to an array type — but that is a PRIVATE HELPER's type, not the
layer's contract. The contract is `ExtractedDecoratorRoute[]`, and N paths is
already representable as N entries. That is exactly how `spring.ts` models the
analogous `@GetMapping({"/a","/b"})` (#2281): one route per path, no special
case at the emit site.

So `decoratorLiteralArg` becomes `decoratorLiteralPaths` returning
`readonly string[] | null`, and `collectClassRoutes` loops the push.

Three answers, all distinct and all load-bearing:
  ['']  no argument — one path, no segment of its own (the old '')
  []    `@Get([])` — legal, knowably mounts nothing, so emits nothing
  null  unreadable — the only one that may suppress a whole controller

An array with ANY unreadable element returns `null` rather than emitting the
readable ones: a partial emit presents an incomplete route set as complete,
which is the same wrong-fact class as a wrong URL.

The CLASS array form (`@Controller(['a','b'])`) still suppresses the class.
That is deliberate parity with `spring.ts`, which detects an array-form class
`@RequestMapping` only to suppress it, leaving the prefix x method cross-product
to #2280. The method path in that shape is perfectly readable, so the choice is
not "drop one route" but "publish it under one of the two prefixes, or none".

Also fixed while there: the object form is now `@Controller`'s alone. Verb
decorators take `string | string[]`, so Nest mounts nothing for
`@Get({ path: 'a' })` and reading it as a route invented a URL the app never
serves. That generosity arrived with the object-form fix earlier in this branch;
it is gated now.

2. The group layer calls the extractor instead of mirroring it
--------------------------------------------------------------

`group/extractors/http-patterns/node.ts` had its own NestJS implementation, and
the two had drifted in both directions. The group side read `class_declaration`
only, only Get/Post/Put/Delete/Patch, required a string `@Controller` argument,
did not decode escapes — and, worst, INVENTED `/` when a method argument was not
a readable string, so `@Get(ROUTES.SEARCH)` published `GET /venues` at the group
layer while the indexer correctly published nothing. ARCHITECTURE.md: "A missing
route is a coverage limit; an invented one is a lie."

Rather than share constants and keep nine behaviours in sync by hand, the group
scan now calls `extractNestRoutes` over the same tree and maps the result into
`HttpDetection` — the shape this file already uses one function over for
`scanDataRouteTables`. Divergence becomes structurally impossible instead of
test-enforced.

Net -140 lines in `node.ts`. `NEST_CONTROLLER_SPEC`, `NEST_METHOD_SPEC`,
`NEST_DECORATOR_TO_HTTP`, `findDecoratedClass`, `findDecoratedMethod` and the
file-local `findEnclosingClass`/`joinPath` all became dead and are gone; each
was confirmed to have no other caller. The exported `findEnclosingClass` and
`joinPath` in `spring-shared.ts` — which `java.ts` and `kotlin.ts` import — are
untouched and are NOT the same functions.

The join now uses ingestion's `normalizeExtractedRoutePath`, so a pathless
`@Get()` under `@Controller('venues')` is `/venues` on both sides rather than
`/venues/` on one. That difference never reached a comparison —
`normalizeHttpPath` and `normalizeContractId` both strip trailing slashes — so
this is correctness of representation, not a behaviour fix.

`@All` maps to method `'*'`, which `findMatchingKeys` already handles on the
provider side: a specific consumer matches a method-agnostic provider on the
same path, the case its comment describes for Django views.

3. A parity test, which is what the repo asked for
---------------------------------------------------

`test/unit/group/nest-route-parity.test.ts`, modelled on the two Spring parity
tests and echoing their reasoning — the #2265 array-form gap was caused by
exactly this kind of drift, and the guard was written then. Each case pairs the
group result against `extractNestRoutes` + `normalizeExtractedRoutePath` AND
pins the literal URLs, so both sides going quiet together cannot pass.

Covers all nine verbs (including Head/Options/All/Sse, previously unreachable at
the group layer), abstract controllers, the object form, a prefix-less
`@Controller()`, escape decoding, a `.js` controller, the full (method, URL) set
on a mixed fixture, and the precision case: `@Get(ROUTES.SEARCH)` next to a
readable sibling emits only the sibling — proving the controller is still seen,
so it is a dropped route rather than a dropped class.

Against the previous `node.ts`, 12 of its 18 cases fail.

Verification
------------

- tsc clean; eslint and prettier clean on all four files.
- `test/unit/group/` + the nest suites + the pipeline test: 55 files, 1098 tests,
  all passing. Route-related sweep: 34 files, 886 passing, 1 skipped.
- Array form differential-checked against the previous extractor over 2655
  sources / 7965 comparisons across the typescript, tsx and javascript grammars:
  15 differences, every one an array-form case gaining routes. Nothing else moved.
- Every new test mutation-checked red.
- Unlooked-for: the group scan got much faster, because the deleted
  `findDecoratedClass`/`findDecoratedMethod` indexed `parent.namedChild(i)` per
  decorator — the same uncached-getter quadratic `nest.ts` documents. An
  800-method controller went 666ms -> 30.6ms, and a 2000-function non-Nest file
  104ms -> 89ms (two fewer compiled-query passes outweigh the added text gate).
This commit is contained in:
Gergo Magyar 2026-08-26 13:59:23 +00:00
parent 19c0525195
commit f78eb23bed
4 changed files with 541 additions and 207 deletions

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

@ -21,6 +21,15 @@
* 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
@ -92,17 +101,26 @@ function decoratorName(decorator: Parser.SyntaxNode): string | null {
}
/**
* The literal string first argument of a decorator call, or `''` when the
* decorator takes no argument (`@Controller()` / `@Get()` — both legal and both
* meaning "no path segment of my own").
* 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").
*
* Returns `null` when an argument IS present but is not a plain literal. That
* is deliberately distinct from `''`: a computed prefix
* 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.
* 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 the literal is delegated to `plainString`, the same judge the
* 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
@ -111,36 +129,68 @@ function decoratorName(decorator: Parser.SyntaxNode): string | null {
* `: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 decoratorLiteralArg(decorator: Parser.SyntaxNode): string | null {
function decoratorLiteralPaths(decorator: Parser.SyntaxNode): readonly string[] | null {
const call = decorator.namedChild(0);
// A bare `@Injectable` with no call, or `@Get()` with no argument — legal,
// and both mean "no path segment of my own".
if (call?.type !== 'call_expression') return '';
if (call?.type !== 'call_expression') return [''];
const first = call.childForFieldName('arguments')?.namedChild(0);
if (!first) return '';
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 (first.type === 'object') {
if (node.type === 'object') {
// `propertyName` reads both spellings that carry a name — `{ path: … }` and
// `{ 'path': … }` — so the class is not dropped over a pair of quotes. A
// computed key (`{ [KEY]: … }`) has none, and keeps the drop, as does a
// computed value.
const path = first.namedChildren.find((child) => {
const path = node.namedChildren.find((child) => {
const key = child.type === 'pair' ? child.childForFieldName('key') : null;
return key !== null && propertyName(key) === 'path';
});
const value = path?.childForFieldName('value');
return value ? plainString(value) : null;
return value ? literalPaths(value) : null;
}
// An array form (`@Get(['a', 'b'])`), an identifier, a member expression, a
// call — none of them is a readable literal, and `plainString` answers `null`
// for every one of them. Skip rather than guess.
return plainString(first);
// `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];
}
/**
@ -205,11 +255,25 @@ function classDecorators(classNode: Parser.SyntaxNode): Parser.SyntaxNode[] {
return out;
}
/** The `@Controller(...)` prefix for a class, or undefined when it has none. */
/**
* 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): string | null | undefined {
for (const decorator of classDecorators(classNode)) {
if (decoratorName(decorator) !== 'Controller') continue;
return decoratorLiteralArg(decorator);
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) return null;
return paths[0];
}
return undefined;
}
@ -298,26 +362,32 @@ function collectClassRoutes(
const httpMethod = NEST_METHOD_DECORATORS.get(name);
if (httpMethod === undefined) continue;
const routePath = decoratorLiteralArg(decorator);
if (routePath === null) continue; // unreadable → skip
const routePaths = decoratorLiteralPaths(decorator);
if (routePaths === null) continue; // unreadable → skip
const handlerName = member.childForFieldName('name')?.text;
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 }),
});
// 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

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

@ -201,9 +201,12 @@ describe('NestJS decorator routes', () => {
it.each([
{ label: 'a constant it cannot read', argument: 'ROUTES.SEARCH' },
{ label: 'an interpolated template', argument: '`${prefix}/search`' },
{ label: 'an array form it cannot reduce to one URL', argument: "['a', 'b']" },
{ 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')
@ -273,6 +276,80 @@ describe('NestJS decorator routes', () => {
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', () => {
@ -326,6 +403,44 @@ describe('NestJS decorator routes', () => {
).toEqual([]);
});
it.each([
{ label: 'a single path', argument: "{ path: 'a' }" },
{ label: 'an array of paths', argument: "{ path: ['a', 'b'] }" },
])('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