Skip to content

Unify Kotlin test suites#22135

Draft
andersfugmann wants to merge 30 commits into
mainfrom
andersfugmann/kotlin-unify-tests
Draft

Unify Kotlin test suites#22135
andersfugmann wants to merge 30 commits into
mainfrom
andersfugmann/kotlin-unify-tests

Conversation

@andersfugmann

Copy link
Copy Markdown
Contributor

This unifies the Kotlin test suites by moving the test-kotlin1/test-kotlin2 contents into a single test-kotlin suite.

What changed:

  • Moved the K2 test suite contents into java/ql/test-kotlin/ and removed the split test-kotlin1 / test-kotlin2 layout.
  • Updated the expect files so the shared suite reflects the extractor fixes.
  • Kept the reviewable history in separate commits:
    1. extractor/location fixes plus expect updates
    2. suite unification and file moves

Review notes:

  • Review one commit at a time.
  • The first commit shows the expect-file convergence work.
  • The last commit is the structural rename / delete step that unifies the suites.

@andersfugmann andersfugmann force-pushed the andersfugmann/kotlin-unify-tests branch 7 times, most recently from a0e698f to ff707b3 Compare July 9, 2026 12:29
…otlin2

- Port ministdlib from test-kotlin1 to test-kotlin2. The ministdlib test
  exercises a minimal Kotlin standard library written from scratch. Its
  options file is updated to include -language-version 2.0 so the test
  runs in K2 mode when the K2 compiler is active.

- Port nested_types from test-kotlin2 to test-kotlin1. The nested_types
  test exercises type-alias and inner-type queries. Expected output is
  identical in K1 and K2 modes so no expected-file changes are needed.

- Add test-kotlin2/options with codeql-extractor-kotlin-options:
  -language-version 2.0. The CodeQL CLI adds -language-version 1.9 by
  default in legacy test extraction mode. Without this override the K2
  test suite would run in K1 mode, defeating the purpose of the split.

Both ministdlib and nested_types produce byte-identical expected output
across K1 (2.3.20, -language-version 1.9) and K2 (2.4.0, default K2).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@andersfugmann andersfugmann force-pushed the andersfugmann/kotlin-unify-tests branch 2 times, most recently from f458cd6 to 0027db7 Compare July 10, 2026 13:32
andersfugmann and others added 7 commits July 10, 2026 19:58
In K2 mode the frontend emits `-123L` as IrCall(unaryMinus, IrConst(123L))
rather than IrConst(-123L) as in K1. Queries that search for negative numeric
literals therefore need to match both a UnaryMinusExpr wrapping a literal and a
plain literal, depending on language mode.

Fix: when extractCallExpression encounters an isNumericFunction(unaryMinus) call
whose dispatchReceiver is already an IrConst, fold the negation into the constant
before extracting. The resulting literal node is identical to what K1 emits.

Location: extend the span one character to the left to cover the `-` sign.
In K2 the IrCall's startOffset equals the receiver's startOffset, so we recover
the minus by subtracting one from the receiver offset.

K1 is unaffected: the K1 frontend folds the sign into the constant before IR
generation, so this new branch never triggers when compiling with -language 1.9.

Expected output changes:
- test-kotlin2/library-tests/literals/literals.expected: negative long, float and
  double literals now appear as plain typed literals instead of as UnaryMinus nodes.
  The file is now byte-identical to test-kotlin1/library-tests/literals/literals.expected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d test-kotlin2

Remove the K1-only comment '// This doesn't generate a throw statement in
Kotlin 1 mode' from test-kotlin1/library-tests/generated-throws/generated-throws.kt
so the source files are byte-identical between the two suites.

The expected outputs still legitimately differ: in K2 mode the compiler generates
an implicit throw NoWhenBranchMatchedException for the exhaustive sealed-class when
expression, but the K1 frontend does not emit this node. This is a mode-specific
behaviour difference that cannot be bridged by an extractor change.

Both tests continue to pass:
- test-kotlin1 (kotlinc 2.3.20 / -language 1.9): 0 throw results (unchanged)
- test-kotlin2 (kotlinc 2.4.0 / -language 2.0): 1 throw result (unchanged)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy K1's richer C2.kt (which includes test2, test3 and l.get(0) in test)
into test-kotlin2. K2 in -language 2.0 mode already tracks dataflow through
array.set() and indirect wrapper calls, so the additional tests produce results
identical to K1.

The expected files for test-kotlin1 and test-kotlin2 are now byte-identical for
this test.

Verified:
- test-kotlin1 (kotlinc 2.3.20 / -language 1.9): all tests pass
- test-kotlin2 (kotlinc 2.4.0 / -language 2.0): all tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
K1 and K2 IR backends compute source locations differently. K1 uses the IR
node's synthetic startOffset/endOffset, while K2 reconstructs source positions
from PSI. For expression-level nodes this causes location differences across
the two language modes.

Introduce PSI-backed location lookup as the preferred source for spans wherever
PSI is available:

  getPsiBasedLocation(element) ?: tw.getLocation(element)

getPsiBasedLocation() resolves the PSI element for the IR node via
psi2Ir.findPsiElement() and builds a location from its startOffset..endOffset.
currentIrFile is tracked in extractFileContents so the PSI lookup has the
file context it needs.

Applied to expression-level nodes (both K1 and K2 modes):
- local variable declarations (extractVariable, extractVariableExpr)
- IrLocalDelegatedProperty blocks
- IrWhen expressions and when-branches
- IrGetValue (varaccess) expressions
- IrFunctionExpression (lambda) nodes
- Block statements (extractBlock)
- this/super access expressions (extractThisAccess)
- String literals

