For months CGraph listed Kotlin as a supported language and extracted nothing from it. Point it at a Kotlin repo and it reported success, wrote a graph, and exited zero — with no symbols and no edges in it. The cause was not a parser crash or a missing grammar. It was that tree-sitter-kotlin exposes no named fields on its declarations, so every field-name-based lookup in the extractor config matched nothing, silently. This post is what that failure mode looks like, why nothing caught it, and what finally did.
"Configured" is not "functional"
CGraph's extraction is mostly declarative. For each language you name the grammar node kinds that matter — which node is a function, which field holds its name, which field holds its body, which field of a call node holds the callee — and a shared pipeline walks the tree and does the rest. That design is why adding Rust was mostly configuration rather than code.
The design has a sharp edge. A field name that does not exist on a grammar's nodes is not an error. It is a lookup that returns nothing. Kotlin had a complete-looking kotlin_config — name fields, body fields, call-accessor fields, all named — and every one of them referred to fields the fwcd/tree-sitter-kotlin grammar does not define on its declarations or on call_expression.
The consequences compounded quietly:
label_for_nodecould not find a name field on any construct, so it skipped every class, object, and function. No symbol nodes.add_raw_callcould not find a callee accessor field, so it labelled each callee with the entire text of the call expression. Those labels matched no declaration, so nothing resolved. No call edges.
A Kotlin repo therefore produced an empty graph. Configured, wired into the language table, listed in the docs — and never functional. The fix landed in CGraph#65.
Resolving positionally instead of by field name
The repair stops asking the grammar for fields it does not have and reads structure by position instead:
kotlin_symbol_name— aclassorobjecttakes itstype_identifierchild; a function takes itssimple_identifierchild.kotlin_callee_name— a field-lesscall_expressionresolves to the callee leaf name. Anavigation_expression(thereceiver.membershape) resolves to thesimple_identifierinside itsnavigation_suffix.add_raw_callconsultsresolve_callee_namewhen a call node has no accessor field at all.
Two details in there are load-bearing. First, Kotlin's navigation_expression result is deliberately kept non-member, so it resolves project-wide by name the way Java's method_invocation does. Second, the add_raw_call change is backward-compatible by construction: every other configured language does declare a callee field, so only Kotlin's field-less call takes the new path.
The measured result on a real Kotlin project: 2 files / 2 nodes / 0 edges before, 13 nodes / 16 edges after, with Calc() constructor calls and c.add() navigation calls both resolving. A new check_kotlin_extraction case in configured_extractors_test now fails if Kotlin ever goes quiet again, and extractor_goldens_test pins the shape.
The bug was invisible until something measured it
This is the part worth keeping. A test that asserts "extraction did not crash" passes happily on an empty graph. So does a test that asserts the output file is valid JSON. Both were true the whole time.
What made the deadness falsifiable was a downstream consumer that needed the graph to be good, not merely present. Blastline selects which tests a diff can reach, and it refuses to select from a graph it does not trust. One of its guards, disconnected-tests, measures how much of a codebase's symbols the test files can forward-reach and bails out under a floor of 25%. That number is a property of the graph, so it doubles as a graph quality metric.
Kotlin could not produce a reachability number at all — there was nothing to reach. After #65, cashapp/turbine measures test-to-implementation reachability of 0.625, comfortably clear of the floor.
The generalizable lesson: a pipeline stage that can degrade to silently doing nothing needs an assertion about its output's content, not its exit code. We had learned a version of this before — the four daemon bugs that passed every test were also all green-CI failures — and the Kotlin extractor is the same lesson one layer up.
Java had the same shape of bug
Kotlin's was the loudest instance, not the only one. In Java, new Foo() produced no edge at all, which severs the test → class → methods path that carries most of Java's reachability. CGraph#66 resolves constructor calls to the class node, and the reachability numbers moved accordingly: stleary/JSON-java from 0.241 to 0.847, google/gson from 0.145 to 0.757. Both were below the 25% floor before the fix and clear it after.
Neither of those was a crash either. Both were edges that were never emitted, on a graph that looked fine.
What is true today
Kotlin and Java extraction both work, and blastline recognizes JVM test conventions — Maven Surefire's Test*/*Test/*Tests/*TestCase, Failsafe's IT*/*IT/*ITCase, and Kotest/Spek's *Spec.kt — across .java and .kt sources, with .kts Gradle scripts excluded.
Being honest about the ceiling: Kotlin ships advisory, not as a hard gate. Its reachability clears the floor, but reachability is not the bar — the author-co-changed replay that the verified families passed has not been run for Kotlin, so Kotlin selections inform you rather than gate you. Java, whose graph got the same kind of repair plus interface dispatch, has since been replay-verified; Kotlin has not, and the distinction is the replay, not the extractor.
If you have a Kotlin or Java repo, build a graph of it and check that the symbol count is not zero — cgraph --root . --out cgraph-out, then open cgraph-out/graph.html. The full language list lives in the CGraph FAQ, and the indexing pipeline doc covers what extraction hands to resolution. The extractor and the Kotlin resolver are open source — read the code on GitHub.