Skip to content

fix(kotlin): resolve a constructed class, so a DI-heavy backend keeps its coupling (#387) - #388

Open
johnatbasicas wants to merge 2 commits into
trailhq:mainfrom
johnatbasicas:fix/kotlin-constructor-call-edges
Open

johnatbasicas wants to merge 2 commits into
trailhq:mainfrom
johnatbasicas:fix/kotlin-constructor-call-edges

Conversation

@johnatbasicas

@johnatbasicas johnatbasicas commented Sep 14, 2026

Copy link
Copy Markdown

Closes #387.

What

Kotlin construction (Mailer(host = "smtp")) produced no incoming edge, so callers and blast reported nothing for service classes that are constructed across the codebase. Only free functions resolved.

resolve.ts already has this exact fallback for Python (PY_CTOR_KINDS) and Swift (SWIFT_CTOR_KINDS): types are tried only after functions have found nothing. Kotlin is Swift's case again — no new to mark construction, and free functions exist too, so it takes the same fallback rather than Java's outright widening. This adds Kotlin to that list.

class alone, rather than Swift's three kinds, because Kotlin's other type kinds are not reachable this way — data class, object and companion object are already kind class; an enum class constructor is private, so Color(…) cannot be written from outside. interface is reachable in principle (a fun interface is SAM-converted by writing Action { … }, and since Kotlin 1.6 an annotation class can be instantiated) but tree-sitter-kotlin@0.3.8 cannot parse fun interface at all — it yields an ERROR node, so no such interface becomes a node to resolve against. The comment says so, and flags it as worth revisiting if that grammar gap closes.

The fallback inherits the fallback's imprecision honestly, and the comment says that too: class also covers object, where Factory() may be an operator fun invoke; a local val Mailer = factory; Mailer() shadows the type it names. Python and Swift already make that trade; this is no looser. Ordering keeps it safe — a real function of that name resolves first — as does resolveName's unique-match rule, which drops the ambiguous rather than picking.

Measured

On a production Kotlin + TypeScript monorepo (1,340 indexed files, 717 .kt, 13,589 symbols, 30,842 edges):

  • a service class constructed in a Koin module and instantiated five times in its integration test went from no indexed callers to its real construction sites
  • 30,842 → 33,027 edges (+2,185), 0 false positives against a source-text audit
  • free functions were already excellent and are unchanged — one callers query there returns 51 files with exact call sites

Tests

5 tests in test/graph-kotlin.test.ts, each verified red before the change and green after:

  • a constructed class resolves, and a function call still resolves first
  • construction inside a class body resolves too
  • construction does not reach a same-named class in another language
  • .kts is covered alongside .kt
  • an object with operator fun invoke is acknowledged by the ordering rule

Full suite: 1226/1226, exit 0. tsc -p tsconfig.json --noEmit: exit 0.

(The suite needs LC_ALL=en_US.UTF-8; without it nine pre-existing thousands-separator assertions fail on main too — unrelated to this change.)

Platform safety

CI gates on windows-latest as well as ubuntu-latest, so the one new regex is worth stating explicitly: KOTLIN_EXT = /\.kts?$/i matches a file extension, anchored at end of string. It never looks at a path separator, so \ versus / cannot change its result, and it is unaffected by line endings — it is applied to e.file, not to file contents. Both .kt and .kts are covered, with a test pinning .kts.

The change adds no filesystem access, no shell invocation and no path construction, so there is nothing else here with a platform-dependent surface.

…allers (trailhq#387)

`Mailer(host = "smtp")` is an ordinary `call_expression` — Kotlin has no `new`
to mark construction — so every constructor edge was matched against the
function-only index and dropped. A class therefore reported `no indexed callers`
while a free function declared beside it in the same file reported all of them,
which on a DI-heavy backend loses exactly the coupling that matters: measured on
a 717-file Kotlin monorepo, a service class constructed in a Koin module and
instantiated five times in its integration test had no incoming edges at all.

Same fallback shape Python and Swift already use — types are tried only once
functions have found nothing, so a real function call still resolves first.
`class` alone: `data class` and `object` are that kind already, and an `enum
class` or `interface` cannot be constructed by name. A package-qualified
spelling still drops rather than reducing to its last segment, for the reason
`javaConstructedTypeName` does (trailhq#103).
The previous wording claimed interfaces and annotations have no constructor.
They do: a `fun interface` is SAM-converted by writing `Action { … }`, an
ordinary call expression, and since Kotlin 1.6 an annotation class can be
instantiated. The real reason to leave the kind out is narrower and measured —
tree-sitter-kotlin@0.3.8 cannot parse `fun interface` at all, so no such
interface is ever a node to resolve against.

Also states the imprecision the fallback actually carries instead of implying it
has none: `class` covers `object`, where `Factory()` may be `operator fun
invoke`, and a local val can shadow the type it names. Python and Swift make the
same trade directly above.

Two tests for the properties that keep it safe: a function named after a type
still wins (the stdlib's own factory idiom), and `.kts` behaves like `.kt`.
@trailhq-graft

trailhq-graft Bot commented Sep 14, 2026

Copy link
Copy Markdown

🌱 graft blast radius

1 area changed → 3 areas can be affected. 5 dependent symbols, depth 2.
Tests: Edge Resolution has tests the diff did not touch.
Tag: @anirudhkumar-nanonets — 4 of 4 areas · @shhdwi — Edge Resolution, Graph Construction

flowchart TB
  A0(("Graph Construction<br/>2 symbols"))
  A1(("Pull Request Review<br/>2 symbols"))
  A2(("Graph Engine<br/>1 symbol"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2 reached;
Loading
Can be affected Symbols Nearest hop Reached from
Graph Construction 2 src/graph/build.ts:L151-L410 buildGraph — calls, depth 1 Edge Resolution
Pull Request Review 2 src/app/brain-build.ts:L251-L358 readRepository — calls, depth 2 Edge Resolution
Graph Engine 1 src/engine.ts:L91-L101 graph — calls, depth 2 Edge Resolution
Who knows this code — 2 people across 4 areas
Area Who knows it
Edge Resolution · changed @shhdwi — 9 commits, last 1mo ago · @anirudhkumar-nanonets — 7 commits, last 20d ago
Graph Construction · affected @anirudhkumar-nanonets — 18 commits, last 25d ago · @shhdwi — 5 commits, last 1mo ago
Pull Request Review · affected @anirudhkumar-nanonets — 9 commits, last 4d ago
Graph Engine · affected @anirudhkumar-nanonets — 16 commits, last 2mo ago

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 5 dependent symbols, grouped by area

Graph Construction — 2 symbols in 2 files

  • src/graph/build.ts:L151-L410 — buildGraph (calls, depth 1)
    197: new Set(files.map((f) => genericLangOf(f.abs)?.name).filter((n): n is string => !!n)),
  • src/graph/refresh.ts:L150-L227 — ensureFreshGraph (calls, depth 2)

Pull Request Review — 2 symbols in 2 files

  • src/app/brain-build.ts:L251-L358 — readRepository (calls, depth 2)
    263: const phase = (name: string): (() => void) => {
  • src/app/review.ts:L45-L99 — reviewPullRequest (calls, depth 2)
    66: // Same two passes `graft blast --name` runs, in the same order: a reviewer's

Graph Engine — 1 symbol in 1 file

  • src/engine.ts:L91-L101 — graph (calls, depth 2)
Test signal per changed area — 1 ⚠

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • Edge Resolution — 1 of 1 reached · 3 test files reach it, none changed here
35 test suites also reference this code

38 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/container-extract.test.ts
  • test/context-only-dir.test.ts
  • test/context.test.ts
  • test/covers.test.ts
  • test/generic-extract.test.ts
  • test/graph-cross-language.test.ts
  • test/graph-go.test.ts
  • test/graph-incremental.test.ts
  • test/graph-invariants.test.ts
  • test/graph-java.test.ts
  • test/graph-languages.test.ts
  • test/graph-php.test.ts
  • test/graph-posix-paths.test.ts
  • test/graph-python.test.ts
  • test/graph-r-classes.test.ts
  • test/graph-r-phase3.test.ts
  • test/graph-r-phase4.test.ts
  • test/graph-r-phase5.test.ts
  • …15 more

graft blast · origin/main...HEAD · depth 2 · 2 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kotlin: class construction produces no edge, so callers and blast report nothing for DI-injected service classes

2 participants