Declaration-level nodes (class, function, property) are guarded with
if (usesK2) to avoid a regression in K1 mode where the PSI lookup causes
parameterised type instantiations to appear as fromSource(), inflating
generic-type query results. The K1 IR frontend does not map all declaration
nodes cleanly to source PSI elements; for these nodes we keep the original
IR-based location in K1 mode.

Expected output changes (both suites):
- controlflow/basic/bbStmts, bbStrictDominance, bbSuccessor, getASuccessor,
  strictDominance: when-branch and varaccess location improvements
- java-kotlin-collection-type-generic-methods/test: new stdlib entries from
  JDK update (AbstractCollection<Runnable> methods)
- annotation_classes/PrintAst: variable access location improvement in K1
- classes/genericExprTypes: location improvement in K1
- compilation-units/cus: removed two internal JDK inner-class entries (stdlib
  version change)
- reflection/reflection: removed a few external-class entries (stdlib version)

Verified: all 285 tests pass for both test-kotlin1 (kotlinc 2.3.20 / K1) and
test-kotlin2 (kotlinc 2.4.0 / K2).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In K1 mode, the IrCall node for the !! operator stores startOffset at
the '!' character rather than at the start of the operand. This means
that x!! was previously reported as spanning from '!' to the end of the
expression, omitting the operand from the location.

Fix this by computing the NotNullExpr location from the value argument's
startOffset (the operand) to c.endOffset. This matches the K2 behaviour
where the IrCall.startOffset already points at the operand.

The fix guards against invalid (< 0) offsets on either side and falls
back to the default IrCall location in those cases.

Updated expected files in test-kotlin1:
- exprs/unaryOp.expected: !! locations now start at operand column
- exprs/exprs.expected: same (NotNullExpr entries corrected)
- exprs/binop.expected: !! child locations now correct (parent binop
  location for s!!.plus(5) still differs due to a separate K1 IR
  limitation where the enclosing call inherits the wrong receiver offset)
- controlflow/basic/bbStmts.expected: CFG references to !! now use the
  improved location
- controlflow/basic/getASuccessor.expected: same

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…r to initializer

In K1 mode, IrVariable.startOffset points to the name identifier and
IrVariable.endOffset is also at the name end, giving a location that
covers only the variable name (e.g. "local1") rather than the full
declaration ("val local1 = 2 + 3").

In K2 mode the IR offsets already point from the val/var keyword to the
end of the initializer, matching the intuitive source span.

Fix this for K1 by adding an IrVariable-specific overload of
getPsiBasedLocation that:
1. finds the leaf PSI element at v.startOffset (the name identifier)
2. walks up to the enclosing KtVariableDeclaration (KtProperty)
3. uses the val/var keyword position as the start offset to exclude
   any leading annotations from the span

This matches the K2 IR behavior and gives a more complete location for
variable declarations when used in CodeQL queries.

The fix applies to both K1 and K2 since the overload is unconditional;
for K2 the walk-up gives the same result as before.

Expected updates in test-kotlin1:
- variables: local variable locations now span from val/var to initializer
- exprs, stmts, methods, modifiers, reflection: same local variable span
- controlflow/basic: CFG node references updated for new variable spans
  (the "var ...;" node now starts at the val/var keyword)

Class member and top-level properties (IrProperty, not IrVariable) are
not affected by this change and retain their existing location behaviour.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
K1 IrProperty.startOffset includes leading modifiers (private, abstract,
lateinit, annotations) in the span start; K2 already starts at val/var.
Walk the PSI tree from p.startOffset to the enclosing KtProperty, then use
valOrVarKeyword.startOffset as the declaration start, giving a consistent
start in both K1 and K2.

Two related but distinct locations are derived from the KtProperty:

- The property itself spans val/var through the end of the full
  declaration (KtProperty.endOffset), including an explicit getter/setter
  body on a following line. This is getPsiBasedLocation(IrProperty).
- Synthesised accessors (DEFAULT_PROPERTY_ACCESSOR origin) span val/var
  through the end of the property name (KtProperty.nameIdentifier.endOffset)
  via getPsiBasedAccessorLocation, applied through accessorOverride().
  Explicit getter/setter bodies keep their own independently computed
  location.

This makes K1 accessor locations match K2 and gives each synthesised
accessor a precise span, rather than the property's full declaration span.

Example (properties.kt line 3, "var modifiableInt = 1"):
  property  modifiableInt     -> 3:5:3:25   (val/var .. end of "= 1")
  accessor  getModifiableInt  -> 3:5:3:21   (val/var .. end of name)
  accessor  setModifiableInt  -> 3:5:3:21

Because accessor locations appear wherever accessors are reported, this
refinement updates many expected files (property listings, modifiers,
methods, reflection, control-flow and expression dumps). Every change is a
location-coordinate change only: no result tuple is added or removed.

The PSI-based location is restricted to unspecialised extractions
(classTypeArgsIncludingOuterClasses.isNullOrEmpty()). Specialised generic
instances (e.g. C<String>.prop) continue to use the binary whole-file
location returned by getLocation(p, typeArgs), preserving the existing
behaviour that keeps them absent from fromSource() queries.

The visibility merge in extractFunction is extended to accept an
overriddenAttributes parameter from the caller; the internal fake-override
visibility adjustment (DescriptorVisibilities.PUBLIC for Java binary Object
methods) is merged with any caller-supplied attributes so that neither
overrides the other silently.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@andersfugmann andersfugmann force-pushed the andersfugmann/kotlin-unify-tests branch from 06b0b1a to 327eedc Compare July 10, 2026 18:03
andersfugmann and others added 2 commits July 10, 2026 22:35
…guage versions

