diff --git a/graphify/extract.py b/graphify/extract.py index 0cac5060..0d8505a9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -130,6 +130,8 @@ from graphify.extractors.resolution import ( # noqa: E402,F401 _workspace_globs, ) +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_member_type_table, _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 + _RECURSION_LIMIT = 10_000 # Language built-in globals that AST may classify as call targets when used as @@ -168,49 +170,11 @@ def _file_node_id(rel_path: Path) -> str: return _make_id(_file_stem(rel_path)) -def _csharp_namespace_id(dotted_name: str) -> str: - digest = hashlib.sha1(dotted_name.encode("utf-8")).hexdigest()[:16] - return f"csharp_namespace:{digest}" - - SEMANTIC_RELATIONS = frozenset({ "inherits", "implements", "mixes_in", "embeds", "references", "calls", "imports", "imports_from", "re_exports", "contains", "method", }) -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, - } - # 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 @@ -223,247 +187,9 @@ def _semantic_reference_edge( # ── Generic helpers ─────────────────────────────────────────────────────────── -_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", -}) - # 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. -_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.lang (auto-imported) plus the ubiquitous java.util / java.io / java.time / @@ -474,789 +200,13 @@ def _java_type_parameters_in_scope(node, source: bytes) -> frozenset[str]: # 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. -_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_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 / C++ type-ref helpers ───────────────────────────────────────────────── -_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) - # ── Scala type-ref helpers ─────────────────────────────────────────────────── -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 _resolve_name(node, source: bytes, config: LanguageConfig) -> str | None: """Get the name from a node using config.name_field, falling back to child types.""" @@ -1272,17 +222,6 @@ def _resolve_name(node, source: bytes, config: LanguageConfig) -> str | None: return None -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 - - # ── 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: @@ -1427,73 +366,6 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p }) -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 _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] = [] @@ -1692,250 +564,6 @@ def _get_c_func_name(node, source: bytes) -> str | None: return None -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 _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None: """Collect ``var -> ClassName`` from ObjC local declarations (``Foo *f = ...;``) in a method body, for receiver typing in the cross-file message-send pass @@ -1971,458 +599,23 @@ def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> No # ── JS/TS extra walk for arrow functions ────────────────────────────────────── -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 - # 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`. -_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 # ── TS extra walk for namespace / module declarations ───────────────────────── -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 - # ── C# extra walk for namespace declarations ────────────────────────────────── -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 - # ── Swift extra walk for enum cases ────────────────────────────────────────── -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 - # ── Java extra walk for enum constants ─────────────────────────────────────── -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 - # ── Language configs ────────────────────────────────────────────────────────── @@ -2716,31 +909,6 @@ def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, st return modules -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 - - _SWIFT_CONFIG = LanguageConfig( ts_module="tree_sitter_swift", class_types=frozenset({"class_declaration", "protocol_declaration"}), @@ -2759,2385 +927,13 @@ _SWIFT_CONFIG = LanguageConfig( # ── Ruby local type inference (for member-call resolution) ───────────────────── -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 "" - - # `Const = (...)` 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). -_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 # ── Generic extractor ───────────────────────────────────────────────────────── -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 ``