Skip to content

fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) - #1632

Merged
Shashankss1205 merged 14 commits into
CodeGraphContext:mainfrom
rrodriguesNutrium:fix/five-open-issues
Aug 18, 2026
Merged

Shashankss1205 merged 14 commits into
CodeGraphContext:mainfrom
rrodriguesNutrium:fix/five-open-issues

Conversation

@rrodriguesNutrium

Copy link
Copy Markdown
Contributor

Five independent bug fixes, one commit each. Every commit stands alone — its own claim, its own test, its own evidence — so they can be taken or dropped individually.

Commit Issue What was wrong
8554b05 #1608 3 Kotlin parser tests fail on a clean macOS checkout
c1b60e2 #1601 DECORATED_BY silently drops every class-level decorator
cae35a3 #1606 find_dead_code hard-capped at 50, making exclude_decorated_with look inert
976d596 #1600 object A { fun x() = 1 } on one line is never indexed
(final) #1607 every empty list property stored as [""] rather than []

All five were filed out of #1595.


#1608 — macOS /var vs /private/var

tempfile returns /var/folders/…; /var is a symlink to /private/var and the call-resolution layer stores the resolved path. Any assertion comparing an edge's called_file_path against Path(data["path"]).as_posix() compared the two forms and failed.

Fixed in the _write_and_parse fixture rather than in the assertions. There are 38 such comparison sites in that file; only 3 happened to be reachable in a shape that tripped, so patching those 3 would have left 35 latent. Resolving once, before parsing, makes data["path"] canonical everywhere.

Not a regression — it reproduces on a clean tree — but it made a fresh macOS checkout look broken and could mask real failures.

#1601DECORATED_BY drops class decorators

Declared as FROM Function TO Function, FROM Class TO Function; the writer hardcoded :Function on the decorated endpoint, so class rows matched nothing.

The silent drop had two layers, and fixing only the first would have looked correct while changing nothing:

  1. The hardcoded label. Now iterates the declared source labels and stops at the first that matches, exactly as write_binds_links does.
  2. Class has no context column, so WHERE … decorated.context = $decorated_context raises Binder exception: Cannot find property context for decorated — which _is_binder_exception swallows. Parameterising the label alone still dropped every class row, one layer further down. The predicate is now emitted only for the Function label, which is exact rather than approximate: the builder only ever sets decorated_context from a function's class_context.

Not Kotlin-specific. The TypeScript golden gains 4 edges that were being dropped:

Class:User        -[DECORATED_BY]-> Function:Entity
Class:User        -[DECORATED_BY]-> Function:Injectable
Class:User        -[DECORATED_BY]-> Function:Serializable
Class:UserService -[DECORATED_BY]-> Function:Component

#1606find_dead_code capped at 50

LIMIT 50 sat in the query, applied after the decorator filter, so excluded rows were backfilled by the next ones in path order and the count came back 50 either way. On the reported Android codebase that presented as exclude_decorated_with doing nothing while it was in fact removing 6,086 of 7,141 false positives.

  • total_count is returned alongside the page, unconditionally.
  • limit is a parameter, not a constant.
  • The handler's TOOL_RESULT_LIMITS plumbing was already there but starved — it never saw more than 50 rows, so raising the configured limit did nothing. It now pages a real result set.
  • The CLI is updated in the same commit: its table had no page size of its own — the query's LIMIT 50 was acting as one by accident — so unbounding the finder alone would have printed thousands of rows. It now requests the configured limit and prints the true total.

Deliberately not done: the issue's third suggestion (order so results sample rather than cluster). That is a product decision about what a truncated sample should mean, not a defect; it is now documented on the method instead of being silent.

#1600 — single-line objects never indexed

The grammar misparses object A { fun x() = 1 } into infix_expression(object_literal, simple_identifier, lambda_literal) — no object_declaration, so the classes query never matches. The empty and multi-line forms parse correctly.

Fixed in three places, because capturing the node alone would have looked complete while leaving members unattributed:

  • the classes query gains an arm for the misparse shape;
  • _parse_classes records it as an Object (otherwise it lands as a Class);
  • both context walks recognise it — the members of a misparsed object have no object_declaration ancestor, so fun x landed at top level with context = None while the identical multi-line object gives its members context = "A".

