Skip to content

feat: discover gRPC and Connect services - #14

Merged
jorgeraad merged 6 commits into
mainfrom
feat/grpc-discovery
Jul 9, 2026
Merged

jorgeraad merged 6 commits into
mainfrom
feat/grpc-discovery

Conversation

@jorgeraad

@jorgeraad jorgeraad commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator

Adds gRPC/Connect discovery to surface. Closes #13.

What

  • transport axis on EndpointInfo (http | grpc | grpc_web | connect, absent ⇒ http) plus optional grpc metadata (serviceFqn, method, streamingType). Orthogonal to kind, so a gRPC method is still kind: "api". Threaded through the endpoint() helper and the JSON/NDJSON/table/markdown formatters (gRPC rows badge as GRPC/CONNECT).
  • New grpc proto-first extractor — parses .proto service/rpc definitions into one endpoint per rpc with wire path /pkg.Service/Method, streaming type (unary/server/client/bidi), and service/method metadata. Language-agnostic. Auto-detects Connect toolchains (buf/connect deps) and tags connect.
  • NestJS @GrpcMethod / @GrpcStreamMethod support for code-first gRPC (methods in decorators, no .proto), guarded to defer to the proto extractor when a .proto exists.
  • Mapper: dedup key now includes transport so a gRPC method and an HTTP route sharing a (method, path) don't collapse.

The .proto parser is string-aware — comments and braces inside option-string paths (e.g. option (google.api.http) = { post: "/v1/{parent=shops/*}/orders" }) don't corrupt parsing, and rpcs are associated to services by position rather than brace-matching.

Testing

Unit tests + fixtures (12 new): all four streaming types, multi-service files, commented-out rpcs, the option-string brace/slash trap (as a regression guard), Connect detection, and NestJS code-first + defer-to-proto.

Beyond fixtures, validated against a spread of real open-source repos, comparing extracted method counts against the raw .proto rpc counts:

Repo Ecosystem Result
grpc-go Go 15/15
grpc-spring Java (@GrpcService) 13/13
grpc-dotnet .NET 90 methods / 39 services
tonic Rust matches (after dedup of duplicate test protos)
connectrpc examples-go / connect-go Connect correct connect transport; matches non-testdata
etcd Go (gogoproto) 49/49
cosmos-sdk Cosmos (gogoproto) 249/249 non-testdata
opentelemetry-proto OTel 4/4
googleapis scale / variety 13,105 methods / 1,833 services in ~1s, 0 malformed paths

Streaming direction verified on real streaming APIs (e.g. Speech StreamingRecognize → bidi, Spanner ExecuteStreamingSql → server_stream, Pub/Sub StreamingPull → bidi). Iterating on googleapis is what surfaced and fixed the option-string parsing bug noted above.

tsc / eslint / prettier clean; full suite (39 tests) green.


Note

Medium Risk
Touches core mapping/dedup and adds a regex-based proto parser; incorrect parsing or dedup could drop or mislabel endpoints, but behavior is covered by fixtures and real-repo validation described in the PR.

Overview
Adds gRPC and Connect endpoint discovery alongside existing HTTP scanning, with a new transport dimension on endpoints (grpc, connect, etc.) and optional grpc metadata (service FQN, method, streaming type). JSON, NDJSON, table, and markdown output now surface transport/grpc and label non-HTTP rows as GRPC or CONNECT.

