Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/codegraphcontext/tools/indexing/persistence/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,24 @@ def relationship_label_for_row(row: Dict[str, Any]) -> str:
if called_label in labels_with_context:
called_context_clause = 'AND (row.called_context = "" OR called.context = row.called_context)'

# ...and which have a 'line_number'. File and Directory do not:
# the Kùzu File table is (path, name, relative_path, package_name,
# is_dependency). Referencing called.line_number against them raises
# a binder exception on strongly-typed backends, which the caller
# swallows via _is_binder_exception -> continue, silently dropping
# the ENTIRE Function->File batch. Neo4j is untyped here and
# evaluates the predicate to null, so it kept those edges — which is
# why dynamic-import edges existed on Neo4j and nowhere else.
labels_without_line_number = {"File", "Directory"}
predicates = []
if called_label not in labels_without_line_number:
predicates.append(
"(row.called_line_number <= 0 OR called.line_number = row.called_line_number)"
)
if called_context_clause:
predicates.append(called_context_clause.removeprefix("AND ").strip())
where_clause = ("WHERE " + "\n AND ".join(predicates)) if predicates else ""

def _write_batch(batch_rows: List[Dict[str, Any]], relation_label: str) -> None:
if not batch_rows:
return
Expand All @@ -832,8 +850,7 @@ def _write_batch(batch_rows: List[Dict[str, Any]], relation_label: str) -> None:
UNWIND $batch AS row
MATCH (caller:File {{path: row.caller_file_path}})
MATCH (called:{called_label} {{name: row.called_name, path: row.called_file_path}})
WHERE (row.called_line_number <= 0 OR called.line_number = row.called_line_number)
{called_context_clause}
{where_clause}
MERGE (caller)-[call:{relation_label} {{line_number: row.line_number, full_call_name: row.full_call_name, args_key: row.args_key}}]->(called)
SET call.args = row.args
SET call.confidence = row.confidence
Expand All @@ -845,8 +862,7 @@ def _write_batch(batch_rows: List[Dict[str, Any]], relation_label: str) -> None:
UNWIND $batch AS row
MATCH (caller:{caller_label} {{name: row.caller_name, path: row.caller_file_path, line_number: row.caller_line_number}})
MATCH (called:{called_label} {{name: row.called_name, path: row.called_file_path}})
WHERE (row.called_line_number <= 0 OR called.line_number = row.called_line_number)
{called_context_clause}
{where_clause}
MERGE (caller)-[call:{relation_label} {{line_number: row.line_number, full_call_name: row.full_call_name, args_key: row.args_key}}]->(called)
SET call.args = row.args
SET call.confidence = row.confidence
Expand Down
12 changes: 11 additions & 1 deletion src/codegraphcontext/tools/indexing/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,17 @@ async def process_file(file: Path) -> Optional[Dict[str, Any]]:
for failure in parse_failures[:10]:
warning_logger(f" parse failed: {failure['path']}: {failure['error']}")

all_file_data = [file_data for file_data in all_file_data if "error" not in file_data]
# Sort before post-processing. Files are appended in `asyncio.as_completed`
# order, so the list order varies run to run with task scheduling. Everything
# downstream that builds a map by iterating it — global_class_bases,
# interface_implementors, class_method_index — then inherits that ordering,
# and any last-writer-wins or first-match-wins choice becomes
# nondeterministic. The graph write loop already sorted for exactly this
# reason; resolution was left unsorted.
all_file_data = sorted(
(file_data for file_data in all_file_data if "error" not in file_data),
key=lambda data: str(data.get("path") or ""),
)