object_literal as the left operand is the discriminator and is what makes matching an infix_expression safe — it can only come from the object keyword, so someValue apply { … } is not captured. Boundary test included. Also checked: the single-line companion object { … } form parses correctly and was never affected; pinned so the new arm can't start double-counting it.

#1607 — empty lists stored as [""]

A function with no decorators stored decorators == [""]. Same for args, modifiers, every list property, every language.

The issue asked whether the sentinel was working around a backend that rejects empty lists, in which case the fix would belong at the read boundary. It is not. Kùzu 0.11.3 accepts [] for a STRING[] column in every shape the writer uses — inside UNWIND $rows, as a single-row parameter, and when every row in the batch is empty so there is no sibling row to infer an element type from. The dominant-type inference is unaffected too: it only skips None, and [] is not None. Both cases have tests.


On the golden refresh

Worth flagging, because it changes how these diffs should be read.

The goldens carry pre-existing churn that the check does not see. Regenerating on unmodified main rewrites 62 golden files while all 21 golden tests still pass. Two causes: the raw .jsonl files carry internal node offsets, and load_and_normalize discards CALLS/HEURISTIC_CALLS edges entirely by design (they are heuristic and vary across parser/backend versions). A raw git diff of a golden therefore buries the real signal.

So no golden here was bulk-regenerated on trust. For each refresh the delta was measured through the golden test's own load_and_normalize, and only goldens whose checked content actually changed were touched:

The comparison is intersection-based on node properties, matching the check's own common_keys = set(exp_node).intersection(act_node). That distinction matters: the committed goldens are missing properties the current code emits (language, is_dependency, visibility), which the check skips. That staleness is pre-existing and is not touched here.

Verification

tests/unit          1240 passed, 19 skipped, 0 failed
tests/integration     45 passed

main baseline was 3 failed / 1222 passed — those 3 are #1608, fixed in the first commit, so the suite is green from there on. Each commit was run against both suites before the next one started.

Closes #1600, #1601, #1606, #1607, #1608

Not in scope

rrodriguesNutrium and others added 4 commits August 14, 2026 12:24
…raphContext#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes CodeGraphContext#1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Context#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes CodeGraphContext#1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…phContext#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes CodeGraphContext#1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes CodeGraphContext#1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

@rrodriguesNutrium is attempting to deploy a commit to the shashankss1205's projects Team on Vercel.

A member of the Team first needs to authorize it.

