mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge branch 'main' into dependabot/github_actions/codeql-action-29ab28765e
This commit is contained in:
commit
6517c00e0f
21 changed files with 2189 additions and 227 deletions
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/trivy.yml
vendored
2
.github/workflows/trivy.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
45
gitnexus-web/package-lock.json
generated
45
gitnexus-web/package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
548
gitnexus/src/core/ingestion/route-extractors/nest.ts
Normal file
548
gitnexus/src/core/ingestion/route-extractors/nest.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
26
gitnexus/test/fixtures/multi-verb-route-app/api/widgetsController.ts
vendored
Normal file
26
gitnexus/test/fixtures/multi-verb-route-app/api/widgetsController.ts
vendored
Normal 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 '[]';
|
||||
}
|
||||
}
|
||||
14
gitnexus/test/fixtures/nest-route-app/src/health/health.controller.ts
vendored
Normal file
14
gitnexus/test/fixtures/nest-route-app/src/health/health.controller.ts
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
16
gitnexus/test/fixtures/nest-route-app/src/legacy/legacy.controller.ts
vendored
Normal file
16
gitnexus/test/fixtures/nest-route-app/src/legacy/legacy.controller.ts
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
35
gitnexus/test/fixtures/nest-route-app/src/venues/venues.controller.ts
vendored
Normal file
35
gitnexus/test/fixtures/nest-route-app/src/venues/venues.controller.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
34
gitnexus/test/fixtures/nest-route-app/src/venues/venues.service.ts
vendored
Normal file
34
gitnexus/test/fixtures/nest-route-app/src/venues/venues.service.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
216
gitnexus/test/integration/nest-route-pipeline.test.ts
Normal file
216
gitnexus/test/integration/nest-route-pipeline.test.ts
Normal 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(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
289
gitnexus/test/unit/group/nest-route-parity.test.ts
Normal file
289
gitnexus/test/unit/group/nest-route-parity.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
738
gitnexus/test/unit/nest-decorator-routes.test.ts
Normal file
738
gitnexus/test/unit/nest-decorator-routes.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue