From 10ad921b423b767dd8a947bbf0fbcc2e95038ad3 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Sun, 9 Aug 2026 23:19:08 +0100 Subject: [PATCH] fix: JS/Kotlin/Swift/SQL extractor correctness batch; bump to 0.9.38 (#2568, #2565, #2561, #2577, #2575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These changes are interleaved across engine.py/extract.py by function, so they land as one batch. #2568 (thanks @imagineers-tyler): the 0.9.37 #2552 callback-body fix unioned sibling closures' local names under the shared declaration, so a local in one callback suppressed a real indirect_call in a sibling. Locals are now scoped per body (keyed by body id, via walk_calls' extra_locals). Restore-only, never fabricates; #2552 capture preserved. #2565 (thanks @kskchaitanya1993): Kotlin property initializers — class, top-level, companion, and `by lazy {}` — now seed call extraction, so `val repo = createRepo()` produces a calls edge; literal initializers produce none; FQ calls compose with the #2550 resolver. #2561 (thanks @fakewaffle): Swift receiver typing now handles `@Environment(Store.self)` (whitelisted; @Query/keypath/dotted skipped to avoid a wrong edge) and in-corpus factory bindings via a marked concrete return type; opaque/array/out-of-corpus returns stay unresolved. #2577 (thanks @wilyan09007, PR #2579): the SQL extractor no longer emits a reads_from edge to a CTE name. WITH names are scoped per query (a subquery CTE no longer suppresses an outer real table of the same name), so a CTE no longer mints a bare stub that binds to an unrelated same-named symbol. #2575 (thanks @phudayyy, PR #2574): a dynamic `import('…')` inside a nested function or at module scope now produces an edge, dynamic_import is included in affected, and calls inside nested named functions are collected; a dynamic import already captured as a deferred imports_from is not double-counted. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 +- graphify/affected.py | 6 + graphify/extract.py | 202 ++++++++++++++++++++---- graphify/extractors/engine.py | 227 ++++++++++++++++++++++++--- graphify/extractors/sql.py | 47 +++++- pyproject.toml | 2 +- tests/fixtures/sample_cte.sql | 8 + tests/test_js_callback_calls.py | 93 +++++++++++ tests/test_js_dynamic_imports.py | 226 ++++++++++++++++++++++++++ tests/test_kotlin_grammar.py | 148 +++++++++++++++++ tests/test_multilang.py | 100 ++++++++++++ tests/test_swift_cross_file_calls.py | 178 +++++++++++++++++++++ 12 files changed, 1188 insertions(+), 59 deletions(-) create mode 100644 tests/fixtures/sample_cte.sql create mode 100644 tests/test_js_dynamic_imports.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 660fcf4e..7372a80e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,15 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) -## 0.9.37 (unreleased) +## 0.9.38 (unreleased) + +- Fix: the 0.9.37 callback-body fix (#2552) no longer lets a local declared in one callback suppress a call in a sibling callback (#2568, thanks @imagineers-tyler). Each callback body's local names are now scoped to that body instead of unioned under the shared declaration, so a real `indirect_call` in one sibling closure is no longer dropped because another sibling declared a same-named local. This can only restore dropped edges, never fabricate. +- Fix: Kotlin calls in a property initializer are now collected (#2565, thanks @kskchaitanya1993). A class property (`val repo = createRepo()`), a `by lazy { ... }` delegate, a companion-object property, and a top-level property initializer now produce `calls` edges attributed to the enclosing class (or file), including fully-qualified calls. A plain literal initializer produces no edge. +- Fix: Swift receiver-type inference now handles `@Environment(Store.self)` properties and factory-initialised bindings (#2561, thanks @fakewaffle). A member call on a receiver typed only through an `@Environment(Type.self)` attribute, or bound to an in-corpus factory whose return type is known (`let x = ServiceFactory.make()`), now resolves. Ambiguous or non-concrete returns (opaque `some P`, arrays, out-of-corpus) stay unresolved rather than guessing. +- Fix: the SQL extractor no longer emits a `reads_from` edge to a CTE name (#2577, thanks @wilyan09007). A `WITH cte AS (...)` name is scoped to its query and is no longer treated as a table, so it no longer mints a bare stub that could bind to an unrelated same-named symbol; an outer real table sharing a subquery-CTE's name still resolves. +- Fix: a dynamic `await import('…')` inside a nested function or at module scope now produces an edge (#2575, thanks @phudayyy), and `dynamic_import` edges are now included in `affected`. Calls inside a nested named function are also collected now. A dynamic import already captured as a deferred `imports_from` is not double-counted. + +## 0.9.37 (2026-08-08) - Fix: TypeScript member calls no longer fabricate a high-confidence `calls` edge by matching a receiver type by name alone (#2553, thanks @Earthfreedom). A member call now resolves only when the receiver's type is defined in the same file or actually imported by the caller's file, so a third-party `import type { Repo }` can no longer bind to an unrelated local `class Repo`; table-inferred receivers are tiered to INFERRED rather than EXTRACTED. - Fix: TypeScript/JavaScript calls inside a callback body passed to another call (for example `export const handler = wrapper(async (req) => { helper() })`) are no longer dropped (#2552, thanks @Earthfreedom). The callback body is now walked and its calls attributed to the declaration, through the same import-gated resolution so it cannot fabricate edges. diff --git a/graphify/affected.py b/graphify/affected.py index ce141522..0a3cd157 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -15,6 +15,12 @@ DEFAULT_AFFECTED_RELATIONS = ( "references", "imports", "imports_from", + # `import('…')` — emitted by the Svelte/Astro/Vue rescue passes and (since + # #2575) by plain JS/TS too. Omitting it made every dynamic import + # invisible to blast-radius traversal even where the edge WAS in the + # graph, and dynamic import is precisely how codebases break require + # cycles, so the missing edges sat under the most load-bearing modules. + "dynamic_import", "re_exports", "inherits", "extends", diff --git a/graphify/extract.py b/graphify/extract.py index 2ea5b500..3ec8711a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1231,9 +1231,88 @@ def extract_js(path: Path) -> dict: result = _extract_generic(path, config) if "error" not in result: _extract_js_rationale(path, result) + _rescue_js_dynamic_imports(path, result) return result +def _rescue_js_dynamic_imports(path: Path, result: dict) -> None: + """Recover ``import('…')`` edges the AST pass does not emit for plain JS/TS. + + tree-sitter models ``await import('x')`` as a ``call_expression``, not an + ``import_statement``, so the specifier only reaches the graph when + ``walk_calls`` visits that call — which it never does at module scope + (only function bodies are walked for calls). The Svelte/Astro/Vue + extractors already patch the same gap by regex because their AST pass + fails wholesale; plain ``.ts``/``.js`` was left out on the reasoning that + its AST pass "works". It works for STATIC imports; dynamic ones outside a + walked body fell through silently (#2575), and because they cluster under + hub modules the loss compounds with ``affected`` traversal depth. + + Dedupe: a dynamic import the AST pass DID capture is already in the graph + as an ``imports_from`` edge marked ``deferred`` (``_dynamic_import_js``). + Re-emitting it here as a second ``dynamic_import`` edge would state the + same fact twice, so a match whose resolved target already has a deferred + edge is skipped. + + Regex false positives in comments/strings are the precedented trade of + the Svelte/Vue rescues; a ``//``-prefix guard covers the common case. + """ + try: + import re as _re + src = path.read_text(encoding="utf-8", errors="replace") + if "import(" not in src: # cheap bail — most files have none + return + existing_ids = {n["id"] for n in result.get("nodes", [])} + file_node_id = _make_id(str(path)) + aliases = _load_tsconfig_aliases(path.parent) + base_url = _load_tsconfig_base_url(path.parent) + deferred_ids: set[str] = set() + deferred_files: set[str] = set() + for e in result.get("edges", []): + if e.get("deferred") and e.get("relation") == "imports_from": + deferred_ids.add(e.get("target")) + tf = e.get("target_file") + if tf: + try: + deferred_files.add(str(Path(tf).resolve())) + except OSError: + deferred_files.add(str(tf)) + # `(? None: _add_doc_ref(m.group(1), lineno) +def _resolve_rescued_specifier( + path: Path, + raw: str, + aliases, + base_url, +) -> "tuple[str, str, Path | None] | None": + """Resolve a regex-rescued import specifier the way ``_import_js`` does. + + Returns ``(node_id, stub_source_file, resolved_file)`` — ``resolved_file`` + is the target as a real on-disk file, or None when the specifier is + external or dangling. Returns None when no target can be minted at all + (empty bare-import segment). Split out of :func:`_emit_rescued_import` so + :func:`_rescue_js_dynamic_imports` can resolve a match FIRST and skip + specifiers the AST pass already emitted, without duplicating the + resolution rules. + """ + if raw.startswith("."): + resolved = _resolve_js_module_path( + Path(os.path.normpath(path.parent / raw)) + ) + resolved_file = resolved if resolved is not None and resolved.is_file() else None + return _make_id(str(resolved)), str(resolved), resolved_file + # Check tsconfig.json path aliases (e.g. "$lib/" -> "src/lib/", + # "@/" -> "src/") before treating as external. Mirrors _import_js + # logic so alias imports resolve to the same file node IDs the + # extractor creates (#701). + resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url) + if resolved_alias is not None: + resolved_alias = _resolve_js_module_path(resolved_alias) + resolved_file = (resolved_alias if resolved_alias is not None + and resolved_alias.is_file() else None) + return _make_id(str(resolved_alias)), str(resolved_alias), resolved_file + # Bare/scoped import (node_modules) - use last segment; + # build_from_json drops as external if no matching node exists. + module_name = raw.split("/")[-1] + if not module_name: + return None + return _make_id(module_name), raw, None + + def _emit_rescued_import( result: dict, existing_ids: set, @@ -1369,35 +1488,10 @@ def _emit_rescued_import( dedupe (#2195). Stub nodes are still minted for unresolved specifiers (externals, not-yet-created files) so prior behavior is preserved. """ - resolved_file: "Path | None" = None - if raw.startswith("."): - resolved = _resolve_js_module_path( - Path(os.path.normpath(path.parent / raw)) - ) - node_id = _make_id(str(resolved)) - stub_source_file = str(resolved) - if resolved is not None and resolved.is_file(): - resolved_file = resolved - else: - # Check tsconfig.json path aliases (e.g. "$lib/" -> "src/lib/", - # "@/" -> "src/") before treating as external. Mirrors _import_js - # logic so alias imports resolve to the same file node IDs the - # extractor creates (#701). - resolved_alias = _resolve_tsconfig_alias(raw, aliases, base_url=base_url) - if resolved_alias is not None: - resolved_alias = _resolve_js_module_path(resolved_alias) - node_id = _make_id(str(resolved_alias)) - stub_source_file = str(resolved_alias) - if resolved_alias is not None and resolved_alias.is_file(): - resolved_file = resolved_alias - else: - # Bare/scoped import (node_modules) - use last segment; - # build_from_json drops as external if no matching node exists. - module_name = raw.split("/")[-1] - if not module_name: - return - node_id = _make_id(module_name) - stub_source_file = raw + resolution = _resolve_rescued_specifier(path, raw, aliases, base_url) + if resolution is None: + return + node_id, stub_source_file, resolved_file = resolution edge = { "source": file_node_id, "target": node_id, "relation": relation, "confidence": "EXTRACTED", @@ -2361,6 +2455,56 @@ def _resolve_swift_member_calls( if tnode is not None: method_index[(src, _key(tnode.get("label", "")))] = tgt + # #2561: pending factory bindings (`let x = Factory.make()`) are label-only — + # resolve each against the factory method's marked plain return type + # (`swift_plain_return` on the return_type references edge) and fold the + # result into the declaring file's table so the raw-call loop below types + # `x.method()` through the existing INFERRED path. Every step is + # exactly-one guarded; any failure leaves the receiver untyped (no edge, + # never a wrong one). setdefault: an explicit annotation wins. + factory_by_file: dict[str, dict] = {} + for result in per_file: + tt = result.get("swift_type_table") + if tt and tt.get("path") and tt.get("factory"): + factory_by_file[tt["path"]] = tt["factory"] + if factory_by_file: + # method nid -> marked plain-return target nids (must be exactly one). + return_targets_by_method: dict[str, set[str]] = {} + for e in all_edges: + if (e.get("relation") == "references" + and e.get("context") == "return_type" + and (e.get("metadata") or {}).get("swift_plain_return")): + return_targets_by_method.setdefault( + e.get("source"), set()).add(e.get("target")) + for path, pending in factory_by_file.items(): + # Copy before folding: the resolved label is corpus-dependent and + # must not leak back into the per-file result. + table = dict(type_table_by_file.get(path, {})) + type_table_by_file[path] = table + for receiver, bind in pending.items(): + try: + factory_type, factory_method = bind + except (TypeError, ValueError): + continue + if factory_type in _LANGUAGE_BUILTIN_GLOBALS: + continue + factory_defs = type_def_nids.get(_key(factory_type), []) + if len(factory_defs) != 1: + continue + method_nid = method_index.get((factory_defs[0], _key(factory_method))) + if method_nid is None: + continue + targets = return_targets_by_method.get(method_nid, set()) + if len(targets) != 1: + continue + tnode = node_by_id.get(next(iter(targets))) + ret_label = str(tnode.get("label", "")) if tnode else "" + if not ret_label or ret_label in _LANGUAGE_BUILTIN_GLOBALS: + continue + if len(type_def_nids.get(_key(ret_label), [])) != 1: + continue + table.setdefault(receiver, ret_label) + all_raw_calls: list[dict] = [] for result in per_file: all_raw_calls.extend(result.get("raw_calls", [])) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 1baaca83..a044cede 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -851,6 +851,72 @@ def _swift_property_type_node(property_node): return c return None +def _swift_attribute_type_name(property_node, source: bytes) -> str | None: + """Return the type named by an ``@Environment(Type.self)`` attribute argument. + + Structural, whitelist-gated (#2561): only the ``Environment`` wrapper names + the property's OWN type in its argument — ``@Query(Item.self)`` properties + hold a *collection* of the argument type, so typing them as the element type + fabricates member-call edges (measured false edge in the report). The + argument must be a navigation_expression of exactly + ``[simple_identifier (uppercase), navigation_suffix ".self"]``; the keypath + form (``@Environment(\\.dismiss)``, key_path_expression head) and the + module-dotted form (``@Environment(MyModule.Store.self)``, nested + navigation_expression head) are skipped — a missed edge, never a wrong one. + """ + for c in property_node.children: + if c.type != "modifiers": + continue + for attr in c.children: + if attr.type != "attribute": + continue + head = next((a for a in attr.children if a.type == "user_type"), None) + if head is None or _read_text(head, source) != "Environment": + continue + arg = next((a for a in attr.children + if a.type == "navigation_expression"), None) + if arg is None: + continue + named = [a for a in arg.children if a.is_named] + if len(named) != 2: + continue + ident, suffix = named + if ident.type != "simple_identifier" or suffix.type != "navigation_suffix": + continue + if _read_text(suffix, source) != ".self": + continue + name = _read_text(ident, source) + if name and name[:1].isupper(): + return name + return None + +def _swift_factory_call(call_node, source: bytes) -> tuple[str, str] | None: + """If a Swift call expression is a static factory call (``Factory.make()``), + return ``(factory_type, method_name)``; else None (#2561). + + Only the exact depth-1 shape is accepted: a navigation_expression of + ``[simple_identifier (uppercase), navigation_suffix]``. Deeper chains + (``A.B.make()``, ``Singleton.shared.make()``) stay untyped — the resolver + would have to guess the intermediate hop. + """ + first = call_node.children[0] if call_node.children else None + if first is None or first.type != "navigation_expression": + return None + named = [c for c in first.children if c.is_named] + if len(named) != 2: + return None + head, suffix = named + if head.type != "simple_identifier" or suffix.type != "navigation_suffix": + return None + htext = _read_text(head, source) + if not htext or not htext[:1].isupper(): + return None + mname = next((_read_text(sc, source) for sc in suffix.children + if sc.type == "simple_identifier"), None) + if not mname: + return None + return htext, mname + def _swift_property_name(property_node, source: bytes) -> str | None: """Return the bound name of a Swift property (``let x``/``var x = ...``).""" for c in property_node.children: @@ -1385,7 +1451,8 @@ def _cpp_local_var_types(body_node, source: bytes, table: dict[str, str]) -> Non for c in n.children: stack.append(c) -def _swift_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None: +def _swift_local_var_types(body_node, source: bytes, table: dict[str, str], + factory: dict[str, tuple[str, str]] | None = None) -> None: """Collect ``var -> Type`` from local ``let``/``var`` bindings in a Swift function body, so a member call on the local (``x.method()``) resolves to Type in the cross-file member-call pass (#1604). @@ -1395,6 +1462,10 @@ def _swift_local_var_types(body_node, source: bytes, table: dict[str, str]) -> N - a static-member access ``let x = Type.shared`` (a navigation_expression with an upper-cased head) — the singleton-cached-into-a-local idiom, one of the most common Swift call patterns and previously resolved to nothing. + A factory call (``let x = Factory.make()``) has no in-file type; when + ``factory`` is given, the pending ``name -> (Factory, method)`` binding is + stashed there (label-only) for corpus-side resolution against the factory + method's plain return type (#2561). Nested function declarations are not descended into (their locals are scoped away); the first binding for a name wins, so a class property of the same name already in the table is not overwritten. @@ -1406,9 +1477,12 @@ def _swift_local_var_types(body_node, source: bytes, table: dict[str, str]) -> N continue if n.type == "property_declaration": prop_type: str | None = None + factory_bind: tuple[str, str] | None = None for child in n.children: if child.type == "call_expression": prop_type = _swift_constructor_type(child, source) + if prop_type is None: + factory_bind = _swift_factory_call(child, source) break if child.type == "navigation_expression": head = child.children[0] if child.children else None @@ -1420,6 +1494,9 @@ def _swift_local_var_types(body_node, source: bytes, table: dict[str, str]) -> N name = _swift_property_name(n, source) if name and prop_type and name not in table: table[name] = prop_type + elif (name and factory_bind is not None and factory is not None + and name not in table and name not in factory): + factory[name] = factory_bind for c in n.children: stack.append(c) @@ -1840,7 +1917,8 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, callable_def_nids: set | None = None, - local_bound_names: dict | None = None) -> bool: + local_bound_names: dict | None = None, + closure_locals_by_body: dict | None = None) -> bool: """Handle lexical_declaration (arrow functions, CJS requires, module-level const literals) for JS/TS. Returns True if handled.""" # CommonJS / prototype member assignments whose value is a function: # exports.X = () => {} → file-contained function X() @@ -1996,12 +2074,18 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, closures: list = [] _js_topmost_closures(inner, closures) for closure in closures: - if local_bound_names is not None: - local_bound_names[const_nid] = ( - local_bound_names.get(const_nid, set()) - | _js_local_bound_names(closure, source)) + # #2568: keep each sibling closure's + # params/locals scoped to its OWN body + # (keyed by id(body), fed to walk_calls as + # extra_locals) instead of unioning them + # under const_nid — the union let closure + # A's param suppress a real indirect_call + # to the same name in sibling closure B. body = closure.child_by_field_name("body") if body: + if closure_locals_by_body is not None: + closure_locals_by_body[id(body)] = ( + _js_local_bound_names(closure, source)) function_bodies.append((const_nid, body)) if arrow_found: return True @@ -2508,6 +2592,12 @@ def _extract_generic( # guard skips any call-argument identifier in the enclosing function's set, # so a param/local that shadows a module function name yields no edge. local_bound_names: dict[str, set[str]] = {} + # JS/TS only (#2568): per-BODY locals for sibling closures tracked under a + # single const nid by the #2552 branch (`const h = wrapper(cb1, cb2)`). + # Keyed by id(body) — like receiver_types_by_body — and fed to the per-body + # walk_calls as extra_locals, so each closure sees only its own + # params/locals instead of a shared union that over-suppresses siblings. + closure_locals_by_body: dict[int, set[str]] = {} pending_listen_edges: list[tuple[str, str, int]] = [] # tree-sitter-swift parses both `class Foo` and `extension Foo` as # `class_declaration`. Same-file pairs collapse via seen_ids, but cross-file @@ -2526,6 +2616,11 @@ def _extract_generic( # threaded out as `swift_type_table` so member calls (`vm.update()`) can be # resolved to the receiver's real definition in _resolve_swift_member_calls. type_table: dict[str, str] = {} + # #2561: pending factory bindings (`let x = Factory.make()`), name -> + # (FactoryType, method). Label-only (no nids, so the per-file AST cache + # stays valid); resolved corpus-side in _resolve_swift_member_calls against + # the factory method's marked plain return type. + swift_factory_bindings: dict[str, tuple[str, str]] = {} # Java receiver typing is method-scoped: current-class fields are shared, # while parameters and locals belong only to their declaring method. java_field_types: dict[str, dict[str, str]] = {} @@ -3387,18 +3482,41 @@ def _extract_generic( return if (config.ts_module == "tree_sitter_kotlin" - and t == "property_declaration" - and parent_class_nid): - type_node = _kotlin_property_type_node(node) - if type_node is not None: - line = node.start_point[0] + 1 - refs: list[tuple[str, str]] = [] - _kotlin_collect_type_refs(type_node, source, False, refs) - for ref_name, role in refs: - ctx = "generic_arg" if role == "generic_arg" else "field" - target_nid = ensure_named_node(ref_name, line) - if target_nid != parent_class_nid: - add_edge(parent_class_nid, target_nid, "references", line, context=ctx) + and t == "property_declaration"): + # Field-type references stay class-gated: top-level properties keep + # their pre-#2565 (no-references) behavior unchanged. + if parent_class_nid: + type_node = _kotlin_property_type_node(node) + if type_node is not None: + line = node.start_point[0] + 1 + refs: list[tuple[str, str]] = [] + _kotlin_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + target_nid = ensure_named_node(ref_name, line) + if target_nid != parent_class_nid: + add_edge(parent_class_nid, target_nid, "references", line, context=ctx) + # #2565: seed the initializer into initializer_nodes so walk_calls + # collects its calls (`val repo = createRepo()`), which previously + # died at the `return` below. Seeding the WHOLE expression (not just + # call_types) lets walk_calls recurse into nested argument calls + # (`HttpClient(base())`) and lambda bodies; a literal initializer + # (`val plain = 5`) contains no call and yields nothing. The + # explicit type, if any, lives inside variable_declaration BEFORE + # the `=`, so post-`=` named children are only the initializer. + # Top-level properties attribute to the file node. + owner_nid = parent_class_nid or file_nid + seen_eq = False + for child in node.children: + if not child.is_named: + seen_eq = seen_eq or child.type == "=" + continue + if seen_eq: # `= expr` initializer + initializer_nodes.append((owner_nid, child)) + elif child.type == "property_delegate": # `by lazy { ... }` / any delegate + for sub in child.children: + if sub.is_named: + initializer_nodes.append((owner_nid, sub)) return if (config.ts_module == "tree_sitter_swift" @@ -3421,6 +3539,7 @@ def _extract_generic( # (`let vm = VM()`) produces a calls edge. #1356 Stage 2a: when the # property has no type annotation, infer its type from the # constructor so `vm.update()` later resolves to VM. + pending_factory: tuple[str, str] | None = None for child in node.children: if child.type in config.call_types: initializer_nodes.append((parent_class_nid, child)) @@ -3428,6 +3547,11 @@ def _extract_generic( ctor = _swift_constructor_type(child, source) if ctor is not None: prop_type = ctor + else: + # #2561: `let x = Factory.make()` — no in-file type; + # stash the label-only binding for corpus-side + # resolution against make's plain return type. + pending_factory = _swift_factory_call(child, source) # #1604 Stage 2b: `let x = Type.shared` (or any `Type.staticProp`) # binds x to Type via a static-member access, which is a # navigation_expression, not a constructor call. Infer x's type from @@ -3440,9 +3564,18 @@ def _extract_generic( htext = _read_text(head, source) if htext and htext[:1].isupper(): prop_type = htext + # #2561: `@Environment(Store.self) var store` names the property's + # type only inside the attribute argument (modifiers > attribute), + # which the direct-children scan above never reaches. Last resort: + # annotation and constructor inference keep priority. + if prop_type is None: + prop_type = _swift_attribute_type_name(node, source) prop_name = _swift_property_name(node, source) if prop_name and prop_type: type_table[prop_name] = prop_type + elif (prop_name and pending_factory is not None + and prop_name not in swift_factory_bindings): + swift_factory_bindings[prop_name] = pending_factory # #2181: a computed property (`var body: some View { … }`) or an # observed one (`willSet`/`didSet`) carries a body that the branches # above never emitted — so the property node AND every call inside it @@ -3793,11 +3926,21 @@ def _extract_generic( if return_node is not None: refs = [] _swift_collect_type_refs(return_node, source, False, refs) + # #2561: a plain concrete return (`-> Type`, node type + # user_type — NOT `some P`/`[T]`/`T?`, which parse as + # opaque_type/array_type/optional_type) with exactly one + # role=="type" ref is marked so the factory-receiver pass + # can read the method's return label corpus-side. + plain_return = (return_node.type == "user_type" + and sum(1 for _, r in refs if r == "type") == 1) for ref_name, role in refs: ctx = "generic_arg" if role == "generic_arg" else "return_type" target_nid = ensure_named_node(ref_name, line) if target_nid != func_nid: - add_edge(func_nid, target_nid, "references", line, context=ctx) + add_edge(func_nid, target_nid, "references", line, + context=ctx, + metadata={"swift_plain_return": True} + if plain_return and role == "type" else None) if (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript") and func_name == "constructor"): @@ -4022,7 +4165,8 @@ def _extract_generic( if _js_extra_walk(node, source, file_nid, stem, str_path, nodes, edges, seen_ids, function_bodies, parent_class_nid, add_node, add_edge, - callable_def_nids, local_bound_names): + callable_def_nids, local_bound_names, + closure_locals_by_body): return # TS namespace / module containers (internal_module, module) @@ -4117,6 +4261,22 @@ def _extract_generic( walk(child, parent_class_nid=parent_class_nid) return + # #2565: a `companion object` is not an attribution scope of its own — + # its members belong to the enclosing class in Kotlin. The default + # recurse below would strip parent_class_nid, orphaning companion + # property initializers (and leaving companion `fun`s file-level). + # Recurse transparently, entering the class_body's children directly + # since a bare class_body would itself default-recurse and drop the + # parent link. Companion `fun`s thereby become class-attributed methods. + if config.ts_module == "tree_sitter_kotlin" and t == "companion_object": + for child in node.children: + if child.type == "class_body": + for member in child.children: + walk(member, parent_class_nid=parent_class_nid) + else: + walk(child, parent_class_nid=parent_class_nid) + return + # #2551: tree-sitter ERROR recovery can wrap declarations that plainly # sit inside a class body (e.g. the Kotlin grammar choking on a one-line # sibling member). The default recurse below deliberately drops @@ -4323,6 +4483,15 @@ def _extract_generic( _tracked_body_ids: set[int] = set() _JS_CLOSURE_TYPES = ("arrow_function", "function_expression") + # #2575: nested NAMED functions get the same descent as closures. walk() + # appends only the OUTER declaration's body to function_bodies and never + # recurses into it, so `function outer(){ function inner(){ helper() } }` + # hit this boundary and dropped every call (and dynamic import) inside + # inner. Nested declarations are never in function_bodies, so the + # _tracked_body_ids guard below still prevents double-walking the + # top-level ones (those are entered via their own function_bodies entry). + _JS_DESCEND_TYPES = _JS_CLOSURE_TYPES + ( + "function_declaration", "generator_function_declaration") def walk_calls( node, @@ -4340,7 +4509,7 @@ def _extract_generic( # (const-assigned arrows) are walked with their own nid — skip to # avoid double-counting. if (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript") - and node.type in _JS_CLOSURE_TYPES): + and node.type in _JS_DESCEND_TYPES): body = node.child_by_field_name("body") if body is not None and id(body) not in _tracked_body_ids: # This closure's own params/locals (`(r) => c.get(r)`) are @@ -4938,7 +5107,8 @@ def _extract_generic( # properties are typed in the walk, but method-body locals were not (#1604). if config.ts_module == "tree_sitter_swift": for _caller_nid, body_node in function_bodies: - _swift_local_var_types(body_node, source, type_table) + _swift_local_var_types(body_node, source, type_table, + factory=swift_factory_bindings) # JS/TS: bodies already walked with their own caller_nid (const-assigned # arrows, methods). An INLINE/returned arrow or function-expression that is @@ -4957,6 +5127,7 @@ def _extract_generic( body_node, caller_nid, receiver_types_by_body.get(id(body_node)), + frozenset(closure_locals_by_body.get(id(body_node), ())), ) # #1356: walk property/field initializers (collected above). walk_calls @@ -5090,10 +5261,16 @@ def _extract_generic( # a name clash (first-binding-wins in the helper). if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): _ts_receiver_type_table(root, source, type_table) - if type_table: - if config.ts_module == "tree_sitter_swift": + if config.ts_module == "tree_sitter_swift": + if type_table or swift_factory_bindings: result["swift_type_table"] = {"path": str_path, "table": type_table} - elif config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): + if swift_factory_bindings: + # Lists, not tuples: the value must round-trip the JSON AST cache. + result["swift_type_table"]["factory"] = { + k: list(v) for k, v in swift_factory_bindings.items() + } + elif type_table: + if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): result["ts_type_table"] = {"path": str_path, "table": type_table} elif config.ts_module == "tree_sitter_cpp": result["cpp_type_table"] = {"path": str_path, "table": type_table} diff --git a/graphify/extractors/sql.py b/graphify/extractors/sql.py index 4b52eaef..49931763 100644 --- a/graphify/extractors/sql.py +++ b/graphify/extractors/sql.py @@ -284,6 +284,15 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict: "select", "where", "set", "dual", "null", "true", "false", "first", "skip", "rows", "next", "only", "lateral", } + # Same CTE-blindness as the AST path (#2577): a `WITH AS (` + # binding is statement-local, not a table, so its name must not + # become a reads_from stub. The regex has no scope tree, so the + # skip is body-wide — the right trade for a recovery path. + for cm in re.finditer( + r"(?:\bWITH\s+(?:RECURSIVE\s+)?|,\s*)([\w$]+)\s*(?:\([^()]*\))?\s+AS\s*\(", + text, re.IGNORECASE, + ): + _NON_TABLES.add(_norm_ident(cm.group(1))) seen_tbls: set[str] = set() for rm in re.finditer(r"\b(?:FROM|JOIN|INTO)\s+([\w$]+)", text, re.IGNORECASE): tbl = rm.group(1) @@ -301,19 +310,51 @@ def extract_sql(path: Path, content: str | bytes | None = None) -> dict: for child in node.children: walk(child) - def _walk_from_refs(node, caller_nid: str, line: int) -> None: - """Recursively find FROM/JOIN table references inside a node.""" + def _walk_from_refs(node, caller_nid: str, line: int, + cte_names: frozenset[str] = frozenset()) -> None: + """Recursively find FROM/JOIN table references inside a node, skipping CTEs. + + A name bound by `WITH AS (...)` is not a table: emitting it as a + `reads_from` target minted a bare `_ref_stub`, and because that stub is + intentionally sourceless (see `_ref_stub`) it carried no schema, file, or + language namespace, so a CTE named `levels` or `slug` collided with any + same-named node from another language during the build (#2577). + + Scoping matters: a CTE is visible only inside the query that declares it, + and a `WITH` inside a subquery is scoped to that subquery alone. So the + active set is extended PER SUBTREE — each node's directly-owned `cte` + children (`create_query` for a statement-level WITH, `subquery` for a + nested one) join the set passed down into that node's recursion only. A + single statement-wide pre-collect would also suppress an OUTER reference + to a real table that merely shares a subquery-CTE's name + (`... FROM t2 JOIN (WITH t2 AS (...) SELECT ...) sub`), dropping the + real `-> t2` edge. + """ + own: set[str] = set() + for c in node.children: + if c.type != "cte": + continue + # First identifier is the CTE's name; later ones are its column + # list (`WITH levels(a, b) AS (...)`), which must not be skipped. + for cc in c.children: + if cc.type in ("identifier", "object_reference"): + own.add(_norm_ident(_read(cc))) + break + if own: + cte_names = frozenset(cte_names | own) if node.type in ("from", "join"): for c in node.children: if c.type == "relation": for cc in c.children: if cc.type == "object_reference": tbl = _read(cc) + if _norm_ident(tbl) in cte_names: + continue tbl_nid = table_nids.get(_norm_ident(tbl)) or _ref_stub(tbl) _add_edge(caller_nid, tbl_nid, "reads_from", c.start_point[0] + 1) for child in node.children: - _walk_from_refs(child, caller_nid, line) + _walk_from_refs(child, caller_nid, line, cte_names) # Pre-pass: register every table/view DEFINED in this file before walking, # so forward references (a FK to a table created later in the same file) diff --git a/pyproject.toml b/pyproject.toml index 8b184ac2..bfb64622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.37" +version = "0.9.38" description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = "Apache-2.0" diff --git a/tests/fixtures/sample_cte.sql b/tests/fixtures/sample_cte.sql new file mode 100644 index 00000000..af1609fa --- /dev/null +++ b/tests/fixtures/sample_cte.sql @@ -0,0 +1,8 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + role TEXT NOT NULL +); + +CREATE VIEW v_roles AS + WITH levels AS (SELECT 'admin' AS role) + SELECT * FROM users JOIN levels ON levels.role = users.role; diff --git a/tests/test_js_callback_calls.py b/tests/test_js_callback_calls.py index d91e9db1..3ca482ea 100644 --- a/tests/test_js_callback_calls.py +++ b/tests/test_js_callback_calls.py @@ -11,6 +11,12 @@ The composition test guards the #2552/#2553 coupling: the newly-walked callback body feeds member calls into `_resolve_typescript_member_calls`, whose origin gate (#2553) must keep a third-party-typed receiver from fabricating an edge to an unrelated local class. + +#2568 (regression of the #2552 fix): the const-literal branch unioned ALL +sibling closures' params/locals under the one const nid, so a name that is a +LOCAL in sibling closure A wrongly suppressed a real `indirect_call` to that +same name in sibling closure B. The fix scopes each closure's bindings to its +own body (fed to walk_calls as extra_locals), so shadowing stays per-closure. """ from __future__ import annotations @@ -83,3 +89,90 @@ def test_callback_member_call_is_origin_gated(tmp_path): and sf.get(e["target"], "").endswith("fileb.ts")] assert not fabricated, \ f"third-party-typed receiver fabricated edge(s) to local Repo: {fabricated}" + + +def _indirect(r, lbl): + return [(lbl.get(e["source"]), lbl.get(e["target"])) for e in r["edges"] + if e["relation"] == "indirect_call"] + + +def test_sibling_closure_param_does_not_suppress_indirect_call(tmp_path): + # #2568: closure 1's param `alpha` must not shadow closure 2's reference + # to the module-level function `alpha` — each sibling closure tracked + # under the const nid gets only its OWN bindings as scope. + _, lbl, r = _extract(tmp_path, { + "handler.ts": ( + "function alpha(x: unknown) { return x; }\n" + "function wrapper(a: unknown, b: unknown) { return a || b; }\n" + "export const handler = wrapper(\n" + " (alpha) => { return alpha; },\n" + " (pool) => { pool.submit(alpha); });\n"), + }) + indirect = _indirect(r, lbl) + assert ("handler", "alpha()") in indirect, \ + f"sibling closure's param suppressed a real indirect_call; indirect={indirect}" + + +def test_own_closure_local_still_suppresses_indirect_call(tmp_path): + # A binding in the SAME closure still shadows the module function — the + # per-body scoping must not drop genuine suppression. + _, lbl, r = _extract(tmp_path, { + "handler.ts": ( + "function alpha(x: unknown) { return x; }\n" + "function wrapper(a: unknown, b: unknown) { return a || b; }\n" + "export const handler = wrapper(\n" + " (beta) => beta,\n" + " (pool) => { const alpha = pool.get(); pool.submit(alpha); });\n"), + }) + to_alpha = [p for p in _indirect(r, lbl) if p[1] == "alpha()"] + assert not to_alpha, \ + f"closure's own local `alpha` must shadow the module fn; got {to_alpha}" + + +def test_shadow_and_reference_split_across_siblings(tmp_path): + # Closure 1 passes its OWN param `alpha` (no edge); closure 2 references + # the module `alpha` (one edge). Exactly one indirect_call to alpha. + _, lbl, r = _extract(tmp_path, { + "handler.ts": ( + "function alpha(x: unknown) { return x; }\n" + "function wrapper(a: unknown, b: unknown) { return a || b; }\n" + "const q: unknown[] = [];\n" + "export const handler = wrapper(\n" + " (alpha) => { q.push(alpha); },\n" + " (pool) => { pool.submit(alpha); });\n"), + }) + to_alpha = [p for p in _indirect(r, lbl) if p[1] == "alpha()"] + assert to_alpha == [("handler", "alpha()")], \ + f"expected exactly one handler -> alpha indirect_call, got {to_alpha}" + + +def test_multi_closure_direct_calls_still_captured(tmp_path): + # #2552 guard for the multi-closure shape: a direct call inside the first + # of two sibling closures still yields a `calls` edge from the const. + calls, _, _ = _extract(tmp_path, { + "helpers.ts": _HELPERS, + "handler.ts": ( + "import { helperA } from './helpers';\n" + "function wrapper(a: unknown, b: unknown) { return a || b; }\n" + "export const handler = wrapper(\n" + " (a) => { helperA(); },\n" + " (b) => b);\n"), + }) + assert ("handler", "helperA()") in calls, \ + f"direct call in first sibling closure dropped; calls={sorted(calls)}" + + +def test_unreferenced_module_name_fabricates_nothing(tmp_path): + # No fabrication: a sibling param named after a module callable, with the + # module name never referenced in an emission position, yields no + # indirect_call to it. + _, lbl, r = _extract(tmp_path, { + "handler.ts": ( + "function alpha(x: unknown) { return x; }\n" + "function wrapper(a: unknown, b: unknown) { return a || b; }\n" + "export const handler = wrapper(\n" + " (alpha) => alpha + 1,\n" + " (pool) => pool.drain());\n"), + }) + to_alpha = [p for p in _indirect(r, lbl) if p[1] == "alpha()"] + assert not to_alpha, f"fabricated indirect_call(s) to alpha: {to_alpha}" diff --git a/tests/test_js_dynamic_imports.py b/tests/test_js_dynamic_imports.py new file mode 100644 index 00000000..1db30efe --- /dev/null +++ b/tests/test_js_dynamic_imports.py @@ -0,0 +1,226 @@ +"""`import('…')` in plain .ts/.js must produce exactly one edge per fact (#2575). + +tree-sitter models ``await import('x')`` as a ``call_expression``, not an +``import_statement``, so the specifier only reaches the graph when the call +walk visits it. Two holes remained: module-scope dynamic imports (no function +body is ever walked for them) and calls inside NESTED named functions (the +function boundary in walk_calls descended into arrow/function-expression +closures but returned at a nested ``function_declaration``). The fixes are a +regex rescue pass for plain JS/TS (mirroring the Svelte/Astro/Vue ones) plus +descending the boundary into nested named declarations — deduped so an +AST-captured dynamic import (already a ``deferred`` ``imports_from`` edge) is +not restated as a second ``dynamic_import`` edge. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import networkx as nx + +from graphify.affected import DEFAULT_AFFECTED_RELATIONS, affected_nodes +from graphify.extract import _file_node_id, extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _edges_to(result: dict, target: str, *relations: str) -> list[dict]: + tgt = _file_node_id(Path(target)) + rels = relations or ("dynamic_import", "imports_from") + return [e for e in result["edges"] + if e["target"] == tgt and e["relation"] in rels] + + +def test_nested_named_function_dynamic_import_edges(tmp_path: Path): + """#2575: the reported case — `await import()` inside a nested named + function produced no edge at all (the boundary returned before the call + walk could see it).""" + _write(tmp_path / "src/dep.ts", "export const dep = 1\n") + importer = _write( + tmp_path / "src/page.ts", + "export function outer() {\n" + " async function inner() {\n" + " const { dep } = await import('./dep')\n" + " return dep\n" + " }\n" + " return inner\n" + "}\n", + ) + + result = extract([tmp_path / "src/dep.ts", importer], root=tmp_path) + + assert _edges_to(result, "src/dep.ts"), "nested dynamic import produced no edge" + + +def test_doubly_nested_dynamic_import_edges(tmp_path: Path): + _write(tmp_path / "src/dep.ts", "export const dep = 1\n") + importer = _write( + tmp_path / "src/page.ts", + "export function a() {\n" + " function b() {\n" + " async function c() {\n" + " return await import('./dep')\n" + " }\n" + " return c\n" + " }\n" + " return b\n" + "}\n", + ) + + result = extract([tmp_path / "src/dep.ts", importer], root=tmp_path) + + assert _edges_to(result, "src/dep.ts"), "doubly nested dynamic import lost" + + +def test_module_scope_dynamic_import_edges(tmp_path: Path): + """Module scope is outside every walked function body, so only the rescue + pass can see it.""" + _write(tmp_path / "src/dep.ts", "export const dep = 1\n") + importer = _write( + tmp_path / "src/boot.ts", + "const { dep } = await import('./dep')\n" + "export const booted = dep\n", + ) + + result = extract([tmp_path / "src/dep.ts", importer], root=tmp_path) + + edges = _edges_to(result, "src/dep.ts") + assert edges, "module-scope dynamic import produced no edge" + assert any(e["relation"] == "dynamic_import" for e in edges) + + +def test_ast_captured_dynamic_import_is_one_fact_not_two(tmp_path: Path): + """Dedupe: a dynamic import the AST pass already emitted (as a deferred + imports_from edge) must not be restated by the rescue as a second + dynamic_import edge to the same target.""" + _write(tmp_path / "src/dep.ts", "export const dep = 1\n") + importer = _write( + tmp_path / "src/page.ts", + "export async function load() {\n" + " const { dep } = await import('./dep')\n" + " return dep\n" + "}\n", + ) + + result = extract([tmp_path / "src/dep.ts", importer], root=tmp_path) + + edges = _edges_to(result, "src/dep.ts") + assert len(edges) == 1, f"one import(), one edge — got {edges}" + assert edges[0]["relation"] == "imports_from" + assert edges[0].get("deferred") is True + + +def test_static_import_alongside_dynamic_is_untouched(tmp_path: Path): + """The rescue pass must not disturb the AST pass it runs beside.""" + _write(tmp_path / "src/a.ts", "export const a = 1\n") + _write(tmp_path / "src/b.ts", "export const b = 2\n") + importer = _write( + tmp_path / "src/main.ts", + "import { a } from './a'\n" + "export const later = async () => a + (await import('./b')).b\n", + ) + + result = extract( + [tmp_path / "src/a.ts", tmp_path / "src/b.ts", importer], root=tmp_path + ) + + a_edges = _edges_to(result, "src/a.ts", "imports_from") + assert a_edges and not any(e.get("deferred") for e in a_edges) + assert len(_edges_to(result, "src/b.ts")) == 1 + + +def test_tsconfig_aliased_dynamic_import_edges(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["./src/*"]}}}), + ) + _write(tmp_path / "src/agent/runner.ts", "export const run = () => 1\n") + importer = _write( + tmp_path / "src/boot.ts", + "export const runner = await import('@/agent/runner')\n", + ) + + result = extract([tmp_path / "src/agent/runner.ts", importer], root=tmp_path) + + assert _edges_to(result, "src/agent/runner.ts") + + +def test_template_literal_specifier_without_substitution(tmp_path: Path): + """A backtick specifier with no `${` is as static as a quoted one — the + AST path already resolved it, the rescue must too.""" + _write(tmp_path / "src/dep.ts", "export const dep = 1\n") + importer = _write( + tmp_path / "src/boot.ts", + "export const dep = await import(`./dep`)\n", + ) + + result = extract([tmp_path / "src/dep.ts", importer], root=tmp_path) + + assert _edges_to(result, "src/dep.ts") + + +def test_identifier_ending_in_import_is_not_matched(tmp_path: Path): + """`fooimport('./x')` is a call to `fooimport`, not a dynamic import.""" + _write(tmp_path / "src/x.ts", "export const x = 1\n") + importer = _write( + tmp_path / "src/caller.ts", + "declare function fooimport(s: string): unknown\n" + "export const r = fooimport('./x')\n", + ) + + result = extract([tmp_path / "src/x.ts", importer], root=tmp_path) + + assert not _edges_to(result, "src/x.ts") + + +def test_line_commented_dynamic_import_is_not_matched(tmp_path: Path): + _write(tmp_path / "src/x.ts", "export const x = 1\n") + importer = _write( + tmp_path / "src/caller.ts", + "// const { x } = await import('./x')\n" + "export const r = 1\n", + ) + + result = extract([tmp_path / "src/x.ts", importer], root=tmp_path) + + assert not _edges_to(result, "src/x.ts") + + +def test_nested_named_function_calls_resolve(tmp_path: Path): + """The durable half of #2575: ordinary calls inside a nested named function + were dropped at the same boundary. They now attribute to the enclosing + function, exactly like untracked closures (#1630).""" + f = _write( + tmp_path / "src/mod.ts", + "export function helper() { return 1 }\n" + "export function outer() {\n" + " function inner() { return helper() }\n" + " return inner\n" + "}\n", + ) + + result = extract([f], root=tmp_path) + + by_id = {n["id"]: n["label"].rstrip("()") for n in result["nodes"]} + calls = {(by_id.get(e["source"]), by_id.get(e["target"])) + for e in result["edges"] if e["relation"] == "calls"} + assert ("outer", "helper") in calls, f"calls found: {calls}" + + +def test_dynamic_import_is_traversed_by_affected(): + """Emitting the edge is only half the fix: while `dynamic_import` was + absent from DEFAULT_AFFECTED_RELATIONS, every dynamic edge stayed invisible + to blast-radius traversal — including the ones the Svelte/Astro/Vue rescue + passes had been emitting all along.""" + assert "dynamic_import" in DEFAULT_AFFECTED_RELATIONS + + g = nx.DiGraph() + g.add_node("importer", label="importer.ts") + g.add_node("dep", label="dep.ts") + g.add_edge("importer", "dep", relation="dynamic_import") + hits = affected_nodes(g, "dep", depth=1) + assert any(h.node_id == "importer" for h in hits) diff --git a/tests/test_kotlin_grammar.py b/tests/test_kotlin_grammar.py index fc54c796..2c414e63 100644 --- a/tests/test_kotlin_grammar.py +++ b/tests/test_kotlin_grammar.py @@ -299,6 +299,154 @@ def test_kotlin_one_line_class_keeps_field_reference(tmp_path): assert (c, money) in field_refs +# ── #2565: property-initializer calls ──────────────────────────────────────── + +_INIT_CORPUS = { + "lib/Lib.kt": ( + "package com.demo.lib\n" + "\n" + "class Repo\n" + "\n" + "class HttpClient(val url: String)\n" + "\n" + "fun createRepo(): Repo {\n" + " return Repo()\n" + "}\n" + "\n" + "fun base(): String {\n" + " return \"\"\n" + "}\n" + "\n" + "fun compute(): Int {\n" + " return 1\n" + "}\n" + "\n" + "fun companionInit(): Int {\n" + " return 2\n" + "}\n" + ), + "app/Service.kt": ( + "package com.demo.app\n" + "\n" + "import com.demo.lib.HttpClient\n" + "import com.demo.lib.base\n" + "import com.demo.lib.companionInit\n" + "import com.demo.lib.compute\n" + "import com.demo.lib.createRepo\n" + "\n" + "class Service {\n" + " val repo = createRepo()\n" + " private val client = HttpClient(base())\n" + " val x by lazy {\n" + " compute()\n" + " }\n" + " val plain = 5\n" + " companion object {\n" + " val shared = companionInit()\n" + " }\n" + " fun go() {\n" + " val r = createRepo()\n" + " }\n" + "}\n" + ), + # No import here on purpose: the shared cross-file pass dedups on + # (source, target) across relations, so a file-level `imports` edge to + # createRepo would mask the file-level `calls` edge this corpus pins down + # (single-candidate resolution needs no import evidence outside JS/TS). + "app/TopLevel.kt": ( + "package com.demo.app\n" + "\n" + "val topRepo = createRepo()\n" + ), +} + + +def test_kotlin_class_property_initializer_calls(tmp_path): + r = _extract(tmp_path, _INIT_CORPUS) + calls = _edges(r, "calls") + service = _find(r, "Service") + create = _find(r, "createRepo()") + client = _find(r, "HttpClient") + base = _find(r, "base()") + assert (service, create) in calls, \ + "`val repo = createRepo()` runs at construction time — a calls edge" + assert (service, client) in calls, \ + "`val client = HttpClient(...)` is a constructor call" + assert (service, base) in calls, \ + "walk_calls recurses into nested initializer argument calls" + + +def test_kotlin_delegate_initializer_calls(tmp_path): + r = _extract(tmp_path, _INIT_CORPUS) + service = _find(r, "Service") + compute = _find(r, "compute()") + assert (service, compute) in _edges(r, "calls"), \ + "`by lazy { compute() }` invokes compute() to produce the property" + + +def test_kotlin_companion_property_initializer_attributes_to_class(tmp_path): + r = _extract(tmp_path, _INIT_CORPUS) + service = _find(r, "Service") + ci = _find(r, "companionInit()") + assert (service, ci) in _edges(r, "calls"), \ + "a companion object is not an attribution scope: its property " \ + "initializers belong to the enclosing class" + + +def test_kotlin_literal_initializer_emits_nothing(tmp_path): + r = _extract(tmp_path, _INIT_CORPUS) + service = _find(r, "Service") + plain_line = _INIT_CORPUS["app/Service.kt"].splitlines().index( + " val plain = 5") + 1 + assert not [e for e in r["edges"] if e["source"] == service + and e.get("source_location") == f"L{plain_line}"], \ + "`val plain = 5` contains no call — nothing to emit" + + +def test_kotlin_function_body_calls_unchanged(tmp_path): + r = _extract(tmp_path, _INIT_CORPUS) + calls = _edges(r, "calls") + go = _find(r, ".go()") + create = _find(r, "createRepo()") + repo = _find(r, "Repo") + assert (go, create) in calls + assert (create, repo) in calls + + +def test_kotlin_top_level_property_initializer_attributes_to_file(tmp_path): + r = _extract(tmp_path, _INIT_CORPUS) + top_file = _find(r, "TopLevel.kt") + create = _find(r, "createRepo()") + assert (top_file, create) in _edges(r, "calls"), \ + "a top-level `val` has no class: its initializer belongs to the file" + + +def test_kotlin_fq_initializer_call_resolves_extracted(tmp_path): + # Composition with #2550: a fully-qualified constructor call in a property + # initializer flows through walk_calls' qualified_prefix stamping and + # resolves to the REAL Router node via _resolve_kotlin_qualified_calls. + r = _extract(tmp_path, { + "nav/Router.kt": ( + "package com.demo.nav\n" + "\n" + "class Router\n" + ), + "app/App.kt": ( + "package com.demo.app\n" + "\n" + "class App {\n" + " val r = com.demo.nav.Router()\n" + "}\n" + ), + }) + app = _find(r, "App") + router = _find(r, "Router") + edge = next(e for e in r["edges"] if e["relation"] == "calls" + and e["source"] == app and e["target"] == router) + assert edge["confidence"] == "EXTRACTED", \ + "the FQN is written verbatim in source: exact match, EXTRACTED" + + # ── keep-the-bar: multi-line Kotlin is byte-identical ──────────────────────── def test_multiline_kotlin_unchanged(tmp_path, capsys): diff --git a/tests/test_multilang.py b/tests/test_multilang.py index c19e7de1..cb390eeb 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -487,6 +487,106 @@ def test_sql_no_dangling_edges(): for e in r["edges"]: assert e["source"] in node_ids, f"dangling source: {e['source']}" +def test_sql_cte_is_not_read_as_a_table(): + """#2577: a name bound by WITH ... AS (...) is scoped to its statement, not a table. + + Emitting it as a reads_from target minted a bare, sourceless stub carrying no + schema, file, or language namespace, so a CTE named `levels` or `slug` collided + with a same-named node from another language. The real table in the same + FROM/JOIN must still resolve. + """ + r = _extract_sql_or_skip("sample_cte.sql") + labels = [n["label"] for n in r["nodes"]] + assert "levels" not in labels, "CTE name leaked into the graph as a table node" + + reads = [e for e in r["edges"] if e["relation"] == "reads_from"] + assert reads, "the real table reference should still emit a reads_from edge" + assert not any(e["target"] == "levels" for e in reads), "CTE emitted as a reads_from target" + + # the real v_roles -> users edge is kept + nid = {n["label"]: n["id"] for n in r["nodes"]} + assert (nid["v_roles"], nid["users"]) in {(e["source"], e["target"]) for e in reads} + +def test_sql_column_list_cte_is_not_read_as_a_table(tmp_path): + """#2577: `WITH levels(a, b) AS (...)` — the name precedes a column list.""" + pytest.importorskip("tree_sitter_sql") + p = tmp_path / "schema.sql" + p.write_text( + "CREATE TABLE users (id INT, role TEXT);\n" + "CREATE VIEW v AS\n" + " WITH levels(role, rank) AS (SELECT 'admin', 1)\n" + " SELECT * FROM users JOIN levels ON levels.role = users.role;\n" + ) + r = extract_sql(p) + labels = [n["label"] for n in r["nodes"]] + assert "levels" not in labels + reads = [e for e in r["edges"] if e["relation"] == "reads_from"] + assert not any(e["target"] == "levels" for e in reads) + +def test_sql_cte_shadows_same_named_table_within_its_statement(tmp_path): + """#2577: inside the declaring statement the CTE shadows a real same-named + table (SQL scoping), so v1's FROM binds to the CTE and emits nothing; v2 has + no CTE in scope and reads the real table. Exactly one deterministic edge.""" + pytest.importorskip("tree_sitter_sql") + p = tmp_path / "schema.sql" + p.write_text( + "CREATE TABLE levels (role TEXT);\n" + "CREATE VIEW v1 AS WITH levels AS (SELECT 'admin' AS role)" + " SELECT * FROM levels;\n" + "CREATE VIEW v2 AS SELECT * FROM levels;\n" + ) + r = extract_sql(p) + reads = [e for e in r["edges"] if e["relation"] == "reads_from"] + assert len(reads) == 1, f"expected exactly one reads_from, got {reads}" + nid = {n["label"]: n["id"] for n in r["nodes"]} + assert reads[0]["source"] == nid["v2"] + assert reads[0]["target"] == nid["levels"] # the real, sourced table node + +def test_sql_subquery_cte_does_not_suppress_outer_real_table(tmp_path): + """#2577 refinement: a WITH inside a subquery is scoped to that subquery + only. A statement-wide pre-collect would also swallow the OUTER reference + to the real `t2`, dropping a true edge — per-subtree scoping keeps it.""" + pytest.importorskip("tree_sitter_sql") + p = tmp_path / "schema.sql" + p.write_text( + "CREATE TABLE t2 (id INT);\n" + "CREATE VIEW v6 AS SELECT * FROM t2 JOIN" + " (WITH t2 AS (SELECT 1 AS id) SELECT * FROM t2) sub" + " ON sub.id = t2.id;\n" + ) + r = extract_sql(p) + nid = {n["label"]: n["id"] for n in r["nodes"]} + reads = {(e["source"], e["target"]) for e in r["edges"] + if e["relation"] == "reads_from"} + assert (nid["v6"], nid["t2"]) in reads, ( + "outer reference to the real t2 table was wrongly suppressed" + ) + +def test_sql_cte_never_binds_to_cross_language_symbol(tmp_path): + """#2577: the reported leak — the CTE's sourceless stub was unique corpus-wide, + so _rewire_unique_stub_nodes bound it to a same-named symbol from ANOTHER + language (schema_v_roles -> ui_levels). With the CTE excluded, no reads_from + edge may target a TypeScript node.""" + pytest.importorskip("tree_sitter_sql") + sql = tmp_path / "schema.sql" + sql.write_text( + "CREATE TABLE users (id INT, role TEXT);\n" + "CREATE VIEW v_roles AS\n" + " WITH levels AS (SELECT 'admin' AS role)\n" + " SELECT * FROM users JOIN levels ON levels.role = users.role;\n" + ) + ts = tmp_path / "ui.ts" + ts.write_text("export function levels() { return ['admin']; }\n") + + r = extract([sql, ts], root=tmp_path) + ts_nodes = {n["id"] for n in r["nodes"] + if str(n.get("source_file", "")).endswith(".ts")} + for e in r["edges"]: + if e["relation"] == "reads_from": + assert e["target"] not in ts_nodes, ( + f"SQL reads_from leaked cross-language: {e}" + ) + def test_sql_cross_file_fk_resolves_and_never_leaks_scan_path(tmp_path): """#2324: a REFERENCES target defined in ANOTHER file must collapse onto the real table node (via the sourceless-stub rewire), and no node id or edge diff --git a/tests/test_swift_cross_file_calls.py b/tests/test_swift_cross_file_calls.py index 94935e1f..81e56ae5 100644 --- a/tests/test_swift_cross_file_calls.py +++ b/tests/test_swift_cross_file_calls.py @@ -273,6 +273,184 @@ def test_extension_does_not_merge_into_same_named_foreign_type(tmp_path: Path): assert e.get("target") not in swift_nids, "the TS Store came to own a Swift node" +# ── #2561: attribute-argument and factory-returned receiver types ───────────── + +def test_environment_attribute_typed_receiver_resolves(tmp_path: Path): + # @Environment(Store.self) names the property's type only inside the + # attribute argument (modifiers > attribute), which the direct-children + # scan never reached — store.reset() produced no edge at all. + base = tmp_path / "src" + _write(base / "Store.swift", "class Store {\n func reset() {}\n}\n") + _write(base / "HomeView.swift", ( + "struct HomeView {\n" + " @Environment(Store.self) var store\n" + " func go() {\n" + " store.reset()\n" + " }\n" + "}\n" + )) + result = extract(sorted(base.glob("*.swift")), cache_root=tmp_path / "cache", + parallel=False) + edge = next((e for e in result["edges"] if e.get("relation") == "calls" + and _label(result, e["target"]) == ".reset()"), None) + assert edge is not None, "store.reset() must resolve to Store.reset" + assert _label(result, edge["source"]) == ".go()" + assert edge["confidence"] == "INFERRED" and edge["confidence_score"] == 0.8 + + +def test_environment_keypath_and_dotted_forms_are_skipped(tmp_path: Path): + # @Environment(\.dismiss) (keypath head) and @Environment(MyModule.Store.self) + # (nested-navigation head) are undeterminable: skipping is a missed edge, + # typing them would be a WRONG edge (e.g. into a fabricated MyModule node). + base = tmp_path / "src" + _write(base / "Store.swift", "class Store {\n func reset() {}\n}\n") + _write(base / "SheetView.swift", ( + "struct SheetView {\n" + " @Environment(\\.dismiss) var dismiss\n" + " @Environment(MyModule.Store.self) var other\n" + " func close() {\n" + " other.reset()\n" + " }\n" + "}\n" + )) + result = extract(sorted(base.glob("*.swift")), cache_root=tmp_path / "cache", + parallel=False) + assert (".close()", "calls", ".reset()") not in _edge_labels(result, ("calls",)) + for e in result["edges"]: + assert _label(result, e["source"]) != "MyModule" + assert _label(result, e["target"]) != "MyModule" + + +def test_stateobject_annotated_receiver_still_resolves(tmp_path: Path): + # Regression pin: an explicitly-annotated wrapped property (@StateObject + # var vm: ViewModel) resolved before #2561 and must keep resolving — the + # attribute helper is a LAST resort behind the annotation. + base = tmp_path / "src" + _write(base / "ViewModel.swift", "class ViewModel {\n func load() {}\n}\n") + _write(base / "RootView.swift", ( + "struct RootView {\n" + " @StateObject var vm: ViewModel\n" + " func go() {\n" + " vm.load()\n" + " }\n" + "}\n" + )) + result = extract(sorted(base.glob("*.swift")), cache_root=tmp_path / "cache", + parallel=False) + assert (".go()", "calls", ".load()") in _edge_labels(result, ("calls",)) + + +def test_factory_returned_receiver_resolves(tmp_path: Path): + # `let widget = ServiceFactory.make()` (make -> Widget): the receiver types + # as make's plain return type, for both a stored property and a local. + base = tmp_path / "src" + files = [ + _write(base / "Widget.swift", "class Widget {\n func go() {}\n}\n"), + _write(base / "ServiceFactory.swift", + "class ServiceFactory {\n static func make() -> Widget {\n" + " return Widget()\n }\n}\n"), + _write(base / "Consumer.swift", ( + "struct Consumer {\n" + " let widget = ServiceFactory.make()\n" + " func run() {\n" + " widget.go()\n" + " }\n" + " func local() {\n" + " let w = ServiceFactory.make()\n" + " w.go()\n" + " }\n" + "}\n" + )), + ] + result = extract(files, cache_root=tmp_path / "cache", parallel=False) + calls = _edge_labels(result, ("calls",)) + assert (".run()", "calls", ".go()") in calls # stored-property receiver + assert (".local()", "calls", ".go()") in calls # method-local receiver + for e in result["edges"]: + if e.get("relation") == "calls" and _label(result, e["target"]) == ".go()": + assert e["confidence"] == "INFERRED" and e["confidence_score"] == 0.8 + + +def test_factory_receiver_resolves_through_cross_file_extension(tmp_path: Path): + # #2538 composition: the called method lives in a cross-file `extension + # Widget` — the extension merge runs before this resolver, so the factory + # receiver resolves through the merged method_index. + base = tmp_path / "src" + files = [ + _write(base / "Widget.swift", "class Widget {\n func spin() {}\n}\n"), + _write(base / "Widget+Ext.swift", "extension Widget {\n func go() {}\n}\n"), + _write(base / "ServiceFactory.swift", + "class ServiceFactory {\n static func make() -> Widget {\n" + " return Widget()\n }\n}\n"), + _write(base / "Consumer.swift", ( + "struct Consumer {\n" + " let widget = ServiceFactory.make()\n" + " func run() {\n" + " widget.go()\n" + " }\n" + "}\n" + )), + ] + result = extract(files, cache_root=tmp_path / "cache", root=base, parallel=False) + assert (".run()", "calls", ".go()") in _edge_labels(result, ("calls",)) + + +def test_undeterminable_factory_returns_yield_no_edge(tmp_path: Path): + # `-> some P` (opaque), `-> [Widget]` (a COLLECTION of Widget, not a + # Widget), and an out-of-corpus `-> Ghost` are all undeterminable: the + # receiver stays untyped and no edge reaches Widget.go. + base = tmp_path / "src" + files = [ + _write(base / "Widget.swift", "class Widget {\n func go() {}\n}\n"), + _write(base / "ServiceFactory.swift", ( + "class ServiceFactory {\n" + " static func makeOpaque() -> some P {\n return Widget()\n }\n" + " static func makeMany() -> [Widget] {\n return []\n }\n" + " static func makeGhost() -> Ghost {\n return Ghost()\n }\n" + "}\n" + )), + _write(base / "Consumer.swift", ( + "struct Consumer {\n" + " let a = ServiceFactory.makeOpaque()\n" + " let b = ServiceFactory.makeMany()\n" + " let c = ServiceFactory.makeGhost()\n" + " func run() {\n" + " a.go()\n" + " b.go()\n" + " c.go()\n" + " }\n" + "}\n" + )), + ] + result = extract(files, cache_root=tmp_path / "cache", parallel=False) + assert (".run()", "calls", ".go()") not in _edge_labels(result, ("calls",)) + + +def test_ambiguous_factory_type_yields_no_edge(tmp_path: Path): + # Two ServiceFactory definitions: the exactly-one-definition guard must + # refuse to pick a factory, so the receiver stays untyped. + base = tmp_path / "src" + files = [ + _write(base / "Widget.swift", "class Widget {\n func go() {}\n}\n"), + _write(base / "a/ServiceFactory.swift", + "class ServiceFactory {\n static func make() -> Widget {\n" + " return Widget()\n }\n}\n"), + _write(base / "b/ServiceFactory.swift", + "class ServiceFactory {\n static func make() -> Widget {\n" + " return Widget()\n }\n}\n"), + _write(base / "Consumer.swift", ( + "struct Consumer {\n" + " let widget = ServiceFactory.make()\n" + " func run() {\n" + " widget.go()\n" + " }\n" + "}\n" + )), + ] + result = extract(files, cache_root=tmp_path / "cache", parallel=False) + assert (".run()", "calls", ".go()") not in _edge_labels(result, ("calls",)) + + def test_extension_merge_does_not_prune_unrelated_edges(tmp_path: Path): # The post-merge edge rebuild dedups on a key that ignores confidence and # weight. It must only touch edges the merge actually rewrote, or a single