The local variable declaration expression (extractVariableExpr) computed its
location via getPsiBasedLocation(v as IrElement). The `as IrElement` cast forced
overload resolution to the generic getPsiBasedLocation(IrElement), which resolves
the PSI element straight from the IR element's raw source offsets via
PsiSourceManager.findPsiElement. Those offsets differ between the two frontend
language versions:

  fun f(param: Int) {
      val local1 = 2 + 3   // line 6: `        val local1 = 2 + 3`
  }

- With -language-version 1.9 the IrVariable offsets cover only the name, so
  findPsiElement returns the name leaf and the variable is located at 6:13:6:18.
- With -language-version 2.0 the IrVariable offsets cover the whole declaration,
  so it is located at 6:9:6:26 (the `val` keyword through the initialiser).

The IrVariable-specific overload getPsiBasedLocation(IrVariable) is frontend
stable: it finds the leaf at the variable's start offset, walks up to the
enclosing KtVariableDeclaration and returns the span from the `val`/`var` keyword
to the end of the declaration. Using it for the entity location makes both
language versions emit the full declaration span 6:9:6:26.

This is the same helper already used for the enclosing localvariabledeclstmt
location; this change applies it to the variable entity (localvars / the
localvariabledeclexpr) as well, so the two are consistent.

Effect: 12 test-kotlin1 expected files move toward the test-kotlin2 output (all
strictly reduce the tk1-vs-tk2 difference; e.g. variables 16->4, reflection
111->67, exprs 1759->1363 diff lines). test-kotlin2 output is unchanged (the
overload produces the same span there as the generic one did). Both suites pass
all 3333 tests with --check-databases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Also update the enhanced-nullability Kotlin integration test expected: its
local variable declaration expression now spans the val/var keyword to the
initialiser (user.kt:2:7:2:7 -> user.kt:2:3:2:16), consistent with the
library-test convergence.
…rsions

Anchor a property backing field's location on its property declaration (the
`val`/`var` keyword to the end of the declaration) rather than the raw IR
offset. The raw `IrField` offset is inconsistent between frontends: under
`-language-version 1.9` it includes leading modifiers in the span start, while
under 2.0 it starts at the `val`/`var` keyword.

Example: `private val privateProp: Int = 0`

  before (lang 1.9):  properties.kt:35:5:35:32  | int privateProp;  (col 5 = `private`)
  after  (lang 1.9):  properties.kt:35:13:35:32 | int privateProp;  (col 13 = `val`)
  lang 2.0 (unchanged): properties.kt:35:13:35:32 | int privateProp;

The property entity already uses this PSI-based anchor (getPsiBasedLocation),
so the field now matches its own property location, which is what the 2.0
frontend already emits.

Delegated properties are excluded via `isDelegated`: their field is the
`$delegate` storage, whose location is the delegate expression rather than the
property declaration, and is converged separately.

This is a no-op for `-language-version 2.0` (only test-kotlin1 expected files
change); the two suites' backing-field and field-type-access locations now
agree.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@andersfugmann andersfugmann force-pushed the andersfugmann/kotlin-unify-tests branch from 7e79e69 to 19774cc Compare July 10, 2026 20:36
andersfugmann and others added 7 commits July 11, 2026 01:46
…ve spans

Synthesised and bare `get`/`set` accessors were extracted with different
source locations depending on the frontend:

    val typedProp: Int = 3            // getTypedProp
        K1: 5:5:5:17  (val..name)     K2: 5:5:5:22  (val..type)

    val defaultGetter = 7
        get                           // getDefaultGetter
        K1: 19:5:19:21 (property head) K2: 20:13:20:15 (`get` keyword)

Under K2 the extractor has no PSI back-mapping for these accessors
(`getKtFile` returns null), so it cannot reproduce K1's property-name-end
span; K2 instead falls back to the raw IR offsets. Rather than converge on a
value K2 cannot produce, K1 is made to match the K2-native spans via the PSI:

  * a bare `get`/`set` keyword now points at the keyword token; and
  * a fully synthesised accessor now spans the property signature
    (`val`/`var` .. type annotation, or .. name when untyped), excluding the
    initialiser.

Explicit-body accessors (`get() = 5`) are unaffected: they are located at
their body and never take this override.

Only K1 output changes; the test-kotlin2 (K2) expected files are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ative class span

The K1 and K2 frontends record different source locations for the
compiler-synthesised primary constructor of a class that declares no primary
constructor in source. K2 uses the class declaration's raw IR offsets, which
include any leading modifier keywords, while K1's raw offsets start at the
`class` keyword and omit the modifiers.

Since K1 (unlike K2) retains the PSI, we recover the modifier-inclusive span
from the enclosing KtClassOrObject so K1 matches the K2-native span.

Example, for `open class C0<V> {}` on line 11 (the `open` modifier is at
column 1, the `class` keyword at column 6):

  before (K1):  generics.kt:11:6:11:19 | C0 | C0()
  after  (K1):  generics.kt:11:1:11:19 | C0 | C0()   (matches K2)

The fix is deliberately narrow and leaves all other constructors untouched:
 - explicit primary constructors (`class C1(val t: T)`, `class C2()`) keep
   their own parameter-list location, which both frontends already agree on;
 - explicit secondary constructors keep their own location (only the
   `isPrimary` constructor is adjusted);
 - specialised/parameterised copies of a generic constructor
   (`typeSubstitution != null`) are excluded, so they do not gain a spurious
   source location and appear in source-filtered queries.

Relearned test-kotlin1 (K1) and test-kotlin2 (K2): all 3333 tests pass with
database-consistency checks. Only test-kotlin1 expected files change (K2 output
is unchanged, as K2 already emits these spans natively). No previously matching
row diverges; net K1-vs-K2 divergence decreases on every affected file, with
generics, generic-inner-classes and modifiers now fully identical.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ty span

