kotlin-kernel-port-checklist.md 74 KB

Kotlin kernel port (R7b) — the bug-for-bug checklist

Status: PORT COMPLETE (2026-07-20) — walker codegraph-kernel/src/kotlin.rs

  • the vendored-grammar-C build (codegraph-kernel/grammars/kotlin via build.rs cc — the mechanism's first use), all gates passed (bump dumps byte-identical old-vs-new ×3 as predicted; parity sweeps 0-diff okio 299/322 / okhttp 531/580 / kotlinx.coroutines 1031/1082 with exactly the predicted 23/49/51 deferrals; kernel-arm dumps byte-identical ×3; KMP expect/actual synthesis IDENTICAL — 412 edges both arms; kernel-kotlin-parity suite; DEFAULT_ROUTED += kotlin — 15 languages). One fixture note: the survey's torture.kt itself tripped the PHANTOM-error class (one-line class/object bodies) and deferred — the checked-in parity fixture reflows those to multi-line, and the phantom shape is pinned in the defer test instead. Survey basis: every TS-side branch a .kt/.kts file exercises, with file:line anchors as of a6c62d7 (HEAD at survey time, clean main). Every grammar-shape claim below was probed against both the production tree-sitter-wasms build and a fresh fwcd 0.3.8 tag build (probe scripts + dumps in the session scratchpad svy-kotlin/ — see §Probe artifacts), and every extraction-behavior claim was pinned against the real dist/ extractor (extract-*.txt ground-truth dumps), not derived from code reading alone. Read WITH docs/design/rust-kernel-migration-plan.md (§0a recipe, §2 boundary, §4 tracker row "kotlin", §5 gates) and the format precedents (rust-lang-kernel-port-checklist.md, ruby-kernel-port-checklist.md, php-kernel-port-checklist.md, csharp-kernel-port-checklist.md).

Blocking findings: none — but two eyes-open items, one of them a NOVEL mechanism. (1) The grammar bump is behavior-neutral (rust-style gate: byte-identical CSTs on all 1,984 gate-repo files, 0 error disagreements) — but the crates.io crate tree-sitter-kotlin = 0.3.8 is UNUSABLE by the kernel (it pins tree-sitter >= 0.21, < 0.23; the kernel links 0.25), and the successor crate tree-sitter-kotlin-ng is a different grammar (8 fields vs 0, 289 vs 357 symbols, renamed kinds — would break every kotlin.ts branch). The port must take the vendored-grammar-C route: compile the sha-matched 0.3.8 parser.c+scanner.c inside codegraph-kernel via build.rs — the FIRST language to exercise the mechanism the §4 tracker prescribes for vendored grammars (§Grammar prep). (2) Both-arm parse-error incidence is 4.7–8.5% on the gate repos (fun-interface misparses, phantom single-line-class-body errors, soft-keyword identifiers, call().prop = x LHS shapes — all grammar-inherent, all identical across arms). The default --max-deferral 0.1 HOLDS but with only ~1.2–2× headroom (okhttp 8.45%) — expect double-digit deferral COUNTS on kotlin sweeps and don't misread them as walker bugs (§Architecture decisions #6).

Grammar prep (behavior-neutral re-vendor + the vendored-C kernel build)

kotlin is NOT in VENDORED_WASM_LANGS (grammars.ts:291-317) — production loads node_modules/tree-sitter-wasms/out/tree-sitter-kotlin.wasm (mapping kotlin: 'tree-sitter-kotlin.wasm' at grammars.ts:35; tree-sitter-wasms 0.1.13 builds it from npm tree-sitter-kotlin ^0.3.1, the fwcd lineage; production wasm sha256 b5cb00c8…, 4,052,705 bytes, ABI 14).

  • Lineage decision (investigated, not assumed): two crates exist.
    • tree-sitter-kotlin 0.3.8 (crates.io max_stable, published 2024-08-03; repo fwcd/tree-sitter-kotlin — OUR wasm's lineage; repo still active but no crate release since). Tag 0.3.8 (annotated tag 9e7e624 → commit e1a2d5ad1f61f5740677183cd4125bb071cd2f30); sha256-verified crate-tarball ↔ tag, BOTH generated artifacts (kotlin HAS an external scanner):
    • src/parser.c 54104a7ef1555c265b746c790e0f8bb953cc17806e9df0c3af82f7f62c06a70a
    • src/scanner.c 27f73337ec357fc341fa57538f34c14277b0346980c3405dc30beab6202ec6d0
    • tree-sitter-kotlin-ng 1.1.0 (tree-sitter-grammars org, 2025-01) — REJECTED: a different grammar, not a re-publish (STATE_COUNT 11432 vs 10155, SYMBOL_COUNT 289 vs 357, FIELD_COUNT 8 vs 0, kinds renamed — additive_expressionbinary_expression, call_suffix gone, binding_pattern_kind gone…). Adopting it is an extractor REWRITE, not a port. Do not revisit until/unless the TS side migrates grammars.
  • The 0.3.8 build is behavior-IDENTICAL to the production wasm (this bump is a reproducibility re-vendor, csharp-flavored, not a version change): kind/field tables identical (360 node types, 134 named kinds, 0 fields, ABI 14 both — table-compare.cjs); the 258-line torture file's full CST dump is byte-identical OLD↔NEW; and the gate-repo sweep (error-sweep.cjs <repo> --sexp) found 0 error disagreements and 0 s-expression mismatches on every clean file across all three repos. Expect the standalone bump gate's old-vs-new full-init dump diff to be byte-identical on all three (rust-style "expect zero", NOT php's enumerate+classify).
  • ABI note: the 0.3.8 tag's checked-in parser.c declares LANGUAGE_VERSION 14 — content parity, ABI stays 14 (ruby precedent). kernel-grammar-parity must assert same-revision, not an ABI change.
  • FIELD_COUNT 0 is load-bearing for the whole port: every childForFieldName/getChildByField lookup in the kotlin path returns null, which is what makes several TS hooks dead code (§Extractor config). The walker must reproduce the null-field world exactly — do NOT "helpfully" use -ng-style fields that don't exist here.
  • Wasm build (from the tag's CHECKED-IN parser.c — never tree-sitter generate):

    git clone --depth 1 --branch 0.3.8 https://github.com/fwcd/tree-sitter-kotlin
    cd tree-sitter-kotlin
    # the 0.3.8 tag predates tree-sitter.json, which cli 0.25.10 requires —
    # add the METADATA-ONLY shim (grammar name/scope; nothing regenerated):
    #   {"grammars":[{"name":"kotlin","scope":"source.kotlin","path":".",
    #     "file-types":["kt","kts"]}],"metadata":{"version":"0.3.8","license":"MIT"}}
    npx -y tree-sitter-cli@0.25.10 build --wasm -o tree-sitter-kotlin.wasm .
    

    (brew emcc present; survey artifact sha256 c80c88867a589a1a0959bcea89de84b7e9684b3693b2cdb2944812458e62ff48, 4,052,313 bytes, at scratchpad svy-kotlin/tree-sitter-kotlin-NEW.wasm. Do NOT use tree-sitter-cli 0.24 — it drops \p{...} classes, the #1164 vbnet lesson, and this grammar's identifiers use them.)

  • Kernel side — the NOVEL part (crate pin impossible): the 0.3.8 crate's [dependencies.tree-sitter] version = ">= 0.21, < 0.23" + old-style pub fn language() -> tree_sitter::Language bindings cannot link against the kernel's tree-sitter = "0.25". Instead of a crate dep, vendor the grammar C into the kernel (the §4 tracker's prescription for vendored-grammar languages — kotlin is the first to need it):

    • copy the tag's src/parser.c, src/scanner.c, and src/tree_sitter/*.h to codegraph-kernel/grammars/kotlin/ (shas above, recorded in a comment);
    • codegraph-kernel/build.rs: cc::Build compiling both C files with the crate's own flag set (-Wno-unused-parameter, -Wno-unused-but-set-variable, -Wno-trigraphs; msvc -utf-8 — crib the tarball's bindings/rust/build.rs);
    • Cargo: add tree-sitter-language = "0.1" (the version-agnostic LanguageFn shim every modern grammar crate uses) + cc as a build-dependency (if not already present);
    • langs.rs:

      extern "C" { fn tree_sitter_kotlin() -> *const (); }
      // …
      "kotlin" => Some(unsafe { tree_sitter_language::LanguageFn::from_raw(tree_sitter_kotlin) }.into()),
      

      plus LANGUAGES += "kotlin" (14 entries).

    • __tests__/kernel-grammar-parity.test.ts:39 GRAMMAR_LANGUAGES += 'kotlin' — the id-by-id ABI/kind/field-table compare against the vendored wasm is the proof the C build and the wasm build are the same revision.

  • Staging plan (bump PR, before any walker exists): vendor the wasm to src/extraction/wasm/tree-sitter-kotlin.wasm; VENDORED_WASM_LANGS += 'kotlin' (grammars.ts:291) with an R7b comment (tag + sha-matched note + "crate unusable — kernel compiles vendored C, see codegraph-kernel/grammars/ kotlin"); the kernel C vendor + build.rs + langs.rs + grammar-parity row can land WITH the bump (they're inert until a walker exists) or with the walker — but wasm + C must be same-tag from day one. copy-assets already globs src/extraction/wasm/*.wasm. MIT license (fwcd), same family as the rest.

  • Error incidence (both arms, all .kt/.kts ≤1MiB, error-sweep.cjs):

| Repo | files | OLD hasError | NEW hasError | disagreements | sexp mismatches (clean files) | |---|---|---|---|---|---| | okio | 322 | 23 (7.14%) | 23 (7.14%) | 0 | 0 | | okhttp | 580 | 49 (8.45%) | 49 (8.45%) | 0 | 0 | | kotlinx.coroutines | 1,082 | 51 (4.71%) | 51 (4.71%) | 0 | 0 |

Error classes (sampled + probed): (a) fun interface — unsupported by the grammar, ALWAYS errors (okhttp 10/49, kotlinx 6/51; okhttp3's core Call.kt/Authenticator.kt/Dns.kt are in this class); (b) phantom single-line class bodiesclass X { fun f() {} } sets hasError=true with ZERO ERROR/missing nodes and a COMPLETE, correct CST (probed both arms; okio 1, okhttp 7, kotlinx 6); (c) soft-keyword identifiers (var final = false; final = true errors — final is reserved by the grammar); (d) call("x").prop = value navigation-off-call assignment LHS (errors; plain obj.prop = x is fine); (e) assorted expect-header and gradle-kts DSL shapes. class Foo private constructor(x) and @Inject constructor parse CLEAN (probed — don't blame ctor visibility). All classes error on BOTH arms → defer-to-wasm keeps parity; only the speedup is lost on those files.

  • Probe scripts + outputs live in the survey scratchpad (…/scratchpad/ svy-kotlin/): table-compare.cjs, shape-probe-kotlin.cjs + torture-{OLD,NEW}.txt (byte-identical), mini-probes.cjs + mini-probes.out (17 targeted shapes, all OLD==NEW), error-sweep.cjs + errors-<repo>.txt, extract-probe.cjs (runs the REAL dist extractor — its extract-{torture,vref,vref-nopkg,docs,bodiless,crlf,funiface,lfpkg,kts}.txt dumps are the pinned ground truth cited throughout and double as walker test expectations), torture.kt + the small fixtures, the 0.3.8 tag clone + crate tarball + ng tarball with matching shas. Scratch is throwaway — re-derive from this doc if gone.

Architecture decisions

  1. No preParse. kotlinExtractor has no preParse hook (languages/ kotlin.ts — whole file, no such key), so preParsedSource (kernel/index.ts:96) is a no-op — both arms parse raw bytes. Nothing to hoist. No POST_PASSES entry either (kernel/index.ts:81) → tryKernelExtractRaw stays eligible.
  2. Three framework resolvers can force the DECODED path for kotlin; none of the gate repos trips any of them (verified). parse-worker.ts:93-100 forces any language with an applicable framework extract() onto the decoded extractFromSource path. Kotlin appears in:
    • springResolver (frameworks/java.ts:13, languages: ['java','kotlin', 'yaml','properties']; extract() at :197 regexes @GetMapping etc. over raw .kt source) — detect (:23) = pom.xml/build.gradle/build.gradle.kts containing spring-boot/springframework, or Spring annotations in any .java file. okio/okhttp/kotlinx.coroutines: none match (grepped).
    • expoModulesResolver (frameworks/expo-modules.ts:154, languages: ['swift','kotlin']) — detect = package.json expo-modules-core or an Expo Module DSL source scan. Not present.
    • fabricViewResolver (frameworks/fabric.ts:366, kotlin in languages) — detect needs codegenNativeComponent. Not present. So all three parity repos exercise the raw buffers-to-store transport; a Spring-Boot Kotlin app or an Expo/RN app is the decoded-path smoke check.
  3. The framework extractors themselves need NO port — regex over raw source, run in extractFromSource:6736-6758 after either arm. §Frameworks pins their input contracts.
  4. One walker module (suggest codegraph-kernel/src/kotlin.rs; no crate collision since there is no kotlin crate dep), registered in langs.rs; per-file has_error()defer: like every walker. java.rs is the closest crib (JVM package→namespace node via extractFilePackage, class-like scope stack, methods-in-class-like, dotted imports, STATIC_MEMBER + TYPE_ANNOTATION + VALUE_REF membership, annotation decorators, node_ids vec). Kotlin diverges from it in nine places, each detailed below: (a) a visitNode hook whose PROPERTY branch is the only live-in-kernel part (fun-interface recovery is defer-shielded); (b) getReceiverType — extension functions → receiver-qualified method QNs + the owner-contains fallback (NO ported walker has this surface yet); (c) extractModifiers — expect/actual → node DECORATORS (also a first); (d) resolveBody by TYPE (zero-field grammar); (e) extraClassNodeTypes (object_declaration); (f) classifyClassNode keyword sniffing (interface/enum reuse class_declaration); (g) the #750 kotlin re-encode in extractCall (namedChild(0), NOT a function field); (h) a fn-ref spec with EMPTY idTypes + callable_reference/navigation_expression specials; (i) dead-field lookups everywhere (signatures, type annotations) that must stay dead.
  5. .kt/.ktskotlin at detectLanguage (grammars.ts:106-107), no content sniffing, no dialect. .kts scripts are ordinary kotlin files whose top-level statements attribute calls to the FILE node (pinned: extract-kts.txtcalls println from=file, top-level valconstant). MAX_FILE_SIZE (1 MiB, extraction/index.ts:132) and generated-file skips are orchestrator/TS-side and shared.
  6. Deferral expectations: okio 23/322 = 7.14%, okhttp 49/580 = 8.45%, kotlinx.coroutines 51/1,082 = 4.71% — grammar-inherent, both-arm (§Grammar prep table). Keep the sweep default --max-deferral 0.1 (it holds on all three) but EXPECT these counts; a kotlin sweep at ~8% deferral is normal, one at >10% means a walker bug. No c/cpp 0.5 exemption.
  7. REF_FLAG_FILE_PATH (wire v2 slot) is NOT needed for kotlin. No kotlin extraction path emits refs carrying filePath (the visitNode hook only creates nodes; verified across every ground-truth dump — zero refs printed a filePath). The ruby/php trait-mixin bit stays unused here.

Extractor config (languages/kotlin.ts — 353 lines, read it whole)

Types: functionTypes=[function_declaration]; classTypes=class_declaration; methodTypes= function_declaration; interfaceTypes=[] ; structTypes=[]; enumTypes=[]; enumMemberTypes=[enum_entry]; typeAliasTypes=[type_alias]; importTypes=[import_header]; callTypes=[call_expression]; variableTypes=[property_declaration]; fieldTypes=property_declaration; extraClassNodeTypes=[object_declaration]. nameField=simple_identifier, bodyField=function_body, paramsField=function_value_parameters, returnField=type.

FIELD_COUNT 0 consequences (the dead-field cluster — reproduce the deadness):

  • nameField/bodyField/paramsField/returnField are node-TYPE names used as FIELD names — every getChildByField on them returns null. Names resolve via extractName's FALLBACK (first direct namedChild of type identifier|type_identifier|simple_identifier|constant, tree-sitter.ts:178-189); bodies resolve via the resolveBody hook (by type); params/return field walks are DEAD (§Type-annotation refs).
  • getSignature (kotlin.ts:277) is DEAD CODE — always undefined. It reads getChildByField(node, 'function_value_parameters') → null → early return. Ground truth: every function/method in extract-torture.txt has sig=undefined. The walker must emit NO signature for functions/methods.
  • The property hook's typeNode = node.childForFieldName('type') (kotlin.ts:125) is DEAD → property signature is always undefined too (val topVal: Int = 3 → sig undefined — pinned).

Hooks PRESENT (port each exactly):

  • visitNode (kotlin.ts:87-215) — runs for EVERY node the main walker visits (tree-sitter.ts:943-953; NOT in visitFunctionBody). Three branches:
    1. property_declaration (:98-131) — the LIVE branch. varDecl = first namedChild of type variable_declaration; nameNode = ITS first simple_identifier; either missing (destructuring's multi_variable_declaration) → return false (fall to the ladder). Then the SCOPE WALK up the parent chain, first match wins: function_body|function_declaration|lambda_literal| anonymous_initializer|control_structure_body|getter|setter → 'local' → return true, extract nothing (this is how init-block/getter-body/ top-level-control-flow locals reached via visitNode recursion are skipped); companion_object|object_declaration → 'const'; class_declaration → 'instance'; nothing matches (top level) → 'const' (the initial value). Kind: instance → field; else val (a binding_pattern_kind child with text exactly val) → constant, varvariable (const val is just a val; a delegated by lazy {} property has no = but still a binding → same rule). ctx.createNode(kind, name, node, { signature: undefined }) — extra carries ONLY the (always-undefined) signature: no docstring, no visibility, no isStatic, no returnType on kotlin property nodes — but createNode's extractModifiers merge still runs, so expect val / actual val DO get decorators. Return true → the dispatcher runs scanFnRefSubtree(node, 0) (capture-only, halts at nested function/lambda types) and NEVER descends → property initializers emit NO calls/instantiates refs anywhere (val SHARED = WidgetK(0) → nothing; by lazy { compute() } → nothing, the scan halts at the lambda_literal). Consequences pinned in extract-torture.txt.
    2. lambda_literal after a fun-interface ERROR (:139-143) and
    3. fun-interface misparse recovery (:145-214) (ERROR/ function_declaration shapes; isFunInterfaceNode :46; Pattern 1 walks the sibling lambda's statements with a synthesized interface node pushed — ground truth extract-funiface.txt: interface node at the ERROR's extent + transform as its method) — both branches are DEFER-SHIELDED in the kernel: every fun interface (either pattern, probed) makes the tree hasError=true, so the kernel defers the whole FILE to wasm before the walker would run. Do NOT port branches 2-3. Walker rule: port branch 1 only; a defer: on has_error covers the rest. (The parity suite still needs a fun-interface fixture asserting the kernel defers and the wasm arm serves the pinned output.)
  • resolveBody (kotlin.ts:219-241) — find by TYPE among namedChildren: first ERROR child whose child(0) is { (the fun-interface parent-body case — unreachable on non-erroring files, keep for wasm-parity of the TS side only), else first function_body | class_body | enum_class_body. Used by extractFunction/Method/Class/Enum body resolution AND by createNode's endLine extension (tree-sitter.ts:1329-1333, function/method kinds only). Single-expression bodies (fun f() = expr) are a function_body starting with = — resolved and walked like any body.
  • classifyClassNode (kotlin.ts:242-255) — scan ALL children (anon included): child.type interface → 'interface'; enum → 'enum'; else 'class'. annotation class / data class / sealed class → 'class' (their class_modifier children don't match); sealed interface → 'interface'.
  • getReceiverType (kotlin.ts:256-276) — LIVE, the extension-function surface. Walk ALL children in order: remember the last user_type; on a . (anon) child WITH a remembered user_type → return that user_type's FIRST type_identifier child's text (else the whole user_type text); on simple_identifier or function_value_parameters → break (past the name; no receiver). Probed shapes:
    • fun WidgetK.extend()WidgetK.
    • fun <T> List<T>.genericExt()List (type_parameters is skipped; generic args live in a type_arguments child of the user_type, and the FIRST type_identifier is the base).
    • fun com.example.Qualified.qext()com — a qualified receiver's user_type holds MULTIPLE type_identifiers (com,example,Qualified) and the find takes the FIRST segment. QN becomes com::qext. BUG, PRESERVE.
    • infix fun Int.pow()Int; operator fun WidgetK.plus()WidgetK (modifiers don't disturb the walk).
  • getVisibility (kotlin.ts:288-301) — for each child of type modifiers: TEXT .includes('public'|'private'|'protected'|'internal') in that order; no modifiers/no match → 'public'. QUIRKS, PRESERVE: (a) kotlin emits a visibility value no other language does — 'internal'; (b) the probe file's private internal fun (invalid kotlin, parses fine) → 'private' (order); (c) TEXT-includes false positives — annotations live inside modifiers, so @publicize-style lowercase annotation text containing a keyword flips visibility (e.g. a lone @internalApi annotation → 'internal' instead of 'public'). Match the includes-on-raw-text semantics exactly.
  • isStatic (kotlin.ts:302-305) — always false (not undefined): every function/method node carries isStatic: false.
  • isAsync (kotlin.ts:306-315) — any modifiers child whose TEXT .includes('suspend') → true, else false. The real shape is modifiers > function_modifier > suspend. TEXT-includes false positive, PRESERVE (probed, mini-probes.out suspendFalsePos): @suspendMarker fun g()isAsync true (the annotation text contains lowercase 'suspend').
  • extractModifiers (kotlin.ts:316-338) — expect/actual, the KMP surface. Scan children for modifiers → their platform_modifier children → their children of NODE TYPE expect/actual (anon keyword nodes; matched by type, not text) → collect in order; empty → undefined. Runs inside createNode (tree-sitter.ts:1355-1358) for EVERY node kind — merged newNode.decorators = [...(existing ?? []), ...mods]. Ground truth: expect fun/expect class → dec=["expect"]; actual fun/class/val → ["actual"]; actual typealias PlatformClock → type_alias node with dec=["actual"] (the synthesizer's KMP_TYPE_KINDS depends on this); members of an expect class are NOT marked (no platform_modifier of their own) but an actual fun inside an actual class IS. decorators on kotlin nodes come ONLY from this hook — the annotation channel is decorates REFS, never the node list (§Decorators).
  • extractImport (kotlin.ts:339-346) — signature = trimmed source.substring(node.startIndex, node.endIndex) (UTF-16); moduleName = the first namedChild of type identifier's substring (the dotted path). No identifier → null (doesn't occur; even import a.b.* has the identifier). No handledRefs → the generic imports ref also fires (§Imports).
  • packageTypes=[package_header] + extractPackage (kotlin.ts:347-352) — first namedChild of type identifier → trimmed substring (com.example.torture); none → null. §Namespace capture.

Hooks ABSENT (the walker must NOT do these): preParse, resolveName, recoverMangledName, isMisparsedFunction, isConst, isExported (undefined on every node except the file node's literal false), classifyMethodNode, extractPropertyName, propertyTypes, interfaceKind (→ kind interface), extractBareCall, synthesizeMembers, skipBodilessClass (a bodiless class Foo still mints a node — the 1685 comment names Kotlin as the deliberate case), methodsAreTopLevel, resolveTypeAliasKind.

tree-sitter.ts branches (anchors as of a6c62d7)

visitNode dispatch — what each kotlin node hits (ladder at 936-1303)

Node Branch Behavior
every node visitNode hook first (943) property_declarations (non-destructuring) consumed there; handled → scanFnRefSubtree + STOP
every node maybeCaptureFnRefs (990) fires for value_arguments/assignment (the KOTLIN_SPEC keys) in visitNode context too — how top-level/class-scope callable refs in call args are captured
function_declaration functionTypes:994 inside class-like AND ∈ methodTypes → extractMethod:1737; else extractFunction:1517 (which itself diverts to extractMethod when getReceiverType fires — extension fns at any scope). skipChildren
class_declaration classTypes:1005 → classify 'interface' → extractInterface:1834; 'enum' → extractEnum:1914; else extractClass:1679
object_declaration extraClassNodeTypes:1022 extractClass(node) → kind class (objects and sealed-class object members are class nodes; extractInheritance runs → their delegation_specifiers emit extends)
companion_object no branch recursed → its class_body children visited with the OUTER CLASS still on top: properties → hook ('const' scope → constant/variable under the class, class: parent ⇒ value-ref targets), functions → extractMethod of the outer class. A NAMED companion (companion object Named) is identical — the name mints nothing
property_declaration (hook-declined = destructuring) fieldTypes:1084 (in class-like) else variableTypes:1098 extractField / extractVariable — both emit NOTHING for kotlin destructuring (no variable_declarator/variable_declaration/identifier direct children; extractVariable's generic fallback :2863-2881 finds no identifier-typed child — kotlin names are simple_identifier). skipChildren + scanFnRefSubtree → the RHS call is invisible too. isClassScopeConstantAssignment (1508) needs node.type assignment → always false for kotlin
type_alias typeAliasTypes:1071 → extractTypeAlias:2890 plain type_alias node (no resolveTypeAliasKind). QUIRK: the alias-value ref walk reads getChildByField(node,'value') → null (no fields) → NO reference to the aliased type; returns false → the alias's children ARE re-visited (harmless — user_type/modifiers match nothing)
import_header importTypes:1209 → extractImport:3170 §Imports (the import_list wrapper has no branch and recurses into each header)
package_header consumed by extractFilePackage BEFORE the walk (1397) during the walk it's recursed, nothing matches
call_expression (top level / class body / object body / .kts statements) callTypes:1248 → extractCall:3684 attributes to the nodeStack top (file/namespace/class). Note class-BODY calls only occur via init blocks etc. (below)
anonymous_initializer (init { }) no branch recursed → its statements' calls → calls refs FROM THE CLASS node; its val locals → hook 'local' → nothing (pinned: calls "register" from=class:WidgetK)
secondary_constructor no branch NO constructor node; recursed → body calls attribute to the CLASS (calls "log" from=class:WidgetK); the constructor_delegation_call's value_arguments still feed fn-ref capture
getter/setter as SIBLINGS (accessor on its own line) no branch recursed → accessor-body calls attribute to the CLASS (or file). See §Properties for the sibling/child split
object_literal (object : T { … } initializer) no branch anywhere never a node; see §Body walker for the method-leak quirk
file_annotation (@file:JvmName("x")) no branch recursed; its value_arguments feed fn-ref capture (string args → nothing). No decorates ref
INSTANTIATION_KINDS (354-361) no kotlin member extractInstantiation:4610 is UNREACHABLE for kotlin — constructor calls Foo() are call_expressions → plain calls refs named Foo (capitalized). Kotlin emits zero instantiates refs, ever
impl_item:1274 / property_signature:1282 / export_statement / swift property:1121 never not kotlin node kinds (the swift property_declaration branch at 1121-1193 is gated language === 'swift' — kotlin property_declarations never enter it)

Node creation, IDs, qualified names

  • createNode (1308): id = generateNodeId(filePath, kind, name, startRow+1) = `${kind}:${sha256(`${filePath}:${kind}:${name}:${line}`).hex.slice(0,32)}` (tree-sitter-helpers.ts:18-30). FILE node id = literal file:${filePath} (509), name = basename, qualifiedName = filePath, endLine = source.split('\n').length, isExported false. Dedupe/self-checks compare ID STRINGS (node_ids vec pattern).
  • endLine extension via resolveBody (1329) is LIVE for kotlin function/method nodes (body found by type; in-range for this grammar, so in practice a no-op extension — but CALL the hook, the ERROR-body branch is part of the contract).
  • contains edge from nodeStack top for every created node (1363); extractModifiers merge (1355-1358); captureValueRefScope (1374).
  • Namespace capture — extractFilePackage (1397): scan the ROOT's direct namedChildren for the first package_header (a leading file_annotation or KDoc is skipped by the type filter); extractPackage → dotted text → createNode('namespace', 'com.example.torture', pkgNode) = node #2 after the file node, pushed for the WHOLE walk. Every top-level symbol's qualifiedName = com.example.torture::Name (buildQualifiedName:1447 joins stack names with ::; namespacePrefix always empty outside C/C++). No package header (scripts) → no namespace node, bare QNs, file: parents.
  • Receiver-QN override: extension methods get extraProps.qualifiedName = composeReceiverQualifiedName(receiverType, name) (1790-1792) = `${receiverType}::${name}` verbatim (1435-1436; prefix empty → pass through) — NO package prefix: fun WidgetK.extend in package com.example.torture has QN WidgetK::extend (pinned). This is the first ported walker with the receiver-QN surface — get the two QN builders' divergence exactly right.
  • isInsideClassLikeNode (1486): stack-top node kind ∈ {class, struct, interface, trait, enum, module} — namespace does NOT count (top-level fns under the package namespace stay functions).

extractFunction / extractMethod (1517 / 1737)

  • extractFunction: line 1522 — getReceiverType short-circuit is LIVE: any function_declaration with a receiver (top-level extension fns, and nested ones inside bodies) diverts to extractMethod. Name via extractName fallback (first simple_identifier — backtick names keep their backticks: function "`weird name`"). <anonymous> unreachable (grammar requires the name). Extras: docstring (§Docstrings), signature undefined (dead hook), visibility (hook), isExported undefined, isAsync (hook), isStatic false, returnType (hook — §below). extractTypeAnnotations → emits NOTHING (§Type-annotation refs); extractDecoratorsFor → §Decorators. Push, body via resolveBody, visitFunctionBody, pop.
  • extractMethod (in-class-like functions + receiver-diverted extension fns): receiverType recomputed (1742); gate 1747 passes via class-like OR receiver. Same extras. Receiver path (extension fns): QN override (1790) + the owner-contains fallback (1799-1813) — receiver present AND not class-like → find the FIRST node in this.nodes with name === receiverType && filePath === this.filePath && kind ∈ {struct, class, enum, trait} → contains edge owner→method. QUIRKS, PRESERVE: interface is NOT in the kind setfun Drawable.ext() never gets an owner edge even with Drawable in-file; source-order dependent (extension above its class → no edge); the qualified-receiver bug (com::qext) looks up a node named com (never found). Extension fns keep their normal contains edge from the nodeStack top (namespace/file) REGARDLESS — the owner edge is additive.
  • expect fun has no body → resolveBody null → no body walk; node still minted with dec=["expect"]. Interface bodiless methods likewise.
  • Nested named fun inside a body → visitFunctionBody:5245 → extractFunction → a function node contained by the enclosing function/method (QN …::caller::localFn), receiver check applies (a nested extension fn becomes a method with owner-contains).

getReturnType = extractKotlinReturnType (kotlin.ts:17-43)

Positional (no fields): iterate namedChildren; before function_value_parameters → skip; after it, the FIRST user_type | nullable_type wins; hitting function_body or type_constraints first → undefined. nullable_type unwraps to its inner user_type (?? child). Name = the user_type's first type_identifier's text (?? the whole user_type), trimmed; must match /^[A-Za-z_]\w*$/; Unit/Nothing (KOTLIN_NON_CLASS_RETURN kotlin.ts:6) → undefined. Pinned: : WidgetK → WidgetK; : WidgetK? → WidgetK; : Unit → undefined; inferred = expr → undefined; : (Int) -> Unit (function_type) → undefined; : T (generic param) → T (leaks as a returnType — preserve); extension receiver types never mistaken (they sit BEFORE the params). Methods and functions both.

extractClass (1679) — and the bodiless-header asymmetry

resolvedBody = resolveBody (class_body by type; null for bodiless). NO skipBodilessClass → bodiless classes mint nodes. Extras: docstring, visibility (hook), isExported undefined; decorators via createNode's extractModifiers (expect/actual classes). Then extractInheritance (§below) — BEFORE the body walk, so extends refs precede member emissions. Then extractCsharpPrimaryCtorParamRefs (no-op — needs language csharp… actually gated at :5939 by language, cheap early-out) and extractDecoratorsFor (§Decorators — the @MyMarker class ref). Push, walk, pop.

  • Bodied class: body = class_body → ONLY class_body children are visited. The primary_constructor is a DIRECT child of class_declaration, NOT of class_body → constructor properties (class Foo(val a: Int)) mint NO field nodes, ctor default-value calls emit NOTHING, and data class components are invisible (pinned: DataK has zero members).
  • Bodiless class: body = the class node itself (1714) → the HEADER children are visited: the primary_constructor recursion reaches default-value call_expressions → calls refs from the CLASS node, and delegation_specifier recursion reaches super-ctor argument calls too. Ground truth (extract-bodiless.txt): class Bodiless(val b: Int = initB()) : Base(readCfg2()) → extends Base + calls initB + calls readCfg2, all from class:Bodiless; the IDENTICAL bodied class emits ONLY extends Base. Reproduce the asymmetry exactly; also note the header's value_arguments feed fn-ref capture in the bodiless case only.
  • Class-body members: hook properties (fields/constants), function_ declarations → extractMethod, nested class/object/enum → their branches, getter/setter siblings + anonymous_initializer + secondary_ constructor → plain recursion (calls attribute to the class).
  • extractInterface (1834): kind interface; docstring, isExported undefined — NO visibility (extractInterface never calls getVisibility; pinned vis=undefined). extractInheritance runs; body walk with the interface pushed (bodiless member funs still mint method nodes; a val prop with same-line getter → hook → field). Bodiless interface (sealed interface SealedIface) → body ?? node fallback (1856) → header children re-visited (nothing emits — but keep the traversal).
  • extractEnum (1914): body REQUIRED (resolveBody finds enum_class_body) — a bodiless enum would mint nothing (doesn't occur). docstring, visibility, isExported undefined. extractInheritance (enum delegation_specifiers). Body loop (1941-1950): enum_entry ∈ enumMemberTypes → extractEnumMembers(entry); everything else (function_declaration after the ;, companion_object, secondary constructors) → visitNode with the enum pushed → methods of the enum, companion constants under it.
  • extractEnumMembers (1958): getChildByField(node,'name') → null (no fields) → the identifier-children scan (1967-1974): one enum_member node per direct simple_identifier child, positioned at the IDENTIFIER node (createNode('enum_member', text, child)) — one per entry in practice. QUIRKS, PRESERVE: an entry's value_arguments (OK(200)) and an entry's class_body (OK(200) { override fun label() … }) are NEVER visited — the override methods and any calls inside them are COMPLETELY INVISIBLE (pinned: Http has enum_members OK/ERR + methods label/common/of only).

Properties — the hook rules + the getter-position split (probed)

  • Same-line accessor (val a: Int get() = compute()) → the getter is a CHILD of property_declaration → the hook consumes everything → the getter body is never walked (no calls refs).
  • Next-line accessor (val b: Int\n get() = compute()) → the getter is a SIBLING (child of class_body / source_file) → after the hook handles the property, the walker visits the getter separately → its body's calls attribute to the CLASS (or file/namespace at top level). Pin BOTH variants.
  • Top-level properties: scope 'const' → val→constant / var→variable, contained by the namespace (or file). Class body → field. companion/ object body → constant/variable under the CLASS node (stack top). Interface body → field (class_declaration parent matches 'instance').
  • Body-context locals NEVER reach the hook (visitFunctionBody doesn't run it) — instead they're plain-recursed: a local val fn = { … } lambda initializer IS walked, so its inner calls attribute to the enclosing function (pinned: println from caller), unlike hook-consumed properties. Local val x: T = … type annotations emit nothing (§Type-annotation refs).
  • lateinit var svc: Service in a class → field svc (modifiers don't matter to the hook).

Imports (3170-3236) — and the comment-gluing trap

Hook returns {moduleName: dotted path, signature: trimmed full text}: import node (name = com.example.other.OtherClass; QN = namespace-prefixed) + the generic imports ref (3183-3194): {fromNodeId: nodeStack top — the namespace node when a package exists, else the file node, referenceName: the dotted path, line: import_header startRow+1, column: startColumn}. NO kotlin-specific emit pass (the 3197-3234 rust/php/ruby/python emitters are all gated off). Shapes (probed):

  • import a.b.C → identifier text a.b.C.
  • wildcard import com.example.wild.* → the identifier covers only com.example.wild (the .* is a sibling wildcard_import) → a normal import node/ref named com.example.wild (NOT null, unlike rust's wildcard).
  • alias import a.b.LongName as Short → identifier = a.b.LongName (the import_alias child is ignored) — the SOURCE path, the alias binds nothing.
  • Comment-gluing (grammar quirk, LF and CRLF alike): comments FOLLOWING an import (or the package header) attach INSIDE the import_header / package_header node → the node's extent extends over them and the hook's signature (trimmed full text) INCLUDES the comment lines verbatim (pinned: sig "import com.example.alias.LongName as Short\n\n// line comment run 1\n// line comment run 2", node L7-10); the namespace node similarly spans to the last glued comment (extract-lfpkg.txt: namespace L1-4). Ref line/column stay at the header START. Downstream: those comments are NOT siblings of the next declaration → its docstring is LOST (§Docstrings).
  • flushFnRefCandidates' QUALIFIED_IMPORT (665) matches dotted paths → kotlin imports contribute their LAST segment to importedNames (OtherClass, helper, wild, LongName) — the fn-ref gate is "defined in this file ∪ imported simple names" (unlike rust/ruby).

extractCall (3684) — the kotlin paths

Entry: not vbnet/erlang/ruby/arkts. func = getChildByField(node,'function') ?? node.namedChild(0) (4313) → always namedChild(0) for kotlin (no fields). The cpp operator recovery (4324) is language-gated off.

Member branch (4364) — func.type === navigation_expression:

  1. property (4369-4378): field lookups null → child1 = func.namedChild(1); navigation_suffix → its first simple_identifier (?? the suffix itself — unreachable in valid code: suffixes carry the identifier; ::class suffixes never appear under a call's function position). methodName = its text. Safe-call suffixes (?.) carry the same simple_identifier — x?.render() emits exactly like x.render().
  2. receiver = object/operand/argument fields (null) ?? func.namedChild(0) (4385-4389).
  3. LITERAL_RECEIVER_TYPES (4397, set at 373-388): kotlin members that occur: string_literal ("literal".uppercase()), integer_literal (5.toString()) → emit NOTHING (pinned). Kotlin's other literal kinds (boolean_literal, character_literal, null) also appear in the set — port the WHOLE set verbatim.
  4. receiver simple_identifier (4401; kotlin's name kind IS in the check list) not in SKIP_RECEIVERS {self,this,cls,super} (by TEXT — kotlin receivers are never those texts as simple_identifiers) → `${recv}.${method}` (w.render, Registry.register, Short.static, instances.add, it.render, anon.draw).
  5. receiver this_expression/super_expression → none of the branches → fall to the ELSE → bare methodName (this.toString()toString; super.hashCode()hashCode). Same net effect as SKIP, different path.
  6. receiver call_expression + kotlin in the gate list (4408-4418) — the #750 re-encode (4429-4442): innerNav = receiver.namedChild(0) (NOT a function field!) → its text with /\s+/g stripped; re-encode ONLY when /^[A-Z]/`${innerCallee}().${methodName}`. Pinned: WidgetK.create().render()WidgetK.create().render (+ the inner WidgetK.create from recursion); Foo.getInstance().bar()Foo.getInstance().bar + Foo.getInstance; Foo().bar() would → Foo().bar; lowercase chains fall to bare: lowerFactory().chain()chain + lowerFactory; w.chainInner().render()render + w.chainInner; listOf(1).forEach {…}forEach + listOf.
  7. receiver navigation_expression (2-hop a.b.method()), postfix_ expression (x!!.draw()), parenthesized, etc. → bare methodName.

Else branch (4518-4520) — calleeName = RAW func text: bare helper/run/WidgetK (constructor calls are plain capitalized calls refs — kotlin emits NO instantiates, §dispatch table); backticked `weird name` verbatim; generic calls generic<Int>(1)generic (type_arguments live in the call_suffix, not the callee). QUIRKS, PRESERVE (all pinned in extract-torture.txt):

  • Paren-then-lambda trailing() { it * 3 } parses as call(call(trailing,()), annotated_lambda) → TWO refs: the outer's callee is the inner call's RAW TEXT trailing() (garbage, unresolvable) + the inner trailing. A no-paren trailing call (trailing { … }, run { … }) is ONE call → one clean ref.
  • Newline-glued invoke chains: fn(3) followed by a line starting ( continues the expression (kotlin grammar) → fn(3)\n(fn)(4) is one 3-deep call chain emitting callees fn(3)\n (fn) (raw text with embedded newline), fn(3), and fn. Deterministic garbage — reproduce byte-for-byte.
  • The parenthesized-conversion regex (4530, /^\(\s*\*?\s*([A-Za-z_][\w.]*)\s*\)$/) applies to kotlin calleeNames — a clean single-line (handler)(4) (as the FIRST statement of a body, un-glued) has func text (handler) → rewritten to handler. Port the regex.
  • Template strip (4542) + cpp fn-ptr fan-out (4556) are c/cpp-gated — no.
  • Final ref: {callerId = stack top, name, line = call startRow+1, column = call startColumn (UTF-16)}. Inner calls of every chain are ALSO visited (the body walker recurses after extractCall — no consumption).
  • Calls inside string-template interpolations EMIT (in bodies): "… ${w.render()} …" → calls w.render at the inner call's position (interpolated_expression recursion). $topVal (interpolated_identifier) emits nothing anywhere.

Static-member / value-read refs (4750-4808) — kotlin IS in STATIC_MEMBER_LANGS (345-347)

Called ONLY from the body walker (5218) — top-level/class-scope reads emit nothing (hook-consumed property initializers doubly so). navigation_expression ∈ MEMBER_ACCESS_TYPES (326). Mechanics:

  • callee-of-call skip (4772-4778): parent ∈ callTypes AND parent.namedChild(0) starts at this node → skip (Registry.register(w)'s nav emits no references ref).
  • recv = object/expression/scope fields (null) ?? namedChild(0); accepted types include simple_identifier (4792); text must match /^[A-Z][A-Za-z0-9_]*$/references ref at the RECEIVER's position (pushStaticMemberRef 4800).
  • Pinned: Registry.count (statement) → references Registry; Color.RED → references Color; com.example.Fq.CONST_READ → NOTHING (nested navs — the outer recv is a navigation_expression, not accepted; the innermost recv com is lowercase); listOf(1).size → nothing (recv is a call). Assignment WRITES emit nothing: Registry.count = 5 / += 1 parse as assignment > directly_assignable_expression — that node type is NOT in MEMBER_ACCESS_TYPES (pinned: assignRefs() emits zero refs).
  • A Foo.Bar nav nested inside a bigger expression is visited on its own as the walker recurses — each nav node is evaluated once.

Decorators — kotlin annotations DO emit decorates (unlike csharp/php), asymmetrically

extractDecoratorsFor (4897) runs for functions/methods/classes (NOT hook-created properties/fields/constants — the hook never calls it; pinned: @field:JvmField val fielded → NO ref). Kotlin annotations live at modifiers > annotation > … — scan #1 (4976-4987) descends into modifiers children (the comment at 4979 names Kotlin) and consider() accepts node type annotation (4928):

  • @JvmStatic / @MyMarker (no args) → annotation > user_type — user_type IS in the target list (4950) → name = its text (<-strip + last-./::-segment normalization at 4959-4962 apply) → decorates ref {from: the decorated node, name, line/col of the ANNOTATION node}. Pinned: decorates MyMarker from class Annotated, JvmStatic from method jvmStatic.
  • @Deprecated("gone", ReplaceWith("new")) (with args) → annotation > constructor_invocation — NOT call_expression, NOT in the identifier list → NO ref at all (and the argument expressions are never visited by anything — no calls refs either). Pinned: method old has zero decorator refs.
  • Use-site targets @field:JvmField on a FUNCTION/CLASS would emit (the use_site_target child is skipped, the user_type matches) — but on properties (their usual home) the hook path never runs the extractor, so in practice they're silent.
  • platform_modifier children of modifiers (expect/actual) → not accepted types → no decorates refs (they ride node.decorators instead).
  • The backward-sibling scan (5013-5022) is inert — kotlin annotations are inside the declaration's modifiers, never preceding siblings (file_annotation has no branch that reaches consider()).

Inheritance — delegation_specifier (5595-5615)

extractInheritance's child loop runs over the CLASS NODE's direct namedChildren — kotlin's delegation_specifiers are direct children (probed; no wrapper node). Per specifier:

  • userType = find direct user_type; ctorInv = find direct constructor_invocation; target = userType ?? ctorInv; none → skip.
  • typeId: user_type → its FIRST type_identifier (?? itself); constructor_invocation → its user_type's FIRST type_identifier (?? the user_type ?? the invocation).
  • ONE extends ref per specifier {name: typeId text, line/col: the typeId node}. Kotlin NEVER emits implements — interfaces ride extends too (pinned: SubK → extends OpenBase + Drawable + Comparable).
  • QUIRKS, PRESERVE: qualified supertype : com.example.deep.RemoteBase() → ref named com (first type_identifier of the multi-segment user_type — pinned); generic supertype Comparable<SubK>Comparable (type_arguments' identifiers aren't direct children); by-delegation (: Drawable by d) emits NOTHING — the specifier's only child is explicit_delegation and the direct-child finds miss (pinned: DelegatedImpl has zero extends).
  • Runs for classes, objects (extraClassNodeTypes → extractClass), interfaces, enums. object Add : SealedOp() → extends SealedOp ✓. Anonymous object_literals never reach it (no class node).

Type-annotation references — kotlin ∈ TYPE_ANNOTATION_LANGUAGES (5753) but the machinery is DEAD

extractTypeAnnotations (5788) for kotlin takes the GENERIC path: params = getChildByField(node, 'function_value_parameters') (5844) → null (zero fields); returnType = getChildByField(node, 'type') (5851) → null; the type_annotation direct-child search (5873) → no such node kind in this grammar. extractVariableTypeAnnotation (6074) needs a type_annotation child → dead; the body-walker variable_declarator branch (5230) needs node kind variable_declarator → kotlin has none. property_signature/ method_signature (1282) are TS-only kinds. Net: kotlin emits ZERO type-annotation references refs — no param types, no return types, no property types, no local types (pinned: torture has no such refs). BUILTIN_TYPES is never consulted for kotlin. The walker needs cheap early-outs that preserve exactly this nothing.

Docstrings (tree-sitter-helpers.ts:95-127) — KDoc is DROPPED

Kotlin comment node kinds: line_comment (//) and multiline_comment (/* */ AND KDoc /** */). getPrecedingDocstring accepts only {comment, line_comment, block_comment, documentation_comment} — multiline_comment is NOT in the set → KDoc NEVER becomes a docstring, and a KDoc sitting between a // run and the declaration BREAKS the chain (it's a non-comment named sibling to the scan). Pinned (extract-docs.txt): /** KDoc */ + // line one + // line two + fun → doc = "line one\nline two"; KDoc-only → doc undefined; /** kdoc */ then // trailing line then fun → "trailing line" only. DOCSTRING_WRAPPER_TYPES (55-62) contains no kotlin kinds → no anchor climbing. cleanCommentMarkers (77-90): only the ^\/\/[/!]?\s? per-line strip fires for kotlin line comments — all gm strips ride js_multiline_strip in docstring.rs (#1329 CRLF semantics) — call the shared docstring.rs, port nothing. Properties (hook-created) never get docstrings at all. The import/package comment-gluing (§Imports) eats the docstring of the first declaration after the import block — pinned: topLevel has doc=undefined despite two // lines directly above it.

Value-reference edges (398-931) — kotlin IS in VALUE_REF_LANGS (401)

Port the full machinery (crib java.rs/go.rs): CODEGRAPH_VALUE_REFS=0 kill; MAX_VALUE_REF_NODES = 20,000 caps the prune DFS and each reader scan; isGeneratedFile skip.

  • Targets (captureValueRefScope:735): kind constant|variable, name length ≥3 AND /[A-Z_]/ (file_table qualifies via _; count does not), parent id prefix ∈ {file:, class:, module:, struct:, enum:}. QUIRK (the php-namespace analogue, pinned): in ANY file with a package header, top-level properties' parent is the namespace: node → NOT accepted → top-level kotlin constants are NEVER value-ref targets. Only un-packaged files (scripts, .kts) keep file-level targets (extract-vref-nopkg.txt), and class/object/companion-scope constants (class: parent) are the working population (extract-vref.txt: readTop → TOP_LIMIT, readBoth → SHARED_TABLE).
  • Reader scopes: every function/method/constant/variable node (764) — fields are NOT readers (a class val's initializer reads nothing; a hook-'const' object property IS a reader — its whole property_declaration subtree incl. by lazy { } lambda contents is DFS'd; the reader DFS has NO lambda halt, unlike the fn-ref scan).
  • Shadow prune (803-878): the kotlin declarator case is property_declaration (856-869) — vd = find direct variable_declaration → its first simple_identifier → bump (the Swift half of that case — name field / value_binding_pattern — is null path for kotlin); destructuring (multi_variable_declaration) bumps NOTHING (a destructured local shadow never prunes — quirk, preserve). bump() counts identifier/simple_identifier nodes (807 — the comment names Kotlin). Every val/var ANYWHERE in the tree bumps its name: the target's own declarator + any body-local re-declaration → declCount > fileScopeCount → target deleted. Pinned: companion RETRY_MAX + a method-local val RETRY_MAX = 9 → RETRY_MAX pruned (readBoth emits only SHARED_TABLE).
  • Emission (880-930): per reader scope DFS (stack-based, namedChildren pushed in order and POPPED — reverse-source-order visitation, ruby precedent; edge ORDER follows); reader node type simple_identifier (906-909 — the comment names Kotlin; identifier/constant/name never occur in kotlin trees). Any textual occurrence whose text maps to a target: nav members (X.SHARED_TABLE's member half), ${TARGET} interpolations (interpolated_expression > simple_identifier) — but NOT $TARGET (node kind interpolated_identifier, not accepted — pinned). Skip self-id, same-name, dedupe per (scope,target) → EDGE {kind:'references', metadata:{valueRef:true}}, appended AFTER the walk (flush order below). The Dart/Pascal sibling pull (891) is inert — a kotlin property's next sibling can be a getter, which is neither function_body nor block.

Function-as-value capture (#756) — KOTLIN_SPEC (function-ref.ts:240-248)

idTypes = EMPTY (bare simple_identifiers are NEVER candidates; explicitRef always true — irrelevant, no addressOfOnly). dispatch: value_arguments → args; assignment → rhs with NO field (RHS = LAST named child; the lhs for the param-storage skip comes from namedChild(0) — the directly_assignable_expression; the skip (408:430-443) compares the LHS's trailing identifier to the FULL rhs text — callable refs start ::, so it effectively never fires for kotlin, but port the comparison). layers: value_argument → null (fan out namedChildren). special: {callable_reference, navigation_expression}. No unwrap/ungatedModes/addressOfOnly.

  • The value_argument label-forward skip (547-557) is DEAD for kotlin — it reads getChildByField(node,'name') → null (zero fields), so a named argument f(cb = cb) is NOT skipped; the fan-out visits both the label and value identifiers (bare ids → nothing anyway, idTypes empty). Only Swift exercises the skip. Reproduce the fan-out.
  • callable_reference special (649-665): scan namedChildren — receiver = last type_identifier child, member = last simple_identifier child. No member → [] (String::class — the class is an anon keyword → member null → nothing). No receiver → bare member (::topLeveltopLevel, gated). Receiver present → /^[A-Z]/ on its text: OtherClass::handle → candidate OtherClass::handle (the :: rule at flush:709 — ALWAYS-flush); w::render → [] — the grammar parses even a lowercase variable receiver as type_identifier, and the CASE regex (not the node type) is what drops it (pinned: no ref).
  • navigation_expression special (671-681): only when the WHOLE node's text starts this:: → the navigation_suffix starting :: → its LAST named child → candidate this.<member> (ALWAYS-flush). this::caller pinned. Ordinary a.b navs in args → [].
  • Capture points: visitNode:990 (top-level/class-scope call args), visitFunctionBody:5137, scanFnRefSubtree (hook-consumed property subtrees — val x = register(::f) captures via the inner value_arguments; the scan halts at lambda_literal (610), so refs inside by lazy { }/trailing lambdas under a hook-consumed property are NOT captured). NOT captured anywhere: property/local initializer callable refs (val m = ::caller, val bound = w::render) — kotlin's dispatch has NO property_declaration/varinit key (unlike SWIFT_SPEC — do not borrow it). Pinned: torture emits exactly three function_refs — topLevel (definedHere), OtherClass::handle, this.caller.
  • Flush gate (639-728): generated-file skip; this.-prefixed + ::-containing candidates always flush; bare names need definedHere (same-file function/method names) ∪ importedNames (dotted-import last segments — §Imports). Dedupe ${fromNodeId}|${name} → {referenceKind:'function_ref'} (FUNCTION_REF_CODE 200 on the wire).

visitFunctionBody (5129-5286) — kotlin rows

  • maybeCaptureFnRefs (5137) per node; macro_invocation branch rust-gated.
  • call_expression → extractCall (5143), NO return → children recursed (chains/args re-visited).
  • INSTANTIATION_KINDS (5145) — never for kotlin. extractBareCall — absent.
  • extractStaticMemberRef (5218) — every body node (§Static-member).
  • variable_declarator type-annotation branch (5230) — dead (no such kind).
  • Nested function_declaration (5245) → named → extractFunction (→ receiver check → possibly extractMethod). Local funs become function nodes (QN …::caller::localFn).
  • classTypes (5255): a body-local class LocalClass { … } → full extractClass (kind via classify), contained by the enclosing function — its methods extract normally (pinned: caller::LocalClass::lm).
  • object_declaration in a body is NOT dispatched (extraClassNodeTypes is not checked in visitForCallsAndStructure) → recursed → its class_body's funs hit 5245 → extractFunction: a local object's methods become FUNCTIONS contained by the enclosing function (pinned: caller::om), its properties mint nothing (no hook here). Same for object_literal (anonymous objects): val anon = object : Drawable { override fun draw() … } in a body → NO class node, NO extends ref, draw leaks out as a function under the enclosing fn with its body calls attributed to it (pinned: caller::draw, calls helper from=function:draw). At TOP-LEVEL/class scope the same object_literal sits inside a hook-consumed property → completely invisible (methods and all — scan halts at nothing relevant but extraction never runs). Pin the asymmetry.
  • Bodies recursed transparently through when/if/for/try (control_structure_body), elvis, postfix !!, labels (label@), lambdas (annotated_lambda/lambda_literal — enclosing-fn attribution), string templates (interpolated_expression emits calls; interpolated_identifier is inert).

Misc shared paths

  • Positions: line = startPosition.row + 1, column = startPosition.columnUTF-16 code units (textutil::col16), as are startIndex/endIndex substrings (getNodeText everywhere) and the import-signature .trim().
  • Refs carry NO filePath/language (store denormalizes; §arch-7 — no REF_FLAG_FILE_PATH use). function_ref = wire code 200.
  • extract() wrap: file node → namespace node (if package) → walk order → flushFnRefCandidates then flushValueRefs (538-539, both while the namespace is still pushed) → pops. Table order: nodes in creation order; contains edges interleaved with creation, value-ref EDGES appended LAST; walk-order refs, then function_ref refs at flush. Store/harness are rowid-order-sensitive — reproduce exactly.
  • CRLF hazards inventory for the kotlin path: kotlin.ts has NO regexes over multi-line source (getReturnType's /^[A-Za-z_]\w*$/ and the visibility .includes are single-token); the shared paths' regexes that fire for kotlin are extractCall's parenthesized-conversion (4530, single-name, \s* can eat \r — port as-is with \s semantics), the #750 \s+ strip (JS \s\r — Rust regex \s matches \r too, parity holds), decorator name normalization (4959-4962), and cleanCommentMarkers' gm strips → js_multiline_strip in docstring.rs (#1329), call it. Grammar-level CRLF probed clean (identical shapes, no errors, extract-crlf.txt byte-sane; the comment-gluing reproduces on CRLF identically).
  • Defer policy: per-file has_error()defer:; expected incidence 4.7–8.5% (§arch-6) including the PHANTOM class (hasError with no ERROR/missing node — trust the flag, not node presence). wasm recovery is canonical; fun-interface files always land here.
  • MAX_FILE_SIZE / generated-file skips: shared, nothing kotlin-specific.

Frameworks & synthesis consumers (stay TS-side — pin the walker's output contract)

  • kotlinExpectActualEdges (resolution/callback-synthesizer.ts:987-1026; doc block :955-985) — the tracker's "expect/actual pairing is synthesis-side". Reads queries.iterateNodesByLanguageWithDecorator( 'kotlin','actual') (db/queries.ts:1082 — a LIKE pre-filter over the node DECORATORS column) then exact decorators.includes('actual'), getNodesByQualifiedNameExact(act.qualifiedName), kind compatibility via KMP_TYPE_KINDS {class, interface, struct, enum, type_alias} (:982 — actual typealias fulfillment), different file, counterpart NOT marked actual → synthesized calls edge decl→actual. Walker obligations: decorators content+order from extractModifiers; EXACT qualifiedNames (package-prefixed — com.example::PlatformFile); kinds; filePath; startLine. Validate on kotlinx.coroutines (the KMP gate repo: 30 expect-files / 95 actual-files at survey time) — spot-check synthesizedBy: 'kotlin-expect-actual' edge counts are IDENTICAL under kernel and wasm arms after a full init of each.
  • Closure-collection pass (callback-synthesizer.ts:252-326, CC_LANGUAGES :77 = {swift, kotlin} — the #1235 gate) — synthesis-side, no port, but it consumes extraction artifacts: for every method/function node with language === 'kotlin' it re-reads source and slices sliceLines(content, m.startLine, m.endLine), regexing .forEach { it( dispatchers and .append/.add/.push/.insert( registrars. Walker obligations: method/function node startLine/endLine spans (incl. the resolveBody endLine extension) and the language tag — a truncated endLine silently drops dispatch edges.
  • rnCrossPlatformEdges (callback-synthesizer.ts:1645+) — kotlin ∈ NATIVE set (:1649); pairs native method/function node NAMES across java/kotlin/objc/cpp with JS callers. Standard node-table obligation only.
  • springResolver / expoModulesResolver / fabricViewResolver (§arch-2) — regex over raw .kt source in extractFromSource:6736-6758; their route nodes carry literal ids (route:${filePath}:${line}:…) and their refs carry filePath+language (framework refs, unlike extraction refs). No walker dependency beyond method/class node names for their handler-ref resolution.

Parity mechanics (all have bitten before)

  • Emission order per §Misc: file → namespace → source-order walk (per construct: node + contains edge → extends refs BEFORE body members → extractor-order refs) → function_ref refs → value-ref EDGES last.
  • generateNodeId inputs: (filePath, kind, name, startRow+1) — name keeps backticks; import nodes are named the dotted path; enum_member line = the IDENTIFIER's line (not the enum_entry's — same line in practice, but the COLUMN and the node's position row both come from the identifier child); namespace line = the package_header start; the glued-comment extents affect endLine/endColumn (and import signatures), never the id line.
  • Receiver-QN methods: id hashes the NAME only — the qualifiedName override (WidgetK::extend) does not enter generateNodeId.
  • UTF-16 columns/slices (textutil::col16/slice_utf16): every ref/node column, getNodeText substrings, import-signature trim. Kotlin sources are multibyte-heavy (string templates, KDoc) — the torture fixture needs a non-ASCII line before a symbol.
  • CRLF: §Misc inventory; CRLF variants of every fixture derived in-memory (kernel-tsjs-parity pattern).
  • Defer policy: has_error()defer: — INCLUDING phantom errors (complete CSTs; do not "optimize" by checking for ERROR nodes) and every fun-interface file. Sweep with the default --max-deferral 0.1; expected deferral counts okio 23 / okhttp 49 / kotlinx.coroutines 51.
  • node-ID-string dedupe: node_ids vec pattern (same-(kind,name,line) collisions are routine — e.g. one-line class X { fun x() … } shapes).

Gates (per plan §5, no exceptions)

  • Standalone GRAMMAR-BUMP gate first (rust pattern), before any walker: vendor the wasm + VENDORED_WASM_LANGS += 'kotlin' (+ the kernel C vendor/build.rs/langs.rs/grammar-parity row staged with it), then old-vs-new full-init dump-diffs (scripts/dump-graph.mjs, cmp) on the three gate repos with the kernel OFF both arms. Expected: byte-identical on all three (behavior-neutral bump — any hunk at all blocks). Full suite green ×2.
  • Torture fixtures per ## Fixtures to build below, exercised by a new __tests__/kernel-kotlin-parity.test.ts.
  • Parity sweeps (scripts/kernel-parity.mjs <dir>, order-sensitive full-object, default --max-deferral 0.1):
    • /private/tmp/claude-501/-Users-colby-Development-CodeGraph-codegraph/0c11bda1-0b19-4fec-bcd9-d0cb4b2d6e8a/scratchpad/gate-repos/okio (small, 322 kt/kts files)
    • …/gate-repos/okhttp (medium, 580)
    • …/gate-repos/kotlinx.coroutines (large, 1,082 — the KMP/expect-actual gate) (cloned fresh at survey; re-clone public OSS if gone — agent-eval policy). Expect 0-diff on every NON-deferred file and exactly the §arch-6 deferral counts. Then full-init dump-diffs byte-identical (kernel arm vs CODEGRAPH_KERNEL=0, dump-graph.mjs, cmp) on the same three.
  • KMP synthesis spot-check (tracker row requirement): after the kotlinx.coroutines dumps, select count(*) from edges where json_extract(metadata,'$.synthesizedBy')='kotlin-expect-actual' equal across arms (the dump gate already implies it; assert it explicitly once).
  • Suite: kernel-kotlin-parity torture + CRLF variants + the defer fixture (a fun interface file — asserts kernel defer: + wasm-served output matches extract-funiface.txt shape) + a phantom-error fixture (single-line class body — kernel defers despite the complete CST); full suite ×2 green with CODEGRAPH_KERNEL_EXPECT=1.
  • DEFAULT_ROUTED += 'kotlin' (kernel/index.ts:37) only after ALL of the above; changelog rides the existing kernel entry.
  • Post-route perf sanity: gate repos ride the raw path (§arch-2); a Spring-Boot-Kotlin or Expo repo is the decoded-path smoke check. Deferral costs mean the kotlin speedup lands on ~92-95% of files — measure accordingly.

Fixtures to build

  1. __tests__/fixtures/kernel-parity/torture.kt — the survey's svy-kotlin/torture.kt is the seed (its extract-torture.txt is the expected-output pin). Inventory, by branch: package header + file KDoc + @file: annotation; imports: plain, dotted, wildcard (.* → package path), alias (as — source path), comment-glued signature (comments after the last import); top-level fn with params/return (ret from user_type; sig undefined); extension fns: plain (WidgetK::extend), generic receiver (List), qualified receiver (com::qext bug), infix (Int::pow), operator; suspend fn (isAsync true) + the @suspendMarker text-includes false positive; private internal (visibility order) + an internal fun ('internal'); inferred return / Unit / nullable / lambda return / : T generic leak; expect fun (bodiless + dec) / actual fun; tailrec self-call in expression body; top-level val/var/const val/by lazy {} (constant/variable kinds, NO initializer refs, NO capture inside the delegate lambda) + destructuring (val (a,b) → nothing, both scopes) + next-line-getter top-level val (getter calls → file/namespace); class with primary ctor (props invisible, defaults not walked), class-body val/var/computed (fields; same-line getter consumed vs next-line getter → class-attributed calls), init block + secondary ctor (class-attributed calls), methods, companion (constants under the class + method + named companion Named minting nothing); bodiless-vs-bodied header asymmetry (class Bodiless(val b = initB()) : Base(readCfg2()) → extends + 2 class-level calls; the bodied twin → extends only); data class (no members); abstract/open one-liners (phantom-error shapes — but keep the PARITY fixture erroring-free: single-line bodies go in the DEFER fixture instead, since the kernel defers them!); interface (no visibility; bodiless method nodes; default impl; val prop w/ same-line getter → field); sealed class + nested object/data class members (extends SealedOp); sealed interface (bodiless); enum: simple entries (positions = identifier), ctor'd entries, entry-with-body (overrides invisible), post-; methods + companion-in-enum; object declaration (class kind; members; value-ref targets); annotation class; annotated class/method (@MyMarker/@JvmStatic → decorates; @Deprecated("x", …) → NOTHING; @field:/@get: on properties → nothing); typealias ×2 (no value refs)
    • actual typealias (dec on type_alias); expect class + actual class (+ marked member); call shapes: bare, constructor (WidgetK calls ref, NO instantiates), this./super. (bare), member, aliased-import receiver, literal receivers (nothing), capitalized-chain re-encode (WidgetK.create().render + inner) + lowercase chains (bare + inner), 2-hop nav call, safe-call x?.render() (plain encoding), !! receiver (bare), elvis-arm ctor call, trailing lambda (run {} / w.let {} / listOf(1).forEach {}), paren-then-lambda (trailing() {}trailing() + trailing), generic call (generic<Int>(1)generic), backtick call, glued newline-invoke chain (raw-text callee — or a note excluding it if the fixture keeps statements separated; pin ONE of the two deliberately), (handler)(4) first-in-body (conv-regex rewrite), interpolation call ("${w.render()}") + $id (nothing); static-member reads: Registry.count / Color.RED (references at receiver pos), com.example.Fq.X (nothing), Cls.member as callee (skip), assignment LHS writes (nothing); delegation specifiers: plain + ctor'd + generic + qualified (com bug) + by delegation (nothing); local declarations in bodies: named local fn, local class (full), local object (methods leak as functions), object : Iface {} literal (function leak; and the top-level-property twin — invisible); callable refs: ::topLevel (defined-here gate), OtherClass::handle (import gate irrelevant — always-flush), w::render (dropped), this::caller (this. flush), String::class (nothing), val m = ::caller (NOT captured), assignment obj.cb = ::handler (captured), named-arg f(cb = ::handler) (fan-out capture); value refs: companion/object constants + readers (incl. a ${CONST} interpolation read and a $CONST non-read), the local-shadow prune, count-style non-target names, and the namespace-drop (packaged file: top-level consts are NOT targets — plus the un-packaged .kts/no-package twin where they ARE); docstrings: // runs (kept), KDoc (dropped), KDoc above a // run (run kept), KDoc between run and decl (chain broken), comment-after-imports (lost to gluing); a non-ASCII (UTF-16) line before a symbol; a when/for/labeled-loop body.
  2. CRLF variants of the fixtures derived in-memory (kernel-tsjs-parity pattern) — docstring cleaning + comment-gluing + import signatures under CRLF bytes.
  3. .kts fixture — top-level statements (calls from the FILE node), top-level val (file-parent value-ref target), no package header.
  4. Defer fixture #1: fun interface (Pattern 1 shape) — kernel defers (defer:), wasm output serves the pinned interface+method recovery (extract-funiface.txt).
  5. Defer fixture #2: phantom errorabstract class A { abstract fun i(): Int } one-liner — kernel defers on has_error() despite a complete, ERROR-node-free CST; wasm output is byte-normal.
  6. KMP fixture pairexpect class + fun in one file, actual class
    • actual typealias in another (same QNs, different files) — feeds the expect/actual synthesizer identically under both arms (can fold into the frameworks-integration or synthesizer suites if simpler).

Probe artifacts (session scratchpad svy-kotlin/)

table-compare.cjs (kind/field/ABI tables), shape-probe-kotlin.cjs + torture-{OLD,NEW}.txt (full CST dumps, byte-identical) + torture.diff (empty), mini-probes.cjs + mini-probes.out (fun-interface ×2, destructuring ×2, safe-call/elvis, assignment shapes, file annotation, delegates, named-arg refs, getter nesting, KDoc runs, CRLF, suspend false-positive, lateinit, top-level object literal — all OLD==NEW), error-sweep.cjs + errors-{okio,okhttp,kotlinx.coroutines}.txt, extract-probe.cjs + extract-{torture,vref,vref-nopkg,docs,bodiless,crlf, funiface,lfpkg,kts}.txt (dist-extractor ground truth), fixtures (torture.kt, vref.kt, vref-nopkg.kt, docs.kt, bodiless.kt, crlf.kt, funiface.kt, lfpkg.kt, script.kts), grammar material (tree-sitter-kotlin/ 0.3.8 tag clone + the tree-sitter.json shim, crate-extract/ = crates.io 0.3.8 tarball, ng-extract/ = kotlin-ng 1.1.0 tarball, tree-sitter-kotlin-NEW.wasm = the staged-candidate build, sha256s in §Grammar prep). Scratch dirs are throwaway — re-derive from this doc if gone.