tensor-grep: adding a language to the symbol graph
The registration checklist for extending tensor-grep's tree-sitter symbol graph
(defs/refs/callers/blast-radius/tg source/tg imports) to a new language.
Ground-truthed directly against src/tensor_grep/cli/lang_registry.py (full file),
lang_go.py, lang_php.py, and lang_csharp.py (the three shipped module-shaped
languages), and src/tensor_grep/cli/repo_map.py's real dispatch sites — not from
memory or the session ledger alone. Sibling of tensor-grep-architecture-contract,
scoped to one subsystem (the symbol-graph tier), not the front door / routing /
backend contract.
When to use this skill vs. a sibling
| You are about to… | Use |
|---|---|
| Add/extend the symbol graph for a language (this skill) | you are here |
| Understand the front door, routing, or the Backend Fail-Closed Contract for search itself | tensor-grep-architecture-contract |
| Land the change safely (registration gates, one-merge-per-tick, dogfood) | tensor-grep-change-control |
| Adversarially check an AI-drafted add-language plan against real code before dispatch | verify-plan-against-code (global skill) |
| Debug a live "no symbols found" / wrong-result report for an already-supported language | tensor-grep-debugging-playbook |
| Find a hot-path lever and prove an optimization byte-identical (not language-specific) | profile-guided-byte-identical-optimization (global skill) |
Drain several language PRs that all touch test_lang_registry.py / uv.lock / the pyproject ast extra |
tensor-grep-change-control's Campaign Orchestration cross-ref (AGENTS.md A22) |
Use tg as a consumer (search/orient/callers flags) |
code-search-and-retrieval-reference |
Current status (verified against tg v1.110.14, origin/main @ a6242bb)
repo_map.py currently carries 10 lang_registry.register_language(...) call sites
(grep -n "register_language(" src/tensor_grep/cli/repo_map.py): python, javascript,
typescript, rust (the original four, inline in repo_map.py), plus go, java,
php, csharp, c, and cpp — confirmed via language_id= greps and
tests/unit/test_lang_registry.py::test_language_registry_has_exactly_the_stage2_languages's
literal set-pin (which now includes "c" and "cpp"). All top-10 languages are now
registered — the "C/C++ deferred" framing below this line in earlier passes of this skill is
STALE; C landed via PR #731 (v1.97.0) and C++ via PR #732 (v1.98.0), each a self-contained
module (lang_c.py/lang_cpp.py, mirroring lang_go.py's shape), both at the same
foundational tier as Java/PHP/C# (defs/imports only, no references_and_calls). A
follow-up C fix (#736, v1.98.2) corrected a file-scope function-pointer-variable mis-kind -
see B5's declarator-shape addendum below before writing similar C/C++ declarator-walking
logic. SUPERSEDED 2026-08-04 by Task 10E (C++, the final wave of the top-10 language-support
campaign): the "foundational tier" claim in the paragraph above is now STALE for every language
named in it. Java (10A), C# (10B), PHP (10C), C (10D), and now C++ (10E) all carry a real
references_and_calls extractor; repo_map._symbol_navigation_descriptor() reports
10 parser-backed / 0 foundational — the foundational tier is EMPTY. lang_cpp.py's new
cpp_references_and_calls extends C's bare-identifier-call confirmation with three C++-only
shapes (qualified calls Foo::bar(), explicit this->method(), and new Widget() as a
ref_kind="constructor" reference) but DELIBERATELY does not attempt Java/C#/PHP-style
general receiver-type confirmation (w.method(), p->method()) — C++'s real inheritance and
auto make that walk unsound for the common case; see lang_cpp.py's own "TASK 10E CALL/
ACCESS NODE SHAPES" / "RESOLUTION CONFIDENCE" docstring block for the full reasoning. Re-run
the one-liner rather than trust this paragraph either.
SUPERSEDED (append-only, do not edit the paragraph above) - 2026-08-11, Task 10E
final wave: C and C++ are now PARSER-BACKED too; _symbol_navigation_descriptor() returns
10 parser-backed (c, cpp, csharp, go, java, javascript, php, python, rust, typescript)
and the foundational tier is EMPTY. The "same foundational tier as Java/PHP/C#" claim
above is accurate-as-dated (v1.98.x) history; the tier split has since moved to 10/0 and is
pinned by tests/unit/test_lang_registry.py. Re-run the grep above before trusting any
"N of top-10" count - it is a snapshot, not a promise; this count has changed on every pass
of this skill so far.
(2026-08-12 maintenance note: the two SUPERSEDED blocks above were reordered on this date to restore append-only chronology — the 2026-08-11 entry had been placed ABOVE the 2026-08-04 entry despite the append-only (newest-last) instruction. Both blocks describe the same 10/0 terminal state; all dated content was preserved verbatim, only the order changed: oldest first, newest last.)
The tiered language model (unchanged shape, re-verify the coverage numbers):
| Tier | Scope | Mechanism | Coverage |
|---|---|---|---|
| Text search | any file | rg passthrough (bootstrap front door) | universal |
| Structural scan/rewrite | many languages | ast-grep, which tg wraps (tg ast-info, tg run) |
~26 langs (ast-grep's own list) |
| Symbol graph (this skill) | tree-sitter grammars in lang_registry |
defs/refs/callers/blast-radius/tg source/tg imports |
10 of 10 top-10 languages live on main (Python/JS/TS/Java/C#/C++/C/Go/Rust/PHP) |
Positioning: tg = rg (text) + ast-grep (structural) + this symbol/retrieval/capsule layer —
"not faster grep" (mirrors tensor-grep-architecture-contract's moat framing). Top-10
ranking (TIOBE Jul-2026 + Stack Overflow 2025 + GitHub Octoverse 2025 consensus): Python,
JavaScript, TypeScript, Java, C#, C++, C, Go, Rust, PHP.
B1 — the pattern: register_language + a lang_<x>.py module
The current, correct pattern for a NEW language is a self-contained
src/tensor_grep/cli/lang_<x>.py module (clone lang_go.py) that ends in a
lang_registry.register_language(LanguageSpec(...)) call from repo_map.py. This is
not the inline _rust_* / _parser_for_source_suffix machinery still visible in
repo_map.py for Rust and Python — that style predates the registry (Stage 0's pure-parity
refactor wrapped it, it did not replace it). Java is the one exception that used
inline-in-repo_map.py (_java_imports_and_symbols etc. — grep -n "^def _java_imports_and_symbols" src/tensor_grep/cli/repo_map.py, was :4782, now :4783) and still
registers through lang_registry — both shapes are contract-consistent, but the module
shape is what Go, PHP, and C# (the three most recent additions) all converged on, and is
what lang_go.py's own docstring recommends: it keeps repo_map.py from growing further.
One-directional import rule (stated in both lang_registry.py's module docstring — grep -n "never the reverse" src/tensor_grep/cli/lang_registry.py, was :10-12, now :10-11 — and
lang_go.py:9-15):
repo_map.py → lang_<x>.py, never the reverse. A helper the new module needs that
repo_map.py already has must be duplicated locally (see lang_go.py:44-87's (was cited
:37-87, re-grep grep -n "Duplicated tiny helpers" to relocate the block if this drifts again)
byte-identical-to-repo_map.py tiny helpers), not imported — importing back creates a
cycle.
LanguageSpec (grep -n "class LanguageSpec" src/tensor_grep/cli/lang_registry.py — was
:67-111, now :72-119; frozen dataclass) is the single contract. Fields
worth knowing before writing one:
| Field | Status | Note |
|---|---|---|
language_id, suffixes |
wired, required | e.g. "go", frozenset({".go"}) |
parser_for_path |
wired | returns the parser or None if the grammar package isn't installed — the fail-closed gate |
provenance_when_missing |
wired, default "regex-heuristic" |
a language with no regex fallback (every language after the original four) must override this to "grammar-missing" — see B3 |
extract_imports_and_symbols, references_and_calls, provider_alias_calls, file_imports_symbol_from_definition, import_update_target |
wired | any of these left None = an honestly-deferred capability, not a bug — see PHP's precedent in B3 |
prime_repo_context |
wired | None if the language has no per-repo workspace state to prime (tsconfig/go.mod-style) |
def_node_kinds, classify_ref_kind |
doc-only in Stage 0 | no dispatch seam reads these yet — populate for self-documentation, do not assume they are wired |
register_language() is idempotent (grep -n "def register_language" src/tensor_grep/cli/lang_registry.py — was :118-128, now :126-136) — re-registering the same
language_id replaces the entry and re-derives every suffix pointer, so a stale mapping
never survives a reload. LANGUAGE_REGISTRY starts empty (grep -n "^LANGUAGE_REGISTRY" src/tensor_grep/cli/lang_registry.py — was :114, now :122) until whatever module
calls register_language(...) is imported — a bare import lang_registry with no
import repo_map gets an empty dict (see "Fast self-check" below).
B2 — the critical seams (miss silent half-integration)
Enumerate every seam lang_go.py touches and hit all of them. These are re-verified
repo_map.py locations on v1.96.1-pending (re-grepped fresh after PR #728 inserted a 16-line
go/php/csharp dispatch block inside _imports_with_lines_for_path, shifting every seam below
it by +16 — except build_file_imports, which shifted +41, because a 12-line frozenset
addition and a 13-line _resolve_raw_import_entry branch both land between it and seam 6; see
B2's worked example below) — re-grep the symbol before trusting the line number on a later
version (main.py/repo_map.py churn every release):
| # | Seam | Location | Feeds | Miss-it symptom |
|---|---|---|---|---|
| 1 | lang_registry.register_language(LanguageSpec(...)) |
repo_map.py (10 call sites as of this pass — was reported "8" in an earlier pass of this table, already stale then; re-derive, don't trust either number: grep -c "register_language(" src/tensor_grep/cli/repo_map.py) |
wiring the suffix at all | new suffix never resolves; silently excluded everywhere |
| 2 | _imports_and_symbols_for_path |
repo_map.py — grep -n "^def _imports_and_symbols_for_path" src/tensor_grep/cli/repo_map.py (was :6626, now :6627) |
symbol/def extraction dispatch | new language absent from defs/symbols |
| 3 | _imports_with_lines_for_path |
repo_map.py — grep -n "^def _imports_with_lines_for_path" src/tensor_grep/cli/repo_map.py (was :6831, now :6832) |
tg imports (line-numbered import entries) |
tg imports silently empty even though defs exist |
| 4 | build_symbol_source_from_map |
repo_map.py — grep -n "^def build_symbol_source_from_map" src/tensor_grep/cli/repo_map.py (was :16309, now :16326) |
tg source |
tg source returns nothing for a real symbol |
| 5a | _target_language_for_path — MOST-FORGOTTEN |
repo_map.py — grep -n "^def _target_language_for_path" src/tensor_grep/cli/repo_map.py (was :7850, now :7867) |
tg agent capsule's primary_target_language / confidence gate |
a target file in the new language does not filter a mismatched-language validation suggestion |
| 5b | _provider_language_for_path — a SIBLING seam, easy to miss because 5a's own comments never mention it |
repo_map.py — grep -n "^def _provider_language_for_path" src/tensor_grep/cli/repo_map.py (was :15192, now :15209) |
the LSP-provider language dispatch (sits just above _path_from_lsp_file_uri/_lsp_symbol_kind_name — a DIFFERENT purpose than 5a's symbol-graph capsule gate, but it must resolve the SAME language_id for any suffix a LanguageSpec registers) |
test_target_and_provider_language_agree_with_registry (below) fails loudly for the new suffix; less obviously, an LSP-provider code path silently disagrees with the symbol graph about what language a file is |
| 6 | _SUPPORTED_FILE_DEPENDENCY_LANGUAGES |
repo_map.py — grep -n '_SUPPORTED_FILE_DEPENDENCY_LANGUAGES\s*=' src/tensor_grep/cli/repo_map.py (was :17131, now :17148) |
gates whether tg imports/tg importers even attempts dependency resolution |
file-dependency graph silently (but honestly, see B3) excludes the language |
Seam 5b is easy to miss precisely because seam 5a's own code comments never mention it —
unlike every other seam in this table, nothing in _target_language_for_path points you at
_provider_language_for_path. The two functions serve different callers (5a feeds the agent
capsule's confidence gate; 5b feeds the LSP-provider dispatch, e.g. clangd-via-LSP for a
language whose symbol-graph tier is only foundational or absent) but both must return the
SAME language_id for every suffix a LanguageSpec registers, or the dynamic parity test
below fails. _provider_language_for_path sometimes ALREADY recognizes a suffix before its
LanguageSpec is registered (a latent pre-wiring, not a bug) — e.g. it independently maps
.c/.cc/.cpp/.cxx/.h/.hh/.hpp/.hxx to "c"/"cpp" for the LSP provider even
before a C or C++ LanguageSpec exists — which means the CHOICE of language_id for a new
language is not always free: check _provider_language_for_path for an existing mapping
BEFORE naming your new LanguageSpec.language_id, or the two functions will disagree the
moment you register it.
Seam 5a is not a hypothesis — the live code says so in its own comments. Reading
_target_language_for_path on main today:
if suffix == ".go":
# MOST-FORGOTTEN seam (PATH A Stage 1 design note): without this, the capsule's
# query-language-vs-target-language 0.55 confidence cap (agent_capsule.py) never even
# sees "go" as a candidate target language...
return "go"
...
if suffix in _JAVA_SUFFIXES:
# Same MOST-FORGOTTEN seam, Stage 2: without this, `tg agent`'s capsule never reports
# primary_target_language == "java" for a Java target.
return "java"
if suffix == ".php":
# MOST-FORGOTTEN seam (see the ".go" branch above) -- same fix, same reason...
return "php"
Seam 5b has NO equivalent per-branch comment on main today — it is a plain suffix
dispatch (repo_map.py:14711-14739) with no "MOST-FORGOTTEN"-style warning attached to any
of its branches, which is exactly why it is the one this skill itself omitted until this
pass: nothing in the code nudges you toward it the way seam 5a's comments do.
Worked example, from PR #728, SUPERSEDED by the top-10 C/C++ campaign — seam 6 was closed
for go/php/csharp at the FOUNDATIONAL tier first, then C/C++ joined the same frozenset later;
re-read this before assuming "in the frozenset" means "fully working," and re-derive the
member count rather than trusting either number below. _SUPPORTED_FILE_DEPENDENCY_LANGUAGES
(grep -n '_SUPPORTED_FILE_DEPENDENCY_LANGUAGES\s*=' src/tensor_grep/cli/repo_map.py — was
:17131, now :17148) on main today is frozenset({"python", "javascript", "typescript", "rust", "java", "go", "php", "csharp", "c", "cpp"}) — all 10 registered languages are now
members (this table used to say "all 8" right after #728 landed C/C++ hadn't joined yet; a
later "Top-10 language campaign" commit added "c"/"cpp" to the same frozenset with its own
inline comment — re-count with the grep above, don't carry either "8" or "10" forward without
checking). PR #728 shipped three new per-language extractors — lang_go.go_imports_with_lines,
lang_php.php_imports_with_lines, lang_csharp.csharp_imports_with_lines — dispatched from
_imports_with_lines_for_path (grep -n "^def _imports_with_lines_for_path" src/tensor_grep/cli/repo_map.py
— was :6831, now :6832); each walks the same node kind its *_imports_and_symbols sibling
already walks (import_spec / namespace_use_clause / using_directive respectively) and
emits one {"module": ..., "line": ...} row per statement. tg imports on a .go/.php/.cs
file no longer reports result_incomplete with an empty list the way it did before this PR —
it returns real, line-numbered rows.
But resolution — WHICH file/module each row's module string actually points to — is
still deferred for all five (go/php/csharp/c/cpp), and it is honestly deferred, never silently
faked. _resolve_raw_import_entry (grep -n "^def _resolve_raw_import_entry" src/tensor_grep/cli/repo_map.py
— was :17160, now :17177) carries an elif language_id in ("go", "php", "csharp", "c", "cpp") branch (re-grep elif language_id in ( in repo_map.py; currently :17246, mirroring
the elif language_id == "java" branch immediately above it, currently :17237-17245 — both
numbers already superseded twice across this skill's re-verify passes, re-grep rather than
trusting either) that always returns resolved, external, provenance, confidence = None, False, [], 0.0 — every row comes back resolved=None, external=False rather than a fabricated file
path or a fabricated external=True. Each language is missing different resolver machinery:
Go's own _go_import_path_to_dir (lang_go.py) already resolves an import path to a package
directory, not a single file — a Go import names a package that can span many .go files
with no 1:1 import-to-file mapping, so picking "the" file needs new design, not just wiring
existing code; PHP has no PSR-4/composer.json autoload-map reader; C# has no .csproj/
assembly-reference map; C/C++ have no standardized manifest at all (no
go.mod/composer.json/.csproj equivalent). None of that resolver machinery is built yet — see
docs/BACKLOG.md for the exact per-language scope still open. The fail-closed contract (B3)
still fires exactly as before for any language genuinely outside this frozenset:
build_file_imports (grep -n "^def build_file_imports" src/tensor_grep/cli/repo_map.py — was
:17271, now :17288) sets result_incomplete=True with
incomplete_reason=f"'{language_id}' has no import-resolution support in \tg imports` yet"for any registered-but-unsupported language, and_imports_with_lines_for_path's own docstring (inside the function body, grep -n "unsupported language (e.g. Kotlin)" src/tensor_grep/cli/repo_map.py— was cited:6440, now :6835`) names Kotlin as its worked example of one — go/php/csharp/c/cpp
just are not examples of it anymore.
A second, separate gate stays narrower still, and closing seam 6 does not close it too.
_confirm_import_edges (grep -n "^def _confirm_import_edges" src/tensor_grep/cli/repo_map.py
— was :17350, now :17367; the tg importers reverse-confirm step that turns a prefiltered
"maybe imports it" into a confirmed edge) has its own independent language allow-list —
if language_id not in ("javascript", "typescript", "rust", "python"): return [] (re-verified
unchanged this pass) — which still excludes java, go, php, csharp, c, AND cpp alike. Membership
in _SUPPORTED_FILE_DEPENDENCY_LANGUAGES does not imply membership in this second, stricter
gate; a future PR that builds true forward resolution for go/php/csharp/c/cpp still would not
make tg importers's reverse-confirm step cover them without touching this allow-list too. This
is the same "forgot a seam but the honesty floor caught it" lesson as before, one tier deeper:
even a foundational landing must decide, per emitted row, whether to fabricate confidence it
doesn't have — every landing so far has chosen not to, matching Java's (#725) precedent exactly.
True forward resolution for go/php/csharp/c/cpp (and then extending
_confirm_import_edges's allow-list) remains a good next PR for whoever reads this skill next.
Two more seams exist beyond this table, found by reading lang_go.py itself rather than
the ledger (not independently re-grepped against repo_map.py's call sites this pass —
verify before citing a line number): (7) the per-language dispatch arms that call
references_and_calls / file_imports_symbol_from_definition directly, which feed
tg callers/tg blast-radius; (8) clear_<lang>_repo_context_cache (lang_go.py:449)
wired into the daemon-refresh sweep, so tg session refresh doesn't serve stale
import-resolution context after a repo change.
B3 — fail-closed contract, extended per-language
- Override
provenance_when_missing. The registry default is"regex-heuristic"(grep -n "provenance_when_missing: str" src/tensor_grep/cli/lang_registry.py— was:89, now:94) — true for the original JS/TS/Rust languages, which have a real regex fallback. Every language shipped since (Go, PHP) has no regex fallback and explicitly setsprovenance_when_missing="grammar-missing"in itsLanguageSpec(...)call. Skipping this override makes a grammar-absent file for the new language read as "zero symbols found" instead of a genuineresolution_gapsentry — a silent lie by omission (lang_go.py:17-24). - A
Nonecallable field is an honest deferral, not a bug — PHP is the shipped precedent.lang_php.py's own docstring states its Stage 1 landing is "deliberately narrower than Go's": it implementsextract_imports_and_symbols+parser_symbol_sourcesonly, and registersreferences_and_calls,file_imports_symbol_from_definition,import_update_target, andprime_repo_contextall asNone.repo_map.py's_language_coverage_gaps_for_universealready treatsimport_update_target is Noneas aresolution_gapsentry — sotg callers/tg blast-radiusstay honest about PHP's current lack of reverse-import resolution instead of reading as a proven zero. You do not have to land every seam in one PR — land a real, honestly-labeled subset, exactly like PHP did. - Every extractor function returns the empty shape (
[]/([], [])), never raises, when the grammar is missing (every public function inlang_go.pystarts withparser = _go_parser(); if parser is None: return <empty>). - Symbol-kind vocabulary — emit the language's own, do not pre-collapse. Each module
emits its native kind strings (Go:
"function"/"method"/"struct"/"interface"/"const"/"var"/"type"). The FULL def-node vocabulary for Go is_GO_DEF_NODE_KINDS(grep -n "_GO_DEF_NODE_KINDS" src/tensor_grep/cli/lang_go.py— this spot was cited aslang_go.py:110-113, which is only_GO_TYPE_SPEC_KIND_BY_TYPE_FIELD, the struct/interface sub-mapping applied INSIDE atype_spec; the full set now lives at:117-123, with the kind emission sites at:290(const/var),:397(function),:400(method), and thetype_specbranch defaulting to"type"). A later normalization layer (not independently re-verified this pass — presumably inrepo_map.py) is what the ledger records as the cross-language collapse: class/interface/struct/enum/record/trait →"class"; method/constructor/function →"function". Emit the real vocabulary in the new module; re-verify where the collapse actually happens before assuming its exact shape. resolution_confidencebanding is the same fail-closed principle per-match.go_references_and_calls(lang_go.py:711) bands 0.95 for a confirmed resolution (resolution_provenance=["go-import-resolution"]) vs. 0.7"receiver-heuristic"for a textually-plausible-but-statically-unconfirmed one (lang_go.py:820) — an unconfirmed match is demoted, never dropped. This is the per-match instance of the Backend Fail-Closed Contract: never fabricate certainty.
B4 — verify the plan against current code before dispatch
A real onboarding brief this session said "mirror inline _rust_*" — stale, because the
repo had already grown lang_registry.py and the module pattern since that mental model
formed. All three build agents that received the brief independently caught it via the
verify-plan-against-code discipline before writing code, and corrected to the module
shape. Rule: before dispatching an add-a-language plan — to a subagent, codex, cursor, or
your own future self — re-read lang_registry.py plus the most recently added lang_<x>.py
sibling fresh. Do not trust a memory, an old skill snapshot (including this one — see the
"Fast self-check" below), or a prior session's summary about which shape is current.
B5 — live-verify grammar node shapes before writing extraction logic
Do not guess a node shape from documentation, another language's grammar, or intuition —
dump the real parse tree. lang_go.py shipped with at least three node-shape surprises
found exactly this way, each pinned by an inline F<n> fix comment — worked, re-verified
proof this step cannot be skipped:
- Generic receiver type nesting (
lang_go.py:126-159, F8 fix):func (r *MyType[T]) M()parses the receiver's type as ageneric_typenode whose raw text is"MyType[T]"— never matching the plain"MyType"atype_specdeclares, unless you descend intogeneric_type's owntypefield. - Grammar-version-dependent content node (
lang_go.py:162-189, F11 fix): a recenttree_sitter_goexposesinterpreted_string_literal_contentas a child of an import path; an older/differently-built grammar can omit it — silently zeroing out every import in the file with no error and noresolution_gapsentry (the parser loaded fine, so nothing marks a gap). Fix: fall back to quote-stripping the raw node text. - Row-counting divergence (
lang_go.py:766, the row-counting fix -- cite this by LINE, not by F-tag:F26is reused across at least 5 sites inlang_go.py, so an F-tag grep lands on a different fix): tree-sitter's row index advances only on"\n"; naive Python line-splitting also splits on other separators — one stray separator shifts every later line lookup out of alignment with tree-sitter's own rows unless you count rows the same way tree-sitter does.
None of these were guessable from a grammar README. Parse real (or minimal handwritten)
source covering every construct you plan to extract through the target tree_sitter_<lang>
package directly, and print node.type/node.children recursively, before writing
extraction logic.
A fourth, independently-verified example (PR #726 merged mid-authoring-pass — re-checked
against the real file rather than left as a secondhand ledger note): C#'s aliased using
directive. using MyAlias = System.Text.StringBuilder; parses with the alias identifier
emitted first (leftmost child) and the actual target namespace last (rightmost
child) — the reverse of what you might guess. _csharp_using_directive_target
(lang_csharp.py:138-150) handles all four using forms (plain, dotted, aliased,
static/global-qualified) with one rule: take the last matching
identifier/qualified_name child, never the first — verified against the installed
tree_sitter_c_sharp 0.23.x grammar for all four forms (lang_csharp.py:113-124's own
comment table). Getting this backwards would record every aliased import as its local
alias name instead of the namespace actually being imported.
A fifth example, and the most important one for C-family declarator walkers (PR #736, v1.98.2):
a "seems obviously right" tell was FALSIFIED by the live AST, not confirmed by it. lang_c.py's
_c_declarator_name_node walks a declarator chain to decide seen_function: bool — "did the chain
pass through a function_declarator?" A banked note from an earlier session proposed the fix as
"require function_declarator to be OUTERMOST" to exclude a file-scope function-pointer VARIABLE
(void (*handler)(int);, which this module deliberately must NOT report as a function). Dumping the
real tree_sitter_c 0.24.2 AST before writing any code showed that hypothesis was simply wrong: a
function-pointer variable's declarator chain ALSO has function_declarator outermost — identical in
that one respect to a real prototype void f(int);. "Outermost" is not the discriminator at all. The
real tell lives one level deeper, in what that function_declarator node's OWN declarator field
WRAPS:
| Shape | function_declarator's own declarator field |
|---|---|
void f(int); (real prototype) |
bare identifier |
int *make_ptr(void); (returns pointer) |
bare identifier, one hop under a pointer_declarator |
void (*handler)(int); (fn-ptr VARIABLE — exclude) |
parenthesized_declarator wrapping a pointer_declarator |
int (foo)(void); (redundant-paren real prototype — keep, gate-caught refinement) |
parenthesized_declarator wrapping a bare identifier directly |
A parenthesized_declarator hop alone is not the signal — a real function can be wrapped in
meaningless redundant parens too (row 4); what matters is whether the parens wrap a bare name (real
function) or a pointer declarator (variable). lang_cpp.py had its OWN, independently-written
declarator walker (_cpp_declarator_name_node) with the same latent bug shape — this WAS fixed
by PR #737 (confirmed landed: git log --oneline -- src/tensor_grep/cli/lang_cpp.py shows
3c68a34 fix(lang-cpp): exclude function-pointer variables (were mis-kinded "function") (#737)
on top of the #732 landing) — do not assume this skill's own older passes describing it as "not
yet landed" are still current, re-run the git log. The fix ports lang_c.py's
_c_parenthesized_declarator_wraps_bare_name tell via a new
_cpp_parenthesized_declarator_wraps_bare_name (lang_cpp.py:381) and confirms the
member-function-pointer wrinkle (void (C::*mp)(int);, which wraps a qualified_identifier
instead of a pointer_declarator) is SCOPE-DEPENDENT: file/namespace-scope excludes via the
bare-name type check, while in-class scope excludes via a different path entirely (tree-sitter-cpp
can't resolve C:: inside a class body and emits an ERROR node, giving the
parenthesized_declarator two named children instead of one). Rule for this skill's audience
specifically: a banked "the fix is obviously X" note — even one written in a prior session about
this exact bug class — is a hypothesis about a declarator SHAPE, and declarator shapes are exactly
the thing B5 already tells you not to guess. Dump the real parse tree for every shape you plan to
include/exclude before trusting a one-line fix description, including your own.
B6 — tiered model recap (see "Current status" above for the live table)
text search (any language, rg passthrough) → structural scan/rewrite (~26 langs via the
ast-grep wrapper tg wraps) → deep symbol graph (this skill's tier, the tree-sitter
grammars in lang_registry). Adding a language to the symbol graph does not change the
other two tiers — a language with no LanguageSpec still gets full-text search and (if
ast-grep supports it) structural scan/rewrite; it just has no defs/refs/callers/
tg source support until it clears this checklist.
E1 — priority and what's next
Top-10 by TIOBE Jul-2026 + Stack Overflow 2025 + GitHub Octoverse 2025 consensus: Python,
JavaScript, TypeScript, Java, C#, C++, C, Go, Rust, PHP. All 10 are now registered on
main — C landed via PR #731 (v1.97.0), C++ via PR #732 (v1.98.0), both scoped exactly per
items 1-5 below (a Stage-1, per-file, foundational-tier landing, not the full
#include-graph resolution items 1-2 warn against blocking on). The five scoping notes
below are kept as a worked EXAMPLE of "scope before starting, not while coding" for the
next language beyond the top-10, not as an open task:
- No module system. Go has
go.mod/go.work; C/C++ has no compiler-enforced namespace-to-directory mapping. The honest floor for a first landing is per-file symbol extraction (filename-as-scope), not a fullcompile_commands.json/CMake include-graph. SUPERSEDED 2026-08-12 — the "#includeresolution ... tracked but not started" claim this item used to end with is STALE: PR #957 (commit9f854d4, verified viagit log --oneline -1 9f854d4) shipped a fail-closed#includeresolution engine —src/tensor_grep/cli/lang_c_cpp_include.py(quoted includes search the importer's directory first then repo-rooted include roots; angle includes only the repo-rooted roots; macro/call-form includes stay unresolved; never fabricates an on-disk path) — wired intolang_c.c_file_imports_symbol_from_definitionandlang_cpp.cpp_file_imports_symbol_from_definition(grep those symbols insrc/tensor_grep/cli/lang_c.py/lang_cpp.py), and tested bytests/unit/test_c_cpp_cross_file_callers.py. What REMAINS honestly deferred: forwardtg importsresolution for c/cpp —_resolve_raw_import_entry'selif language_id in ("go", "php", "csharp", "c", "cpp")branch still returns the deferred tuple (resolved=None, external=False) — and a clangd-gradecompile_commands.json/CMake include-graph resolver, which is not started. #includeis textual, not semantic. tree-sitter has no preprocessor; a#define-wrapped declaration (export/visibility macros are common in real C/C++ headers) can hide or reshape the node the extractor expects — B5's live-verify discipline is mandatory here, not optional, on a much larger surface than Go's. Confirmed as shipped:class MACRO Name(a macro-decorated class declaration) misparses on the installed C++ grammar — an INHERENT tree-sitter ceiling, not a bug in this codebase's extractor, and disclosed as accepted in PR #732.- Declaration/definition split. A C/C++ function typically appears twice (a header
prototype, a body-bearing definition) — which one is canonical for
tg source/tg defsis a design decision to make explicitly, not an assumption carried over from Go's one-declaration model. - C and C++ are two separate grammar packages (
tree-sitter-cvs.tree-sitter-cpp) — decide upfront whether they are oneLanguageSpecor two (recommend two, mirroring how JS/TS already get two specs rather than one with a mode flag). Confirmed as shipped: they landed as two separate modules (lang_c.py/lang_cpp.py), each its ownLanguageSpec, exactly as recommended here. - A first Stage 1 landing can reasonably scope to per-file extraction +
declaration/definition dedup by name, in the same 0.7
"receiver-heuristic"-equivalent confidence band Go uses for anything short of confirmed resolution — a real, honestly-labeled feature now, rather than blocking on#include-graph resolution.
Known post-ship correction (PR #736, v1.98.2 — see B5's declarator-shape addendum above for
the full worked example): C's file-scope function-pointer VARIABLE
(void (*handler)(int);) was initially mis-kinded "function" — fixed by distinguishing
what a function_declarator's own declarator field wraps, not whether it's outermost. The
sibling fix for lang_cpp.py's independently-written declarator walker (same bug shape, plus
a C++-only member-function-pointer wrinkle) HAS SINCE LANDED as PR #737 (3c68a34 fix(lang-cpp): exclude function-pointer variables (were mis-kinded "function") (#737),
confirmed via git log --oneline -- src/tensor_grep/cli/lang_cpp.py — two earlier passes of
this skill said "not yet landed"; that was accurate when written and is now stale, which is
exactly why this section says "re-verify with git log" instead of asserting a fixed date).
Parallel-drain hygiene (cross-ref: AGENTS.md Campaign Orchestration A22)
A new grammar touches three files that several in-flight language PRs are likely to touch
at once: tests/unit/test_lang_registry.py (the LANGUAGE_REGISTRY.keys() set-pin test,
test_language_registry_has_exactly_the_stage2_languages), the pyproject ast extra
(grep -n '^ast = ' pyproject.toml — was :600, now :614, now :621, plus the mirrored dev/bench extras), and uv.lock (a new
tree-sitter-<lang> [[package]] block). When more than one language PR is in flight:
- Drain ONE at a time and rebase each onto the prior, UNIONing the assertions — e.g. the set-pin test must assert the full accumulated language set, never take-one-side.
- A CLEAN rebase (no conflict marker) is not proof of correctness — a silent auto-merge
can drop a
lang_*import. Always re-runpytest tests/unit/test_lang_registry.pyafter every rebase, not just after the final one. uv lockregenerated from scratch churns ~280 unrelated lines (local-vs-CI uv-version marker-expr reformatting) — hand-splice only the new dependency's[[package]]block (alphabetical) plus itsrequires-dist/optional-dependency refs, and verify withuv export --format requirements.txt --all-extras --no-emit-project --locked(must exit 0 — the exactaudit.yml"Dependency & License Audit" gate).- If you edit
uv.lock/ci.yml(CRLF-committed files) with a Python text-mode write (open(path, newline="\n")), it flips every line ending in the file, turning an 11-line change into a 1000+ line diff. Read/write in binary mode (rb/wb) and byte-replace, preserving\r\n.
See AGENTS.md's Campaign Orchestration Disciplines (A22) for the general form of this
rule, not specific to language PRs.
Validation
- Extend
tests/unit/test_lang_registry.py, not just a new bespoke test file — it already carries the pattern a new language must fit:test_spec_for_path_resolves_every_ registered_suffix,test_language_registry_has_exactly_the_stage2_languages(the union-pin set — add yourlanguage_idhere),test_target_and_provider_language_agree_ with_registry(this ONE dynamic test pins BOTH seam 5a_target_language_for_pathAND seam 5b_provider_language_for_pathat once — it iterates every registeredLanguageSpecand asserts both functions return that spec's ownlanguage_idfor each of its suffixes, so it fails loudly if you wire only one of the pair), and thetest_*_provenance_is_tree_sitter_when_grammar_present/test_grammar_absent_monkeypatch_*_provenance_flips_to_grammar_missingpair (21 tests as of 2026-07-27 -- re-run the grep rather than trusting this number; it grows with every language total as of this writing —grep -c "def test_" tests/unit/test_lang_registry.py). - Fixture/parity dogfood: write a minimal real-world-shaped fixture file in the new
language exercising every construct you extract (functions, types/generics if the
language has them, qualified access, imports) and run
tg defs/tg refs/tg callers/tg source/tg importsagainst it through the real installed binary, notCliRunner(AGENTS.md's "Dogfood the Real Binary, Not CliRunner"). - Confirm
_target_language_for_path(seam 5) with a livetg agentcapsule run on a fixture in the new language and checkprimary_target_languagein the JSON — this is the seam a unit test onlang_registryalone will not catch, because it lives inrepo_map.py, not the registry module. - Never trust a subagent's "I added the language and it works" as a self-report — confirm
against external state: the registry dict, a real symbol-command run, and (if
_target_language_for_pathwas touched) a capsule run.
Fast self-check before trusting a claim about this design
# Import side effect: LANGUAGE_REGISTRY is empty until the registering module (repo_map) is imported
uv run python -c "from tensor_grep.cli import lang_registry; print(sorted(lang_registry.LANGUAGE_REGISTRY.keys()))"
uv run python -c "from tensor_grep.cli import lang_registry, repo_map; print(sorted(lang_registry.LANGUAGE_REGISTRY.keys()))"
# Does a suffix resolve, and what does provenance_when_missing say?
uv run python -c "from tensor_grep.cli import lang_registry, repo_map; s = lang_registry.spec_for_path('x.go'); print(s.
…(truncated)