For local and member delegated properties, the synthesised accessor
(`<get-prop>`/`<set-prop>`) and its generated wrapper class/constructor
were anchored by the K1 frontend at the delegate expression rather than
at the property declaration. K2 (raw IR) anchors them at the property
`val`/`var` keyword through the end of the delegate, i.e. the whole
`KtProperty` span.

Adopt the K2 span for both frontends so the extractor emits identical
locations regardless of the supplied language version. Example:

  <get-prop1>  6:24:9:9  ->  6:9:9:9

Add `getPsiBasedDelegatedAccessorLocation`, which walks from the
accessor's PSI up to the enclosing `KtProperty` and returns its span.
It returns null when there is no PSI (K2) or no enclosing property, so
K2 keeps its native locations and non-delegated callables are
unaffected. Wire it into `extractFunction`'s location chain and into
`extractGeneratedClass` (guarded on `DELEGATED_PROPERTY_ACCESSOR`) so the
generated class/constructor sorts before the method, matching K2.

Updates test-kotlin1 expected only (test-kotlin2 unchanged).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…s onto the delegate expression

The synthesised body of a delegated-property accessor (`get`/`getValue`/
`setValue`/`invoke` calls, the `<prop>$delegate` access, associated type
accesses and property-reference classes) carries the source range of the
whole `KtPropertyDelegate` node. The K1 frontend's range starts at the
`by` keyword; K2 starts at the delegate expression itself
(e.g. `lazy { ... }`), three columns later. The `by` keyword is syntactic
glue in the property declaration, not part of the expression being
evaluated, so K2's narrower range is the more intuitive one. Adopt it for
both frontends. Example:

  get / getValue / invoke ...  6:24:9:9  ->  6:27:9:9

Add a scoped offset remap on `FileTrapWriter`: while extracting a
`DELEGATED_PROPERTY_ACCESSOR` body, any location whose offsets exactly
equal the `by`-inclusive delegate range is emitted with the delegate
expression's range instead. The range is recovered from the enclosing
`KtProperty`'s PSI (`delegate.expression`), which is available under K1;
under K2 there is no PSI so the remap is inactive and the raw offsets
already exclude `by`. Matching the full delegate range exactly means only
these synthesised body expressions are affected.

Updates test-kotlin1 expected only (test-kotlin2 unchanged).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The K1 frontend lowers `if`/`when` branches with their raw IR offsets
collapsed onto the whole enclosing IrWhen expression, so every branch
reports the same span (e.g. `stmts.kt:17:26:17:58`). K2 records per-branch
spans reconstructed from the branch condition start through the result end
(or just the result span for an `else` branch).

Reconstruct the per-branch span from the branch's own condition/result
offsets, gated on the raw branch span being collapsed onto the enclosing
IrWhen (as under K1); under K2 the raw per-branch offsets already differ so
the helper is a no-op. `correctedEndOffset` additionally fixes K1 recording
a bare assignment's raw end at its left-hand side rather than past its
right-hand value.

Example (stmts.kt:17):
  before: 17:26:17:58 (x2, both branches collapsed onto the IrWhen)
  after:  17:29:17:43 and 17:50:17:58 (per-branch, matching K2)

Only the K1 (test-kotlin1) expected files change; the K2 (test-kotlin2)
expected files are unchanged. Controlflow expected files converge as a pure
cascade of the branch-location shift.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The K1 frontend records an assignment's raw end offset at its left-hand
side rather than past its right-hand value, so the ExprStmt wrapper
synthesised around a bare assignment statement (`z = 4`) collapses onto the
LHS and produces a zero-width span. K2 spans the whole assignment.

Widen the ExprStmt wrapper location to end past the assigned value via a new
`getExpressionStmtLocation` helper (reusing `correctedEndOffset`). This makes
the wrapper match the inner `AssignExpr`, which already uses
`e.startOffset .. rhsValue.endOffset`. The helper is a no-op for
non-assignments and under K2, where the assignment already spans its value.

Example (stmts.kt:17):
  before: 17:37:17:37 (collapsed onto LHS `z`)
  after:  17:37:17:41 (spans `z = 4`, matching K2)

Only the K1 (test-kotlin1) expected files change; the K2 (test-kotlin2)
expected files are unchanged. Controlflow expected files converge as a pure
cascade of the location shift (dominator.expected fully converges).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… assigned value)

For an indexed array assignment `a[i] = v` (and its compound forms `a[i] op= v`),
the K1 and K2 frontends disagree on the source location of both the synthesised
array-access node (`...[...]`) and the assignment node (`...=...`/`...op=...`):

  - K1 records the end offset at the left-hand side array access, e.g.
    `a1[0] = a1[0]` gets `12:3:12:7` (just `a1[0]`).
  - K2 spans through the assigned value, e.g. `12:3:12:15` (the whole `a1[0] = a1[0]`).

Decision: adopt the K2 span. An assignment expression should cover its whole
source range (left-hand side through the right-hand value), consistent with how
ordinary (non-indexed) assignments are already located and with the Java
extractor's array-store locations. This makes location-based queries (and any
`...=...`/`...[...]` overlap reasoning) behave the same regardless of frontend.

Implementation: the set operation is desugared to an array `set` call. Two code
paths build these nodes, and both used the raw call/block end offset:

  - simple `a[i] = v`  -> the `Array.set` IrCall branch in `extractCall`
  - compound `a[i] op= v` -> `tryExtractArrayUpdate`