info_logger(
f"File processing complete. {len(all_file_data)} files parsed. "
Expand Down
32 changes: 26 additions & 6 deletions src/codegraphcontext/tools/indexing/resolution/calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2908,12 +2908,21 @@ def enclosing_function_for_call(call_dict: Dict[str, Any]) -> Optional[Dict[str,
call_to_resolve.get("call_kind") == "dynamic_import"
and caller_lang in ("typescript", "javascript")
):
static_imports = [
imp for imp in file_data.get("imports", [])
if imp.get("source") and imp.get("name") not in ("*",)
]
if static_imports:
source = static_imports[0]["source"]
# Resolve from the dynamic import's OWN specifier. This used to
# take static_imports[0] — the first *static* import in the file —
# which has nothing to do with the module being imported, so
# await import(`./tough_dynamic_${name}`)
# produced an edge to whatever the file happened to import first.
# A template literal or variable specifier is not knowable at
# index time, so emit nothing rather than guess.
raw_args = call_to_resolve.get("args") or []
specifier = (raw_args[0] or "").strip() if raw_args else ""
is_string_literal = len(specifier) >= 2 and (
(specifier[0] == specifier[-1] == '"')
or (specifier[0] == specifier[-1] == "'")
)
if is_string_literal:
source = specifier[1:-1]
caller_dir = Path(caller_file_path).parent
target = (caller_dir / source).resolve()
if not target.suffix:
Expand Down Expand Up @@ -3003,6 +3012,17 @@ def enclosing_function_for_call(call_dict: Dict[str, Any]) -> Optional[Dict[str,
)
for impl_fn in impl_entries:
impl_path = _resolved_posix(impl_fn["path"])
# This fan-out speculatively widens an already-resolved
# call to every implementor of the interface, keyed on a
# bare simple name. Without a language check a Java
# `Processor.process` collides with an unrelated Kotlin
# `Processor.process` in a different project and emits a
# cross-language edge that no build could ever produce.
# Java/Kotlin interop is real, but it is scoped to a
# shared module — a bare name match across source roots
# is not evidence of it.
if detect_lang_from_path(impl_path) != caller_lang:
continue
impl_line = impl_fn.get("line_number")
if (
impl_path == resolved.get("called_file_path")
Expand Down
28 changes: 23 additions & 5 deletions tests/unit/tools/test_cross_lang_call_resolution_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,23 @@ def get_parser(ext):
for row in part_links
)

def test_typescript_dynamic_import_calls_static_import_file(self, manager):
def test_typescript_dynamic_import_with_non_literal_specifier_is_not_resolved(self, manager):
"""A dynamic import whose specifier is not a static string literal must
produce no CALLS edge.

This test previously asserted the opposite — that
``await import(`./tough_dynamic_${moduleName}`)`` resolves to
``tough_circular_b.ts`` — and was named
``..._calls_static_import_file`` accordingly. That edge was fabricated:
the resolver ignored the dynamic specifier entirely and used
``static_imports[0]``, i.e. whichever module the file happened to
``import`` first. ``tough_circular_b`` is unrelated to the dynamic
import, and no ``tough_dynamic_*`` file exists in the fixture at all.

Besides being wrong, the edge pointed at a File node with
``called_line_number = 0``, which the backends bind differently — it was
one of the edges making the Database Parity Check flaky.
"""
wrapper = _parser(manager, "typescript")
parser = TypescriptTreeSitterParser(wrapper)
base = FIXTURES / "sample_project_typescript"
Expand All @@ -649,13 +665,15 @@ def test_typescript_dynamic_import_calls_static_import_file(self, manager):
"imports": parsed.get("imports", []),
})
*_, fn_to_file = build_function_call_groups(files, {})
edge = next(

fabricated = [
e for e in fn_to_file
if e.get("caller_name") == "loadModuleDynamically"
]
assert fabricated == [], (
"a template-literal dynamic import specifier is not statically "
f"knowable and must not produce an edge; got {fabricated}"
)
assert edge["line_number"] == 31
assert edge["called_name"] == "tough_circular_b.ts"
assert edge["called_file_path"].endswith("tough_circular_b.ts")

def test_typescript_decorated_by_validate_on_update_age(self, manager):
wrapper = _parser(manager, "typescript")
Expand Down
Loading