A new proto-first grpc extractor turns each .proto rpc into an endpoint at wire path /package.Service/Method, classifies unary/server/client/bidi streaming, and picks Connect vs plain gRPC from Buf/Connect toolchain hints per package in monorepos. Parsing is string-aware so comments and google.api.http path literals with {…/*} do not break RPC detection.

NestJS gains @GrpcMethod / @GrpcStreamMethod extraction for code-first handlers; when the same wire path exists in a .proto, the mapper dedup key includes transport and proto wins over decorator-only entries. --framework connect routes to the same extractor as grpc.

Reviewed by Cursor Bugbot for commit b14fcc4. Bugbot is set up for automated code reviews on this repo. Configure here.

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.
@jorgeraad jorgeraad added the enhancement New feature or request label Jul 9, 2026
Comment thread src/extractors/index.ts
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).
Comment thread src/extractors/nestjs.ts Outdated
Comment thread src/extractors/nestjs.ts Outdated
Comment thread src/extractors/grpc.ts Outdated
… 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.
Comment thread src/types.ts
Comment thread src/format.ts
…n 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`.
Comment thread src/mapper.ts Outdated
Comment thread src/extractors/grpc.ts
…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.
jorgeraad added a commit that referenced this pull request Jul 9, 2026
…rt tests to vitest

Rebased onto feat/grpc-discovery (#14, stacked). Integrate #14's code-first
gRPC detection into #9's class-aware nestjs extractor:
- @GrpcMethod/@GrpcStreamMethod handling is now a third match loop inside the
  main per-file loop, reusing findClasses/classAt so the owning class name is
  resolved consistently (correct even in multi-controller files). Broaden the
  file filter to include @grpc so gRPC-only controllers are scanned.
- gRPC endpoints keep kind:"api" with transport:"grpc"; server/bidi
  streaming is modeled via grpc.streamingType, not a websocket kind. Documented
  the decision in grpc.ts.
- Port #14's grpc.test.ts and nestjs gRPC tests from bun:test to vitest; move
  grpc/nestjs-grpc fixtures under src/extractors/__fixtures__/. No bun:test
  references remain.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Wrong alias dedup key
    • Scoped the bare-gRPC-alias dedup key by the endpoint's resolved service so a package-qualified proto RPC in an unrelated monorepo service no longer suppresses a code-first decorator endpoint.

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit e83540a. Configure here.

Comment thread src/mapper.ts Outdated
Comment thread src/mapper.ts
Comment thread src/extractors/nestjs.ts
jorgeraad added a commit that referenced this pull request Jul 9, 2026
…rt tests to vitest

Rebased onto feat/grpc-discovery (#14, stacked). Integrate #14's code-first
gRPC detection into #9's class-aware nestjs extractor:
- @GrpcMethod/@GrpcStreamMethod handling is now a third match loop inside the
  main per-file loop, reusing findClasses/classAt so the owning class name is
  resolved consistently (correct even in multi-controller files). Broaden the
  file filter to include @grpc so gRPC-only controllers are scanned.
- gRPC endpoints keep kind:"api" with transport:"grpc"; server/bidi
  streaming is modeled via grpc.streamingType, not a websocket kind. Documented
  the decision in grpc.ts.
- Port #14's grpc.test.ts and nestjs gRPC tests from bun:test to vitest; move
  grpc/nestjs-grpc fixtures under src/extractors/__fixtures__/. No bun:test
  references remain.
…ths; 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.
@jorgeraad
jorgeraad force-pushed the feat/grpc-discovery branch from 987b6cf to b14fcc4 Compare July 9, 2026 18:41
@jorgeraad

Copy link
Copy Markdown
Collaborator Author

Note: I replaced the Bugbot Autofix commit (987b6cf, service-scoped alias dedup) with a more complete fix. The autofix scoped the short-name dedup by ep.service, but that still drops the endpoint whenever surface assigns the code-first handler and an unrelated proto the same detected service (e.g. a single-app repo where ep.service is identical for both) — verified it still suppressed /HeroesService/FindOne in that case. This commit instead removes short-name matching entirely (gRPC endpoints only collapse on an identical wire path, proto wins the tiebreak), which also covers the proto-tiebreak and owning-class findings in the same review.

jorgeraad added a commit that referenced this pull request Jul 9, 2026
…rt tests to vitest

Rebased onto feat/grpc-discovery (#14, stacked). Integrate #14's code-first
gRPC detection into #9's class-aware nestjs extractor:
- @GrpcMethod/@GrpcStreamMethod handling is now a third match loop inside the
  main per-file loop, reusing findClasses/classAt so the owning class name is
  resolved consistently (correct even in multi-controller files). Broaden the
  file filter to include @grpc so gRPC-only controllers are scanned.
- gRPC endpoints keep kind:"api" with transport:"grpc"; server/bidi
  streaming is modeled via grpc.streamingType, not a websocket kind. Documented
  the decision in grpc.ts.
- Port #14's grpc.test.ts and nestjs gRPC tests from bun:test to vitest; move
  grpc/nestjs-grpc fixtures under src/extractors/__fixtures__/. No bun:test
  references remain.
jorgeraad added a commit that referenced this pull request Jul 9, 2026
Rebased onto feat/grpc-discovery @ b14fcc4, which removed the buggy short-name
dropBareGrpcAliases in favor of an identical-wire-path collapse (proto wins via
isProtoGrpc). Reconcile #9's kind-detection layer with the new base:
- adopt b14fcc4's nestjs-grpc-proto fixture (package-qualified hero.HeroesService
  decorators + a BillingController @GrpcMethod() no-arg case).
- replace the stale 'proto wins over bare decorator' / 'dedup lives in mapRaw'
  tests with b14fcc4's 'proto tiebreak + owning-class service resolution' cases
  (ported to vitest); drop the now-unused mapRaw import.
The nestjs extractor already resolves the @GrpcMethod fallback service from the
owning class via classAt, matching #14.
@jorgeraad
jorgeraad merged commit ebd8313 into main Jul 9, 2026
2 checks passed
jorgeraad added a commit that referenced this pull request Jul 9, 2026
…rt tests to vitest

Rebased onto feat/grpc-discovery (#14, stacked). Integrate #14's code-first
gRPC detection into #9's class-aware nestjs extractor:
- @GrpcMethod/@GrpcStreamMethod handling is now a third match loop inside the
  main per-file loop, reusing findClasses/classAt so the owning class name is
  resolved consistently (correct even in multi-controller files). Broaden the
  file filter to include @grpc so gRPC-only controllers are scanned.
- gRPC endpoints keep kind:"api" with transport:"grpc"; server/bidi
  streaming is modeled via grpc.streamingType, not a websocket kind. Documented
  the decision in grpc.ts.
- Port #14's grpc.test.ts and nestjs gRPC tests from bun:test to vitest; move
  grpc/nestjs-grpc fixtures under src/extractors/__fixtures__/. No bun:test
  references remain.
jorgeraad added a commit that referenced this pull request Jul 9, 2026
Rebased onto feat/grpc-discovery @ b14fcc4, which removed the buggy short-name
dropBareGrpcAliases in favor of an identical-wire-path collapse (proto wins via
isProtoGrpc). Reconcile #9's kind-detection layer with the new base:
- adopt b14fcc4's nestjs-grpc-proto fixture (package-qualified hero.HeroesService
  decorators + a BillingController @GrpcMethod() no-arg case).
- replace the stale 'proto wins over bare decorator' / 'dedup lives in mapRaw'
  tests with b14fcc4's 'proto tiebreak + owning-class service resolution' cases
  (ported to vitest); drop the now-unused mapRaw import.
The nestjs extractor already resolves the @GrpcMethod fallback service from the
owning class via classAt, matching #14.
jorgeraad added a commit that referenced this pull request Jul 9, 2026
* test: add vitest + reference fastapi extractor test

Sets up vitest as the test runner and adds the first per-extractor
test using the FastAPI fixture (which already emits kind: "websocket"
for @app.websocket routes). Establishes the fixture-driven pattern that
subsequent per-framework tests will follow.

* feat(django): detect page (TemplateView/render) and websocket (Channels) kinds

Class-based views deriving from TemplateView/ListView/DetailView and FBVs
calling render() are now emitted as kind: "page". websocket_urlpatterns
entries (Django Channels) emit kind: "websocket". Strips .as_view/.as_asgi
from handler names so view classes can be looked up by name.

* feat(express): detect page (res.render/sendFile) and websocket kinds

Replaced the lazy single-line route regex with a balanced-paren walker so
multi-line arrow handler bodies can be inspected. Page detection looks for
res.render(...) or res.sendFile(...) in the handler body. Websocket
detection covers app.ws() (express-ws), io.on('connection')/io.of()
(socket.io), and new WebSocketServer (ws library).

* feat(fastapi): detect page kind for HTMLResponse / TemplateResponse

A route is page-shaped when its decorator args set response_class=HTMLResponse
or its function body returns an HTMLResponse(...) or .TemplateResponse(...).
Body window is bounded to the next top-level decorator/def to avoid bleeding
between adjacent routes.

* feat(flask): detect page kind for handlers using render_template

A handler whose body contains render_template() or render_template_string()
emits kind: "page". Body window is bounded to the next top-level def/class/
decorator at column 0 so adjacent handlers do not leak into each other's
classification.

* feat(go): detect websocket kind for gorilla/websocket upgrades

A route handler emits kind: "websocket" when its file imports
github.com/gorilla/websocket and the handler body contains an .Upgrade(
call. Applies uniformly across gin, echo, fiber, and net/http via a shared
findWebsocketHandlers helper. Also tightened the handler-name regex so
trailing handler args resolve correctly.

* feat(laravel): detect page kind for routes/web.php

Routes defined in routes/web.php emit kind: "page" (session/CSRF/Blade
view convention); routes from routes/api.php remain kind: "api". The
file-of-origin distinction is reused from the existing /api prefix logic.

* feat(nestjs): detect page (@Render) and websocket (@WebSocketGateway) kinds

Methods carrying @Render('view') emit kind: "page". Classes annotated
with @WebSocketGateway() emit each @SubscribeMessage('event') method as a
websocket endpoint with method WS. Methods are now associated with their
owning class via a per-class scan so controller vs gateway is disambiguated.

* feat(rails): detect page vs api by controller superclass

Routes are classified by walking the controller's inheritance chain on
disk: ActionController::Base ancestor → kind: "page"; ActionController::API
ancestor → kind: "api". Resolution memoizes per file with a cycle guard.
Also fixes a pre-existing bug where namespace blocks leaked across the
whole routes.rb file — namespaces are now tracked block-by-block.

* feat(spring): detect page (@controller) and websocket (@MessageMapping) kinds

Methods on a class annotated with @controller (without @RestController) emit
kind: "page"; @RestController stays kind: "api". @MessageMapping and
@SubscribeMapping methods emit a separate websocket endpoint regardless of
class kind. Class context is resolved via a back-scan from each mapping
match, so inner classes and multi-class files are handled.

* chore: exclude __fixtures__ from tsc and eslint

Fixture directories under src/extractors/__fixtures__/ contain synthetic
test inputs that mimic external frameworks (e.g. @nestjs/common imports
without the package being installed). They're scanned as raw source text
by the extractors, never compiled or imported. Excluding them from both
the TypeScript program and the ESLint config keeps tooling clean without
needing per-file @ts-nocheck escape hatches.

* test(nextjs): port nextjs.test.ts from bun:test to vitest

The rest of the test suite uses vitest (introduced for the per-extractor
fixture tests). Unify on one runner so 'bun run test' covers all suites.
Replaces import.meta.dir (bun-only) with the standard ESM
fileURLToPath(import.meta.url) pattern.

* chore: apply prettier formatting to nestjs and rails extractors

* fix(nestjs): scope @Render detection to the current method's decorator stack

The previous heuristic checked a 400-char backward window and a 400-char
forward window for @Render. In a controller with multiple methods, that
easily included @Render decorators belonging to a sibling method, so a
plain @get sitting near a @Render-decorated handler was misclassified as
a page.

Replace both windows with a precise line-by-line walk that captures only
the contiguous decorator stack of the current method — stopping at any
line that ends a previous statement (}, ;) or otherwise looks like code.
Add a regression test where a @get("/data") sibling sits directly below
a @Render-decorated method and asserts its kind is "api".

* fix(nestjs,rails): scope class decorators per-class; order rails end/do correctly

Address Cursor Bugbot review on #9:
- nestjs findClasses: attribute only the immediately-preceding decorator
  block to each class, so a @Controller/@WebSocketGateway in an earlier
  class in the same file no longer bleeds onto later classes. Add a
  multi-controller regression fixture + test.
- rails: process 'end' (close + namespace pop) before 'do' (open) so a
  combined 'end; scope do' line pops namespaces at the correct depth.

* feat(nestjs,grpc): integrate #14 gRPC into restructured extractor; port tests to vitest

Rebased onto feat/grpc-discovery (#14, stacked). Integrate #14's code-first
gRPC detection into #9's class-aware nestjs extractor:
- @GrpcMethod/@GrpcStreamMethod handling is now a third match loop inside the
  main per-file loop, reusing findClasses/classAt so the owning class name is
  resolved consistently (correct even in multi-controller files). Broaden the
  file filter to include @grpc so gRPC-only controllers are scanned.
- gRPC endpoints keep kind:"api" with transport:"grpc"; server/bidi
  streaming is modeled via grpc.streamingType, not a websocket kind. Documented
  the decision in grpc.ts.
- Port #14's grpc.test.ts and nestjs gRPC tests from bun:test to vitest; move
  grpc/nestjs-grpc fixtures under src/extractors/__fixtures__/. No bun:test
  references remain.

* test(django): lock in kind classification for re_path/string-prefixed routes

Phase 2 kind-coverage audit: confirm #12's re_path()/raw-string routes flow
through the same page-vs-api classification as plain path() routes (no undefined
kind). Audited all extractors — nextjs already assigns page/api correctly (and
is tested), server-actions emits kind:action, grpc/connect stays api with
transport+streamingType, and actix/sst/openapi emit only REST/spec routes so the
api default is correct.

* chore(grpc): reconcile with grpc-discovery per-proto Connect refactor

Rebased onto the updated feat/grpc-discovery (per-proto Connect scoping + gRPC
dedup moved into mapRaw). Preserve its new tests under the vitest port:
- move grpc-mixed monorepo fixtures under src/extractors/__fixtures__/ and keep
  the connect-vs-plain-per-package test (as vitest it()).
- re-add the 'dedup lives in mapRaw so impact() sees it too' nestjs test.

* fix(django,go): harden kind heuristics per Bugbot review

Address Cursor Bugbot findings on #9's kind-detection heuristics:
- django: resolve CBV page detection through the full inheritance chain so a
  view that extends a template base indirectly (HomeView -> SiteBaseView ->
  TemplateView) is still a page.
- django: scope the view/function registry per app directory (with a global
  fallback) so two apps defining the same view name don't clobber each other's
  page-vs-api kind.
- django: capture single-line function-view bodies (def view(request): return
  render(...)) and an optional -> ReturnType annotation, so they aren't
  misclassified as api.
- go: scope websocket-handler names per directory (package) so an ordinary HTTP
  route isn't mislabeled websocket just because an unrelated package defines a
  same-named .Upgrade()-ing handler.
Add regression fixtures + vitest cases for each.

* chore(grpc): align #9 gRPC tests/fixture with #14 b14fcc4 dedup rework

Rebased onto feat/grpc-discovery @ b14fcc4, which removed the buggy short-name
dropBareGrpcAliases in favor of an identical-wire-path collapse (proto wins via
isProtoGrpc). Reconcile #9's kind-detection layer with the new base:
- adopt b14fcc4's nestjs-grpc-proto fixture (package-qualified hero.HeroesService
  decorators + a BillingController @GrpcMethod() no-arg case).
- replace the stale 'proto wins over bare decorator' / 'dedup lives in mapRaw'
  tests with b14fcc4's 'proto tiebreak + owning-class service resolution' cases
  (ported to vitest); drop the now-unused mapRaw import.
The nestjs extractor already resolves the @GrpcMethod fallback service from the
owning class via classAt, matching #14.

* fix(django): index async def views in the kind registry

Address Cursor Bugbot finding: the function-view regex only matched sync `def`,
so `async def` template views were never indexed (misclassified api) and a
following async view could be absorbed into the preceding sync view's body
(bleeding its render() and mismarking a JSON route as page). funcRe now matches
an optional `async` prefix and treats `async def` as a body terminator. Added
a regression fixture (async_page + a sync json_only right before it) + test.

* fix(django,express): nested view packages + genuine socket.io namespaces

Address Cursor Bugbot round 3:
- django: resolve the page/api registry within the whole app subtree (the dir
  containing urls.py), not just the immediate parent, so views in a nested
  package (myapp/views/*.py) still resolve to their owning app and same-named
  views across apps keep the right kind. Add a blogapp fixture with a views/
  package colliding by name with an otherapp APIView.
- express: only treat .of()/.on("connection") as socket.io when the file
  actually imports socket.io AND the call is on a confirmed server instance
  (new Server(...), require("socket.io")(...), or a factory binding). Unrelated
  .of() chains are no longer emitted as websocket endpoints. Add a fixture with
  a genuine io.of("/rooms") plus an unrelated registry.of("/plugins").
Also format two fixture package.json files that CI's prettier --check flagged.

* fix(nestjs,django,go): mask comments in class scan; make dirOf cross-platform

Address Cursor Bugbot round 5 on the rebased-onto-main head:
- nestjs: findClasses now detects class boundaries on a comment-masked copy of
  the file (offsets preserved), so a `class` token inside a // or /* */ comment
  no longer creates a phantom class that steals later handlers and drops the
  @controller prefix. Add a fixture with `// class Helper` inside a controller.
- django & go: dirOf now normalizes \\ to / and guards the no-separator case, so
  per-app view scoping and per-package websocket scoping work on Windows scan
  paths (which use \\) instead of corrupting the path via lastIndexOf('/')=-1.

---------

Co-authored-by: Test <test@pensar.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Model transport as an orthogonal axis + add gRPC discovery

1 participant