…GraphContext#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes CodeGraphContext#1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
C0deRatoR and others added 9 commits August 18, 2026 22:53
…ive (CodeGraphContext#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (CodeGraphContext#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (CodeGraphContext#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
…GraphContext#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (CodeGraphContext#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (CodeGraphContext#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (CodeGraphContext#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (CodeGraphContext#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (CodeGraphContext#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (CodeGraphContext#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
…odeGraphContext#1633) (CodeGraphContext#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes CodeGraphContext#1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
…e calls (Fixes CodeGraphContext#1573) (CodeGraphContext#1636)

* Fixes CodeGraphContext#1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes CodeGraphContext#1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name
# Conflicts:
#	src/codegraphcontext/tools/code_finder.py
#	src/codegraphcontext/tools/languages/kotlin.py
Shashankss1205
Shashankss1205 previously approved these changes Aug 18, 2026

@Shashankss1205 Shashankss1205 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on a local trial-merge: targeted tests for all commits pass (111), full unit suite 1289 passed / 19 skipped, and the full parser-goldens integration run is green (21/21, ~5 min) — important given the golden regeneration from the [\"\"][] writer fix.

Two notes on the merge resolution I pushed to your branch:

  • #1600 (single-line Kotlin objects): #1610 by @mjq2020 landed first and fixes the same misparse, so I resolved kotlin.py to main's version and dropped that commit's changes — no point carrying two implementations. Your test for it survives (names didn't collide) and passes against #1610's fix.
  • find_dead_code: kept your cap removal + total_count docstring and main's new _normalize_repo_path_filter call from #1634 side by side.

The other three fixes (DECORATED_BY class edges, empty-list properties, macOS tmp fixture) apply as-is. Thanks @rrodriguesNutrium — closes #1601, #1606, #1607, #1608.

@rrodriguesNutrium
rrodriguesNutrium dismissed Shashankss1205’s stale review August 18, 2026 17:48

The merge-base changed after approval.

@Shashankss1205
Shashankss1205 merged commit 0ebd349 into CodeGraphContext:main Aug 18, 2026
15 of 17 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog tasks to Done in CGC Progress Board Aug 18, 2026
Shashankss1205 added a commit that referenced this pull request Aug 18, 2026
* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* regenerate sample_project goldens on merged tree (from-import fix + empty-list fix)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>
Co-authored-by: Ricardo R. Rodrigues <ricardorodrigues@nutrium.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Shashankss1205 added a commit that referenced this pull request Aug 18, 2026
…scores (#1294) (#1626)

* Optimize website Lighthouse performance and accessibility scores

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* fix(imports): record from-import source module and edge language (#1640)

* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* regenerate sample_project goldens on merged tree (from-import fix + empty-list fix)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>
Co-authored-by: Ricardo R. Rodrigues <ricardorodrigues@nutrium.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat: created dedicated contributing page (#1280)

* feat: created dedicated contributing page

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: correct import path for Contributing page

* fix: correct template string usage in markdown conversion

* restore package.json/package-lock.json from main (no dependency changes needed)

* restore footer maintainer attribution; fix h2 template literal so headings interpolate

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>
Co-authored-by: Ricardo R. Rodrigues <ricardorodrigues@nutrium.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Atirna <noctiselara0@gmail.com>
Co-authored-by: Shrisha <shrisha337@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Shashankss1205 added a commit that referenced this pull request Aug 18, 2026
…rging (#1630)

* fix(indexer): disambiguate node identity so colliding symbols stop merging

Fixes #1393

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* fix(imports): record from-import source module and edge language (#1640)

* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* regenerate sample_project goldens on merged tree (from-import fix + empty-list fix)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>
Co-authored-by: Ricardo R. Rodrigues <ricardorodrigues@nutrium.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat: created dedicated contributing page (#1280)

* feat: created dedicated contributing page

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: correct import path for Contributing page

* fix: correct template string usage in markdown conversion

* restore package.json/package-lock.json from main (no dependency changes needed)

* restore footer maintainer attribution; fix h2 template literal so headings interpolate

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* complete #1393 for embedded backends: occurrence_index in Kuzu schema, uid_map, SCHEMA_MAP and bundle uid parts, with real-Kuzu regression tests

* extend occurrence_index to EnumMember/Mixin/Extension/Object, coalesce repeated Variable mentions, regen misc golden

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>
Co-authored-by: Ricardo R. Rodrigues <ricardorodrigues@nutrium.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Atirna <noctiselara0@gmail.com>
Co-authored-by: Shrisha <shrisha337@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Shashankss1205 added a commit that referenced this pull request Aug 18, 2026
* fix(windows): normalize graph relative paths to POSIX

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* fix(imports): record from-import source module and edge language (#1640)

* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* regenerate sample_project goldens on merged tree (from-import fix + empty-list fix)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>
Co-authored-by: Ricardo R. Rodrigues <ricardorodrigues@nutrium.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat: created dedicated contributing page (#1280)

* feat: created dedicated contributing page

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: correct import path for Contributing page

* fix: correct template string usage in markdown conversion

* restore package.json/package-lock.json from main (no dependency changes needed)

* restore footer maintainer attribution; fix h2 template literal so headings interpolate

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* perf/a11y: Optimize website Lighthouse Performance and Accessibility scores (#1294) (#1626)

* Optimize website Lighthouse performance and accessibility scores

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* fix(imports): record from-import source module and edge language (#1640)

* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle pat…
Shashankss1205 added a commit that referenced this pull request Aug 18, 2026
#1628)

* CPP Parser Fix: Six bugs detected and fixed

* test(cpp): move parser regression tests into tests/ and drop working notes

Relocate the six-bug regression tests to tests/unit/parsers/ so they are picked up by test discovery and run in CI, per CONTRIBUTING.md. Use the shared temp_test_dir fixture instead of a local sys.path shim. Remove the cpp_parser_fix/ spec working notes, which were scratch material rather than project documentation.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* fix(imports): record from-import source module and edge language (#1640)

* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* regenerate sample_project goldens on merged tree (from-import fix + empty-list fix)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>
Co-authored-by: Ricardo R. Rodrigues <ricardorodrigues@nutrium.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat: created dedicated contributing page (#1280)

* feat: created dedicated contributing page

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: correct import path for Contributing page

* fix: correct template string usage in markdown conversion

* restore package.json/package-lock.json from main (no dependency changes needed)

* restore footer maintainer attribution; fix h2 template literal so headings interpolate

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* perf/a11y: Optimize website Lighthouse Performance and Accessibility scores (#1294) (#1626)

* Optimize website Lighthouse performance and accessibility scores

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* fix(imports): record from-import source module and edge language (#1640)

* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp)…
Shashankss1205 added a commit that referenced this pull request Aug 18, 2026
…s (JS/TS) (#1570) (#1629)

* fix(parser): retain enclosing context for calls in anonymous callbacks (#1570)

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* fix(imports): record from-import source module and edge language (#1640)

* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* regenerate sample_project goldens on merged tree (from-import fix + empty-list fix)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>
Co-authored-by: Ricardo R. Rodrigues <ricardorodrigues@nutrium.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat: created dedicated contributing page (#1280)

* feat: created dedicated contributing page

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: correct import path for Contributing page

* fix: correct template string usage in markdown conversion

* restore package.json/package-lock.json from main (no dependency changes needed)

* restore footer maintainer attribution; fix h2 template literal so headings interpolate

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* perf/a11y: Optimize website Lighthouse Performance and Accessibility scores (#1294) (#1626)

* Optimize website Lighthouse performance and accessibility scores

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* fix(imports): record from-import source module and edge language (#1640)

* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmai…
Shashankss1205 added a commit that referenced this pull request Aug 18, 2026
…ges (#1624)

* feat(compose): flag composables and resolve @Preview into PREVIEWS edges

Adds `is_composable` to Function nodes and a PREVIEWS relationship from
each @Preview-annotated function to the composable it renders.

- kotlin.py sets is_composable from a module-level ^@Composable\b match
- database_embedded_kuzu.py declares the column, the SCHEMA_MAP entry and
  the migration (all three are required; missing any one silently drops
  the property on Kuzu while schemaless backends keep it)
- build_previews_links rejects annotation-echo phantom calls: the parser
  records `@Preview` argument expressions as calls, so a preview function
  appears to call itself and every composable named in its annotation

Stacked on the Hilt slice.

* fix(parsers/kotlin): stop recording annotations as function calls (#1602)

An annotation with arguments is `annotation -> @ + constructor_invocation`
in the tree-sitter Kotlin grammar, and KOTLIN_QUERIES["calls"] captures
`constructor_invocation`, so every such annotation was recorded as a call
made by the annotated declaration. `@Preview(showBackground = true)` on
`fun GreetingPreview()` became a call from GreetingPreview to "Preview".

The echo also reached expressions *inside* the argument list:
`@PreviewParameter(provider = FooProvider::class)` contributed a
`callable_reference` capture, which is why a preview function appeared to
call every type named anywhere in its own annotations.

Filtered at source in _parse_calls via _is_within_annotation, an ancestor
walk for an annotation node. The parse tree is the discriminator, not the
name: `B()` in `class A : B()` is also a `constructor_invocation` but its
chain runs through `delegation_specifier`, so it survives, as does a
`constructor_delegation_call` under `secondary_constructor`. Keying on the
node type alone would have deleted both.

The walk accepts two node types rather than one. Declaration-level
annotations nest under `annotation` -- functions, classes, objects,
interfaces, typealiases, properties, and use-site targets like
`@field:ColumnInfo(...)` and `@get:JvmName(...)`. File-level annotations
are the exception: `@file:JvmName("Utils")` produces `file_annotation`
with no `annotation` in the chain, so checking only `annotation` would
have left the phantom at the top of every file using a `@file:` target.
Each position has a test.

This is the bug #1624 previously worked around rather than fixed.
build_previews_links keeps its own_decorator_names rejection as a
backstop (it is pinned by a test feeding hand-built rows, so it stays
meaningful now that parser output no longer carries the echo), with the
docstring updated to say the parser filters at source.

Golden refresh: the Kotlin golden was already stale on this branch --
AndroidAnnotations.kt gained the compose functions, shifting line numbers
for UserViewModel, UserEntity, PlainHelper and findAll -- so the
integration golden test was red before this commit and is green after.
Regenerating twice, with and without this fix, isolates its own effect to
CALLS 28 -> 24 (-4). Node counts and every other edge type -- PREVIEWS,
BINDS, INHERITS, CONTAINS, HAS_PARAMETER, IMPORTS, COMPANION_OF -- are
identical across the two regenerations.

At parser level the fix removes 6 phantom rows across the fixture; 4 had
resolved into graph edges. Three are in Annotations.kt (`@Fancy(...)`, a
plain non-Android fixture), confirming the issue was never Compose- or
Android-specific.

tests/unit: 3 failed, 1246 passed (was 3 failed, 1238 passed before this
commit -- the same 3 pre-existing macOS /var-vs-/private/var failures of
#1608, which reproduce on a clean checkout).
tests/integration: 45 passed.

Closes #1602

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* fix(imports): record from-import source module and edge language (#1640)

* fix(imports): record from-import source module and edge language (#1639)

from X import Y was storing a Module named Y. Keep the Module as X, put Y
on the IMPORTS edge, and stamp r.lang so Python queries can filter out
JS/TS specifiers that share the same Module name.

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>

* regenerate sample_project goldens on merged tree (from-import fix + empty-list fix)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>
Co-authored-by: mjq2020 <74635395+mjq2020@users.noreply.github.com>
Co-authored-by: Soham Gangopadhyay <sohamgangopadhyay2007@gmail.com>
Co-authored-by: pm-dun86 <pm@dunedincouriers.com>
Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>
Co-authored-by: Boda shanmukha datta <shanmukhadattaboda069@gmail.com>
Co-authored-by: Ricardo R. Rodrigues <ricardorodrigues@nutrium.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat: created dedicated contributing page (#1280)

* feat: created dedicated contributing page

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: correct import path for Contributing page

* fix: correct template string usage in markdown conversion

* restore package.json/package-lock.json from main (no dependency changes needed)

* restore footer maintainer attribution; fix h2 template literal so headings interpolate

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* perf/a11y: Optimize website Lighthouse Performance and Accessibility scores (#1294) (#1626)

* Optimize website Lighthouse performance and accessibility scores

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_path` string comparison with no normalization.
Node paths are stored as the absolute filesystem path used at indexing time,
so passing anything else — the repo name from list_indexed_repositories(),
a relative path, a trailing slash — silently matched zero nodes, with no
error surfaced to the caller.

Adds _normalize_repo_path_filter(), which resolves a non-absolute repo_path
against the indexed repository list (by name, basename, or cwd-relative
path) before it reaches any STARTS WITH filter. Wired into every method on
CodeFinder that accepts repo_path. Also replaces the unrelated but similarly
broken Path(repo_path).resolve() in audit_kotlin_call_ambiguity, which
resolved against the server process cwd instead of the indexed root.

Verified against a local KuzuDB index containing both a Python and a Java
repo: short repo names now resolve to the correct absolute root and return
the expected matches, while cross-repo scoping still correctly excludes the
other repo's results.

Fixes #1633.

Co-authored-by: paul.mathieson <paul.mathieson@modulrfinance.com>

* fix(resolution): preserve function name for attribute-qualified module calls (Fixes #1573) (#1636)

* Fixes #1542: Parameterize relationship query limits, add truncation flags & CLI support

* fix(resolution): don't clobber resolved_called_name for attribute-qualified calls in resolve_function_call()

import m; m.fn() and import m as t; t.fn() produced zero CALLS/HEURISTIC_CALLS edges because the import-alias fallback in resolve_function_call() treated the receiver name (lookup_name == base_obj) as if it were the callee's own name, overwriting resolved_called_name with the module/receiver name. The later writer.py MATCH (by called_name) then found no matching Function node and silently dropped the edge. Guard the overwrite so it only applies to genuine direct-call import aliases (lookup_name == called_name), fixing Python module.fn()/module_alias.fn() call resolution without touching any other resolution tier.

Fixes #1573

* fix(resolution): allow import-alias module path lookup for attribute calls without overwriting called_name

* fix: five independent bugs from #1595 (#1600, #1601, #1606, #1607, #1608) (#1632)

* test(macos): resolve the tmp path in the Kotlin parser fixture (#1608)

Three tests in TestKotlinFunctionCallResolution failed on a clean macOS
checkout: tempfile hands back /var/folders/... while /var is a symlink to
/private/var, and the call-resolution layer stores the fully resolved
path. Any assertion comparing an edge's called_file_path against
Path(data["path"]).as_posix() therefore compared /private/var/... to
/var/... and failed.

Fixed in _write_and_parse rather than in the assertions. There are 38
such comparison sites in this file; only 3 happened to be reachable in a
shape that tripped the mismatch, so patching those 3 would have left the
other 35 latent for the next test that resolves a path. Resolving once,
before parsing, makes data["path"] canonical everywhere and removes the
need for each call site to remember .resolve().

Not a regression -- it reproduces on a clean tree -- but it made a fresh
macOS checkout look broken and could mask real failures.

tests/unit: 1225 passed, 19 skipped, 0 failed (was 3 failed, 1222 passed).

The same helper shape exists in test_java_parser.py and
test_java_package_qualified_names.py. Those are green, so they are left
alone rather than changed speculatively.

Closes #1608

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): land DECORATED_BY edges for decorated classes (#1601)

DECORATED_BY is declared as a REL TABLE GROUP with two pairs --
`FROM Function TO Function, FROM Class TO Function` -- and
build_decorated_by_links emits rows for decorated classes correctly. The
writer hardcoded `:Function` on the decorated endpoint, so every class row
matched nothing and was dropped with no error and no log.

The silent drop had two layers, and fixing only the first would have
looked correct while changing nothing:

1. The hardcoded label. Now iterates the declared source labels
   ("Function", "Class") and stops at the first that matches real
   endpoints, exactly as write_binds_links does -- a row carries
   name+path+line but no label, so a same-named node under the other
   label would otherwise pick up a second, spurious edge.

2. The `context` predicate. `Class` has no `context` column, so
   `WHERE ... decorated.context = $decorated_context` raises "Binder
   exception: Cannot find property context for decorated" -- which
   _is_binder_exception catches and swallows. Parameterising the label
   alone still dropped every class row, one layer further down. The
   predicate is now emitted only for the Function label. That is exact,
   not approximate: build_decorated_by_links only ever sets
   decorated_context from a function's class_context and leaves it "" for
   classes, so the predicate was a no-op there regardless.

Effect is not Kotlin-specific -- it is every language that records
class-level decorators. The TypeScript golden gains 4 edges that were
being dropped:

    Class:User        -[DECORATED_BY]-> Function:Entity
    Class:User        -[DECORATED_BY]-> Function:Injectable
    Class:User        -[DECORATED_BY]-> Function:Serializable
    Class:UserService -[DECORATED_BY]-> Function:Component

Only the TypeScript golden is refreshed, and its normalized delta is
exactly those 4 additions: 0 edges removed, 0 node changes. Verified by
diffing through the golden test's own load_and_normalize rather than by
raw file diff -- the raw .jsonl files churn between regenerations
(internal node offsets, and CALLS/HEURISTIC_CALLS reclassification, which
normalization discards by design), so a raw diff would have buried the
signal. Confirmed that regenerating on unmodified main rewrites 62 golden
files while all 21 golden tests still pass, which is why the other
goldens are deliberately left untouched rather than bulk-regenerated.

Test drives the real chain -- parser, builder, writer, query -- because
the defect is precisely that correctly-built rows never land. It uses
Kotlin rather than Python: Python's parser does not populate `decorators`
on classes at all (`@my_decorator class C` yields `[]`), a separate
parser gap that is not this bug and is left unfiled here.

tests/unit: 1228 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1601

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tools): stop find_dead_code capping itself at 50 results (#1606)

The query ended with a hardcoded `LIMIT 50` applied *after* the decorator
filter, so excluded rows were backfilled by the next ones in path order
and the returned count was 50 either way. On the reporter's Android
codebase that presented as `exclude_decorated_with` doing nothing while it
was in fact removing 6,086 of 7,141 false positives.

Three consequences, all fixed here:

- The total was unobtainable at any call site. find_dead_code now returns
  `total_count` alongside the page, unconditionally -- a count that
  appears only when truncated is nearly as unusable as no count.
- The handler's pagination was starved. analysis_handlers already read
  TOOL_RESULT_LIMITS and set truncated/result_limit, but never saw more
  than the 50 rows the query returned, so configuring a higher limit
  changed nothing. It now pages a real result set.
- `limit` is a parameter rather than a constant, defaulting to None (all
  rows) at the finder and to the configured tool limit at each caller.

The CLI is updated in the same commit rather than left to follow. Its
table had no page size of its own -- the query's `LIMIT 50` was acting as
one by accident -- so unbounding the finder alone would have printed
several thousand table rows on a real codebase. It now requests the
configured limit explicitly and prints the true total, which is the number
the issue was actually asking for:

    Total: 7141 function(s); showing the first 50 by path

Not done deliberately: the issue's third suggestion, ordering so the
result samples rather than clusters. Ordering is still path then line, so
a limited result is a path-ordered prefix concentrated in the first few
files. That is a product decision about what a truncated sample should
mean rather than a defect, and it is now documented on the method instead
of being silent.

Tests drive the real parser -> writer -> query chain with 70 dead
functions, 30 of them annotated -- above the old cap, and split so the
filtered count (40) coincides with neither 50 nor the unfiltered total,
so a still-capped result cannot pass by accident.

tests/unit: 1233 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1606

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(parsers/kotlin): index single-line objects (#1600)

The tree-sitter Kotlin grammar misparses `object A { fun x() = 1 }`. It
produces

    infix_expression
      object_literal ("object")
      simple_identifier ("A")
      lambda_literal { statements { function_declaration } }

rather than an `object_declaration`, so the pattern the classes query
looks for is never produced and the singleton is silently missing from
the graph. `object A { }` and the multi-line form parse correctly; only
the one-line-with-a-body shape is affected, and it is a common Kotlin
idiom.

Fixed in three places, because capturing the node alone would have looked
complete while leaving members unattributed:

- The classes query gains an arm for the misparse shape.
- _parse_classes records it under "objects"/Object; without that it would
  fall through to the class_declaration branch and be stored as a Class.
- _get_parent_context and _get_enclosing_class_context recognise it as an
  enclosing scope. This is the half that is easy to miss: the members of
  a misparsed object have no object_declaration ancestor, so `fun x`
  landed at top level with context None while the identical multi-line
  object gives its members context "A".

`object_literal` as the left operand is the discriminator, and it is what
makes matching an `infix_expression` safe: object_literal can only come
from the `object` keyword, so an ordinary infix call with a trailing
lambda (`someValue apply { ... }`) is not captured. There is a boundary
test for exactly that, plus a regression test that the multi-line and
empty forms still produce one object each rather than being duplicated by
the new arm.

Also checked and deliberately not changed: the single-line companion form
`companion object { fun z() = 3 }` parses as a real companion_object and
was never affected. It is pinned by a test so a future change to the new
arm cannot start double-counting it.

tests/unit: 1238 passed, 19 skipped.
tests/integration: 45 passed, no golden changes.

Closes #1600

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(writer): store empty list properties as [] rather than [""] (#1607)

A function with no decorators persisted `decorators == [""]`, and the same
applied to `args`, `modifiers` and every other list-valued property in
every language. It did not corrupt query results -- `"" CONTAINS
'Preview'` is false, so find_dead_code still retained un-annotated
functions correctly -- but `[""]` is not what any caller means by "none",
and it forced every consumer to special-case a one-element list holding
the empty string.

The issue asked whether the `else [""]` branch was working around a
backend that rejects empty lists, in which case the fix would belong at
the read boundary instead. It is not. Kùzu 0.11.3 accepts `[]` for a
STRING[] column in every shape this writer uses: inside `UNWIND $rows AS
row`, as a single-row parameter, and when *every* row in the batch is
empty so there is no sibling row to infer an element type from. The
dominant-type inference just above is unaffected too -- it only skips
None, and [] is not None, so an always-empty key still resolves to
"list". Both cases have tests; the all-empty batch is the one that would
plausibly have motivated a sentinel.

Golden refresh, 18 of 21 projects. The delta was measured through the
golden test's own load_and_normalize rather than by raw file diff, and it
is exactly 641 node properties changing [""] -> [], with 0 nodes added or
removed and 0 edges added or removed:

    sample_project             80    sample_project_javascript   40
    sample_project_c            3    sample_project_kotlin      110
    sample_project_cpp         26    sample_project_lua           4
    sample_project_csharp      17    sample_project_perl         11
    sample_project_dart        19    sample_project_ruby         12
    sample_project_elixir       3    sample_project_rust         77
    sample_project_go         104    sample_project_scala         4
    sample_project_haskell      3    sample_project_swift        22
    sample_project_java        14    sample_project_typescript   92

The three goldens with no checked change (elisp, misc, php) were reverted
rather than left churning, and they are exactly the three that passed the
integration suite before the refresh -- which cross-checks the
measurement.

Measuring through the normalizer rather than by raw diff is necessary
here, not fastidious: regenerating goldens on unmodified main rewrites 62
files while all 21 golden tests still pass, because the raw .jsonl
carries internal node offsets and load_and_normalize discards
CALLS/HEURISTIC_CALLS edges by design. The property comparison is
intersection-based, matching the check's own
`common_keys = set(exp_node).intersection(act_node)`; the committed
goldens are additionally missing properties the current code emits
(language, is_dependency, visibility), which the check skips and which
this commit deliberately does not touch.

tests/unit: 1240 passed, 19 skipped.
tests/integration: 45 passed.

Closes #1607

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix: index single-line Kotlin objects (#1610)

* ci: add cross-OS E2E tests and gracefully skip missing backends (#1635)

* ci: add cross-OS E2E tests and gracefully skip missing backends

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active (#1641)

* fix(cli/mcp): refuse switch_context while indexing jobs are still active

* fix(php): extract promoted and variadic parameters (#1643)

* fix(cli/mcp): stop path→repo_path alias from breaking calculate_cyclomatic_complexity (#1638)

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix(graph): re-absolutize repo-scoped bundle paths on import so a second bundle is not treated as a duplicate (#1627)

* fix pkg_map: falkordb import name is 'falkordb', not 'falkordblite'

---------

Co-authored-by: Yash <C0deRator@proton.me>
Co-authored-by: Ashmeet Singh Sandhu <sandhuashmeet40@gmail.com>
Co-authored-by: Shashank Shekhar Singh <Shashankshekharsingh1205@gmail.com>

* fix: normalize repo_path filters so bare repo names actually match (#1633) (#1634)

Every repo_path-scoped query in CodeFinder built its Cypher filter as a raw
`node.path STARTS WITH $repo_…
@Shashankss1205 Shashankss1205 added gssoc:approved GSSoC validation: counts toward scoring level:advanced GSSoC difficulty: 55 pts contributor / 30 mentor mentor:Shashankss1205 GSSoC mentor attribution: credits reviewing mentor quality:exceptional GSSoC quality: x1.5 contributor / +10 mentor type:bug GSSoC type bonus: bug labels Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gssoc:approved GSSoC validation: counts toward scoring level:advanced GSSoC difficulty: 55 pts contributor / 30 mentor mentor:Shashankss1205 GSSoC mentor attribution: credits reviewing mentor quality:exceptional GSSoC quality: x1.5 contributor / +10 mentor type:bug GSSoC type bonus: bug

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

bug(parsers/kotlin): object A { fun x() = 1 } on one line is never indexed

8 participants