Skip to content

feat(proto): link a .proto declaration to the code generated from it - #1660

Open
ferrine wants to merge 5 commits into
colbymchenry:mainfrom
ferrine:feat/protobuf-contract-resolver
Open

feat(proto): link a .proto declaration to the code generated from it#1660
ferrine wants to merge 5 commits into
colbymchenry:mainfrom
ferrine:feat/protobuf-contract-resolver

Conversation

@ferrine

@ferrine ferrine commented Aug 31, 2026

Copy link
Copy Markdown

Branch: ferrine:feat/protobuf-contract-resolvermain
Stacked on: feat/protobuf-language (PR #4).
Size: +644 over #4 · Tests: __tests__/protobuf-contract-synthesizer.test.ts (14 new)

Why

One .proto field is implemented again in every language a project generates
for, every one of those sites is machine-written, and the .proto is the only
place the shape is authored. Without an edge between them, a whole family of
defects has nowhere to show up — a field decoded on one side but never read on
another; a field whose meaning changed while its name and tag did not; a field
the server stopped sending that a client still declares. Each is green in every
single-language check and wrong at runtime.

What changed

New synthesis pass (src/resolution/protobuf-contract-synthesizer.ts), gated on
the project having protos at all.

Discovery is by convention, not configuration. Every generator names its
output after the .proto it came from (foo_pb2.py, foo.pb.ex, foo.pb.go,
foo_pb.ts), and those conventions are published and stable.

Precision comes from a peer-file gate. A field named id is matched only
inside the generated outputs of its own proto, never repo-wide — that scoping
is what makes matching a name as common as id safe. A proto with no generated
peers produces nothing.

Edge direction is generated → proto: generated code is derived from the
contract, so it is the dependent, and that is the direction impact analysis
traverses. Emitted the other way the relationship is recorded but "what else
moves when I change this field" stays unanswered.

Each edge carries match: 'symbol' | 'declaring-type' so a consumer can tell an
exact peer from the coarser fallback, plus the tag and the generated language.

Two fixes folded in, both found on a real multi-language repo

  • Qualified declaration names. Generators for languages with dotted module
    names emit the full name as the declaration's own name — protobuf-elixir
    writes defmodule Acme.V1.Interval, so the node is Acme.V1.Interval while
    the message is Interval. Matching the simple name only made every such peer
    invisible, and silently: another language's generator emitting a bare name
    links normally, so it looks like a working feature with one generator simply
    absent. Candidates are now also indexed under their trailing segment.
  • The declaring-type fallback is decided per target language. Whether a
    generator emits a symbol per field is a fact about that generator —
    protoc-gen-js writes an accessor per field, protoc's Python stubs write only a
    class annotation, which is not extracted as a node. Deciding the fallback once
    for the whole declaration let any language that resolved precisely suppress
    the fallback for every language that could not, and those languages then
    contributed no edge at all. The failure is silent and inverted: improving one
    language's extraction deleted the coarser coverage of the others. There is a
    regression test that fails with expected undefined on the old code.

Notes for review

A no-op on any repo without .proto files, and on a repo with protos but no
generated peers.

ferrine and others added 5 commits August 30, 2026 21:15
`callers`, `callees` and `impact` each carried their own symbol filter,
comparing the user's query against the BARE node name only:

    node.name === symbol || node.name.endsWith('.' + symbol)

That fails in two opposite directions in the same repository.

A bare name OVER-reports. Every same-named definition passes the filter
and their results are unioned under one heading — "Callers of group" can
list callers that belong to an entirely different `group`, with nothing
saying the name was ambiguous. Collisions cluster on short generic names
(`group`, `num`, `parse`), so this bites hardest exactly where the verbs
would otherwise be most useful.

A qualified name UNDER-reports. `Foo.Bar.baz` can never equal a bare
`baz`, so every candidate fails the filter, and the guarded fallback
takes whichever node full-text search ranked first — or reports "not
found" for a symbol that plainly exists. It only ever appeared to work
when FTS happened to return exactly one hit.

All three now resolve through graph/symbol-lookup, which the MCP tools
share, so a verb cannot drift from the matcher again:

- the exact-name index is consulted first and is authoritative. It is
  complete and uncapped, whereas FTS ranks, truncates, and tokenises
  `::` away — so resolution no longer depends on search ranking. FTS
  stays as the fallback for the fuzzy cases it is good at.
- `matchesSymbol` gains a boundary-aligned suffix match under a
  canonical separator. Splitting on every separator assumes no scope
  component contains one, which is false for any language whose module
  names are themselves dotted: the stored `A.B::c` can never equal the
  split-and-rejoined `A::B::c`, so a precise query resolved to nothing.
- an ambiguous bare name still aggregates (an interface method and its
  overrides are usually all wanted) but the union is now disclosed, with
  the matched definitions named and a qualified spelling that narrows
  it. `--json` gains a `targets` array and an `ambiguous` flag.

`matchesSymbol` moves out of mcp/tools.ts unchanged apart from the new
stage; the tool path delegates to it, so its existing coverage applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
A `.proto` is the contract layer of a polyglot repository: one field is
implemented again in every generated language, each of those sites is
machine-written and must never be hand-edited, and none of it was
visible to the graph. Indexing it makes a field a symbol you can ask
questions about — and makes "what else moves when this changes"
answerable from the one place the answer is actually written down.

Extracted: messages (including nested), enums and their values, fields,
`oneof` members, `map` types, services, `rpc`s (including `stream`),
imports, and `reserved`. Names are protobuf's own fully-qualified names
(`acme.reporting.v1.Measurement.observed_at`), which is both what a user
would type and what a generator uses. Field types and rpc request /
response messages become `references`, so `callers` on a message lists
the fields and RPCs that depend on it.

Two properties are modelled on purpose, because they are where
protobuf's real defects live and neither survives a naive extraction:

- THE TAG NUMBER IS PART OF A FIELD'S IDENTITY. Renaming a field at the
  same tag is wire-compatible; changing its TYPE at the same tag and
  name is a silent mis-decode that every single-language check passes.
  The tag is recorded as a marker, not just left in prose, so a check
  can read it without re-parsing the declaration.
- `reserved` IS SEMANTIC. A retired number must never be re-used and a
  reader touching a reserved field is a defect, so reservations —
  numbers, ranges and names — are kept as symbols rather than discarded
  as syntax. They are deliberately not `field` nodes, so a reservation
  can never be mistaken for a live field.

Implemented as a standalone scanner (proto-extractor.ts) rather than a
vendored grammar, following the Liquid / Razor / MyBatis precedent. The
IDL is small and effectively frozen, so the usual reason to want a
grammar — tracking an evolving syntax surface — does not apply, and it
avoids shipping another megabyte of wasm whose silent absence is its own
failure mode. Comments are blanked offset-preserving before scanning,
skipping string literals so a URL in an option value is not mistaken for
a comment.

Verified against upstream protos: descriptor.proto (proto2, groups,
extensions, 17 reserved statements), pubsub.proto, struct.proto,
timestamp.proto and grpc health.proto all parse with zero errors, and
the message / enum / service / rpc counts match the files exactly.

Depends on the qualified-lookup fix: without it a protobuf FQN is a
dotted container name, which the old matcher could not match at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
A `.proto` is authored once and implemented again in every language the
project generates for. Each of those sites is machine-written and must
never be hand-edited, which makes the contract the only place the shared
shape exists — and, with no edges, the only place with no link to
anything that implements it.

That gap holds a family of defects that every tier's own gate passes by
construction: a field decoded on one side but never read on another; a
field whose meaning changed while its name and tag did not; a field the
server stopped sending that a client still declares.

Synthesis pass, gated on the project having protos:

- a message / enum / service links to its generated type in each target
  language;
- an `rpc` links to the GENERATED METHOD itself — every generator emits
  one, so this resolves at member level;
- a field links to the generated type that declares it. Go struct
  fields, Python class annotations and TypeScript interface members are
  all deliberately not extracted as nodes (member-dense code would
  explode the graph), so there is no member-level peer to match. The
  declaring type is coarser but true — regenerating it IS what the
  change requires — and containment does not rescue the field either:
  dependents traverse INCOMING edges while `contains` points message →
  field, so a field's impact never climbs to its message. The match kind
  is recorded in the edge so the two are distinguishable.

Edges point GENERATED → PROTO. Generated code is the dependent, and that
is the direction impact analysis reads; emitted the other way the
relationship is recorded but the question stays unanswered.

Discovery is by convention, never configuration — every generator names
its output after the proto (`foo_pb2.py`, `foo.pb.ex`, `foo.pb.go`,
`foo_pb.ts`), and those conventions are published and stable. Precision
comes from the peer-file join: a field named `id` is matched only inside
the generated outputs of its OWN proto, never repo-wide, which is what
makes a name that common safe to match at all. A file without a
generator marker in its name is never treated as generated output, so
hand-written code sitting beside a proto is not linked.

Also maps `.pyi`, which was not indexed at all. Type stubs are real
checked-in API surface, and for protobuf they are the only place
per-field Python declarations exist — a modern `_pb2.py` is a serialized
descriptor blob with no per-field symbols.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
Generators for languages with dotted module names emit the FULL name as
the declaration's own name — protobuf-elixir writes
`defmodule Acme.V1.Interval`, so the node is named `Acme.V1.Interval`
while the proto message it implements is `Interval`. The peer index only
held each candidate under spellings of its own name, so a simple-name
match never reached it and every such peer was invisible.

Silently, too: a target language whose generator emits a bare name links
normally, so the result looks like a working feature with one language's
generator simply absent from the output rather than a matching bug. On a
polyglot repo generating from the same protos into both a bare-name and a
qualified-name language, roughly a third of the expected contract edges
were missing on that basis alone.

Index each candidate under its trailing segment as well as its own name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
Whether a generator emits a symbol for each field is a fact about THAT
generator — protoc-gen-js writes an accessor per field, protoc's Python
stubs write only a class annotation that is not extracted as a node. The
fallback to a member's declaring type was decided once for the whole
declaration, across every language at once, so any language that DID
resolve precisely suppressed the fallback for every language that could
not, and those languages contributed no edge at all.

The failure is silent and inverted: making one language's extraction
better DELETES the coarser coverage of the others. Seen when Elixir's
generated modules started yielding per-field symbols — every Python
declaring-type edge in the graph disappeared in the same re-index, with
nothing reporting it.

Peers are now indexed and matched per target language, so each one falls
back on its own evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant