Inline Rust test fns (#[test], #[tokio::test], #[async_std::test],
#[actix_rt::test], #[test_case::case]) inside a regular .rs file were indexed
as ordinary Functions (is_test=false) and leaked past the store.c is_test!=1
filter into graph/agent context — test detection was file-path-based only
(cbm_is_test_file: *_test.rs / test_*). Add rust_def_is_test(decorators)
(mirroring rust_cfg_qualified_name's scan) and set def.is_test in
extract_func_def's Rust branch.
Closes#855.
Signed-off-by: Greg Tiller <tiller@dal.ca>
A JS/TS/TSX member call `x.foo()` whose receiver is not `this`/`super` now
sets CBMCall.is_method, mirroring the existing Perl arrow/method flag. This
lets the call-resolution passes recognise a member call whose receiver type
the TS-LSP could not resolve, so they can decline to bind it to an unrelated
project symbol by a weak short-name guess (#592/#606; precedent PR #477 for
Perl).
`this`/`super` receivers are left unflagged — their target is the enclosing
class, where a namespace-proximity weak match is usually right. Bare calls and
new-expressions have no member receiver, so they stay is_method=false; every
non-TS/JS/Perl language is byte-identical (the struct is zero-init).
The pre-#592 test that asserted a JS member call never sets is_method inverts;
it is replaced by TS and JS flagging tests plus a Go test that keeps the
"flag-exempt languages are unaffected" contract, and the cbm.h field comment
is updated to describe both the Perl and TS/JS semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Hitoshi Okazaki <okazaki.hitoshi@tecnos.co.jp>
- format the include trailing comments and wrap the over-long reap log line
(clang-format-20 violations flagged by lint / lint)
- allowlist subprocess.c's fork with justification (security-static): the
supervisor primitive forks and execs immediately; no code runs in the
forked image
- move bin into the fork-child block alongside argv (cppcheck variableScope
consistency)
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Mixed Gradle/Maven source roots (src/main/java + src/main/kotlin) share
JVM packages that path-derived module QNs cannot join: cross-file LSP
emitted package-shaped names while graph nodes stayed path-derived, and
same-package defs across roots were filtered out, so Java<->Kotlin calls
fell back to weak or wrong textual edges.
The declared package is JVM ground truth. Normalize JVM defs to their
declared package (inferring it from conventional source roots only when
no package was extracted), index defs by declared namespace alongside
the path module so same-package defs survive per-file filtering, restrict
JVM callers to JVM defs, register cross-def return-type signatures for
Kotlin receiver typing, and consume qualified member-call overrides by
leaf name in the parallel lsp_idx fast path.
Distilled from #684 with one design change: the new unique-Class.method
tail-match fallbacks in lsp_resolve.h are gated to JVM callers
(cbm_pipeline_lsp_allow_tail_match, Java/Kotlin only). Tail-matching by
unique leaf is safe where class-per-file package semantics hold; in
other languages a single wrong-module coincidence would fabricate a
CALLS edge. The leaf-key lsp_idx lookup stays global: it aligns the
fast path with the build-side key that already used leaf names.
Tests: carried mixed Java/Kotlin regression (red on main, green here)
plus a gate guard asserting the tail fallbacks stay off for non-JVM
callers and on for JVM.
Co-authored-by: nguyentamdat <nguyentamdat@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The recursion detector flagged any call whose short name matched the
enclosing def as self-recursion, so super().save() inside save,
axios.get() inside get, and console.error() inside error were all
false positives feeding is_recursive, recursion_in_loop and
unguarded_recursion.
Add is_self_receiver() and AND it into the short-name comparison: a
qualified callee only counts when its whole receiver chain (everything
before the last '.') names the same object — self/this/cls/@self, or
the enclosing def's own receiver identifier parsed from
CBMDefinition.receiver (Go: s in 'func (s *Store) save()'). Bare names
keep the prior behavior. Matching the full chain keeps self.obj.recur()
(a field's same-named method) out, and the dynamic receiver whitelist
keeps Go s.save() detected.
Distilled from PR #699 with two corrections: the receiver chain is
matched via the last dot instead of the first segment (self.obj.recur
was still a false positive), and the enclosing-receiver whitelist so
Go method self-recursion is not lost.
Closes#599
Co-authored-by: Gen Li <lg320531124@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Distills PR #770 (graph-buffer dedup key) and completes the edge-uniqueness
contract it left partial: the contract lives in THREE places, and changing
only the buffer ships databases that violate their own UNIQUE constraint
(PRAGMA integrity_check: non-unique entry in sqlite_autoindex_edges_1).
A single 'import { A, B } from ./lib' produced ONE IMPORTS edge: the graph
buffer dedups edges on (source_id, target_id, type) and merge-replaces
properties on collision, so the second symbol silently overwrote the first.
pass_calls.c, pass_usages.c, pass_semantic.c and pass_lsp_cross.c parse one
local_name per IMPORTS edge for cross-file resolution, so the dropped
symbol's calls failed to resolve too — not just "who imports X" queries.
Changes, kept in sync across all three sites:
- graph_buffer.c (from #770): make_edge_key() folds local_name into the
dedup key for IMPORTS edges only; all three key call-sites updated.
Hardened beyond #770: EDGE_KEY_BUF bumped to 256 and oversized
local_names are re-keyed with an FNV-1a hash of the full name instead
of being silently truncated (two long names sharing a prefix must not
collide back into one edge).
- store.c: edges gains local_name_gen, a VIRTUAL generated column
(IMPORTS -> coalesce(json_extract(properties,'$.local_name'),''),
else '' — NOT NULL because NULLs never conflict in a UNIQUE index);
uniqueness widened to UNIQUE(source_id, target_id, type,
local_name_gen) and the insert upsert's conflict target matches.
init_schema probes pre-#768 DBs (no local_name_gen) and fails the
open: SQLite cannot ALTER a table constraint in place, and an
unopenable DB already takes the existing repair path — full index
deletes + rebuilds, artifact import refuses and falls back to a
reindex. Read-only query opens skip init_schema and keep working.
- sqlite_writer.c/.h: dump DDL matches the widened schema; the hand-built
sqlite_autoindex_edges_1 comparator and entry builder include the
local_name column; CBMDumpEdge carries local_name extracted via real
JSON parsing (yyjson) so index entries match json_extract's unescaped
values exactly, per the idx_edges_url_path precedent.
- artifact.h: CBM_ARTIFACT_SCHEMA_VERSION 1 -> 2 so old binaries refuse
artifacts carrying the widened schema (their 3-column conflict target
can no longer prepare against it).
Tests (reproduce-first, all red on the unfixed code):
- gbuf tier: multi-symbol import -> 2 IMPORTS edges; long-local_name
truncation guard.
- store tier: distinct local_name coexists as 2 rows, same local_name
still upserts, non-IMPORTS dedup unchanged.
- writer tier: dumped DB passes integrity_check with 2 sibling imports
and exposes matching local_name_gen values.
- end-to-end: TS fixture through the real pipeline -> 2 queryable
IMPORTS edges AND integrity_check ok; with only the buffer half of
the fix this test still fails on integrity_check, proving the schema
half is required.
Closes#768.
Co-authored-by: Alexandros Pappas <11921291+apappas1129@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
py_eval_expr_type evaluated a call node's attribute receiver twice (once
in the container special-case, once in the general attribute path), so an
N-link chained call cost O(2^N) evaluations — real-world ~65-link builder
chains hung indexing for hours (#710). The evaluator also recursed once
per expression nesting level with no depth guard, so a pathologically deep
expression overflowed the native stack (#720).
Three guard layers, mirroring c_eval_expr_type's design in c_lsp.c:
1. Memoization: py_eval_expr_type is now a wrapper around the renamed
py_eval_expr_type_uncached. Results are cached per node in an
open-addressing table on PyLSPContext (arena-allocated, cold per file),
keyed by node identity (TSNode.id) — NOT the start byte, since every
leftmost descendant of a chain shares it and byte-keyed entries alias
distinct nodes into silently wrong resolutions. Entries carry a scope
generation; every scope bind/restore bumps it (O(1) whole-cache flush),
so re-evaluation under changed bindings (lambda re-walks, isinstance
narrowing) behaves exactly as before. Inserts are load-bounded (<75%)
and rejected when growth fails, so a full table can never spin.
2. Depth cap: PY_LSP_MAX_EVAL_DEPTH 256 (same as C_EVAL_DEPTH_LIMIT);
past the cap the evaluator returns unknown instead of overflowing the
stack. Truncated results — including any ancestor of a truncation —
are never cached, so a depth- or budget-limited `unknown` cannot
poison post-truncation reuse.
3. Step budget: PY_EVAL_MAX_STEPS_PER_FILE 10000 (mirrors
C_EVAL_MAX_STEPS_PER_FILE); pathological files degrade to unknown
instead of stalling indexing. Cache hits don't consume budget.
Distills PR #732 (memoization; fixed the start-byte cache key and the
missing staleness handling) and PR #758 (depth cap; raised 64 -> 256 to
match the sibling LSPs, added the never-cache-truncated ordering), and
adds the step budget plus reproduce-first tests: a 40-link chain that
must resolve its final link, a heterogeneous-receiver chain that fails
under byte keying, a 30k-deep expression crash guard, and a budget
degradation guard.
Fixes#710. Fixes#720.
Co-authored-by: LA-10 <lashuha@uwaterloo.com>
Co-authored-by: Dustin Persek <dustin.persek@protonmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Completes the crash/hang supervisor. A file that makes the indexer make NO
progress (an external-scanner infinite loop, not a crash) is now contained the
same way a crash is: the supervisor's no-progress quiet-timeout kills the worker,
classifies it as a hang, and the recovery loop quarantines the marker-pinned file
and re-runs until a clean run indexes the good files.
- src/mcp/index_supervisor.c: worker_quiet_timeout_ms() defaults to a generous
15 min NO-PROGRESS timeout (reset by the worker's periodic log heartbeat), with
the CBM_INDEX_WORKER_TIMEOUT_S override. (--progress deliberately NOT added: it
installs a replace-mode sink + carriage-return updates that would remove the
newline heartbeat the quiet-timeout relies on.)
- Quarantine entries now carry the phase ("rel\tphase"): a fault signal -> "crash",
a no-progress kill -> "hang". internal/cbm/cbm.c parses it (bare path tolerated,
defaults crash) and exposes cbm_index_quarantine_phase(); the extract loops
(pass_parallel.c, pass_definitions.c) report the real phase + reason in skipped[].
- src/mcp/mcp.c: recovery loop logs the outcome, and a best-effort-PARTIAL terminal
spawn — when the loop cannot converge but files were quarantined, one final
quarantine-only run yields a partial index (good files indexed, known-bad ones
skipped) + a high-severity index.supervisor.partial log, instead of a hard error.
- scripts/smoke-invariants.sh: inv_hanger_skipped_cli (hang twin of the crasher
guard — baseline rc=124 vacuity check; supervised -> status indexed + hanger
phase=hang + good file indexed). Hardened run_bounded to force-kill (SIGKILL) a
SIGTERM-catching busy-spin and to not block on the fallback fifo open — the hang
guard is the first invariant to actually hang and exposed this latent harness bug.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Stage 3b contained a crash to the worker but returned a failure response. Now the
supervisor pins the exact crashing file, quarantines it, and re-indexes the
healthy files: index_repository returns status="indexed" with the crasher listed
in skipped[] (phase="crash") and every other file indexed normally.
On a crash/hang the supervisor re-runs the worker single-threaded with a per-file
marker (CBM_INDEX_MARKER_FILE) so the marker names the EXACT crasher; that file is
appended to a quarantine list (CBM_INDEX_QUARANTINE_FILE) and the worker is
re-spawned until a clean run indexes the good files. Bounded by
CBM_INDEX_MAX_RESTARTS (default 100); each crash quarantines one new file, so the
loop makes monotonic progress.
- internal/cbm/cbm.c/.h: per-file marker + a lazily-loaded crash-quarantine set,
with a HARD guard at the top of cbm_extract_file (the one choke point every
extract pass funnels through) that short-circuits a quarantined file to an empty
result BEFORE the marker/parser — so no re-extracting pass (sequential
pass_calls/usages/semantic on a cache miss) can crash on it again.
- src/pipeline/pipeline.c: effective_worker_count() honours CBM_INDEX_SINGLE_THREAD
(=1 worker, sequential path) for exact single-threaded attribution.
- src/pipeline/pass_parallel.c, pass_definitions.c: report a quarantined file as a
phase="crash" skip via the Stage-2 skip plumbing so it surfaces in skipped[].
- src/mcp/index_supervisor.{h,c}: cbm_index_spawn_worker gains single_thread /
marker / quarantine knobs, passed to the child as inherited env around the
sequential spawn.
- src/mcp/mcp.c: index_run_supervised recovery loop (attribute -> quarantine ->
re-run until clean; else a contained-failure response).
- scripts/smoke-invariants.sh: inv_crasher_skipped_cli upgrades the 3b guard to the
skip-continue contract (baseline crash escapes; supervised -> status indexed +
crasher phase=crash + good file indexed + good file NOT skipped).
In-run quarantine only (not persisted across separate index runs); content-hash
persistence + re-admit is a follow-up.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A single pathological file that hard-crashes the native indexer (SIGSEGV / stack
overflow / abort / tree-sitter assertion) previously took down the whole MCP
server or CLI (issue #668). index_repository now runs in a worker subprocess: the
parent reaps it, and a crash is contained — the parent survives and reports it
instead of dying.
- src/mcp/index_supervisor.{h,c}: worker-role state + cbm_index_spawn_worker(),
which spawns `<self> cli --index-worker index_repository <json> --response-out
<tmp>` via the subprocess primitive, reaps, and classifies. fork+exec only;
recursion is prevented by the --index-worker argv flag, never an ambient env
var (which could misfire and silently run unprotected in-process).
- src/mcp/mcp.c: a supervisor gate at the top of handle_index_repository wraps the
run unless this process is the worker or CBM_INDEX_SUPERVISOR=0 (kill switch).
Clean exit passes the worker's response through; a crash/hang returns a
contained-failure response; a spawn failure degrades to the in-process path.
- src/main.c: parse --index-worker / --response-out; the worker writes its result
to the response file for the parent to read back.
- src/ui/http_server.c: its existing index spawn now passes --index-worker so it
does not re-supervise (one isolation layer, no redundant process nesting).
- src/foundation/subprocess.c: the forked child redirects output with open()+dup2()
instead of freopen() — async-signal-safe, no malloc between fork and exec, since
the parent may be multithreaded (server threads + mimalloc/sqlite/libgit2 state).
- internal/cbm/cbm.c: CBM_TEST_CRASH_ON / CBM_TEST_HANG_ON deterministic fault
injectors (test-only, env-gated) so the guard is honest — green iff a real fault
is genuinely contained, not a fixture that may stop faulting.
- scripts/smoke-invariants.sh: inv_crasher_contained_cli asserts the crash escapes
as a signal without the supervisor (rc>=128) and is contained + reported with it
(rc<128, outcome=crash).
Precise skip-and-continue (quarantine the culprit file, index the rest) is layered
on in a follow-up; this change contains the crash and keeps the process alive.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Spring class-level RequestMapping prefixes were not folded into method mapping route_path values, so the graph could contain partial Route nodes like /orders instead of the actual endpoint /api/orders even while HANDLES edges existed.
Combine Java/Kotlin class mapping prefixes with method-level mappings during method definition extraction, and scan bounded annotation-argument subtrees so named or array-valued Spring annotations can provide the path literal.
Strengthen edge_types_probe to assert exact Spring Route node names for Java and Kotlin, not just HANDLES edge counts.
Signed-off-by: jonghyun.lee <jonghyun.lee@musinsa.com>
The per-language "resolve calls in AST node" walkers recurse once per nesting
level. Four of them — Python (py_resolve_calls_in), Go (resolve_calls_in_node),
PHP (php_resolve_calls_in_node), Kotlin (kt_resolve_calls_in_node) — had no depth
guard, so a deeply-nested or cyclic file could drive them into a native stack
overflow (SIGSEGV) that takes down the whole index run. C and Java already guard
this (C_LSP_MAX_WALK_DEPTH / JAVA_LSP_MAX_WALK_DEPTH).
Add the same wrapper/inner depth guard to all four, backed by a shared,
env-configurable cap: CBM_LSP_MAX_WALK_DEPTH (default 512) in scope.h. Past the
cap the wrapper skips the subtree — those calls stay unresolved (graceful
degradation, not a crash). The walk_depth-- runs after the inner body returns, so
early returns can't leak the counter.
Reproduce-first: lsp_{python,go,php,kotlin}_deep_nesting_no_crash (30000-deep
nested calls, run under a forked child + WIFSIGNALED). RED without the cap (the
child SIGSEGVs — verified by raising CBM_LSP_MAX_WALK_DEPTH to 100M), GREEN with
it. Full suite 5752/0, no ASan/leak.
Part of the resilient-indexing effort (Track B prevention layer). Refs #668.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
walk_defs used a fixed `walk_defs_frame_t stack[4096]` — a single ~160 KB frame on
the C stack. Two problems:
- On small thread stacks (notably the pre-2026-03 Windows 1 MB main thread) that
huge frame overflowed the stack during the definitions pass — the 0xC00000FD
crash a reporter hit indexing a large SQL file (#668). Reproduced locally by
running extraction under a constrained stack (~208 KB) + ASan.
- The `top < 4096` push guards SILENTLY DROPPED every top-level definition past
4096, so a file with more than 4096 top-level defs was only partially indexed.
Replace the fixed array with a growable heap stack (wd_stack_t + wd_push): a
256-frame initial buffer that doubles on demand, bounded by a generous,
env-configurable ceiling (CBM_WALK_DEFS_MAX, default 8M frames) that WARNs once
instead of dropping, and returns cleanly on realloc OOM (no NULL deref). A normal
file now uses ~10 KB of heap instead of 160 KB of stack, and files with thousands
of top-level defs are fully extracted. The frame stack is a pure index-addressed
LIFO (frames copied by value on pop), so heap-backing is semantically identical.
Reproduce-first: walk_defs_no_truncation_over_4096_issue668 — 5000 top-level defs
must all be extracted (RED on the old 4096 cap, GREEN after). Full suite 5748/0,
no ASan/UBSan reports on the new alloc.
Root cause was pinned by reproduction (constrained-stack + ASan), refuting earlier
grammar/scanner hypotheses — extraction and the vendored tree-sitter tree ops are
otherwise iterative. The separate error-recovery assertion mode
(symbol<token_count) was not locally reproducible and is addressed by the
forthcoming general crash-containment layer.
Refs #668
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Django REST Framework ViewSets expose extra endpoints through @action-decorated
methods, e.g. @action(detail=True, methods=["post"], url_path="approve"). On
main these methods carried no route_path, so Phase 2a created no Route node and
no HANDLES edge for them -- the endpoint was invisible in the graph (#603).
Add try_drf_action_decorator (internal/cbm/extract_defs.c): when a method's
decorator identifier is "action", read the methods / url_path / detail kwargs
and set *out_path/*out_method. The existing decorator-route pipeline
(ensure_one_decorator_route, Phase 2a) then creates the Route + handler->Route
HANDLES edge in the established direction. Fallbacks: method name for url_path,
"GET" for methods; detail=True -> /{pk}/<seg>, else /<seg>.
Reproduce-first guard: handles_drf_action_python in the edge_types_probe suite
-- a DRF @action method must produce >=1 HANDLES edge (RED with 0 edges before
the extractor, GREEN after).
Known limitation: multi-method actions (methods=["get","post"]) capture only the
first method -> a single Route.
Distilled from #647 (thanks @muskiteer). Drops that PR's unrelated project-wide
HANDLES edge-direction reversal, which was unnecessary for the fix and broke the
handler->Route convention relied on by pass_cross_repo.c's find_route_handler.
Closes#603
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Co-Authored-By: muskiteer <agarwal.vansh3110@gmail.com>
cbm.c uses git_allocator (with GIT_OPT_SET_ALLOCATOR) but includes only
<git2.h>, which does not pull in <git2/sys/alloc.h> where the type is
declared. This compiles out on a bare macOS toolchain (libgit2 absent
from the pkg-config path, HAVE_LIBGIT2 undefined), but fails to compile
when libgit2 is present — e.g. in the Nix dev shell:
internal/cbm/cbm.c:347: error: unknown type name 'git_allocator'
Refs #705.
Signed-off-by: Kris Williams <115474+kriswill@users.noreply.github.com>
The per-file C cross-LSP resolver mutated the finalized, project-wide cross
registry that is shared read-only across the parallel resolve workers. Each
post-finalize add landed in a tail the hash index does not cover, so every
lookup linear-scanned an ever-growing tail -> O(files*defs) on large C
codebases. The Linux-kernel full index hung at "[4/9] Resolving" on 11 cores
for >6 min and never finished, plus a heap data race across workers.
Seal every shared cross-registry: CBMTypeRegistry gains a read_only flag, set
by all five {c,py,cs,ts,go}_build_cross_registry builders right after
finalize, and cbm_registry_add_func/_type no-op on a sealed registry. One
chokepoint guards every language, robust to any resolver mutation site. The
plain-C function-registration site is also skipped directly when the registry
is shared.
Add six cross-language invariant tests (c, c++, py, c#, ts, go): resolving
against a finalized shared registry must leave func_count/type_count
unchanged -- RED before the fix (C +1, C++ +2 post-finalize adds), GREEN
after.
Verified: full suite 5720 passed / 0 failed; Linux-kernel full index now
completes all 9 phases in 643s (4.88M nodes, 11.9M edges) instead of hanging
at phase 4.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The Kotlin LSP resolves a method call on an unknown-typed receiver whose member
is a universal kotlin.Any method (toString/equals/hashCode) to kotlin.Any.<m>
and emits lsp_kt_any, but no kotlin.Any node existed in the graph, so pass_calls
had no target node and dropped the call (callable=0).
Add kotlin_builtins.c (mirror of py_builtins.c): inject kotlin.Any plus its
three universal methods into result->defs during cbm_run_kotlin_lsp, before the
pipeline mints def nodes. The lsp_kt_any call now targets the injected node and
forms a CALLS edge.
Update the kotlin grammar-label golden to include the 4 injected nodes, exactly
as python's golden already includes its injected builtins.
Un-skips repro_lsp_kt_any. Repro suite 312/0, unit suite 5693/0.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The call extractor dropped any callee matched by cbm_is_keyword(), and the
Python keyword set lists builtins (len, str, range, int, list, dict, print) to
suppress them as bare usages. That also dropped the textual CALL, so the LSP
resolution to the injected builtins.<name> node (py_builtins.c) had no call to
attach to and no CALLS edge formed (callable=0) for free-function and
constructor builtins. Method builtins (s.upper) were unaffected because method
names are not in the keyword set.
Add cbm_is_resolvable_builtin() — the builtins we mint a real graph node for —
and keep those callees in call extraction only; the keyword filter still
applies to usage and semantic extraction (its four other call sites are
unchanged). Every un-filtered builtin has a target node, so the new edge is
always callable-sourced, never Module-sourced.
Un-skips repro_lsp_py_builtin and repro_lsp_py_builtin_constructor.
Repro suite 311/0, unit suite 5693/0.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The Kotlin destructuring call (extract_calls.c, commit 15e541b) and the Dart
signature-sibling while-loop (extract_unified.c, commit e01e77f) carried
clang-format violations that failed the dry-run lint gate. Reformat just those
two statements (per-region, LLVM-22 = clang-format-20 canonical) so lint-format
is clean. No behavior change.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
For a JSX component imported by a relative specifier (`import { Widget } from
"./widget"`), the per-file pass emitted lsp_ts_jsx_import with the raw specifier
as the module QN ("./widget.Widget"). That QN matches no node, but — winning the
exact-equality/confidence join over the cross-file pass's correct resolution
(equal confidence, emitted first) — it dropped the edge, so the strategy was
absent. The cross-file pass re-runs with the specifier resolved to the real
module QN and emits the correct "<proj>.widget.Widget" resolution. Skip the
per-file emission for relative specifiers (mark unresolved) so the cross-file
resolution stands.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
`import static demo.Util.twice` records the import target package-qualified
("demo.Util"), but the class is registered under the project/directory QN
("<proj>.Util") when the `package` declaration and the file's directory differ
(e.g. a flat layout). java_lookup_method("demo.Util", ...) then missed, the call
fell through to the lsp_static_import_text fallback, and the precise
lsp_static_import strategy was absent.
On a miss, resolve the import's class by its short name against the registry and
retry the method lookup, preferring an in-module match. Mirrors the C++
short-name type fallback.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
OCaml's def walk does not descend into function bodies, so it emits a node only
for the OUTERMOST value_definition (the top-level function). The unified walk,
however, descends to find calls and pushed a SCOPE_FUNC for every value_definition
it met — including a nested local `let x = e in ...`, which is also a
value_definition but has no node. An in-body call then attributed to that nodeless
local binding, so its CALLS edge sourced to neither a Function nor the Module
(callable=0, module=0 in the breadth invariant).
Push a function scope only for the outermost value_definition (none already on
the scope stack) for OCaml, so in-body calls attribute to the top-level function
the def walk actually emitted.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
For an out-of-line method definition `int Processor::run() {...}`, c_lsp built
the resolved-call caller QN from c_build_qn("Processor::run") = "Processor.run"
— the bare class-qualified name with NO module prefix. The def walk and the
call-scope QN both qualify the same method as "<module>.Processor.run", so the
exact-equality join in cbm_pipeline_find_lsp_resolution missed and the LSP
resolution (the correct caller for a call inside the out-of-line body, which
tree-sitter alone attributes to the module) was silently discarded — the edge
fell through to the registry resolver with no lsp_ strategy (gap #5a).
When the class scope of a qualified declarator is resolved HERE to the full
module-qualified class QN (saved_class_qn != enclosing_class_qn) and the built
func_qn carries a bare class prefix, rebuild it as <class QN>.<method short name>
so the caller QN matches both other producers and the rescue joins.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A symbol declared in `namespace ns` was collapsed to the file scope
(`proj.file.serialize`, not `proj.file.ns.serialize`) because the def walk
treated namespace_definition as a module-level container that contributes no QN
segment. With the namespace absent from the QN, an unqualified `serialize(d)`
resolved DIRECTLY to the top-level name and the argument-dependent-lookup path
(c_adl_resolve, which reads the namespace out of the argument type's QN) was
never reached.
Make a namespace contribute a QN segment in the def walk (it extends the
enclosing scope like a class but emits no def of its own; its members — both
free functions and nested classes — are walked under the extended scope). Mirror
it in c_lsp:
- c_process_function qualifies a namespace-scoped free function with
current_namespace instead of the file module, matching the def QN.
- c_lookup_member gains a short-name fallback so a type referenced by its
file-scoped or bare name (e.g. the return type of a namespace-scoped factory
used outside that namespace) still resolves to its namespace-qualified
registered QN.
- c_adl_resolve also tries the module-prefixed form of an argument type's
namespace, since `ns::Data` evaluates to the bare namespace `ns` while the
overload is registered under `<module>.ns.serialize`.
C#/PHP need the same treatment paired with their own resolvers (a def-only change
breaks their existing namespace handling) and are done separately.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Three C++ constructs invoke a member implicitly with no textual call node, so
the call walk produced nothing for the c-LSP to attach its resolution to
(callable=0, no CALLS edge):
- delete p; -> destructor T::~T
- T a = b; -> copy/move ctor T::T
- if (obj) { ... } -> conversion T::operator bool
Synthesize a textual call sourced to the enclosing function at each trigger so
the lsp_{destructor,copy_constructor,conversion} resolution binds. The
destructor callee QN embeds the type (T.~T) which is not textually available
from `delete p`, so it joins via the reason gate (c_lsp stashes the operand
text); the other two join by callee short-name (the declared type / the
type-independent operator bool).
Two underlying defects surfaced and are fixed:
- The conversion check evaluated the type of the `condition_clause` node
(the `( expr )` wrapper, which has no type) instead of the inner
expression, so `if (obj)` never resolved obj's type. Unwrap the clause.
- A conversion operator's `operator_cast` node spans the full
"operator bool() const" in this grammar (params + cv-qualifiers folded in),
so the method was indexed under that malformed name and member lookups for
"operator bool" missed. Add cbm_func_name_node_text to normalize it to
"operator bool", shared by the defs and unified extractors so the def name
and call-scope QN agree.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
An overloaded C++ binary operator (return a + b; where Vec defines operator+)
invokes the operator method implicitly, so the call walk never produced a call
node — the c-LSP resolved Vec::operator+ (lsp_operator) but had no call site to
attach to (callable=0, no CALLS edge).
Synthesize a textual call to the bare operator name (operator<token>) at each
binary_expression for C++/CUDA, mirroring c_lsp.c's binary handling (the operator
token is the first unnamed child). The join binds it to the c-LSP resolution.
Builtin-operand expressions synthesize operator+ too but resolve no member and
are dropped, so no spurious edges. Mirrors the existing Kotlin operator-call
synthesis.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
PureScript function application (greet "world", log (...)) is an exp_apply node
whose function head is an exp_name child — extract_fp_callee only handled
apply/application_expression, and PureScript wasn't routed to it, so the callee
was never extracted and no call to greet was found.
Route PureScript through extract_fp_callee (like Haskell/OCaml) and teach it
exp_apply: the head is an exp_name (its text is the qualified function name), and
curried applications nest exp_apply, so descend the function head to recover the
leftmost callee. Haskell/OCaml apply handling is unchanged.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
An Agda definition is two 'function' nodes: the type signature
(compute : Nat -> Nat, whose lhs has a function_name child that names the def)
and the body clause (compute x = add x 1, whose lhs has no function_name). The
shared resolver deliberately returns NULL for the body clause to avoid a
duplicate def — but that left the body clause unscoped, so the in-body call
(add x 1) sourced to the Module.
Resolve the body clause's name in compute_func_qn (call-scope only, not the
shared resolver) from the lhs head identifier, so the call attributes to the
function while definition extraction stays single-def. grammar_scientific is
fully green.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A SCSS @function call (double($x) inside another @function) is a call_expression
whose callee is a function_name child — there is no function field, so the
generic field-based resolver returned NULL and the call was dropped, leaving no
CALLS edge to the in-file @function (enclosing_parity SCSS: 0 edges). The
function scope itself was already correct (the call's enclosing was the calling
@function).
extract_scss_callee now also handles call_expression by reading the function_name
child, alongside the existing include_statement (mixin) case.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Julia parses short-form definitions (helper(x) = x + 1) as an 'assignment' node
whose LHS is a call_expression, not as 'short_function_definition' (the
configured julia func type was effectively dead for short form). So helper/run
were never extracted as Function nodes, in-body calls resolved no target, and no
CALLS edge formed (enclosing_parity/grammar_scientific Julia: 0 edges).
Add 'assignment' to julia_func_types and name it in cbm_resolve_func_name only
when the LHS is a call_expression (the head identifier is the function name);
plain 'x = 5' resolves NULL so it is neither extracted nor scoped. This fixes
both the def (helper/run become nodes) and the call scope (the body sources to
the function). The LHS signature call is a self-reference and is dropped by the
source==target guard.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Dart models a function as a function_signature plus a SIBLING function_body
(both children of program/class_body), not a single node containing the body.
So a scope pushed at the signature never covers the body, and the signature's
name was also unresolved by the shared resolver — in-body calls sourced to the
Module (enclosing-func drift).
Two parts in the unified walk:
- compute_func_qn resolves a dart function_signature/method_signature name from
its identifier child (method_signature wraps a function_signature).
- push_boundary_scopes pushes the SCOPE_FUNC at the function_body, computing the
QN from the preceding signature sibling, so the body's children attribute to
the function. grammar_scripting and enclosing_parity dart cases now green.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A call inside a Rust impl method (impl Calc { fn compute(&self,x) { add(...) } })
was sourced to the Module: impl_item is in rust_class_types, so push_boundary_scopes
routed it through compute_class_qn, which returned NULL because impl_item has no
name field (the implementing type is in the type field). The dedicated impl
handler later in push_boundary_scopes is therefore dead code (the class branch
already consumed impl_item). With no class scope, compute_func_qn produced
proj.file.compute instead of proj.file.Calc.compute, which does not match the
class-qualified def-side Method node, so calls_find_source missed it and fell
back to the file/Module node.
Resolve impl_item's type field in compute_class_qn (mirroring the objc class-name
case), so the impl pushes the Calc scope and impl-method calls attribute to the
method. grammar_core is now fully green.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A top-level Objective-C C function (static int helper(int x) {...}) was never
extracted as a Function def: resolve_func_name_c_family handled function_definition
for C/C++/CUDA/GLSL/HLSL/ISPC/Slang but not Obj-C, so cbm_resolve_func_name
returned no name node and extract_func_def dropped the def. With no helper node,
a call helper(x) from a method body resolved to nothing and produced no CALLS
edge (grammar_core_objc: 0 callable-sourced edges).
Obj-C top-level functions share the exact C declarator structure, so route them
through cbm_resolve_c_declarator_name_node like the other C-family languages.
Completes the Obj-C method-call chain together with the class/method scope fix:
helper is now a node, and the (already correctly attributed) call from
Calc.Calculator.compute resolves an edge to it.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Calls inside an Obj-C method (- (int)compute:(int)x { return helper(x); }) were
sourced to the enclosing @implementation class (or the Module) instead of the
method, because the unified walk pushed no class or function scope for
Objective-C:
- compute_class_qn returned NULL for class_interface / class_implementation (no
name field; the class name is a plain identifier child), so no class scope was
tracked.
- compute_func_qn could not name a method_definition (the selector keyword is a
plain identifier child, distinct from the method_type return-type child).
Resolve the class name from the identifier child (mirroring the def extractor),
and resolve the method call-scope QN directly in compute_func_qn (not via the
shared cbm_resolve_func_name, which would make the def pass double-emit the
method as a top-level Function alongside the class-member Method node). In-body
calls now source to <project>.<file>.<Class>.<method>, matching the Method node
QN. Same enclosing-func-drift class as the calls-source-to-callable fix; the
grammar_core_objc red additionally needs the free-function call to resolve an
edge target.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
$p->anything(1) on a class with a __call magic method emitted php_method_dynamic
to a synthetic <class>.anything target at confidence 0.20 — below the pipeline
join's 0.6 floor, and to a node that does not exist — so no CALLS edge ever
carried the strategy.
The call actually dispatches to the class's __call handler. Resolve to
<class>.__call (a real method node) at 0.85 (the dispatch is certain), and stash
the dynamic method name ("anything") in reason so the join binds it to the
textual call site whose callee differs from __call. Admit php_method_dynamic to
the reason-matched join (lsp_resolve.h), the same pattern used for func-pointer,
ctor-ref, and dict-dispatch resolutions.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Two Rust trait-resolution fixes, both cases where the trait's own abstract
method shadowed the concrete impl:
lsp_trait_dispatch: d.speak(2) where Dog implements trait Speak resolved the
method inherently on Dog and was tagged lsp_method_dispatch. The trait-dispatch
path only fired when a method was resolved THROUGH a separate receiver type. Add
rust_method_is_trait_impl: when an inherently-resolved method also comes from a
trait the receiver implements (a declaring trait among its embedded_types),
tag it lsp_trait_dispatch. Mirrors the existing override-is-dispatch notion.
lsp_trait_ufcs: Speak::speak(2) resolved to the trait's own abstract method
Speak.speak (lsp_ufcs) because the inherent UFCS lookup found it first. A
trait-typed UFCS head must resolve to the sole concrete impl. Detect the trait
via is_interface (set at registration, reliable on the early resolve pass) and
resolve through rust_find_sole_trait_impl, which scans implementers and excludes
the trait's own method. Critically, emit NOTHING when no impl is known yet: the
resolver runs twice and a partial-pass lsp_ufcs to the abstract method would
otherwise outrank the real trait_ufcs from the complete pass and win the join.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A virtual method called on a concrete derived object (Derived d; d.speak(2),
where Derived overrides Base::speak) resolved directly to Derived.speak and was
emitted as lsp_type_dispatch. The existing virtual-dispatch path only fired when
a method was resolved THROUGH a base with a derived override; an override
resolved directly on the derived type was missed.
Extend the same notion (a derived override of an inherited method = polymorphic
dispatch) to the direct-resolution case: when a method is found directly on the
receiver type AND a base class also declares it, emit lsp_virtual_dispatch. This
mirrors the codebase's existing heuristic definition of virtual dispatch (an
override exists) rather than the textual 'virtual' keyword, so it stays
consistent. lsp_type_dispatch and lsp_base_dispatch fixtures are unaffected.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A Python call through a dict dispatch table, funcs["a"](v), produced no CALLS
edge at all: the call's function field is a subscript, which the textual
callee extractor did not handle, so no textual call existed for the py-LSP's
lsp_dict_dispatch resolution to attach to (callable=0, strategy absent).
Three coordinated changes, the same reason-field pattern used for func_ptr and
ctor refs:
- extract_calls: for a Python call whose function field is a subscript with an
identifier base and a literal string key, emit the base identifier ("funcs")
as the textual callee so a callable-sourced CALLS edge exists. Gated to that
exact shape so other subscript calls (arr[i]()) are unaffected.
- py_lsp: the dict-dispatch resolution stashes the subscript base identifier in
reason (the textual callee differs from the resolved target "foo"), via a new
py_emit_resolved_call_reason; py_emit_resolved_call delegates with NULL.
- lsp_resolve: admit lsp_dict_dispatch into the reason-matched join so the
resolution binds to the textual call site.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Two C# bare-invocation resolution fixes, each mirroring an existing pattern:
cs_inherited_method: a bare call Method() resolved via cs_lookup_method on
the enclosing class walks the base chain, so an inherited method was emitted
as cs_self_method. Distinguish by receiver_type exactly as the instance-call
path does — a method declared on the enclosing class is cs_self_method, one
found on a base is cs_inherited_method.
cs_using_static: a 'using static Demo.MathUtil' directive stores its target as
the namespace-qualified source text, but types register under the file-stem QN
(proj.Client.MathUtil), so the method lookup missed. Route the target through
cs_resolve_type_name (whose short-name fallback bridges namespace-qualified
names to the file-stem registration) before looking up the method.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>