"""engine — moved verbatim from graphify/extract.py.""" from __future__ import annotations import hashlib import importlib from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text from graphify.extractors.models import LanguageConfig from graphify.extractors.resolution import _resolve_js_import_target from graphify.security import sanitize_metadata from pathlib import Path def _csharp_namespace_id(dotted_name: str) -> str: digest = hashlib.sha1(dotted_name.encode("utf-8")).hexdigest()[:16] return f"csharp_namespace:{digest}" REFERENCE_CONTEXTS = frozenset({ "field", "parameter_type", "return_type", "generic_arg", "attribute", "value", "type", }) def _source_location(line: int | str | None) -> str | None: if line is None: return None if isinstance(line, str): return line if line.startswith("L") else f"L{line}" return f"L{line}" def _semantic_reference_edge( source: str, target: str, context: str, source_file: str, line: int | str | None, ) -> dict: if context not in REFERENCE_CONTEXTS: raise ValueError(f"unknown reference context: {context}") return { "source": source, "target": target, "relation": "references", "context": context, "confidence": "EXTRACTED", "source_file": source_file, "source_location": _source_location(line), "weight": 1.0, } _PYTHON_TYPE_CONTAINERS = frozenset({ "list", "dict", "set", "tuple", "frozenset", "type", "List", "Dict", "Set", "Tuple", "FrozenSet", "Type", "Optional", "Union", "Sequence", "Iterable", "Mapping", "MutableMapping", "Iterator", "Callable", "Awaitable", "AsyncIterable", "AsyncIterator", "Coroutine", "Generator", "AsyncGenerator", "ContextManager", "AsyncContextManager", "Annotated", "ClassVar", "Final", "Literal", "Concatenate", "ParamSpec", "TypeVar", "None", "Ellipsis", }) _PYTHON_ANNOTATION_NOISE = frozenset({ # scalar builtins "str", "int", "float", "bool", "bytes", "bytearray", "complex", "object", "True", "False", # unittest.mock "MagicMock", "Mock", "AsyncMock", "NonCallableMock", "NonCallableMagicMock", "PropertyMock", "patch", "sentinel", }) def _python_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: """Walk a Python type annotation; append (name, role) where role is 'type' or 'generic_arg'. Builtin/typing containers (list, dict, Optional, Union, …) are not emitted as refs themselves, but their nested type arguments still count as generic_arg. """ if node is None: return t = node.type if t == "type": for c in node.children: if c.is_named: _python_collect_type_refs(c, source, generic, out) return if t == "identifier": name = _read_text(node, source) if name and name not in _PYTHON_TYPE_CONTAINERS and name not in _PYTHON_ANNOTATION_NOISE: out.append((name, "generic_arg" if generic else "type")) return if t == "attribute": tail = _read_text(node, source).rsplit(".", 1)[-1] if tail and tail not in _PYTHON_TYPE_CONTAINERS and tail not in _PYTHON_ANNOTATION_NOISE: out.append((tail, "generic_arg" if generic else "type")) return if t == "generic_type": for c in node.children: if c.type == "identifier": container = _read_text(c, source) if container and container not in _PYTHON_TYPE_CONTAINERS and container not in _PYTHON_ANNOTATION_NOISE: out.append((container, "generic_arg" if generic else "type")) elif c.type == "type_parameter": for sub in c.children: if sub.is_named: _python_collect_type_refs(sub, source, True, out) return if t == "subscript": value = node.child_by_field_name("value") if value is not None: _python_collect_type_refs(value, source, generic, out) for c in node.children: if c is value or not c.is_named: continue _python_collect_type_refs(c, source, True, out) return if node.is_named: for c in node.children: if c.is_named: _python_collect_type_refs(c, source, generic, out) def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: """Return names declared as `interface` in this C# compilation unit.""" out: set[str] = set() stack = [root_node] while stack: n = stack.pop() if n.type == "interface_declaration": name_node = n.child_by_field_name("name") if name_node is not None: text = _read_text(name_node, source) if text: out.add(text) stack.extend(n.children) return out def _csharp_classify_base(name: str, interface_names: set[str]) -> str: """`implements` if the base name is an interface (declared or by I-prefix convention), else `inherits`.""" if name in interface_names: return "implements" if len(name) >= 2 and name[0] == "I" and name[1].isupper(): return "implements" return "inherits" _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS = frozenset({ "class_declaration", "interface_declaration", "record_declaration", "struct_declaration", "method_declaration", }) def _csharp_type_parameters_in_scope(node, source: bytes) -> frozenset[str]: """Return C# type-parameter names visible from ``node``.""" names: set[str] = set() scope = node while scope is not None: if scope.type in _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS: for child in scope.children: if child.type != "type_parameter_list": continue for param in child.children: if param.type == "type_parameter": name_node = next( (sub for sub in param.children if sub.type == "identifier"), None, ) if name_node is not None: name = _read_text(name_node, source) if name: names.add(name) elif param.type == "identifier": name = _read_text(param, source) if name: names.add(name) scope = scope.parent return frozenset(names) def _csharp_collect_type_refs( node, source: bytes, generic: bool, out: list[tuple[str, str, bool, str]], skip: frozenset[str] | None = None, ) -> None: """Walk a C# type expression; append (name, role, qualified, qualifier) tuples.""" if node is None: return if skip is None: skip = _csharp_type_parameters_in_scope(node, source) t = node.type if t == "predefined_type": return if t == "identifier": name = _read_text(node, source) if name and name not in skip: out.append((name, "generic_arg" if generic else "type", False, "")) return if t == "qualified_name": prefix, _, text = _read_text(node, source).rpartition(".") text = text.split("<", 1)[0] if text and text not in skip: out.append((text, "generic_arg" if generic else "type", True, prefix)) return if t == "generic_name": name_child = node.child_by_field_name("name") if name_child is None: for sub in node.children: if sub.type == "identifier": name_child = sub break if name_child is not None: qualified = name_child.type == "qualified_name" prefix, _, name = _read_text(name_child, source).rpartition(".") if name and name not in skip: out.append((name, "generic_arg" if generic else "type", qualified, prefix if qualified else "")) for sub in node.children: if sub.type == "type_argument_list": for arg in sub.children: if arg.is_named: _csharp_collect_type_refs(arg, source, True, out, skip) return if t in ("nullable_type", "array_type", "pointer_type", "ref_type"): for c in node.children: if c.is_named: _csharp_collect_type_refs(c, source, generic, out, skip) return if node.is_named: for c in node.children: if c.is_named: _csharp_collect_type_refs(c, source, generic, out, skip) def _csharp_attribute_names(method_node, source: bytes) -> list[tuple[str, bool, str]]: """Collect attribute names from a C# method/declaration's attribute_list children.""" names: list[tuple[str, bool, str]] = [] skip = _csharp_type_parameters_in_scope(method_node, source) for child in method_node.children: if child.type != "attribute_list": continue for attr in child.children: if attr.type != "attribute": continue name_node = attr.child_by_field_name("name") if name_node is None: for sub in attr.children: if sub.type in ("identifier", "qualified_name"): name_node = sub break if name_node is not None: qualified = name_node.type == "qualified_name" prefix, _, text = _read_text(name_node, source).rpartition(".") if text and text not in skip: names.append((text, qualified, prefix if qualified else "")) return names _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS = frozenset({ "class_declaration", "interface_declaration", "record_declaration", "method_declaration", "constructor_declaration", }) def _java_type_parameters_in_scope(node, source: bytes) -> frozenset[str]: """Return Java type-parameter names visible from ``node``.""" names: set[str] = set() scope = node while scope is not None: if scope.type in _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS: params = scope.child_by_field_name("type_parameters") if params is not None: for param in params.children: if param.type != "type_parameter": continue name_node = next( (child for child in param.children if child.type == "type_identifier"), None, ) if name_node is not None: names.add(_read_text(name_node, source)) scope = scope.parent return frozenset(names) _JAVA_BUILTIN_TYPES = frozenset({ # java.lang — core "Object", "String", "CharSequence", "StringBuilder", "StringBuffer", "Number", "Byte", "Short", "Integer", "Long", "Float", "Double", "Boolean", "Character", "Void", "Class", "Enum", "Record", "Math", "System", "Thread", "Runnable", "Comparable", "Iterable", "Cloneable", "AutoCloseable", "Appendable", "Readable", "Process", "ProcessBuilder", "Runtime", "Package", "ThreadLocal", "InheritableThreadLocal", # java.lang — throwables "Throwable", "Exception", "RuntimeException", "Error", "IllegalArgumentException", "IllegalStateException", "NullPointerException", "IndexOutOfBoundsException", "ArrayIndexOutOfBoundsException", "ClassCastException", "NumberFormatException", "ArithmeticException", "UnsupportedOperationException", "InterruptedException", "CloneNotSupportedException", "SecurityException", "StackOverflowError", "OutOfMemoryError", "AssertionError", # java.util — collections & core "Collection", "List", "ArrayList", "LinkedList", "Vector", "Stack", "Set", "HashSet", "LinkedHashSet", "TreeSet", "SortedSet", "NavigableSet", "EnumSet", "Map", "HashMap", "LinkedHashMap", "TreeMap", "SortedMap", "NavigableMap", "Hashtable", "EnumMap", "Properties", "Queue", "Deque", "ArrayDeque", "PriorityQueue", "Iterator", "ListIterator", "Comparator", "Optional", "OptionalInt", "OptionalLong", "OptionalDouble", "Collections", "Arrays", "Objects", "Date", "Calendar", "Random", "UUID", "Scanner", "StringJoiner", "StringTokenizer", "BitSet", "Spliterator", "Locale", "NoSuchElementException", "ConcurrentModificationException", # java.util.stream "Stream", "IntStream", "LongStream", "DoubleStream", "Collector", "Collectors", # java.util.function "Function", "BiFunction", "Consumer", "BiConsumer", "Supplier", "Predicate", "BiPredicate", "UnaryOperator", "BinaryOperator", "IntFunction", "ToIntFunction", "ToLongFunction", "ToDoubleFunction", # java.util.concurrent "Callable", "Future", "CompletableFuture", "CompletionStage", "Executor", "ExecutorService", "Executors", "ScheduledExecutorService", "TimeUnit", "ConcurrentHashMap", "ConcurrentMap", "CopyOnWriteArrayList", "BlockingQueue", "CountDownLatch", "Semaphore", "CyclicBarrier", "AtomicInteger", "AtomicLong", "AtomicBoolean", "AtomicReference", # java.time "Instant", "Duration", "Period", "LocalDate", "LocalTime", "LocalDateTime", "ZonedDateTime", "OffsetDateTime", "ZoneId", "ZoneOffset", "DayOfWeek", "Month", "Year", "Clock", "DateTimeFormatter", # java.io / java.nio.file "IOException", "UncheckedIOException", "FileNotFoundException", "File", "InputStream", "OutputStream", "Reader", "Writer", "BufferedReader", "BufferedWriter", "InputStreamReader", "OutputStreamWriter", "FileReader", "FileWriter", "PrintStream", "PrintWriter", "ByteArrayInputStream", "ByteArrayOutputStream", "Serializable", "Closeable", "Path", "Paths", "Files", # java.math "BigDecimal", "BigInteger", }) def _java_collect_type_refs( node, source: bytes, generic: bool, out: list[tuple[str, str]], skip: frozenset[str] | None = None, ) -> None: """Walk a Java type expression; append (name, role) tuples.""" if node is None: return if skip is None: skip = _java_type_parameters_in_scope(node, source) t = node.type if t in ("integral_type", "floating_point_type", "boolean_type", "void_type"): return if t == "type_identifier": name = _read_text(node, source) if name and name not in skip and name not in _JAVA_BUILTIN_TYPES: out.append((name, "generic_arg" if generic else "type")) return if t == "scoped_type_identifier": text = _read_text(node, source).rsplit(".", 1)[-1] if text and text not in _JAVA_BUILTIN_TYPES: out.append((text, "generic_arg" if generic else "type")) return if t == "generic_type": for c in node.children: if c.type in ("type_identifier", "scoped_type_identifier"): text = _read_text(c, source).rsplit(".", 1)[-1] if ( text and text not in _JAVA_BUILTIN_TYPES and (c.type == "scoped_type_identifier" or text not in skip) ): out.append((text, "generic_arg" if generic else "type")) break for c in node.children: if c.type == "type_arguments": for arg in c.children: if arg.is_named: _java_collect_type_refs(arg, source, True, out, skip) return if t == "array_type": for c in node.children: if c.is_named: _java_collect_type_refs(c, source, generic, out, skip) return if node.is_named: for c in node.children: if c.is_named: _java_collect_type_refs(c, source, generic, out, skip) def _java_receiver_type_name(type_node, source: bytes) -> str | None: """Return the concrete declared type usable for Java receiver resolution.""" if type_node is None: return None t = type_node.type if t == "type_identifier": name = _read_text(type_node, source) elif t == "scoped_type_identifier": name = _read_text(type_node, source).rsplit(".", 1)[-1] elif t == "generic_type": base = next( ( child for child in type_node.children if child.type in ("type_identifier", "scoped_type_identifier") ), None, ) return _java_receiver_type_name(base, source) else: return None if ( not name or name in _JAVA_BUILTIN_TYPES or name in _java_type_parameters_in_scope(type_node, source) ): return None return name def _java_declarator_names(declaration_node, source: bytes) -> list[str]: names: list[str] = [] for child in declaration_node.children: if child.type != "variable_declarator": continue name_node = child.child_by_field_name("name") if name_node is not None: name = _read_text(name_node, source) if name: names.append(name) return names def _java_lambda_parameters( lambda_node, source: bytes, ) -> list[tuple[str, str | None]]: parameters = lambda_node.child_by_field_name("parameters") if parameters is None: return [] if parameters.type == "identifier": return [(_read_text(parameters, source), None)] if parameters.type == "inferred_parameters": return [ (_read_text(child, source), None) for child in parameters.children if child.type == "identifier" ] bindings: list[tuple[str, str | None]] = [] for parameter in parameters.children: if parameter.type not in ("formal_parameter", "spread_parameter"): continue name_node = parameter.child_by_field_name("name") if name_node is not None: bindings.append(( _read_text(name_node, source), _java_receiver_type_name( parameter.child_by_field_name("type"), source ), )) return bindings def _java_method_receiver_types( method_node, source: bytes, field_types: dict[str, str], ) -> dict[str, str]: """Build the receiver type table visible to one Java method. Current-class fields are the base scope, and parameters shadow them for the full method. Conflicting local declarations are omitted because raw call facts do not retain lexical scope. """ method_types: dict[str, str] = {} ambiguous: set[str] = set() def bind(name: str, type_name: str | None) -> None: if not name or not type_name or name in ambiguous: return previous = method_types.get(name) if previous is not None and previous != type_name: method_types.pop(name, None) ambiguous.add(name) else: method_types[name] = type_name params = method_node.child_by_field_name("parameters") if params is not None: for param in params.children: if param.type not in ("formal_parameter", "spread_parameter"): continue type_name = _java_receiver_type_name( param.child_by_field_name("type"), source ) name_node = param.child_by_field_name("name") if name_node is not None: bind(_read_text(name_node, source), type_name) body = method_node.child_by_field_name("body") stack = list(body.children) if body is not None else [] while stack: node = stack.pop() if node.type in ( "class_declaration", "class_body", "interface_declaration", "record_declaration", "enum_declaration", "annotation_type_declaration", ): continue if node.type == "lambda_expression": # Raw calls are method-scoped, so a lambda-local binding cannot be # distinguished from an enclosing binding with the same name. for name, type_name in _java_lambda_parameters(node, source): if type_name is None or field_types.get(name) not in (None, type_name): method_types.pop(name, None) ambiguous.add(name) else: bind(name, type_name) if node.type == "local_variable_declaration": type_name = _java_receiver_type_name( node.child_by_field_name("type"), source ) for name in _java_declarator_names(node, source): if field_types.get(name) not in (None, type_name): method_types.pop(name, None) ambiguous.add(name) else: bind(name, type_name) stack.extend(node.children) table = dict(field_types) table.update(method_types) for name in ambiguous: table.pop(name, None) table.update({f"this.{name}": type_name for name, type_name in field_types.items()}) return table def _java_annotation_names(declaration_node, source: bytes) -> list[str]: """Collect annotation names from a Java declaration's `modifiers` child.""" names: list[str] = [] modifiers = None for child in declaration_node.children: if child.type == "modifiers": modifiers = child break if modifiers is None: return names for anno in modifiers.children: if anno.type not in ("marker_annotation", "annotation"): continue name_node = anno.child_by_field_name("name") if name_node is None: for sub in anno.children: if sub.type in ("identifier", "scoped_identifier", "type_identifier"): name_node = sub break if name_node is not None: text = _read_text(name_node, source).rsplit(".", 1)[-1] if text: names.append(text) return names def _php_name_text(node, source: bytes) -> str | None: """Return the unqualified name text from a PHP `name`/`qualified_name` node.""" if node is None: return None return _read_text(node, source).rsplit("\\", 1)[-1] or None def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: """Walk a PHP type expression; append (name, role) tuples.""" if node is None: return t = node.type if t == "primitive_type": return if t == "named_type": for c in node.children: if c.type in ("name", "qualified_name"): text = _php_name_text(c, source) if text: out.append((text, "generic_arg" if generic else "type")) return return if t in ("name", "qualified_name"): text = _php_name_text(node, source) if text: out.append((text, "generic_arg" if generic else "type")) return if t in ("nullable_type", "union_type", "intersection_type", "optional_type"): for c in node.children: if c.is_named: _php_collect_type_refs(c, source, generic, out) return if node.is_named: for c in node.children: if c.is_named: _php_collect_type_refs(c, source, generic, out) def _php_method_return_type_node(method_node): """Return the named_type/primitive_type node sitting after formal_parameters.""" saw_params = False for c in method_node.children: if c.type == "formal_parameters": saw_params = True continue if saw_params and c.is_named and c.type not in ("compound_statement",): if c.type in ("named_type", "primitive_type", "nullable_type", "union_type", "intersection_type", "optional_type"): return c return None def _kotlin_user_type_name(user_type_node, source: bytes) -> str | None: """Return the head identifier text from a Kotlin user_type node (without generics).""" if user_type_node is None: return None for c in user_type_node.children: if c.type == "type_identifier": text = _read_text(c, source) return text or None if c.type == "identifier": text = _read_text(c, source) return text or None if c.type == "simple_user_type": for sub in c.children: if sub.type in ("identifier", "type_identifier"): text = _read_text(sub, source) return text or None return None def _kotlin_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: """Walk a Kotlin type expression; append (name, role) tuples.""" if node is None: return t = node.type if t in ("integral_literal", "boolean_literal"): return if t == "user_type": for c in node.children: if c.type in ("identifier", "type_identifier"): text = _read_text(c, source) if text: out.append((text, "generic_arg" if generic else "type")) break if c.type == "simple_user_type": for sub in c.children: if sub.type in ("identifier", "type_identifier"): text = _read_text(sub, source) if text: out.append((text, "generic_arg" if generic else "type")) break break for c in node.children: if c.type == "type_arguments": for arg in c.children: if arg.type == "type_projection": for sub in arg.children: if sub.is_named: _kotlin_collect_type_refs(sub, source, True, out) elif arg.is_named: _kotlin_collect_type_refs(arg, source, True, out) return if t in ("identifier", "type_identifier"): text = _read_text(node, source) if text: out.append((text, "generic_arg" if generic else "type")) return if t in ("nullable_type", "parenthesized_type", "type_reference"): for c in node.children: if c.is_named: _kotlin_collect_type_refs(c, source, generic, out) return if node.is_named: for c in node.children: if c.is_named: _kotlin_collect_type_refs(c, source, generic, out) def _kotlin_property_type_node(property_node): """Find the user_type node within a Kotlin property_declaration.""" for c in property_node.children: if c.type == "variable_declaration": for sub in c.children: if sub.type in ("user_type", "nullable_type", "type_reference"): return sub if c.type in ("user_type", "nullable_type", "type_reference"): return c return None def _kotlin_function_return_type_node(func_node): """Find the return-type node of a Kotlin function_declaration (the type after `: ` post-params).""" saw_params = False saw_colon = False for c in func_node.children: if c.type == "function_value_parameters": saw_params = True continue if saw_params and c.type == ":": saw_colon = True continue if saw_colon: if c.is_named: return c return None def _swift_declaration_keyword(node) -> str | None: """Return the leading kind token for a Swift class_declaration: class/struct/enum/extension/actor.""" for c in node.children: if not c.is_named and c.type in ("class", "struct", "enum", "extension", "actor"): return c.type return None def _swift_pre_scan(root_node, source: bytes) -> tuple[set[str], set[str]]: """Pre-scan a Swift compilation unit and return (protocol_names, class_like_names).""" protocols: set[str] = set() classes: set[str] = set() stack = [root_node] while stack: n = stack.pop() if n.type == "protocol_declaration": name_node = n.child_by_field_name("name") if name_node is None: for c in n.children: if c.type == "type_identifier": name_node = c break if name_node is not None: text = _read_text(name_node, source) if text: protocols.add(text) elif n.type == "class_declaration": kw = _swift_declaration_keyword(n) if kw in ("class", "struct", "enum", "actor"): name_node = n.child_by_field_name("name") if name_node is not None: text = _read_text(name_node, source) if text: classes.add(text) stack.extend(n.children) return protocols, classes def _swift_classify_base(name: str, kind: str | None, is_first: bool, protocols: set[str], classes: set[str]) -> str: """Classify a Swift inheritance_specifier entry as `inherits` or `implements`.""" if name in protocols: return "implements" if name in classes: return "inherits" # struct/enum/extension/actor cannot inherit a class — all conformances are protocols. if kind in ("struct", "enum", "extension", "actor"): return "implements" # `class`: first entry is conventionally the base class; subsequent are protocols. return "inherits" if is_first else "implements" def _swift_user_type_name(user_type_node, source: bytes) -> str | None: """Return the head type_identifier text from a Swift user_type node (without generics).""" if user_type_node is None: return None for c in user_type_node.children: if c.type == "type_identifier": text = _read_text(c, source) return text or None return None def _swift_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: """Walk a Swift type expression; append (name, role) tuples (role 'type' or 'generic_arg').""" if node is None: return t = node.type if t == "type_annotation": for c in node.children: if c.is_named: _swift_collect_type_refs(c, source, generic, out) return if t == "user_type": for c in node.children: if c.type == "type_identifier": text = _read_text(c, source) if text: out.append((text, "generic_arg" if generic else "type")) break for c in node.children: if c.type == "type_arguments": for arg in c.children: if arg.is_named: _swift_collect_type_refs(arg, source, True, out) return if t == "type_identifier": text = _read_text(node, source) if text: out.append((text, "generic_arg" if generic else "type")) return if t in ("optional_type", "implicitly_unwrapped_optional_type", "array_type", "dictionary_type", "tuple_type"): for c in node.children: if c.is_named: _swift_collect_type_refs(c, source, generic, out) return if node.is_named: for c in node.children: if c.is_named: _swift_collect_type_refs(c, source, generic, out) def _swift_property_type_node(property_node): """Return the type_annotation child of a Swift property_declaration, if any.""" for c in property_node.children: if c.type == "type_annotation": return c return None def _swift_property_name(property_node, source: bytes) -> str | None: """Return the bound name of a Swift property (``let x``/``var x = ...``).""" for c in property_node.children: if c.type == "pattern": for sc in c.children: if sc.type == "simple_identifier": return _read_text(sc, source) if c.type == "simple_identifier": return _read_text(c, source) return None def _swift_constructor_type(call_node, source: bytes) -> str | None: """If a Swift call expression is a constructor (``Foo()``), return the type name. Only upper-cased callees are treated as types so a free-function call like ``configure()`` in an initializer is not mistaken for a constructor. """ first = call_node.children[0] if call_node.children else None if first is not None and first.type == "simple_identifier": text = _read_text(first, source) if text and text[:1].isupper(): return text return None def _swift_receiver_name(recv_node, source: bytes) -> str | None: """Return the depth-1 receiver name of a Swift member call (``recv.method()``). ``vm.update()`` -> ``vm``; ``Type.staticMethod()`` -> ``Type``; ``Singleton.shared.method()`` -> ``Singleton`` (head of the chain); ``self.svc.fetch()`` -> ``svc`` (the property the call is reached through). Returns None for anything deeper, so resolution stays depth-1. """ if recv_node is None: return None if recv_node.type == "simple_identifier": return _read_text(recv_node, source) if recv_node.type == "navigation_expression": head = recv_node.children[0] if recv_node.children else None if head is not None and head.type == "simple_identifier": return _read_text(head, source) if head is not None and head.type == "self_expression": for child in recv_node.children: if child.type == "navigation_suffix": for sc in child.children: if sc.type == "simple_identifier": return _read_text(sc, source) return None _C_PRIMITIVE_TYPE_NODES = frozenset({ "primitive_type", "sized_type_specifier", "auto", "placeholder_type_specifier", }) def _c_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: """Walk a C type expression; append (name, role) tuples for user-defined types. Skips primitive types and qualifiers; recognises type_identifier.""" if node is None or node.type in _C_PRIMITIVE_TYPE_NODES: return t = node.type if t == "type_identifier": text = _read_text(node, source) if text: out.append((text, "generic_arg" if generic else "type")) return if t in ("pointer_declarator", "reference_declarator", "array_declarator", "type_qualifier", "type_descriptor", "abstract_pointer_declarator", "abstract_reference_declarator", "abstract_array_declarator"): for c in node.children: if c.is_named: _c_collect_type_refs(c, source, generic, out) def _cpp_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: """Walk a C++ type expression; append (name, role) tuples. Resolves qualified_identifier tails (std::string → string) and template_type base + arguments (std::vector → vector + HttpClient as generic_arg).""" if node is None or node.type in _C_PRIMITIVE_TYPE_NODES: return t = node.type if t == "type_identifier": text = _read_text(node, source) if text: out.append((text, "generic_arg" if generic else "type")) return if t == "qualified_identifier": name_node = node.child_by_field_name("name") if name_node is not None: _cpp_collect_type_refs(name_node, source, generic, out) return if t == "template_type": name_node = node.child_by_field_name("name") if name_node is not None: text = _read_text(name_node, source) if text: out.append((text, "generic_arg" if generic else "type")) args_node = node.child_by_field_name("arguments") if args_node is not None: for c in args_node.children: if c.is_named: _cpp_collect_type_refs(c, source, True, out) return if t in ("type_descriptor", "pointer_declarator", "reference_declarator", "array_declarator", "type_qualifier", "abstract_pointer_declarator", "abstract_reference_declarator", "abstract_array_declarator"): for c in node.children: if c.is_named: _cpp_collect_type_refs(c, source, generic, out) def _scala_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: """Walk a Scala type expression; append (name, role) tuples. Handles type_identifier, generic_type (List[T]), and common type wrappers.""" if node is None: return t = node.type if t == "type_identifier": text = _read_text(node, source) if text: out.append((text, "generic_arg" if generic else "type")) return if t == "generic_type": base = node.child_by_field_name("type") if base is None: for c in node.children: if c.type == "type_identifier": base = c break if base is not None and base.type == "type_identifier": text = _read_text(base, source) if text: out.append((text, "generic_arg" if generic else "type")) for c in node.children: if c.type == "type_arguments": for arg in c.children: if arg.is_named: _scala_collect_type_refs(arg, source, True, out) return if t in ("compound_type", "infix_type", "function_type", "tuple_type", "annotated_type", "projected_type"): for c in node.children: if c.is_named: _scala_collect_type_refs(c, source, generic, out) def _python_collect_param_refs(params_node, source: bytes) -> list[tuple[str, str]]: """Collect type refs from each typed parameter under a `parameters` node.""" out: list[tuple[str, str]] = [] if params_node is None: return out for child in params_node.children: if child.type in ("typed_parameter", "typed_default_parameter"): type_node = child.child_by_field_name("type") _python_collect_type_refs(type_node, source, False, out) return out def _python_param_names(params_node, source: bytes) -> set[str]: """Plain parameter identifiers declared on a Python `parameters` node. Covers positional/keyword params plus `*args` / `**kwargs` and typed or default forms — anything that binds a local name the function body can shadow a module-level definition with. """ out: set[str] = set() if params_node is None: return out for child in params_node.children: if child.type == "identifier": out.add(_read_text(child, source)) elif child.type in ( "typed_parameter", "default_parameter", "typed_default_parameter", "list_splat_pattern", "dictionary_splat_pattern", ): # The bound name is the first identifier child (the rest is type/default). name_n = child.child_by_field_name("name") if name_n is None: name_n = next( (c for c in child.children if c.type == "identifier"), None ) if name_n is not None: out.add(_read_text(name_n, source)) return out def _python_collect_assignment_targets(node, source: bytes, out: set[str]) -> None: """Identifiers bound as `pattern` targets under a Python AST subtree. Recurses through `pattern_list` / `tuple_pattern` / `list_pattern` so tuple unpacking (`a, b = ...`, `for a, b in ...`) contributes every bound name. """ if node is None: return if node.type == "identifier": out.add(_read_text(node, source)) return if node.type in ("pattern_list", "tuple_pattern", "list_pattern"): for c in node.children: _python_collect_assignment_targets(c, source, out) def _python_local_bound_names(func_def_node, source: bytes) -> set[str]: """Names bound LOCALLY inside a Python function: parameters plus assignment, `for`, `with ... as`, and comprehension targets. Used by the indirect-dispatch guard to reject a call-argument identifier that is a parameter or a local binding — it names a local value, not the module- level function/class that happens to share the name. Nested `function_definition` and `class_definition` subtrees are NOT descended into: their bindings belong to a different scope. """ bound: set[str] = set() bound |= _python_param_names(func_def_node.child_by_field_name("parameters"), source) def walk(n) -> None: for child in n.children: t = child.type if t in ("function_definition", "class_definition", "lambda"): continue # inner scope — its bindings are not this function's locals if t == "assignment": _python_collect_assignment_targets( child.child_by_field_name("left"), source, bound ) elif t in ("for_statement", "for_in_clause"): _python_collect_assignment_targets( child.child_by_field_name("left"), source, bound ) elif t == "with_statement": for item in child.children: if item.type == "with_clause": for wi in item.children: if wi.type == "with_item": alias = wi.child_by_field_name("alias") _python_collect_assignment_targets(alias, source, bound) elif t == "named_expression": # walrus := _python_collect_assignment_targets( child.child_by_field_name("name"), source, bound ) walk(child) body = func_def_node.child_by_field_name("body") if body is not None: walk(body) return bound def _python_module_bound_names(root, source: bytes) -> set[str]: """Names rebound by assignment at MODULE scope (top-level `x = ...`, `for`, walrus). The module-scope analogue of the per-function shadow set: a dispatch-table value whose name is reassigned to data at module level (`handler = build()`) names that value, not a same-named function, so it must not manufacture an indirect edge. Function and class bodies are not descended into — their bindings are local. """ bound: set[str] = set() def walk(n) -> None: for child in n.children: t = child.type if t in ("function_definition", "class_definition", "lambda"): continue # inner scope — not a module-level binding if t == "assignment": _python_collect_assignment_targets( child.child_by_field_name("left"), source, bound ) elif t in ("for_statement", "for_in_clause"): _python_collect_assignment_targets( child.child_by_field_name("left"), source, bound ) elif t == "named_expression": # walrus := _python_collect_assignment_targets( child.child_by_field_name("name"), source, bound ) walk(child) walk(root) return bound _JS_SCOPE_BOUNDARY = frozenset({ "function_declaration", "function_expression", "function", "arrow_function", "method_definition", "class_declaration", "class", "generator_function", "generator_function_declaration", }) def _js_collect_pattern_idents(node, source: bytes, bound: set) -> None: """Collect binding identifier names from a JS/TS pattern (a parameter, or a declarator LHS). Recurses through destructuring (object/array patterns, rest) but never into the default-value side of `x = default` or a type annotation, so only names actually bound by the pattern are collected.""" t = node.type if t in ("identifier", "shorthand_property_identifier_pattern"): bound.add(_read_text(node, source)) return if t == "type_annotation": return # `(h: Handler)` — Handler is a type, not a bound name if t == "assignment_pattern": # `x = default` — only x is bound left = node.child_by_field_name("left") if left is not None: _js_collect_pattern_idents(left, source, bound) return if t == "pair_pattern": # `{ a: localName }` — localName is bound val = node.child_by_field_name("value") if val is not None: _js_collect_pattern_idents(val, source, bound) return for c in node.children: if c.is_named: _js_collect_pattern_idents(c, source, bound) def _js_local_bound_names(func_node, source: bytes) -> set[str]: """Names bound locally inside a JS/TS function: parameters plus `const`/`let`/ `var` declarator targets. Mirrors `_python_local_bound_names`: an argument that is a parameter or local binding names a local value, not a same-named module function, so it must not manufacture an indirect_call edge. Nested function and class scopes are not descended into.""" bound: set[str] = set() params = func_node.child_by_field_name("parameters") if params is not None: _js_collect_pattern_idents(params, source, bound) def walk(n) -> None: for c in n.children: if c.type in _JS_SCOPE_BOUNDARY: continue # inner scope — its bindings are not this function's locals if c.type == "variable_declarator": name = c.child_by_field_name("name") if name is not None: _js_collect_pattern_idents(name, source, bound) walk(c) body = func_node.child_by_field_name("body") if body is not None: walk(body) return bound def _js_module_bound_names(root, source: bytes) -> set[str]: """Module-scope names rebound to NON-function data (`const X = {...}`, `let y = 5`). The JS/TS module-scope shadow set. Unlike the per-function set, a declarator whose value is itself a function (`const cb = () => {}`) is EXCLUDED: that name IS a callable we want dispatch tables to resolve to, not a data shadow. """ bound: set[str] = set() def walk(n) -> None: for c in n.children: if c.type in _JS_SCOPE_BOUNDARY: continue if c.type == "variable_declarator": value = c.child_by_field_name("value") if value is None or value.type not in _JS_FUNCTION_VALUE_TYPES: name = c.child_by_field_name("name") if name is not None: _js_collect_pattern_idents(name, source, bound) walk(c) walk(root) return bound def _js_dispatch_value_idents(coll_node): """Yield identifier value-nodes of a JS/TS object/array literal that are function-reference candidates: object property VALUES and shorthand properties (`{ handler }`), and array elements. Keys and inline methods are not references.""" if coll_node.type == "object": for c in coll_node.children: if c.type == "pair": val = c.child_by_field_name("value") if val is not None and val.type == "identifier": yield val elif c.type == "shorthand_property_identifier": yield c else: # array for el in coll_node.children: if el.type == "identifier": yield el def _find_body(node, config: LanguageConfig): """Find the body node using config.body_field, falling back to child types.""" b = node.child_by_field_name(config.body_field) if b: return b for child in node.children: if child.type in config.body_fallback_child_types: return child return None def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edges: list, seen_dyn_pairs: set) -> bool: """Detect dynamic import() calls in JS/TS and emit imports_from edges. Handles patterns like: await import('./foo.js') import('./foo.js').then(...) const m = await import(`./foo`) Returns True if the node was a dynamic import (caller should skip normal call handling). """ # Dynamic import is a call_expression whose function child is the keyword "import". # tree-sitter-typescript parses `import('...')` as call_expression with first child # being an "import" token (type="import"). func_node = node.child_by_field_name("function") if func_node is None: # Fallback: check first child directly (some TS versions) if node.children and _read_text(node.children[0], source) == "import": func_node = node.children[0] else: return False if _read_text(func_node, source) != "import": return False # Extract the module path from the arguments args = node.child_by_field_name("arguments") if args is None: return True # It's an import() but no args — skip for arg in args.children: if arg.type == "template_string": # Skip dynamic template literals — path can't be statically resolved if any(c.type == "template_substitution" for c in arg.children): break raw = _read_text(arg, source).strip("`") elif arg.type == "string": raw = _read_text(arg, source).strip("'\" ") else: continue if not raw: break # Resolve path using the same logic as static imports. resolved = _resolve_js_import_target(raw, str_path) if resolved is None: break tgt_nid, _ = resolved pair = (caller_nid, tgt_nid) if pair not in seen_dyn_pairs: seen_dyn_pairs.add(pair) edges.append({ "source": caller_nid, "target": tgt_nid, # A deferred `import(...)` is a real dependency, so keep it as an # `imports_from` edge (visible in the graph) but mark it `deferred` # so find_import_cycles does not treat it as a static import and # report a phantom file cycle (#1241). "relation": "imports_from", "context": "import", "deferred": True, "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{node.start_point[0] + 1}", "weight": 1.0, }) break return True def _get_cpp_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) if node.type in ("field_identifier", "destructor_name", "operator_name"): return _read_text(node, source) if node.type == "qualified_identifier": # An out-of-class DEFINITION (`void Foo::bar() {}`) carries a # qualified_identifier declarator. Retaining the `Foo::` qualifier makes # _make_id(stem, "Foo::bar") normalize to the same id as the in-class # member _make_id(class_nid, "bar"), so the decl in Foo.h and the def in # Foo.cpp resolve to ONE method node instead of two (#1547). The full # qualified text also handles nested scopes (`A::B::bar`). Free functions # never have a qualified_identifier here, so their bare-name ids are # unchanged; only qualified definitions shift onto their owning class. return _read_text(node, source) decl = node.child_by_field_name("declarator") if decl: return _get_cpp_func_name(decl, source) for child in node.children: if child.type == "identifier": return _read_text(child, source) return None def _cpp_declarator_name(node, source: bytes) -> str | None: """Return the bare variable name from a C++ declaration declarator, unwrapping pointer/reference/init wrappers (``*f``, ``&r``, ``f = Foo()``). Returns None for anything that isn't a plain named local (arrays, function pointers, structured bindings) so the type table never records a guessed receiver.""" t = node.type if t == "identifier": return _read_text(node, source) if t in ("pointer_declarator", "reference_declarator", "init_declarator"): inner = node.child_by_field_name("declarator") if inner is None: for c in node.children: if c.type in ("identifier", "pointer_declarator", "reference_declarator"): inner = c break if inner is not None: return _cpp_declarator_name(inner, source) return None def _cpp_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None: """Collect ``var -> ClassName`` from local variable declarations in a C++ function body, for receiver-type inference in the cross-file member-call pass (#1547). Handles ``Foo f;``, ``Foo* f;``, ``Foo *f = ...;``, ``Foo f = Foo();``. Only a class-like (``type_identifier``/``qualified_identifier``) type with a single named declarator is recorded — PRECISION over recall: a built-in type (``int x``), an ambiguous multi-declarator line, or an un-nameable declarator contributes nothing rather than a guess. A qualified type ``ns::Foo`` records its simple tail ``Foo`` so it keys to the type's definition node label. """ stack = [body_node] while stack: n = stack.pop() if n.type in ("function_definition", "lambda_expression"): # Don't descend into a nested function/lambda: its locals are scoped # away and would pollute this body's table. if n is not body_node: continue if n.type == "declaration": type_node = n.child_by_field_name("type") if type_node is not None and type_node.type in ( "type_identifier", "qualified_identifier" ): type_name = _read_text(type_node, source).split("::")[-1].strip() declarators = [ c for c in n.children if c.type in ("identifier", "pointer_declarator", "reference_declarator", "init_declarator") ] # A single declarator only: `Foo a, b;` is ambiguous to attribute # to one receiver name cleanly, so skip multi-declarator lines. if type_name and type_name[:1].isupper() and len(declarators) == 1: var = _cpp_declarator_name(declarators[0], source) if var and var not in table: table[var] = type_name for c in n.children: stack.append(c) def _swift_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None: """Collect ``var -> Type`` from local ``let``/``var`` bindings in a Swift function body, so a member call on the local (``x.method()``) resolves to Type in the cross-file member-call pass (#1604). Two initializer shapes are recorded, PRECISION over recall: - a constructor call ``let x = Type()`` (``_swift_constructor_type``); - a static-member access ``let x = Type.shared`` (a navigation_expression with an upper-cased head) — the singleton-cached-into-a-local idiom, one of the most common Swift call patterns and previously resolved to nothing. Nested function declarations are not descended into (their locals are scoped away); the first binding for a name wins, so a class property of the same name already in the table is not overwritten. """ stack = [body_node] while stack: n = stack.pop() if n.type == "function_declaration" and n is not body_node: continue if n.type == "property_declaration": prop_type: str | None = None for child in n.children: if child.type == "call_expression": prop_type = _swift_constructor_type(child, source) break if child.type == "navigation_expression": head = child.children[0] if child.children else None if head is not None and head.type == "simple_identifier": htext = _read_text(head, source) if htext and htext[:1].isupper(): prop_type = htext break name = _swift_property_name(n, source) if name and prop_type and name not in table: table[name] = prop_type for c in n.children: stack.append(c) def _csharp_member_type_table(root, source: bytes) -> dict[str, str]: """Collect ``name -> TypeName`` for C# receiver typing (#1609): class fields, properties, method parameters, and local variable declarations. File-scoped, first-binding-wins (like the C++ table): a field declared once at class scope is visible to every method's `field.Method()`, and a param/local shadowing the same name is a conservative approximation graphify already accepts for receiver typing. Only a resolvable, non-`var` type name is recorded; `var` without a `new T()` initializer, and predefined/lower-cased primitives, are skipped (precision over recall — an untypable receiver is left for the resolver to drop rather than guess). `var v = new T()` is typed from the object-creation. """ table: dict[str, str] = {} def _typed(type_node) -> str | None: info = _read_csharp_type_name(type_node, source) if not info: return None name = info[0] # A genuine C# class name is Pascal-cased; skip predefined primitives # (int/bool/string) which never own a resolvable method definition here. return name if name and name[:1].isupper() else None def _decl_names(var_decl): for c in var_decl.children: if c.type == "variable_declarator": nm = c.child_by_field_name("name") or next( (g for g in c.children if g.type == "identifier"), None) if nm is not None: yield _read_text(nm, source), c def _new_type(declarator) -> str | None: # `var v = new Server()` — recover the type from the object_creation_expression. for g in declarator.children: if g.type == "object_creation_expression": return _typed(g.child_by_field_name("type")) return None stack = [root] while stack: n = stack.pop() t = n.type if t in ("field_declaration", "local_declaration_statement"): vd = next((c for c in n.children if c.type == "variable_declaration"), None) if vd is not None: type_node = vd.child_by_field_name("type") declared = _typed(type_node) for name, decl in _decl_names(vd): resolved = declared or _new_type(decl) if name and resolved and name not in table: table[name] = resolved elif t == "property_declaration": nm = n.child_by_field_name("name") resolved = _typed(n.child_by_field_name("type")) if nm is not None and resolved: pname = _read_text(nm, source) if pname not in table: table[pname] = resolved elif t == "parameter": nm = n.child_by_field_name("name") resolved = _typed(n.child_by_field_name("type")) if nm is not None and resolved: pname = _read_text(nm, source) if pname not in table: table[pname] = resolved for c in n.children: stack.append(c) return table def _ts_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None: """Add TS/JS receiver bindings to ``table`` (name -> TypeName), for member-call resolution beyond the constructor-injected `this.field` case (#1630): * local ``const/let/var x = new Foo()`` -> ``x: Foo`` (Pattern A); * a type-annotated parameter ``(svc: Svc)`` -> ``svc: Svc`` (Pattern B), so a call on the param — including inside a returned closure — resolves. File-scoped, first-binding-wins (merged into the constructor-injection table, which is populated first and therefore wins on a name clash). Only a bare ``type_identifier`` (a single class/interface name) is recorded — an array, union, generic, qualified, or predefined type is skipped (precision over recall, matching the receiver-typed resolvers for Swift/C#/C++).""" def _bare_type_ident(type_annotation): # type_annotation -> ": T"; accept only a single type_identifier child. idents = [c for c in type_annotation.children if c.type == "type_identifier"] others = [c for c in type_annotation.children if c.is_named and c.type not in ("type_identifier",)] if len(idents) == 1 and not others: return _read_text(idents[0], source) return None stack = [root] while stack: n = stack.pop() t = n.type if t == "variable_declarator": name_n = n.child_by_field_name("name") value = n.child_by_field_name("value") if (name_n is not None and name_n.type == "identifier" and value is not None and value.type == "new_expression"): ctor = value.child_by_field_name("constructor") if ctor is not None and ctor.type in ("identifier", "type_identifier"): name = _read_text(name_n, source) tname = _read_text(ctor, source) if name and tname and name not in table: table[name] = tname elif t == "required_parameter" or t == "optional_parameter": pat = n.child_by_field_name("pattern") ann = n.child_by_field_name("type") if pat is not None and pat.type == "identifier" and ann is not None: tname = _bare_type_ident(ann) name = _read_text(pat, source) if name and tname and name not in table: table[name] = tname for c in n.children: stack.append(c) def _find_require_call(value_node): """Return the call_expression node if `value_node` is a `require(...)` call or `require(...).x` member access. Otherwise None.""" if value_node is None: return None if value_node.type == "call_expression": fn = value_node.child_by_field_name("function") if fn is not None and fn.type == "identifier": return value_node if value_node.type == "member_expression": obj = value_node.child_by_field_name("object") return _find_require_call(obj) return None def _require_imports_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> bool: """Detect CommonJS require imports inside lexical_declaration / variable_declaration. Handles three patterns: const { foo, bar } = require('./mod') → file → mod (imports_from), file → foo, file → bar const mod = require('./mod') → file → mod (imports_from) const x = require('./mod').y → file → mod (imports_from), file → y Returns True if any require import was found. """ if node.type not in ("lexical_declaration", "variable_declaration"): return False found = False for child in node.children: if child.type != "variable_declarator": continue value = child.child_by_field_name("value") call = _find_require_call(value) if call is None: continue fn = call.child_by_field_name("function") if fn is None or _read_text(fn, source) != "require": continue args = call.child_by_field_name("arguments") if args is None: continue raw = None for arg in args.children: if arg.type == "string": raw = _read_text(arg, source).strip("'\"` ") break if not raw: continue resolved = _resolve_js_import_target(raw, str_path) if resolved is None: continue tgt_nid, resolved_path = resolved line = node.start_point[0] + 1 edges.append({ "source": file_nid, "target": tgt_nid, "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{line}", "weight": 1.0, }) found = True # Symbol-level edges for destructured / accessor binders. target_stem = _file_stem(resolved_path) if resolved_path is not None else None name_node = child.child_by_field_name("name") sym_names: list[str] = [] if name_node is not None and name_node.type == "object_pattern": # `const { a, b: alias } = require('./m')` — emit edges for each property key for prop in name_node.children: if prop.type == "shorthand_property_identifier_pattern": sym_names.append(_read_text(prop, source)) elif prop.type == "pair_pattern": key = prop.child_by_field_name("key") if key is not None: sym_names.append(_read_text(key, source)) elif value is not None and value.type == "member_expression": # `const x = require('./m').y` — symbol is the property accessed prop = value.child_by_field_name("property") if prop is not None: sym_names.append(_read_text(prop, source)) if target_stem is not None: for sym in sym_names: 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, }) return found _JS_FUNCTION_VALUE_TYPES = frozenset({"arrow_function", "function_expression", "function", "generator_function"}) def _js_member_assignment_target(left, source: bytes): """Classify the symbol an `assignment_expression` LHS defines when its RHS is a function. Returns (kind, owner_name, member_name) or None. this.foo = fn → ("this", None, "foo") exports.foo = fn → ("exports", None, "foo") module.exports.foo = fn → ("exports", None, "foo") Foo.prototype.bar = fn → ("prototype", "Foo", "bar") Any other shape (an arbitrary `obj.x = fn`) returns None and is skipped — capturing those would reintroduce the bare-named / phantom-god-node class of bug the module-level scope guard (#1077) exists to prevent. """ if left is None or left.type != "member_expression": return None prop = left.child_by_field_name("property") if prop is None: return None member_name = _read_text(prop, source) if not member_name: return None obj = left.child_by_field_name("object") if obj is None: return None if obj.type == "this": return ("this", None, member_name) if obj.type == "identifier": if _read_text(obj, source) == "exports": return ("exports", None, member_name) return None if obj.type == "member_expression": # module.exports.X or Foo.prototype.X inner_obj = obj.child_by_field_name("object") inner_prop = obj.child_by_field_name("property") if inner_obj is None or inner_prop is None: return None inner_prop_name = _read_text(inner_prop, source) if inner_obj.type == "identifier": inner_obj_name = _read_text(inner_obj, source) if inner_obj_name == "module" and inner_prop_name == "exports": return ("exports", None, member_name) if inner_prop_name == "prototype": return ("prototype", inner_obj_name, member_name) return None def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, callable_def_nids: set | None = None, local_bound_names: dict | None = None) -> bool: """Handle lexical_declaration (arrow functions, CJS requires, module-level const literals) for JS/TS. Returns True if handled.""" # CommonJS / prototype member assignments whose value is a function: # exports.X = () => {} → file-contained function X() # module.exports.X = fn → file-contained function X() # Foo.prototype.bar = fn → method bar() owned by Foo # (`this.X = fn` lives inside a function body, which is not recursed here; # it is captured at the enclosing function — see the function branch.) if node.type == "expression_statement": assign = next((c for c in node.children if c.type == "assignment_expression"), None) if assign is not None: value = assign.child_by_field_name("right") if value is not None and value.type in _JS_FUNCTION_VALUE_TYPES: target = _js_member_assignment_target( assign.child_by_field_name("left"), source) if target is not None: kind, owner_name, member_name = target line = node.start_point[0] + 1 handled = False if kind == "exports": nid = _make_id(stem, member_name) add_node_fn(nid, f"{member_name}()", line) add_edge_fn(file_nid, nid, "contains", line) handled = True elif kind == "prototype": owner_nid = _make_id(stem, owner_name) nid = _make_id(owner_nid, member_name) add_node_fn(nid, f".{member_name}()", line) add_edge_fn(owner_nid, nid, "method", line) handled = True if handled: if callable_def_nids is not None: callable_def_nids.add(nid) # CJS/prototype fn is callable if local_bound_names is not None: local_bound_names[nid] = _js_local_bound_names(value, source) body = value.child_by_field_name("body") if body: function_bodies.append((nid, body)) return True # Class fields whose value is a function: # class C { handler = () => {} } → method handler() owned by C # Reaches here with parent_class_nid set because class bodies are recursed # with the class nid as parent. if parent_class_nid and node.type in ("field_definition", "public_field_definition"): prop = node.child_by_field_name("property") or node.child_by_field_name("name") value = node.child_by_field_name("value") if (prop is not None and value is not None and value.type in _JS_FUNCTION_VALUE_TYPES): field_name = _read_text(prop, source) if field_name: line = node.start_point[0] + 1 nid = _make_id(parent_class_nid, field_name) add_node_fn(nid, f".{field_name}()", line) add_edge_fn(parent_class_nid, nid, "method", line) if callable_def_nids is not None: callable_def_nids.add(nid) # arrow class-field is callable if local_bound_names is not None: local_bound_names[nid] = _js_local_bound_names(value, source) body = value.child_by_field_name("body") if body: function_bodies.append((nid, body)) return True if node.type in ("lexical_declaration", "variable_declaration"): # CJS require imports — emit edges, do not block other lexical_declaration handling require_found = _require_imports_js(node, source, file_nid, stem, edges, str_path) # Scope guard (#1077): only emit nodes for module-level declarations. # Without this, `const x = ...` inside an arrow callback (e.g. inside # `describe(() => { const set = new Set(...) })`) emits a bare-named # node, and the same name collides across unrelated files producing # phantom god-nodes. Bodies of arrow functions are walked separately # via function_bodies, so we never need to emit nodes for locals here. parent = node.parent is_module_level = parent is not None and ( parent.type == "program" or (parent.type == "export_statement" and parent.parent is not None and parent.parent.type == "program") ) # Arrow function declarations and module-level const literals (lexical_declaration only) arrow_found = False const_found = False if node.type == "lexical_declaration" and is_module_level: for child in node.children: if child.type == "variable_declarator": value = child.child_by_field_name("value") if value and value.type in _JS_FUNCTION_VALUE_TYPES: # `const f = () => {}` and `const f = function(){}` name_node = child.child_by_field_name("name") if name_node: func_name = _read_text(name_node, source) line = child.start_point[0] + 1 func_nid = _make_id(stem, func_name) add_node_fn(func_nid, f"{func_name}()", line) add_edge_fn(file_nid, func_nid, "contains", line) if callable_def_nids is not None: callable_def_nids.add(func_nid) # `const f = () =>` is callable if local_bound_names is not None: local_bound_names[func_nid] = _js_local_bound_names(value, source) body = value.child_by_field_name("body") if body: function_bodies.append((func_nid, body)) arrow_found = True elif value and value.type in ( "object", "array", "as_expression", "call_expression", "new_expression", ): # Module-level const with literal/object/array/factory value name_node = child.child_by_field_name("name") if name_node: const_name = _read_text(name_node, source) line = child.start_point[0] + 1 const_nid = _make_id(stem, const_name) add_node_fn(const_nid, const_name, line) add_edge_fn(file_nid, const_nid, "contains", line) const_found = True if arrow_found: return True if const_found: return True if require_found: return True return False def _ts_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, walk_fn) -> bool: """Emit a container node for a TS `namespace`/`module` declaration. `namespace Foo {}` parses as `internal_module` (with `name`/`body` fields); `module Bar {}` and ambient `declare module "pkg" {}` parse as a named `module` node that exposes no fields, so its name and body are found positionally. Without this the container was never a node — its members were still reached by the default recurse but lost their namespace context. The members stay file-contained (parity with C#'s `_csharp_extra_walk`); the namespace becomes a sibling marker node so it is queryable. Returns True if handled. The guard requires `is_named` because the anonymous `module` keyword token shares the `module` type string and would otherwise match here. """ if node.is_named and node.type in ("internal_module", "module"): name_node = node.child_by_field_name("name") if name_node is None: for child in node.children: if child.is_named and child.type in ( "identifier", "nested_identifier", "string"): name_node = child break body = node.child_by_field_name("body") if body is None: for child in node.children: if child.type == "statement_block": body = child break if name_node is not None: ns_name = _read_text(name_node, source) if name_node.type == "string": ns_name = ns_name.strip("'\"`") if ns_name: ns_nid = _make_id(stem, ns_name) line = node.start_point[0] + 1 add_node_fn(ns_nid, ns_name, line) add_edge_fn(file_nid, ns_nid, "contains", line) if body is not None: for child in body.children: walk_fn(child, parent_class_nid) return True return False def _csharp_namespace_name(node, source: bytes) -> str: name_node = node.child_by_field_name("name") if name_node is not None: return _read_text(name_node, source).strip() for child in node.children: if child.type in ("identifier", "qualified_name"): return _read_text(child, source).strip() return "" def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, walk_fn, namespace_stack: list[str], scope_stack: list[str]) -> bool: """Handle namespace declarations for C#. Returns True if handled.""" if node.type == "namespace_declaration": ns_name = _csharp_namespace_name(node, source) pushed = False if ns_name: namespace_stack.append(ns_name) scope_stack.append(f"s{node.start_byte}") pushed = True ns_label = ".".join(namespace_stack) ns_nid = _csharp_namespace_id(ns_label) line = node.start_point[0] + 1 add_node_fn(ns_nid, ns_label, line, node_type="namespace", metadata={"kind": "csharp_namespace"}) add_edge_fn(file_nid, ns_nid, "contains", line) body = node.child_by_field_name("body") if body: try: for child in body.children: walk_fn(child, parent_class_nid) finally: if pushed: namespace_stack.pop() scope_stack.pop() elif pushed: namespace_stack.pop() scope_stack.pop() return True if node.type == "file_scoped_namespace_declaration": ns_name = _csharp_namespace_name(node, source) if ns_name: namespace_stack.append(ns_name) scope_stack.append(f"s{node.start_byte}") ns_label = ".".join(namespace_stack) ns_nid = _csharp_namespace_id(ns_label) line = node.start_point[0] + 1 add_node_fn(ns_nid, ns_label, line, node_type="namespace", metadata={"kind": "csharp_namespace"}) add_edge_fn(file_nid, ns_nid, "contains", line) return True return False def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, ensure_named_node_fn) -> bool: """Handle enum_entry for Swift. Returns True if handled.""" if node.type == "enum_entry" and parent_class_nid: line = node.start_point[0] + 1 for child in node.children: if child.type == "simple_identifier": case_name = _read_text(child, source) case_nid = _make_id(parent_class_nid, case_name) add_node_fn(case_nid, case_name, line) add_edge_fn(parent_class_nid, case_nid, "case_of", line) # Associated-value types nest as `enum_type_parameters -> user_type -> # type_identifier` (a sibling of the case-name simple_identifier). The # case-name loop above never descends into them, so `case started(Session)` # used to drop the Event -> Session reference entirely. Mirror the Swift # property/parameter emit style: collect the type refs and emit a # `references` edge from the ENUM node to each collected type. for child in node.children: if child.type != "enum_type_parameters": continue for grand in child.children: if not grand.is_named: continue refs: list[tuple[str, str]] = [] _swift_collect_type_refs(grand, source, False, refs) for ref_name, role in refs: ctx = "generic_arg" if role == "generic_arg" else "type" target_nid = ensure_named_node_fn(ref_name, line) if target_nid != parent_class_nid: add_edge_fn(parent_class_nid, target_nid, "references", line, context=ctx) return True return False def _java_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, walk_fn) -> bool: """Handle enum_constant for Java. Returns True if handled.""" if node.type == "enum_constant" and parent_class_nid: name_node = node.child_by_field_name("name") if name_node is None: return True const_name = _read_text(name_node, source) line = node.start_point[0] + 1 const_nid = _make_id(parent_class_nid, const_name) add_node_fn(const_nid, const_name, line) add_edge_fn(parent_class_nid, const_nid, "case_of", line) # Anonymous-body constants (`MONDAY { void greet(){} }`): descend so the # body's methods aren't dropped; const_nid attaches them to the constant. for child in node.children: if child.type == "class_body": for member in child.children: walk_fn(member, parent_class_nid=const_nid) return True return False def _kotlin_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, walk_fn) -> bool: """Handle enum_entry for Kotlin. Returns True if handled (#1700 Kotlin half).""" if node.type == "enum_entry" and parent_class_nid: name_node = None for child in node.children: if child.type in ("simple_identifier", "identifier"): name_node = child break if name_node is None: return True const_name = _read_text(name_node, source) line = node.start_point[0] + 1 const_nid = _make_id(parent_class_nid, const_name) add_node_fn(const_nid, const_name, line) add_edge_fn(parent_class_nid, const_nid, "case_of", line) for child in node.children: if child.type == "class_body": for member in child.children: walk_fn(member, parent_class_nid=const_nid) return True return False def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None: """Resolve a C# type name, whether it was qualified, and its qualifier prefix.""" if node is None: return None if node.type in ("identifier", "predefined_type"): return (_read_text(node, source), False, "") if node.type == "qualified_name": prefix, _, tail = _read_text(node, source).rpartition(".") tail = tail.split("<", 1)[0] return (tail, True, prefix) if node.type == "generic_name": name_node = node.child_by_field_name("name") if name_node is not None: qualified = name_node.type == "qualified_name" prefix, _, tail = _read_text(name_node, source).rpartition(".") return (tail, qualified, prefix if qualified else "") for child in node.children: if not child.is_named: continue result = _read_csharp_type_name(child, source) if result: return result return None def _ruby_new_class_name(node, source: bytes) -> str | None: """Return ``ClassName`` if ``node`` is a ``ClassName.new(...)`` call, else None. Only a bare capitalized constant receiver counts (``Processor.new``); namespaced (``A::B.new``) and dynamic receivers are intentionally ignored so the binding stays unambiguous. """ if node is None or node.type != "call": return None recv = node.child_by_field_name("receiver") meth = node.child_by_field_name("method") if recv is None or meth is None: return None if recv.type != "constant" or _read_text(meth, source) != "new": return None return _read_text(recv, source) def _ruby_local_class_bindings(body_node, source: bytes) -> dict[str, str | None]: """Map ``local_var -> ClassName`` for ``var = ClassName.new`` within one Ruby method body, not descending into nested method definitions. 100%-confidence contract: a variable assigned more than once, or to anything other than a single ``Constant.new``, maps to ``None`` (ambiguous) so callers never resolve it. Only the certain single-binding case carries a type. """ bindings: dict[str, str | None] = {} boundary = {"method", "singleton_method"} def visit(n) -> None: for child in n.children: if child.type in boundary: continue # nested method has its own scope if child.type == "assignment": left = child.child_by_field_name("left") right = child.child_by_field_name("right") if left is not None and left.type == "identifier": var = _read_text(left, source) cls = _ruby_new_class_name(right, source) if right is not None else None if cls is None: # assigned to something we can't type: poison if it was typed if var in bindings: bindings[var] = None elif var in bindings: if bindings[var] != cls: bindings[var] = None # reassigned to a different class else: bindings[var] = cls visit(child) visit(body_node) return bindings def _ruby_const_last_name(node, source: bytes) -> str: """Last constant of a ``constant`` or ``scope_resolution`` (``A::B::C`` -> ``C``).""" if node is None: return "" if node.type == "constant": return _read_text(node, source) if node.type == "scope_resolution": consts = [c for c in node.children if c.type == "constant"] if consts: return _read_text(consts[-1], source) return "" _RUBY_CLASS_FACTORIES = frozenset({("Struct", "new"), ("Class", "new"), ("Data", "define")}) def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node, add_edge, walk, callable_def_nids: set) -> bool: """Ruby: a constant assignment whose RHS is ``Struct.new(...)``, ``Class.new(Super)`` or ``Data.define(...)`` defines a class named after the constant (#1640). Synthesize the class node, attach block-defined methods via ``method`` (by recursing the block with the new node as parent), and emit an ``inherits`` edge for ``Class.new(Super)``. Returns True if handled. """ if node.type != "assignment": return False left = node.child_by_field_name("left") right = node.child_by_field_name("right") if left is None or right is None or left.type != "constant" or right.type != "call": return False recv = right.child_by_field_name("receiver") meth = right.child_by_field_name("method") if recv is None or meth is None or recv.type != "constant": return False if (_read_text(recv, source), _read_text(meth, source)) not in _RUBY_CLASS_FACTORIES: return False const_name = _read_text(left, source) if not const_name: return False line = node.start_point[0] + 1 class_nid = _make_id(stem, const_name) add_node(class_nid, const_name, line) callable_def_nids.add(class_nid) # a class is callable (its constructor) # Mirror the generic class branch: containment always hangs off the file node. add_edge(file_nid, class_nid, "contains", line) # `Class.new(Super)` — the first positional constant argument is the superclass. if _read_text(recv, source) == "Class": args = next((c for c in right.children if c.type == "argument_list"), None) if args is not None: for arg in args.children: if arg.type in ("constant", "scope_resolution"): base = _ruby_const_last_name(arg, source) if base: base_nid = _make_id(stem, base) if base_nid not in seen_ids: base_nid = _make_id(base) if base_nid not in seen_ids: # origin_file lets _disambiguate_colliding_node_ids # tell this file's unresolved reference apart from # another file's same-named one, instead of every # file's stub collapsing onto one shared bare id # (see ensure_named_node(), which sets the same # field for this exact reason). nodes.append({ "id": base_nid, "label": base, "file_type": "code", "source_file": "", "source_location": "", "origin_file": str_path, }) seen_ids.add(base_nid) add_edge(class_nid, base_nid, "inherits", line) break # Recurse the do/brace block so block-defined methods attach to the class. # The block wraps its statements in a `body_statement` (like a class body); # descend into it so the method handler sees parent_class_nid — otherwise the # default recurse resets the parent to None and the method hangs off the file # with a dot-less label. block = next((c for c in right.children if c.type in ("do_block", "block")), None) if block is not None: body = next((c for c in block.children if c.type == "body_statement"), block) for child in body.children: walk(child, parent_class_nid=class_nid) return True def _extract_generic( path: Path, config: LanguageConfig, *, source_override: bytes | None = None ) -> dict: """Generic AST extractor driven by LanguageConfig. ``source_override`` parses the given bytes instead of reading ``path``, while still keying nodes/edges off ``path``. Lets container formats (e.g. Vue SFCs) mask the wrapper and parse just the embedded ``