From 60021f9ab8dad1c853c1a5b9ca34fb8a4d84d225 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 26 Aug 2026 17:46:38 +0000 Subject: [PATCH] fix(ingestion): refuse a @Controller options object whose path is not provable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `literalPaths` took the first `pair` named `path` and ignored everything after it, so a member that overrides `path` at runtime produced a URL the app never serves: @Controller({ path: 'cats', ...options }) -> /cats, but options.path wins @Controller({ path: 'cats', path: 'dogs' }) -> /cats, but dogs wins Both are the wrong-fact class this module's header forbids, and both cost every route on the class rather than one route. The object branch is now a fail-closed walk, mirroring `routeFromObject` in data-route-table.ts: skip comments first, then refuse on any non-`pair` child (spread, computed key, shorthand, method), any unreadable key, and any repeated key name. Key names compare through `propertyName`, so `path` and `'path'` collide as duplicates rather than reading as two different keys. Refusing on a spread positioned BEFORE the literal is deliberate even though JS evaluation order makes that shape provably safe. The rule is not cost — the walk already runs in source order, so position tracking would be one boolean — and it is not blanket parity with the sibling extractor. It is that the shape's real frequency is unmeasured (this repo contains no NestJS application; all four `@Controller({` occurrences are its own test fixtures) and the failure directions are asymmetric: reading it wrong publishes a URL that does not exist, refusing it omits a route still findable in source. If the new log line shows the shape in real repos, the position-sensitive rule is the ready upgrade. `containsExecutingExpression` is deliberately not ported from that sibling. It guards whole-entry declarativeness for a static route table; here only `path` needs to be readable, so a computed value on an unrelated key such as `scope: Scope.REQUEST` stays benign and is pinned as such. Because a refusal silently costs a whole controller, it now logs under `isDev` and names the file plus the offending shape. Emitted at `info`, not `debug`: the logger's base level is already `info`, so an isDev-gated `debug` would be gated twice and stay silent in exactly the run it exists for. `filePath` is threaded from `extractNestRoutes` down to the object branch to make that line useful; without it the message names a shape but not where to find it. 7 of the new cases were red on base for the right reason — each published a prefix the application never mounts. --- .../core/ingestion/route-extractors/nest.ts | 115 +++++++++++++++--- .../test/unit/nest-decorator-routes.test.ts | 83 +++++++++++++ 2 files changed, 181 insertions(+), 17 deletions(-) diff --git a/gitnexus/src/core/ingestion/route-extractors/nest.ts b/gitnexus/src/core/ingestion/route-extractors/nest.ts index 1220b5a66..1f052bb25 100644 --- a/gitnexus/src/core/ingestion/route-extractors/nest.ts +++ b/gitnexus/src/core/ingestion/route-extractors/nest.ts @@ -44,6 +44,8 @@ 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 @@ -129,7 +131,10 @@ function decoratorName(decorator: Parser.SyntaxNode): string | null { * `:id(d+)`, and `@Get('/v\u0069ews')` came out as `/vews`. Both are paths the * app never serves, i.e. the wrong-URL outcome the paragraph above forbids. */ -function decoratorLiteralPaths(decorator: Parser.SyntaxNode): readonly string[] | null { +function decoratorLiteralPaths( + decorator: Parser.SyntaxNode, + filePath: string, +): readonly string[] | null { const call = decorator.namedChild(0); // A bare `@Injectable` with no call, or `@Get()` with no argument — legal, // and both mean "no path segment of my own". @@ -141,7 +146,42 @@ function decoratorLiteralPaths(decorator: Parser.SyntaxNode): readonly string[] // 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); + return literalPaths(first, filePath); +} + +/** + * How much of a refused options object to quote in the dev line. Enough to + * recognise the shape, bounded because an options object is arbitrary source. + */ +const REFUSED_OBJECT_LOG_LIMIT = 160; + +/** + * Decline an options object whose `path` is not provable, and say so. + * + * Gated on `isDev` exactly as the routes phase gates its own registry line + * (`pipeline-phases/routes.ts`), and emitted at `info` rather than `debug` + * because the logger's base level IS `info`: an `isDev`-gated `logger.debug` + * would be gated twice and stay silent in the very dev run it exists for. + * + * Names the FILE, not just the shape — a controller dropped without a path to + * look at is only marginally louder than one dropped in silence, and this + * refusal costs every route on the class. The line number is deliberately + * absent: `lineOffset` (a Vue SFC `