Each now widens the end offset to the assigned value's end via a small offset-based
`correctedEndOffset(rawEnd, valueEnd)` helper (extracted from the existing
`correctedEndOffset(IrExpression)` so the two share one rule). The widening only
fires when the value's end lies past the raw end, so it is a no-op under K2 (where
the raw end already spans the value) and ignores undefined/synthetic offsets. A new
`TrapWriter.getLocation(e, endOffset)` overload preserves the existing IrCall
start-offset adjustment while overriding just the end.

Tradeoff: the change is deliberately scoped to the two array-assignment paths
rather than globally rewriting how set-call locations are derived, to avoid
perturbing unrelated expressions. Only the `test-kotlin1` array expected files
change; `test-kotlin2` is unaffected, and the two suites' `arrayAccesses` and
`assignExprs` expected files are now byte-identical. All 3333 tests pass in both
K1 and K2 modes (with database-consistency checks).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
andersfugmann and others added 13 commits July 12, 2026 00:31
…ons onto K2

For `val x by Delegate()`, the compiler synthesises getter/setter bodies that
forward to the delegate's `getValue`/`setValue`, passing a synthetic receiver
argument (the enclosing `this`, or `null` for top-level/extension delegates).

Under the K1 frontend (test-kotlin1, `-language-version 1.9`) these synthetic
receiver arguments were given a bogus location `1:9:1:12`: their IR offsets
(8..11) are not real source offsets for the argument, and `findPsiElement`
resolves them to whatever sits at file offset 8, which for these tests is the
line-1 `import`. This is meaningless and makes the receivers un-searchable by
location.

The K2 frontend (test-kotlin2, default) already produces the intuitive result:
the synthetic member receiver is located at the DELEGATE EXPRESSION (e.g.
`by ResourceDelegate()` -> `ResourceDelegate()`), and a receiver-less `null`
argument gets the whole-file location `0:0:0:0`. K2 output is the canonical
target for this unification (it is also compiler-version-independent).

This change adds `getDelegatedAccessorSyntheticArgumentLocation`, gated on:
  - the enclosing declaration being an IrFunction with origin
    DELEGATED_PROPERTY_ACCESSOR (member/top-level accessors only),
  - the element being either the accessor's dispatch/extension receiver
    `IrGetValue` or a null `CodeQLIrConst`, and
  - the element offsets lying outside the enclosing `KtProperty` text range
    (so genuine in-source expressions are never rehomed).
It returns the delegate expression's location for the `this`/receiver case and
the whole-file location for the `null` case, mirroring K2. It is wired at the
three sites that extract these synthetic args (extractThisAccess, the
IrGetValue variable/extension-receiver path, and the null case of
extractConstant).

The helper relies on PSI (getPsi2Ir), so it is a no-op under K2: test-kotlin2
output is unchanged (verified). Only test-kotlin1 converges.

Trade-offs / scope:
  - LOCAL delegated properties (declared inside a function body) are a distinct
    mechanism: their get/set is inlined into the enclosing function (no
    DELEGATED_PROPERTY_ACCESSOR origin) and the residual divergence there is an
    end-offset difference, not the `1:9:1:12` bug. They are deliberately left
    for a separate commit.
  - The `$delegate` backing-variable initializer/type-access spans still differ
    between suites (e.g. `4:18` vs `4:21`); that is an unrelated mechanism and
    is not touched here.
  - The outside-KtProperty-range gate could in principle false-negative for a
    delegated property declared at the very top of a file (offsets <= 11); this
    is preferred to over-correcting real expressions and is not observed in the
    test corpus.

Relearned test-kotlin1 expected updated (exprs, methods; incl. PrintAst).
Verified via full dual-suite relearn (CI-faithful: 2.3.20/lang-1.9 for tk1,
default/2.4.0 for tk2, database consistency checks): all 3333 tests pass, only
the three delegates-related tk1 files change, every changed row is a pure
relocation of a synthetic receiver from `1:9:1:12` to the K2 target, and
test-kotlin2 is byte-for-byte unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…onto K2

Follow-up to the member/top-level delegated-accessor convergence, covering the
remaining case: a LOCAL delegated property (declared inside a function body,
e.g. `val x: Int by DelegateProvider()`).

Unlike member/top-level delegated properties, a local delegated property's
forwarding get/set is inlined into the enclosing function rather than
materialised as a `DELEGATED_PROPERTY_ACCESSOR`, so the previous fix
(getDelegatedAccessorSyntheticArgumentLocation, gated on that origin) did not
apply. Because a local property has no dispatch/extension receiver, its
synthetic `thisRef` argument (passed to the delegate's
`provideDelegate`/`getValue`/`setValue`) is always a `null` constant with no
source token.

Under K1 this synthetic `null` was given a bogus location `1:9:1:12`: its IR
offsets (8..11) are not real source offsets and `findPsiElement` resolves them
to the file's line-1 `import`. K2 records it at the whole-file location
`0:0:0:0`, which is the honest representation for an argument with no source
token, and is the canonical target for this unification.

This change adds `getLocalDelegatedPropertySyntheticNullLocation`, gated on:
  - a scoped context (`currentLocalDelegatedProperty`) that is set only while
    extracting a specific `IrLocalDelegatedProperty`'s generated artifacts
    (its delegate initializer and forwarding get/set),
  - the element being a `null` `CodeQLIrConst`, and
  - the element offsets lying outside the property's `KtProperty` text range
    (so a genuine source `null` inside the delegate expression, e.g.
    `by foo(null)`, is never relocated).
It returns the whole-file location, mirroring K2. Per the design review, this
is deliberately a distinct, narrowly-scoped path anchored on the local
delegated property being extracted, NOT a relaxation of the accessor-origin
gate to arbitrary enclosing functions (which would risk moving genuine source
`null`/`this` literals). It relies on PSI, so it is a no-op under K2.

