mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 00:36:39 +00:00
fix: JS/Kotlin/Swift/SQL extractor correctness batch; bump to 0.9.38 (#2568, #2565, #2561, #2577, #2575)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
09a34ad87a
commit
10ad921b42
+9
-1
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
+173
-29
@@ -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))
|
||||
# `(?<!\w)` so `fooimport('x')` and `_import('x')` do not match. The
|
||||
# backtick alternative mirrors _dynamic_import_js's template-string
|
||||
# handling: a literal `import(`./x`)` resolves, `${`-substituted ones
|
||||
# are excluded (no `$` in the class) as statically unresolvable.
|
||||
for m in _re.finditer(
|
||||
r"""(?<!\w)import\(\s*(?:'([^'\n]+)'|"([^"\n]+)"|`([^`$\n]+)`)\s*\)""",
|
||||
src,
|
||||
):
|
||||
raw = m.group(1) or m.group(2) or m.group(3)
|
||||
if not raw:
|
||||
continue
|
||||
line_start = src.rfind("\n", 0, m.start()) + 1
|
||||
if "//" in src[line_start:m.start()]:
|
||||
continue # line-commented-out import
|
||||
resolution = _resolve_rescued_specifier(path, raw, aliases, base_url)
|
||||
if resolution is None:
|
||||
continue
|
||||
node_id, _stub_sf, resolved_file = resolution
|
||||
# AST-captured already: same resolved target id, same resolved
|
||||
# on-disk file, or the engine's ref-namespaced external id.
|
||||
if node_id in deferred_ids or _make_id("ref", raw) in deferred_ids:
|
||||
continue
|
||||
if resolved_file is not None:
|
||||
try:
|
||||
if str(resolved_file.resolve()) in deferred_files:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
_emit_rescued_import(
|
||||
result, existing_ids, file_node_id, path, raw,
|
||||
"dynamic_import", aliases, base_url,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── JS/TS rationale + doc-reference extraction ────────────────────────────────
|
||||
#
|
||||
# Parity with _extract_python_rationale: Python files get rationale nodes from
|
||||
@@ -1342,6 +1421,46 @@ def _extract_js_rationale(path: Path, result: dict) -> 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", []))
|
||||
|
||||
+202
-25
@@ -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}
|
||||
|
||||
@@ -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 <name> 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 <name> 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)
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user