From 457c3ccc7a6a05985bf6aaee81bc25d7ef99f5ef Mon Sep 17 00:00:00 2001 From: Jorge Raad Date: Thu, 9 Jul 2026 17:34:55 +0000 Subject: [PATCH 1/6] feat: discover gRPC and Connect services Add an orthogonal transport axis to EndpointInfo (http | grpc | grpc_web | connect) plus optional gRPC metadata (serviceFqn, method, streamingType), so a gRPC method stays kind:"api" and is distinguished by how it's reached. - New proto-first grpc extractor: parses .proto service/rpc definitions into one endpoint per rpc with wire path /pkg.Service/Method and streaming type. Language-agnostic; auto-detects Connect toolchains and tags transport connect. String-aware comment/brace handling so option-string paths don't corrupt parsing. - NestJS @GrpcMethod/@GrpcStreamMethod for code-first gRPC, deferring to the proto extractor when a .proto is present. - Mapper dedup key includes transport so gRPC and HTTP routes sharing (method, path) don't collapse. - Thread transport/grpc through the endpoint() helper and all formatters. Closes #13. --- scripts/fixtures/grpc-connect/buf.gen.yaml | 4 + scripts/fixtures/grpc-connect/eliza.proto | 7 + .../fixtures/grpc-proto/api/v1/orders.proto | 30 ++++ scripts/fixtures/nestjs-grpc-proto/hero.proto | 7 + .../fixtures/nestjs-grpc-proto/package.json | 1 + .../src/heroes.controller.ts | 14 ++ scripts/fixtures/nestjs-grpc/package.json | 1 + .../nestjs-grpc/src/heroes.controller.ts | 14 ++ src/extractors/grpc.test.ts | 67 ++++++++ src/extractors/grpc.ts | 160 ++++++++++++++++++ src/extractors/index.ts | 2 + src/extractors/nestjs.test.ts | 45 +++++ src/extractors/nestjs.ts | 40 +++++ src/format.ts | 22 ++- src/mapper.ts | 2 +- src/types.ts | 22 ++- src/utils.ts | 6 + 17 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 scripts/fixtures/grpc-connect/buf.gen.yaml create mode 100644 scripts/fixtures/grpc-connect/eliza.proto create mode 100644 scripts/fixtures/grpc-proto/api/v1/orders.proto create mode 100644 scripts/fixtures/nestjs-grpc-proto/hero.proto create mode 100644 scripts/fixtures/nestjs-grpc-proto/package.json create mode 100644 scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts create mode 100644 scripts/fixtures/nestjs-grpc/package.json create mode 100644 scripts/fixtures/nestjs-grpc/src/heroes.controller.ts create mode 100644 src/extractors/grpc.test.ts create mode 100644 src/extractors/grpc.ts create mode 100644 src/extractors/nestjs.test.ts diff --git a/scripts/fixtures/grpc-connect/buf.gen.yaml b/scripts/fixtures/grpc-connect/buf.gen.yaml new file mode 100644 index 0000000..2479b24 --- /dev/null +++ b/scripts/fixtures/grpc-connect/buf.gen.yaml @@ -0,0 +1,4 @@ +version: v1 +plugins: + - plugin: connect-go + out: gen diff --git a/scripts/fixtures/grpc-connect/eliza.proto b/scripts/fixtures/grpc-connect/eliza.proto new file mode 100644 index 0000000..5080132 --- /dev/null +++ b/scripts/fixtures/grpc-connect/eliza.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; +package connectrpc.eliza.v1; +service ElizaService { + rpc Say(SayRequest) returns (SayResponse); +} +message SayRequest { string sentence = 1; } +message SayResponse { string sentence = 1; } diff --git a/scripts/fixtures/grpc-proto/api/v1/orders.proto b/scripts/fixtures/grpc-proto/api/v1/orders.proto new file mode 100644 index 0000000..65b4f91 --- /dev/null +++ b/scripts/fixtures/grpc-proto/api/v1/orders.proto @@ -0,0 +1,30 @@ +// Orders API — license header +/* a block comment + spanning multiple lines with a stray */ +syntax = "proto3"; +package shop.v1; + +import "google/protobuf/empty.proto"; + +service OrderService { + // Create an order + rpc CreateOrder(CreateOrderRequest) returns (Order) { + option (google.api.http) = { + post: "/v1/{parent=shops/*}/orders" + body: "*" + }; + } + rpc StreamOrders(StreamOrdersRequest) + returns (stream Order); + rpc UploadOrders(stream Order) returns (UploadSummary); + rpc Chat(stream ChatMsg) returns (stream ChatMsg); + rpc DeleteOrder(DeleteOrderRequest) returns (google.protobuf.Empty); +} + +service AdminService { + rpc Shutdown(ShutdownRequest) returns (google.protobuf.Empty); +} + +message CreateOrderRequest { string parent = 1; } +message Order { string id = 1; } +// rpc CommentedOut(Foo) returns (Bar); diff --git a/scripts/fixtures/nestjs-grpc-proto/hero.proto b/scripts/fixtures/nestjs-grpc-proto/hero.proto new file mode 100644 index 0000000..7d7766a --- /dev/null +++ b/scripts/fixtures/nestjs-grpc-proto/hero.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; +package hero; +service HeroesService { + rpc FindOne(HeroById) returns (Hero); +} +message HeroById { int32 id = 1; } +message Hero { int32 id = 1; string name = 2; } diff --git a/scripts/fixtures/nestjs-grpc-proto/package.json b/scripts/fixtures/nestjs-grpc-proto/package.json new file mode 100644 index 0000000..52e4a88 --- /dev/null +++ b/scripts/fixtures/nestjs-grpc-proto/package.json @@ -0,0 +1 @@ +{ "name": "nestjs-grpc-fixture", "dependencies": { "@nestjs/core": "^10.0.0", "@nestjs/microservices": "^10.0.0" } } diff --git a/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts b/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts new file mode 100644 index 0000000..bf822fd --- /dev/null +++ b/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts @@ -0,0 +1,14 @@ +import { Controller } from '@nestjs/common'; +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; + +@Controller() +export class HeroesController { + @GrpcMethod('HeroesService', 'FindOne') + findOne(data: HeroById): Hero { return {} as Hero; } + + @GrpcMethod('HeroesService') + findAll(data: Empty): Heroes { return {} as Heroes; } + + @GrpcStreamMethod('HeroesService', 'StreamHeroes') + streamHeroes(messages: any) { return messages; } +} diff --git a/scripts/fixtures/nestjs-grpc/package.json b/scripts/fixtures/nestjs-grpc/package.json new file mode 100644 index 0000000..52e4a88 --- /dev/null +++ b/scripts/fixtures/nestjs-grpc/package.json @@ -0,0 +1 @@ +{ "name": "nestjs-grpc-fixture", "dependencies": { "@nestjs/core": "^10.0.0", "@nestjs/microservices": "^10.0.0" } } diff --git a/scripts/fixtures/nestjs-grpc/src/heroes.controller.ts b/scripts/fixtures/nestjs-grpc/src/heroes.controller.ts new file mode 100644 index 0000000..bf822fd --- /dev/null +++ b/scripts/fixtures/nestjs-grpc/src/heroes.controller.ts @@ -0,0 +1,14 @@ +import { Controller } from '@nestjs/common'; +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; + +@Controller() +export class HeroesController { + @GrpcMethod('HeroesService', 'FindOne') + findOne(data: HeroById): Hero { return {} as Hero; } + + @GrpcMethod('HeroesService') + findAll(data: Empty): Heroes { return {} as Heroes; } + + @GrpcStreamMethod('HeroesService', 'StreamHeroes') + streamHeroes(messages: any) { return messages; } +} diff --git a/src/extractors/grpc.test.ts b/src/extractors/grpc.test.ts new file mode 100644 index 0000000..31ae3a1 --- /dev/null +++ b/src/extractors/grpc.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "path"; +import { grpc } from "./grpc.ts"; +import { createScanContext } from "../scan-context.ts"; + +function extract(fixture: string) { + const dir = resolve(import.meta.dir, "../../scripts/fixtures", fixture); + return grpc.extract(createScanContext(dir)); +} + +describe("grpc proto extraction", () => { + const eps = extract("grpc-proto"); + const byPath = (p: string) => eps.find((e) => e.path === p); + + test("one endpoint per rpc across all services", () => { + expect(eps.length).toBe(6); + }); + + test("wire path is /package.Service/Method", () => { + expect(byPath("/shop.v1.OrderService/CreateOrder")).toBeDefined(); + expect(byPath("/shop.v1.AdminService/Shutdown")).toBeDefined(); + }); + + test("detects all four streaming types", () => { + expect( + byPath("/shop.v1.OrderService/CreateOrder")!.grpc!.streamingType, + ).toBe("unary"); + expect( + byPath("/shop.v1.OrderService/StreamOrders")!.grpc!.streamingType, + ).toBe("server_stream"); + expect( + byPath("/shop.v1.OrderService/UploadOrders")!.grpc!.streamingType, + ).toBe("client_stream"); + expect(byPath("/shop.v1.OrderService/Chat")!.grpc!.streamingType).toBe( + "bidi", + ); + }); + + test("tags transport and framework as grpc", () => { + const e = byPath("/shop.v1.OrderService/CreateOrder")!; + expect(e.transport).toBe("grpc"); + expect(e.framework).toBe("grpc"); + expect(e.grpc!.serviceFqn).toBe("shop.v1.OrderService"); + expect(e.grpc!.method).toBe("CreateOrder"); + }); + + test("ignores commented-out rpcs", () => { + expect(byPath("/shop.v1.OrderService/CommentedOut")).toBeUndefined(); + }); + + test("survives braces and slashes inside option-string paths", () => { + // DeleteOrder is declared AFTER an `option (google.api.http)` block whose + // path literal contains `{parent=shops/*}` — the exact trap that a + // non-string-aware comment/brace scanner chokes on. + expect(byPath("/shop.v1.OrderService/DeleteOrder")).toBeDefined(); + }); +}); + +describe("grpc connect detection", () => { + const eps = extract("grpc-connect"); + test("maps to the connect transport when a connect toolchain is present", () => { + expect(eps.length).toBe(1); + expect(eps[0]!.transport).toBe("connect"); + expect(eps[0]!.framework).toBe("connect"); + expect(eps[0]!.path).toBe("/connectrpc.eliza.v1.ElizaService/Say"); + }); +}); diff --git a/src/extractors/grpc.ts b/src/extractors/grpc.ts new file mode 100644 index 0000000..e1eb30f --- /dev/null +++ b/src/extractors/grpc.ts @@ -0,0 +1,160 @@ +import { join } from "path"; +import type { + EndpointInfo, + Extractor, + FrameworkId, + GrpcStreaming, + ScanContext, +} from "../types.ts"; +import { buildLineIndex, endpoint } from "../utils.ts"; + +const CONNECT_HINTS = [ + "connectrpc.com/connect", + "@connectrpc/", + "protoc-gen-connect", + "connect-go", + "connect-es", + "connectrpc", +]; + +const DEP_FILES = [ + "package.json", + "go.mod", + "Cargo.toml", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "buf.gen.yaml", + "buf.yaml", + "requirements.txt", + "pyproject.toml", +]; + +function usesConnect(ctx: ScanContext): boolean { + for (const f of DEP_FILES) { + const c = ctx.readFile(join(ctx.repoPath, f)); + if (c && CONNECT_HINTS.some((h) => c.toLowerCase().includes(h))) + return true; + } + return false; +} + +// Blank out comments while preserving byte offsets and newlines. String-aware: +// proto path options carry `/*`, `*/` and `//` inside quoted literals +// (e.g. "/v1/{name=projects/*/topics/*}"), which must NOT be read as comments. +function stripComments(src: string): string { + let out = ""; + for (let i = 0; i < src.length; ) { + const c = src[i]!; + if (c === '"' || c === "'") { + out += c; + i++; + while (i < src.length && src[i] !== c) { + if (src[i] === "\\" && i + 1 < src.length) { + out += src[i]! + src[i + 1]!; + i += 2; + } else { + out += src[i]; + i++; + } + } + if (i < src.length) { + out += src[i]; + i++; + } + } else if (c === "/" && src[i + 1] === "/") { + while (i < src.length && src[i] !== "\n") { + out += " "; + i++; + } + } else if (c === "/" && src[i + 1] === "*") { + out += " "; + i += 2; + while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) { + out += src[i] === "\n" ? "\n" : " "; + i++; + } + if (i < src.length) { + out += " "; + i += 2; + } + } else { + out += c; + i++; + } + } + return out; +} + +function streaming(client: boolean, server: boolean): GrpcStreaming { + if (client && server) return "bidi"; + if (client) return "client_stream"; + if (server) return "server_stream"; + return "unary"; +} + +const PACKAGE_RE = /\bpackage\s+([A-Za-z_][\w.]*)\s*;/; +const SERVICE_RE = /\bservice\s+([A-Za-z_]\w*)\s*\{/g; +const RPC_RE = + /\brpc\s+([A-Za-z_]\w*)\s*\(\s*(stream\s+)?[.\w]+\s*\)\s*returns\s*\(\s*(stream\s+)?[.\w]+\s*\)/g; + +export const grpc: Extractor = { + id: "grpc", + detect: (_repoPath, ctx) => ctx.iterFiles([".proto"]).length > 0, + extract(ctx) { + const endpoints: EndpointInfo[] = []; + const framework: FrameworkId = usesConnect(ctx) ? "connect" : "grpc"; + const transport = framework === "connect" ? "connect" : "grpc"; + + for (const file of ctx.iterFiles([".proto"])) { + const raw = ctx.readFile(file); + if (!raw) continue; + const src = stripComments(raw); + const rel = ctx.rel(file); + const lines = buildLineIndex(raw); + const pkg = PACKAGE_RE.exec(src)?.[1] ?? ""; + + // Services never nest and rpcs only live inside them, so each rpc belongs + // to the nearest service declared before it — no brace matching needed. + const services: { name: string; index: number }[] = []; + SERVICE_RE.lastIndex = 0; + let s: RegExpExecArray | null; + while ((s = SERVICE_RE.exec(src))) { + services.push({ name: s[1]!, index: s.index }); + } + if (!services.length) continue; + + RPC_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = RPC_RE.exec(src))) { + let svc: { name: string; index: number } | null = null; + for (const cand of services) { + if (cand.index < m.index) svc = cand; + else break; + } + if (!svc) continue; + + const serviceFqn = pkg ? `${pkg}.${svc.name}` : svc.name; + const method = m[1]!; + endpoints.push( + endpoint({ + method: "ANY", + path: `/${serviceFqn}/${method}`, + handler: method, + file: rel, + line: lines.lineAt(m.index), + framework, + transport, + grpc: { + serviceFqn, + method, + streamingType: streaming(!!m[2], !!m[3]), + }, + }), + ); + } + } + + return endpoints; + }, +}; diff --git a/src/extractors/index.ts b/src/extractors/index.ts index 398a0e8..93b843d 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -13,6 +13,7 @@ import { laravel } from "./laravel.ts"; import { sst } from "./sst.ts"; import { serverActions } from "./server-actions.ts"; import { openapi } from "./openapi.ts"; +import { grpc } from "./grpc.ts"; const ALL_EXTRACTORS: Extractor[] = [ flask, @@ -32,6 +33,7 @@ const ALL_EXTRACTORS: Extractor[] = [ sst, serverActions, openapi, + grpc, ]; const EXTRACTOR_MAP = new Map( diff --git a/src/extractors/nestjs.test.ts b/src/extractors/nestjs.test.ts new file mode 100644 index 0000000..a9886e1 --- /dev/null +++ b/src/extractors/nestjs.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "path"; +import { nestjs } from "./nestjs.ts"; +import { createScanContext } from "../scan-context.ts"; + +function extract(fixture: string) { + const dir = resolve(import.meta.dir, "../../scripts/fixtures", fixture); + return nestjs.extract(createScanContext(dir)); +} + +describe("nestjs code-first gRPC", () => { + const eps = extract("nestjs-grpc"); + const grpc = eps.filter((e) => e.transport === "grpc"); + const byPath = (p: string) => grpc.find((e) => e.path === p); + + test("extracts @GrpcMethod handlers with explicit service + method", () => { + expect(byPath("/HeroesService/FindOne")).toBeDefined(); + }); + + test("derives the method name from the handler when the arg is omitted", () => { + // @GrpcMethod('HeroesService') on findAll() -> FindAll + expect(byPath("/HeroesService/FindAll")).toBeDefined(); + }); + + test("@GrpcStreamMethod is marked as streaming", () => { + expect(byPath("/HeroesService/StreamHeroes")!.grpc!.streamingType).toBe( + "bidi", + ); + }); + + test("keeps framework nestjs, transport grpc", () => { + const e = byPath("/HeroesService/FindOne")!; + expect(e.framework).toBe("nestjs"); + expect(e.transport).toBe("grpc"); + }); +}); + +describe("nestjs gRPC defers to proto when one is present", () => { + test("emits no decorator-derived gRPC endpoints when a .proto exists", () => { + const grpc = extract("nestjs-grpc-proto").filter( + (e) => e.transport === "grpc", + ); + expect(grpc.length).toBe(0); + }); +}); diff --git a/src/extractors/nestjs.ts b/src/extractors/nestjs.ts index 7e195cc..d859767 100644 --- a/src/extractors/nestjs.ts +++ b/src/extractors/nestjs.ts @@ -12,6 +12,9 @@ export const nestjs: Extractor = { extract(ctx) { const endpoints: EndpointInfo[] = []; const tsFiles = ctx.iterFiles([".ts"]); + // When the repo ships `.proto`, the proto extractor is the authoritative + // source of gRPC methods; skip decorator scanning to avoid duplicates. + const codeFirstGrpc = ctx.iterFiles([".proto"]).length === 0; const controllerRe = /@Controller\s*\(\s*['"]([^'"]*)['"]\s*\)/; const methodRe = @@ -75,6 +78,43 @@ export const nestjs: Extractor = { } } + if (codeFirstGrpc) { + const grpcRe = + /@(GrpcMethod|GrpcStreamMethod)\s*\(([^)]*)\)[\s\S]{0,160}?(?:async\s+)?(\w+)\s*\(/g; + for (const f of tsFiles) { + const content = ctx.readFile(f); + if (!content || !content.includes("@Grpc")) continue; + const rel = ctx.rel(f); + const lines = buildLineIndex(content); + const className = /class\s+(\w+)/.exec(content)?.[1] ?? ""; + for (const m of content.matchAll(grpcRe)) { + const args = [...m[2]!.matchAll(/['"]([^'"]+)['"]/g)].map( + (a) => a[1]!, + ); + const handler = m[3]!; + const service = args[0] ?? className.replace(/Controller$/, ""); + const method = + args[1] ?? handler.charAt(0).toUpperCase() + handler.slice(1); + endpoints.push( + endpoint({ + method: "ANY", + path: `/${service}/${method}`, + handler, + file: rel, + line: lines.lineAt(m.index), + framework: "nestjs", + transport: "grpc", + grpc: { + serviceFqn: service, + method, + streamingType: m[1] === "GrpcStreamMethod" ? "bidi" : "unary", + }, + }), + ); + } + } + } + return endpoints; }, }; diff --git a/src/format.ts b/src/format.ts index 2d3cdb8..0d0daa8 100644 --- a/src/format.ts +++ b/src/format.ts @@ -62,7 +62,11 @@ function renderEndpointRows( ): string[] { const lines: string[] = []; for (const ep of endpoints) { - const badge = methodBadge(ep.method); + const badge = methodBadge( + ep.transport && ep.transport !== "http" + ? ep.transport.toUpperCase() + : ep.method, + ); const path = ep.path.length > pathW ? ep.path.slice(0, pathW - 1) + "…" @@ -220,6 +224,8 @@ export function formatJson(result: MapResult, options?: FormatOptions): string { framework: e.framework, }; if (e.handlerFile) obj.handlerFile = e.handlerFile; + if (e.transport) obj.transport = e.transport; + if (e.grpc) obj.grpc = e.grpc; if (e.service) obj.service = e.service; if (e.params.length) obj.params = e.params; if (e.auth.length) obj.auth = e.auth; @@ -272,6 +278,8 @@ export function formatNdjson( framework: e.framework, }; if (e.handlerFile) obj.handlerFile = e.handlerFile; + if (e.transport) obj.transport = e.transport; + if (e.grpc) obj.grpc = e.grpc; if (e.service) obj.service = e.service; if (e.params.length) obj.params = e.params; if (e.auth.length) obj.auth = e.auth; @@ -331,8 +339,12 @@ export function formatMarkdown( for (const ep of svcEndpoints) { const auth = ep.auth.join(", "); + const label = + ep.transport && ep.transport !== "http" + ? ep.transport.toUpperCase() + : ep.method; lines.push( - `| ${ep.method} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, + `| ${label} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, ); } } @@ -344,8 +356,12 @@ export function formatMarkdown( for (const ep of fwEndpoints) { const auth = ep.auth.join(", "); + const label = + ep.transport && ep.transport !== "http" + ? ep.transport.toUpperCase() + : ep.method; lines.push( - `| ${ep.method} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, + `| ${label} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, ); } } diff --git a/src/mapper.ts b/src/mapper.ts index 3c1f46c..973b2aa 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -391,7 +391,7 @@ export function map(repoPath: string, options: MapOptions = {}): MapResult { const seen = new Set(); const unique: EndpointInfo[] = []; for (const ep of raw.endpoints) { - const key = `${ep.method}::${ep.path}`; + const key = `${ep.transport ?? "http"}::${ep.method}::${ep.path}`; if (!seen.has(key)) { seen.add(key); unique.push(ep); diff --git a/src/types.ts b/src/types.ts index b992df0..9f17ba3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,20 @@ export type HttpMethod = export type EndpointKind = "api" | "page" | "action" | "websocket"; +export type EndpointTransport = "http" | "grpc" | "grpc_web" | "connect"; + +export type GrpcStreaming = + | "unary" + | "server_stream" + | "client_stream" + | "bidi"; + +export interface GrpcMeta { + serviceFqn: string; + method: string; + streamingType: GrpcStreaming; +} + export type ServiceType = | "nextjs" | "lambda" @@ -48,6 +62,10 @@ export interface EndpointInfo { params: ParamInfo[]; auth: string[]; internal: boolean; + /** Wire transport. Absent means plain HTTP. */ + transport?: EndpointTransport; + /** Present when transport is a gRPC variant. */ + grpc?: GrpcMeta; service?: string; /** Repo-relative path of the handler's source file, when distinct from `file` * (e.g. SST routes declared in infra/ but implemented in packages/functions/). */ @@ -91,7 +109,9 @@ export type FrameworkId = | "laravel" | "sst" | "server_actions" - | "openapi"; + | "openapi" + | "grpc" + | "connect"; export interface FrameworkDetect { /** Substring matches checked against dependency file content */ diff --git a/src/utils.ts b/src/utils.ts index 39d82c0..6276d5b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,9 @@ import type { EndpointInfo, EndpointKind, + EndpointTransport, FrameworkId, + GrpcMeta, HttpMethod, ParamInfo, } from "./types.ts"; @@ -104,6 +106,8 @@ export function endpoint(e: { kind?: EndpointKind; params?: ParamInfo[]; auth?: string[]; + transport?: EndpointTransport; + grpc?: GrpcMeta; service?: string; handlerFile?: string; serviceRoot?: string; @@ -120,6 +124,8 @@ export function endpoint(e: { auth: e.auth ?? [], internal: false, }; + if (e.transport) ep.transport = e.transport; + if (e.grpc) ep.grpc = e.grpc; if (e.service) ep.service = e.service; if (e.handlerFile) ep.handlerFile = e.handlerFile; if (e.serviceRoot) ep.serviceRoot = e.serviceRoot; From 50def9de0377cae035f2edb618011cc5ea9bf559 Mon Sep 17 00:00:00 2001 From: Jorge Raad Date: Thu, 9 Jul 2026 17:44:43 +0000 Subject: [PATCH 2/6] fix(grpc): resolve `--framework connect` to the grpc extractor connect is a valid FrameworkId but no extractor registered under that id, so an explicit --framework connect override looked up nothing and skipped gRPC discovery. Alias the connect id to the grpc extractor (which picks the grpc/connect variant from the toolchain). --- src/extractors/grpc.test.ts | 12 ++++++++++++ src/extractors/index.ts | 3 +++ 2 files changed, 15 insertions(+) diff --git a/src/extractors/grpc.test.ts b/src/extractors/grpc.test.ts index 31ae3a1..e167347 100644 --- a/src/extractors/grpc.test.ts +++ b/src/extractors/grpc.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "path"; import { grpc } from "./grpc.ts"; import { createScanContext } from "../scan-context.ts"; +import { map } from "../mapper.ts"; +import { getExtractor } from "./index.ts"; function extract(fixture: string) { const dir = resolve(import.meta.dir, "../../scripts/fixtures", fixture); @@ -64,4 +66,14 @@ describe("grpc connect detection", () => { expect(eps[0]!.framework).toBe("connect"); expect(eps[0]!.path).toBe("/connectrpc.eliza.v1.ElizaService/Say"); }); + + test("`--framework connect` resolves the grpc extractor", () => { + expect(getExtractor("connect")).toBe(grpc); + const dir = resolve(import.meta.dir, "../../scripts/fixtures/grpc-connect"); + const grpcEps = map(dir, { + frameworkOverride: "connect", + }).endpoints.all.filter((e) => e.grpc); + expect(grpcEps.length).toBe(1); + expect(grpcEps[0]!.transport).toBe("connect"); + }); }); diff --git a/src/extractors/index.ts b/src/extractors/index.ts index 93b843d..eb1cd89 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -39,6 +39,9 @@ const ALL_EXTRACTORS: Extractor[] = [ const EXTRACTOR_MAP = new Map( ALL_EXTRACTORS.map((e) => [e.id, e]), ); +// The grpc extractor emits both grpc and connect transports (it picks the +// variant from the toolchain), so `--framework connect` resolves to it too. +EXTRACTOR_MAP.set("connect", grpc); export function getExtractor(id: FrameworkId): Extractor | undefined { return EXTRACTOR_MAP.get(id); From 3c4d081a3612b3ee399a84b0c8a601e335c46407 Mon Sep 17 00:00:00 2001 From: Jorge Raad Date: Thu, 9 Jul 2026 17:55:26 +0000 Subject: [PATCH 3/6] fix(grpc): scope NestJS defer to the mapper; detect Connect in nested workspaces - NestJS @GrpcMethod scanning no longer disabled by a repo-wide .proto check. The extractor always emits decorator endpoints; the mapper prefers a package-qualified proto endpoint over a bare decorator duplicate for the same service+method. Fixes: --framework nestjs on a proto repo emitting nothing, and monorepo packages with code-first gRPC being hidden by an unrelated proto. - usesConnect scans buf/connect dep files tree-wide instead of only at the repo root, so Connect toolchains declared in nested workspace packages are detected. --- scripts/fixtures/nestjs-grpc-proto/hero.proto | 4 ++ src/extractors/grpc.ts | 23 +++--- src/extractors/nestjs.test.ts | 24 +++++-- src/extractors/nestjs.ts | 70 +++++++++---------- src/mapper.ts | 19 +++++ 5 files changed, 85 insertions(+), 55 deletions(-) diff --git a/scripts/fixtures/nestjs-grpc-proto/hero.proto b/scripts/fixtures/nestjs-grpc-proto/hero.proto index 7d7766a..924f846 100644 --- a/scripts/fixtures/nestjs-grpc-proto/hero.proto +++ b/scripts/fixtures/nestjs-grpc-proto/hero.proto @@ -2,6 +2,10 @@ syntax = "proto3"; package hero; service HeroesService { rpc FindOne(HeroById) returns (Hero); + rpc FindAll(Empty) returns (Heroes); + rpc StreamHeroes(stream HeroById) returns (stream Hero); } message HeroById { int32 id = 1; } message Hero { int32 id = 1; string name = 2; } +message Empty {} +message Heroes { repeated Hero heroes = 1; } diff --git a/src/extractors/grpc.ts b/src/extractors/grpc.ts index e1eb30f..23178f4 100644 --- a/src/extractors/grpc.ts +++ b/src/extractors/grpc.ts @@ -1,4 +1,4 @@ -import { join } from "path"; +import { basename } from "path"; import type { EndpointInfo, Extractor, @@ -17,22 +17,19 @@ const CONNECT_HINTS = [ "connectrpc", ]; -const DEP_FILES = [ - "package.json", - "go.mod", - "Cargo.toml", - "pom.xml", - "build.gradle", - "build.gradle.kts", +// Files that carry a Connect/Buf toolchain signal. Scanned tree-wide (not just +// at the repo root) so nested-workspace monorepos are detected. +const CONNECT_DEP_FILES = new Set([ "buf.gen.yaml", "buf.yaml", - "requirements.txt", - "pyproject.toml", -]; + "package.json", + "go.mod", +]); function usesConnect(ctx: ScanContext): boolean { - for (const f of DEP_FILES) { - const c = ctx.readFile(join(ctx.repoPath, f)); + for (const f of ctx.iterFiles([".yaml", ".json", ".mod"])) { + if (!CONNECT_DEP_FILES.has(basename(f).toLowerCase())) continue; + const c = ctx.readFile(f); if (c && CONNECT_HINTS.some((h) => c.toLowerCase().includes(h))) return true; } diff --git a/src/extractors/nestjs.test.ts b/src/extractors/nestjs.test.ts index a9886e1..08fc9f0 100644 --- a/src/extractors/nestjs.test.ts +++ b/src/extractors/nestjs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "path"; import { nestjs } from "./nestjs.ts"; import { createScanContext } from "../scan-context.ts"; +import { map } from "../mapper.ts"; function extract(fixture: string) { const dir = resolve(import.meta.dir, "../../scripts/fixtures", fixture); @@ -35,11 +36,24 @@ describe("nestjs code-first gRPC", () => { }); }); -describe("nestjs gRPC defers to proto when one is present", () => { - test("emits no decorator-derived gRPC endpoints when a .proto exists", () => { - const grpc = extract("nestjs-grpc-proto").filter( - (e) => e.transport === "grpc", +describe("proto wins over bare decorator paths at the mapper", () => { + const dir = resolve( + import.meta.dir, + "../../scripts/fixtures/nestjs-grpc-proto", + ); + const grpc = map(dir).endpoints.all.filter((e) => e.grpc); + + test("keeps the package-qualified proto endpoints", () => { + expect(grpc.length).toBe(3); + expect(grpc.every((e) => e.grpc!.serviceFqn === "hero.HeroesService")).toBe( + true, + ); + }); + + test("drops the bare /HeroesService/* decorator duplicates", () => { + expect(grpc.some((e) => e.path === "/hero.HeroesService/FindOne")).toBe( + true, ); - expect(grpc.length).toBe(0); + expect(grpc.some((e) => e.path.startsWith("/HeroesService/"))).toBe(false); }); }); diff --git a/src/extractors/nestjs.ts b/src/extractors/nestjs.ts index d859767..1104b27 100644 --- a/src/extractors/nestjs.ts +++ b/src/extractors/nestjs.ts @@ -12,9 +12,6 @@ export const nestjs: Extractor = { extract(ctx) { const endpoints: EndpointInfo[] = []; const tsFiles = ctx.iterFiles([".ts"]); - // When the repo ships `.proto`, the proto extractor is the authoritative - // source of gRPC methods; skip decorator scanning to avoid duplicates. - const codeFirstGrpc = ctx.iterFiles([".proto"]).length === 0; const controllerRe = /@Controller\s*\(\s*['"]([^'"]*)['"]\s*\)/; const methodRe = @@ -78,40 +75,39 @@ export const nestjs: Extractor = { } } - if (codeFirstGrpc) { - const grpcRe = - /@(GrpcMethod|GrpcStreamMethod)\s*\(([^)]*)\)[\s\S]{0,160}?(?:async\s+)?(\w+)\s*\(/g; - for (const f of tsFiles) { - const content = ctx.readFile(f); - if (!content || !content.includes("@Grpc")) continue; - const rel = ctx.rel(f); - const lines = buildLineIndex(content); - const className = /class\s+(\w+)/.exec(content)?.[1] ?? ""; - for (const m of content.matchAll(grpcRe)) { - const args = [...m[2]!.matchAll(/['"]([^'"]+)['"]/g)].map( - (a) => a[1]!, - ); - const handler = m[3]!; - const service = args[0] ?? className.replace(/Controller$/, ""); - const method = - args[1] ?? handler.charAt(0).toUpperCase() + handler.slice(1); - endpoints.push( - endpoint({ - method: "ANY", - path: `/${service}/${method}`, - handler, - file: rel, - line: lines.lineAt(m.index), - framework: "nestjs", - transport: "grpc", - grpc: { - serviceFqn: service, - method, - streamingType: m[1] === "GrpcStreamMethod" ? "bidi" : "unary", - }, - }), - ); - } + // gRPC handlers (@GrpcMethod / @GrpcStreamMethod). Emitted unconditionally; + // when a .proto also defines the method, the mapper prefers the + // package-qualified proto endpoint over this bare decorator one. + const grpcRe = + /@(GrpcMethod|GrpcStreamMethod)\s*\(([^)]*)\)[\s\S]{0,160}?(?:async\s+)?(\w+)\s*\(/g; + for (const f of tsFiles) { + const content = ctx.readFile(f); + if (!content || !content.includes("@Grpc")) continue; + const rel = ctx.rel(f); + const lines = buildLineIndex(content); + const className = /class\s+(\w+)/.exec(content)?.[1] ?? ""; + for (const m of content.matchAll(grpcRe)) { + const args = [...m[2]!.matchAll(/['"]([^'"]+)['"]/g)].map((a) => a[1]!); + const handler = m[3]!; + const service = args[0] ?? className.replace(/Controller$/, ""); + const method = + args[1] ?? handler.charAt(0).toUpperCase() + handler.slice(1); + endpoints.push( + endpoint({ + method: "ANY", + path: `/${service}/${method}`, + handler, + file: rel, + line: lines.lineAt(m.index), + framework: "nestjs", + transport: "grpc", + grpc: { + serviceFqn: service, + method, + streamingType: m[1] === "GrpcStreamMethod" ? "bidi" : "unary", + }, + }), + ); } } diff --git a/src/mapper.ts b/src/mapper.ts index 973b2aa..ad8eff3 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -388,9 +388,28 @@ export function mapRaw( export function map(repoPath: string, options: MapOptions = {}): MapResult { const raw = mapRaw(repoPath, options); + // A gRPC method found in a `.proto` carries a package-qualified serviceFqn + // (`pkg.Service`); the same method found via a framework decorator (e.g. + // NestJS `@GrpcMethod`) usually lacks the package. When both exist, prefer + // the qualified one and drop the bare duplicate. + const shortName = (fqn: string) => fqn.slice(fqn.lastIndexOf(".") + 1); + const qualifiedGrpc = new Set(); + for (const ep of raw.endpoints) { + if (ep.grpc && ep.grpc.serviceFqn.includes(".")) { + qualifiedGrpc.add(`${shortName(ep.grpc.serviceFqn)}::${ep.grpc.method}`); + } + } + const seen = new Set(); const unique: EndpointInfo[] = []; for (const ep of raw.endpoints) { + if ( + ep.grpc && + !ep.grpc.serviceFqn.includes(".") && + qualifiedGrpc.has(`${ep.grpc.serviceFqn}::${ep.grpc.method}`) + ) { + continue; + } const key = `${ep.transport ?? "http"}::${ep.method}::${ep.path}`; if (!seen.has(key)) { seen.add(key); From e34bb96ebee45d19e4bf6a1de3cbb436d54049b5 Mon Sep 17 00:00:00 2001 From: Jorge Raad Date: Thu, 9 Jul 2026 18:07:01 +0000 Subject: [PATCH 4/6] chore(grpc): export transport types; rename shadowed label in markdown formatter - Re-export EndpointTransport, GrpcStreaming, and GrpcMeta from the public API so consumers can annotate against the new EndpointInfo fields, matching the existing pattern for HttpMethod/EndpointKind/ParamInfo. - Rename the per-row `label` in the markdown formatter to `methodLabel` so it no longer shadows the section-header `label`. --- src/format.ts | 8 ++++---- src/index.ts | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/format.ts b/src/format.ts index 0d0daa8..ad4134d 100644 --- a/src/format.ts +++ b/src/format.ts @@ -339,12 +339,12 @@ export function formatMarkdown( for (const ep of svcEndpoints) { const auth = ep.auth.join(", "); - const label = + const methodLabel = ep.transport && ep.transport !== "http" ? ep.transport.toUpperCase() : ep.method; lines.push( - `| ${label} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, + `| ${methodLabel} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, ); } } @@ -356,12 +356,12 @@ export function formatMarkdown( for (const ep of fwEndpoints) { const auth = ep.auth.join(", "); - const label = + const methodLabel = ep.transport && ep.transport !== "http" ? ep.transport.toUpperCase() : ep.method; lines.push( - `| ${label} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, + `| ${methodLabel} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, ); } } diff --git a/src/index.ts b/src/index.ts index 52779a7..fadd16b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,8 +9,11 @@ export type { DiffHunk, EndpointInfo, EndpointKind, + EndpointTransport, FunctionDef, FrameworkId, + GrpcMeta, + GrpcStreaming, HttpMethod, ImpactResult, MapResult, From e83540af1f210fafd37f0f9fc876f6f740cb5050 Mon Sep 17 00:00:00 2001 From: Jorge Raad Date: Thu, 9 Jul 2026 18:28:19 +0000 Subject: [PATCH 5/6] fix(grpc): dedup gRPC aliases in mapRaw; scope Connect detection per-proto - Move the bare-decorator vs package-qualified gRPC dedup from map() into mapRaw() so impact() (which builds from mapRaw) no longer double-counts gRPC methods that have both a .proto and an @GrpcMethod decorator. - Decide connect vs plain gRPC per proto by walking up to the nearest package with a Connect/Buf toolchain (memoized per directory), so a Connect service in one package no longer mislabels vanilla gRPC protos elsewhere in a monorepo. Also covers Connect config in nested workspace packages. --- .../grpc-mixed/svc-connect/buf.gen.yaml | 4 ++ .../grpc-mixed/svc-connect/eliza.proto | 4 ++ .../fixtures/grpc-mixed/svc-plain/bank.proto | 4 ++ src/extractors/grpc.test.ts | 7 +++ src/extractors/grpc.ts | 47 +++++++++++++------ src/extractors/nestjs.test.ts | 9 +++- src/mapper.ts | 39 ++++++++------- 7 files changed, 80 insertions(+), 34 deletions(-) create mode 100644 scripts/fixtures/grpc-mixed/svc-connect/buf.gen.yaml create mode 100644 scripts/fixtures/grpc-mixed/svc-connect/eliza.proto create mode 100644 scripts/fixtures/grpc-mixed/svc-plain/bank.proto diff --git a/scripts/fixtures/grpc-mixed/svc-connect/buf.gen.yaml b/scripts/fixtures/grpc-mixed/svc-connect/buf.gen.yaml new file mode 100644 index 0000000..2479b24 --- /dev/null +++ b/scripts/fixtures/grpc-mixed/svc-connect/buf.gen.yaml @@ -0,0 +1,4 @@ +version: v1 +plugins: + - plugin: connect-go + out: gen diff --git a/scripts/fixtures/grpc-mixed/svc-connect/eliza.proto b/scripts/fixtures/grpc-mixed/svc-connect/eliza.proto new file mode 100644 index 0000000..d9607b0 --- /dev/null +++ b/scripts/fixtures/grpc-mixed/svc-connect/eliza.proto @@ -0,0 +1,4 @@ +syntax="proto3"; +package eliza.v1; +service ElizaService { rpc Say(Req) returns (Resp); } +message Req{} message Resp{} diff --git a/scripts/fixtures/grpc-mixed/svc-plain/bank.proto b/scripts/fixtures/grpc-mixed/svc-plain/bank.proto new file mode 100644 index 0000000..8198ee0 --- /dev/null +++ b/scripts/fixtures/grpc-mixed/svc-plain/bank.proto @@ -0,0 +1,4 @@ +syntax="proto3"; +package bank.v1; +service BankService { rpc GetBalance(Req) returns (Resp); } +message Req{} message Resp{} diff --git a/src/extractors/grpc.test.ts b/src/extractors/grpc.test.ts index e167347..ea2705a 100644 --- a/src/extractors/grpc.test.ts +++ b/src/extractors/grpc.test.ts @@ -76,4 +76,11 @@ describe("grpc connect detection", () => { expect(grpcEps.length).toBe(1); expect(grpcEps[0]!.transport).toBe("connect"); }); + + test("scopes connect vs plain gRPC per package in a monorepo", () => { + const mixed = extract("grpc-mixed"); + const byPath = (p: string) => mixed.find((e) => e.path === p); + expect(byPath("/eliza.v1.ElizaService/Say")!.transport).toBe("connect"); + expect(byPath("/bank.v1.BankService/GetBalance")!.transport).toBe("grpc"); + }); }); diff --git a/src/extractors/grpc.ts b/src/extractors/grpc.ts index 23178f4..e0947f6 100644 --- a/src/extractors/grpc.ts +++ b/src/extractors/grpc.ts @@ -1,4 +1,4 @@ -import { basename } from "path"; +import { dirname, join } from "path"; import type { EndpointInfo, Extractor, @@ -17,23 +17,39 @@ const CONNECT_HINTS = [ "connectrpc", ]; -// Files that carry a Connect/Buf toolchain signal. Scanned tree-wide (not just -// at the repo root) so nested-workspace monorepos are detected. -const CONNECT_DEP_FILES = new Set([ +const CONNECT_DEP_FILES = [ "buf.gen.yaml", "buf.yaml", "package.json", "go.mod", -]); +]; -function usesConnect(ctx: ScanContext): boolean { - for (const f of ctx.iterFiles([".yaml", ".json", ".mod"])) { - if (!CONNECT_DEP_FILES.has(basename(f).toLowerCase())) continue; - const c = ctx.readFile(f); - if (c && CONNECT_HINTS.some((h) => c.toLowerCase().includes(h))) - return true; - } - return false; +// Connect vs. plain gRPC is decided per proto by walking up to the nearest +// package that declares a Connect/Buf toolchain — so a Connect service in one +// package doesn't mislabel vanilla gRPC protos elsewhere in a monorepo. Results +// are memoized per directory (ancestors are shared across sibling protos). +function makeConnectResolver(ctx: ScanContext): (file: string) => boolean { + const memo = new Map(); + const root = ctx.repoPath; + const dirUsesConnect = (dir: string): boolean => { + const cached = memo.get(dir); + if (cached !== undefined) return cached; + let result = false; + for (const name of CONNECT_DEP_FILES) { + const c = ctx.readFile(join(dir, name)); + if (c && CONNECT_HINTS.some((h) => c.toLowerCase().includes(h))) { + result = true; + break; + } + } + if (!result && dir !== root && dir.startsWith(root)) { + const parent = dirname(dir); + if (parent !== dir) result = dirUsesConnect(parent); + } + memo.set(dir, result); + return result; + }; + return (file) => dirUsesConnect(dirname(file)); } // Blank out comments while preserving byte offsets and newlines. String-aware: @@ -100,12 +116,13 @@ export const grpc: Extractor = { detect: (_repoPath, ctx) => ctx.iterFiles([".proto"]).length > 0, extract(ctx) { const endpoints: EndpointInfo[] = []; - const framework: FrameworkId = usesConnect(ctx) ? "connect" : "grpc"; - const transport = framework === "connect" ? "connect" : "grpc"; + const connectForFile = makeConnectResolver(ctx); for (const file of ctx.iterFiles([".proto"])) { const raw = ctx.readFile(file); if (!raw) continue; + const framework: FrameworkId = connectForFile(file) ? "connect" : "grpc"; + const transport = framework === "connect" ? "connect" : "grpc"; const src = stripComments(raw); const rel = ctx.rel(file); const lines = buildLineIndex(raw); diff --git a/src/extractors/nestjs.test.ts b/src/extractors/nestjs.test.ts index 08fc9f0..2be9588 100644 --- a/src/extractors/nestjs.test.ts +++ b/src/extractors/nestjs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "path"; import { nestjs } from "./nestjs.ts"; import { createScanContext } from "../scan-context.ts"; -import { map } from "../mapper.ts"; +import { map, mapRaw } from "../mapper.ts"; function extract(fixture: string) { const dir = resolve(import.meta.dir, "../../scripts/fixtures", fixture); @@ -56,4 +56,11 @@ describe("proto wins over bare decorator paths at the mapper", () => { ); expect(grpc.some((e) => e.path.startsWith("/HeroesService/"))).toBe(false); }); + + test("dedup lives in mapRaw so impact() sees it too", () => { + const rawGrpc = mapRaw(dir).endpoints.filter((e) => e.grpc); + expect(rawGrpc.some((e) => e.path.startsWith("/HeroesService/"))).toBe( + false, + ); + }); }); diff --git a/src/mapper.ts b/src/mapper.ts index ad8eff3..a6da765 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -375,7 +375,7 @@ export function mapRaw( return { repoPath: resolved, frameworks, - endpoints, + endpoints: dropBareGrpcAliases(endpoints), services: refinedServices, filesScanned: ctx.filesScanned, }; @@ -385,31 +385,34 @@ export function mapRaw( // Main mapper — dedup, filter, sort over raw results // --------------------------------------------------------------------------- -export function map(repoPath: string, options: MapOptions = {}): MapResult { - const raw = mapRaw(repoPath, options); - - // A gRPC method found in a `.proto` carries a package-qualified serviceFqn - // (`pkg.Service`); the same method found via a framework decorator (e.g. - // NestJS `@GrpcMethod`) usually lacks the package. When both exist, prefer - // the qualified one and drop the bare duplicate. +// A gRPC method found in a `.proto` carries a package-qualified serviceFqn +// (`pkg.Service`); the same method found via a framework decorator (e.g. NestJS +// `@GrpcMethod`) usually lacks the package. When both exist, drop the bare +// duplicate. Applied in `mapRaw` so `map()` and `impact()` stay consistent. +function dropBareGrpcAliases(endpoints: EndpointInfo[]): EndpointInfo[] { const shortName = (fqn: string) => fqn.slice(fqn.lastIndexOf(".") + 1); - const qualifiedGrpc = new Set(); - for (const ep of raw.endpoints) { + const qualified = new Set(); + for (const ep of endpoints) { if (ep.grpc && ep.grpc.serviceFqn.includes(".")) { - qualifiedGrpc.add(`${shortName(ep.grpc.serviceFqn)}::${ep.grpc.method}`); + qualified.add(`${shortName(ep.grpc.serviceFqn)}::${ep.grpc.method}`); } } + return endpoints.filter( + (ep) => + !( + ep.grpc && + !ep.grpc.serviceFqn.includes(".") && + qualified.has(`${ep.grpc.serviceFqn}::${ep.grpc.method}`) + ), + ); +} + +export function map(repoPath: string, options: MapOptions = {}): MapResult { + const raw = mapRaw(repoPath, options); const seen = new Set(); const unique: EndpointInfo[] = []; for (const ep of raw.endpoints) { - if ( - ep.grpc && - !ep.grpc.serviceFqn.includes(".") && - qualifiedGrpc.has(`${ep.grpc.serviceFqn}::${ep.grpc.method}`) - ) { - continue; - } const key = `${ep.transport ?? "http"}::${ep.method}::${ep.path}`; if (!seen.has(key)) { seen.add(key); From b14fcc4856aa6d43deae5eecd9afbe103e3a5ec1 Mon Sep 17 00:00:00 2001 From: Jorge Raad Date: Thu, 9 Jul 2026 18:38:57 +0000 Subject: [PATCH 6/6] fix(grpc): stop unsafe short-name dedup; prefer proto on identical paths; owning-class service - Remove the short-name+method alias dedup: it could drop an unrelated code-first `/Service/Method` handler when any package-qualified proto shared the same unqualified service name and method. gRPC endpoints now only collapse when they resolve to the SAME wire path. - On a genuine identical-path collision, prefer the proto/connect definition over a framework decorator (canonical, package-qualified source), instead of keeping whichever extractor ran first. - Resolve the @GrpcMethod fallback service name from the class that actually owns the decorator, not the file's first class. --- .../src/heroes.controller.ts | 12 ++++-- src/extractors/nestjs.test.ts | 33 +++++++--------- src/extractors/nestjs.ts | 18 ++++++++- src/mapper.ts | 39 +++++++------------ 4 files changed, 53 insertions(+), 49 deletions(-) diff --git a/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts b/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts index bf822fd..b7ffd4c 100644 --- a/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts +++ b/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts @@ -3,12 +3,18 @@ import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; @Controller() export class HeroesController { - @GrpcMethod('HeroesService', 'FindOne') + @GrpcMethod('hero.HeroesService', 'FindOne') findOne(data: HeroById): Hero { return {} as Hero; } - @GrpcMethod('HeroesService') + @GrpcMethod('hero.HeroesService', 'FindAll') findAll(data: Empty): Heroes { return {} as Heroes; } - @GrpcStreamMethod('HeroesService', 'StreamHeroes') + @GrpcStreamMethod('hero.HeroesService', 'StreamHeroes') streamHeroes(messages: any) { return messages; } } + +@Controller() +export class BillingController { + @GrpcMethod() + charge(data: any): any { return {}; } +} diff --git a/src/extractors/nestjs.test.ts b/src/extractors/nestjs.test.ts index 2be9588..e2f006c 100644 --- a/src/extractors/nestjs.test.ts +++ b/src/extractors/nestjs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "path"; import { nestjs } from "./nestjs.ts"; import { createScanContext } from "../scan-context.ts"; -import { map, mapRaw } from "../mapper.ts"; +import { map } from "../mapper.ts"; function extract(fixture: string) { const dir = resolve(import.meta.dir, "../../scripts/fixtures", fixture); @@ -36,31 +36,26 @@ describe("nestjs code-first gRPC", () => { }); }); -describe("proto wins over bare decorator paths at the mapper", () => { +describe("proto tiebreak + owning-class service resolution", () => { const dir = resolve( import.meta.dir, "../../scripts/fixtures/nestjs-grpc-proto", ); const grpc = map(dir).endpoints.all.filter((e) => e.grpc); + const byPath = (p: string) => grpc.find((e) => e.path === p); - test("keeps the package-qualified proto endpoints", () => { - expect(grpc.length).toBe(3); - expect(grpc.every((e) => e.grpc!.serviceFqn === "hero.HeroesService")).toBe( - true, - ); - }); - - test("drops the bare /HeroesService/* decorator duplicates", () => { - expect(grpc.some((e) => e.path === "/hero.HeroesService/FindOne")).toBe( - true, - ); - expect(grpc.some((e) => e.path.startsWith("/HeroesService/"))).toBe(false); + test("on an identical wire path, the proto definition wins over the decorator", () => { + const e = byPath("/hero.HeroesService/FindOne"); + expect(e).toBeDefined(); + expect(e!.framework).toBe("grpc"); }); - test("dedup lives in mapRaw so impact() sees it too", () => { - const rawGrpc = mapRaw(dir).endpoints.filter((e) => e.grpc); - expect(rawGrpc.some((e) => e.path.startsWith("/HeroesService/"))).toBe( - false, - ); + test("a decorator with no matching proto survives", () => { + // @GrpcMethod() with no args in BillingController; resolved from its OWN + // class (BillingController -> Billing), not the file's first class. + const e = byPath("/Billing/Charge"); + expect(e).toBeDefined(); + expect(e!.framework).toBe("nestjs"); + expect(e!.grpc!.serviceFqn).toBe("Billing"); }); }); diff --git a/src/extractors/nestjs.ts b/src/extractors/nestjs.ts index 1104b27..0677d39 100644 --- a/src/extractors/nestjs.ts +++ b/src/extractors/nestjs.ts @@ -85,11 +85,25 @@ export const nestjs: Extractor = { if (!content || !content.includes("@Grpc")) continue; const rel = ctx.rel(f); const lines = buildLineIndex(content); - const className = /class\s+(\w+)/.exec(content)?.[1] ?? ""; + // Associate each decorator with the class that owns it (nearest `class` + // declared before it), so files with multiple controllers resolve the + // fallback service name from the right class. + const classes = [...content.matchAll(/\bclass\s+(\w+)/g)].map((c) => ({ + name: c[1]!, + index: c.index, + })); + const classAt = (offset: number) => { + let name = ""; + for (const c of classes) { + if (c.index < offset) name = c.name; + else break; + } + return name; + }; for (const m of content.matchAll(grpcRe)) { const args = [...m[2]!.matchAll(/['"]([^'"]+)['"]/g)].map((a) => a[1]!); const handler = m[3]!; - const service = args[0] ?? className.replace(/Controller$/, ""); + const service = args[0] ?? classAt(m.index).replace(/Controller$/, ""); const method = args[1] ?? handler.charAt(0).toUpperCase() + handler.slice(1); endpoints.push( diff --git a/src/mapper.ts b/src/mapper.ts index a6da765..67eb3b9 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -375,7 +375,7 @@ export function mapRaw( return { repoPath: resolved, frameworks, - endpoints: dropBareGrpcAliases(endpoints), + endpoints, services: refinedServices, filesScanned: ctx.filesScanned, }; @@ -385,38 +385,27 @@ export function mapRaw( // Main mapper — dedup, filter, sort over raw results // --------------------------------------------------------------------------- -// A gRPC method found in a `.proto` carries a package-qualified serviceFqn -// (`pkg.Service`); the same method found via a framework decorator (e.g. NestJS -// `@GrpcMethod`) usually lacks the package. When both exist, drop the bare -// duplicate. Applied in `mapRaw` so `map()` and `impact()` stay consistent. -function dropBareGrpcAliases(endpoints: EndpointInfo[]): EndpointInfo[] { - const shortName = (fqn: string) => fqn.slice(fqn.lastIndexOf(".") + 1); - const qualified = new Set(); - for (const ep of endpoints) { - if (ep.grpc && ep.grpc.serviceFqn.includes(".")) { - qualified.add(`${shortName(ep.grpc.serviceFqn)}::${ep.grpc.method}`); - } - } - return endpoints.filter( - (ep) => - !( - ep.grpc && - !ep.grpc.serviceFqn.includes(".") && - qualified.has(`${ep.grpc.serviceFqn}::${ep.grpc.method}`) - ), - ); -} +// A `.proto` and a framework decorator (e.g. NestJS `@GrpcMethod`) can describe +// the same gRPC method. We only collapse them when they resolve to the SAME +// wire path (identical `transport::method::path`) — matching on the unqualified +// service name would wrongly merge unrelated services that share a short name. +// On a genuine collision the proto definition wins (it's the canonical source). +const isProtoGrpc = (ep: EndpointInfo) => + ep.framework === "grpc" || ep.framework === "connect"; export function map(repoPath: string, options: MapOptions = {}): MapResult { const raw = mapRaw(repoPath, options); - const seen = new Set(); + const indexByKey = new Map(); const unique: EndpointInfo[] = []; for (const ep of raw.endpoints) { const key = `${ep.transport ?? "http"}::${ep.method}::${ep.path}`; - if (!seen.has(key)) { - seen.add(key); + const idx = indexByKey.get(key); + if (idx === undefined) { + indexByKey.set(key, unique.length); unique.push(ep); + } else if (isProtoGrpc(ep) && !isProtoGrpc(unique[idx]!)) { + unique[idx] = ep; } }