Verified via full dual-suite relearn (CI-faithful: 2.3.20/lang-1.9 for tk1,
default/2.4.0 for tk2, database consistency checks): all 3333 tests pass. Only
two tk1 files change (exprs.expected and its PrintAst), each a single row
relocating the synthetic `null` from `1:9:1:12` to `0:0:0:0`; the NullLiteral
rows now match test-kotlin2 exactly, no `1:9:1:12` bogus locations remain
anywhere in test-kotlin1, and test-kotlin2 is byte-for-byte unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An explicit property setter written `set(_)` produces a value parameter
whose source spelling is the Kotlin discard placeholder `_`. The two
frontends model this differently:

  * K2 preserves the source name and exposes the parameter as `_`.
  * K1 additionally flags the parameter with
    `IrDeclarationOrigin.UNDERSCORE_PARAMETER`, which the extractor treats
    as a synthesized name (no name is written and QL synthesizes `p0`).

This made `test-kotlin1` report `p0` while `test-kotlin2` reported `_`
for the same source (library-tests/underscore-parameters).

The `UNDERSCORE_PARAMETER` origin is *also* set by K1 (and, for the
synthesized cases, by K2) on discarded parameters that do not retain a
`_` source spelling, most importantly:

  * lambda placeholder parameters, e.g. `{ index, _ -> ... }`
  * the synthesized `invoke` parameters of function types
    (funcExprs.kt lines 27/30/31/90/94)

For those, the two frontends assign *different* internal names
(`<anonymous parameter N>` under K1 vs `<unused var>` under K2), so the
synthetic-name treatment is exactly what keeps them converged at `pN`.
Removing the origin check wholesale therefore replaces one small
divergence with a much larger one.

To converge only the case both frontends agree on, we suppress the
synthetic-name treatment only when the parameter's own name is exactly
`_` (the source discard spelling that both K1 and K2 preserve). This
makes K1 emit `_` for `set(_)` (matching K2) while leaving every other
discarded parameter synthetic in both suites.

Scope of the change (verified by a full dual-suite `--learn` with
database-consistency checks, all 3333 tests passing):

  * test-kotlin1 library-tests/underscore-parameters: `p0` -> `_`
    (now identical to test-kotlin2).
  * test-kotlin2: byte-for-byte unchanged.
  * No other expected file changes (lambda / function-type discard
    parameters remain `pN` in both suites).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An exhaustive `when` expression (one with no explicit `else` that the
compiler proves total) gets a synthetic `throw NoWhenBranchMatchedException(...)`
as its fallback branch. The two frontends locate that synthetic call
differently:

  * K2 gives the call the source offsets of the enclosing `when`
    expression, so the throw (and the `new NoWhenBranchMatchedException`)
    are located at the `when` block, e.g. `6:3:9:3`.
  * K1 leaves the synthetic call with undefined offsets, so the extractor
    emits a `0:0:0:0` (no-source) location for both nodes.

A real source location is strictly more useful than none, and anchoring
the implicit fallback to the `when` it belongs to is the intuitive
choice, so we adopt the K2 behaviour for both frontends.

`extractCall` now records the enclosing `when`'s location while
extracting its branches (`currentSyntheticWhenLocation`) and uses it as
the fallback for the `noWhenBranchMatchedException` builtin whenever the
synthetic call itself has undefined offsets. This only changes K1: under
K2 the call already carries valid offsets, so `tw.getLocation(c)` is used
unchanged and K2 output is byte-identical.

Relearned both suites: all 3333 tests pass and the only changed row is
test-kotlin1/library-tests/no-when-branch-found, which now matches
test-kotlin2 exactly (`0:0:0:0` -> `6:3:9:3`).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A data class's generated `copy(...)` has one value parameter per
primary-constructor property. The K1 frontend records each such
parameter (and its type accesses) at the source location of the
corresponding property; the K2 frontend leaves them with undefined
offsets, which the extractor emits as a `0:0:0:0` location.

This divergence is purely a K2 information regression: the richer K1
location is unambiguously better (it points at the real property in
source, enabling location-based queries), so we converge K2 onto K1
rather than the other way around.

Because K2 exposes no PSI back-mapping, the location cannot be
recomputed from source; instead we recover it from the IR. For a value
parameter of a `GENERATED_DATA_CLASS_MEMBER` function whose own offsets
are undefined, we look up the primary-constructor parameter at the same
index and reuse its location.

Guards keep the change surgical:
  - `vp.startOffset >= 0` bails out, so K1 (which already has real
    offsets) is untouched.
  - the origin must be `GENERATED_DATA_CLASS_MEMBER`.
  - the primary-ctor parameter name must match and carry real offsets,
    which restricts the remap to `copy`-style parameters and excludes
    members such as `equals(other)`.

Relearned both suites: only data-class `copy` parameter rows change
(K2 now matches K1). data-classes/PrintAst.expected becomes byte
-identical across suites; the residual diffs in methods/{exprs,
parameters}.expected are pre-existing, unrelated divergences.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An explicit property accessor may carry its own annotations, for example:

    val x: Int
        @JvmName("getX_prop")
        get() = 15

The K2 frontend records such an accessor with raw IR offsets that begin
at the leading annotation; the K1 frontend's raw offsets start at the
`get`/`set` keyword and omit the annotation. This is a pure K1
information regression: the annotation is part of the accessor
declaration and the K2 span is the more faithful one, so we converge K1
onto K2.

Because the annotation-inclusive start cannot be reconstructed under K2
(no PSI back-mapping) but is trivially available under K1, we recover it
from the KtPropertyAccessor PSI node, whose text range begins at its
modifier list. A new helper getPsiBasedAnnotatedAccessorLocation returns
this span, and accessorOverride now applies it to explicit accessors (in
addition to the existing synthesised-accessor handling).

