Skip to content

Commit 666fa2c

Browse files
authored
Feat/grpc support (#70)
* feat(grpc): model proto RPCs as routes and detect TS gRPC-web client calls * feat(grpc): bind server RPC routes to Go handler methods via handled_by edge * feat(grpc): detect Go grpc-go client call sites as client-role routes * feat(grpc): detect struct-field-injected, connect-go, and connect-es clients * feat(grpc): recognize grpc-web clients and package-level-var Go clients * Fixing lint
1 parent 3a58baa commit 666fa2c

32 files changed

Lines changed: 2447 additions & 13 deletions

File tree

ARCHITECTURE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ For `symbol` facts, the specific construct is carried in `Props["symbol_kind"]`,
6565
| `instantiates` | A symbol constructs an instance of a type |
6666
| `injects` | A symbol takes a type as a dependency-injected constructor parameter |
6767
| `has_method` | A type owns a method (synthesized when the graph is built — see below) |
68+
| `handled_by` | A route/endpoint is served by a symbol — e.g. a gRPC RPC route bound to its Go handler method (added post-extraction) |
6869

6970
### A tiny example
7071

@@ -531,6 +532,7 @@ Each extractor is detected by characteristic project files and then parses what
531532
| C/C++ | tree-sitter | a C source (`.c`) or C++ source (`.cpp`/`.cc`/`.cxx`/`.hpp`/...) present, or a build file (`CMakeLists.txt`/`Makefile`/`meson.build`/`*.vcxproj`) plus any header |
532533
| PHP | tree-sitter | `composer.json`, a WordPress bootstrap file (`wp-load.php`/`wp-settings.php`/`wp-config.php`), or any `.php` source within 3 directory levels |
533534
| OpenAPI | YAML/JSON scanner| any file containing `openapi:` or `swagger:` |
535+
| gRPC | proto3 scanner | any `.proto` file present |
534536

535537
**Go** uses the standard-library parser directly, so symbols, methods, interfaces, imports, and call edges are exact.
536538

@@ -552,6 +554,8 @@ Each extractor is detected by characteristic project files and then parses what
552554

553555
**OpenAPI** scans for spec files independently of the main walker (so it finds them even when `*.yaml`/`*.json` are globally ignored), confirming candidates by an `openapi:`/`swagger:` key. It emits one `route` per operation enriched with method, `operationId`, summary, tags, and a spec back-reference; specs under an `openapi/client/` directory are marked `role:"client"`. Gateway extensions (`x-gateway-config`, `x-gateway-capabilities`) are parsed into props.
554556

557+
**gRPC** models Protocol Buffers services the same way HTTP endpoints are modeled, so a gRPC surface answers the same cross-repo and unused-endpoint questions as a REST one. A small dependency-free proto3 scanner (comment-stripped, brace-depth aware — the same class of parser as OpenAPI's) reads each `.proto` and emits, for every `rpc`, a **server-role `route`** whose `Name` is the gRPC wire path `/pkg.Service/Method` (e.g. `/users.v1.UserService/GetUser`) with `method:"POST"`, `framework:"grpc"`, `source:"grpc-proto"`, `type:"grpc"`, and `rpc_service`/`rpc_method`/`streaming` props — the exact path+method a gRPC-web client hits over HTTP, so these flow through the cross-repo linker's normalized path+method matching and the `unused-routes` explainer with no linker special-casing. Each service also emits an `interface` symbol, each RPC a `method` symbol (`has_method`-linked to its service), and each message a `struct`/`enum` symbol, and proto `import`s become `dependency` facts — so proto participates in `traverse`, `find_path`, and `impact_analysis`. **Client-side detection** covers both TypeScript and Go. In the **TypeScript** extractor a repo-wide pre-pass resolves generated stubs — `@protobuf-ts` (`new ServiceType(...)`), connect-es (`typeName`), and **classic grpc-web** (where it derives the service and methods from the `MethodDescriptor`/`rpcCall` `/pkg.Service/Method` path literals) — into a service→method map, then per-file it binds `new XxxServiceClient(...)` variables (including typed constructor-injected fields) and emits a **client-role `route`** (`source:"ts-grpc-client"`) for each `client.method(...)` **call site** — only for methods actually called, so an RPC the frontend never invokes correctly surfaces as unmatched by clients. The **Go** extractor does the same for grpc-go consumers: because a Go call site (`client.GetUser(ctx, req)`) carries no wire path, a repo-wide pre-pass reads the authoritative `/pkg.Service/Method` from the *generated* code — the concrete client's `Invoke`/`NewStream` string literal (grpc-go, unary + streaming) or the `…Procedure` const (connect-go) — and builds a client-interface→method→path index. Per-file, it reuses the Go extractor's own receiver/field/local-variable type resolution (`resolveChain`) so a client is recognized whether it's a **local variable**, an **inline construction**, a **struct field** (`s.users.GetUser(...)` — dependency injection), or a **package-level var**, emitting a **client-role `route`** (`source:"go-grpc-client"`) per call site. Both **grpc-go** and **connect-go** consumers are covered. On the TypeScript side, **connect-es** consumers using `createClient(Service, transport)` / `createPromiseClient(...)` are detected alongside the `new XxxClient(...)` form. Cross-repo gRPC edges are tagged `via:"grpc"`. **Go handler binding:** a post-extraction pass connects each gRPC server route to the Go method that serves it via a `handled_by` edge (route → `pkg.Type.Method`) and a `handler` prop, so `impact_analysis`/`find_path` traverse from the RPC to its implementation (and, through the cross-repo edges, on to its clients). The bridge is the `protoc-gen-go-grpc` forward-compatibility convention — a server impl embeds `Unimplemented<Service>Server`, which the Go extractor already records as an `implements` edge, so the service short name matches the route's `rpc_service` with no new Go parsing; ambiguous or non-embedding impls are left unbound. *Scope:* client detection targets protoc-gen-go-grpc, connect-go, `@protobuf-ts`, connect-es, and classic grpc-web generated stubs; hand-rolled clients that bypass the generated stubs are not recognized (a namespaced commonjs grpc-web constructor, `new proto.pkg.XxxClient(...)`, binds only best-effort).
558+
555559
**Ruby** is parsed with tree-sitter, replacing the former line-based regex scanner — the grammar handles heredocs, endless methods (`def x = expr`), multi-line expressions, and the nested scopes that tripped up the line scanner. It is Rails-aware: ActiveRecord models (`has_many`/`has_one`/`belongs_to`/`has_and_belongs_to_many`, scopes, table inference, explicit `self.table_name`) emit `storage` facts; the route DSL in `config/routes.rb` (plus `config/routes/*.rb` and packwerk `draw`) is walked from the real block structure, so nested `namespace`/`scope`/`resources`/`member`/`collection` blocks produce one `route` per RESTful action (honoring `only:`/`except:`); and Packwerk package boundaries (`package.yml` dependency enforcement, `app/public/` privacy) are parsed. It tracks modules, classes, methods with `public`/`private`/`protected` visibility, `class << self` eigenclass and `module_function` methods — now correctly typed as class methods rather than instance methods — mixins (`include`/`extend`/`prepend` → `implements` edges), `ActiveSupport::Concern` (flagged `concern: true`), constants, and `attr_*` accessors. Like the other AST extractors, it walks method bodies for call sites, emitting `calls` edges (qualified `Const.method`/`Ns::Class.method` and receiver `var.method`, deduplicated) and `implements` edges for superclasses — so Ruby participates in `traverse`, `find_path`, and `impact_analysis`.
556560

557561
**C/C++** is parsed with tree-sitter and handles the header/source split that defines the language. The extractor owns both languages: `.c` files (and bare `.h` headers in a C context) are parsed with the **tree-sitter-c** grammar, while `.cpp`/`.cc`/`.cxx`/`.hpp`/... (and `.h` headers in a C++ context) use **tree-sitter-cpp**; every fact carries a `language` prop (`"c"` or `"cpp"`). A real C grammar is required rather than reusing the C++ one, because C code routinely uses C++ keywords as ordinary identifiers (`new`, `try`, `class`, `delete`, `private`, ...) plus C-only constructs (`_Generic`, `restrict`, GCC range designators) that the C++ grammar rejects. Bare `.h` headers are attributed **per directory subtree**: a header is treated as C++ only when its own subtree contains unambiguous C++ sources — so a handful of stray `.cpp` files (e.g. `tools/` in an otherwise pure-C kernel tree) no longer flip every `.h` in the repo to C++; absent that signal, `.h` defaults to C. In C, a `static` function has internal linkage and is emitted with `exported=false` (file-private); C++ keeps `exported=true`. Classes, structs, unions, enums (incl. `enum class`), namespaces, free functions and methods, data members, and `typedef`/`using` aliases become symbol facts named `<dir>.<ns1::ns2::Class::member>` — enola's `<dir>.` module convention on the outside, native C++ `::` scope inside. Because an out-of-line definition `Class::method` (parsed from a `qualified_identifier`) yields the same canonical name as its in-class declaration, a dedup pass **merges a header's method prototype with its `.cpp` definition** into a single symbol (the definition wins for file/line and carries the call-graph edges). Base classes become `implements` edges; method bodies are walked for `calls`/`instantiates` edges; quoted `#include "x.h"` becomes a `dependency` resolved to the declaring module, while system `<...>` includes are skipped. Templates are unwrapped to their inner declaration and flagged `templated`, and the walker descends through `#if`/`#ifdef` preprocessor guards (so code wrapped in `#if defined(HAVE_*)` and headers behind include guards are still extracted). Like the other AST extractors, it walks each function/method body for complexity metrics — `cyclomatic`, `loop_depth`, `loop_count`, `calls_in_loop`, and `recursive_self` (counting `for`/`while`/`do-while`/range-`for` and STL-algorithm lambdas like `for_each`/`transform` as loops) — which the enterprise `analyze_performance` tool consumes. *Limitation:* header/source merging relies on the `.h` and `.cpp` living in the same directory (the common layout); split `include/` + `src/` trees are not merged.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Cursor (add to `mcp.json`):
3737
3838
Done. Your agent now has a precise structural map of your code. For configuration options, multi-repo setup, and what to ask next, see [Quick start](#quick-start) below.
3939

40-
**Supported languages:** Go · JavaScript · TypeScript · Python · Java · Kotlin · Swift · Ruby · C · C++ · PHP · Vue · Svelte · OpenAPI — with framework awareness (Next.js, Nuxt, SvelteKit, FastAPI, Django, Spring, Rails, Laravel, Symfony, SwiftUI, Jetpack Compose, WordPress, …)
40+
**Supported languages:** Go · JavaScript · TypeScript · Python · Java · Kotlin · Swift · Ruby · C · C++ · PHP · Vue · Svelte · OpenAPI · gRPC — with framework awareness (Next.js, Nuxt, SvelteKit, FastAPI, Django, Spring, Rails, Laravel, Symfony, SwiftUI, Jetpack Compose, WordPress, …)
4141

4242
---
4343

@@ -236,6 +236,7 @@ When you snapshot a *different* repo without `append`, enola assumes you're exte
236236
| C / C++ | `.c`/`.h` (tree-sitter-c) or `.cpp`/`.hpp`/… (tree-sitter-cpp), or `CMakeLists.txt`/`Makefile` + header (per-fact `language`, header/source method merging, namespaces, templates) |
237237
| PHP | `composer.json`, WordPress markers, or any `.php` source (WordPress / Laravel / Symfony route + outbound HTTP-client aware) |
238238
| OpenAPI | any spec with an `openapi:` / `swagger:` key |
239+
| gRPC | any `.proto` file (proto services → routes; TypeScript gRPC-web client calls detected) |
239240

240241
Framework- and platform-specific detection for each language is described in **[ARCHITECTURE.md → Supported languages](ARCHITECTURE.md#supported-languages)**.
241242

internal/cachecov/coverage_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ var versionCoverage = map[int][]string{
104104
70: {"TestExtractEndpointFacts_DefaultPrefix"}, // Swift endpoint version-prefix
105105
71: {"TestExtractStoredMethodEndpointFacts"}, // Swift stored-method endpoints
106106
72: {"TestWrapperEndpoint_PathAndVerbFromCallSite", "TestRoutes_NestedSingularResource"},// Swift request-wrapper + Ruby nested resources
107+
73: {"TestExtract_ServerRoutesPerRPC", "TestGRPCClient_OnlyCalledMethodsEmitted"}, // gRPC proto server routes + TS gRPC-web client routes
108+
74: {"TestGoGRPCClient_EmitsClientRoutes"}, // Go gRPC client call-site routes
109+
75: {"TestConnectES_CreateClient", "TestGoGRPCClient_ConnectGo"}, // connect-es + connect-go + struct-field-injected clients
110+
76: {"TestGRPCWebClient", "TestGoGRPCClient_PackageVar"}, // grpc-web clients + Go package-level-var clients
107111
}
108112

109113
func TestCacheVersionCoverage(t *testing.T) {

internal/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func Default() *Config {
8181
// reference-only extraction so the dead-code detector can see that a
8282
// production symbol is exercised by a test and not mis-report it as dead.
8383
TestGlobs: []string{"**/*_spec.rb", "**/*_test.rb"},
84-
Extractors: []string{"cpp", "go", "java", "kotlin", "openapi", "php", "python", "typescript", "swift", "ruby"},
84+
Extractors: []string{"cpp", "go", "grpc", "java", "kotlin", "openapi", "php", "python", "typescript", "swift", "ruby"},
8585
Explainers: []string{"cycles", "layers", "crossrepo", "coverage", "unused-routes", "god-class", "hotspots", "dependency-depth", "exported-surface", "complexity-outliers"},
8686
Renderers: []string{"llm_context"},
8787
Output: OutputConfig{

internal/engine/cache.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,24 @@ import (
142142
// call site's `urlPathComponent:` arg, verb from `method:`/`httpMethod:` or a type
143143
// default); Ruby extractor fixes nested Rails resource paths — a singular `resource`
144144
// gets no `:id`, and children of a plural `resources` nest under `:<singular>_id`.
145-
const cacheVersion = "v72"
145+
// v73: new gRPC extractor emits a server-role KindRoute per proto RPC (Name
146+
// "/pkg.Service/Method", method POST) plus service/rpc/message symbols; the TS
147+
// extractor detects gRPC-web client call sites as client-role routes
148+
// (source "ts-grpc-client"), so gRPC flows through the cross-repo linker and
149+
// unused-routes like HTTP.
150+
// v74: Go extractor detects gRPC client call sites (NewXxxClient(...) +
151+
// client.Method(...)) and emits client-role routes (source "go-grpc-client"),
152+
// resolving the wire path from the generated concrete client's Invoke/NewStream
153+
// literal. Documentation-only bump — goextractor is not a FileOwner, so its
154+
// facts are never cached; recorded for changelog continuity.
155+
// v75: broadened gRPC client detection — Go now resolves struct-field-injected
156+
// clients (via the field-type map) and connect-go (procedure-const paths); the
157+
// TypeScript extractor detects connect-es createClient/createPromiseClient(...)
158+
// call sites. Bump required because the TS extractor is a FileOwner (cached).
159+
// v76: classic grpc-web clients recognized (TS extractor derives service+methods
160+
// from MethodDescriptor/rpcCall path literals); Go extractor resolves gRPC
161+
// clients held in package-level vars. Bump required for the TS (FileOwner) change.
162+
const cacheVersion = "v76"
146163

147164
// extractorCache holds per-extractor facts keyed by a content hash of the files
148165
// the extractor depends on. It is loaded from disk at the start of a snapshot and

internal/engine/engine.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ func (e *Engine) GenerateSnapshot(ctx context.Context, repoPath string, appendMo
247247
tStage = time.Now()
248248
e.linkCrossRepo()
249249
e.flagUnmatchedRoutes()
250+
e.bindGRPCHandlers()
250251
tLink = time.Since(tStage)
251252

252253
// 3c. Build graph index for traversal queries

internal/engine/golden_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ var fixtures = []fixture{
5757
{name: "openapi_sample", subRepos: []string{"."}},
5858
{name: "multirepo", subRepos: []string{"repoA", "repoB"}},
5959
{name: "php_multirepo", subRepos: []string{"provider", "consumer"}},
60+
{name: "go_grpc_multirepo", subRepos: []string{"server", "client"}},
6061
}
6162

6263
func TestGolden(t *testing.T) {

internal/engine/grpcbind.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package engine
2+
3+
import (
4+
"log"
5+
"regexp"
6+
"strings"
7+
8+
"github.com/enola-labs/enola/internal/facts"
9+
)
10+
11+
// unimplementedEmbed matches the target of the `implements` edge a Go gRPC
12+
// server impl carries by embedding protoc-gen-go-grpc's forward-compat base
13+
// type — e.g. "usersv1.UnimplementedUserServiceServer" (optionally package- or
14+
// alias-qualified) — capturing the service short name ("UserService").
15+
var unimplementedEmbed = regexp.MustCompile(`^(?:.*\.)?Unimplemented(.+)Server$`)
16+
17+
// bindGRPCHandlers connects each gRPC server route (emitted from a .proto by the
18+
// grpc extractor) to the Go method that implements it, so route → handler is
19+
// traversable by impact_analysis and find_path.
20+
//
21+
// The bridge is the protoc-gen-go-grpc forward-compatibility convention: a
22+
// server impl embeds Unimplemented<Service>Server, which the Go extractor
23+
// already records as an `implements` edge on the impl struct. The embedded
24+
// type's short name ("UserService") equals the last segment of the route's
25+
// rpc_service ("users.v1.UserService"), so the route's rpc_method maps to the
26+
// struct's method symbol ("users.UserService.<Method>").
27+
//
28+
// It runs post-extraction (like flagUnmatchedRoutes) over the assembled store,
29+
// before BuildGraph, and is idempotent, so it recomputes safely on every
30+
// snapshot and append without any per-extractor cache involvement.
31+
func (e *Engine) bindGRPCHandlers() {
32+
symbols := e.store.ByKind(facts.KindSymbol)
33+
34+
// Per-repo index of service short name → impl struct symbol name, plus the
35+
// set of method symbol names (for existence checks). Both are scoped by repo
36+
// so a route only ever binds to a handler in its own repo.
37+
implMap := map[string]map[string]string{} // repo → shortName → struct name
38+
ambiguous := map[string]map[string]bool{} // repo → shortName → seen twice
39+
methodSet := map[string]map[string]bool{} // repo → method name → exists
40+
41+
for _, s := range symbols {
42+
kind, _ := s.Props["symbol_kind"].(string)
43+
switch kind {
44+
case facts.SymbolStruct:
45+
short := implShortName(s)
46+
if short == "" {
47+
continue
48+
}
49+
if implMap[s.Repo] == nil {
50+
implMap[s.Repo] = map[string]string{}
51+
ambiguous[s.Repo] = map[string]bool{}
52+
}
53+
if existing, ok := implMap[s.Repo][short]; ok && existing != s.Name {
54+
ambiguous[s.Repo][short] = true
55+
} else {
56+
implMap[s.Repo][short] = s.Name
57+
}
58+
case facts.SymbolMethod:
59+
if methodSet[s.Repo] == nil {
60+
methodSet[s.Repo] = map[string]bool{}
61+
}
62+
methodSet[s.Repo][s.Name] = true
63+
}
64+
}
65+
66+
bound := 0
67+
e.store.UpdateWhere(func(f *facts.Fact) {
68+
if f.Kind != facts.KindRoute || f.Props == nil {
69+
return
70+
}
71+
if f.Props["type"] != "grpc" || f.Props["role"] != "server" {
72+
return
73+
}
74+
short := lastDotSegment(propStr(f, "rpc_service"))
75+
method := propStr(f, "rpc_method")
76+
if short == "" || method == "" {
77+
return
78+
}
79+
if ambiguous[f.Repo][short] {
80+
return // two impls claim this service short name — don't guess
81+
}
82+
impl := implMap[f.Repo][short]
83+
if impl == "" {
84+
return
85+
}
86+
target := impl + "." + method
87+
if !methodSet[f.Repo][target] {
88+
return
89+
}
90+
if hasRelation(f, facts.RelHandledBy, target) {
91+
return // idempotent across appends
92+
}
93+
f.Relations = append(f.Relations, facts.Relation{Kind: facts.RelHandledBy, Target: target})
94+
f.Props["handler"] = target
95+
bound++
96+
})
97+
if bound > 0 {
98+
log.Printf("[engine] bound %d gRPC server route(s) to their Go handler", bound)
99+
}
100+
}
101+
102+
// implShortName returns the gRPC service short name a struct implements by
103+
// embedding Unimplemented<Service>Server, or "" if it embeds no such type.
104+
func implShortName(s facts.Fact) string {
105+
for _, r := range s.Relations {
106+
if r.Kind != facts.RelImplements {
107+
continue
108+
}
109+
if m := unimplementedEmbed.FindStringSubmatch(r.Target); m != nil {
110+
return m[1]
111+
}
112+
}
113+
return ""
114+
}
115+
116+
func hasRelation(f *facts.Fact, kind, target string) bool {
117+
for _, r := range f.Relations {
118+
if r.Kind == kind && r.Target == target {
119+
return true
120+
}
121+
}
122+
return false
123+
}
124+
125+
func propStr(f *facts.Fact, key string) string {
126+
if f.Props == nil {
127+
return ""
128+
}
129+
v, _ := f.Props[key].(string)
130+
return v
131+
}
132+
133+
// lastDotSegment returns the substring after the final '.', or the whole string
134+
// if there is none — turning a proto FQN "users.v1.UserService" into the service
135+
// short name "UserService".
136+
func lastDotSegment(s string) string {
137+
if i := strings.LastIndex(s, "."); i >= 0 {
138+
return s[i+1:]
139+
}
140+
return s
141+
}

0 commit comments

Comments
 (0)