Files
rajarshidattapy f5743dde2b fix(cpp): keep symbols for nested C++ types and C++/CLI sources (#2876)
Nested class/struct types were dropped because the field_declaration branch returned
before walking the class_specifier in its type field. Walk it instead, so nested types
and their members are emitted with a contains edge from the enclosing type. C++/CLI
(ref class, gcnew, ^/% handles) cannot be parsed by tree-sitter-cpp at all (it produces
ERROR nodes and fabricates symbols), so normalize those tokens to plain C++ before
parsing, preserving byte length and line breaks; SCREAMING_CASE constants and attached
XOR/modulo operators are kept out of the handle rewrite.
2026-08-20 15:42:36 +01:00

6960 lines
316 KiB
Python

"""Deterministic structural extraction from source code using tree-sitter. Outputs nodes+edges dicts."""
from __future__ import annotations
import hashlib
import importlib
import json
import os
import re
import sys
import textwrap
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path, PurePath
from typing import Any, Callable
from .cache import load_cached, save_cached
from .mcp_ingest import extract_mcp_config, is_mcp_config_path
from .manifest_ingest import extract_package_manifest, is_package_manifest_path
from .resolver_registry import (
LanguageResolver,
register as register_language_resolver,
run_language_resolvers,
)
from .ruby_resolution import resolve_ruby_member_calls
from .pascal_resolution import resolve_pascal_inherited_calls
# --- migrated to graphify/extractors/ (see graphify/extractors/MIGRATION.md) ---
from graphify.extractors.base import ( # noqa: F401
_LANGUAGE_BUILTIN_GLOBALS,
_file_stem,
_make_id,
_read_text,
)
from graphify.extractors.apex import extract_apex # noqa: F401
from graphify.extractors.bash import extract_bash # noqa: F401
from graphify.extractors.blade import extract_blade # noqa: F401
from graphify.extractors.csharp import (
CsharpNameResolver,
_resolve_cross_file_csharp_imports,
_resolve_csharp_type_references,
)
from graphify.extractors.dart import extract_dart # noqa: F401
from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm # noqa: F401
from graphify.extractors.elixir import extract_elixir # noqa: F401
from graphify.extractors.fortran import _cpp_preprocess, extract_fortran # noqa: F401
from graphify.extractors.go import _GO_PREDECLARED_FUNCS, extract_go # noqa: F401
from graphify.extractors.json_config import extract_json # noqa: F401
from graphify.extractors.commonlisp import extract_commonlisp # noqa: F401
from graphify.extractors.markdown import extract_markdown, _MD_LINK_INDEX_CACHE # noqa: F401
from graphify.extractors.ocaml import extract_ocaml # noqa: F401
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401
from graphify.extractors.razor import extract_razor # noqa: F401
from graphify.extractors.rust import extract_rust # noqa: F401
from graphify.extractors.sln import extract_sln # noqa: F401
from graphify.extractors.sql import extract_sql # noqa: F401
from graphify.extractors.terraform import extract_terraform # noqa: F401
from graphify.extractors.verilog import extract_verilog # noqa: F401
from graphify.extractors.zig import extract_zig # noqa: F401
from graphify.security import sanitize_metadata
from graphify.paths import disambiguate_ambiguous_candidates
from graphify.extractors.models import LanguageConfig, _JS_CACHE_BYPASS_SUFFIXES, _NamespaceExportFact, _StarExportFact, _SymbolAliasFact, _SymbolDeclarationFact, _SymbolExportFact, _SymbolImportFact, _SymbolResolutionFacts, _SymbolUseFact, _WORKSPACE_PACKAGE_CACHE # noqa: E402,F401
from graphify.extractors.resolution import ( # noqa: E402,F401
_DECLDEF_HEADER_SUFFIXES,
_DECLDEF_IMPL_SUFFIXES,
_EXPORT_CONDITION_PRIORITY,
_JS_INDEX_FILES,
_JS_PRIMITIVE_TYPES,
_JS_RESOLVE_EXTS,
_TSCONFIG_ALIAS_CACHE,
_VUE_SCRIPT_LANG_RE,
_VUE_SCRIPT_RE,
_WORKSPACE_MANIFEST_NAMES,
_apply_symbol_resolution_facts,
_augment_symbol_resolution_edges,
_collect_js_symbol_resolution_facts,
_collect_python_symbol_resolution_facts,
_contained_in_package,
_decldef_class_stem,
_disambiguate_colliding_node_ids,
_find_workspace_root,
_go_import_path_for_file,
_is_type_like_definition,
_js_call_identifier,
_js_default_export_name,
_js_default_import_name,
_js_export_clause,
_js_export_statement_is_star,
_js_exported_declaration_names,
_js_lexical_aliases,
_js_module_specifier,
_js_named_specifiers,
_js_namespace_export_name,
_js_source_path,
_js_top_level_function_bodies,
_load_tsconfig_aliases,
_load_tsconfig_base_url,
_load_workspace_packages,
_match_tsconfig_alias,
_merge_decl_def_classes,
_node_disambiguation_source_key,
_package_entry_candidates,
_parse_js_tree,
_parse_python_tree,
_pascal_class_stem_cache,
_pascal_project_root,
_pascal_resolve_class,
_pascal_resolve_unit,
_pascal_unit_cache,
_pnpm_workspace_globs,
_python_call_identifier,
_python_import_from_module,
_python_imported_names,
_python_top_level_function_bodies,
_read_tsconfig_aliases,
_resolve_c_include_path,
_resolve_cross_file_imports,
_resolve_cross_file_java_imports,
_resolve_export_target,
_resolve_go_type_references,
_resolve_java_type_references,
_resolve_php_type_references,
_resolve_js_import_path,
_resolve_js_import_target,
_resolve_js_module_path,
_resolve_lua_import_target,
_probe_python_module_candidate,
_resolve_python_module_path,
_resolve_tsconfig_alias,
_resolve_workspace_import,
_source_key,
_strip_jsonc,
_ts_collect_type_refs,
_ts_heritage_clause_entries,
_ts_walk_class_members,
_vue_mask_non_script,
_walk_js_tree,
_walk_python_tree,
_workspace_globs,
)
from graphify.symbol_resolution import resolve_bash_source_edges # noqa: E402
from graphify.extractors.engine import REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401
from graphify.extractors.pascal import _PAS_BEGIN_END_TOKEN_RE, _PAS_CALL_RE, _PAS_END_SEMI_RE, _PAS_IMPL_HEADER_RE, _PAS_KEYWORDS, _PAS_METHOD_DECL_RE, _PAS_MODULE_RE, _PAS_TOKEN_RE, _PAS_TYPE_HEADER_RE, _PAS_USES_RE, _extract_pascal_regex, _pascal_find_body, _pascal_split_bases, _pascal_split_sections, _pascal_split_uses, _pascal_strip_comments, extract_pascal # noqa: E402,F401
from graphify.extractors.objc import _objc_local_var_types, extract_objc # noqa: E402,F401
from graphify.extractors.julia import extract_julia # noqa: E402,F401
_RECURSION_LIMIT = 10_000
# Language built-in globals that AST may classify as call targets when used as
# constructors or coercion functions (e.g. String(x), Number(x), Boolean(x)).
# Without this filter they become god-nodes accumulating spurious edges from
# every call site. Filter applied at same-file and cross-file resolution.
# See issue #726.
def _raise_recursion_limit() -> None:
if sys.getrecursionlimit() < _RECURSION_LIMIT:
sys.setrecursionlimit(_RECURSION_LIMIT)
def _safe_extract(extractor: Callable, path: Path) -> dict:
try:
return extractor(path)
except RecursionError:
print(f" warning: skipped {path} (recursion limit exceeded)", file=sys.stderr, flush=True)
return {"nodes": [], "edges": [], "error": "recursion_limit_exceeded"}
except Exception as e:
if os.environ.get("GRAPHIFY_DEBUG"):
import traceback
traceback.print_exc(file=sys.stderr)
print(f" warning: skipped {path} ({type(e).__name__}: {e})", file=sys.stderr, flush=True)
return {"nodes": [], "edges": [], "error": f"{type(e).__name__}: {e}"}
def _file_node_id(rel_path: Path) -> str:
"""File-level node ID matching the skill.md spec: ``{parent_dir}_{stem}`` —
one parent directory level, no extension. ``rel_path`` MUST be relative to
the project root so top-level files collapse to a bare stem (``setup.py`` ->
``setup``) instead of picking up the root directory name. This must equal the
ID semantic subagents generate, or AST and semantic extraction split a file
into two disconnected ghost nodes (#1033)."""
return _make_id(_file_stem(rel_path))
def _repoint_python_package_imports(paths, all_nodes, all_edges, root) -> None:
"""Repoint Python absolute-import edges to the real file node under a nested
(e.g. ``src/``) package root (#2072).
Absolute imports target an id derived from the dotted module path
(``_make_id('pkg.mod')`` -> ``pkg_mod``), but file-node ids are
scan-root-relative (``src_pkg_mod`` when the code lives under ``src/``), so
the edge dangles and is silently dropped — the graph loses most ``imports``
edges purely because of where the scan started. Build an alias map from the
dotted-module id to the real file-node id by detecting each ``.py`` file's
package root (the contiguous run of ancestor dirs carrying ``__init__.py``)
and rewrite matching ``imports``/``imports_from`` edge targets. Guards: never
shadow an existing node id, and drop an alias claimed by more than one file
(ambiguous -> leave dangling, as before). Files whose package root IS the
scan root are skipped (ids already coincide)."""
try:
root = Path(root).resolve()
except OSError:
root = Path(root)
node_ids = {n.get("id") for n in all_nodes if isinstance(n, dict)}
alias_to_files: dict[str, set[str]] = {}
for p in paths:
if p.suffix.lower() not in (".py", ".pyi"):
continue
try:
rel = Path(p).resolve().relative_to(root)
except (ValueError, OSError):
continue
parts = rel.parts
if len(parts) < 2:
continue # top-level file: scan-root-relative id already matches
d = Path(p).resolve().parent
levels = 0
# Bounded by the number of dirs between the file and the scan root, so a
# pathological `/__init__.py` chain can't loop forever.
while levels < len(parts) - 1 and (d / "__init__.py").is_file():
levels += 1
d = d.parent
if levels == 0:
continue # not inside a package (namespace pkg / loose module)
mod_parts = parts[-(levels + 1):] # package dirs + the file itself
if len(mod_parts) == len(parts):
continue # package root == scan root: file-node id already coincides
file_node = _file_node_id(rel)
alias = _make_id(str(Path(*mod_parts).with_suffix("")))
alias_to_files.setdefault(alias, set()).add(file_node)
if p.name in ("__init__.py", "__init__.pyi") and len(mod_parts) > 1:
# `import pkg` / `from pkg import x` targets the package-dir id.
pkg_alias = _make_id(str(Path(*mod_parts[:-1])))
alias_to_files.setdefault(pkg_alias, set()).add(file_node)
alias_map = {
a: next(iter(fs))
for a, fs in alias_to_files.items()
if len(fs) == 1 and a not in node_ids
}
if not alias_map:
return
for e in all_edges:
# Only repoint edges emitted from a Python file: a non-Python import edge
# (e.g. C# `using Pkg.Mod;`, Java/Go dotted imports) can have a dangling
# target string that coincides with a Python alias, and repointing it
# would fabricate a cross-language import edge (#2072 review).
if (
isinstance(e, dict)
and e.get("relation") in ("imports", "imports_from")
and str(e.get("source_file", "")).lower().endswith((".py", ".pyi"))
):
tgt = e.get("target")
if tgt in alias_map:
e["target"] = alias_map[tgt]
SEMANTIC_RELATIONS = frozenset({
"inherits", "implements", "mixes_in", "embeds", "references",
"calls", "imports", "imports_from", "re_exports", "contains", "method",
})
# Condition keys consulted when resolving an `exports` target, in priority
# order. `default` is Node's catch-all and must be consulted LAST so a more
# specific condition (source/import/module/etc.) wins when several match.
# ── LanguageConfig dataclass ─────────────────────────────────────────────────
# ── Generic helpers ───────────────────────────────────────────────────────────
# Scalar builtins and test-mock names that appear as type annotations but carry
# no useful semantic meaning as graph nodes (#1147). Suppressed at the annotation
# walker level so they are never created as nodes or emitted as edges.
# java.lang (auto-imported) plus the ubiquitous java.util / java.io / java.time /
# java.util.{stream,function,concurrent} / java.math / java.nio.file types that
# appear as field, parameter, return, and generic-argument annotations. They never
# resolve to a project node, so emitting `references` edges to them is pure noise
# (mirrors _GO_PREDECLARED_TYPES / _PYTHON_ANNOTATION_NOISE). Suppressed at the
# type-ref walker so they are never created as nodes or emitted as edges. The
# boxed-scalar/`void` primitives are already dropped by grammar node type above;
# these are the class/interface names the grammar reports as identifiers.
# ── C / C++ type-ref helpers ─────────────────────────────────────────────────
# ── Scala type-ref helpers ───────────────────────────────────────────────────
def _resolve_name(node, source: bytes, config: LanguageConfig) -> str | None:
"""Get the name from a node using config.name_field, falling back to child types."""
if config.resolve_function_name_fn is not None:
# For C/C++ where the name is inside a declarator
return None # caller handles this separately
n = node.child_by_field_name(config.name_field)
if n:
return _read_text(n, source)
for child in node.children:
if child.type in config.name_fallback_child_types:
return _read_text(child, source)
return None
# ── Import handlers ───────────────────────────────────────────────────────────
def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
t = node.type
if t == "import_statement":
for child in node.children:
if child.type in ("dotted_name", "aliased_import"):
raw = _read_text(child, source)
raw_module, _, raw_alias = raw.partition(" as ")
module_name = raw_module.strip().lstrip(".")
tgt_nid = _make_id(module_name)
edge = {
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
}
if raw_alias:
# `import pkg.mod as alias` binds the local name `alias`, not
# `mod`'s own stem, to the module -- stash it so the cross-file
# member-call resolver can match `alias.func()` against this
# edge instead of dropping it (#2082).
edge["local_alias"] = raw_alias.strip()
edges.append(edge)
elif t == "import_from_statement":
module_node = node.child_by_field_name("module_name")
if module_node:
raw = _read_text(module_node, source)
target_path: "Path | None" = None
if raw.startswith("."):
# Relative import - resolve to full path so IDs match file node IDs
dots = len(raw) - len(raw.lstrip("."))
module_name = raw.lstrip(".")
base = Path(str_path).parent
for _ in range(dots - 1):
base = base.parent
# A relative import can name a subpackage (a directory with an
# __init__.py), not a module file. Probing the candidate on disk
# (mirroring the companion `imports` edge's
# _resolve_python_module_path) resolves `graphs` -> graphs/__init__.py
# instead of a nonexistent graphs.py: without it the target keeps an
# absolute-path-derived slug that the target_file stamp below can't
# heal, so it dangles per-checkout (#2455).
candidate = base / module_name.replace(".", "/") if module_name else base
resolved = _probe_python_module_candidate(candidate)
if resolved is not None:
target_path = resolved
else:
rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py"
target_path = base / rel
tgt_nid = _make_id(str(target_path))
else:
tgt_nid = _make_id(raw)
edge = {
"source": file_nid,
"target": tgt_nid,
"relation": "imports_from",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
}
# Stamp the resolved target file (mirroring _import_js, #1814) so
# the #2169 remap pass can canonicalize this edge's target on an
# incremental run where the target file itself is not in the
# batch — without it the target keeps an absolute-path-derived id
# that matches no node in the merged graph and dangles (#2213).
# Existence-gated: a speculative import of a nonexistent sibling
# must stay dangling, exactly as before. The stamp is transient
# and popped before graph.json ships.
if target_path is not None:
try:
if target_path.is_file():
edge["target_file"] = str(target_path)
except OSError:
pass
edges.append(edge)
def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
is_reexport = node.type == "export_statement"
# Only handle export_statement if it has a `from` clause (re-export).
# Pure exports like `export const x = 1` or `export { localVar }` have no source module.
if is_reexport:
has_from = any(child.type == "from" or (_read_text(child, source) == "from") for child in node.children if child.type in ("from", "identifier"))
if not has_from:
# Check for string child (source path) as a more reliable indicator
has_from = any(child.type == "string" for child in node.children)
if not has_from:
return
resolved_path: "Path | None" = None
module_string = None
for child in node.children:
if child.type == "string":
module_string = child
break
if child.type == "import_require_clause":
# TS import-equals form: `import x = require("./m")`. The module
# string sits inside the clause, not on the import_statement
# itself, so the direct-child scan above never sees it.
module_string = next(
(sub for sub in child.children if sub.type == "string"), None
)
break
if module_string is not None:
raw = _read_text(module_string, source).strip("'\"` ")
resolved = _resolve_js_import_target(raw, str_path)
if resolved is not None:
tgt_nid, resolved_path = resolved
# `_resolve_js_import_path` returns the attempted path when no
# local file exists. Static ES imports must treat that as unresolved
# rather than minting a checkout-specific target ID (#2457).
if resolved_path is not None and not resolved_path.is_file():
tgt_nid = _make_id("ref", raw)
resolved_path = None
edge = {
"source": file_nid,
"target": tgt_nid,
"relation": "imports_from",
"context": "re-export" if is_reexport else "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
}
# Stamp the resolved target file so a same-basename cross-extension
# sibling (foo.ts importing/re-exporting ./foo.mjs) keys its target salt
# by the TARGET's file rather than the importer's. Both files collapse to
# the base id `foo`; without this the salted lookup mis-points the target
# back onto the importer's own variant, a phantom self-loop (#1814).
if resolved_path is not None:
edge["target_file"] = str(resolved_path)
edges.append(edge)
# Emit symbol-level edges for named imports/re-exports from local/aliased files.
# e.g. `import { Foo, type Bar } from './bar'` → file → Foo, file → Bar (EXTRACTED)
# e.g. `export { Foo } from './bar'` → file → Foo (re_exports edge)
# Uses the same _make_id(target_stem, name) key that _extract_generic emits when
# defining the symbol, so these edges wire importers directly to existing symbol nodes.
if resolved_path is not None:
target_stem = _file_stem(resolved_path)
line = node.start_point[0] + 1
if is_reexport:
# Handle: export { foo, bar } from './module'
# export { default as baz } from './module'
for child in node.children:
if child.type == "export_clause":
for spec in child.children:
if spec.type == "export_specifier":
# The exported name is the local name from the source module
name_node = spec.child_by_field_name("name")
if name_node:
sym = _read_text(name_node, source)
if sym == "default":
continue # skip default re-exports for ID matching
edges.append({
"source": file_nid,
"target": _make_id(target_stem, sym),
"relation": "re_exports",
"context": "re-export",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
# Which file this symbol target was synthesized
# from, so the id-remap post-pass can repoint a
# target the candidates rewrite never learns —
# a barrel defines no symbols (#1983). Transient,
# stripped at build like the #1814 stamp.
"target_file": str(resolved_path),
})
else:
# Handle: import { Foo, type Bar } from './bar'
for child in node.children:
if child.type == "import_clause":
for sub in child.children:
if sub.type == "named_imports":
for spec in sub.children:
if spec.type == "import_specifier":
name_node = spec.child_by_field_name("name")
if name_node:
sym = _read_text(name_node, source)
edges.append({
"source": file_nid,
"target": _make_id(target_stem, sym),
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
# See the re_exports stamp above (#1983).
"target_file": str(resolved_path),
})
def _import_java(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
def _walk_scoped(n) -> str:
parts: list[str] = []
cur = n
while cur:
if cur.type == "scoped_identifier":
name_node = cur.child_by_field_name("name")
if name_node:
parts.append(_read_text(name_node, source))
cur = cur.child_by_field_name("scope")
elif cur.type == "identifier":
parts.append(_read_text(cur, source))
break
else:
break
parts.reverse()
return ".".join(parts)
for child in node.children:
if child.type in ("scoped_identifier", "identifier"):
path_str = _walk_scoped(child)
module_name = path_str.split(".")[-1].strip("*").strip(".") or (
path_str.split(".")[-2] if len(path_str.split(".")) > 1 else path_str
)
if module_name:
tgt_nid = _make_id(module_name)
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
break
def _import_c(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
for child in node.children:
if child.type in ("string_literal", "system_lib_string", "string"):
raw = _read_text(child, source).strip('"<> ')
# Quoted includes: try to resolve to a real file so the target ID
# matches the node ID _extract_generic creates for that file.
if child.type != "system_lib_string":
resolved = _resolve_c_include_path(raw, str_path)
if resolved is not None:
tgt_nid = _make_id(str(resolved))
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
# Stamp the resolved target, mirroring _import_python (#1814):
# without it, an include whose header lives outside this
# batch's paths keeps the raw absolute-path id no later pass
# ever learns to relativize (#2243).
"target_file": str(resolved),
})
break
module_name = raw.split("/")[-1].split(".")[0]
if module_name:
tgt_nid = _make_id(module_name)
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
break
def _import_csharp(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
text = _read_text(node, source).strip().rstrip(";")
if text.startswith("global "):
text = text[len("global "):].strip()
if not text.startswith("using"):
return
body = text[len("using"):].strip()
using_kind, alias, target_fqn = "namespace", None, body
if body.startswith("static "):
using_kind, target_fqn = "static", body[len("static "):].strip()
elif "=" in body:
lhs, rhs = body.split("=", 1)
using_kind, alias, target_fqn = "alias", lhs.strip(), rhs.strip()
if not target_fqn:
return
edges.append({
"source": file_nid,
"target": _make_id(target_fqn),
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
"metadata": sanitize_metadata({k: v for k, v in
{"using_kind": using_kind, "alias": alias, "target_fqn": target_fqn,
"scope_kind": "namespace" if scope_stack else "file",
"scope_id": scope_stack[-1] if scope_stack else None}.items() if v is not None}),
})
def _import_kotlin(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
# Grammar 1.1.0 (PyPI tree_sitter_kotlin) emits an `import` node whose
# children are the `import` keyword and a `qualified_identifier` (the dotted
# path), optionally followed by `.` `*` (wildcard) or `as` + `identifier`
# (alias). There is no `path` field. Older forks emit `import_header` with a
# `path` field or a bare `identifier` child; keep those branches so the
# extractor works across grammar generations (#2526, adapted from PR #2531
# by @Mustaqeem66).
path_node = node.child_by_field_name("path")
if path_node is None:
path_node = next(
(c for c in node.children if c.type == "qualified_identifier"), None
)
if path_node is not None:
raw = _read_text(path_node, source).strip()
else:
raw = next(
(_read_text(c, source).strip() for c in node.children
if c.type == "identifier"),
"",
)
if not raw:
return
# Wildcard (`import a.b.*`): imports a whole package, not a symbol. The last
# path segment is a PACKAGE name, so a symbol-level edge would dangle on (or
# collide with) an unrelated node that happens to share the package's name.
if raw.endswith(".*") or raw == "*" or any(c.type == "*" for c in node.children):
return
# Alias (`import a.b.C as D`): the alias is the identifier child after `as`.
alias = None
saw_as = False
for child in node.children:
if not saw_as:
saw_as = child.type == "as"
elif child.type in ("identifier", "simple_identifier"):
alias = _read_text(child, source).strip() or None
break
module_name = raw.split(".")[-1].strip()
if not module_name:
return
# Target is the bare last segment for now; _resolve_kotlin_import_targets
# rewrites it to the real node id via the target_fqn stamped here, once the
# per-file package index exists. Unresolved targets stay dangling like other
# languages' external imports.
edges.append({
"source": file_nid,
"target": _make_id(module_name),
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
"metadata": sanitize_metadata({k: v for k, v in
{"target_fqn": raw, "alias": alias}.items() if v is not None}),
})
def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
for child in node.children:
if child.type in ("stable_id", "identifier"):
raw = _read_text(child, source)
module_name = raw.split(".")[-1].strip("{} ")
if module_name and module_name != "_":
tgt_nid = _make_id(module_name)
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
break
def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
for child in node.children:
if child.type in ("qualified_name", "name", "identifier"):
raw = _read_text(child, source)
module_name = raw.split("\\")[-1].strip()
if module_name:
tgt_nid = _make_id(module_name)
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
break
# ── C/C++ function name helpers ───────────────────────────────────────────────
def _get_c_func_name(node, source: bytes) -> str | None:
"""Recursively unwrap declarator to find the innermost identifier (C)."""
if node.type == "identifier":
return _read_text(node, source)
decl = node.child_by_field_name("declarator")
if decl:
return _get_c_func_name(decl, source)
for child in node.children:
if child.type == "identifier":
return _read_text(child, source)
return None
# ── JS/TS extra walk for arrow functions ──────────────────────────────────────
# Node types whose value is a callable, for the JS/TS assignment / class-field
# / function-expression forms below. Older tree-sitter-javascript grammars
# label a function expression `function`; current ones use `function_expression`.
# ── TS extra walk for namespace / module declarations ─────────────────────────
# ── C# extra walk for namespace declarations ──────────────────────────────────
# ── Swift extra walk for enum cases ──────────────────────────────────────────
# ── Java extra walk for enum constants ───────────────────────────────────────
# ── Language configs ──────────────────────────────────────────────────────────
_PYTHON_CONFIG = LanguageConfig(
ts_module="tree_sitter_python",
class_types=frozenset({"class_definition"}),
function_types=frozenset({"function_definition"}),
import_types=frozenset({"import_statement", "import_from_statement"}),
call_types=frozenset({"call"}),
call_function_field="function",
call_accessor_node_types=frozenset({"attribute"}),
call_accessor_field="attribute",
call_accessor_object_field="object",
function_boundary_types=frozenset({"function_definition"}),
import_handler=_import_python,
)
_JS_CONFIG = LanguageConfig(
ts_module="tree_sitter_javascript",
class_types=frozenset({"class_declaration"}),
function_types=frozenset({"function_declaration", "generator_function_declaration", "method_definition"}),
import_types=frozenset({"import_statement", "export_statement"}),
call_types=frozenset({"call_expression", "new_expression"}),
call_function_field="function",
call_accessor_node_types=frozenset({"member_expression"}),
call_accessor_field="property",
call_accessor_object_field="object",
# `function_expression` belongs here so UNTRACKED inline/nested expressions
# reach walk_calls' existing closure handler, which already names the type
# (`_JS_CLOSURE_TYPES`). Without it the gate never opens, so such an
# expression's parameters and locals never fold into extra_locals for its
# subtree and read as by-name references (#2241 family). A top-level
# `const f = function (…) {}` is tracked via its declarator and was already
# fine; the inline/nested forms are what this covers.
function_boundary_types=frozenset({"function_declaration", "generator_function_declaration", "arrow_function", "method_definition", "function_expression", "generator_function"}),
import_handler=_import_js,
)
_TS_CONFIG = LanguageConfig(
ts_module="tree_sitter_typescript",
ts_language_fn="language_typescript",
class_types=frozenset({
"class_declaration",
"abstract_class_declaration", # TS abstract class
"interface_declaration", # parity with Java/C#
"enum_declaration", # named enums
"type_alias_declaration", # named type aliases
}),
function_types=frozenset({"function_declaration", "generator_function_declaration", "method_definition", "method_signature"}),
import_types=frozenset({"import_statement", "export_statement"}),
call_types=frozenset({"call_expression", "new_expression"}),
call_function_field="function",
call_accessor_node_types=frozenset({"member_expression"}),
call_accessor_field="property",
call_accessor_object_field="object",
# `function_expression`: see the note on the JS config above.
function_boundary_types=frozenset({"function_declaration", "generator_function_declaration", "arrow_function", "method_definition", "function_expression", "generator_function"}),
import_handler=_import_js,
)
# .tsx files must use the TSX grammar (JSX-aware), not the plain TypeScript grammar.
# tree-sitter-typescript ships two languages: language_typescript (for .ts) and
# language_tsx (for .tsx). Parsing .tsx with language_typescript silently fails on
# JSX expressions, dropping any call_expression nested inside JSX (e.g. {fmtDate(x)}).
_TSX_CONFIG = LanguageConfig(
ts_module="tree_sitter_typescript",
ts_language_fn="language_tsx",
class_types=_TS_CONFIG.class_types,
function_types=_TS_CONFIG.function_types,
import_types=_TS_CONFIG.import_types,
call_types=_TS_CONFIG.call_types,
call_function_field=_TS_CONFIG.call_function_field,
call_accessor_node_types=_TS_CONFIG.call_accessor_node_types,
call_accessor_field=_TS_CONFIG.call_accessor_field,
call_accessor_object_field=_TS_CONFIG.call_accessor_object_field,
function_boundary_types=_TS_CONFIG.function_boundary_types,
import_handler=_TS_CONFIG.import_handler,
)
_JAVA_CONFIG = LanguageConfig(
ts_module="tree_sitter_java",
# record_declaration shares class_declaration's name/body/interfaces fields,
# so it becomes a first-class type node instead of an isolated file (#1373).
# Enums and annotation declarations use the same name/body contract.
class_types=frozenset({
"class_declaration", "interface_declaration", "record_declaration",
"enum_declaration", "annotation_type_declaration",
}),
function_types=frozenset({"method_declaration", "constructor_declaration"}),
import_types=frozenset({"import_declaration"}),
# object_creation_expression (`new Foo(...)`) is handled by a dedicated Java
# branch in walk_calls below — its callee is in the `type` field, not `name`.
call_types=frozenset({"method_invocation", "object_creation_expression"}),
call_function_field="name",
call_accessor_node_types=frozenset(),
function_boundary_types=frozenset({"method_declaration", "constructor_declaration"}),
import_handler=_import_java,
)
_GROOVY_CONFIG = LanguageConfig(
ts_module="tree_sitter_groovy",
class_types=frozenset({"class_declaration", "interface_declaration"}),
function_types=frozenset({"method_declaration", "constructor_declaration"}),
import_types=frozenset({"import_declaration"}),
call_types=frozenset({"method_invocation"}),
call_function_field="name",
call_accessor_node_types=frozenset(),
function_boundary_types=frozenset({"method_declaration", "constructor_declaration"}),
import_handler=_import_java,
)
_C_CONFIG = LanguageConfig(
ts_module="tree_sitter_c",
class_types=frozenset(),
function_types=frozenset({"function_definition"}),
import_types=frozenset({"preproc_include"}),
call_types=frozenset({"call_expression"}),
call_function_field="function",
call_accessor_node_types=frozenset({"field_expression"}),
call_accessor_field="field",
function_boundary_types=frozenset({"function_definition"}),
import_handler=_import_c,
resolve_function_name_fn=_get_c_func_name,
)
_CPP_CONFIG = LanguageConfig(
ts_module="tree_sitter_cpp",
class_types=frozenset({"class_specifier", "struct_specifier"}),
function_types=frozenset({"function_definition"}),
import_types=frozenset({"preproc_include"}),
call_types=frozenset({"call_expression"}),
call_function_field="function",
call_accessor_node_types=frozenset({"field_expression", "qualified_identifier"}),
call_accessor_field="field",
function_boundary_types=frozenset({"function_definition"}),
import_handler=_import_c,
resolve_function_name_fn=_get_cpp_func_name,
)
_RUBY_CONFIG = LanguageConfig(
ts_module="tree_sitter_ruby",
# `module Foo` is a container node just like `class Foo` in tree-sitter's
# Ruby grammar (name in a `constant` child, body in `body_statement`), so it
# gets a node and its methods attach via `method` (#1640). Without it, plain
# utility/`module_function` modules produced no node and their methods hung
# off the file via `contains` with dot-less labels.
class_types=frozenset({"class", "module"}),
function_types=frozenset({"method", "singleton_method"}),
import_types=frozenset(),
call_types=frozenset({"call"}),
call_function_field="method",
call_accessor_node_types=frozenset(),
name_fallback_child_types=("constant", "scope_resolution", "identifier"),
body_fallback_child_types=("body_statement",),
function_boundary_types=frozenset({"method", "singleton_method"}),
)
_CSHARP_CONFIG = LanguageConfig(
ts_module="tree_sitter_c_sharp",
class_types=frozenset({
"class_declaration",
"interface_declaration",
"enum_declaration",
"struct_declaration",
"record_declaration",
}),
function_types=frozenset({"method_declaration"}),
import_types=frozenset({"using_directive"}),
call_types=frozenset({"invocation_expression"}),
call_function_field="function",
call_accessor_node_types=frozenset({"member_access_expression"}),
call_accessor_field="name",
body_fallback_child_types=("declaration_list",),
function_boundary_types=frozenset({"method_declaration"}),
import_handler=_import_csharp,
)
_KOTLIN_CONFIG = LanguageConfig(
ts_module="tree_sitter_kotlin",
class_types=frozenset({"class_declaration", "object_declaration"}),
function_types=frozenset({"function_declaration"}),
# Grammar 1.1.0 (PyPI tree_sitter_kotlin) names the import node `import`;
# older forks use `import_header`. Accept both (#2526).
import_types=frozenset({"import_header", "import"}),
call_types=frozenset({"call_expression"}),
call_function_field="",
call_accessor_node_types=frozenset({"navigation_expression"}),
call_accessor_field="",
# Different tree-sitter-kotlin grammar versions name plain identifier
# nodes differently: PyPI's `tree_sitter_kotlin` uses `identifier`,
# older forks use `simple_identifier`. Accept both so the extractor
# works across grammar generations.
name_fallback_child_types=("simple_identifier", "identifier"),
body_fallback_child_types=("function_body", "class_body", "enum_class_body"),
function_boundary_types=frozenset({"function_declaration"}),
import_handler=_import_kotlin,
)
_SCALA_CONFIG = LanguageConfig(
ts_module="tree_sitter_scala",
class_types=frozenset({"class_definition", "object_definition"}),
function_types=frozenset({"function_definition"}),
import_types=frozenset({"import_declaration"}),
call_types=frozenset({"call_expression"}),
call_function_field="",
call_accessor_node_types=frozenset({"field_expression"}),
call_accessor_field="field",
name_fallback_child_types=("identifier",),
body_fallback_child_types=("template_body",),
function_boundary_types=frozenset({"function_definition"}),
import_handler=_import_scala,
)
_PHP_CONFIG = LanguageConfig(
ts_module="tree_sitter_php",
ts_language_fn="language_php",
class_types=frozenset({"class_declaration"}),
function_types=frozenset({"function_definition", "method_declaration"}),
import_types=frozenset({"namespace_use_clause"}),
call_types=frozenset({"function_call_expression", "member_call_expression", "scoped_call_expression", "class_constant_access_expression"}),
static_prop_types=frozenset({"scoped_property_access_expression"}),
helper_fn_names=frozenset({"config"}),
container_bind_methods=frozenset({"bind", "singleton", "scoped", "instance"}),
event_listener_properties=frozenset({"listen", "subscribe"}),
call_function_field="function",
call_accessor_node_types=frozenset({"member_call_expression"}),
call_accessor_field="name",
name_fallback_child_types=("name",),
body_fallback_child_types=("declaration_list", "compound_statement"),
function_boundary_types=frozenset({"function_definition", "method_declaration"}),
import_handler=_import_php,
)
def _import_lua(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
"""Extract require('module') from Lua variable_declaration nodes."""
text = _read_text(node, source)
import re
m = re.search(r"""require\s*[\('"]\s*['"]?([^'")\s]+)""", text)
if m:
raw_module = m.group(1)
if raw_module:
tgt_nid = _resolve_lua_import_target(raw_module, str_path)
if tgt_nid:
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": str_path,
"source_location": str(node.start_point[0] + 1),
"weight": 1.0,
})
_LUA_CONFIG = LanguageConfig(
ts_module="tree_sitter_lua",
ts_language_fn="language",
class_types=frozenset(),
function_types=frozenset({"function_declaration"}),
import_types=frozenset({"variable_declaration"}),
call_types=frozenset({"function_call"}),
call_function_field="name",
call_accessor_node_types=frozenset({"method_index_expression"}),
call_accessor_field="name",
name_fallback_child_types=("identifier", "method_index_expression"),
body_fallback_child_types=("block",),
function_boundary_types=frozenset({"function_declaration"}),
import_handler=_import_lua,
)
def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> list[tuple[str, str]]:
"""Emit module-level ``imports`` edges and report the imported modules.
A Swift ``import CoreKit`` names a module, not a file path, so — unlike the
file-resolving JS/TS handlers — there is no existing node for the edge to
point at. The returned ``(id, label)`` pairs let the extractor materialize a
``type=module`` anchor node so the edge survives; without it ``build_from_json``
prunes every Swift import edge as a dangling/external reference (#1327).
"""
modules: list[tuple[str, str]] = []
for child in node.children:
if child.type == "identifier":
raw = _read_text(child, source)
tgt_nid = _make_id(raw)
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
modules.append((tgt_nid, raw))
break
return modules
_SWIFT_CONFIG = LanguageConfig(
ts_module="tree_sitter_swift",
class_types=frozenset({"class_declaration", "protocol_declaration"}),
function_types=frozenset({"function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration"}),
import_types=frozenset({"import_declaration"}),
call_types=frozenset({"call_expression"}),
call_function_field="",
call_accessor_node_types=frozenset({"navigation_expression"}),
call_accessor_field="",
name_fallback_child_types=("simple_identifier", "type_identifier", "user_type"),
body_fallback_child_types=("class_body", "protocol_body", "function_body", "enum_class_body"),
function_boundary_types=frozenset({"function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration"}),
import_handler=_import_swift,
)
# ── Ruby local type inference (for member-call resolution) ─────────────────────
# `Const = <factory>(...)` shapes that define a lightweight class named after the
# constant. tree-sitter parses each as an `assignment`, not a `class`, so the
# generic class branch never saw them (#1640).
# ── Generic extractor ─────────────────────────────────────────────────────────
# ── Python rationale extraction ───────────────────────────────────────────────
_RATIONALE_PREFIXES = ("# NOTE:", "# IMPORTANT:", "# HACK:", "# WHY:", "# RATIONALE:", "# TODO:", "# FIXME:")
def _shorten_rationale_label(text: str, width: int = 80) -> str:
"""Collapse whitespace and truncate ``text`` to ``width`` chars for a
rationale node label, cutting on a word boundary rather than mid-word.
Shared by the Python and JS/TS rationale extractors (#2206).
``textwrap.shorten`` collapses to just the placeholder when the first
"word" alone exceeds ``width`` (e.g. a docstring/comment that opens with
an unbroken URL) -- that would emit a content-free label, so fall back to
a plain character truncation of the normalized text in that case.
"""
label = textwrap.shorten(text, width=width, placeholder="…")
if label in ("", "…"):
flat = " ".join(text.split())
label = flat if len(flat) <= width else flat[: width - 1] + "…"
return label
def _is_autogenerated_python(source: bytes) -> bool:
"""Return True if this Python file is auto-generated and its module docstring is noise.
Covers: Alembic/Flask-Migrate revisions, Django migrations, protobuf/gRPC/OpenAPI stubs.
Module docstrings in these files are change annotations or boilerplate, not rationale.
"""
head = source[:2048].decode("utf-8", errors="replace")
# Generic generated-file markers (protobuf, gRPC, OpenAPI codegen, etc.)
if any(m in head for m in ("DO NOT EDIT", "@generated", "Generated by the protocol buffer")):
return True
# Alembic / Flask-Migrate revision files
if (re.search(r"^revision\s*[:=]", head, re.MULTILINE)
and "def upgrade(" in head
and "down_revision" in head):
return True
# Django migrations
if "class Migration(migrations.Migration)" in head and "operations" in head:
return True
return False
def _extract_python_rationale(path: Path, result: dict) -> None:
"""Post-pass: extract docstrings and rationale comments from Python source.
Mutates result in-place by appending to result['nodes'] and result['edges'].
"""
try:
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
language = Language(tspython.language())
parser = Parser(language)
source = path.read_bytes()
tree = parser.parse(source)
root = tree.root_node
except Exception:
return
stem = _file_stem(path)
str_path = str(path)
nodes = result["nodes"]
edges = result["edges"]
seen_ids = {n["id"] for n in nodes}
file_nid = _make_id(str(path))
def _get_docstring(body_node) -> tuple[str, int] | None:
if not body_node:
return None
for child in body_node.children:
if child.type == "expression_statement":
for sub in child.children:
if sub.type in ("string", "concatenated_string"):
text = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace")
text = text.strip("\"'").strip('"""').strip("'''").strip()
if len(text) > 20:
return text, child.start_point[0] + 1
break
return None
def _add_rationale(text: str, line: int, parent_nid: str) -> None:
# Normalize whitespace before truncating, not after: slicing raw text
# first can land mid-word, leave a run of literal spaces where a
# newline + indentation used to be, or end on a "." that turns into
# an Obsidian "..md" filename once export.py appends the extension.
label = _shorten_rationale_label(text)
rid = _make_id(stem, "rationale", str(line))
if rid not in seen_ids:
seen_ids.add(rid)
nodes.append({
"id": rid,
"label": label,
"file_type": "rationale",
"source_file": str_path,
"source_location": f"L{line}",
})
edges.append({
"source": rid,
"target": parent_nid,
"relation": "rationale_for",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
# Module-level docstring — skip for auto-generated files (Alembic, Django
# migrations, protobuf stubs, etc.) whose module docstrings are revision
# annotations, not architectural rationale.
if not _is_autogenerated_python(source):
ds = _get_docstring(root)
if ds:
_add_rationale(ds[0], ds[1], file_nid)
# Class and function docstrings
def walk_docstrings(node, parent_nid: str) -> None:
t = node.type
if t == "class_definition":
name_node = node.child_by_field_name("name")
body = node.child_by_field_name("body")
if name_node and body:
class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace")
nid = _make_id(stem, class_name)
ds = _get_docstring(body)
if ds:
_add_rationale(ds[0], ds[1], nid)
for child in body.children:
walk_docstrings(child, nid)
return
if t == "function_definition":
name_node = node.child_by_field_name("name")
body = node.child_by_field_name("body")
if name_node and body:
func_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace")
nid = _make_id(parent_nid, func_name) if parent_nid != file_nid else _make_id(stem, func_name)
ds = _get_docstring(body)
if ds:
_add_rationale(ds[0], ds[1], nid)
return
for child in node.children:
walk_docstrings(child, parent_nid)
walk_docstrings(root, file_nid)
# Rationale comments (# NOTE:, # IMPORTANT:, etc.)
source_text = source.decode("utf-8", errors="replace")
for lineno, line_text in enumerate(source_text.splitlines(), start=1):
stripped = line_text.strip()
if any(stripped.startswith(p) for p in _RATIONALE_PREFIXES):
_add_rationale(stripped, lineno, file_nid)
# ── Public API ────────────────────────────────────────────────────────────────
def extract_python(path: Path) -> dict:
"""Extract classes, functions, and imports from a .py file via tree-sitter AST."""
result = _extract_generic(path, _PYTHON_CONFIG)
if "error" not in result:
_extract_python_rationale(path, result)
return result
def extract_js(path: Path) -> dict:
"""Extract classes, functions, arrow functions, and imports from a .js/.ts/.tsx/.mts/.cts file."""
suffix = path.suffix.lower()
if suffix == ".tsx":
config = _TSX_CONFIG
elif suffix in (".ts", ".mts", ".cts"):
config = _TS_CONFIG
else:
config = _JS_CONFIG
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 FROM THIS FILE'S NODE is skipped. The source check matters: the AST
pass anchors the edge on the enclosing function when the ``import()`` is
written inside one, and that is a different fact from "this file depends on
that module" — the only one file-level traversal can use (#2584).
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()
rescued_targets: set[str] = set()
for e in result.get("edges", []):
# Only a FILE-level deferred edge makes the rescue redundant (#2584).
#
# `_dynamic_import_js` emits `caller_nid -> target`, and `caller_nid` is this
# file's node only when the `import()` sits at module scope. Written inside a
# function it is that function's node — a different fact, at a granularity
# `affected` does not walk. Matching on target alone treated the two as one and
# skipped the rescue, so a dynamic import inside a function ended up with no
# file-level edge at all. The reverse walk then reached the enclosing function
# and stopped: the only edge pointing at it is `contains`, deliberately kept out
# of DEFAULT_AFFECTED_RELATIONS.
#
# Measured on a ~700-file TS repo: `affected --depth 3` returned 39 of 49 truly
# affected files (recall 0.80, precision 1.00) and deeper traversal did not help,
# which is a dead end rather than a depth limit. It stayed hidden because the
# usual case still resolves — when the next importer imports that exact symbol
# by name there IS an edge into the function. Switch that importer to
# `import * as ns` or a side-effect `import './dyn'` and the same graph goes
# silent.
if (e.get("deferred") and e.get("relation") == "imports_from"
and e.get("source") == file_node_id):
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
# One file depending on one module is one file-level fact, however many
# call sites defer it. Pre-existing (two module-scope `import('./x')` in one
# file already emitted two identical edges on v8), but #2584 routes every
# in-function dynamic import through here too, which would turn an edge case
# into the common one — a hub module deferred from eight functions of the same
# file would carry eight identical arrows.
emit_key = str(resolved_file.resolve()) if resolved_file is not None else raw
if emit_key in rescued_targets:
continue
rescued_targets.add(emit_key)
_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
# docstrings and `# NOTE:`-style comments, but JS/TS comments were discarded
# entirely. That silently drops two high-value signals in mixed corpora:
# 1. rationale comments (`// NOTE:`, `// WHY:`, ...) — same as Python;
# 2. architecture-decision references (`ADR-0011`, `RFC 793`) that teams
# conventionally cite in file/function headers. These are the natural
# join points between code and design docs in the same graph — without
# them, code<->ADR edges never form even when the code cites the ADR.
_JS_RATIONALE_PREFIXES = (
"// NOTE:", "// IMPORTANT:", "// HACK:", "// WHY:", "// RATIONALE:",
"// TODO:", "// FIXME:",
"* NOTE:", "* IMPORTANT:", "* HACK:", "* WHY:", "* RATIONALE:",
"* TODO:", "* FIXME:",
)
# Doc-reference tokens worth first-classing as graph nodes. Deliberately
# conservative: ADR-NNNN (Architecture Decision Records, any zero padding)
# and RFC NNNN / RFC-NNNN.
_JS_DOC_REF_RE = re.compile(r"\b(ADR[- ]?\d{1,5}|RFC[- ]?\d{1,5})\b", re.IGNORECASE)
# Only look for doc references inside comments, not string literals or code.
_JS_COMMENT_LINE_RE = re.compile(r"^\s*(//|/\*|\*)")
def _extract_js_rationale(path: Path, result: dict) -> None:
"""Post-pass: extract rationale comments and doc references from JS/TS source.
Mutates result in-place by appending to result['nodes'] and result['edges'].
"""
try:
source_text = path.read_text(encoding="utf-8", errors="replace")
except Exception:
return
stem = _file_stem(path)
str_path = str(path)
nodes = result["nodes"]
edges = result["edges"]
seen_ids = {n["id"] for n in nodes}
file_nid = _make_id(str(path))
seen_doc_refs: set[str] = set()
def _add_rationale(text: str, line: int) -> None:
# Normalize whitespace before truncating, not after: slicing raw text
# first can land mid-word, leave a run of literal spaces where a
# newline + indentation used to be, or end on a "." that turns into
# an Obsidian "..md" filename once export.py appends the extension.
label = _shorten_rationale_label(text)
rid = _make_id(stem, "rationale", str(line))
if rid not in seen_ids:
seen_ids.add(rid)
nodes.append({
"id": rid,
"label": label,
"file_type": "rationale",
"source_file": str_path,
"source_location": f"L{line}",
})
edges.append({
"source": rid,
"target": file_nid,
"relation": "rationale_for",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
def _add_doc_ref(token: str, line: int) -> None:
# Normalize "adr 11" / "ADR-0011" spellings to a canonical "ADR-0011"
# style label so references to the same document collapse to one node.
kind, num = re.match(r"([A-Za-z]+)[- ]?(\d+)", token).groups()
kind = kind.upper()
label = f"{kind}-{num.zfill(4)}" if kind == "ADR" else f"{kind}-{num}"
if label in seen_doc_refs:
return
seen_doc_refs.add(label)
rid = _make_id("docref", label)
if rid not in seen_ids:
seen_ids.add(rid)
nodes.append({
"id": rid,
"label": label,
"file_type": "doc_ref",
"source_file": str_path,
"source_location": f"L{line}",
})
edges.append({
"source": file_nid,
"target": rid,
"relation": "cites",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
for lineno, line_text in enumerate(source_text.splitlines(), start=1):
stripped = line_text.strip()
if any(stripped.startswith(p) for p in _JS_RATIONALE_PREFIXES):
_add_rationale(stripped.lstrip("/* "), lineno)
if _JS_COMMENT_LINE_RE.match(line_text):
for m in _JS_DOC_REF_RE.finditer(stripped):
_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,
file_node_id: str,
path: Path,
raw: str,
relation: str,
aliases,
base_url,
) -> None:
"""Shared edge/stub emit for the Svelte/Astro/Vue regex-rescue import passes.
Resolves the specifier the same way ``_import_js`` does — relative paths and
tsconfig aliases both go through :func:`_resolve_js_module_path` so
extensionless specifiers probe real on-disk extensions (``../lib/content``
-> ``content.ts``) instead of a naive ``.js``->``.ts`` suffix swap.
When the resolved target is a real file on disk, mirror ``_import_js``:
emit ONLY the edge, stamped with ``target_file``, and mint no stub node.
The #2169 canonicalization loop in :func:`extract` reads the stamp and
repoints the edge at the real file node's canonical id. Minting a stub
here would carry an absolute-path-derived id when the input path is
absolute — a ghost node (e.g. ``private_tmp_..._src_lib_content``)
duplicating the real ``src_lib_content`` node and clobbering its label on
dedupe (#2195). Stub nodes are still minted for unresolved specifiers
(externals, not-yet-created files) so prior behavior is preserved.
"""
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",
"source_file": str(path),
}
if resolved_file is not None:
# Real file on disk: edge only (no stub node), stamped so the #2169
# canonicalization pass repoints it at the real node (#2195).
edge["target_file"] = str(resolved_file)
result.setdefault("edges", []).append(edge)
return
if node_id in existing_ids:
# Edge target already a real node - just add the edge, don't add a node.
result.setdefault("edges", []).append(edge)
return
result.setdefault("nodes", []).append({
"id": node_id, "label": raw,
"file_type": "code", "source_file": stub_source_file,
"confidence": "EXTRACTED",
})
result.setdefault("edges", []).append(edge)
existing_ids.add(node_id)
def extract_svelte(path: Path) -> dict:
"""Extract imports from .svelte files: script-block via JS AST + template regex fallback.
Tree-sitter only sees the <script> block. Svelte template syntax like
{#await import('./X.svelte')} lives in the markup layer and is invisible
to the JS parser, so a regex pass covers those dynamic imports.
"""
result = _extract_generic(path, _JS_CONFIG)
try:
import re as _re
src = path.read_text(encoding="utf-8", errors="replace")
existing_ids = {n["id"] for n in result.get("nodes", [])}
# Source file node ID must match the one _extract_generic creates:
# _make_id(str(path)) - single arg, no stem prefix. Otherwise the source
# endpoint is a phantom node and build_from_json drops the edge (#701).
file_node_id = _make_id(str(path))
aliases = _load_tsconfig_aliases(path.parent)
base_url = _load_tsconfig_base_url(path.parent)
for m in _re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src):
raw = m.group(1)
if not raw:
continue
# Resolution + emit shared with the static pass below: relative
# paths and tsconfig aliases probe real on-disk extensions (#716,
# #701), and a target that IS a real file emits an edge stamped
# with target_file instead of an absolute-id ghost stub (#2195).
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"dynamic_import", aliases, base_url,
)
# Static imports inside <script> blocks. The JS tree-sitter parser fed
# the full .svelte file produces a top-level ERROR node (HTML markup
# is not valid JS), so import_statement nodes are never reached and
# static imports are silently dropped (#713). Regex over each script
# body recovers them.
script_re = _re.compile(
r"<script\b[^>]*>([\s\S]*?)</script\s*>", _re.IGNORECASE
)
static_import_re = _re.compile(
r"""import\s+(?:[^'"`;]+?\s+from\s+)?['"]([^'"]+)['"]"""
)
for script_match in script_re.finditer(src):
script_body = script_match.group(1)
for m in static_import_re.finditer(script_body):
raw = m.group(1)
if not raw:
continue
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"imports_from", aliases, base_url,
)
except Exception:
pass
return result
def extract_astro(path: Path) -> dict:
"""Extract imports from .astro files: frontmatter (TS) + template regex fallback.
Astro files start with a ``---\\n...\\n---`` frontmatter block of TypeScript
setup code (where almost all imports live), followed by an HTML-with-expressions
template body, and optionally ``<script>`` blocks for client-side JS. Tree-sitter
only sees the file usefully through the frontmatter — feeding the whole file to
the JS parser produces a top-level ERROR node because the template is not valid
JS, so ``import_statement`` nodes are never reached and static imports are
silently dropped (#850). Mirrors :func:`extract_svelte` — same regex-rescue
approach, scanning the frontmatter block and any client-side ``<script>`` blocks
for static and dynamic imports.
"""
result = _extract_generic(path, _JS_CONFIG)
try:
import re as _re
src = path.read_text(encoding="utf-8", errors="replace")
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)
# Dynamic imports anywhere in the file: `import('./X.astro')` is legal in
# frontmatter setup code and inside expression slots.
for m in _re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src):
raw = m.group(1)
if not raw:
continue
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"dynamic_import", aliases, base_url,
)
# Static imports: scan the `---...---` frontmatter at the file head plus any
# client-side <script> blocks. Both are TS/JS regions but live inside a file
# the JS tree-sitter parser cannot validate as a whole.
frontmatter_re = _re.compile(
r"\A\s*---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|\Z)"
)
script_re = _re.compile(
r"<script\b[^>]*>([\s\S]*?)</script\s*>", _re.IGNORECASE
)
static_import_re = _re.compile(
r"""import\s+(?:[^'"`;]+?\s+from\s+)?['"]([^'"]+)['"]"""
)
regions: list[str] = []
fm = frontmatter_re.search(src)
if fm:
regions.append(fm.group(1))
for script_match in script_re.finditer(src):
regions.append(script_match.group(1))
for region in regions:
for m in static_import_re.finditer(region):
raw = m.group(1)
if not raw:
continue
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"imports_from", aliases, base_url,
)
except Exception:
pass
return result
# The open-tag matcher skips over quoted attribute values so a `>` inside one
# (e.g. Vue 3.3+ generic components: `<script setup lang="ts"
# generic="T extends Record<string, unknown>">`) doesn't prematurely end the tag.
def extract_vue(path: Path) -> dict:
"""Extract imports, symbols, and type refs from a ``.vue`` SFC.
Masks the non-``<script>`` regions and parses the script with the grammar
its ``lang`` implies (``tsx``→TSX, ``js``/``jsx``→JS, ``ts`` or unset→TS;
TS is a superset of JS so it is a safe default). A regex pass then recovers
``import('…')`` dynamic imports the AST does not edge.
"""
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"nodes": [], "edges": []}
masked, lang = _vue_mask_non_script(src)
if lang == "tsx":
config = _TSX_CONFIG
elif lang in ("js", "jsx"):
config = _JS_CONFIG
else: # "ts" or unspecified — default to the TS grammar (superset of JS)
config = _TS_CONFIG
result = _extract_generic(path, config, source_override=masked.encode("utf-8"))
# Dynamic `import('…')` calls aren't edged by the AST pass; recover by regex,
# mirroring extract_svelte/extract_astro.
try:
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)
for m in re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src):
raw = m.group(1)
if not raw:
continue
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"dynamic_import", aliases, base_url,
)
except Exception:
pass
return result
def extract_java(path: Path) -> dict:
"""Extract classes, interfaces, methods, constructors, and imports from a .java file."""
return _extract_generic(path, _JAVA_CONFIG)
def _is_spock_file(path: Path, ts_result: dict) -> bool:
"""Return True when the file contains Spock-style ``def "feature"()`` methods
that tree-sitter-groovy cannot parse, detected by checking the raw source."""
import re as _re
_SPOCK_FEATURE_RE = _re.compile(r"""^\s*def\s+[\"']""", _re.MULTILINE)
try:
return bool(_SPOCK_FEATURE_RE.search(path.read_text(errors="replace")))
except OSError:
return False
def _extract_spock_fallback(path: Path, ts_result: dict) -> dict:
"""Regex-based fallback for Spock spec files where tree-sitter-groovy cannot parse
``def "feature name"()`` methods. Merges import edges from the tree-sitter pass
(which survive reliably) with class and feature-method nodes extracted via regex.
"""
import re as _re
source = path.read_text(errors="replace")
str_path = str(path)
stem = _file_stem(path)
# Only keep the file node from the tree-sitter pass (guaranteed present and
# correctly IDed) plus all import edges. All other ts nodes are discarded to
# avoid orphaned method/constructor nodes whose parent edges were dropped.
file_node = next((n for n in ts_result.get("nodes", []) if n.get("label") == path.name), None)
nodes: list[dict] = [file_node] if file_node else []
edges: list[dict] = [e for e in ts_result.get("edges", []) if e.get("context") == "import"]
seen_ids: set[str] = {n["id"] for n in nodes}
def _add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": label,
"file_type": "code",
"source_file": str_path,
"source_location": f"L{line}",
})
def _add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED") -> None:
edges.append({
"source": src,
"target": tgt,
"relation": relation,
"confidence": confidence,
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
lines_text = source.splitlines()
# Extract class declarations
class_re = _re.compile(r"^\s*(?:[\w@]+\s+)*class\s+(\w+)")
# Extract Spock feature methods: def "..." () or def '...' ()
# Two separate capture groups per quote style so apostrophes inside
# double-quoted names (e.g. "shouldn't") are captured correctly.
feature_re = _re.compile(r"""^\s*def\s+(?:\"([^\"]+)\"|'([^']+)')\s*\(""")
# Extract plain def methods (non-string names) as well
plain_method_re = _re.compile(r"""^\s*def\s+(\w+)\s*\(""")
current_class_nid: str | None = None
file_nid = _make_id(str_path)
# Ensure the file node exists (tree-sitter pass may have emitted it)
if file_nid not in seen_ids:
_add_node(file_nid, path.name, 1)
for lineno, line_text in enumerate(lines_text, start=1):
cm = class_re.match(line_text)
if cm:
class_name = cm.group(1)
class_nid = _make_id(stem, class_name)
_add_node(class_nid, class_name, lineno)
_add_edge(file_nid, class_nid, "contains", lineno)
current_class_nid = class_nid
continue
if current_class_nid is None:
continue
fm = feature_re.match(line_text)
if fm:
method_name = fm.group(1) or fm.group(2)
method_label = f'"{method_name}"'
method_nid = _make_id(current_class_nid, method_name)
_add_node(method_nid, method_label, lineno)
_add_edge(current_class_nid, method_nid, "method", lineno)
continue
pm = plain_method_re.match(line_text)
if pm:
method_name = pm.group(1)
if method_name not in ("if", "while", "for", "switch", "catch"):
method_label = f".{method_name}()"
method_nid = _make_id(current_class_nid, method_name)
_add_node(method_nid, method_label, lineno)
_add_edge(current_class_nid, method_nid, "method", lineno)
return {"nodes": nodes, "edges": edges}
def extract_groovy(path: Path) -> dict:
"""Extract classes, methods, constructors, and imports from a .groovy/.gradle file.
Falls back to a regex-based Spock extractor when tree-sitter-groovy cannot parse
``def "feature name"()`` methods (common in Spock specification classes).
"""
result = _extract_generic(path, _GROOVY_CONFIG)
if _is_spock_file(path, result):
result = _extract_spock_fallback(path, result)
return result
def extract_c(path: Path) -> dict:
"""Extract functions and includes from a .c/.h file."""
return _extract_generic(path, _C_CONFIG)
# doctest / Catch2 name each test case with a string literal
# (``TEST_CASE("name")``), which tree-sitter-cpp cannot parse: the construct
# becomes an ERROR node and the whole test function is dropped from the graph
# (issue #2594). Recover the test cases with a regex fallback, mirroring the
# Spock handling of Groovy ``def "feature"()`` above. Scoped to doctest +
# Catch2, which share this string-named-macro surface.
#
# Only the top-level test-declaration macros are recovered as callable nodes.
# ``SUBCASE`` / ``SECTION`` are *nested* scopes inside a test body and
# ``TEST_SUITE`` is a grouping wrapper, not a test function — emitting them as
# file-contained nodes would fabricate wrong-granularity nodes and edges.
_CPP_STRING_TEST_MACROS = (
"TEST_CASE", "TEST_CASE_TEMPLATE", "SCENARIO",
)
# The name group consumes C-string escapes (``\"``, ``\\``) so a test whose
# name embeds an escaped quote is captured whole, not truncated at the escape.
_CPP_STRING_TEST_RE = re.compile(
r'^[ \t]*(?:' + "|".join(_CPP_STRING_TEST_MACROS) + r')\s*\(\s*"((?:[^"\\]|\\.)+)"',
re.MULTILINE,
)
def _augment_cpp_string_tests(path: Path, result: dict) -> dict:
"""Append callable nodes for doctest/Catch2 string-named test cases that
tree-sitter-cpp drops as ERROR nodes (issue #2594).
The generic C++ pass still recovers the surrounding functions and include
edges reliably, so this only adds the missing ``TEST_CASE("...")`` nodes and
their ``contains`` edge from the file node — it does not rebuild the result.
Matching is line-anchored raw text, mirroring the Spock fallback above; it is
deliberately not comment/preprocessor aware (a ``TEST_CASE`` disabled behind
a block comment or ``#if 0`` may still surface as a node, exactly as a
commented Spock ``def "feature"()`` would).
"""
try:
source = path.read_text(errors="replace")
except OSError:
return result
matches = list(_CPP_STRING_TEST_RE.finditer(source))
if not matches:
return result
str_path = str(path)
stem = _file_stem(path)
file_nid = _make_id(str_path)
# A test name that is all punctuation (TEST_CASE("***")) normalizes to empty,
# so _make_id(stem, name) collapses onto this bare-stem id — colliding with
# the file's namespace and silently swallowing every later such test under
# one id (#1899). Detect that collapse and fall back to a line-positional id.
stem_collapse_id = _make_id(stem)
nodes = result.setdefault("nodes", [])
edges = result.setdefault("edges", [])
seen_ids = {n.get("id") for n in nodes}
for m in matches:
test_name = m.group(1)
line = source.count("\n", 0, m.start()) + 1
test_nid = _make_id(stem, test_name)
if test_nid == stem_collapse_id:
test_nid = _make_id(stem, "test", f"L{line}")
if test_nid in seen_ids:
continue
seen_ids.add(test_nid)
# Keep the raw test name as the label (mirroring the Spock fallback,
# which labels feature methods with their quoted string name).
nodes.append({
"id": test_nid,
"label": f'"{test_name}"',
"file_type": "code",
"source_file": str_path,
"source_location": f"L{line}",
})
edges.append({
"source": file_nid,
"target": test_nid,
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
return result
# ── C++/CLI normalization (#2876) ────────────────────────────────────────────
# tree-sitter-cpp implements none of `ref class`, `Type^`, `Type%`, `gcnew` or
# `[assembly:…]`. The ERROR lands on the type header, which dissolves the whole
# class body — a 141-method .NET interop wrapper yielded 12 junk symbols. These
# rewrites map each spelling onto the nearest standard C++ one.
# Only files carrying one of these engage the rewrite, so plain C/C++/CUDA is
# parsed byte-for-byte as before. `^`/`%` alone are not markers: they are the
# ordinary XOR and modulo operators.
_CPP_CLI_MARKER_RE = re.compile(
rb"\b(?:ref|value)\s+(?:class|struct)\b"
rb"|\binterface\s+class\b"
rb"|\bgcnew\b"
rb"|\[\s*(?:assembly|module)\s*:"
)
# `public ref class Foo` / `value struct Bar` / `interface class Baz` → the
# access specifier goes too: it is not legal at namespace scope, and leaving it
# behind is what made recovery invent a stray `public` node.
_CPP_CLI_CLASS_RE = re.compile(
rb"(?:\b(?:public|private|protected)\s+)?\b(?:ref|value)\s+(?=(?:class|struct)\b)"
rb"|(?:\b(?:public|private|protected)\s+)?\binterface\s+(?=class\b)"
)
# Handle (`String^ s`) and tracking-reference (`int% n`) suffixes.
#
# `^` and `%` are also XOR and modulo, and `String^ s` is lexically identical to
# `a^ b` — attachment to the preceding token does not separate them, because
# `a% b` and `hash^ mask` are attached too. Rewriting on attachment alone
# corrupted those into `a b` / `hash mask`. So the rewrite is restricted to
# the two positions where an operator reading is impossible or implausible.
#
# 1. Followed by a token that cannot begin an operand: `f(String^, int)`,
# `List<String^>`, `(String^)x`, `Object^;`. `a^,` is not valid C++, so
# there is no arithmetic to lose here. `*` and `&` are deliberately NOT in
# the set — `a^*p` and `a^&b` are valid XOR expressions, and `String^*` is
# rare enough not to be worth trading for them.
_CPP_CLI_SUFFIX_UNAMBIGUOUS_RE = re.compile(rb"(?<=[A-Za-z0-9_>])[\^%](?=\s*[,)\]>;])")
# 2. A type-shaped left side followed by a declarator: `System::String^ s`,
# `List<int>^ items`, `DataTable^ t`, `int% n`. Qualified names, a closing
# generic bracket, .NET's PascalCase convention and the primitive value
# types are all type positions; requiring one keeps lowercase operands like
# `count% 2` and `hash^ mask` as arithmetic. A capitalized name must also
# carry a lowercase letter, so SCREAMING_CASE constants stay arithmetic
# too (`MASK^ value`); a lone capital is exempt for generic parameters
# (`T^ x`). The type is captured and re-emitted so the substitution stays
# byte-length preserving.
_CPP_CLI_SUFFIX_DECL_RE = re.compile(
rb"(\b[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+" # System::String
rb"|\b[A-Z][A-Za-z0-9_]*[a-z][A-Za-z0-9_]*" # String, DataTable
rb"|\b[A-Z](?=[\^%])" # T
rb"|\b(?:bool|char|wchar_t|short|int|long|float|double|unsigned|signed)"
rb"|>)" # List<int>^
rb"[\^%](?=\s+[A-Za-z_])"
)
# `[assembly:AssemblyVersion("1.0")]` and friends.
_CPP_CLI_ATTR_RE = re.compile(rb"\[\s*(?:assembly|module)\s*:[^\[\]]*\]", re.S)
def _blank_keeping_newlines(m: "re.Match[bytes]") -> bytes:
"""Replace a match with spaces, but keep its line breaks.
Byte length alone is not enough. Both the class-header and the attribute
pattern can span lines — ``[assembly:AssemblyVersion(\\n "1.0"\\n)]`` is
ordinary formatting — and blanking a newline merges two source lines, which
shifts the reported line number of every symbol below it. Preserving CR and
LF in place keeps line and column stable as well as offset.
"""
return re.sub(rb"[^\r\n]", b" ", m.group(0))
def _normalize_cpp_cli(source: bytes) -> bytes | None:
"""Rewrite C++/CLI spellings to standard C++ ones, or None if not C++/CLI.
The rewrite is **byte-length preserving** — dropped tokens are overwritten
with spaces, never deleted, and ``gcnew`` becomes ``new`` plus padding — and
line breaks inside a removed token are kept, so every offset, line and
column still points at the same place in the file on disk and reported
source locations stay accurate (#2876).
"""
if not _CPP_CLI_MARKER_RE.search(source):
return None
out = _CPP_CLI_CLASS_RE.sub(_blank_keeping_newlines, source)
out = re.sub(rb"\bgcnew\b", b"new ", out)
out = _CPP_CLI_SUFFIX_UNAMBIGUOUS_RE.sub(b" ", out)
out = _CPP_CLI_SUFFIX_DECL_RE.sub(rb"\1 ", out)
return _CPP_CLI_ATTR_RE.sub(_blank_keeping_newlines, out)
def extract_cpp(path: Path) -> dict:
"""Extract functions, classes, and includes from a .cpp/.cc/.cxx/.hpp file.
C++/CLI sources are normalized to standard C++ first (#2876); see
:func:`_normalize_cpp_cli`.
Recovers doctest/Catch2 ``TEST_CASE("name")`` test cases that tree-sitter-cpp
drops as ERROR nodes (issue #2594), mirroring the Spock fallback for Groovy.
"""
try:
source = path.read_bytes()
except OSError:
# Let _extract_generic report the read failure in its usual shape.
return _augment_cpp_string_tests(path, _extract_generic(path, _CPP_CONFIG))
result = _extract_generic(
path, _CPP_CONFIG, source_override=_normalize_cpp_cli(source) or source
)
return _augment_cpp_string_tests(path, result)
def extract_ruby(path: Path) -> dict:
"""Extract classes, methods, singleton methods, and calls from a .rb file."""
return _extract_generic(path, _RUBY_CONFIG)
def extract_csharp(path: Path) -> dict:
"""Extract C# type declarations, methods, namespaces, and usings from a .cs file."""
return _extract_generic(path, _CSHARP_CONFIG)
def extract_kotlin(path: Path) -> dict:
"""Extract classes, objects, functions, and imports from a .kt/.kts file."""
return _extract_generic(path, _KOTLIN_CONFIG)
def extract_scala(path: Path) -> dict:
"""Extract classes, objects, functions, and imports from a .scala file."""
return _extract_generic(path, _SCALA_CONFIG)
def extract_php(path: Path) -> dict:
"""Extract classes, functions, methods, namespace uses, and calls from a .php file."""
return _extract_generic(path, _PHP_CONFIG)
# One level of balanced parens (e.g. `Foo #(Bar #(int))`) — bounded so malformed
# input cannot trigger pathological backtracking.
def extract_lua(path: Path) -> dict:
"""Extract functions, methods, require() imports, and calls from a .lua file."""
return _extract_generic(path, _LUA_CONFIG)
def extract_swift(path: Path) -> dict:
"""Extract classes, structs, protocols, functions, imports, and calls from a .swift file."""
return _extract_generic(path, _SWIFT_CONFIG)
# ── Julia extractor (custom walk) ────────────────────────────────────────────
# ── Go extractor (custom walk) ────────────────────────────────────────────────
# ── Rust extractor (custom walk) ──────────────────────────────────────────────
# Common Rust trait/stdlib method names that appear in virtually every codebase.
# Resolving these cross-file produces spurious INFERRED edges across crate
# boundaries (issue #908) — skip them from the unresolved-call queue entirely.
# ── Zig ───────────────────────────────────────────────────────────────────────
# ── PowerShell ────────────────────────────────────────────────────────────────
# ── PowerShell manifest (.psd1) ──────────────────────────────────────────────
# Keys in a .psd1 whose values are module names/paths we treat as imports.
# ── Cross-file import resolution ──────────────────────────────────────────────
def _canonicalize_csharp_namespace_nodes(all_nodes: list[dict], all_edges: list[dict]) -> None:
"""Collapse duplicate C# namespace node entries to one canonical node per label."""
by_label: dict[str, list[dict]] = {}
for node in all_nodes:
if node.get("type") != "namespace":
continue
label = node.get("label")
if isinstance(label, str):
by_label.setdefault(label, []).append(node)
remap: dict[str, str] = {}
drop_node_ids: set[int] = set()
for group in by_label.values():
if len(group) < 2:
continue
canonical = sorted(
group,
key=lambda node: (
str(node.get("source_file") or ""),
str(node.get("source_location") or ""),
str(node.get("id") or ""),
),
)[0]
canonical_id = canonical.get("id")
for node in group:
if node is canonical:
continue
drop_node_ids.add(id(node))
dup_id = node.get("id")
if isinstance(dup_id, str) and isinstance(canonical_id, str):
remap[dup_id] = canonical_id
if remap:
for edge in all_edges:
if edge.get("source") in remap:
edge["source"] = remap[str(edge["source"])]
if edge.get("target") in remap:
edge["target"] = remap[str(edge["target"])]
if drop_node_ids:
all_nodes[:] = [node for node in all_nodes if id(node) not in drop_node_ids]
# Languages whose identifiers are case-insensitive, so cross-file name resolution
# may fold case. Everywhere else, case is semantic (`Path` the class vs `PATH` the
# env var are distinct) and folding manufactures false edges / super-hubs (#1581).
_CASE_INSENSITIVE_EXTS = frozenset({
".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps", # PHP fns/classes
".sql", # SQL identifiers
".nim", ".nims", ".nimble", # Nim (style-insensitive)
})
def _lang_is_case_insensitive(source_file: object) -> bool:
"""True when the file's language resolves identifiers case-insensitively (#1581)."""
if not source_file:
return False
return Path(str(source_file)).suffix.lower() in _CASE_INSENSITIVE_EXTS
# Language interop families for cross-file call resolution. A call in one language
# can never bind by name to a definition in another family — a TSX component does
# not invoke a Kotlin method, and a Python function does not invoke a Java one.
# Families are grouped by REAL interop so legitimate cross-language resolution
# keeps working: Kotlin/Java/Scala/Groovy share the JVM, C/C++/Objective-C/CUDA
# share headers and symbols (Swift bridges to Objective-C), and JS/TS variants
# (plus Vue/Svelte/Astro SFC script blocks) compile into one module graph.
# Extensions absent from this map (docs, configs, unknown languages) resolve to
# no family and are never filtered — same permissive default as before.
_LANG_FAMILY_BY_EXT: dict[str, str] = {
# JS/TS module graph (SFCs embed JS/TS)
".js": "jsts", ".jsx": "jsts", ".mjs": "jsts", ".cjs": "jsts",
".ts": "jsts", ".tsx": "jsts", ".mts": "jsts", ".cts": "jsts",
".vue": "jsts", ".svelte": "jsts", ".astro": "jsts",
# JVM interop
".java": "jvm", ".kt": "jvm", ".kts": "jvm",
".scala": "jvm", ".groovy": "jvm", ".gradle": "jvm",
# C-family: shared headers, Objective-C/C++ mix, Swift↔ObjC bridging
".c": "native", ".h": "native", ".cpp": "native", ".cc": "native",
".cxx": "native", ".hpp": "native", ".cu": "native", ".cuh": "native",
".metal": "native", ".m": "native", ".mm": "native", ".swift": "native",
# Single-language families
".py": "python",
".go": "go",
".rs": "rust",
".rb": "ruby", ".rake": "ruby",
".php": "php", ".phtml": "php", ".php3": "php", ".php4": "php",
".php5": "php", ".php7": "php", ".phps": "php",
".cs": "dotnet", ".razor": "dotnet", ".cshtml": "dotnet", ".xaml": "dotnet",
".lua": "lua", ".luau": "lua",
".zig": "zig",
".ex": "elixir", ".exs": "elixir",
".jl": "julia",
".dart": "dart",
".sh": "shell", ".bash": "shell",
".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell",
}
def _lang_family(source_file: object) -> str | None:
"""Interop family of the file's language, or None when unknown/not code."""
if not source_file:
return None
return _LANG_FAMILY_BY_EXT.get(Path(str(source_file)).suffix.lower())
def _node_label_key(node: dict, fold: bool = False) -> str:
label = str(node.get("label", "")).strip()
key = re.sub(r"[^a-zA-Z0-9]+", "", label)
return key.lower() if fold else key
def _is_top_level_function_definition(node: dict) -> bool:
"""A free/top-level function def (label ``name()``), not a method or type.
Methods carry a leading dot (``.foo()``) or a qualifier (``Class.foo()``);
excluding those keeps a bare-name reference from binding to a receiver-scoped
method, which the receiver-typed resolvers own (#1781).
"""
label = str(node.get("label", "")).strip()
return (
node.get("file_type") == "code"
and label.endswith(")")
and not label.startswith(".")
and "." not in label
)
def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None:
"""Map unresolved no-source stubs to a unique real definition with the same label."""
real_by_label: dict[str, list[dict]] = {} # exact-case type-like (all languages)
real_by_label_ci: dict[str, list[dict]] = {} # case-INSENSITIVE-language reals only
func_by_label: dict[str, list[dict]] = {} # top-level function defs (#1781)
stubs: list[dict] = []
for node in nodes:
key = _node_label_key(node)
if not key:
continue
if node.get("source_file"):
if _is_type_like_definition(node):
# Match stubs case-SENSITIVELY: a `Path` reference must not rewire to a
# `PATH` env var (#1581). Fold only for genuinely case-insensitive
# languages, where `foo` legitimately resolves to `Foo`.
real_by_label.setdefault(key, []).append(node)
if _lang_is_case_insensitive(node.get("source_file")):
real_by_label_ci.setdefault(
_node_label_key(node, fold=True), []).append(node)
elif _is_top_level_function_definition(node):
func_by_label.setdefault(key, []).append(node)
continue
stubs.append(node)
# Language families referencing each stub, for the function-merge guard (#1781):
# a cross-module `references` edge to a function used to dangle on a sourceless
# name-only stub because functions were excluded as rewire targets. We now allow
# a UNIQUE function definition to absorb it, but only when it shares a language
# family with the stub's referrers — so a Python `get_db` reference can't bind to
# a unique Go `get_db()` (mirrors the #1718/#1749 interop guard).
stub_ids = {str(s.get("id")) for s in stubs if s.get("id")}
stub_families: dict[str, set] = {}
supertype_stub_ids: set[str] = set() # stubs used as a base type — never a function
_SUPERTYPE_RELATIONS = {"inherits", "implements", "extends"}
for edge in edges:
rel = edge.get("relation")
for endpoint in ("source", "target"):
nid = edge.get(endpoint)
if nid in stub_ids:
fam = _lang_family(edge.get("source_file"))
if fam is not None:
stub_families.setdefault(str(nid), set()).add(fam)
# A stub referenced as a supertype must resolve to a class/type,
# not a same-named function (you don't inherit from a function).
if endpoint == "target" and rel in _SUPERTYPE_RELATIONS:
supertype_stub_ids.add(str(nid))
remap: dict[str, str] = {}
for stub in stubs:
stub_id = str(stub.get("id", ""))
if not stub_id:
continue
candidates = real_by_label.get(_node_label_key(stub), [])
if len(candidates) != 1:
# No unique exact type match — fall back to a case-insensitive match, but
# only against case-insensitive-language definitions (so a case-sensitive
# `PATH` can never absorb a `Path` reference).
candidates = real_by_label_ci.get(_node_label_key(stub, fold=True), [])
if len(candidates) != 1:
# #1781: no unique type — try a unique top-level FUNCTION definition,
# gated by (a) the stub not being used as a supertype and (b) a
# language-family match with the stub's referrers.
fcands = func_by_label.get(_node_label_key(stub), [])
if len(fcands) == 1 and stub_id not in supertype_stub_ids:
fams = stub_families.get(stub_id, set())
cand_fam = _lang_family(fcands[0].get("source_file"))
if not fams or cand_fam is None or cand_fam in fams:
candidates = fcands
if len(candidates) != 1:
continue
target_id = candidates[0].get("id")
if isinstance(target_id, str) and target_id and target_id != stub_id:
remap[stub_id] = target_id
if not remap:
return
by_id = {node.get("id"): node for node in nodes if node.get("id")}
csharp_scoped_relations = {"inherits", "implements", "references", "imports"}
for edge in edges:
is_csharp_scoped_edge = (
str(edge.get("source_file", "")).endswith(".cs")
and edge.get("relation") in csharp_scoped_relations
)
source = edge.get("source")
if source in remap:
remapped_source = remap[str(source)]
if not (
is_csharp_scoped_edge
and str(by_id.get(remapped_source, {}).get("source_file", "")).endswith(".cs")
):
edge["source"] = remapped_source
target = edge.get("target")
if target in remap:
remapped_target = remap[str(target)]
if not (
is_csharp_scoped_edge
and str(by_id.get(remapped_target, {}).get("source_file", "")).endswith(".cs")
):
edge["target"] = remapped_target
referenced = {x for e in edges for x in (e.get("source"), e.get("target"))}
drop_ids = {stub_id for stub_id in remap if stub_id not in referenced}
nodes[:] = [node for node in nodes if node.get("id") not in drop_ids]
def _augment_js_reexport_edges(
paths: list[Path],
nodes: list[dict],
edges: list[dict],
root: Path,
) -> None:
"""Compatibility wrapper for the JS/TS symbol-resolution post-pass."""
facts = _SymbolResolutionFacts()
_collect_js_symbol_resolution_facts(paths, facts)
_apply_symbol_resolution_facts(paths, nodes, edges, root, facts)
# Header / implementation file-extension pairing for the decl/def class merge.
def _merge_swift_extensions(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Collapse cross-file Swift `extension Foo` nodes into the canonical `Foo`.
tree-sitter-swift reuses `class_declaration` for both `class Foo` and
`extension Foo`, and node ids carry the file stem, so each file that
extends `Foo` produces its own `Foo` node. The match is done by label:
when exactly one non-extension declaration shares the label, extension
nodes redirect onto it. Extensions of types outside the corpus (no match)
and ambiguous labels (more than one match) are left untouched — picking
arbitrarily would invent edges.
"""
extension_nids: set[str] = set()
extension_labels: dict[str, str] = {}
for result in per_file:
for ext in result.get("swift_extensions", []) or []:
extension_nids.add(ext["nid"])
extension_labels[ext["nid"]] = ext["label"]
if not extension_nids:
return
# A genuine Swift type is the target of a `contains` edge from its file node;
# bare-reference shadow nodes (`let x: Foo`) carry a source_file but are NOT
# contained, so excluding them keeps a stub from making a real type look
# ambiguous — same predicate the Swift member-call resolver uses (#2538).
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
label_to_canonical: dict[str, list[str]] = {}
for n in all_nodes:
if n.get("id") in extension_nids:
continue
label = n.get("label")
if not label:
continue
# The merge matches on label alone, so without a language gate
# `extension Data` / `extension Store` — idiomatic Swift — would absorb a
# same-named TypeScript or Python class in a polyglot repo and invent
# cross-language edges. Restrict candidates to Swift's own family, which
# keeps the intended Swift↔Objective-C folding, and skip builtin globals
# the way the member-call resolvers do (#1726, #2147).
if _lang_family(n.get("source_file")) != "native":
continue
if label in _LANGUAGE_BUILTIN_GLOBALS:
continue
if not (n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n)):
continue
label_to_canonical.setdefault(label, []).append(n["id"])
remap: dict[str, str] = {}
for ext_nid in extension_nids:
candidates = label_to_canonical.get(extension_labels[ext_nid], [])
if len(candidates) != 1:
continue
canonical_nid = candidates[0]
if canonical_nid != ext_nid:
remap[ext_nid] = canonical_nid
if not remap:
return
all_nodes[:] = [n for n in all_nodes if n.get("id") not in remap]
# Each extension file's `contains` edge ends up pointing at the canonical
# type — multiple files containing the same node is the intended shape:
# the type owns the methods, the files own their slice. Self-loops are
# dropped (e.g. an in-file extension method whose call already pointed at
# the canonical type).
def _key_of(e: dict, src: str, tgt: str) -> tuple:
return (src, tgt, e.get("relation"), e.get("source_file"), e.get("source_location"))
rewritten: list[dict] = []
seen_keys: set[tuple] = set()
for e in all_edges:
src0, tgt0 = e.get("source"), e.get("target")
src = remap.get(src0, src0)
tgt = remap.get(tgt0, tgt0)
if src == src0 and tgt == tgt0:
# Untouched by the merge — keep verbatim. The key below ignores
# confidence/weight/context, so deduping edges this pass never
# rewrote prunes legitimate parallel edges emitted elsewhere in the
# pipeline; one Swift extension in a polyglot repo was enough to
# silently drop unrelated edges from other languages (#2538).
seen_keys.add(_key_of(e, src0, tgt0))
rewritten.append(e)
continue
if src == tgt:
continue
e["source"] = src
e["target"] = tgt
key = _key_of(e, src, tgt)
if key in seen_keys:
continue
seen_keys.add(key)
rewritten.append(e)
all_edges[:] = rewritten
def _merge_csharp_partial_class_nodes(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
paths: list[Path],
root: Path,
) -> None:
"""Collapse C# `partial class Foo` halves split across files into ONE node
(#2332), without crossing assembly boundaries (#2411).
The per-file extractor mints class ids with the file stem, so each file
declaring `partial class Foo` produces its own `Foo` node: members split
across the halves and cross-half calls don't resolve (two candidate types
make every receiver-typed lookup bail as ambiguous). Group partial-stamped
type nodes by (assembly, namespace, label) — same-named types in different
namespaces are distinct types, non-partial same-named types are separate
declarations, and nested partials are excluded (their ids omit the
enclosing type, so a same-named nested pair under different outers would
falsely merge). The `partial` keyword only fuses declarations compiled into
the SAME assembly, so the key also carries the nearest ancestor directory
holding a `*.csproj`/`*.fsproj`/`*.vbproj` — same-named halves under
different project dirs are genuinely distinct types and stay apart. Halves
with NO project file on any ancestor (up to the scan root) all key to ""
and still merge together, so single-project/snippet corpora behave exactly
as before; the probe runs only for groups that are otherwise ambiguous.
The canonical node is the sorted-first half by (source_file,
source_location, id); every edge endpoint and raw-call caller is remapped
onto it. Member node ids are left untouched — only the class-level nodes
collapse.
Must run BEFORE _disambiguate_colliding_node_ids / _rewire_unique_stub_nodes /
_resolve_csharp_type_references and the resolver registry, so every later
pass sees one definition per partial type.
"""
groups: dict[tuple[str, str], list[dict]] = {}
for n in all_nodes:
if not str(n.get("source_file", "")).endswith(".cs"):
continue
if n.get("file_type") != "code":
continue
md = n.get("metadata") or {}
if not md.get("is_partial") or md.get("is_nested_type"):
continue
label = n.get("label")
if not label:
continue
groups.setdefault((str(md.get("namespace", "")), str(label)), []).append(n)
if not any(len(members) >= 2 for members in groups.values()):
return
# Assembly probe (#2411). A node's `source_file` can be a bare filename at
# this point (ambiguous across project dirs), so map nid -> scanned path
# via per_file, which aligns 1:1 with `paths`.
nid_to_path: dict[str, Path] = {}
for result, path in zip(per_file, paths):
for pn in result.get("nodes") or []:
nid_to_path.setdefault(pn["id"], path)
proj_exts = (".csproj", ".fsproj", ".vbproj")
project_dirs: set[Path] = set()
for p in paths:
if p.suffix.lower() in proj_exts:
try:
project_dirs.add(p.resolve().parent)
except OSError:
pass
try:
stop = root.resolve()
except OSError:
stop = root
dir_assembly: dict[Path, str] = {}
def _assembly_of_dir(d: Path) -> str:
"""Nearest ancestor dir (self included) holding a project file, "" if
none up to the scan root; memoized along the walked chain."""
chain: list[Path] = []
key = ""
while True:
cached = dir_assembly.get(d)
if cached is not None:
key = cached
break
chain.append(d)
if d in project_dirs:
key = str(d)
break
try:
has_project = any(
c.suffix.lower() in proj_exts for c in d.iterdir()
)
except OSError:
has_project = False
if has_project:
key = str(d)
break
if d == stop or d.parent == d:
break
d = d.parent
for c in chain:
dir_assembly[c] = key
return key
def _assembly_of_node(nid: str) -> str:
path = nid_to_path.get(nid)
if path is None:
return ""
try:
d = path.resolve().parent
except OSError:
return ""
return _assembly_of_dir(d)
remap: dict[str, str] = {}
for members in groups.values():
if len(members) < 2:
continue
by_assembly: dict[str, list[dict]] = {}
for n in members:
by_assembly.setdefault(_assembly_of_node(n["id"]), []).append(n)
for halves in by_assembly.values():
if len(halves) < 2:
continue
halves.sort(key=lambda n: (
str(n.get("source_file", "")),
str(n.get("source_location", "")),
str(n.get("id", "")),
))
canonical_nid = halves[0]["id"]
for other in halves[1:]:
if other["id"] != canonical_nid:
remap[other["id"]] = canonical_nid
if not remap:
return
all_nodes[:] = [n for n in all_nodes if n.get("id") not in remap]
# Each half's file keeps a `contains` edge to the canonical type — multiple
# files containing one node is the intended shape (same as the Swift
# extension merge): the type owns the members, the files own their slice.
# Self-loops are dropped, exact duplicates dedup.
rewritten: list[dict] = []
seen_keys: set[tuple] = set()
for e in all_edges:
src = remap.get(e.get("source"), e.get("source"))
tgt = remap.get(e.get("target"), e.get("target"))
if src == tgt:
continue
e["source"] = src
e["target"] = tgt
key = (src, tgt, e.get("relation"), e.get("source_file"), e.get("source_location"))
if key in seen_keys:
continue
seen_keys.add(key)
rewritten.append(e)
all_edges[:] = rewritten
# raw_calls carry caller_nid, consumed by the member-call resolvers and the
# cross-file call pass after this merge — a top-level raw call whose caller
# is a merged-away class half must follow it onto the canonical node.
for result in per_file:
for rc in result.get("raw_calls", []) or []:
cn = rc.get("caller_nid")
if cn in remap:
rc["caller_nid"] = remap[cn]
def _resolve_swift_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve cross-file Swift member calls (``recv.method()``) to the real
definition of the receiver's type (#1356).
The shared cross-file call pass drops every ``is_member_call`` because a bare
method name (``update``) collides across the corpus and inflates god-nodes
(#543/#1219). Swift extractors record the receiver of each member call and a
per-file ``name -> type`` table (``swift_type_table``); this pass uses them to
type the receiver, then emits an edge ONLY when that type name resolves to
exactly one definition. A type-qualified call (``Type.staticMethod()``) is
EXTRACTED (the type is named explicitly in source); an instance call typed via
local inference (``obj.method()``) is INFERRED. The shared-pass member-call drop
stays intact: this is purely additive and fires only on receiver-typed Swift calls.
Must run after id-disambiguation so node ids and caller_nids are final.
"""
type_table_by_file: dict[str, dict[str, str]] = {}
for result in per_file:
tt = result.get("swift_type_table")
if tt and tt.get("path"):
type_table_by_file[tt["path"]] = tt.get("table", {})
if not type_table_by_file:
return
def _key(label: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
# A genuine Swift type is the target of a `contains` edge from its file node.
# Bare type references create a same-label shadow node (via ensure_named_node)
# that carries a source_file but is NOT contained; excluding non-contained
# nodes keeps that shadow from making a real type name look ambiguous.
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
# Type name -> definition node ids (real, source-backed, type-like defs only).
# len != 1 is the god-node guard: an ambiguous type name bails.
type_def_nids: dict[str, list[str]] = {}
node_by_id: dict[str, dict] = {}
for n in all_nodes:
node_by_id[n.get("id")] = n
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
# (type_node_id, method_key) -> method_node_id, from `method` edges.
method_index: dict[tuple[str, str], str] = {}
for e in all_edges:
if e.get("relation") != "method":
continue
src, tgt = e.get("source"), e.get("target")
tnode = node_by_id.get(tgt)
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", []))
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
for rc in all_raw_calls:
if not rc.get("is_member_call"):
continue
receiver = rc.get("receiver")
callee = rc.get("callee")
if not receiver or not callee:
continue
# Determine the receiver's type. An upper-cased receiver is itself a type
# (Type.staticMethod(), Singleton.shared.x()); otherwise look it up in the
# declaring file's local type table.
if receiver[:1].isupper():
type_name = receiver
type_qualified = True
else:
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
type_qualified = False
if not type_name:
continue
# A builtin receiver type (Data, NSLock, DispatchQueue, ...) must not
# resolve to a same-named user symbol — the cross-file CALL resolver and
# the TS/Python member-call resolvers already skip these globals (#1726);
# do the same for Swift (#2147).
if type_name in _LANGUAGE_BUILTIN_GLOBALS:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
continue
type_nid = type_defs[0]
caller = rc.get("caller_nid")
if not caller:
continue
method_nid = method_index.get((type_nid, _key(callee)))
target = method_nid or type_nid
relation = "calls" if method_nid else "references"
if target == caller or (caller, target) in existing_pairs:
continue
existing_pairs.add((caller, target))
# A type-qualified call (`Type.staticMethod()`) names the receiver type
# explicitly in source, so it is an exact reference — EXTRACTED, matching
# the Python qualified-class-method pass (#1533). An instance call whose
# receiver type came from local inference (`obj.method()`) stays INFERRED.
all_edges.append({
"source": caller,
"target": target,
"relation": relation,
"context": "call",
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
"confidence_score": 1.0 if type_qualified else 0.8,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
def _resolve_python_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve cross-file Python qualified class-method calls (``ClassName.method()``)
to the class-qualified method node (#1446).
The shared cross-file call pass drops every ``is_member_call`` because a bare
method name (``log``) collides across the corpus and inflates god-nodes
(#543/#1219). That guard is right for *instance* calls (``obj.method()``) but
misses *class-qualified* calls (``ClassName.method()``), where the receiver is
an explicitly-named class — an exact, unambiguous reference. This pass uses the
receiver captured by the extractor, and when it is a capitalized name resolving
to exactly one class node that owns the called method, emits an EXTRACTED
``calls`` edge. Purely additive (only member calls the shared pass skipped),
with a single-definition god-node guard.
Must run after id-disambiguation so node ids and caller_nids are final.
"""
def _key(label: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes}
# A class owns methods: it is the source of one or more `method` edges. Index
# class label -> owning class node ids (len != 1 is the god-node guard), and
# (class_node_id, method_key) -> method_node_id.
class_def_nids: dict[str, list[str]] = {}
method_index: dict[tuple[str, str], str] = {}
for e in all_edges:
if e.get("relation") != "method":
continue
src, tgt = e.get("source"), e.get("target")
cnode = node_by_id.get(src)
if cnode is not None:
class_def_nids.setdefault(_key(cnode.get("label", "")), []).append(src)
tnode = node_by_id.get(tgt)
if tnode is not None:
method_index[(src, _key(tnode.get("label", "")))] = tgt
# A class with N methods produced N entries; collapse to a unique set. (No
# early return when there are no classes: the module arm below resolves
# `module.func()` where the callable is a plain function, not a method.)
for k in list(class_def_nids):
class_def_nids[k] = sorted(set(class_def_nids[k]))
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
# Module-alias arm index (#1883): `module.func()` where `module` is imported.
# Key on stable node ids, not source_file strings (source_file is relativized
# by the CLI id-remap pass but raw_calls keep their original path, so a string
# join would miss under an explicit cache_root). The `imports` edge's source
# is the caller's own file node; `contains` maps a file node to its children.
contains_children: dict[str, dict[str, list[str]]] = {}
file_of_node: dict[str, str] = {}
for e in all_edges:
if e.get("relation") == "contains":
src, tgt = e.get("source"), e.get("target")
tnode = node_by_id.get(tgt)
if tnode is not None:
contains_children.setdefault(src, {}).setdefault(
_key(tnode.get("label", "")), []).append(tgt)
file_of_node[tgt] = src
imported_by_filenode: dict[str, set[str]] = {}
# Local alias bound by `as` on a specific import edge (#2082): `from pkg import
# mod as alias` / `import pkg.mod as alias` bind `alias`, not `mod`'s own stem,
# to the module in the importing file. Keyed by (importing file, target module)
# so two files aliasing the same module differently each match their own.
import_alias_by_filenode: dict[str, dict[str, str]] = {}
for e in all_edges:
if e.get("relation") in ("imports", "imports_from"):
imported_by_filenode.setdefault(e.get("source"), set()).add(e.get("target"))
alias = e.get("local_alias")
if alias:
import_alias_by_filenode.setdefault(e.get("source"), {})[e.get("target")] = _key(alias)
def _module_stem_key(nid: str) -> str:
n = node_by_id.get(nid)
if not n:
return ""
sf = n.get("source_file") or ""
stem = Path(sf).stem if sf else ""
return _key(stem or n.get("label", ""))
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
def _emit_call(caller: str, target_nid: "str | None", rc: dict) -> None:
if not target_nid or target_nid == caller or (caller, target_nid) in existing_pairs:
return
existing_pairs.add((caller, target_nid))
# EXTRACTED: a qualified call (`ClassName.method()` or `module.func()`) is
# an explicit, unambiguous static reference resolved to exactly one
# definition (each arm applies a single-definition god-node guard).
all_edges.append({
"source": caller,
"target": target_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
for rc in all_raw_calls:
if not rc.get("is_member_call"):
continue
receiver = rc.get("receiver")
callee = rc.get("callee")
caller = rc.get("caller_nid")
if not receiver or not callee or not caller:
continue
if receiver[:1].isupper():
# Class arm (#1446): a capitalized receiver is a class reference; an
# instance (`self`, `obj`) never collides with a same-spelled class.
class_nids = class_def_nids.get(_key(receiver), [])
if len(class_nids) != 1: # absent or ambiguous -> bail (god-node guard)
continue
_emit_call(caller, method_index.get((class_nids[0], _key(callee))), rc)
else:
# Module arm (#1883): a lowercase receiver may be an imported module.
# Resolve it against the modules imported into the caller's own file
# (so `self`/`obj`/local instances, which are not imported modules,
# never match), then to the single callable that module contains. A
# receiver also matches the local alias bound on that import edge
# (#2082), so an aliased import resolves the same as the bare name.
rkey = _key(receiver)
caller_file = file_of_node.get(caller)
file_aliases = import_alias_by_filenode.get(caller_file, {})
mods = [t for t in imported_by_filenode.get(caller_file, ())
if t in contains_children
and (_module_stem_key(t) == rkey or file_aliases.get(t) == rkey)]
if len(mods) != 1: # not an imported module, or ambiguous -> bail
continue
children = contains_children[mods[0]].get(_key(callee), [])
if len(children) != 1: # absent or ambiguous callable -> bail
continue
_emit_call(caller, children[0], rc)
def _resolve_typescript_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve cross-file TS/JS member calls via constructor-injection type tables (#1316).
``this.repo.findById()`` drops out in the shared cross-file pass because bare
``findById`` collides across the corpus (god-node guard). TS constructors with
parameter-property modifiers (``private repo: IUserRepository``) produce a
per-file type table mapping field names to their declared types. This pass
looks up the receiver field's type, finds a single-definition class/interface
owning a method with the callee name, and emits a ``calls`` edge — EXTRACTED
when the receiver names the type in source (``Type.method()``), INFERRED when
the type came from the table (the Swift/C#/Java tiering).
Origin gate (#2553): a name-only match is not evidence the caller can even
see the matched type. ``import type { Repo } from 'external-pkg'`` plus
``this.repo.save()`` must not fabricate an edge to an unrelated local
``class Repo`` in another file. The matched type must be origin-verified:
defined in the caller's own file, a named import of the caller's file, or
contained in a module the caller's file imports. Otherwise EMIT NOTHING —
a false call edge is worse than a missing one (the C++ resolver's bar).
"""
type_table_by_file: dict[str, dict[str, str]] = {}
for result in per_file:
tt = result.get("ts_type_table")
if tt and tt.get("path"):
type_table_by_file[tt["path"]] = tt.get("table", {})
if not type_table_by_file:
return
def _key(label: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
type_def_nids: dict[str, list[str]] = {}
node_by_id: dict[str, dict] = {}
for n in all_nodes:
node_by_id[n.get("id")] = n
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
method_index: dict[tuple[str, str], str] = {}
for e in all_edges:
if e.get("relation") != "method":
continue
src, tgt = e.get("source"), e.get("target")
tnode = node_by_id.get(tgt)
if tnode is not None:
method_index[(src, _key(tnode.get("label", "")))] = tgt
# Origin maps (#2553), built like the Python resolver's module arm: key on
# stable NODE ids, not source_file strings (raw_calls keep their original
# pre-relativization paths, so a string join would miss under an explicit
# cache_root). ``contains`` maps a node to its file node; a member call's
# caller is usually a METHOD node, which hangs off its class via a ``method``
# edge instead, so fold those through to the owning class's file.
file_of_node: dict[str, str] = {}
for e in all_edges:
if e.get("relation") == "contains":
file_of_node[e.get("target")] = e.get("source")
for e in all_edges:
if e.get("relation") == "method":
owner_file = file_of_node.get(e.get("source"))
if owner_file is not None:
file_of_node.setdefault(e.get("target"), owner_file)
# ``imports`` targets are the imported symbol nodes; ``imports_from`` targets
# are module file nodes. A symbol id never collides with a file id, so one
# set serves both origin checks below.
imported_by_filenode: dict[str, set[str]] = {}
for e in all_edges:
if e.get("relation") in ("imports", "imports_from"):
imported_by_filenode.setdefault(e.get("source"), set()).add(e.get("target"))
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
for rc in all_raw_calls:
if not rc.get("is_member_call"):
continue
receiver = rc.get("receiver")
callee = rc.get("callee")
caller = rc.get("caller_nid")
if not receiver or not callee or not caller:
continue
if receiver[:1].isupper():
type_name = receiver
type_qualified = True # the receiver names the type in source
else:
type_qualified = False
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
if not type_name:
continue
# A builtin global receiver type (Date, Promise, Map, ...) must not resolve
# to a user symbol. _key() casefolds, so `x: Date; x.getTime()` would bind
# the caller to a same-named user `class DATE` in another file, inventing
# phantom `references[call]` edges and a false god node (#1726). The
# cross-file CALL resolver already skips these globals; do the same here.
if type_name in _LANGUAGE_BUILTIN_GLOBALS:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1:
continue
type_nid = type_defs[0]
# Origin gate (#2553): the caller's file must actually see the matched
# type — same file, a named import of the type, or a module import of
# the type's file. Otherwise a third-party type name that happens to
# collide with a local class fabricates an edge; emit nothing.
caller_file = file_of_node.get(caller)
type_file = file_of_node.get(type_nid)
imported = imported_by_filenode.get(caller_file, set())
if not (
(caller_file is not None and caller_file == type_file)
or type_nid in imported
or (type_file is not None and type_file in imported)
):
continue
method_nid = method_index.get((type_nid, _key(callee)))
if not method_nid:
# Receiver typed, but the type has no such method. The old fallback
# (a `references` edge to the type node) was another fabrication
# vector; skip instead, matching the C# resolver.
continue
if method_nid == caller or (caller, method_nid) in existing_pairs:
continue
existing_pairs.add((caller, method_nid))
# `Type.method()` names the receiver type explicitly in source —
# EXTRACTED; a receiver typed via the constructor-injection/local table
# is inference — INFERRED (the Swift/C#/Java ternary).
all_edges.append({
"source": caller,
"target": method_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
"confidence_score": 1.0 if type_qualified else 0.8,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
def _resolve_cpp_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve cross-file C++ member calls (``f.bar()``, ``f->bar()``,
``Foo::bar()``, ``this->bar()``) to the real definition of the receiver's type
(#1547).
The shared cross-file pass drops every ``is_member_call`` because a bare method
name (``bar``) collides across the corpus and inflates god-nodes (#543/#1219).
The C++ extractor records each member call's receiver and a per-file
``var -> ClassName`` table (``cpp_type_table``) built from local declarations.
This pass types the receiver, then emits an edge ONLY when that type resolves
to exactly ONE definition (the god-node guard).
Receiver typing, by precision tier:
* ``Foo::bar()`` — the scope ``Foo`` names the type explicitly -> EXTRACTED.
* ``this->bar()`` — the receiver is the caller's own enclosing class -> EXTRACTED.
* ``f.bar()`` / ``f->bar()`` — ``f`` typed via the file's local table -> INFERRED.
A receiver whose type can't be inferred locally is SKIPPED (no guess): a false
call edge is worse than a missing one. The ``_merge_decl_def_classes`` pass has
already folded each header/impl class pair into one node, so a paired class is a
single definition and clears the single-definition guard.
Must run after id-disambiguation so node ids and caller_nids are final.
"""
type_table_by_file: dict[str, dict[str, str]] = {}
for result in per_file:
tt = result.get("cpp_type_table")
if tt and tt.get("path"):
type_table_by_file[tt["path"]] = tt.get("table", {})
def _key(label: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
# A genuine C++ type is the target of a `contains` edge from its file node;
# bare-reference shadow nodes (ensure_named_node stubs) are not contained, so
# excluding non-contained nodes keeps them from making a real type ambiguous.
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
type_def_nids: dict[str, list[str]] = {}
node_by_id: dict[str, dict] = {}
for n in all_nodes:
node_by_id[n.get("id")] = n
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
# (type_node_id, method_key) -> method_node_id, and caller -> enclosing type
# (the owning class) for `this->` calls. A C++ class owns its members via
# `method` edges (out-of-line definitions) AND `defines` edges (in-class
# declarations, which the extractor models as fields); index both so a header-
# declared `void bar();` resolves. `method` wins when a key has both.
method_index: dict[tuple[str, str], str] = {}
enclosing_type: dict[str, str] = {}
for rel in ("defines", "method"):
for e in all_edges:
if e.get("relation") != rel:
continue
src, tgt = e.get("source"), e.get("target")
tnode = node_by_id.get(tgt)
if tnode is None:
continue
enclosing_type.setdefault(tgt, src)
method_index[(src, _key(tnode.get("label", "")))] = tgt
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
for rc in all_raw_calls:
if not rc.get("is_member_call"):
continue
receiver = rc.get("receiver")
callee = rc.get("callee")
caller = rc.get("caller_nid")
if not receiver or not callee or not caller:
continue
src_file = rc.get("source_file", "")
# Only resolve C++ raw_calls (other languages share the raw_calls list;
# a `.h` may route to either extract_cpp or extract_objc by content, so the
# extractor-stamped `lang` tag — not the suffix — is the unambiguous gate).
if rc.get("lang") != "cpp":
continue
# Determine the receiver's type and the resulting confidence.
if receiver == "this":
# this->bar(): receiver is the caller's own enclosing class.
type_nid = enclosing_type.get(caller)
if not type_nid:
continue
type_qualified = True
elif receiver[:1].isupper():
# Foo::bar(): the type is named explicitly in source.
type_defs = type_def_nids.get(_key(receiver), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
continue
type_nid = type_defs[0]
type_qualified = True
else:
# f.bar() / f->bar(): type the receiver via the file's local table.
type_name = type_table_by_file.get(src_file, {}).get(receiver)
if not type_name:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
continue
type_nid = type_defs[0]
type_qualified = False
method_nid = method_index.get((type_nid, _key(callee)))
target = method_nid or type_nid
relation = "calls" if method_nid else "references"
if target == caller or (caller, target) in existing_pairs:
continue
existing_pairs.add((caller, target))
all_edges.append({
"source": caller,
"target": target,
"relation": relation,
"context": "call",
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
"confidence_score": 1.0 if type_qualified else 0.8,
"source_file": src_file,
"source_location": rc.get("source_location"),
"weight": 1.0,
})
def _resolve_csharp_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve C# member calls (``recv.Method()``) to the receiver's declared type
(#1609), namespace-aware (#1620).
The shared cross-file pass drops every ``is_member_call`` because a bare method
name collides across the corpus — and for C# an in-file bare match silently
mis-bound ``_server.Save()`` to an unrelated ``Cache.Save()``. The C# extractor
records each member call's receiver and stamps ``receiver_type`` on the raw
call from a METHOD-scoped ``name -> Type`` table of class fields/properties
plus the declaring method's params/locals (#2299 — per-method like Java, so a
name rebound in a different method never poisons this one; same-method
conflicts and untypable rebindings are still POISONED, so a shadowing local of
a different type produces no edge rather than a wrong one). This pass resolves
the stamped type name with the same namespace/using/alias scoping machinery the
type-reference pass uses (``CsharpNameResolver``), so a class name duplicated
across namespaces still binds to the one in scope; only when scoping knows
nothing about the name does it fall back to the corpus-wide unique bare-name
match (the god-node guard). An untypable/ambiguous receiver is skipped — never
a guess.
Receiver typing, by precision tier:
* ``this.M()`` — receiver is the caller's own enclosing class -> EXTRACTED.
* ``base.M()`` — the caller's single resolvable base class -> EXTRACTED.
* ``Type.M()`` (capitalized) — the type is named explicitly in source -> EXTRACTED.
* ``recv.M()`` / ``this.recv.M()`` — ``recv`` typed via the extractor's
method-scoped field/property/param/local table (``receiver_type`` on the
raw call) -> INFERRED.
A method not declared on the receiver's type is looked up through its
``inherits`` chain; a chain containing an unresolvable (out-of-corpus) base
poisons the lookup — the method may live there, so no edge is emitted.
Must run after id-disambiguation so node ids and caller_nids are final.
"""
def _key(label: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
type_def_nids: dict[str, list[str]] = {}
node_by_id: dict[str, dict] = {}
for n in all_nodes:
node_by_id[n.get("id")] = n
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
# Namespace/using/alias-aware simple-name resolution, shared with the C#
# type-reference pass (which has already arbitrated inherits/implements/
# references targets by the time the resolver registry runs).
resolver = CsharpNameResolver(all_nodes, all_edges)
# (type_node_id, method_key) -> method_node_id, and caller -> enclosing type.
# C# owns its methods via `method` edges.
method_index: dict[tuple[str, str], str] = {}
enclosing_type: dict[str, str] = {}
for e in all_edges:
if e.get("relation") != "method":
continue
src, tgt = e.get("source"), e.get("target")
tnode = node_by_id.get(tgt)
if tnode is None:
continue
enclosing_type.setdefault(tgt, src)
method_index[(src, _key(tnode.get("label", "")))] = tgt
# Base-class chain from `inherits` edges (C# files only). The type-reference
# pass has already re-pointed each resolvable base to its real definition and
# left unresolvable ones on dangling sourceless stubs — a stub target marks
# the derived type's base chain as UNRESOLVED (poison: an inherited-member
# lookup through it must bail, the member may be declared out of corpus).
bases_of: dict[str, list[str]] = {}
unresolved_base: set[str] = set()
for e in all_edges:
if e.get("relation") != "inherits":
continue
src_file = e.get("source_file")
if not (isinstance(src_file, str) and src_file.endswith(".cs")):
continue
src, tgt = e.get("source"), e.get("target")
if not (isinstance(src, str) and isinstance(tgt, str)):
continue
tnode = node_by_id.get(tgt)
if tnode is None or not tnode.get("source_file"):
unresolved_base.add(src)
else:
bucket = bases_of.setdefault(src, [])
if tgt not in bucket:
bucket.append(tgt)
def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None:
"""The method's definition on the type or its resolvable base chain.
A type that declares the method directly wins (overrides shadow the
base). Otherwise walk `inherits` upward; an unresolved base anywhere the
walk actually reaches poisons the lookup (no edge), as does anything
other than exactly one declaration found.
"""
hits: set[str] = set()
seen: set[str] = set()
frontier = [type_nid]
while frontier:
nid = frontier.pop()
if nid in seen:
continue
seen.add(nid)
method_nid = method_index.get((nid, callee_key))
if method_nid:
hits.add(method_nid)
continue # an override shadows anything above it
if nid in unresolved_base:
return None # the method may live on the out-of-corpus base
frontier.extend(bases_of.get(nid, []))
return next(iter(hits)) if len(hits) == 1 else None
def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
src_file: str) -> str | None:
"""Resolve a declared type name to exactly one definition node id.
Namespace/using/alias scoping first (so `Svc` duplicated across
namespaces binds to the one in scope); when scoping is decisive but
ambiguous, bail. Only when scoping knows nothing about the name fall
back to the corpus-wide unique bare-name match (which also covers
nested types, absent from the scoped index).
"""
if not type_name:
return None
if caller_node is not None:
resolved, decisive = resolver.resolve_type_name(
type_name, caller_node, src_file
)
if resolved:
return resolved
if decisive:
return None
type_defs = type_def_nids.get(_key(type_name), [])
return type_defs[0] if len(type_defs) == 1 else None
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
for rc in all_raw_calls:
if rc.get("lang") != "csharp" or not rc.get("is_member_call"):
continue
receiver = rc.get("receiver")
callee = rc.get("callee")
caller = rc.get("caller_nid")
if not receiver or not callee or not caller:
continue
src_file = rc.get("source_file", "")
caller_node = node_by_id.get(caller)
if receiver == "this":
type_nid = enclosing_type.get(caller)
if not type_nid:
continue
type_qualified = True
elif receiver == "base":
enclosing = enclosing_type.get(caller)
if not enclosing or enclosing in unresolved_base:
continue
bases = bases_of.get(enclosing, [])
if len(bases) != 1: # no base, or can't tell which — bail
continue
type_nid = bases[0]
type_qualified = True
elif receiver[:1].isupper():
# Type.M() — the type is named explicitly (also covers a Pascal-cased
# local whose name equals its type, resolved via the table below if the
# explicit-type lookup misses).
type_nid = _resolve_type_name_nid(receiver, caller_node, src_file)
if not type_nid:
type_name = rc.get("receiver_type")
type_nid = _resolve_type_name_nid(type_name, caller_node, src_file)
if not type_nid:
continue
type_qualified = True
else:
type_name = rc.get("receiver_type")
if not type_name:
continue
type_nid = _resolve_type_name_nid(type_name, caller_node, src_file)
if not type_nid: # ambiguous or absent -> bail (god-node guard)
continue
type_qualified = False
method_nid = _method_on_type_or_bases(type_nid, _key(callee))
if not method_nid:
continue # receiver typed, but the type has no such method — skip
if method_nid == caller or (caller, method_nid) in existing_pairs:
continue
existing_pairs.add((caller, method_nid))
all_edges.append({
"source": caller,
"target": method_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
"confidence_score": 1.0 if type_qualified else 0.8,
"source_file": src_file,
"source_location": rc.get("source_location"),
"weight": 1.0,
})
def _resolve_java_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve Java member calls against the receiver's declared type.
Explicit type receivers and ``this`` are exact. Fields declared on the
caller's class plus method parameters and explicit locals are inferred from
the extractor's method-scoped type table. A missing or ambiguous receiver
type is skipped rather than falling back to a bare method-name match.
"""
def key(label: str) -> str:
return str(label).strip().removeprefix(".").removesuffix("()")
contained = {edge.get("target") for edge in all_edges
if edge.get("relation") == "contains"}
node_by_id = {node.get("id"): node for node in all_nodes}
type_def_nids: dict[str, list[str]] = {}
for node in all_nodes:
if (
node.get("source_file")
and node.get("id") in contained
and _is_type_like_definition(node)
):
type_def_nids.setdefault(key(node.get("label", "")), []).append(node["id"])
method_index: dict[tuple[str, str], set[str]] = {}
enclosing_type: dict[str, str] = {}
for edge in all_edges:
if edge.get("relation") != "method":
continue
owner, method = edge.get("source"), edge.get("target")
method_node = node_by_id.get(method)
if method_node is None:
continue
enclosing_type.setdefault(method, owner)
method_index.setdefault((owner, key(method_node.get("label", ""))), set()).add(method)
existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges}
for result in per_file:
for raw_call in result.get("raw_calls", []):
if raw_call.get("lang") != "java" or not raw_call.get("is_member_call"):
continue
receiver = raw_call.get("receiver")
callee = raw_call.get("callee")
caller = raw_call.get("caller_nid")
if not receiver or not callee or not caller:
continue
exact = False
if receiver == "this":
type_nid = enclosing_type.get(caller)
exact = True
if not type_nid:
continue
else:
type_name = raw_call.get("receiver_type")
if not type_name and receiver[:1].isupper():
type_name = receiver
exact = True
if not type_name:
continue
type_defs = type_def_nids.get(key(type_name), [])
if len(type_defs) != 1:
continue
type_nid = type_defs[0]
method_nids = method_index.get((type_nid, key(callee)), set())
if len(method_nids) != 1:
continue
method_nid = next(iter(method_nids))
if method_nid == caller or (caller, method_nid) in existing_pairs:
continue
existing_pairs.add((caller, method_nid))
all_edges.append({
"source": caller,
"target": method_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED" if exact else "INFERRED",
"confidence_score": 1.0 if exact else 0.8,
"source_file": raw_call.get("source_file", ""),
"source_location": raw_call.get("source_location"),
"weight": 1.0,
})
def _resolve_objc_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve cross-file Objective-C message sends (``[recv sel]``) to the real
definition of the receiver's type (#1556).
The ObjC extractor keeps its same-file selector matching (alloc/init refs,
dot-syntax accesses, @selector) and additionally emits ``raw_calls`` for every
message send, with the receiver and the reconstructed selector as the callee.
This pass types the receiver and emits a cross-file ``calls`` edge ONLY when the
type resolves to exactly ONE definition (the god-node guard).
Receiver typing:
* ``self`` / ``super`` — the caller's own enclosing class -> EXTRACTED.
* Capitalized receiver (``[Foo new]``) — the type named explicitly -> EXTRACTED.
* ``[f doThing]`` — ``f`` typed via the file's ``Foo *f`` local table -> INFERRED.
* ``[self.bar doIt]`` / ``[_ivarBar doIt]`` — the field typed via the class's
``@property``/ivar table (locals shadow fields for the bare-identifier
form) -> INFERRED. Only the exact ``self.<field>`` receiver shape is
captured; a dotted receiver like ``Foo.shared`` is never passed through,
because ``_key`` would strip the dot and collide with a real ``FooShared``.
An uninferable receiver is SKIPPED (no guess), so an ambiguous selector across
classes never fans out. ``_merge_decl_def_classes`` folds each @interface/@impl
pair into one node, so a paired class clears the single-definition guard.
``@protocol`` declarations are excluded from the receiver-type index: a protocol
is a contract, not a message receiver, and ObjC keeps protocol and class names in
separate namespaces, so a same-named pair used to both mis-bind a message to the
protocol's declaration and, when a real class existed, trip the god-node guard.
Must run after id-disambiguation so node ids and caller_nids are final.
"""
type_table_by_file: dict[str, dict[str, str]] = {}
for result in per_file:
tt = result.get("objc_type_table")
if tt and tt.get("path"):
type_table_by_file[tt["path"]] = tt.get("table", {})
# #1556: cross-file `field -> ClassName` tables merged per class nid (the
# .h/.m pair share one id, preserved by _merge_decl_def_classes, so the header's
# @property entries and the impl's ivar entries land in one table). A cross-file
# conflict on the same (class, field) drops the entry — no guess.
field_types_by_class: dict[str, dict[str, str]] = {}
field_conflicts: set[tuple[str, str]] = set()
for result in per_file:
ft = result.get("objc_field_types")
if not ft:
continue
for cls_nid, tbl in (ft.get("tables") or {}).items():
merged = field_types_by_class.setdefault(cls_nid, {})
for field, tname in tbl.items():
if (cls_nid, field) in field_conflicts:
continue
prev = merged.get(field)
if prev is None:
merged[field] = tname
elif prev != tname:
del merged[field]
field_conflicts.add((cls_nid, field))
def _key(label: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
def _is_protocol_declaration(n: dict) -> bool:
"""A ``@protocol`` declaration, which the ObjC extractor labels ``<Name>``.
A protocol is a contract, never a message receiver, so it must not be a
receiver-typing candidate. It stays a valid target for `implements`; only
this pass's type index excludes it.
"""
label = str(n.get("label", "")).strip()
return label.startswith("<") and label.endswith(">")
type_def_nids: dict[str, list[str]] = {}
node_by_id: dict[str, dict] = {}
for n in all_nodes:
node_by_id[n.get("id")] = n
if (n.get("source_file") and n.get("id") in contained
and _is_type_like_definition(n) and not _is_protocol_declaration(n)):
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
method_index: dict[tuple[str, str], str] = {}
enclosing_type: dict[str, str] = {}
for e in all_edges:
if e.get("relation") != "method":
continue
src, tgt = e.get("source"), e.get("target")
enclosing_type.setdefault(tgt, src)
tnode = node_by_id.get(tgt)
if tnode is not None:
# ObjC method labels carry a +/- sigil (`-doThing`); strip it so the
# selector `doThing` keys to the method.
method_index[(src, _key(tnode.get("label", "")))] = tgt
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
for rc in all_raw_calls:
if not rc.get("is_member_call"):
continue
receiver = rc.get("receiver")
callee = rc.get("callee")
caller = rc.get("caller_nid")
if not receiver or not callee or not caller:
continue
src_file = rc.get("source_file", "")
if rc.get("lang") != "objc":
continue
if rc.get("receiver_kind") == "self_field":
# `[self.bar doIt]`: the extractor stamped the BARE field name; type it
# via the caller's own class's @property/ivar table. Checked before the
# capitalized arm so a capitalized field never reads as a class name.
cls = enclosing_type.get(caller)
type_name = field_types_by_class.get(cls, {}).get(receiver) if cls else None
if not type_name:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
continue
type_nid = type_defs[0]
type_qualified = False
elif receiver in ("self", "super"):
type_nid = enclosing_type.get(caller)
if not type_nid:
continue
type_qualified = True
elif receiver[:1].isupper():
type_defs = type_def_nids.get(_key(receiver), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
continue
type_nid = type_defs[0]
type_qualified = True
else:
# Locals shadow fields: the file's `Foo *f` local table first, then the
# enclosing class's @property/ivar table (covers `[_ivarBar doIt]`).
type_name = type_table_by_file.get(src_file, {}).get(receiver)
if not type_name:
cls = enclosing_type.get(caller)
type_name = field_types_by_class.get(cls, {}).get(receiver) if cls else None
if not type_name:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
continue
type_nid = type_defs[0]
type_qualified = False
method_nid = method_index.get((type_nid, _key(callee)))
target = method_nid or type_nid
relation = "calls" if method_nid else "references"
if target == caller or (caller, target) in existing_pairs:
continue
existing_pairs.add((caller, target))
all_edges.append({
"source": caller,
"target": target,
"relation": relation,
"context": "call",
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
"confidence_score": 1.0 if type_qualified else 0.8,
"source_file": src_file,
"source_location": rc.get("source_location"),
"weight": 1.0,
})
def _kotlin_package_index(per_file: list[dict]) -> dict[str, list[dict]]:
"""Group per-file results by the Kotlin package they declare.
``kotlin_package`` is stamped by the generic engine from the file's
``package_header`` (see extractors/engine.py); every node in the file
inherits it. Files with no package header contribute nothing.
"""
pkg_results: dict[str, list[dict]] = {}
for result in per_file:
pkg = result.get("kotlin_package")
if pkg:
pkg_results.setdefault(pkg, []).append(result)
return pkg_results
def _resolve_kotlin_import_targets(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Rewrite Kotlin ``imports`` edge targets from the bare last segment to the
node the written FQN actually names (#2526).
``_import_kotlin`` emits ``file --imports--> _make_id(last_segment)`` with
the full dotted path stamped as ``metadata.target_fqn``. That target dangles
(node ids carry a file-stem prefix), so build pruned every Kotlin import and
the import-evidence promotion in the shared call pass never fired. Here the
per-file ``kotlin_package`` declarations index each package's importable
(non-member) symbols by exact label; an edge whose ``target_fqn`` splits
into a known package P plus a Name defined exactly ONCE in P is rewritten to
that node id. The FQN is written verbatim in source, so the match is exact —
confidence stays EXTRACTED. Anything else (external dependency, ambiguous
name) is left untouched and dangles like other languages' external imports.
Must run BEFORE the shared call pass builds its import-evidence index (it is
invoked directly in extract(), not via the tail registry run).
"""
pkg_results = _kotlin_package_index(per_file)
if not pkg_results:
return
# package fqn -> {importable label -> [node ids]}. Member labels (leading
# dot) are not importable as `P.Name`, and sourceless reference stubs are
# not definitions; both are excluded so they can't shadow the real symbol.
pkg_symbols: dict[str, dict[str, list[str]]] = {}
for pkg, results in pkg_results.items():
by_label = pkg_symbols.setdefault(pkg, {})
for result in results:
for n in result.get("nodes", []):
if not n.get("source_file") or n.get("type") == "namespace":
continue
label = str(n.get("label", ""))
if not label or label.startswith("."):
continue
by_label.setdefault(label.strip("()"), []).append(n["id"])
for e in all_edges:
if e.get("relation") != "imports":
continue
if not str(e.get("source_file", "")).endswith((".kt", ".kts")):
continue
fqn = (e.get("metadata") or {}).get("target_fqn", "")
pkg, _, name = str(fqn).rpartition(".")
if not pkg or not name:
continue
candidates = pkg_symbols.get(pkg, {}).get(name, [])
if len(candidates) == 1: # single-candidate guard: never fabricate
e["target"] = candidates[0]
def _resolve_kotlin_qualified_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve Kotlin fully-qualified call expressions (#2550).
``com.example.nav.NavGraph()`` parses to a nested navigation_expression
chain; the engine flattens it and stamps the raw_call with
``qualified_prefix="com.example.nav"`` + ``lang="kotlin"`` when EVERY chain
segment is a plain identifier. The shared pass skips member calls, so these
raw_calls produced no edge at all — this pass is strictly additive.
Resolution, guarded by exactly-one-candidate at every step:
* prefix == a declared package FQN P -> candidates are P's top-level
callables (functions/classes the file node `contains`) named callee;
* prefix == P + "." + TypeName where TypeName is a class/object declared
in P -> candidates are that type's methods (`method` edges, `.callee()`
label).
Zero or 2+ candidates -> no edge. The FQN is written verbatim in source, so
a unique match is EXTRACTED.
"""
pkg_results = _kotlin_package_index(per_file)
if not pkg_results:
return
raw = [
rc
for result in per_file
for rc in result.get("raw_calls", [])
if rc.get("lang") == "kotlin" and rc.get("qualified_prefix")
and rc.get("callee") and rc.get("caller_nid")
]
if not raw:
return
node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes}
contains_by_source: dict[str, list[str]] = {}
methods_by_type: dict[str, list[str]] = {}
for e in all_edges:
rel = e.get("relation")
if rel == "contains":
contains_by_source.setdefault(e.get("source"), []).append(e.get("target"))
elif rel == "method":
methods_by_type.setdefault(e.get("source"), []).append(e.get("target"))
# package fqn -> {name -> [top-level callable nids]} and
# package fqn -> {name -> [top-level type nids]} (classes/objects).
pkg_callables: dict[str, dict[str, list[str]]] = {}
pkg_types: dict[str, dict[str, list[str]]] = {}
for pkg, results in pkg_results.items():
callables = pkg_callables.setdefault(pkg, {})
types = pkg_types.setdefault(pkg, {})
for result in results:
file_nid = next(
(n["id"] for n in result.get("nodes", [])
if n.get("source_file")
and n.get("label") == Path(str(n["source_file"])).name),
None,
)
if file_nid is None:
continue
for tgt in contains_by_source.get(file_nid, []):
n = node_by_id.get(tgt)
if n is None or not n.get("source_file"):
continue
name = str(n.get("label", "")).strip("()")
if not name or name.startswith("."):
continue
if n.get("_callable"):
callables.setdefault(name, []).append(tgt)
if n.get("_callable_class"):
types.setdefault(name, []).append(tgt)
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
for rc in raw:
prefix = rc["qualified_prefix"]
callee = rc["callee"]
caller = rc["caller_nid"]
candidates: list[str] = []
if prefix in pkg_callables:
# `P.callee()` — a top-level function or class constructor in P.
candidates = pkg_callables[prefix].get(callee, [])
else:
# `P.Type.callee()` — a method of a class/object declared in P.
pkg, _, type_name = prefix.rpartition(".")
type_nids = pkg_types.get(pkg, {}).get(type_name, []) if pkg else []
if len(type_nids) == 1:
wanted = f".{callee}"
candidates = [
m for m in methods_by_type.get(type_nids[0], [])
if str(node_by_id.get(m, {}).get("label", "")).strip("()") == wanted
]
if len(candidates) != 1: # zero or ambiguous -> no edge (god-node guard)
continue
tgt = candidates[0]
if tgt == caller or (caller, tgt) in existing_pairs:
continue
existing_pairs.add((caller, tgt))
all_edges.append({
"source": caller,
"target": tgt,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED", # the FQN is written verbatim in source
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
# Kotlin import-target resolution runs EARLY (directly in extract(), before the
# shared call pass builds its import-evidence index) — registering it in the
# tail registry would rewrite the targets after promotion already read them.
# It still uses the registry's LanguageResolver/driver for the suffix gate and
# failure isolation.
_KOTLIN_IMPORT_TARGET_RESOLVER = LanguageResolver(
"kotlin_import_targets", frozenset({".kt", ".kts"}), _resolve_kotlin_import_targets
)
# Register the cross-file, language-specific member-call resolvers into the shared
# registry (framework lives in graphify.resolver_registry). A new language plugs in
# by adding one register() call below — no edits to extract()'s body. Order
# preserved from the prior inlined wiring: Swift (#1356) before Python (#1446).
register_language_resolver(
LanguageResolver("swift_member_calls", frozenset({".swift"}), _resolve_swift_member_calls)
)
register_language_resolver(
LanguageResolver("python_member_calls", frozenset({".py"}), _resolve_python_member_calls)
)
# Ruby type-aware member-call resolution (Class.new + typed var.method). Lives in
# graphify.ruby_resolution; registered here as a second consumer of the framework.
register_language_resolver(
LanguageResolver("ruby_member_calls", frozenset({".rb", ".rake"}), resolve_ruby_member_calls)
)
register_language_resolver(
LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"}), _resolve_typescript_member_calls)
)
# C++ (#1547) and ObjC (#1556) receiver-typed member-call resolution. `.h` is in
# both suffix sets because it routes to extract_cpp or extract_objc by content; the
# resolvers each claim only their own raw_calls via the extractor-stamped `lang`.
register_language_resolver(
LanguageResolver(
"cpp_member_calls",
frozenset({".cpp", ".cc", ".cxx", ".hpp", ".cu", ".cuh", ".metal", ".h"}),
_resolve_cpp_member_calls,
)
)
register_language_resolver(
LanguageResolver(
"objc_member_calls",
frozenset({".m", ".mm", ".h"}),
_resolve_objc_member_calls,
)
)
# C# receiver-typed member-call resolution (#1609): `field/param/local.Method()`
# bound to the receiver's declared type instead of a bare same-named match.
register_language_resolver(
LanguageResolver("csharp_member_calls", frozenset({".cs"}), _resolve_csharp_member_calls)
)
register_language_resolver(
LanguageResolver("java_member_calls", frozenset({".java"}), _resolve_java_member_calls)
)
# Pascal/Delphi cross-file inherited-method-call resolution: a call from a
# manual descendant class to a method it inherits from an ancestor declared
# in a DIFFERENT file (the common generated-base/manual-descendant split,
# e.g. Sistec's Th0Xxx/Th5Xxx) falls outside the per-file extractor's own
# scope. Lives in graphify.pascal_resolution; registered here as a consumer
# of the framework, same as the Ruby resolver above.
register_language_resolver(
LanguageResolver(
"pascal_inherited_calls",
frozenset({".pas", ".pp", ".dpr", ".dpk", ".inc"}),
resolve_pascal_inherited_calls,
)
)
# Kotlin fully-qualified call resolution (#2550): `com.pkg.Fn()` /
# `com.pkg.Object.method()` raw_calls the shared pass skips (member calls with
# no receiver). Runs in the tail registry like the other member-call resolvers;
# its sibling import-target pass runs earlier (see _KOTLIN_IMPORT_TARGET_RESOLVER).
register_language_resolver(
LanguageResolver(
"kotlin_qualified_calls", frozenset({".kt", ".kts"}), _resolve_kotlin_qualified_calls
)
)
# Inline markdown link: [text](target "optional title"). The negative lookbehind
# excludes images (![alt](src)). The target stops at whitespace/closing paren so
# an optional "title" after the URL is dropped; an optional <...> wrapper is too.
# Reference-style link definition line: [label]: target "optional title"
# Obsidian-style wikilink: [[target]] / [[target|alias]] / [[target#anchor]].
# Extensions graphify creates document file nodes for. A link to one of these
# resolves to that file's node; links to code/assets are skipped (left to the
# language extractors).
# ── Pascal / Delphi extractor ─────────────────────────────────────────────────
# Size cap for project XML files we parse with stdlib ElementTree.
# Real .csproj/.fsproj/.vbproj/.lpk files are well under 2 MiB; anything
# larger is either malformed or hostile.
_PROJECT_XML_MAX_BYTES = 2 * 1024 * 1024
def _project_xml_is_safe(src: bytes) -> bool:
"""Reject XML that declares DTDs or entities.
Stdlib ``xml.etree.ElementTree`` does not cap entity expansion, so a
crafted project file could trigger a billion-laughs style DoS. External
entity resolution is already disabled by pyexpat defaults, but rejecting
``<!DOCTYPE`` / ``<!ENTITY`` outright is defense in depth.
Legitimate MSBuild and Lazarus package files never contain a DOCTYPE
or ENTITY declaration, so this is a zero-false-positive screen.
"""
# Only the prolog can hold a DTD/internal subset, but be conservative
# and scan the full byte range -- these formats use ASCII tags so a
# case-insensitive substring match is sufficient.
lowered = src.lower()
return b"<!doctype" not in lowered and b"<!entity" not in lowered
def extract_lazarus_package(path: Path) -> dict:
"""Extract package metadata from Lazarus .lpk package files (XML format).
.lpk is an XML file listing the package name, required dependencies,
and the Pascal units that belong to the package.
Produces nodes for:
- The package file itself
- The package (by name)
- Each required package (dependency)
- Each listed unit file (resolved to path-based IDs where possible)
Produces edges for:
- file --contains--> package
- package --imports--> required dependency (context: "import")
- package --contains--> listed unit
"""
try:
import xml.etree.ElementTree as ET
src = path.read_bytes()
except OSError as e:
return {"nodes": [], "edges": [], "error": str(e)}
if len(src) > _PROJECT_XML_MAX_BYTES:
return {"nodes": [], "edges": [], "error": "package file too large"}
if not _project_xml_is_safe(src):
return {"nodes": [], "edges": [],
"error": "refusing XML with DOCTYPE/ENTITY declaration"}
try:
xml_root = ET.fromstring(src)
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
str_path = str(path)
stem = _file_stem(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
def add_node(nid: str, label: str) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({
"id": nid, "label": label, "file_type": "code",
"source_file": str_path, "source_location": "L1",
})
def add_edge(src: str, tgt: str, relation: str, context: str | None = None) -> None:
edge: dict[str, Any] = {
"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": "L1", "weight": 1.0,
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name)
name_elem = xml_root.find(".//Package/Name")
pkg_name = name_elem.get("Value") if name_elem is not None else path.stem
pkg_nid = _make_id(stem, pkg_name)
add_node(pkg_nid, pkg_name)
add_edge(file_nid, pkg_nid, "contains")
# Required packages → imports edges
for item in xml_root.findall(".//RequiredPkgs/"):
dep_elem = item.find("PackageName")
if dep_elem is not None:
dep_name = dep_elem.get("Value", "")
if dep_name:
dep_nid = _make_id(dep_name)
add_node(dep_nid, dep_name)
add_edge(pkg_nid, dep_nid, "imports", context="import")
# Listed units → contains edges, resolved to path-based IDs where possible
for item in xml_root.findall(".//Files/"):
unit_elem = item.find("UnitName")
if unit_elem is not None:
unit_name = unit_elem.get("Value", "")
if unit_name:
unit_nid = _pascal_resolve_unit(path, unit_name)
add_node(unit_nid, unit_name)
add_edge(pkg_nid, unit_nid, "contains")
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
# ── Main extract and collect_files ────────────────────────────────────────────
def _check_tree_sitter_version() -> None:
"""Raise a clear error if tree-sitter is too old for the new Language API."""
try:
from tree_sitter import LANGUAGE_VERSION
except ImportError:
raise ImportError(
"tree-sitter is not installed. Run: pip install 'tree-sitter>=0.23.0'"
)
# Language API v2 starts at LANGUAGE_VERSION 14
if LANGUAGE_VERSION < 14:
import tree_sitter as _ts
raise RuntimeError(
f"tree-sitter {getattr(_ts, '__version__', 'unknown')} is too old. "
f"graphify requires tree-sitter >= 0.23.0 (Language API v2). "
f"Run: pip install --upgrade tree-sitter"
)
# ── .NET project files (.sln, .slnx, .csproj, .razor) ───────────────────────
def extract_slnx(path: Path) -> dict:
"""Extract projects and inter-project dependencies from a .slnx file.
.slnx is the XML-based replacement for the legacy .sln format. Projects
are listed as ``<Project Path="..."/>`` elements (optionally nested inside
``<Folder>`` elements) and build-order dependencies as ``<BuildDependency
Project="..."/>`` children. Unlike .sln there are no GUIDs -- projects are
identified by their path.
"""
import xml.etree.ElementTree as ET
try:
src = path.read_bytes()
except OSError:
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
if len(src) > _PROJECT_XML_MAX_BYTES:
return {"nodes": [], "edges": [], "error": "project file too large"}
if not _project_xml_is_safe(src):
return {"nodes": [], "edges": [],
"error": "refusing XML with DOCTYPE/ENTITY declaration"}
try:
tree = ET.fromstring(src)
except ET.ParseError as e:
return {"nodes": [], "edges": [], "error": f"XML parse error: {e}"}
file_nid = _make_id(str(path))
str_path = str(path)
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str_path, "source_location": None}]
edges: list[dict] = []
seen_ids: set[str] = set()
seen_ids.add(file_nid)
ns = ""
if tree.tag.startswith("{"):
ns = tree.tag.split("}")[0] + "}"
def _resolve(proj_path: str) -> str:
proj_path = proj_path.replace("\\", "/")
try:
return str((path.parent / proj_path).resolve())
except Exception:
return proj_path
# First pass: collect projects (anywhere in the tree, incl. <Folder>).
project_nids: set[str] = set()
for proj in tree.iter(f"{ns}Project"):
proj_path = proj.get("Path")
if not proj_path:
continue
abs_proj = _resolve(proj_path)
proj_nid = _make_id(abs_proj)
if proj_nid and proj_nid not in seen_ids:
seen_ids.add(proj_nid)
label = Path(proj_path).stem
nodes.append({"id": proj_nid, "label": label,
"file_type": "code", "source_file": abs_proj,
"source_location": None})
edges.append({"source": file_nid, "target": proj_nid,
"relation": "contains", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
if proj_nid:
project_nids.add(proj_nid)
# Second pass: build-order dependencies between known projects.
for proj in tree.iter(f"{ns}Project"):
proj_path = proj.get("Path")
if not proj_path:
continue
from_nid = _make_id(_resolve(proj_path))
for dep in proj.iter(f"{ns}BuildDependency"):
dep_path = dep.get("Project")
if not dep_path:
continue
to_nid = _make_id(_resolve(dep_path))
if (from_nid and to_nid and from_nid != to_nid
and to_nid in project_nids):
edges.append({"source": from_nid, "target": to_nid,
"relation": "imports", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
return {"nodes": nodes, "edges": edges}
def extract_csproj(path: Path) -> dict:
"""Extract packages, project refs, and target framework from a .csproj/.fsproj/.vbproj."""
import xml.etree.ElementTree as ET
try:
src = path.read_bytes()
except OSError:
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
if len(src) > _PROJECT_XML_MAX_BYTES:
return {"nodes": [], "edges": [], "error": "project file too large"}
if not _project_xml_is_safe(src):
return {"nodes": [], "edges": [],
"error": "refusing XML with DOCTYPE/ENTITY declaration"}
try:
tree = ET.fromstring(src)
except ET.ParseError as e:
return {"nodes": [], "edges": [], "error": f"XML parse error: {e}"}
file_nid = _make_id(str(path))
str_path = str(path)
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str_path, "source_location": None}]
edges: list[dict] = []
seen_ids: set[str] = set()
seen_ids.add(file_nid)
ns = ""
root_tag = tree.tag
if root_tag.startswith("{"):
ns = root_tag.split("}")[0] + "}"
def find_all(tag: str):
return tree.iter(f"{ns}{tag}")
for tf in find_all("TargetFramework"):
if tf.text:
fw_nid = _make_id("framework", tf.text.strip())
if fw_nid and fw_nid not in seen_ids:
seen_ids.add(fw_nid)
nodes.append({"id": fw_nid, "label": tf.text.strip(),
"file_type": "concept", "source_file": str_path,
"source_location": None})
edges.append({"source": file_nid, "target": fw_nid,
"relation": "references", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
for tf in find_all("TargetFrameworks"):
if tf.text:
for fw in tf.text.strip().split(";"):
fw = fw.strip()
if fw:
fw_nid = _make_id("framework", fw)
if fw_nid and fw_nid not in seen_ids:
seen_ids.add(fw_nid)
nodes.append({"id": fw_nid, "label": fw,
"file_type": "concept", "source_file": str_path,
"source_location": None})
edges.append({"source": file_nid, "target": fw_nid,
"relation": "references", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
for pkg in find_all("PackageReference"):
name = pkg.get("Include") or pkg.get("include") or ""
version = pkg.get("Version") or pkg.get("version") or ""
if not name:
continue
pkg_nid = _make_id("nuget", name)
label = f"{name} ({version})" if version else name
if pkg_nid and pkg_nid not in seen_ids:
seen_ids.add(pkg_nid)
nodes.append({"id": pkg_nid, "label": label,
"file_type": "code", "source_file": str_path,
"source_location": None})
edges.append({"source": file_nid, "target": pkg_nid,
"relation": "imports", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
for proj in find_all("ProjectReference"):
ref_path = proj.get("Include") or proj.get("include") or ""
if not ref_path:
continue
ref_path_norm = ref_path.replace("\\", "/")
try:
abs_ref = str((path.parent / ref_path_norm).resolve())
except Exception:
abs_ref = ref_path_norm
proj_nid = _make_id(abs_ref)
if proj_nid and proj_nid not in seen_ids:
seen_ids.add(proj_nid)
proj_label = Path(ref_path_norm).name
nodes.append({"id": proj_nid, "label": proj_label,
"file_type": "code", "source_file": abs_ref,
"source_location": None})
edges.append({"source": file_nid, "target": proj_nid,
"relation": "imports", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
sdk = tree.get("Sdk") or ""
if sdk:
sdk_nid = _make_id("sdk", sdk)
if sdk_nid and sdk_nid not in seen_ids:
seen_ids.add(sdk_nid)
nodes.append({"id": sdk_nid, "label": sdk,
"file_type": "concept", "source_file": str_path,
"source_location": None})
edges.append({"source": file_nid, "target": sdk_nid,
"relation": "references", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
return {"nodes": nodes, "edges": edges}
def _xml_local_name(name: str) -> str:
return name.rsplit("}", 1)[-1] if name.startswith("{") else name
# A .NET event handler has the signature `(object sender, <T>EventArgs e)`. Used
# to tell a real event handler in the code-behind apart from an ordinary method
# whose name a XAML attribute value happens to match. Tolerates `object?`, a
# namespace-qualified args type, and a generic `EventArgs<T>`.
_EVENT_HANDLER_SIGNATURE_RE = re.compile(
r"\(\s*object\??\s+\w+\s*,\s*[\w.]*EventArgs(?:<[^>]*>)?\s+\w+\s*\)"
)
# XAML attribute names that carry free-form strings or identifiers and never name
# an event handler. They are skipped when matching attribute values to code-behind
# methods so e.g. Content="Save" or Tag="Refresh" can't fabricate an event edge.
_XAML_NON_EVENT_ATTRS = frozenset({
"Name", "Content", "Text", "Title", "Tag", "ToolTip", "Header",
"Class", "Key", "Uid", "DataContext", "Style", "Source",
})
# A handler attribute value is a bare method name (e.g. Click="Save_Click"), not
# markup, a path, or a sentence. Used to skip values like "{Binding ...}" or
# free-form content before looking them up as code-behind methods.
_XAML_IDENT_RE = re.compile(r"[A-Za-z_]\w*")
_XAML_DESIGN_INSTANCE_TYPE_RE = re.compile(
r"\bType\s*=\s*(?:\{x:Type\s+)?(?P<type>[\w.:+]+)"
)
def _xaml_markup_extension(value: str) -> tuple[str, str] | None:
value = value.strip()
if not (value.startswith("{") and value.endswith("}")):
return None
inner = value[1:-1].strip()
if not inner or inner.startswith("}"):
return None
name, _, args = inner.partition(" ")
return name, args.strip()
def _xaml_split_markup_args(args: str) -> list[str]:
parts: list[str] = []
start = 0
depth = 0
for idx, ch in enumerate(args):
if ch == "{":
depth += 1
elif ch == "}" and depth:
depth -= 1
elif ch == "," and depth == 0:
parts.append(args[start:idx].strip())
start = idx + 1
tail = args[start:].strip()
if tail:
parts.append(tail)
return parts
def _xaml_static_resource_key(value: str) -> str | None:
markup = _xaml_markup_extension(value)
if not markup:
return None
name, args = markup
if name != "StaticResource":
return None
for part in _xaml_split_markup_args(args):
if "=" not in part:
return part.strip() or None
key, resource = part.split("=", 1)
if key.strip() == "ResourceKey":
return resource.strip() or None
return None
def _xaml_binding_refs(value: str) -> tuple[str | None, str | None]:
markup = _xaml_markup_extension(value)
if not markup:
return None, None
name, args = markup
if name != "Binding":
return None, None
path_ref = None
converter_ref = None
for part in _xaml_split_markup_args(args):
if not part:
continue
if "=" not in part:
if path_ref is None:
path_ref = part.strip()
continue
key, raw_value = part.split("=", 1)
key = key.strip()
raw_value = raw_value.strip()
if key == "Path":
path_ref = raw_value
elif key == "Converter":
converter_ref = _xaml_static_resource_key(raw_value)
if path_ref and ("{" in path_ref or "}" in path_ref):
path_ref = None
return path_ref or None, converter_ref or None
def _xaml_codebehind_path(path: Path) -> Path | None:
expected = path.with_suffix(path.suffix + ".cs")
if expected.exists():
return expected
try:
for sibling in path.parent.iterdir():
if sibling.name.casefold() == expected.name.casefold():
return sibling
except OSError:
return None
return None
def _xaml_codebehind_symbols(
path: Path,
class_name: str | None,
) -> tuple[dict | None, dict[str, dict], list[dict]]:
codebehind = _xaml_codebehind_path(path)
if not codebehind:
return None, {}, []
result = extract_csharp(codebehind)
if result.get("error"):
return None, {}, []
class_simple = class_name.rsplit(".", 1)[-1] if class_name else None
class_node = None
if class_simple:
for node in result.get("nodes", []):
if node.get("label") == class_simple:
class_node = node
break
class_method_edges: list[dict] = []
if class_node:
class_id = class_node.get("id")
for edge in result.get("edges", []):
if edge.get("source") == class_id and edge.get("relation") == "method":
class_method_edges.append(edge)
method_ids = {edge.get("target") for edge in class_method_edges} if class_node else None
# Only methods with a .NET event-handler signature -- (object sender,
# <T>EventArgs e) -- are eligible to be wired to a XAML attribute as an
# event. Without this gate, any attribute whose value happens to match a
# method name (e.g. Content="Save" next to a business method Save()) would
# produce a spurious "event" edge. The C# extractor does not record the
# parameter list on method nodes, so we read it from the code-behind source
# at the method's recorded line.
try:
cb_lines = codebehind.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
cb_lines = []
def _has_event_handler_signature(node: dict) -> bool:
loc = str(node.get("source_location") or "")
m = re.match(r"L(\d+)", loc)
if not m or not cb_lines:
return False
start = int(m.group(1)) - 1
# Join a few lines so a signature split across lines still matches.
snippet = " ".join(cb_lines[start:start + 3])
return _EVENT_HANDLER_SIGNATURE_RE.search(snippet) is not None
methods: dict[str, dict] = {}
for node in result.get("nodes", []):
if method_ids is not None and node.get("id") not in method_ids:
continue
label = str(node.get("label", ""))
if label.startswith(".") and label.endswith("()") and _has_event_handler_signature(node):
methods[label.strip("()").lstrip(".")] = node
return class_node, methods, class_method_edges
def _xaml_type_simple_name(type_ref: str) -> str | None:
type_ref = type_ref.strip().strip("{}")
if not type_ref:
return None
type_ref = type_ref.split(",", 1)[0].strip()
if type_ref.startswith("x:Type "):
type_ref = type_ref[len("x:Type "):].strip()
if ":" in type_ref:
type_ref = type_ref.rsplit(":", 1)[-1]
if "." in type_ref:
type_ref = type_ref.rsplit(".", 1)[-1]
if "+" in type_ref:
type_ref = type_ref.rsplit("+", 1)[-1]
return type_ref if _XAML_IDENT_RE.fullmatch(type_ref) else None
def _xaml_explicit_viewmodel_names(tree) -> tuple[bool, list[str]]:
has_data_context = False
names: list[str] = []
for elem in tree.iter():
elem_type = _xml_local_name(elem.tag)
if elem_type.endswith(".DataContext") or elem_type == "DataContext":
has_data_context = True
for child in list(elem):
vm_name = _xaml_type_simple_name(_xml_local_name(child.tag))
if vm_name and vm_name not in names:
names.append(vm_name)
for key, value in elem.attrib.items():
if _xml_local_name(key) != "DataContext" or not value:
continue
has_data_context = True
match = _XAML_DESIGN_INSTANCE_TYPE_RE.search(value)
if match:
vm_name = _xaml_type_simple_name(match.group("type"))
if vm_name and vm_name not in names:
names.append(vm_name)
return has_data_context, names
def _xaml_prism_autowire_viewmodel(tree) -> bool:
for elem in tree.iter():
for key, value in elem.attrib.items():
if (
_xml_local_name(key).endswith("ViewModelLocator.AutoWireViewModel")
and value.strip().lower() == "true"
):
return True
return False
def _xaml_inferred_viewmodel_names(view_name: str | None) -> list[str]:
if not view_name:
return []
names: list[str] = []
def add(name: str) -> None:
if name.endswith("ViewModel") and name not in names:
names.append(name)
if view_name == "MainWindow":
add("MainWindowViewModel")
add("MainViewModel")
for suffix in ("UserControl", "View", "Page", "Control"):
if view_name.endswith(suffix) and len(view_name) > len(suffix):
add(view_name[:-len(suffix)] + "ViewModel")
break
return names
def _xaml_project_root(path: Path) -> Path:
project_markers = (".csproj", ".fsproj", ".vbproj", ".sln", ".slnx")
root = path.parent
for directory in (path.parent, *path.parent.parents):
try:
if any(child.suffix in project_markers for child in directory.iterdir()):
root = directory
break
except OSError:
continue
if _XAML_ACTIVE_EXTRACT_ROOT is None:
return root
boundary = _XAML_ACTIVE_EXTRACT_ROOT.resolve()
try:
root.resolve().relative_to(boundary)
return root
except ValueError:
return boundary
def _xaml_csharp_class_nodes(path: Path) -> dict[str, list[dict]]:
from graphify.detect import _is_ignored, _is_noise_dir, _load_graphifyignore
root = _xaml_project_root(path)
cache_key = str(root.resolve()) if _XAML_ACTIVE_EXTRACT_ROOT is not None else None
if cache_key and cache_key in _XAML_CSHARP_CLASS_CACHE:
return _XAML_CSHARP_CLASS_CACHE[cache_key]
classes: dict[str, list[dict]] = {}
patterns = _load_graphifyignore(root)
ignore_cache: dict[Path, bool] = {}
# Prune noise/hidden dirs DURING traversal (not after) so the scan never
# descends into node_modules/.venv/.git/build/..., and CAP the number of
# directories visited. rglob("*.cs") used to walk the entire tree first,
# which on a mis-resolved or huge root (e.g. a .xaml under a shared temp dir
# or a giant monorepo, where _xaml_project_root climbs to a broad ancestor)
# scanned millions of paths and effectively hung. A real .NET project sits
# well under the cap; a runaway root is bounded to a fast, partial scan
# instead of hanging.
import os as _os
_DIR_CAP = 20000
cs_files: list[Path] = []
visited = 0
try:
for dirpath, dirnames, filenames in _os.walk(root):
dirnames[:] = [
d for d in dirnames if not d.startswith(".") and not _is_noise_dir(d)
]
for fn in filenames:
if fn.endswith(".cs"):
cs_files.append(Path(dirpath) / fn)
visited += 1
if visited >= _DIR_CAP:
break
except OSError:
return classes
cs_files.sort()
for cs_path in cs_files:
if patterns and _is_ignored(cs_path, root, patterns, _cache=ignore_cache):
continue
result = extract_csharp(cs_path)
if result.get("error"):
continue
for node in result.get("nodes", []):
label = str(node.get("label", ""))
if not label.endswith("ViewModel") or not _XAML_IDENT_RE.fullmatch(label):
continue
if node.get("source_file"):
classes.setdefault(label, []).append(node)
if cache_key:
_XAML_CSHARP_CLASS_CACHE[cache_key] = classes
return classes
def _xaml_pascal_name(name: str) -> str | None:
name = name.strip().lstrip("_")
if name.startswith("m_"):
name = name[2:]
return name[:1].upper() + name[1:] if _XAML_IDENT_RE.fullmatch(name) else None
_XAML_TOOLKIT_FIELD_RE = re.compile(r"\b(?P<name>_?m?_?[A-Za-z_]\w*)\s*(?:=.*)?;")
_XAML_TOOLKIT_METHOD_RE = re.compile(r"\b(?P<name>[A-Za-z_]\w*)\s*\(")
_XAML_ACTIVE_EXTRACT_ROOT: Path | None = None
_XAML_CSHARP_CLASS_CACHE: dict[str, dict[str, list[dict]]] = {}
def _xaml_communitytoolkit_members(vm_node: dict) -> tuple[dict[str, dict], list[dict]]:
source_file = vm_node.get("source_file")
vm_id = vm_node.get("id")
if not source_file or not vm_id:
return {}, []
try:
# errors="replace" so a non-UTF8 code-behind can't raise UnicodeDecodeError
# and abort the whole extract_xaml (matches every other reader here).
lines = Path(source_file).read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return {}, []
members: dict[str, dict] = {}
edges: list[dict] = []
def add_member(label: str, line_no: int, context: str) -> None:
nid = _make_id(vm_id, label)
members[label] = {
"id": nid,
"label": label,
"file_type": "code",
"source_file": source_file,
"source_location": f"L{line_no}",
}
edges.append({
"source": vm_id,
"target": nid,
"relation": "defines",
"confidence": "INFERRED",
"source_file": source_file,
"source_location": f"L{line_no}",
"weight": 1.0,
"context": context,
})
pending: tuple[str, int] | None = None
for line_no, line in enumerate(lines, 1):
remainder = line.split("]", 1)[1].strip() if "]" in line else ""
if "[" in line and "ObservableProperty" in line:
pending = ("property", line_no)
if not remainder:
continue
line = remainder
if "[" in line and "RelayCommand" in line:
pending = ("command", line_no)
if not remainder:
continue
line = remainder
if not pending or not line.strip() or line.lstrip().startswith("["):
continue
kind, attr_line = pending
pending = None
if kind == "property":
match = _XAML_TOOLKIT_FIELD_RE.search(line)
label = _xaml_pascal_name(match.group("name")) if match else None
if label:
add_member(label, attr_line, "communitytoolkit_observable_property")
else:
match = _XAML_TOOLKIT_METHOD_RE.search(line)
if match:
method = match.group("name").removesuffix("Async")
add_member(f"{method}Command", attr_line, "communitytoolkit_relay_command")
return members, edges
def extract_xaml(path: Path) -> dict:
"""Extract WPF/XAML structure, bindings, x:Class, and event handler references."""
import xml.etree.ElementTree as ET
try:
src = path.read_bytes()
except OSError:
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
if len(src) > _PROJECT_XML_MAX_BYTES:
return {"nodes": [], "edges": [], "error": "xaml file too large"}
if not _project_xml_is_safe(src):
return {"nodes": [], "edges": [],
"error": "refusing XML with DOCTYPE/ENTITY declaration"}
try:
tree = ET.fromstring(src)
except ET.ParseError as e:
return {"nodes": [], "edges": [], "error": f"XML parse error: {e}"}
text = src.decode("utf-8", errors="replace")
lines = text.splitlines()
str_path = str(path)
stem = _file_stem(path)
file_nid = _make_id(str(path))
root_type = _xml_local_name(tree.tag)
root_nid = _make_id(stem, root_type)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
seen_edges: set[tuple[str, str, str, str | None]] = set()
def line_for(value: str | None) -> int:
if value:
for idx, line in enumerate(lines, 1):
if value in line:
return idx
return 1
def add_node(
nid: str,
label: str,
line: int | None,
*,
file_type: str = "code",
source_file: str = str_path,
) -> None:
if nid in seen_ids:
return
seen_ids.add(nid)
nodes.append({
"id": nid, "label": label, "file_type": file_type,
"source_file": source_file,
"source_location": f"L{line}" if line else None,
})
def add_existing_node(node: dict | None) -> None:
if not node:
return
nid = node.get("id")
if not nid or nid in seen_ids:
return
seen_ids.add(nid)
nodes.append(dict(node))
def add_edge(
src_nid: str,
tgt_nid: str,
relation: str,
line: int,
*,
context: str | None = None,
source_file: str = str_path,
confidence: str = "EXTRACTED",
) -> None:
key = (src_nid, tgt_nid, relation, context)
if key in seen_edges:
return
seen_edges.add(key)
edge = {
"source": src_nid, "target": tgt_nid, "relation": relation,
"confidence": confidence, "source_file": source_file,
"source_location": f"L{line}", "weight": 1.0,
}
if context:
edge["context"] = context
edges.append(edge)
def add_existing_edge(edge: dict) -> None:
key = (edge.get("source"), edge.get("target"), edge.get("relation"), edge.get("context"))
if key in seen_edges:
return
seen_edges.add(key)
edges.append(dict(edge))
add_node(file_nid, path.name, 1)
add_node(root_nid, root_type, 1)
add_edge(file_nid, root_nid, "contains", 1)
class_name = None
for key, value in tree.attrib.items():
if _xml_local_name(key) == "Class" and value:
class_name = value.strip()
break
class_node, codebehind_methods, class_method_edges = _xaml_codebehind_symbols(path, class_name)
if class_name:
if class_node:
class_nid = class_node["id"]
add_existing_node(class_node)
else:
class_label = class_name.rsplit(".", 1)[-1]
class_nid = _make_id(stem, class_label)
add_node(class_nid, class_label, line_for(class_name))
add_edge(root_nid, class_nid, "references", line_for(class_name), context="x_class")
has_data_context, vm_names = _xaml_explicit_viewmodel_names(tree)
prism_autowire = _xaml_prism_autowire_viewmodel(tree)
vm_confidence = "EXTRACTED"
if not has_data_context:
view_name = class_name.rsplit(".", 1)[-1] if class_name else None
view_name = view_name or (path.stem if prism_autowire else None)
vm_names = _xaml_inferred_viewmodel_names(view_name)
vm_confidence = "INFERRED"
generated_members: dict[str, dict] = {}
generated_member_edges: list[dict] = []
if vm_names:
csharp_classes = _xaml_csharp_class_nodes(path)
vm_candidates = []
for vm_name in vm_names:
vm_candidates.extend(csharp_classes.get(vm_name, []))
by_id = {node.get("id"): node for node in vm_candidates if node.get("id")}
if len(by_id) == 1:
vm_node = next(iter(by_id.values()))
add_existing_node(vm_node)
add_edge(
root_nid,
vm_node["id"],
"references",
line_for(vm_node["label"]),
context="view_model",
confidence=vm_confidence,
)
generated_members, generated_member_edges = _xaml_communitytoolkit_members(vm_node)
for member in generated_members.values():
add_existing_node(member)
for member_edge in generated_member_edges:
add_existing_edge(member_edge)
for elem in tree.iter():
elem_type = _xml_local_name(elem.tag)
elem_name = None
for key, value in elem.attrib.items():
if _xml_local_name(key) == "Name" and value:
elem_name = value.strip()
break
owner_nid = root_nid
if elem_name:
owner_nid = _make_id(stem, elem_name)
add_node(owner_nid, elem_name, line_for(elem_name))
add_edge(root_nid, owner_nid, "contains", line_for(elem_name))
type_nid = _make_id("xaml", elem_type)
add_node(type_nid, elem_type, line_for(elem_name), file_type="concept")
add_edge(owner_nid, type_nid, "references", line_for(elem_name), context="type")
for key, value in elem.attrib.items():
value = value or ""
# Event wiring: an attribute references a handler only when its local
# name isn't a known free-form/identity property, its value is a bare
# identifier (a method name, not markup or a sentence), and the matched
# code-behind method actually has an event-handler signature (the gate
# in _xaml_codebehind_symbols). This stops Content="Save" / Tag="..."
# from fabricating event edges against same-named ordinary methods.
attr_local = _xml_local_name(key)
if attr_local not in _XAML_NON_EVENT_ATTRS and _XAML_IDENT_RE.fullmatch(value):
method = codebehind_methods.get(value)
if method:
add_existing_node(method)
add_edge(owner_nid, method["id"], "references", line_for(value), context="event")
for method_edge in class_method_edges:
if method_edge.get("target") == method["id"]:
add_existing_node(class_node)
add_existing_edge(method_edge)
break
binding_path, binding_converter = _xaml_binding_refs(value)
if binding_path:
bind_nid = _make_id("binding", binding_path)
add_node(bind_nid, binding_path, line_for(value), file_type="concept")
binding_context = (
"binding_command"
if attr_local == "Command" or attr_local.endswith(".Command")
else "binding_path"
)
add_edge(owner_nid, bind_nid, "references", line_for(value), context=binding_context)
generated_member = generated_members.get(binding_path)
if generated_member:
add_existing_node(generated_member)
add_edge(
owner_nid,
generated_member["id"],
"references",
line_for(value),
context=binding_context,
confidence="INFERRED",
)
if binding_converter:
converter_nid = _make_id("binding_converter", binding_converter)
add_node(converter_nid, binding_converter, line_for(value), file_type="concept")
add_edge(owner_nid, converter_nid, "references", line_for(value), context="binding_converter")
if elem_type == "Binding" and attr_local == "Path":
direct_path = value.strip()
if direct_path and "{" not in direct_path and "}" not in direct_path:
bind_nid = _make_id("binding", direct_path)
add_node(bind_nid, direct_path, line_for(value), file_type="concept")
add_edge(owner_nid, bind_nid, "references", line_for(value), context="binding_path")
if elem_type == "Binding" and attr_local == "Converter":
direct_converter = _xaml_static_resource_key(value)
if direct_converter:
converter_nid = _make_id("binding_converter", direct_converter)
add_node(converter_nid, direct_converter, line_for(value), file_type="concept")
add_edge(owner_nid, converter_nid, "references", line_for(value), context="binding_converter")
return {"nodes": nodes, "edges": edges}
# Config/manifest JSON filenames the structural extractor understands. Anything
# else (eval fixtures, datasets, GeoJSON, API dumps) is *data* and must NOT be
# AST-walked into per-key nodes — that floods the graph with orphan key-nodes
# and near-duplicate communities (#1224). Data JSON is left to the LLM semantic
# pass instead. Matched case-insensitively against the bare filename.
# Top-level keys that prove a JSON object is a config/manifest the extractor can
# draw *cross-file* edges from (deps, extends chains, schema refs).
# ── DM (BYOND DreamMaker) extractor ──────────────────────────────────────────
# DM identity is path-based (`/datum/object/proc/New()`), not block-based, so
# the generic class-body walker doesn't fit well.
# ── DMI (BYOND icon files) ────────────────────────────────────────────────────
# .dmi is a PNG with a tEXt/zTXt "Description" chunk containing BYOND state
# metadata. We want the icon state names (icon_state = "X" in DM code
# references them).
# ── DMM (BYOND map files) ─────────────────────────────────────────────────────
# A .dmm starts with a tile dictionary — each "key" = (type, type{var=val}, ...)
# names one or more types that compose a tile — then a grid. We only need the
# dictionary section: every type path referenced is a `uses` edge.
# ── DMF (BYOND interface forms) ───────────────────────────────────────────────
# Head tokens in an HCL traversal that are meta/builtins, not references to a
# block defined in the corpus (count.index, each.key, self.*, path.module, ...).
_DISPATCH: dict[str, Any] = {
".py": extract_python,
".js": extract_js,
".jsx": extract_js,
".mjs": extract_js,
".cjs": extract_js,
".ts": extract_js,
".tsx": extract_js,
".mts": extract_js,
".cts": extract_js,
".go": extract_go,
".rs": extract_rust,
".java": extract_java,
".groovy": extract_groovy,
".gradle": extract_groovy,
".c": extract_c,
".h": extract_c,
".cpp": extract_cpp,
".cc": extract_cpp,
".cxx": extract_cpp,
".hpp": extract_cpp,
".cu": extract_cpp,
".cuh": extract_cpp,
".metal": extract_cpp,
".rb": extract_ruby, ".rake": extract_ruby,
".cs": extract_csharp,
".kt": extract_kotlin,
".kts": extract_kotlin,
".scala": extract_scala,
".php": extract_php,
".swift": extract_swift,
".lua": extract_lua,
".luau": extract_lua,
".toc": extract_lua,
".zig": extract_zig,
".ps1": extract_powershell,
".psm1": extract_powershell,
".psd1": extract_powershell_manifest,
".ex": extract_elixir,
".exs": extract_elixir,
".m": extract_objc,
".mm": extract_objc,
".jl": extract_julia,
".f": extract_fortran,
".F": extract_fortran,
".f90": extract_fortran,
".F90": extract_fortran,
".f95": extract_fortran,
".F95": extract_fortran,
".f03": extract_fortran,
".F03": extract_fortran,
".f08": extract_fortran,
".F08": extract_fortran,
".vue": extract_vue,
".svelte": extract_svelte,
".astro": extract_astro,
".dart": extract_dart,
".ml": extract_ocaml,
".mli": extract_ocaml,
".lisp": extract_commonlisp,
".cl": extract_commonlisp,
".lsp": extract_commonlisp,
".asd": extract_commonlisp,
".v": extract_verilog,
".sv": extract_verilog,
".svh": extract_verilog,
".sql": extract_sql,
".md": extract_markdown,
".mdx": extract_markdown,
".qmd": extract_markdown,
".skill": extract_markdown,
".pas": extract_pascal,
".pp": extract_pascal,
".dpr": extract_pascal,
".dpk": extract_pascal,
".lpr": extract_pascal,
".inc": extract_pascal,
".dfm": extract_delphi_form,
".lfm": extract_lazarus_form,
".lpk": extract_lazarus_package,
".sh": extract_bash,
".bash": extract_bash,
".json": extract_json,
".tf": extract_terraform,
".tfvars": extract_terraform,
".hcl": extract_terraform,
".dm": extract_dm,
".dme": extract_dm,
".dmi": extract_dmi,
".dmm": extract_dmm,
".dmf": extract_dmf,
".sln": extract_sln,
".slnx": extract_slnx,
".csproj": extract_csproj,
".fsproj": extract_csproj,
".vbproj": extract_csproj,
".xaml": extract_xaml,
".razor": extract_razor,
".cshtml": extract_razor,
".cls": extract_apex,
".trigger": extract_apex,
}
# Extensions whose extractor depends on an optional-dependency extra
# (pyproject [project.optional-dependencies]) and hard-fails without it,
# rather than falling back like Pascal does. Used by the #1745 warning in
# extract() to tell the user which extra restores the language.
_EXTRA_FOR_EXTENSION = {
".sql": "sql",
".tf": "terraform",
".tfvars": "terraform",
".hcl": "terraform",
".dm": "dm",
".dme": "dm",
".ml": "ocaml",
".mli": "ocaml",
".lisp": "commonlisp",
".cl": "commonlisp",
".lsp": "commonlisp",
".asd": "commonlisp",
}
# Substrings an extractor's error carries to classify why a dependency-backed
# file contributed nothing, used by the #1745 warning in extract(). A grammar
# that is present but fails to load (#2602) must not be reported as missing —
# the "install the extra" hint would be a no-op.
_DEP_MISSING_MARKER = "not installed"
_DEP_LOAD_FAILED_MARKER = "failed to load"
# Extensionless executables (CLI entry points like `devctl` or `manage`) carry
# their language in the shebang, not the suffix. detect.classify_file already
# routes them to the CODE path via _shebang_interpreter; _get_extractor must
# honor the same signal or these files are classified as code and then silently
# dropped by extraction. Only interpreters with a real extractor are mapped —
# detect's wider set (perl, fish, tcsh, Rscript) stays unmapped and skipped.
_SHEBANG_DISPATCH: dict[str, Any] = {
"python": extract_python,
"python2": extract_python,
"python3": extract_python,
"bash": extract_bash,
"sh": extract_bash,
"dash": extract_bash,
"zsh": extract_bash,
"ksh": extract_bash,
"node": extract_js,
"nodejs": extract_js,
"ruby": extract_ruby,
"lua": extract_lua,
"php": extract_php,
"julia": extract_julia,
}
# ObjC-only directives. They are illegal in C and C++, so finding one in a `.h`
# file is a near-zero-false-positive signal that the header is Objective-C (and so
# belongs to extract_objc, not extract_c). `@property` is deliberately excluded: it
# doubles as a Doxygen comment command and ObjC properties only ever live inside an
# @interface/@protocol anyway, so the stronger directives already cover them.
#
# `#import` is included because an ObjC *bridging* header is often nothing but
# `#import "X.h"` lines with no @interface (#1556). Routed to extract_c it parses
# `#import` as a `preproc_call` (not `preproc_include`), so every import edge is
# dropped and the header is isolated. `#import` is an ObjC-only directive (illegal
# in C and C++), so this won't hijack genuine C/C++ headers, and extract_objc
# resolves quoted imports via _resolve_c_include_path.
_OBJC_HEADER_MARKERS = (b"@interface", b"@protocol", b"@implementation", b"@import", b"#import")
def _is_objc_header(path: Path) -> bool:
"""Whether a `.h` file is Objective-C rather than C/C++ (#1475).
`.h` is shared by C, C++, and ObjC; the suffix map routes it to extract_c,
which silently drops every @interface/@protocol/@property/method (1 node, 0
edges). Sniffing for an ObjC-only directive reroutes genuine ObjC headers to
extract_objc while leaving every C/C++ header on its existing extractor.
"""
try:
head = path.read_bytes()[:256 * 1024]
except OSError:
return False
return any(marker in head for marker in _OBJC_HEADER_MARKERS)
# C++-only signals. None of these are valid in a plain C header, so finding one
# in a `.h` is a high-confidence signal the header is C++ (#1547). The C grammar
# has no class_specifier, so a `class Foo { ... };` header routed to extract_c
# loses the class and its method prototypes (a junk `foo_foo` node + a sourceless
# `class` stub); routing to extract_cpp recovers the real type. Kept CONSERVATIVE:
# a plain C header with none of these stays on extract_c. ObjC sniffing keeps
# priority (an ObjC header can legitimately contain `::`/`class` inside an inline
# C++ block when compiled as Objective-C++).
_CPP_HEADER_MARKERS = (
b"class ", b"namespace ", b"template", b"::",
b"public:", b"private:", b"protected:",
)
def _is_objc_source(path: Path) -> bool:
"""Whether a `.m` file is Objective-C rather than MATLAB/Octave (#1702).
`.m` is shared by Objective-C implementation files and MATLAB (also Octave).
The suffix map routes `.m` to extract_objc unconditionally, which force-parses
MATLAB through the Objective-C tree-sitter grammar and emits garbage nodes/edges
(worse than skipping). A genuine ObjC `.m` always carries an ObjC directive
(@implementation/@interface/@import/#import); MATLAB has none of them. Reuses
the same marker set as the `.h` sniff. `.mm` is unambiguously Objective-C++ and
is not sniffed.
"""
return _is_objc_header(path)
def _is_cpp_header(path: Path) -> bool:
"""Whether a `.h` file is C++ rather than plain C (#1547).
Mirrors `_is_objc_header`: sniffs for a C++-only token. Used only to reroute
a `.h` from extract_c to extract_cpp when no ObjC marker is present (ObjC has
priority). Conservative by construction — a plain C header matches nothing
here and keeps its existing extract_c routing.
"""
try:
head = path.read_bytes()[:256 * 1024]
except OSError:
return False
return any(marker in head for marker in _CPP_HEADER_MARKERS)
def _get_extractor(path: Path) -> Any | None:
"""Return the correct extractor function for a file, or None if unsupported."""
if path.name.lower().endswith(".blade.php"):
return extract_blade
# MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed
# by filename before generic .json dispatch so they get MCP-aware nodes
# (servers, commands, packages, env vars) instead of opaque JSON keys.
if is_mcp_config_path(path):
return extract_mcp_config
# Package manifests (apm.yml, pyproject.toml, go.mod, pom.xml) → a canonical
# package node + depends_on edges, by filename before generic suffix dispatch
# (#1377). apm.yml would otherwise be a .yml document handled by the LLM.
if is_package_manifest_path(path):
return extract_package_manifest
# `.h` is C/C++/ObjC-ambiguous; route Objective-C headers to extract_objc
# (the suffix map sends `.h` to extract_c, which can't read @interface etc.).
# ObjC sniffing has priority over the C++ sniff: an Objective-C++ header can
# contain both `@interface` and inline C++ (`::`), and it must parse as ObjC.
suffix = path.suffix
if suffix not in _DISPATCH and suffix.lower() in _DISPATCH:
suffix = suffix.lower()
if suffix == ".h":
if _is_objc_header(path):
return extract_objc
# A C++ class header routed to extract_c loses the class entirely (the C
# grammar has no class_specifier). Reroute to extract_cpp (#1547).
if _is_cpp_header(path):
return extract_cpp
# `.m` is Objective-C OR MATLAB. extract_objc unconditionally would force-parse
# MATLAB through the ObjC grammar into garbage (#1702). Route to extract_objc
# only when the file actually looks like Objective-C; otherwise leave it without
# an extractor (surfaced by the no-AST-extractor warning, #1689) rather than
# mis-parsed. `.mm` is unambiguously Objective-C++ and stays on extract_objc.
if suffix == ".m" and not _is_objc_source(path):
return None
# Extensionless files: resolve by shebang, mirroring detect.classify_file.
# Without this, detect labels e.g. `#!/usr/bin/env bash` CLIs as code but
# extraction returns no extractor and the file silently contributes nothing.
if not suffix:
from graphify.detect import _shebang_interpreter
interp = _shebang_interpreter(path)
if interp is not None:
return _SHEBANG_DISPATCH.get(interp)
return _DISPATCH.get(suffix)
def _safe_extract_with_xaml_root(extractor, path: Path, root: Path) -> dict:
global _XAML_ACTIVE_EXTRACT_ROOT
previous_root = _XAML_ACTIVE_EXTRACT_ROOT
_XAML_ACTIVE_EXTRACT_ROOT = root.resolve()
try:
return _safe_extract(extractor, path)
finally:
_XAML_ACTIVE_EXTRACT_ROOT = previous_root
def _extract_single_file(args: tuple) -> tuple[int, dict]:
"""Worker function for parallel extraction. Runs in a subprocess.
Must be at module level (not a closure) so it can be pickled by
ProcessPoolExecutor.
Args:
args: (index, path_str, root_str, cache_location_str) tuple. ``root``
anchors hash keys / node ids / the XAML boundary; ``cache_location``
is where the cache dir is written, decoupled per #1774. A legacy
3-tuple (no cache_location) is still accepted for back-compat.
Returns:
(index, result_dict) so results can be placed back in order.
"""
if len(args) == 4:
idx, path_str, root_str, cache_location_str = args
else: # legacy 3-tuple: location == anchor
idx, path_str, root_str = args
cache_location_str = root_str
path = Path(path_str)
root = Path(root_str)
cache_location = Path(cache_location_str)
_raise_recursion_limit()
bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES
# Check cache first (avoid re-extraction)
if not bypass_cache:
cached = load_cached(path, root, cache_root=cache_location)
if cached is not None:
return idx, cached
extractor = _get_extractor(path)
if extractor is None:
return idx, {"nodes": [], "edges": []}
result = _safe_extract_with_xaml_root(extractor, path, root)
# Never cache a zero-node result for an extractable file. Every supported
# source produces at least a file node, so an empty node list is anomalous
# (e.g. a transient batch/parallel hiccup). Caching it makes the empty
# byte-stable across runs and silently blinds affected/explain to and
# through the file (#1666); skipping the write lets a rerun self-heal.
if not bypass_cache and "error" not in result and result.get("nodes"):
save_cached(path, result, root, cache_root=cache_location)
return idx, result
def _extract_parallel(
uncached_work: list[tuple[int, Path]],
per_file: list[dict | None],
root: Path,
max_workers: int | None,
total_files: int,
cache_location: Path | None = None,
) -> bool:
"""Extract uncached files in parallel using ProcessPoolExecutor.
Returns True if the pool ran to completion. Returns False if the pool
failed in a recoverable way (typically Windows-spawn without an
``if __name__ == "__main__"`` guard in the calling script, which causes
BrokenProcessPool); the caller should fall back to sequential extraction.
"""
import concurrent.futures
if max_workers is None:
# Honour GRAPHIFY_MAX_WORKERS env override; otherwise scale to the
# full CPU. The historical `, 8)` cap was a safety bound for laptops
# in 2023 — on a 32-thread workstation it costs a 4x slowdown
# (issue #792). Capping at len(uncached_work) keeps small jobs
# from spawning useless idle workers.
env_raw = os.environ.get("GRAPHIFY_MAX_WORKERS", "").strip()
env_cap = None
if env_raw:
try:
v = int(env_raw)
if v > 0:
env_cap = v
except ValueError:
pass
cpu_cap = env_cap if env_cap is not None else (os.cpu_count() or 4)
max_workers = min(cpu_cap, len(uncached_work))
# Windows ProcessPoolExecutor hard-caps at 61 workers (CPython limitation
# tied to WaitForMultipleObjects). Clamp here so every path — auto-compute,
# GRAPHIFY_MAX_WORKERS, and --max-workers — stays valid on >61-core boxes
# (issue #1298). Guard against 0 from an empty work list.
if sys.platform == "win32":
max_workers = min(max_workers, 61)
max_workers = max(max_workers, 1)
# A one-worker pool buys no parallelism: it still pays process spawn plus an
# IPC round trip per file, and it is the one residual case where the parent's
# rebuild watchdog (os._exit) can orphan a worker that is mid-task. The
# Windows post-commit hook exports GRAPHIFY_MAX_WORKERS=1, so this is the
# default there. Hand the work back so the caller extracts sequentially in
# this process instead (#2173).
if max_workers == 1:
return False
# root anchors hash keys / node ids / XAML boundary; cache_location is where
# the cache dir is written (defaults to root when not decoupled) (#1774).
root_str = str(root)
cache_loc_str = str(cache_location if cache_location is not None else root)
work_items = [(idx, str(path), root_str, cache_loc_str) for idx, path in uncached_work]
done_count = 0
failed: list[int] = [] # positions into uncached_work whose future failed
_PROGRESS_INTERVAL = 100
try:
with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as pool:
futures = {
pool.submit(_extract_single_file, item): pos
for pos, item in enumerate(work_items)
}
for future in concurrent.futures.as_completed(futures):
try:
idx, result = future.result()
per_file[idx] = result
except concurrent.futures.process.BrokenProcessPool:
# #2444: a pool that dies while results are being consumed
# raises BrokenProcessPool from every pending future. It
# must reach the pool-level handler below (which returns
# False so the caller falls back to sequential), not be
# swallowed here per-future — that left the remaining
# per_file slots empty and silently dropped the files.
raise
except Exception as exc:
pos = futures[future]
print(
f" warning: worker failed for {work_items[pos][1]}: {exc}",
file=sys.stderr, flush=True,
)
failed.append(pos)
done_count += 1
if (
total_files >= _PROGRESS_INTERVAL
and done_count % _PROGRESS_INTERVAL == 0
):
print(
f" AST extraction: {done_count}/{len(uncached_work)} uncached files "
f"({done_count * 100 // len(uncached_work)}%) [{max_workers} workers]",
flush=True,
)
except concurrent.futures.process.BrokenProcessPool:
# On Windows (spawn start method) the worker subprocesses re-import the
# caller's __main__. Inline invocations like `python -c "..."` have no
# __main__ guard, so worker bootstrap raises and the pool dies before
# any work completes. Fall back to in-process sequential extraction —
# slower but correct.
print(
" warning: parallel extraction failed (BrokenProcessPool); "
"falling back to sequential. On Windows this usually means the "
'caller is missing an `if __name__ == "__main__":` guard. Pass '
"parallel=False to extract() to skip the pool entirely.",
flush=True,
)
return False
if failed:
# #2445: retry per-future failures once, in-process, instead of leaving
# their per_file slots None (which the defensive fill downstream turned
# into well-formed empties — silent data loss). This is bounded, not a
# loop: _extract_sequential goes through _safe_extract, which converts
# a second failure into an error-carrying result.
_extract_sequential(
[uncached_work[pos] for pos in failed],
per_file, root, total_files, cache_location,
)
if total_files >= _PROGRESS_INTERVAL:
# Report the same denominator the intermediate lines used (uncached files
# actually processed this run), not total_files — switching to the full
# corpus made the count jump upward at the end (cached hits + files with no
# extractor never entered uncached_work), which read as inconsistent (#1693).
_done = len(uncached_work)
print(
f" AST extraction: {_done}/{_done} uncached files (100%) [{max_workers} workers]",
flush=True,
)
return True
def _extract_sequential(
uncached_work: list[tuple[int, Path]],
per_file: list[dict | None],
root: Path,
total_files: int,
cache_location: Path | None = None,
) -> None:
"""Extract uncached files sequentially (fallback for small batches)."""
_PROGRESS_INTERVAL = 100
for work_idx, (idx, path) in enumerate(uncached_work):
if (
total_files >= _PROGRESS_INTERVAL
and work_idx % _PROGRESS_INTERVAL == 0
and work_idx > 0
):
print(
f" AST extraction: {work_idx}/{len(uncached_work)} uncached files ({work_idx * 100 // len(uncached_work)}%)",
flush=True,
)
extractor = _get_extractor(path)
if extractor is None:
per_file[idx] = {"nodes": [], "edges": []}
continue
bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES
# XAML boundary anchors on `root` (the corpus), not the cache location.
result = _safe_extract_with_xaml_root(extractor, path, root)
# See _extract_single_file: don't cache an anomalous zero-node result (#1666).
if not bypass_cache and "error" not in result and result.get("nodes"):
save_cached(path, result, root, cache_root=cache_location)
per_file[idx] = result
if total_files >= _PROGRESS_INTERVAL:
# Consistent denominator with the intermediate lines (#1693).
_done = len(uncached_work)
print(f" AST extraction: {_done}/{_done} uncached files (100%)", flush=True)
_PARALLEL_THRESHOLD = 20
def extract(
paths: list[Path],
cache_root: Path | None = None,
*,
root: Path | None = None,
parallel: bool = True,
max_workers: int | None = None,
resolution_context_nodes: list[dict] | None = None,
resolution_context_edges: list[dict] | None = None,
) -> dict:
"""Extract AST nodes and edges from a list of code files.
Two-pass process:
1. Per-file structural extraction (classes, functions, imports)
2. Cross-file import resolution: turns file-level imports into
class-level INFERRED edges (DigestAuth --uses--> Response)
Args:
paths: files to extract from
root: explicit anchor for source_file relativization, node ids, and
symbol resolution. Pass the SCAN root whenever the cache lives
somewhere else (`--out`); without it the anchor falls back to
cache_root and every scanned file reads as out-of-root (#1941).
cache_root: explicit root for graphify-out/cache/ (overrides the
inferred common path prefix). Pass Path('.') when running on a
subdirectory so the cache stays at ./graphify-out/cache/.
Anchors ids/source_file only as a fallback when `root` is unset.
parallel: if True and there are >= _PARALLEL_THRESHOLD uncached files,
use ProcessPoolExecutor for multi-core extraction.
max_workers: max subprocess count. Defaults to cpu_count (or the
value of GRAPHIFY_MAX_WORKERS if set), bounded by len(uncached_work).
resolution_context_nodes: read-only AST nodes from files that are NOT
being extracted this run (an incremental rebuild's unchanged
corpus, #2406). They extend the cross-file resolution indexes —
the shared direct-call pass's label/file indexes, the
indirect_call callable guard (via the persisted `_callable` /
`_callable_class` markers, #2438), and the member-call resolvers
run by `run_language_resolvers` (#2437) — so a changed caller can
still bind `foo()`, `obj.method()`, or `submit(handler)` to an
unchanged callee. They are never parsed, mutated, or returned;
raw_calls come only from `paths`, so only edges sourced by the
re-extracted files are emitted.
resolution_context_edges: the `contains`/`method` edges of the same
unchanged corpus (#2437). The member-call resolvers walk these to
map a receiver type to the single class owning the called method;
without them an unchanged callee's class never passes the
single-definition guard. Read-only, same contract as
resolution_context_nodes: they widen the resolvers' view but only
fresh results are appended to the returned nodes/edges.
"""
paths = [Path(p) for p in paths]
anchor_root = Path(root) if root is not None else None
_check_tree_sitter_version()
_raise_recursion_limit()
# Workspace package manifests/globs can change during watch or repeated extraction.
_WORKSPACE_PACKAGE_CACHE.clear()
_XAML_CSHARP_CLASS_CACHE.clear()
_MD_LINK_INDEX_CACHE.clear()
# Infer a common root for cache keys (use first diverging segment, not sum of all matches)
try:
if not paths:
root = Path(".")
elif len(paths) == 1:
root = paths[0].parent
else:
min_parts = min(len(p.parts) for p in paths)
common_len = 0
for i in range(min_parts):
if len({p.parts[i] for p in paths}) == 1:
common_len += 1
else:
break
root = Path(*paths[0].parts[:common_len]) if common_len else Path(".")
except Exception:
root = Path(".")
# An explicit anchor wins. cache_root is only a fallback anchor: it happens to
# equal the scan root for the no---out CLI path and for watch, but with --out it
# is the OUTPUT dir, and letting it anchor made every scanned file "out-of-root"
# -> _portable_out_of_root_sf() -> bare basename for the whole corpus (#1941).
if anchor_root is not None:
root = anchor_root
elif cache_root is not None:
root = cache_root
root = root.resolve()
# #1774: the cache is an OUTPUT, so when no explicit cache_root is given it is
# written under the current working directory — never `root` (the inferred
# common parent of the inputs), which would drop graphify-out/ inside a
# read-only or foreign corpus. `root` still anchors the content-hash keys,
# node ids, symbol resolution, and the XAML project-scan boundary; only the
# cache directory's location diverges from it.
cache_location = (cache_root if cache_root is not None else Path(".")).resolve()
total = len(paths)
# Phase 1: separate cached hits from uncached work
per_file: list[dict | None] = [None] * total
uncached_work: list[tuple[int, Path]] = []
for i, path in enumerate(paths):
if _get_extractor(path) is None:
per_file[i] = {"nodes": [], "edges": []}
continue
bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES
if not bypass_cache:
cached = load_cached(path, root, cache_root=cache_location)
if cached is not None:
per_file[i] = cached
continue
uncached_work.append((i, path))
# Phase 2: extract uncached files (parallel or sequential)
if uncached_work:
ran_parallel = False
if parallel and len(uncached_work) >= _PARALLEL_THRESHOLD:
ran_parallel = _extract_parallel(
uncached_work, per_file, root, max_workers, total, cache_location
)
if not ran_parallel:
# #2444: only re-extract what the pool didn't finish. A pool that
# breaks mid-run has already filled some per_file slots; redoing
# the whole batch would throw that work away.
_extract_sequential(
[(i, p) for (i, p) in uncached_work if per_file[i] is None],
per_file, root, total, cache_location,
)
# Fill any remaining None slots. With the #2444/#2445 handling above this
# is unreachable; the error marker keeps any regression loud (and out of
# the caches/#1666 paths) instead of letting a dropped file masquerade as
# a legitimately-empty one.
for i in range(total):
if per_file[i] is None:
per_file[i] = {
"nodes": [], "edges": [],
"error": "internal: no extraction result produced",
}
# #1666: surface any source file an extractor accepted but that produced zero
# nodes (not even a file node). Such a file is silently absent from the graph,
# so affected/explain are blind to and through it with no other signal.
_empty_sources: list[str] = []
for i, _p in enumerate(paths):
_res = per_file[i] or {}
if _res.get("nodes") or _res.get("error") or _res.get("skipped"):
continue
if _get_extractor(_p) is not None:
_empty_sources.append(str(_p))
if _empty_sources:
_shown = ", ".join(Path(x).name for x in _empty_sources[:5])
_more = f" (+{len(_empty_sources) - 5} more)" if len(_empty_sources) > 5 else ""
print(
f" warning: {len(_empty_sources)} source file(s) produced zero nodes and "
f"are absent from the graph: {_shown}{_more}. A re-run will retry them "
f"(empties are no longer cached); if it persists, please report the "
f"file(s) (#1666).",
file=sys.stderr, flush=True,
)
# #2543: collect sources that must NOT be stamped as up-to-date in the
# incremental manifest. Two cases:
# - extractor returned an error (missing optional extra, parse failure, …)
# - extractor exists but produced zero nodes (#1666 empty-source set)
# The CLI drops these from the stamped file set and clears any prior
# hashes so the next run retries them after the user installs the extra
# (or the transient failure self-heals) without deleting graphify-out/.
_failed_sources: list[str] = []
_failed_seen: set[str] = set()
for i, _p in enumerate(paths):
_res = per_file[i] or {}
_key = str(_p)
if _res.get("error"):
if _key not in _failed_seen:
_failed_sources.append(_key)
_failed_seen.add(_key)
continue
if _res.get("skipped"):
# The extractor declined this file by design (data JSON, #1224), so
# zero nodes is the intended outcome rather than a failure. Marking
# it failed keeps it out of the incremental manifest and re-queues
# it on every subsequent run, forever (#2879).
continue
if (not _res.get("nodes")) and _get_extractor(_p) is not None:
if _key not in _failed_seen:
_failed_sources.append(_key)
_failed_seen.add(_key)
# #1689: a file counted as code (extension in CODE_EXTENSIONS) but with no AST
# extractor wired up (e.g. .r/.R — there is no tree-sitter-r dispatch) silently
# contributes zero nodes. The #1666 warning above deliberately skips these (it
# only fires when an extractor exists), so surface them explicitly, grouped by
# extension, rather than reporting success as if the language were mapped.
from graphify.detect import CODE_EXTENSIONS as _CODE_EXTS
_no_extractor: dict[str, int] = {}
for _p in paths:
_ext = _p.suffix.lower()
if _ext in _CODE_EXTS and _get_extractor(_p) is None:
_no_extractor[_ext] = _no_extractor.get(_ext, 0) + 1
if _no_extractor:
_by_count = ", ".join(
f"{ext} ({n})" for ext, n in sorted(_no_extractor.items(), key=lambda kv: (-kv[1], kv[0]))
)
_tot = sum(_no_extractor.values())
print(
f" warning: {_tot} file(s) are classified as code but graphify has no AST "
f"extractor for their language, so they contributed nothing to the graph: "
f"{_by_count}. Please open an issue to request support for these (#1689).",
file=sys.stderr, flush=True,
)
# #1745: an extractor IS wired up for these files but bailed out because its
# dependency is missing (e.g. .sql needs tree-sitter-sql from the [sql]
# extra). Neither warning above fires — #1666 skips results that carry an
# error, #1689 only covers files with no extractor — so the graph builds
# "successfully" while every such file silently contributes nothing.
# Surface them grouped by extension, naming the extra that provides the
# dependency when there is one.
_missing_dep_count: dict[str, int] = {}
_missing_dep_error: dict[str, str] = {}
for i, _p in enumerate(paths):
_err = (per_file[i] or {}).get("error") or ""
if _DEP_MISSING_MARKER in _err or _DEP_LOAD_FAILED_MARKER in _err:
_ext = _p.suffix.lower()
_missing_dep_count[_ext] = _missing_dep_count.get(_ext, 0) + 1
_missing_dep_error.setdefault(_ext, _err)
for _ext, _n in sorted(_missing_dep_count.items(), key=lambda kv: (-kv[1], kv[0])):
_extra = _EXTRA_FOR_EXTENSION.get(_ext)
_err_text = _missing_dep_error[_ext]
if _extra and _DEP_MISSING_MARKER in _err_text:
# Genuinely absent optional extra — point the user at the install.
_reason = _err_text.split(". ")[0]
_hint = f' Install it with: pip install "graphifyy[{_extra}]"'
_cause = "a dependency is missing"
else:
# Either no known extra, or the grammar is present but failed to
# load (#2602): surface the real error and never suggest reinstall.
_reason = _err_text
_hint = ""
_cause = ("a dependency is missing" if _DEP_MISSING_MARKER in _err_text
else "a dependency failed to load")
print(
f" warning: {_n} {_ext} file(s) contributed nothing to the graph "
f"because {_cause}: {_reason}.{_hint} (#1745)",
file=sys.stderr, flush=True,
)
# #2551: a file the parser ACCEPTED but only with ERROR recovery (e.g. the
# Kotlin grammar rejecting one-line `class C { val x }` bodies, or Luau
# syntax the Lua grammar can't parse, #2520) extracts partially — sometimes
# to nothing but the file node — with no other signal. Neither warning
# above fires (nodes exist, no error marker), so surface it explicitly,
# naming the first error line so the user can find the construct.
_syntax_error_files: list[tuple[str, int | None]] = []
for i, _p in enumerate(paths):
_res = per_file[i] or {}
_pe = _res.get("parse_errors")
if not _pe:
continue
# #2610/#2599: gate the #2551 warning on plausible symbol loss.
# tree-sitter-typescript sets has_error on tiny fully-recovered errors
# (a `&` in a JSX string attr; a semicolon-less `in_*` interface
# member) that extract completely — stay silent. Warn only when
# nothing beyond the file node extracted, or an ERROR region
# dissolved multiple lines (the genuine #2551 Kotlin one-line-body /
# #2520 Luau case). `multiline_error` is absent from pre-fix cached
# results, so those fall back to the file-node-only arm.
if len(_res.get("nodes", [])) <= 1 or _pe.get("multiline_error"):
_rel = os.path.relpath(str(_p), str(root)).replace("\\", "/")
# Symbols recovered from the file, excluding its own file node. This
# is what separates the two cases the warning otherwise blurs: a file
# that contributed nothing but its file node is a total loss, while
# one that yielded most of its symbols and lost an ERROR region is
# partial. "May be partially extracted" rendered both identically.
_kept = max(len(_res.get("nodes", [])) - 1, 0)
_syntax_error_files.append((_rel, _pe.get("first_error_line"), _kept))
if _syntax_error_files:
def _describe_syntax_error(rel: str, line: "int | None", kept: int) -> str:
_where = f"first error at line {line}" if line else "syntax error"
_got = "no symbols extracted" if kept == 0 else f"{kept} symbol(s) extracted"
return f"{rel} ({_where}, {_got})"
_shown = ", ".join(
_describe_syntax_error(*f) for f in _syntax_error_files[:5]
)
_more = (
f" (+{len(_syntax_error_files) - 5} more)"
if len(_syntax_error_files) > 5 else ""
)
# No issue reference here. This message used to end in "(#2551)" for
# EVERY language, and #2551 is closed and Kotlin-specific ("bundled
# grammar rejects one-line type bodies"). It was the only lead the
# message offered, so a reader following it landed on a resolved problem
# in another language and concluded their own was already tracked
# (#2788). The file, the line and the symbol count are the actionable
# part; a single hardcoded number cannot be right for every grammar.
print(
f" warning: {len(_syntax_error_files)} file(s) had syntax errors and "
f"may be partially extracted: {_shown}{_more}",
file=sys.stderr, flush=True,
)
all_nodes: list[dict] = []
all_edges: list[dict] = []
all_raw_calls: list[dict] = []
for result in per_file:
all_nodes.extend(result.get("nodes", []))
all_edges.extend(result.get("edges", []))
all_raw_calls.extend(result.get("raw_calls", []))
# Function / method / class def ids for the cross-file indirect_call callable
# guard. Built from the `_callable` node marker AFTER the id-remap / disambiguation
# passes below (which rewrite node ids), so it can never go stale — see the
# marker set in the per-file extractor. Populated just before the pass that uses it.
callable_nids: set[str] = set()
_augment_symbol_resolution_edges(paths, all_nodes, all_edges, root)
# Merge a header-declared class (and its methods) with its sibling-impl
# definition into ONE node (C/C++/ObjC #1547/#1556). Runs BEFORE the id-remap
# below: a header symbol and its impl counterpart share an id only while both
# still carry the raw file-stem prefix; the per-file prefix remap then diverges
# them (foo_h vs foo_cpp), so the collapse must happen first. Collapsing here
# also means disambiguation sees one source_file per id and won't split them.
_merge_decl_def_classes(all_nodes, all_edges)
# Remap file node IDs from absolute-path-derived to the canonical
# {parent_dir}_{stem} spec form so (a) graph.json edge endpoints are stable
# across machines (#502) and (b) AST file nodes match the IDs semantic
# subagents generate (#1033). Resolve before relativizing so paths passed in
# relative form still anchor to the (resolved) root.
id_remap: dict[str, str] = {}
# A target OUTSIDE the scan root (an out-of-root ProjectReference/.sln/bash
# `source`/#include/relative import) can't be made relative to root; leaving
# it absolute leaked the scan path including the OS username into a
# committed graph.json (#1899). Fall back to a walk-up relative form, or the
# bare basename when that would still embed foreign path segments (a
# far-away or cross-drive target). Shared below by both the target_file
# remap loop (edges with no node of their own, #2243) and the node-level
# relativization pass further down (#1899/#2195).
def _portable_out_of_root_sf(p: Path) -> str:
try:
rel = os.path.relpath(str(p), str(root)).replace("\\", "/")
except ValueError:
return p.name # different Windows drive: no relative path exists
updepth = 0
for seg in rel.split("/"):
if seg == "..":
updepth += 1
else:
break
# More than a couple of walk-ups means the target lives well outside the
# corpus; its ancestor dirs would embed foreign (possibly user-named)
# segments, so collapse to the basename.
return p.name if updepth > 3 else rel
# Symbol node IDs embed the file stem as a prefix (_file_node_id of the path
# the extractor saw). For a root-level file that stem picks up the absolute
# parent directory name, so a symbol becomes <rootdir>_main_run while the
# file node is correctly relativized to main and the skill.md spec wants
# main_run -- splitting the symbol into AST/semantic ghosts (#1096). Relativize
# the symbol prefix the same way, gated by source_file so two files sharing a
# prefix can't cross-contaminate. Keyed by resolved path -> (old_pref, new_pref).
# Each file maps from up to TWO old prefixes — the input-form prefix
# _file_node_id(path) and the absolute-resolved-form prefix
# _file_node_id(path.resolve()). Alias/workspace imports resolve specifiers
# through .resolve(), so their edge targets are keyed off the ABSOLUTE form;
# when inputs are relative the two forms differ and absolute-derived targets
# would otherwise orphan (#1529). Stored as a list so the symbol-prefix remap
# below can try both (identical forms collapse to one — a no-op).
prefix_remap: dict[Path, list[tuple[str, str]]] = {}
# Canonical stem plus every prefix form a file's symbol ids may appear
# under, keyed by resolved path — consumed by the target_file-guided
# barrel repoint below (#1983). Unlike prefix_remap this records ALL
# in-root files, not just those whose prefix changed.
stem_forms: dict[Path, tuple[str, list[str]]] = {}
# Canonicalize edge-target files too, not just this batch's inputs (#2169).
# On an incremental run `paths` is only the CHANGED files, so a changed
# file's cross-file import/re-export edges keep absolute-path-derived
# target ids the remap below never learns — they match no node in the
# merged graph and silently dangle. The target_file stamp (set at edge
# emit time) names each resolved target, so registering id_remap /
# stem_forms for those in-root files as well lets the edge remap and the
# target_file-guided repoint pass fix them exactly as on a full scan.
remap_paths: list[Path] = list(paths)
_remap_seen: set[Path] = set()
for _p in paths:
try:
_remap_seen.add(_p.resolve())
except (OSError, RuntimeError):
pass
for _e in all_edges:
_tf = _e.get("target_file")
if not _tf:
continue
_raw_tp = Path(_tf)
try:
_tp = _raw_tp.resolve()
except (OSError, RuntimeError):
continue
if _tp in _remap_seen:
# Already covered: either the target is in this batch (its input
# form is the same form the extractors minted ids from, and the
# per-path loop registers both that and the resolved form) or an
# earlier stamped edge registered it. Re-appending it here would
# re-run its per-path iteration AFTER later batch files and could
# flip the last-writer of a colliding old-id key.
continue
_remap_seen.add(_tp)
try:
_tp.relative_to(root)
except ValueError:
# Out-of-root target: `_file_node_id` (used below for in-root
# targets) needs a root-relative path, so it cannot help here.
# No node stands for this target either (target_file-stamped
# edges intentionally mint no stub node, #2195), so unlike an
# out-of-root node the belt-and-braces pass below never learns
# this id from anywhere — without registering it here the raw
# scan-path slug survives in the edge forever (#2243). Give it
# the same portable "ext_" id an out-of-root node would get; a
# target that does not actually exist on disk stays dangling,
# exactly as before.
try:
if _tp.is_file():
ext_new_id = _make_id("ext", _portable_out_of_root_sf(_tp))
id_remap[_make_id(str(_tp))] = ext_new_id
if _raw_tp != _tp:
id_remap[_make_id(str(_raw_tp))] = ext_new_id
# Bash entrypoint endpoints suffix the file-level id with
# "__entry" (script-invocation `calls` edges,
# extractors/bash.py); register the suffixed forms too so
# an out-of-root invoked script canonicalizes instead of
# keeping the absolute scan-path slug (#2243).
id_remap.setdefault(
_make_id(str(_tp)) + "__entry", ext_new_id + "__entry")
if _raw_tp != _tp:
id_remap.setdefault(
_make_id(str(_raw_tp)) + "__entry",
ext_new_id + "__entry")
except OSError:
pass
continue
try:
if not _tp.is_file():
# Speculatively-resolved target that doesn't exist (e.g. an
# import of a not-yet-created sibling): keep its raw id
# dangling, exactly as before, so no false canonical edge is
# fabricated toward a nonexistent file.
continue
except OSError:
continue
remap_paths.append(_tp)
# Also register the AS-STAMPED (unresolved) form. The edge target id
# was minted from the stamped path exactly as written (e.g.
# ``str(base / rel)`` for a Python relative import), which under a
# symlinked root (macOS /tmp -> /private/tmp) or relative inputs
# differs from the resolved form; the per-path loop below derives the
# old ids from whichever Path it is given, so a missing form would
# leave the edge target unmapped and dangling.
if _raw_tp != _tp:
_remap_seen.add(_raw_tp)
remap_paths.append(_raw_tp)
for path in remap_paths:
old_id = _make_id(str(path))
try:
rel = path.relative_to(root)
except ValueError:
try:
rel = path.resolve().relative_to(root)
except ValueError:
continue
new_id = _file_node_id(rel)
if old_id != new_id:
id_remap[old_id] = new_id
# Also register the absolute-resolved form of the file-level id so
# alias/workspace import targets (resolved via .resolve()) remap to
# canonical instead of orphaning (#1529).
old_id_abs = _make_id(str(path.resolve()))
if old_id_abs != new_id:
id_remap[old_id_abs] = new_id
old_prefs: list[tuple[str, str]] = []
old_pref = _file_node_id(path)
if old_pref != new_id:
old_prefs.append((old_pref, new_id))
old_pref_abs = _file_node_id(path.resolve())
if old_pref_abs != new_id and old_pref_abs != old_pref:
old_prefs.append((old_pref_abs, new_id))
# Bash entrypoint node ids append "__entry" to the file-level id
# (extractors/bash.py), so a script-invocation edge endpoint minted
# from an out-of-batch target path keeps a suffixed absolute-derived
# id neither the plain id_remap key above nor the source_file-gated
# prefix pass below (nodes only) can reach on an incremental run
# (#2243). Register the suffixed forms, preserving the extension tail
# exactly as the prefix remap yields for in-batch entry nodes
# (`c.sh` -> `c_sh__entry`): new prefix + the tail of the minted id.
for _old, _pref in ((old_id, old_pref), (old_id_abs, old_pref_abs)):
if not _old.startswith(_pref):
continue
_entry_new = new_id + _old[len(_pref):] + "__entry"
_entry_old = _old + "__entry"
if _entry_old != _entry_new:
id_remap.setdefault(_entry_old, _entry_new)
if old_prefs:
prefix_remap[path.resolve()] = old_prefs
# Absolute form first: it is the longest, so prefix decomposition can
# try forms in order without a shorter form shadowing it.
stem_forms[path.resolve()] = (
new_id, [old_pref_abs, old_pref, new_id]
)
if id_remap:
for n in all_nodes:
if n.get("id") in id_remap:
n["id"] = id_remap[n["id"]]
for e in all_edges:
if e.get("source") in id_remap:
e["source"] = id_remap[e["source"]]
if e.get("target") in id_remap:
e["target"] = id_remap[e["target"]]
# raw_calls carry caller_nid, consumed by the cross-file call pass far
# below (after this remap). A module-TOP-LEVEL indirect_call/callback
# records the FILE-level id as its caller — the id minted from the
# absolute input path that this very remap just rewrote on the file
# node. Without rewriting the raw_calls too, the emitted
# indirect_call edge keeps the machine-specific absolute-derived
# source and matches no node in the graph (#2231). Mirrors the
# sym_remap raw_calls rewrite in the prefix pass below.
for rc in all_raw_calls:
cn = rc.get("caller_nid")
if cn in id_remap:
rc["caller_nid"] = id_remap[cn]
# swift_extensions[].nid is the same kind of id carrier as caller_nid
# above (cache.py remaps both), consumed by _merge_swift_extensions far
# below. Left stale it matches no node, so whether the extension merge
# runs at all depends on the FORM of the paths handed to extract() —
# relative input already yields the post-remap slug, absolute input does
# not (#2538). Remap it here so both agree.
for result in per_file:
for ext in result.get("swift_extensions", []) or []:
en = ext.get("nid")
if en in id_remap:
ext["nid"] = id_remap[en]
if prefix_remap:
sym_remap: dict[str, str] = {}
edge_alias_candidates: dict[str, set[str]] = {}
for n in all_nodes:
sf = n.get("source_file")
if not sf:
continue
# Package nodes carry a canonical name-keyed id (pkg_<name>) that must
# stay identical across every manifest that references the package, so
# they are exempt from the file-stem prefix remap (#1377), like the
# type=module anchors (#1327).
if n.get("type") == "package":
continue
try:
entry = prefix_remap.get(Path(sf).resolve())
except Exception:
continue
if entry is None:
continue
nid = n.get("id", "")
# Try both the input-form and absolute-form prefixes for this file
# (#1529). source_file gating above already prevents cross-file
# contamination, so the first matching prefix wins.
canonical_nid: str | None = None
for old_pref, new_pref in entry:
if nid.startswith(old_pref + "_"):
canonical_nid = new_pref + nid[len(old_pref):]
if canonical_nid != nid:
sym_remap[nid] = canonical_nid
break
if nid.startswith(new_pref + "_"):
canonical_nid = nid
break
if canonical_nid is None:
continue
# Named alias imports/re-exports can retain an absolute-prefixed target
# when the symbol node is already canonical. Record every old form
# so a redundant import edge or dangling re-export target can be fixed
# without globally reinterpreting an id that another real node may own.
for old_pref, new_pref in entry:
if not canonical_nid.startswith(new_pref + "_"):
continue
old_nid = old_pref + canonical_nid[len(new_pref):]
if old_nid != canonical_nid:
edge_alias_candidates.setdefault(old_nid, set()).add(canonical_nid)
if sym_remap:
for n in all_nodes:
if n.get("id") in sym_remap:
n["id"] = sym_remap[n["id"]]
for e in all_edges:
if e.get("source") in sym_remap:
e["source"] = sym_remap[e["source"]]
if e.get("target") in sym_remap:
e["target"] = sym_remap[e["target"]]
# raw_calls carry caller_nid (a symbol id) consumed by the cross-file
# call pass below, after this remap — rewrite it too or those edges
# would dangle on their (stale) source.
for rc in all_raw_calls:
cn = rc.get("caller_nid")
if cn in sym_remap:
rc["caller_nid"] = sym_remap[cn]
# Same for swift_extensions[].nid (see the id_remap pass above).
for result in per_file:
for ext in result.get("swift_extensions", []) or []:
en = ext.get("nid")
if en in sym_remap:
ext["nid"] = sym_remap[en]
if edge_alias_candidates:
def _edge_key(edge: dict) -> str:
# target_file is a transient stamp (#1814/#1983); exclude it
# from twin identity or an alias edge (stamped) never matches
# the canonical twin the shared resolver emits (unstamped).
return json.dumps(
{k: v for k, v in edge.items() if k != "target_file"},
sort_keys=True, separators=(",", ":"), default=str,
)
edge_key_counts = Counter(_edge_key(edge) for edge in all_edges)
owned_node_ids = {node.get("id") for node in all_nodes}
deduped_edges: list[dict] = []
for edge in all_edges:
if edge.get("relation") == "re_exports":
candidates = edge_alias_candidates.get(edge.get("target", ""), set())
if len(candidates) == 1 and edge.get("target") not in owned_node_ids:
edge["target"] = next(iter(candidates))
deduped_edges.append(edge)
continue
candidates = (
edge_alias_candidates.get(edge.get("target", ""), set())
if edge.get("relation") == "imports"
else set()
)
if len(candidates) == 1:
candidate = next(iter(candidates))
twin_key = _edge_key({**edge, "target": candidate})
# Drop only when the shared resolver emitted the exact
# canonical twin. Otherwise the target may be a legitimate
# owned node id.
if edge_key_counts[twin_key]:
if edge.get("target") in owned_node_ids:
edge_key_counts[twin_key] -= 1
continue
deduped_edges.append(edge)
all_edges[:] = deduped_edges
# Repoint symbol-level alias edges that resolve THROUGH a barrel (#1983
# follow-up). The candidates rewrite above learns old→canonical forms only
# from symbols a file DEFINES; a barrel defines nothing, so a re-export or
# named import that resolves to one keeps an absolute-prefixed, dangling
# target no rewrite ever learns. Use the target_file stamp to decompose
# such a target into (canonical file stem, symbol), follow the barrel's own
# already-canonical re_exports edge to the defining symbol — iterating so
# multi-hop barrel chains resolve one hop per pass — and, when no chain
# leads to a real node, canonicalize the prefix anyway so a checkout path
# never survives in an edge target.
if stem_forms:
owned_ids = {n.get("id") for n in all_nodes}
def _decompose(target: str, tf: str) -> "tuple[str, str] | None":
try:
forms = stem_forms.get(Path(tf).resolve())
except (OSError, RuntimeError):
return None
if not forms:
return None
canonical, prefixes = forms
for pref in prefixes:
if pref and target.startswith(pref + "_"):
return canonical, target[len(pref) + 1:]
return None
# (canonical file id, symbol) → set of owned targets, learned from
# symbol-level re_exports edges that already point at a real node. A set
# (not last-write-wins): when a barrel re-exports the SAME local name
# from two different modules (`export {x} from './a'; export {x as y}
# from './b'` — both key on local name `x`), the key becomes ambiguous
# and must NOT be guessed, or we fabricate a wrong edge. Ambiguous keys
# resolve to None so the edge falls to the dangling-canonical fallback
# (dropped at build), while the shared resolver's correct edge survives.
chain: dict[tuple[str, str], set] = {}
def _resolve1(key) -> "str | None":
targets = chain.get(key)
return next(iter(targets)) if targets and len(targets) == 1 else None
def _learn(e: dict) -> None:
tf = e.get("target_file")
if not tf or e.get("target") not in owned_ids:
return
dec = _decompose(e.get("target", ""), tf)
if dec is not None:
chain.setdefault((e.get("source"), dec[1]), set()).add(e["target"])
for e in all_edges:
if e.get("relation") == "re_exports":
_learn(e)
pending = [
e for e in all_edges
if e.get("relation") in ("re_exports", "imports")
and e.get("target_file")
and e.get("target") not in owned_ids
]
for _ in range(8): # bounded: each pass resolves one barrel hop
progressed = False
still: list[dict] = []
for e in pending:
dec = _decompose(e.get("target", ""), e["target_file"])
resolved_target = _resolve1((dec[0], dec[1])) if dec else None
if resolved_target is None:
still.append(e)
continue
e["target"] = resolved_target
if e.get("relation") == "re_exports":
# This barrel's edge now feeds the next hop. Learn it
# directly — decomposing the repointed target against this
# edge's own target_file would fail, since the target now
# carries the DEFINING file's stem, not the barrel's.
chain.setdefault((e.get("source"), dec[1]), set()).add(resolved_target)
progressed = True
pending = still
if not progressed:
break
for e in pending:
dec = _decompose(e.get("target", ""), e["target_file"])
if dec is not None:
e["target"] = f"{dec[0]}_{dec[1]}"
# Repoint Python absolute imports onto the real file nodes under a nested
# (src/) package root before the resolver/import-evidence passes run, so the
# graph is identical regardless of scan root (#2072).
_repoint_python_package_imports(paths, all_nodes, all_edges, root)
_merge_swift_extensions(per_file, all_nodes, all_edges)
_merge_csharp_partial_class_nodes(per_file, all_nodes, all_edges, paths, root)
_disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root)
_canonicalize_csharp_namespace_nodes(all_nodes, all_edges)
# PHP namespace/use disambiguation must run BEFORE the unique-stub rewire:
# the false merge (#1923) happens inside the rewire when a bare-name stub
# matches a unique internal class from a different namespace.
_php_exts = {".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}
_php_sel = [
(r, p) for r, p in zip(per_file, paths)
if p.suffix.lower() in _php_exts and not p.name.lower().endswith(".blade.php")
]
if _php_sel:
try:
_resolve_php_type_references(
[r for r, _ in _php_sel], [p for _, p in _php_sel], all_nodes, all_edges
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("PHP type-reference resolution failed, skipping: %s", exc)
# Java package/import disambiguation must likewise run BEFORE the rewire
# (#2504): an EXTERNAL import (`org.springframework.stereotype.Component`)
# leaves a bare `Component` stub that the rewire would collapse onto the only
# internal class with that simple name, manufacturing a false hub. Parking
# such references on an FQN-labeled stub first prevents the merge, and
# import-exact resolution of internal references (#1318/#1744) still applies.
_java_sel = [(r, p) for r, p in zip(per_file, paths) if p.suffix == ".java"]
if _java_sel:
try:
_resolve_java_type_references(
[r for r, _ in _java_sel], [p for _, p in _java_sel], all_nodes, all_edges
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Java type-reference resolution failed, skipping: %s", exc)
# Resolve internal Go pkg.Type references exactly and park external ones
# before the generic bare-label stub rewire can manufacture a collision.
_go_sel = [(r, p) for r, p in zip(per_file, paths) if p.suffix == ".go"]
if _go_sel:
try:
_resolve_go_type_references(
[r for r, _ in _go_sel], [p for _, p in _go_sel],
all_nodes, all_edges, root,
resolution_context_nodes, resolution_context_edges,
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"Go type-reference resolution failed, skipping: %s", exc
)
_rewire_unique_stub_nodes(all_nodes, all_edges)
# Add cross-file class-level edges (Python only - uses Python parser internally)
py_paths = [p for p in paths if p.suffix == ".py"]
if py_paths:
py_results = [r for r, p in zip(per_file, paths) if p.suffix == ".py"]
try:
cross_file_edges = _resolve_cross_file_imports(py_results, py_paths)
all_edges.extend(cross_file_edges)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Cross-file import resolution failed, skipping: %s", exc)
# Cross-file Java import resolution
java_paths = [p for p in paths if p.suffix == ".java"]
if java_paths:
java_results = [r for r, p in zip(per_file, paths) if p.suffix == ".java"]
try:
all_edges.extend(_resolve_cross_file_java_imports(java_results, java_paths))
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Java cross-file import resolution failed, skipping: %s", exc)
# Cross-file C# type-reference resolution: re-point dangling inherits/implements/
# references edges left on shadow stubs, disambiguating same-named types by the
# referencing file's `using` directives + enclosing namespace (mirrors Java #1318).
cs_paths = [p for p in paths if p.suffix == ".cs"]
if cs_paths:
cs_results = [r for r, p in zip(per_file, paths) if p.suffix == ".cs"]
try:
_resolve_csharp_type_references(cs_results, cs_paths, all_nodes, all_edges)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("C# type-reference resolution failed, skipping: %s", exc)
try:
_resolve_cross_file_csharp_imports(cs_results, cs_paths, all_nodes, all_edges)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("C# cross-file import resolution failed, skipping: %s", exc)
# Cross-file Bash source-backed call resolution: a call to a function defined
# in a file this one `source`s is left unresolved by the per-file extractor
# (it only links calls to same-file functions, #2141). Match each bash raw_call
# against functions in the sourced files and emit the calls edge — scoped to
# the source relationship, so a call to an external command never binds to a
# same-named function in an unsourced file. Runs after the id-remap passes
# above so caller_nids and function node ids are final; dedups the source
# edge the extractor already emitted via existing_edges.
# Selecting by filename suffix alone missed extensionless scripts:
# _SHEBANG_DISPATCH routes a `#!/usr/bin/env bash` file with no extension to
# extract_bash, so its functions get indexed, but a suffix-only filter left it
# out of this pass and calls into it never resolved (#2171). Select by shape
# too — the bash extractor tags every node it emits with
# metadata.language == "bash" — while keeping the suffix check so an empty
# .sh file (no nodes to inspect) still participates.
def _looks_like_bash(result: object) -> bool:
if not isinstance(result, dict):
return False
nodes = result.get("nodes")
if not isinstance(nodes, list):
return False
for n in nodes:
if not isinstance(n, dict):
continue
md = n.get("metadata")
if isinstance(md, dict) and md.get("language") == "bash":
return True
return False
sh_pairs = [
(r, p) for r, p in zip(per_file, paths)
if p.suffix in (".sh", ".bash") or _looks_like_bash(r)
]
if sh_pairs:
sh_results = [r for r, _ in sh_pairs]
sh_paths = [p for _, p in sh_pairs]
try:
all_edges.extend(
resolve_bash_source_edges(sh_results, sh_paths, root, existing_edges=all_edges)
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Bash cross-file call resolution failed, skipping: %s", exc)
# Cross-file call resolution for all languages
# Each extractor saved unresolved calls in raw_calls. Now that we have all
# nodes from all files, resolve any callee that exists in another file.
# Build name → ALL matching node IDs so we can skip ambiguous common names
# (e.g. "log", "execute", "find") that appear in multiple files — resolving
# those inflates god_nodes ranking with spurious cross-file edges.
# Build label -> node_id index for cross-file call resolution.
# Skip rationale nodes (their labels are docstring text, not callable
# identifiers, and they were polluting matches for short names — #563).
global_label_to_nids: dict[str, list[str]] = {} # exact-case (all languages)
global_label_to_nids_ci: dict[str, list[str]] = {} # case-INSENSITIVE-language nodes
# #2406: on an incremental rebuild only the CHANGED files are parsed, so
# `all_nodes` alone cannot see a callee that lives in an unchanged file and
# every changed->unchanged DIRECT call silently vanished (while the file-level
# `imports` edge survived, because the JS/Python symbol-resolution pass
# reads the import TARGET off disk instead of off the node list). Extend the
# resolution indexes — and ONLY the indexes — with the caller-supplied
# unchanged-corpus nodes. Fresh nodes win on id collision, nothing is
# appended to `all_nodes`, and raw_calls still come solely from `paths`, so
# the emitted edges remain sourced by the re-extracted files.
#
# Scope: this list feeds the shared direct-call loop below, the
# indirect_call callable guard (#2438, via the persisted `_callable` /
# `_callable_class` markers), and — together with resolution_context_edges —
# the member-call resolvers run by run_language_resolvers (#2437).
resolution_nodes = all_nodes
if resolution_context_nodes:
_fresh_ids = {n["id"] for n in all_nodes}
resolution_nodes = all_nodes + [
n for n in resolution_context_nodes
if n.get("id") and n["id"] not in _fresh_ids
]
for n in resolution_nodes:
if n.get("file_type") == "rationale" or n.get("type") == "namespace":
continue
raw = n.get("label", "")
normalised = raw.strip("()").lstrip(".")
if normalised:
# Case is semantic in most languages, so index (and match, below) by exact
# case — folding collapses `Path` (class) into `PATH` (env var) and makes a
# single shell variable the #1 god-node (#1581). Only case-insensitive
# languages (PHP/SQL/Nim) also get a folded key for legitimate fold-matching.
global_label_to_nids.setdefault(normalised, []).append(n["id"])
if _lang_is_case_insensitive(n.get("source_file")):
global_label_to_nids_ci.setdefault(normalised.lower(), []).append(n["id"])
# Callable-def ids for the indirect_call callable guard, read from the `_callable`
# marker on the FINAL (post-remap) nodes — so a callback resolves only to a real
# function/method/class, never a same-named data symbol, and the guard never goes
# stale when node ids were relativized/disambiguated above (#1566). Read from
# `resolution_nodes`, not `all_nodes` (#2438): an unchanged callee's context node
# carries the marker persisted in graph.json, so an incremental rebuild keeps
# resolving callbacks into unchanged files while data symbols stay excluded.
callable_nids = {n["id"] for n in resolution_nodes if n.get("_callable")}
# Class defs are callable only via their constructor; they are frequently passed
# as descriptive values (`select(Model)`, exception tuples), not invoked. Exclude
# them from the indirect_call guard below to avoid false edges (#2137).
class_nids = {n["id"] for n in resolution_nodes if n.get("_callable_class")}
# Kotlin import targets (#2526): rewrite each `imports` edge from the bare
# last-segment id to the node its written FQN names, via the per-file
# package declarations. Runs HERE — after the id-remap/disambiguation passes
# (ids are final) but before the import-evidence index just below reads the
# edges — so genuine imported calls get promoted INFERRED -> EXTRACTED. The
# tail registry run (run_language_resolvers below) would be too late.
run_language_resolvers(
paths, per_file, all_nodes, all_edges,
resolvers=[_KOTLIN_IMPORT_TARGET_RESOLVER],
)
# Build evidence index from import edges so cross-file calls backed by an
# explicit import statement can be promoted from INFERRED to EXTRACTED.
# Direct symbol imports (`import { foo }` / `const { foo } = require()`) are
# the strongest evidence — caller's file_id has an `imports` edge directly to
# the callee's symbol id. Module imports (`imports_from`) are weaker but still
# confirm the caller pulled in the callee's source file.
file_to_symbol_imports: dict[str, set[str]] = {}
file_to_module_imports: dict[str, set[str]] = {}
for e in all_edges:
if e.get("relation") == "imports":
file_to_symbol_imports.setdefault(e["source"], set()).add(e["target"])
elif e.get("relation") == "imports_from":
file_to_module_imports.setdefault(e["source"], set()).add(e["target"])
# Map each node back to its containing file node id so we can ask
# "did the caller's file import the callee's file?"
# A node and its file node share the exact same ``source_file`` string, and a
# file node is the one whose label is the basename (``add_node(file_nid,
# path.name)``). Resolving file membership by that shared string is robust
# against the path-resolution/symlink mismatch that makes
# ``relative_to(root.resolve())`` throw and fall back to a non-matching
# absolute-derived id — which would spuriously fail import evidence and (with
# the #1659 JS/TS gate below) drop a legitimately-imported call.
sf_to_file_nid: dict[str, str] = {}
for n in resolution_nodes:
sf = n.get("source_file")
if sf and n.get("label") == Path(str(sf)).name:
sf_to_file_nid.setdefault(str(sf), n["id"])
nid_to_file_nid: dict[str, str] = {}
# nid -> raw source_file string, for the ambiguous-name tie-breakers below
# (test/non-test classification + path proximity). Kept separate from the
# file-node-id map because tie-breaking compares the actual file paths.
nid_to_source_file: dict[str, str] = {}
for n in resolution_nodes:
sf = n.get("source_file")
if not sf:
continue
nid_to_source_file[n["id"]] = str(sf)
fnid = sf_to_file_nid.get(str(sf))
if fnid is not None:
nid_to_file_nid[n["id"]] = fnid
continue
# Fallback (no file node found for this source_file): derive it the old
# way from the relativized path.
sf_path = Path(sf)
try:
sf_rel = sf_path.relative_to(root) if sf_path.is_absolute() else sf_path
except ValueError:
sf_rel = sf_path
nid_to_file_nid[n["id"]] = _file_node_id(sf_rel)
existing_pairs = {(e["source"], e["target"]) for e in all_edges}
# Call-like pairs only, for the indirect_call dedup: an `imports` edge from a
# file to the symbol it imports is EXPECTED and must not suppress an
# indirect_call to that same symbol (JS/TS named imports create such an edge).
call_like_pairs = {
(e["source"], e["target"]) for e in all_edges
if e.get("relation") in ("calls", "indirect_call")
}
# JS/TS/JSX modules have no implicit cross-module scope: a call into another
# file is real ONLY if the caller imported it. So a cross-file call from one
# of these files with no import evidence is gated below (#1659).
_JS_TS_CALL_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
_go_module_cache: dict[Path, str | None] = {}
for rc in all_raw_calls:
callee = rc.get("callee", "")
if not callee:
continue
if callee in _LANGUAGE_BUILTIN_GLOBALS:
continue
# Skip member-call callees: obj.log() → "log" has no import evidence
# and collides with any top-level function named "log" in the corpus.
if rc.get("is_member_call"):
continue
# Skip Ruby include/extend/prepend mixin markers: they carry a module
# name as `callee` but are not calls — the Ruby resolver turns them into
# `mixes_in` edges. Letting the shared pass emit a `calls` edge here would
# both mislabel the relation and block the mixes_in emit as a dup (#1668).
if rc.get("is_mixin"):
continue
# Bash calls are resolved only by resolve_bash_source_edges (run above),
# which scopes resolution to the files a script actually `source`s. The
# global name match here would bind a bash call to any same-named function
# in an unsourced file (an INFERRED phantom edge) and would resolve calls
# to external commands that merely share a name with a function elsewhere
# in the corpus — exactly what #2141 must not do.
if rc.get("language") == "bash":
continue
# A Go predeclared function is never a cross-file call: the extractor
# already drops bare `append(s, x)` (extractors/go.py), so this is the
# backstop for Go raw_calls minted on any other path. Language-gated
# rather than folded into _LANGUAGE_BUILTIN_GLOBALS because `new`,
# `close` and `delete` are ordinary method names elsewhere (#2296).
if rc.get("language") == "go" and callee in _GO_PREDECLARED_FUNCS:
continue
# Exact-case match first (case is semantic). Fold only when the CALLING
# file's language is case-insensitive, and only against the folded index of
# case-insensitive-language definitions — so a Python `Path()` call can never
# resolve to a shell `PATH` node (#1581).
candidates = global_label_to_nids.get(callee, [])
if not candidates and _lang_is_case_insensitive(rc.get("source_file")):
candidates = global_label_to_nids_ci.get(callee.lower(), [])
if not candidates:
continue
# Cross-language guard: never bind a call to a definition in a different
# language family. Name-only matching was resolving a TSX callback passed
# by name to a same-named Kotlin method in the Android half of the repo
# (and a Python call to a Kotlin fun) — phantom edges the extraction spec
# explicitly forbids. Candidates whose family is unknown (no source_file,
# non-code nodes) are kept, preserving the previous permissive behavior;
# real interop pairs (Kotlin↔Java, C↔C++↔ObjC, JS↔TS) share a family and
# still resolve.
caller_family = _lang_family(rc.get("source_file"))
if caller_family is not None:
candidates = [
c for c in candidates
if (candidate_family := _lang_family(nid_to_source_file.get(c))) is None
or candidate_family == caller_family
]
if not candidates:
continue
# Imported Go selectors carry exact package evidence. External package
# calls yield no internal candidate instead of binding by bare name.
go_exact_import = False
if rc.get("language") == "go" and rc.get("import_path"):
import_path = str(rc["import_path"])
candidates = [
candidate for candidate in candidates
if _go_import_path_for_file(
nid_to_source_file.get(candidate, ""), root, _go_module_cache
) == import_path
]
if not candidates:
continue
go_exact_import = True
caller = rc["caller_nid"]
# Resolve the caller's file via the raw_call's own source_file string,
# which is stable regardless of any caller_nid remap. An indirect
# callback's caller_nid is the file node, whose id may have been
# relativized after the raw_call was recorded, so a caller_nid lookup can
# miss and (with the #1659 gate) drop a legitimately-imported callback.
caller_file_nid = (
sf_to_file_nid.get(str(rc.get("source_file", "")))
or nid_to_file_nid.get(caller)
)
imported_symbols = file_to_symbol_imports.get(caller_file_nid, set())
imported_modules = file_to_module_imports.get(caller_file_nid, set())
def _has_import_evidence(candidate_id: str) -> bool:
# Direct symbol import (`import { foo }`) is the strongest evidence:
# the caller's file has an `imports` edge straight to this symbol.
# A module import (`import './helper.js'`) confirms the caller pulled
# in the file the candidate lives in.
candidate_file_nid = nid_to_file_nid.get(candidate_id)
return (
candidate_id in imported_symbols
or (candidate_file_nid is not None and candidate_file_nid in imported_modules)
)
if len(candidates) == 1:
tgt = candidates[0]
has_import_evidence = go_exact_import or _has_import_evidence(tgt)
else:
# Ambiguous name (defined in 2+ files). Don't bail outright (#1219):
# if the caller has explicit import evidence pointing at exactly one
# of the candidates, that named import disambiguates unambiguously.
# Prefer direct symbol-import matches; fall back to module-import
# matches only when they too collapse to a single target. Without a
# unique evidence-backed pick we skip, preserving the #543 guard
# against over-connecting common short names (log, execute, find).
symbol_matches = [c for c in candidates if c in imported_symbols]
if len(symbol_matches) == 1:
tgt = symbol_matches[0]
has_import_evidence = True
else:
module_matches = [
c for c in candidates
if (cf := nid_to_file_nid.get(c)) is not None and cf in imported_modules
]
if len(module_matches) == 1:
tgt = module_matches[0]
has_import_evidence = True
else:
# No unique import evidence. Instead of dropping the edge
# outright (which let a single same-named test mock erase the
# real call graph, #1553), apply the shared god-node
# tie-breakers (non-test preference, then path proximity).
# Resolve only if exactly one candidate survives; otherwise
# the #543/#1219 guard still holds and we skip.
tgt = disambiguate_ambiguous_candidates(
candidates,
{c: nid_to_source_file.get(c, "") for c in candidates},
rc.get("source_file", ""),
)
if tgt is None:
continue
has_import_evidence = False
if rc.get("indirect"):
# Cross-file indirect dispatch: a callback passed BY NAME
# (`from .h import fn; pool.submit(fn)`, or listed in a dispatch
# table). Resolved through the same single-definition / import-evidence
# candidate logic as a direct call, but emitted as a distinct INFERRED
# `indirect_call` and ONLY when the target is a real callable def —
# never a same-named data symbol. Stays INFERRED even with import
# evidence: the name is referenced as a value here, not invoked. Dedup
# is call-aware (an existing direct `calls` edge pre-empts it; a benign
# `imports` edge to the same symbol does NOT suppress it).
if tgt != caller and (caller, tgt) not in call_like_pairs and tgt in callable_nids and tgt not in class_nids:
call_like_pairs.add((caller, tgt))
all_edges.append({
"source": caller,
"target": tgt,
"relation": "indirect_call",
"context": rc.get("context", "argument"),
"confidence": "INFERRED",
# 0.85, not 0.8: the rubric in references/extraction-spec.md
# is a discrete set {0.55, 0.65, 0.75, 0.85, 0.95} and 0.8 is
# not in it. Same tier, same meaning ("strong inference"),
# now a value the documented scale actually contains (#2813).
"confidence_score": 0.85,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
continue
# #1659: a JS/TS DIRECT call with no import evidence is almost always an
# unrelated same-named export in a package that was never imported — a
# phantom cross-package edge (a 14-package monorepo had `platform` and
# `sidecar` shown as depending on `registry-protocol` purely because it
# exported generically-named symbols). JS/TS modules have no implicit
# cross-module scope, so leave it unresolved rather than binding by name
# alone. Other languages keep the #1553 single-candidate resolution:
# C/C++ headers, Ruby autoload, and same-package implicit scope
# legitimately call across files without an explicit import. Scoped to
# direct calls: the indirect_call path above is already conservative
# (INFERRED, callable-target-gated) and independent of import evidence.
if not has_import_evidence and str(rc.get("source_file", "")).endswith(_JS_TS_CALL_SUFFIXES):
continue
if tgt != caller and (caller, tgt) not in existing_pairs:
existing_pairs.add((caller, tgt))
# Promote to EXTRACTED when there's a direct import edge from the
# caller's file pointing at either the callee symbol itself or the
# file the callee lives in.
if has_import_evidence:
confidence = "EXTRACTED"
confidence_score = 1.0
else:
confidence = "INFERRED"
# 0.85 rather than 0.8 — the rubric's INFERRED set is discrete
# and does not contain 0.8 (#2813).
confidence_score = 0.85
all_edges.append({
"source": caller,
"target": tgt,
"relation": "calls",
"context": "call",
"confidence": confidence,
"confidence_score": confidence_score,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
# Cross-file, language-specific member-call resolution. Runs after the shared
# call pass so node ids/caller_nids are final; each pass is additive (only the
# receiver-typed/qualified calls the shared pass skipped) with its own
# single-definition god-node guard. Registered in graphify.resolver_registry so
# a new language plugs in without editing this body (#1356 Swift, #1446 Python).
#
# #2437: on an incremental rebuild the resolvers must also see the unchanged
# corpus — its nodes (types/methods, from resolution_nodes above) and its
# persisted contains/method edges (resolution_context_edges) — or the
# single-definition guards bail on every changed->unchanged member call. Run
# them over SCRATCH lists that include the context, then keep only the fresh
# results: raw_calls come solely from `paths`, so nothing sourced by an
# unchanged file is ever emitted, and the ambiguity guards count the same
# candidates a full build would (the context is the whole unchanged corpus).
if resolution_context_nodes or resolution_context_edges:
_rl_nodes = list(resolution_nodes)
_rl_edges = all_edges + list(resolution_context_edges or [])
_n0, _e0 = len(_rl_nodes), len(_rl_edges)
run_language_resolvers(paths, per_file, _rl_nodes, _rl_edges)
all_nodes.extend(_rl_nodes[_n0:])
all_edges.extend(_rl_edges[_e0:])
else:
run_language_resolvers(paths, per_file, all_nodes, all_edges)
# Relativize source_file fields so paths are portable across machines (#555).
# When the node's id was itself minted from the absolute path, remap it to a
# portable id and rewrite the edge endpoints that reference it.
# ``_portable_out_of_root_sf`` is defined above, by ``id_remap`` (#2243).
ext_id_remap: dict[str, str] = {}
# General backstop closing the absolute-id leak CLASS (#2231/#2243): any
# producer that minted an id or edge endpoint as _make_id(<absolute path>)
# and was reached by no earlier remap (module-top-level raw_calls before
# #2231, unstamped edges, regex-rescue stubs #2195, ...) still leaks the
# machine/scan-path slug here. Instead of pattern-matching only the item's
# OWN id, LEARN the absolute-derived key forms (as-written and resolved)
# of every file that appears in the batch from the nodes' source_file, map
# them to the file's canonical id — _file_node_id(rel) in-root, the
# #1899/#2250 "ext" recipe out-of-root — and rewrite every node id and
# edge endpoint through that map. Only absolute-derived ids are renamed to
# their canonical form; a key already owned by a real (differently-minted)
# node is never remapped, so no edge is fabricated toward a node it did
# not already reference.
owned_ids = {n.get("id") for n in all_nodes}
# sf string -> (relativized source_file, canonical id, absolute-derived
# key forms). Cached so the path work (resolve() hits the filesystem)
# runs once per file, not once per node.
_sf_forms: dict[str, tuple[str, str, tuple[str, ...]]] = {}
def _sf_entry(sf: str, sf_path: Path) -> tuple[str, str, tuple[str, ...]]:
cached = _sf_forms.get(sf)
if cached is not None:
return cached
try:
rel = sf_path.relative_to(root)
except ValueError:
portable = _portable_out_of_root_sf(sf_path)
canonical_id = _make_id("ext", portable)
new_sf = portable
else:
# In-root: the same canonical repo-relative form the real file
# node uses (_file_node_id), so the scan root can never leak into
# a persisted id. Real file nodes were already remapped by the
# #2169 pass, so only leftover absolute-derived ids match below
# (belt-and-braces for #2195 regex-rescue stubs and friends).
canonical_id = _file_node_id(rel)
new_sf = rel.as_posix()
try:
sf_resolved = sf_path.resolve()
except (OSError, RuntimeError):
sf_resolved = sf_path
# Learn the STEM (extension-dropped) forms too: symbol producers mint
# compound ids as _make_id(_file_stem(path), name), so a node-less
# absolute-derived endpoint arrives as <stem-key>_<symbol> and only
# the stem prefix can identify the file it came from (#2262).
keys = tuple({
_make_id(str(sf_path)),
_make_id(str(sf_resolved)),
_make_id(_file_stem(sf_path)),
_make_id(_file_stem(sf_resolved)),
})
entry = (new_sf, canonical_id, keys)
_sf_forms[sf] = entry
return entry
for item in all_nodes + all_edges:
sf = item.get("source_file")
if not sf:
continue
sf_path = Path(sf)
if not sf_path.is_absolute():
continue
new_sf, canonical_id, keys = _sf_entry(str(sf), sf_path)
if "id" in item:
for key in keys:
if key == canonical_id or key in ext_id_remap:
continue
if key in owned_ids and item.get("id") != key:
# The key is a real node's id minted some other way —
# renaming it (or edges onto it) would corrupt the graph.
# The node that owns it registers it itself when its own
# id IS the absolute-derived form (#2195 stub).
continue
ext_id_remap[key] = canonical_id
item["source_file"] = new_sf
if ext_id_remap:
# Bash entrypoint ids are the file-level id + "__entry"
# (extractors/bash.py); rewrite the suffixed form of any learned key
# the same way so a script-invocation endpoint can't keep the slug.
_ENTRY = "__entry"
def _canon(nid: str) -> str:
if nid in ext_id_remap:
return ext_id_remap[nid]
if nid.endswith(_ENTRY) and nid[: -len(_ENTRY)] in ext_id_remap:
return ext_id_remap[nid[: -len(_ENTRY)]] + _ENTRY
if nid not in owned_ids:
# Node-less suffixed-compound endpoint (#2262): an id minted
# as _make_id(<absolute stem>, <symbol>) by a producer that
# never materialized the node. No node ever registers it, so
# rewrite by longest learned prefix: the endpoint stays
# dangling (no node is fabricated) but becomes
# machine-portable. Ids owned by real nodes are never
# touched (guard above), and only absolute-path-derived
# prefixes are in ext_id_remap, so ordinary ids can't match.
idx = nid.rfind("_")
while idx > 0:
canonical = ext_id_remap.get(nid[:idx])
if canonical is not None:
return canonical + nid[idx:]
idx = nid.rfind("_", 0, idx)
return nid
for n in all_nodes:
if n.get("id"):
n["id"] = _canon(n["id"])
for e in all_edges:
if e.get("source"):
e["source"] = _canon(e["source"])
if e.get("target"):
e["target"] = _canon(e["target"])
# origin_file is an internal disambiguation hint (#1462): the colliding-id pass
# above reads it to keep same-named cross-file stubs distinct, after which nothing
# consumes it. Drop it from the returned nodes so it never ships into graph.json as
# an absolute, machine-specific path — the same "no absolute paths in output"
# contract that relativizes source_file just above (#555, #932). The per-file AST
# cache keeps its own copy, which is what the colliding-id pass reads on a cache hit.
for n in all_nodes:
n.pop("origin_file", None)
# `_callable` / `_callable_class` are deliberately NOT popped (#2438): they
# persist into graph.json — the same underscore-provenance precedent as
# `_origin` below — so an incremental rebuild can hand them back as
# resolution context and the indirect_call callable guard keeps working for
# targets in unchanged files. Callability is never inferred from a persisted
# label (that would reintroduce the #1566/#2137 data-symbol false positives);
# a graph written before the markers existed simply fails closed until its
# files are re-extracted.
# local_alias is a transient import-resolution hint (#2082), same shape as
# target_file (#1814): it exists only so the module arm of
# _resolve_python_member_calls (run above via run_language_resolvers) can
# match an aliased receiver against the import edge it came from. Nothing
# reads it after that pass runs, so drop it here rather than let an internal
# local variable name ship into graph.json. Popped post-resolution, unlike
# target_file (which _disambiguate_colliding_node_ids pops earlier in the
# pipeline) — local_alias must survive until run_language_resolvers has run,
# so it cannot be popped at that earlier point without breaking the fix.
for e in all_edges:
e.pop("local_alias", None)
# Tag AST provenance so the incremental watch rebuild can distinguish
# AST-extracted nodes from semantic/LLM nodes. On a full re-extraction
# the watcher drops any AST-marked node missing from the fresh output
# even when its source file still exists (#1116). Edges carry the same
# marker so edge eviction can be tier-scoped: re-extracting a source
# replaces its AST edges without evicting the semantic edges the AST
# pass cannot regenerate (#1865).
for n in all_nodes:
n["_origin"] = "ast"
for e in all_edges:
e["_origin"] = "ast"
# Canonicalize source_file to POSIX on every node AND edge (#2625).
#
# Extractors build source_file from the Path they were handed, so a run
# given RELATIVE inputs keeps the native separator on Windows. Only the
# relativizing branch of _sf_entry above ever calls as_posix(), so a single
# extraction could emit `src\lib\content.ts` and `src/pages/index.astro`
# side by side. source_file is compared as a STRING downstream
# (build._norm_source_file keying, _derive_prune_root, dedup, and
# analyze.find_import_cycles, which matches an edge's source_file against a
# node's by equality), so two spellings are two different files — the
# fragmentation of #683, and the reason the CLI path (which passes an
# explicit root) looked correct while the library entry point did not.
#
# Safe as a final pass: ids are minted through make_id, which collapses
# every non-word character — `\` and `/` alike — to `_`, so canonicalizing
# the separator here cannot desync an id from its source_file.
#
# PurePath is the NATIVE flavour on purpose: on POSIX a backslash is a legal
# filename character and must be left alone, so this only rewrites paths on
# the platform where `\` is actually a separator.
for _item in (*all_nodes, *all_edges):
_sf = _item.get("source_file")
if _sf and "\\" in str(_sf):
_item["source_file"] = PurePath(_sf).as_posix()
return {
"nodes": all_nodes,
"edges": all_edges,
"input_tokens": 0,
"output_tokens": 0,
# Surfaces failed/empty AST sources to the CLI so the incremental
# manifest does not freeze them as processed (#2543). Callers that
# only read nodes/edges ignore this key.
"failed_sources": _failed_sources,
}
def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | None = None) -> list[Path]:
containment_root = root if root is not None else target
from graphify.detect import _resolves_under_root
if target.is_file():
return [target] if _resolves_under_root(target, containment_root) else []
_EXTENSIONS = set(_DISPATCH.keys())
from graphify.detect import _is_ignored, _is_noise_dir, _load_graphifyignore
ignore_root = root if root is not None else target
patterns = _load_graphifyignore(ignore_root)
# Shared across all _is_ignored calls in this scan so ancestor-directory
# results are memoised instead of re-evaluated per file.
ignore_cache: dict[Path, bool] = {}
def _ignored(p: Path) -> bool:
return bool(patterns and _is_ignored(p, ignore_root, patterns, _cache=ignore_cache))
if not follow_symlinks:
# The old rglob filter rejected paths with a noise component anywhere,
# including components of target itself — preserve that.
if any(_is_noise_dir(part) for part in target.parts):
return []
# When negation (!) patterns exist, skip directory-level ignore pruning
# so negated files inside ignored dirs can still be reached (same
# conservatism as detect's scan walk).
has_negation = any(pat.startswith("!") for _, pat in patterns)
results: list[Path] = []
for dirpath, dirnames, filenames in os.walk(target):
dp = Path(dirpath)
dirnames[:] = [
d for d in dirnames
if not _is_noise_dir(d, dp) # pass parent so "env"/"*_env" is marker-gated (#2058)
and (has_negation or not _ignored(dp / d))
]
for fname in filenames:
p = dp / fname
suffix = p.suffix
if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _resolves_under_root(p, containment_root):
results.append(p)
return sorted(results)
# Walk with symlink following + cycle detection
results = []
for dirpath, dirnames, filenames in os.walk(target, followlinks=True):
if os.path.islink(dirpath):
real = os.path.realpath(dirpath)
parent_real = os.path.realpath(os.path.dirname(dirpath))
if parent_real == real or parent_real.startswith(real + os.sep):
dirnames.clear()
continue
dp = Path(dirpath)
dirnames[:] = [
d for d in dirnames
if not _is_noise_dir(d, dp) # pass parent so "env"/"*_env" is marker-gated (#2058)
and (not (dp / d).is_symlink() or _resolves_under_root(dp / d, containment_root))
]
for fname in filenames:
p = dp / fname
suffix = p.suffix
if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _resolves_under_root(p, containment_root):
results.append(p)
return sorted(results)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python -m graphify.extract <file_or_dir> ...", file=sys.stderr)
sys.exit(1)
paths: list[Path] = []
for arg in sys.argv[1:]:
paths.extend(collect_files(Path(arg)))
result = extract(paths)
print(json.dumps(result, indent=2))