Guards keep the change surgical:
  - returns null under K2 (getKtFile unavailable; raw offsets already
    include the annotation), leaving K2 untouched.
  - returns null when the accessor declares no annotations of its own, so
    non-annotated explicit accessors (which already converge) are
    unaffected.

Relearned both suites: only explicit annotated-accessor declaration rows
change (K1 now matches K2). annotations/jvmName/test.expected becomes
byte-identical across suites; the residual diffs in jvmstatic-annotation
are pre-existing, unrelated divergences (JVM-static proxy forwarder
locations and call-argument spans).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A primary constructor's call to its superclass is written in source only
as a supertype-list entry (for example `class C : Base()`), so the
synthetic delegating super-constructor call has no `super(...)` token of
its own. The K1 frontend records it at that supertype call expression
(`12:23:12:34`); the K2 frontend records it at the whole class
declaration (`12:1:15:1`).

Neither is a real `super(...)` statement. Consistent with the location
policy adopted for these synthetic constructs (prefer the fuller
whole-construct span), we converge K1 onto the K2 form.

Because the whole-class span cannot be reconstructed under K2 (no PSI
back-mapping) but K2 already emits it from raw IR, the fix is K1-only: a
new helper getPsiBasedPrimaryCtorSuperCallLocation returns the enclosing
KtClassOrObject span (including leading modifiers such as `open`) for the
super call of a primary constructor, and the IrDelegatingConstructorCall
handler uses it for super calls (delegatingClass != currentClass).

Guards keep the change surgical:
  - applies to primary constructors only (both explicit `()` and fully
    implicit); an explicit `super(...)` in a secondary constructor keeps
    its own location, which both frontends already record identically.
  - returns null under K2 (getKtFile unavailable; raw offsets already
    carry the class span), leaving K2 untouched.

Relearned both suites: only primary-ctor super-call rows change (K1 now
matches K2). classes/ctorCalls.expected becomes byte-identical across
suites; the residual vararg/args diff is a pre-existing, unrelated
`public vararg val` parameter-span divergence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When a constructor carries leading modifiers or annotations, for example
`internal constructor(...) { }` or an annotated `@Ann constructor()`,
the K1 frontend's IrBlockBody offsets for the constructor body begin at
the modifier/annotation (`3:3`); the K2 frontend begins at the
`constructor` keyword (`3:12`), consistent with how declarations exclude
leading modifiers from their own span. We converge K1 onto the K2 form.

The K2 span cannot be reconstructed from raw offsets under K1, but the
`constructor` keyword position is available from the PSI. A new helper
getPsiBasedConstructorBodyLocation walks from the block body's start to
the enclosing KtConstructor and returns a location from its
`constructor` keyword through the block body's own end offset;
extractBlockBody uses it in preference to the raw block location.

Guards keep the change surgical:
  - returns null under K2 (getKtFile unavailable; raw offsets already
    exclude the modifier), leaving K2 untouched.
  - returns null for non-constructor bodies and for an implicit primary
    constructor with no `constructor` keyword.
  - for a constructor without modifiers the keyword coincides with the
    block start, so the location is unchanged there.

Relearned both suites: only modifier/annotation-carrying constructor
body-block rows change (K1 now matches K2).
internal-constructor-called-from-java/test.expected becomes
byte-identical across suites; the residual annotation_classes/PrintAst
diffs are pre-existing, unrelated divergences (annotation-argument and
stdlib enum-entry locations).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A source underscore variable (an unused `_` in a `catch (_: E)` clause or a
`val _ = ...` discard) is named differently by the two frontends:

- K1 keeps the source spelling `_`.
- K2 assigns the synthetic `SpecialNames.UNDERSCORE_FOR_UNUSED_VAR`, which
  renders as `<unused var>`.

Decision (D15): adopt the K1 behaviour. `_` is the actual source token, so it
is the more intuitive and source-faithful name; it also keeps the local
variable name consistent with the corresponding value-parameter case, where a
prior fix already normalises the underscore setter parameter to `_`.

`extractVariableExpr` now maps a variable whose IR name is
`SpecialNames.UNDERSCORE_FOR_UNUSED_VAR` to `_` before writing `localvars`.
The check is on the special name rather than a raw string so it is robust
across compiler versions, and it only fires for the frontend-synthesised
unused-variable name, leaving all other locals untouched.

Full dual-suite relearn: all 3333 tests pass. The only changed expected row is
in query-tests/UnderscoreIdentifier, where the K2 catch parameter row converges
from `Exception <unused var>` to `Exception _`, matching K1. The remaining
divergence in that file (the destructuring container `<destruct>` vs
`tmp0_container`) is a separate naming/location issue tracked under C14.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…h frontends

A prefix/postfix increment or decrement (`x++`, `--x`, ...) is desugared into a
block holding an induction temporary. The two frontends name that temporary
differently:

- K1 uses a per-scope counter, `tmp0`, `tmp1`, ... `tmpN`.
- K2 uses the uniform special name `<unary>`.

Decision: adopt the K2 name (`<unary>`) from both frontends. This is the only
robust convergence direction: K1's counter-based `tmpN` numbering depends on the
frontend's temporary allocation order and cannot be reproduced faithfully under
K2 (mapping `<unary>` back to a single `tmp0` would wrongly collapse distinct
temporaries such as `tmp0`..`tmp7` in one function). The uniform `<unary>` name
is frontend-independent and identifies the construct just as well. (This
supersedes the earlier D14 preference for the readable `tmpN` names, which was
made before the numbering infeasibility was established.)

Implementation: `extractBlock` recognises an `IrContainerExpression` whose origin
is one of PREFIX_INCR / PREFIX_DECR / POSTFIX_INCR / POSTFIX_DECR and records its
first statement (the induction temporary) together with the canonical name
`<unary>`. `extractVariableExpr` emits that name for exactly that variable (matched
by identity), leaving all other locals untouched. The recording is saved/restored
around the block's statement extraction so nested blocks behave correctly. The
detection is origin-based and therefore identical for K1 and K2.

Full dual-suite relearn: all 3333 tests pass. The only changed rows are in
test-kotlin1 (exprs/exprs, exprs/PrintAst, controlflow/basic/bbStmts and
getASuccessor), where the K1 increment temporaries converge from `tmpN` to
`<unary>`, matching K2. Remaining exprs.kt differences (unary-operand span, and
the delegatedProperties `<set-?>`/`<get-x>` cluster) are separate location/naming
items tracked elsewhere. The destructuring container temp (`tmp0_container` vs
`<destruct>`) is handled separately in C14b.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…th frontends

A destructuring declaration `val (a, b) = subject` introduces a temporary that
holds the subject; each component is then read from it via `componentN()`. The
two frontends represent that temporary differently:

- K1 names it `tmp<N>_container` and locates it at the subject expression only
  (e.g. `7:26:7:26`, pointing at `p`).
- K2 names it `<destruct>` and locates it across the whole destructuring
  declaration (e.g. `7:9:7:26`, `val (first, _) = p`).

Decision: adopt the K2 representation from both frontends. The `<destruct>` name
is uniform and frontend-independent (K1's `tmp<N>` counter cannot be reproduced
under K2, consistent with C14a), and the full-declaration span is more
informative than a bare pointer at the subject.

Implementation: `isDestructuringContainerVariable` recognises the temporary by
its frontend name (`<destruct>` under K2, `tmp<N>_container` under K1).
`extractVariableExpr` then emits the name `<destruct>` and, under K1 only, a
PSI-based location spanning the enclosing `KtDestructuringDeclaration`
(`getPsiBasedDestructuringContainerLocation`); under K2 the IR offsets are
already correct so the helper returns null and the existing location is kept.

Full dual-suite relearn: all 3333 tests pass. The only changed row is in
query-tests/UnderscoreIdentifier, where the K1 container converges from
`7:26:7:26 | tmp0_container` to `7:9:7:26 | <destruct>`, matching K2. With this
and C15, query-tests/UnderscoreIdentifier is now byte-identical across both
suites. The for-loop destructuring shape (`for ((v, i) in ...)`, where K1 omits
the container entirely) is a separate AST-shape difference tracked as C14c.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Kotlin internal queries job (odasa-buildutils/kotlin_internal_queries)
flagged two code-quality issues introduced by the K1/K2 convergence helpers.
Both are pure source refactors with no effect on extractor output, so no
.expected files change.

1. possiblyThrowingExpressions.ql / notNullExpr: extractVariableExpr used a
   `!!` not-null assertion (`currentDesugarTemp!!.second`) guarded by a
   separate `currentDesugarTemp?.first === v` check. The extractor must avoid
   `!!` (it can throw and lose a source file). Bind `currentDesugarTemp` to a
   local val and null-check it in the `when`, which smart-casts the subsequent
   `.first`/`.second` accesses. Behaviour is identical.

2. separated_overloads.ql: two overload groups were split by newly added
   helpers, which the lint reports as harder-to-read code:
     - `getPsiBasedConstructorBodyLocation` sat between the two
       `extractBlockBody` overloads; moved it below both.
     - the destructuring helpers (`destructuringContainerK1NameRegex`,
       `isDestructuringContainerVariable`,
       `getPsiBasedDestructuringContainerLocation`) sat between the
       `getPsiBasedLocation(IrVariable)` and `getPsiBasedLocation(IrProperty)`
       overloads; moved them below the third overload so all three
       `getPsiBasedLocation` overloads are adjacent.

Verified the standalone extractor still compiles under both K2 (2.4.0) and
K1 (1.9.20-Beta).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…cation

A primary-constructor property parameter written with leading modifiers,
e.g. `public vararg val s: String`, is located differently by the two
frontend paths:

  - K1 (-language-version 1.9) starts the parameter location at the first
    modifier token (`public`), giving test.kt:50:5:50:31.
  - K2 (default) starts it at the `val`/`var` keyword, giving
    test.kt:50:19:50:31.

The K2 span is the more intuitive and consistent one: every other value
parameter is already located from its `val`/`var` keyword (or its name),
so including the leading modifier list here is an outlier that also makes
the property parameter's span inconsistent with the property it backs.

Converge K1 onto the K2 span. `getPsiBasedValueParameterLocation` finds
the enclosing `KtParameter` via PSI back-mapping (available under K1,
where `getKtFile` is non-null; it returns null under K2, which already
emits the desired offsets) and, only when modifiers precede the keyword,
re-anchors the location start at the `val`/`var` keyword while preserving
the parameter's own end offset.

The guard `keyword.startOffset <= vp.startOffset` is essential: when the
`val`/`var` keyword already is the parameter start (no leading modifiers),
the helper must not fire, otherwise it would rewrite the end offset to
`vp.endOffset` and diverge from the raw location for ordinary property
parameters (observed as orphaned `[Parameter]` rows in generics/
reflection/classes PrintAst during development).

After this change test-kotlin1 and test-kotlin2 vararg/args.expected are
byte-identical. All 3333 tests pass in both suites (K1 2.3.20 / lang 1.9
and K2 2.4.0 / lang 2.0).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant