From f66a6292a21c2e6aca92f33b617655231d641f15 Mon Sep 17 00:00:00 2001 From: himanshupatro-334 Date: Mon, 7 Sep 2026 12:02:10 +0530 Subject: [PATCH] feat(js): resolve mixin factory inheritance --- graphify/extractors/engine.py | 208 +++++++++++- graphify/extractors/models.py | 19 ++ graphify/extractors/resolution.py | 439 ++++++++++++++++++++++++- tests/test_js_class_expressions.py | 271 +++++++++++++++ tests/test_js_mixin_fact_collection.py | 404 +++++++++++++++++++++++ tests/test_js_mixin_resolution.py | 300 +++++++++++++++++ tests/test_owned_type_sources.py | 4 +- 7 files changed, 1637 insertions(+), 8 deletions(-) create mode 100644 tests/test_js_class_expressions.py create mode 100644 tests/test_js_mixin_fact_collection.py create mode 100644 tests/test_js_mixin_resolution.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index be6c38552c..66a2c9765f 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2094,11 +2094,16 @@ def _scan_js_nested_function_declarations( container_node, parent_nid: str, *, source: bytes, config, add_node, add_edge, callable_def_nids: set | None, local_bound_names: dict | None, function_bodies: list, + walk_fn: Any = None, + callable_class_nids: set | None = None, + seen_ids: set | None = None, + enclosing_fn_name: str | None = None, ) -> None: """Emit a node + `contains` edge for every named `function`/generator - declaration lexically nested inside *container_node*, scoped under - *parent_nid*, and track its body so calls made from inside it resolve - instead of dangling (#2653). + declaration, nested class declaration, or returned class expression + lexically nested inside *container_node*, scoped under *parent_nid*, + and track its body so calls made from inside it resolve instead of + dangling (#2653, #3349). Recurses through non-function children AND through the bodies of nested arrow / function expressions, so a `function` declared inside an arrow @@ -2110,6 +2115,36 @@ def _scan_js_nested_function_declarations( """ if container_node is None: return + + if container_node.type == "class": + # Concise arrow function returning a class directly: `const mixin = (Base) => class extends Base {}` + name_node = container_node.child_by_field_name("name") + class_name = _read_text(name_node, source) if name_node else None + line = container_node.start_point[0] + 1 + if class_name and normalize_id(class_name): + class_label = class_name + class_nid = _make_id(parent_nid, class_name) + else: + fn_name = enclosing_fn_name or "factory" + class_label = f"{fn_name}@class" + class_nid = _make_id(parent_nid, class_label) + if seen_ids is not None and class_nid in seen_ids: + class_label = f"{fn_name}@class@L{line}" + class_nid = _make_id(parent_nid, class_label) + add_node(class_nid, class_label, line) + add_edge(parent_nid, class_nid, "contains", line) + if callable_def_nids is not None: + callable_def_nids.add(class_nid) + if callable_class_nids is not None: + callable_class_nids.add(class_nid) + class_body = _find_body(container_node, config) + if class_body is None: + class_body = next((c for c in container_node.children if c.type == "class_body"), None) + if class_body and walk_fn is not None: + for bchild in class_body.children: + walk_fn(bchild, parent_class_nid=class_nid) + return + for child in container_node.children: if child.type in ("function_declaration", "generator_function_declaration"): name_node = child.child_by_field_name(config.name_field) @@ -2139,7 +2174,119 @@ def _scan_js_nested_function_declarations( callable_def_nids=callable_def_nids, local_bound_names=local_bound_names, function_bodies=function_bodies, + walk_fn=walk_fn, + callable_class_nids=callable_class_nids, + seen_ids=seen_ids, + enclosing_fn_name=func_name, ) + elif child.type == "class_declaration": + name_node = child.child_by_field_name(config.name_field) + if name_node is None: + for c in child.children: + if c.type in config.name_fallback_child_types: + name_node = c + break + class_name = _read_text(name_node, source) if name_node else None + if class_name and normalize_id(class_name): + line = child.start_point[0] + 1 + nested_nid = _make_id(parent_nid, class_name) + add_node(nested_nid, class_name, line) + add_edge(parent_nid, nested_nid, "contains", line) + if callable_def_nids is not None: + callable_def_nids.add(nested_nid) + if callable_class_nids is not None: + callable_class_nids.add(nested_nid) + class_body = _find_body(child, config) + if class_body is None: + class_body = next((c for c in child.children if c.type == "class_body"), None) + if class_body and walk_fn is not None: + for bchild in class_body.children: + walk_fn(bchild, parent_class_nid=nested_nid) + elif child.type == "return_statement": + ret_expr = child.named_children[0] if child.named_children else None + while ret_expr is not None and ret_expr.type in ( + "parenthesized_expression", "as_expression", "satisfies_expression", + ): + ret_expr = ret_expr.named_children[0] if ret_expr.named_children else None + if ret_expr and ret_expr.type == "class": + name_node = ret_expr.child_by_field_name("name") + class_name = _read_text(name_node, source) if name_node else None + line = ret_expr.start_point[0] + 1 + if class_name and normalize_id(class_name): + class_label = class_name + class_nid = _make_id(parent_nid, class_name) + else: + fn_name = enclosing_fn_name or "factory" + class_label = f"{fn_name}@class" + class_nid = _make_id(parent_nid, class_label) + if seen_ids is not None and class_nid in seen_ids: + class_label = f"{fn_name}@class@L{line}" + class_nid = _make_id(parent_nid, class_label) + add_node(class_nid, class_label, line) + add_edge(parent_nid, class_nid, "contains", line) + if callable_def_nids is not None: + callable_def_nids.add(class_nid) + if callable_class_nids is not None: + callable_class_nids.add(class_nid) + class_body = _find_body(ret_expr, config) + if class_body is None: + class_body = next((c for c in ret_expr.children if c.type == "class_body"), None) + if class_body and walk_fn is not None: + for bchild in class_body.children: + walk_fn(bchild, parent_class_nid=class_nid) + else: + _scan_js_nested_function_declarations( + child, parent_nid, source=source, config=config, + add_node=add_node, add_edge=add_edge, + callable_def_nids=callable_def_nids, + local_bound_names=local_bound_names, + function_bodies=function_bodies, + walk_fn=walk_fn, + callable_class_nids=callable_class_nids, + seen_ids=seen_ids, + enclosing_fn_name=enclosing_fn_name, + ) + elif child.type in ("lexical_declaration", "variable_declaration"): + handled_class_decl = False + for decl in child.children: + if decl.type == "variable_declarator": + val = decl.child_by_field_name("value") + while val is not None and val.type in ( + "parenthesized_expression", "as_expression", "satisfies_expression", + ): + val = val.named_children[0] if val.named_children else None + if val and val.type == "class": + name_node = decl.child_by_field_name("name") + if name_node and name_node.type == "identifier": + cname = _read_text(name_node, source) + if normalize_id(cname): + line = decl.start_point[0] + 1 + cnid = _make_id(parent_nid, cname) + add_node(cnid, cname, line) + add_edge(parent_nid, cnid, "contains", line) + if callable_def_nids is not None: + callable_def_nids.add(cnid) + if callable_class_nids is not None: + callable_class_nids.add(cnid) + cbody = _find_body(val, config) + if cbody is None: + cbody = next((c for c in val.children if c.type == "class_body"), None) + if cbody and walk_fn is not None: + for bchild in cbody.children: + walk_fn(bchild, parent_class_nid=cnid) + handled_class_decl = True + if not handled_class_decl: + _scan_js_nested_function_declarations( + child, parent_nid, source=source, config=config, + add_node=add_node, add_edge=add_edge, + callable_def_nids=callable_def_nids, + local_bound_names=local_bound_names, + function_bodies=function_bodies, + walk_fn=walk_fn, + callable_class_nids=callable_class_nids, + seen_ids=seen_ids, + enclosing_fn_name=enclosing_fn_name, + ) elif child.type in _JS_FUNCTION_VALUE_TYPES: # An anonymous arrow/function expression is not itself a node, but a # `function` declared inside its body still belongs to the enclosing @@ -2150,6 +2297,10 @@ def _scan_js_nested_function_declarations( callable_def_nids=callable_def_nids, local_bound_names=local_bound_names, function_bodies=function_bodies, + walk_fn=walk_fn, + callable_class_nids=callable_class_nids, + seen_ids=seen_ids, + enclosing_fn_name=enclosing_fn_name, ) else: _scan_js_nested_function_declarations( @@ -2158,6 +2309,10 @@ def _scan_js_nested_function_declarations( callable_def_nids=callable_def_nids, local_bound_names=local_bound_names, function_bodies=function_bodies, + walk_fn=walk_fn, + callable_class_nids=callable_class_nids, + seen_ids=seen_ids, + enclosing_fn_name=enclosing_fn_name, ) @@ -2224,7 +2379,9 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, callable_def_nids: set | None = None, local_bound_names: dict | None = None, closure_locals_by_body: dict | None = None, - config=None) -> bool: + config=None, + walk_fn: Any = None, + callable_class_nids: set | 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() @@ -2337,7 +2494,7 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, # 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: + if node.type in ("lexical_declaration", "variable_declaration") and is_module_level: for child in node.children: if child.type == "variable_declarator": value = child.child_by_field_name("value") @@ -2348,6 +2505,13 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, and name_node.type == "identifier" and bool(normalize_id(_read_text(name_node, source))) ) + inner_val = value + while inner_val is not None and inner_val.type in ( + "as_expression", "satisfies_expression", "parenthesized_expression", + ): + inner_val = (inner_val.named_children[0] + if inner_val.named_children else None) + if value and value.type in _JS_FUNCTION_VALUE_TYPES: # `const f = () => {}` and `const f = function(){}` if name_node: @@ -2379,8 +2543,34 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, callable_def_nids=callable_def_nids, local_bound_names=local_bound_names, function_bodies=function_bodies, + walk_fn=walk_fn, + callable_class_nids=callable_class_nids, + seen_ids=seen_ids, + enclosing_fn_name=func_name, ) arrow_found = True + elif inner_val and inner_val.type == "class": + # Class expression: `const Foo = class Bar {}` or `const Foo = class {}` (#3349) + if name_node and name_node.type == "identifier": + class_name = _read_text(name_node, source) + if not normalize_id(class_name): + continue + line = child.start_point[0] + 1 + class_nid = _make_id(stem, class_name) + add_node_fn(class_nid, class_name, line) + add_edge_fn(file_nid, class_nid, "contains", line) + if callable_def_nids is not None: + callable_def_nids.add(class_nid) + if callable_class_nids is not None: + callable_class_nids.add(class_nid) + cbody = _find_body(inner_val, config) if config else None + if cbody is None: + cbody = next((c for c in inner_val.children if c.type == "class_body"), None) + if cbody and walk_fn is not None: + for bchild in cbody.children: + walk_fn(bchild, parent_class_nid=class_nid) + const_found = True + continue elif value and ( is_exported_scalar_binding or value.type in ( @@ -4732,6 +4922,10 @@ def scala_base_name(type_node) -> str | None: callable_def_nids=callable_def_nids, local_bound_names=local_bound_names, function_bodies=function_bodies, + walk_fn=walk, + callable_class_nids=callable_class_nids, + seen_ids=seen_ids, + enclosing_fn_name=func_name, ) if config.ts_module == "tree_sitter_kotlin": # #2347: Kotlin anonymous objects (`object : Foo { … }`, @@ -4825,7 +5019,9 @@ def scala_base_name(type_node) -> str | None: nodes, edges, seen_ids, function_bodies, parent_class_nid, add_node, add_edge, callable_def_nids, local_bound_names, - closure_locals_by_body, config=config): + closure_locals_by_body, config=config, + walk_fn=walk, + callable_class_nids=callable_class_nids): return # TS enum members, and namespace / module containers diff --git a/graphify/extractors/models.py b/graphify/extractors/models.py index 014907c0d5..e00137291b 100644 --- a/graphify/extractors/models.py +++ b/graphify/extractors/models.py @@ -115,6 +115,23 @@ class _SymbolUseFact: context: str line: int +@dataclass(frozen=True) +class _JSClassFactoryFact: + file_path: str + factory_name: str + factory_nid: str + returned_class_nid: str + base_param_index: int + line: int + +@dataclass(frozen=True) +class _JSFactoryApplicationFact: + file_path: str + target_nid: str + factory_name: str + arg_names: tuple[str, ...] + line: int + @dataclass class _SymbolResolutionFacts: declarations: list[_SymbolDeclarationFact] = field(default_factory=list) @@ -129,3 +146,5 @@ class _SymbolResolutionFacts: # is the binding introduced in the importing file: the alias when `from pkg # import submod as alias` is used, otherwise the submodule's own name (#2082). module_imports: list[tuple[Path, Path, int, str]] = field(default_factory=list) + class_factories: list[_JSClassFactoryFact] = field(default_factory=list) + factory_applications: list[_JSFactoryApplicationFact] = field(default_factory=list) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index ed9e857e87..3228fd3896 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -3,13 +3,28 @@ from typing import Any, Callable from pathlib import Path -from graphify.extractors.models import LanguageConfig, _JS_CACHE_BYPASS_SUFFIXES, _NamespaceExportFact, _StarExportFact, _SymbolAliasFact, _SymbolDeclarationFact, _SymbolExportFact, _SymbolImportFact, _SymbolResolutionFacts, _SymbolUseFact, _WORKSPACE_PACKAGE_CACHE # noqa: E402,F401 +from graphify.extractors.models import ( # noqa: E402,F401 + LanguageConfig, + _JS_CACHE_BYPASS_SUFFIXES, + _JSClassFactoryFact, + _JSFactoryApplicationFact, + _NamespaceExportFact, + _StarExportFact, + _SymbolAliasFact, + _SymbolDeclarationFact, + _SymbolExportFact, + _SymbolImportFact, + _SymbolResolutionFacts, + _SymbolUseFact, + _WORKSPACE_PACKAGE_CACHE, +) from graphify.extractors.base import ( # noqa: F401 _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text, ) +from graphify.ids import normalize_id import hashlib import json import os @@ -836,6 +851,8 @@ def _apply_symbol_resolution_facts( or facts.namespace_exports or facts.uses or facts.module_imports + or facts.class_factories + or facts.factory_applications ): return @@ -1185,6 +1202,71 @@ def exported_candidates( use_fact.file_path, ) + # Phase 9D: Resolve JS/TS mixin factory applications and definition bases + if facts.factory_applications and facts.class_factories: + factories_by_file_and_name: dict[tuple[Path, str], _JSClassFactoryFact] = {} + for factory_fact in facts.class_factories: + fact_path = Path(factory_fact.file_path).resolve() + factories_by_file_and_name[(fact_path, factory_fact.factory_name)] = factory_fact + + def _resolve_symbol_in_file(file_path: Path, sym_name: str) -> str | None: + origin = local_aliases_by_file.get(file_path, {}).get(sym_name) + if origin is not None: + origin_path, origin_symbol = resolve_exported_origin(*origin) + target_id = symbol_nodes.get((origin_path, origin_symbol)) + if target_id is not None: + return target_id + return symbol_nodes.get((file_path, sym_name)) + + for app_fact in facts.factory_applications: + app_path = Path(app_fact.file_path).resolve() + + matched_factory = None + factory_origin = local_aliases_by_file.get(app_path, {}).get(app_fact.factory_name) + if factory_origin is not None: + origin_path, origin_symbol = resolve_exported_origin(*factory_origin) + matched_factory = factories_by_file_and_name.get((origin_path, origin_symbol)) + if matched_factory is None: + matched_factory = factories_by_file_and_name.get((app_path, app_fact.factory_name)) + + if matched_factory is None: + continue + + idx = matched_factory.base_param_index + if not (0 <= idx < len(app_fact.arg_names)): + continue + + base_arg_name = app_fact.arg_names[idx] + base_nid = _resolve_symbol_in_file(app_path, base_arg_name) + if base_nid is None or base_nid not in owned: + continue + + target_nid = app_fact.target_nid + returned_class_nid = matched_factory.returned_class_nid + + if target_nid not in owned or returned_class_nid not in owned: + continue + + # Target mixes in the returned factory class + add_edge( + target_nid, + returned_class_nid, + "mixes_in", + "mixin", + app_fact.line, + app_path, + ) + + # Returned factory class inherits from the resolved base argument + add_edge( + returned_class_nid, + base_nid, + "inherits", + "type", + app_fact.line, + app_path, + ) + def _parse_js_tree(path: Path): try: from tree_sitter import Language, Parser @@ -1399,6 +1481,359 @@ def _js_call_identifier(node, source: bytes) -> str | None: return _read_text(function_node, source) return None + +def _extract_js_formal_parameter_names( + fn_node, + params_node, + source: bytes, +) -> list[str | None]: + """Extract formal parameter names for JS/TS function or arrow function. + + Returns None for any parameter that is destructured, rest, or not a simple identifier. + """ + if params_node is None: + if fn_node.type == "arrow_function": + single_param = fn_node.child_by_field_name("parameter") + if single_param is not None and single_param.type == "identifier": + name = _read_text(single_param, source) + return [name] if name else [None] + return [] + + names: list[str | None] = [] + for param in params_node.named_children: + if param.type == "identifier": + names.append(_read_text(param, source)) + elif param.type in ("required_parameter", "optional_parameter"): + pattern = param.child_by_field_name("pattern") + if pattern is not None and pattern.type == "identifier": + names.append(_read_text(pattern, source)) + else: + names.append(None) + elif param.type == "assignment_pattern": + left = param.child_by_field_name("left") + if left is not None and left.type == "identifier": + names.append(_read_text(left, source)) + else: + names.append(None) + else: + names.append(None) + return names + + +def _collect_function_returns(body_node) -> list[object]: + """Collect return_statement nodes belonging directly to body_node, + pruning nested functions, classes, and method scopes. + """ + returns = [] + stack = list(body_node.children) + while stack: + curr = stack.pop() + if curr.type == "return_statement": + returns.append(curr) + continue + if curr.type in ( + "function_declaration", + "generator_function_declaration", + "function_expression", + "arrow_function", + "method_definition", + "class_declaration", + "abstract_class_declaration", + "class", + ): + continue + stack.extend(curr.children) + return returns + + +def _unwrap_class_node(node) -> object | None: + """Unwrap parenthesized / cast expressions to find an inner class node.""" + curr = node + while curr is not None and curr.type in ( + "parenthesized_expression", + "as_expression", + "satisfies_expression", + ): + curr = curr.named_children[0] if curr.named_children else None + if curr is not None and curr.type == "class": + return curr + return None + + +def _extract_class_extends_identifier(class_node, source: bytes) -> str | None: + """Extract the base identifier if the class extends a simple identifier. + + Returns None if there is no heritage, or if the base is dynamic/complex (Rule C). + """ + heritage_node = None + for ch in class_node.children: + if ch.type == "class_heritage": + heritage_node = ch + break + if heritage_node is None: + return None + + # Check for TypeScript extends_clause + extends_clause = None + for ch in heritage_node.children: + if ch.type == "extends_clause": + extends_clause = ch + break + + target_clause = extends_clause if extends_clause is not None else heritage_node + if not target_clause.named_children: + return None + base_expr = target_clause.named_children[0] + while base_expr is not None and base_expr.type in ( + "parenthesized_expression", + "as_expression", + "satisfies_expression", + ): + base_expr = base_expr.named_children[0] if base_expr.named_children else None + + if base_expr is None or base_expr.type not in ("identifier", "type_identifier"): + return None + + base_name = _read_text(base_expr, source) + return base_name if base_name else None + + +def _js_extract_factory_fact( + fn_name: str, + fn_node, + params_node, + body_node, + path: Path, + stem: str, + source: bytes, + line: int, +) -> _JSClassFactoryFact | None: + """Qualify a function as a static class mixin factory and return a fact if qualified.""" + if not fn_name or not normalize_id(fn_name): + return None + if body_node is None: + return None + + param_names = _extract_js_formal_parameter_names(fn_node, params_node, source) + if not param_names: + return None + + # Rule A: Exactly one return statement (or concise arrow return) + # Rule B: Return value must be a class + if body_node.type != "statement_block": + ret_class_node = _unwrap_class_node(body_node) + if ret_class_node is None: + return None + else: + returns = _collect_function_returns(body_node) + if len(returns) != 1: + return None + ret_stmt = returns[0] + if not ret_stmt.named_children: + return None + ret_class_node = _unwrap_class_node(ret_stmt.named_children[0]) + if ret_class_node is None: + return None + + # Rule C: Class must have static heritage extending a simple identifier + base_ident = _extract_class_extends_identifier(ret_class_node, source) + if base_ident is None: + return None + + # Rule D: Base identifier must match exactly one formal parameter + matching_indices = [ + i for i, name in enumerate(param_names) + if name is not None and name == base_ident + ] + if len(matching_indices) != 1: + return None + base_param_index = matching_indices[0] + + factory_nid = _make_id(stem, fn_name) + name_node = ret_class_node.child_by_field_name("name") + class_name = _read_text(name_node, source) if name_node else None + if class_name and normalize_id(class_name): + returned_class_nid = _make_id(factory_nid, class_name) + else: + returned_class_nid = _make_id(factory_nid, f"{fn_name}@class") + + return _JSClassFactoryFact( + file_path=str(path), + factory_name=fn_name, + factory_nid=factory_nid, + returned_class_nid=returned_class_nid, + base_param_index=base_param_index, + line=line, + ) + + +def _extract_factory_application_from_call( + target_nid: str, + call_expr, + path: Path, + source: bytes, + line: int, +) -> _JSFactoryApplicationFact | None: + """Extract a factory application fact from a call expression if arguments are simple identifiers.""" + fn_node = call_expr.child_by_field_name("function") + if fn_node is None: + for ch in call_expr.named_children: + if ch.type != "type_arguments": + fn_node = ch + break + if fn_node is None or fn_node.type not in ("identifier", "type_identifier"): + return None + factory_name = _read_text(fn_node, source) + if not factory_name or not normalize_id(factory_name): + return None + + args_node = call_expr.child_by_field_name("arguments") + if args_node is None: + return None + + arg_names: list[str] = [] + for arg in args_node.named_children: + if arg.type not in ("identifier", "type_identifier"): + return None + arg_name = _read_text(arg, source) + if not arg_name or not normalize_id(arg_name): + return None + arg_names.append(arg_name) + + return _JSFactoryApplicationFact( + file_path=str(path), + target_nid=target_nid, + factory_name=factory_name, + arg_names=tuple(arg_names), + line=line, + ) + + +def _collect_js_mixin_facts( + path: Path, + stem: str, + root_node, + source: bytes, + facts: _SymbolResolutionFacts, +) -> None: + """Collect _JSClassFactoryFact and _JSFactoryApplicationFact (Phase 9C).""" + # 1. Top-level statements for factory definitions and Pattern A (variable-bound applications) + for top_node in root_node.children: + node = top_node + if node.type == "export_statement": + decl = node.child_by_field_name("declaration") + if decl is not None: + node = decl + + if node.type == "function_declaration": + name_node = node.child_by_field_name("name") + if name_node is not None and name_node.type == "identifier": + fn_name = _read_text(name_node, source) + if fn_name and normalize_id(fn_name): + params_node = node.child_by_field_name("parameters") + body_node = node.child_by_field_name("body") + line = node.start_point[0] + 1 + factory_fact = _js_extract_factory_fact( + fn_name, node, params_node, body_node, path, stem, source, line, + ) + if factory_fact is not None: + facts.class_factories.append(factory_fact) + + elif node.type in ("lexical_declaration", "variable_declaration"): + for child in node.children: + if child.type != "variable_declarator": + continue + name_node = child.child_by_field_name("name") + if name_node is None or name_node.type != "identifier": + continue + var_name = _read_text(name_node, source) + if not var_name or not normalize_id(var_name): + continue + value_node = child.child_by_field_name("value") + if value_node is None: + continue + inner_val = value_node + while inner_val is not None and inner_val.type in ( + "as_expression", "satisfies_expression", "parenthesized_expression", + ): + inner_val = inner_val.named_children[0] if inner_val.named_children else None + if inner_val is None: + continue + + line = child.start_point[0] + 1 + + # Check if it's a factory function: `const mixin = (Base) => { ... }` + if inner_val.type in ("arrow_function", "function_expression"): + params_node = inner_val.child_by_field_name("parameters") + body_node = inner_val.child_by_field_name("body") + factory_fact = _js_extract_factory_fact( + var_name, inner_val, params_node, body_node, path, stem, source, line, + ) + if factory_fact is not None: + facts.class_factories.append(factory_fact) + + # Check Pattern A: `const Applied = mixin(Root);` + elif inner_val.type == "call_expression": + target_nid = _make_id(stem, var_name) + app_fact = _extract_factory_application_from_call( + target_nid, inner_val, path, source, line, + ) + if app_fact is not None: + facts.factory_applications.append(app_fact) + + # 2. Pattern B: `class Child extends mixin(Root) {}` + for node in _walk_js_tree(root_node): + if node.type not in ("class_declaration", "abstract_class_declaration"): + continue + name_node = node.child_by_field_name("name") + if name_node is None: + continue + class_name = _read_text(name_node, source) + if not class_name or not normalize_id(class_name): + continue + target_nid = _make_id(stem, class_name) + line = node.start_point[0] + 1 + + # Look for call_expression in class_heritage + for child in node.children: + if child.type != "class_heritage": + continue + call_expr = None + saw_extends_clause = False + for clause in child.children: + if clause.type == "extends_clause": + saw_extends_clause = True + for sub in clause.children: + expr = sub + while expr is not None and expr.type in ( + "as_expression", "satisfies_expression", "parenthesized_expression", + ): + expr = expr.named_children[0] if expr.named_children else None + if expr is not None and expr.type == "call_expression": + call_expr = expr + break + if call_expr is not None: + break + + if not saw_extends_clause: + for sub in child.children: + expr = sub + while expr is not None and expr.type in ( + "as_expression", "satisfies_expression", "parenthesized_expression", + ): + expr = expr.named_children[0] if expr.named_children else None + if expr is not None and expr.type == "call_expression": + call_expr = expr + break + + if call_expr is not None: + app_fact = _extract_factory_application_from_call( + target_nid, call_expr, path, source, line, + ) + if app_fact is not None: + facts.factory_applications.append(app_fact) + + _JS_PRIMITIVE_TYPES = frozenset({ "string", "number", "boolean", "any", "unknown", "void", "never", "object", "null", "undefined", "bigint", "symbol", "this", @@ -1779,6 +2214,8 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut class_nid = _make_id(stem, class_name) _ts_walk_class_members(node, source, path, class_nid, facts) + _collect_js_mixin_facts(path, stem, root_node, source, facts) + def _parse_python_tree(path: Path): try: import tree_sitter_python as tspython diff --git a/tests/test_js_class_expressions.py b/tests/test_js_class_expressions.py new file mode 100644 index 0000000000..3acfb038b7 --- /dev/null +++ b/tests/test_js_class_expressions.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import pytest + +from graphify.extract import extract_js + + +@pytest.mark.parametrize("suffix", [".js", ".ts"]) +def test_named_class_expression(tmp_path, suffix): + """Test A: Named class expression. + + const Foo = class Bar { method() {} }; + - Foo exists as a class node. + - Foo has the class-scoped .method(). + - Bar does not become a separate class node. + - No variable node for Foo remains. + """ + source = tmp_path / f"named_expr{suffix}" + source.write_text( + """ +const Foo = class Bar { + method() {} +}; +""", + encoding="utf-8", + ) + result = extract_js(source) + nodes_by_label = {n["label"]: n for n in result["nodes"]} + labels = set(nodes_by_label) + + assert "Foo" in labels + assert "Bar" not in labels + assert ".method()" in labels + + foo_nid = nodes_by_label["Foo"]["id"] + method_nid = nodes_by_label[".method()"]["id"] + + # Method belongs to Foo + method_edges = [ + e for e in result["edges"] + if e["relation"] == "method" and e["source"] == foo_nid and e["target"] == method_nid + ] + assert len(method_edges) == 1 + + # Exactly one Foo node exists + assert sum(1 for n in result["nodes"] if n["label"] == "Foo") == 1 + + +@pytest.mark.parametrize("suffix", [".js", ".ts"]) +def test_anonymous_class_expression_exported(tmp_path, suffix): + """Test B: Exported anonymous class expression. + + export const Foo = class { method() {} }; + - Foo is a class. + - Its method belongs to Foo. + """ + source = tmp_path / f"anon_expr{suffix}" + source.write_text( + """ +export const Foo = class { + method() {} +}; +""", + encoding="utf-8", + ) + result = extract_js(source) + nodes_by_label = {n["label"]: n for n in result["nodes"]} + labels = set(nodes_by_label) + + assert "Foo" in labels + assert ".method()" in labels + + foo_nid = nodes_by_label["Foo"]["id"] + method_nid = nodes_by_label[".method()"]["id"] + + method_edges = [ + e for e in result["edges"] + if e["relation"] == "method" and e["source"] == foo_nid and e["target"] == method_nid + ] + assert len(method_edges) == 1 + + +@pytest.mark.parametrize("suffix", [".js", ".ts"]) +def test_nested_class_declaration(tmp_path, suffix): + """Test C: Nested class declaration inside function. + + function factory() { + class Inner { + method() {} + } + } + - factory() contains Inner + - Inner is a class + - Inner owns .method() + """ + source = tmp_path / f"nested_decl{suffix}" + source.write_text( + """ +function factory() { + class Inner { + method() {} + } +} +""", + encoding="utf-8", + ) + result = extract_js(source) + nodes_by_label = {n["label"]: n for n in result["nodes"]} + labels = set(nodes_by_label) + + assert "factory()" in labels + assert "Inner" in labels + assert ".method()" in labels + + factory_nid = nodes_by_label["factory()"]["id"] + inner_nid = nodes_by_label["Inner"]["id"] + method_nid = nodes_by_label[".method()"]["id"] + + # factory() contains Inner + assert any( + e["source"] == factory_nid and e["target"] == inner_nid and e["relation"] == "contains" + for e in result["edges"] + ) + # Inner owns method + assert any( + e["source"] == inner_nid and e["target"] == method_nid and e["relation"] == "method" + for e in result["edges"] + ) + + +@pytest.mark.parametrize("suffix", [".js", ".ts"]) +def test_returned_named_class(tmp_path, suffix): + """Test D: Returned named class. + + function factory(Base) { + return class Shared extends Base { + method() {} + }; + } + - Shared exists under factory() + - Shared owns .method() + - No inherits edge to Base yet in Pass 1 + """ + source = tmp_path / f"ret_named{suffix}" + source.write_text( + """ +function factory(Base) { + return class Shared extends Base { + method() {} + }; +} +""", + encoding="utf-8", + ) + result = extract_js(source) + nodes_by_label = {n["label"]: n for n in result["nodes"]} + labels = set(nodes_by_label) + + assert "factory()" in labels + assert "Shared" in labels + assert ".method()" in labels + + factory_nid = nodes_by_label["factory()"]["id"] + shared_nid = nodes_by_label["Shared"]["id"] + method_nid = nodes_by_label[".method()"]["id"] + + assert any( + e["source"] == factory_nid and e["target"] == shared_nid and e["relation"] == "contains" + for e in result["edges"] + ) + assert any( + e["source"] == shared_nid and e["target"] == method_nid and e["relation"] == "method" + for e in result["edges"] + ) + assert not any(e["relation"] == "inherits" for e in result["edges"]) + + +@pytest.mark.parametrize("suffix", [".js", ".ts"]) +def test_returned_anonymous_class(tmp_path, suffix): + """Test E: Returned anonymous class. + + function mixin(Base) { + return class extends Base { + method() {} + }; + } + - mixin@class exists under mixin() + - mixin@class owns .method() + - Deterministic ID + - No inherits edge to Base yet in Pass 1 + """ + source = tmp_path / f"ret_anon{suffix}" + source.write_text( + """ +function mixin(Base) { + return class extends Base { + method() {} + }; +} +""", + encoding="utf-8", + ) + result = extract_js(source) + nodes_by_label = {n["label"]: n for n in result["nodes"]} + labels = set(nodes_by_label) + + assert "mixin()" in labels + assert "mixin@class" in labels + assert ".method()" in labels + + mixin_nid = nodes_by_label["mixin()"]["id"] + class_nid = nodes_by_label["mixin@class"]["id"] + method_nid = nodes_by_label[".method()"]["id"] + + assert any( + e["source"] == mixin_nid and e["target"] == class_nid and e["relation"] == "contains" + for e in result["edges"] + ) + assert any( + e["source"] == class_nid and e["target"] == method_nid and e["relation"] == "method" + for e in result["edges"] + ) + assert not any(e["relation"] == "inherits" for e in result["edges"]) + + +def test_multiple_anonymous_returned_classes(tmp_path): + """Multiple anonymous classes inside the same function receive distinct deterministic IDs.""" + source = tmp_path / "multi_anon.js" + source.write_text( + """ +function makeClasses(cond) { + if (cond) { + return class { + firstMethod() {} + }; + } + return class { + secondMethod() {} + }; +} +""", + encoding="utf-8", + ) + result = extract_js(source) + labels = {n["label"] for n in result["nodes"]} + assert "makeClasses()" in labels + assert "makeClasses@class" in labels + assert ".firstMethod()" in labels + assert ".secondMethod()" in labels + # Second anonymous class disambiguates with line number + assert any(l.startswith("makeClasses@class@L") for l in labels) + + +def test_preserve_ordinary_variables(tmp_path): + """Ordinary variable declarations and initializers retain their normal behavior.""" + source = tmp_path / "ordinary.js" + source.write_text( + """ +const value = 42; +export const exportedScalar = 100; +export const other = SomeClass; +export const result = createThing(); +""", + encoding="utf-8", + ) + result = extract_js(source) + labels = {n["label"] for n in result["nodes"]} + assert "value" not in labels + assert "exportedScalar" in labels + assert "other" in labels + assert "result" in labels diff --git a/tests/test_js_mixin_fact_collection.py b/tests/test_js_mixin_fact_collection.py new file mode 100644 index 0000000000..48daf24ec5 --- /dev/null +++ b/tests/test_js_mixin_fact_collection.py @@ -0,0 +1,404 @@ +"""Tests for Phase 9C: JS/TS mixin factory and application fact collection (Pass 2).""" +from __future__ import annotations + +from pathlib import Path +import pytest + +from graphify.extract import extract +from graphify.extractors.base import _file_stem, _make_id +from graphify.extractors.models import _SymbolResolutionFacts +from graphify.extractors.resolution import _collect_js_symbol_resolution_facts + + +def _collect_facts_for_source(tmp_path: Path, filename: str, content: str) -> tuple[_SymbolResolutionFacts, Path]: + file_path = tmp_path / filename + file_path.write_text(content, encoding="utf-8") + facts = _SymbolResolutionFacts() + _collect_js_symbol_resolution_facts([file_path], facts) + return facts, file_path + + +# --- Factory Detection Tests --- + +def test_simple_factory(tmp_path): + """1. Simple factory: exactly one factory fact, base_param_index=0, anonymous returned class.""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "simple_factory.js", + """ +function mixin(Base) { + return class extends Base {}; +} +""", + ) + assert len(facts.class_factories) == 1 + fact = facts.class_factories[0] + stem = _file_stem(file_path) + expected_factory_nid = _make_id(stem, "mixin") + expected_returned_nid = _make_id(expected_factory_nid, "mixin@class") + assert fact.factory_name == "mixin" + assert fact.factory_nid == expected_factory_nid + assert fact.returned_class_nid == expected_returned_nid + assert fact.base_param_index == 0 + assert fact.line == 2 + + +def test_named_returned_class(tmp_path): + """2. Named returned class: returned_class_nid ends with the class name.""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "named_return.js", + """ +function mixin(Base) { + return class Shared extends Base {}; +} +""", + ) + assert len(facts.class_factories) == 1 + fact = facts.class_factories[0] + stem = _file_stem(file_path) + expected_factory_nid = _make_id(stem, "mixin") + expected_returned_nid = _make_id(expected_factory_nid, "Shared") + assert fact.factory_name == "mixin" + assert fact.base_param_index == 0 + assert fact.returned_class_nid == expected_returned_nid + + +def test_multiple_parameters(tmp_path): + """3. Multiple parameters: base_param_index accurately identifies the base parameter.""" + facts, _ = _collect_facts_for_source( + tmp_path, + "multi_params.js", + """ +function mixin(Config, Base) { + return class extends Base {}; +} +""", + ) + assert len(facts.class_factories) == 1 + fact = facts.class_factories[0] + assert fact.factory_name == "mixin" + assert fact.base_param_index == 1 + + +def test_typescript_parameter(tmp_path): + """4. TypeScript parameter: type annotation does not interfere with parameter matching.""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "ts_factory.ts", + """ +function mixin(Base: T) { + return class extends Base {}; +} +""", + ) + assert len(facts.class_factories) == 1 + fact = facts.class_factories[0] + stem = _file_stem(file_path) + expected_factory_nid = _make_id(stem, "mixin") + expected_returned_nid = _make_id(expected_factory_nid, "mixin@class") + assert fact.factory_name == "mixin" + assert fact.base_param_index == 0 + assert fact.returned_class_nid == expected_returned_nid + + +def test_arrow_function_factory(tmp_path): + """Arrow function factory assigned to const.""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "arrow_factory.js", + """ +const mixin = (Base) => { + return class extends Base {}; +}; +""", + ) + assert len(facts.class_factories) == 1 + fact = facts.class_factories[0] + stem = _file_stem(file_path) + expected_factory_nid = _make_id(stem, "mixin") + expected_returned_nid = _make_id(expected_factory_nid, "mixin@class") + assert fact.factory_name == "mixin" + assert fact.base_param_index == 0 + assert fact.returned_class_nid == expected_returned_nid + + +def test_concise_arrow_factory(tmp_path): + """Concise arrow function returning a class directly.""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "concise_factory.js", + """ +const mixin = (Base) => class extends Base {}; +""", + ) + assert len(facts.class_factories) == 1 + fact = facts.class_factories[0] + stem = _file_stem(file_path) + expected_factory_nid = _make_id(stem, "mixin") + expected_returned_nid = _make_id(expected_factory_nid, "mixin@class") + assert fact.factory_name == "mixin" + assert fact.base_param_index == 0 + assert fact.returned_class_nid == expected_returned_nid + + +def test_function_expression_factory(tmp_path): + """Function expression factory assigned to const.""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "fn_expr_factory.js", + """ +const mixin = function(Base) { + return class extends Base {}; +}; +""", + ) + assert len(facts.class_factories) == 1 + fact = facts.class_factories[0] + stem = _file_stem(file_path) + expected_factory_nid = _make_id(stem, "mixin") + expected_returned_nid = _make_id(expected_factory_nid, "mixin@class") + assert fact.factory_name == "mixin" + assert fact.base_param_index == 0 + assert fact.returned_class_nid == expected_returned_nid + + +# --- Application Detection Tests --- + +def test_variable_bound_application(tmp_path): + """5. Variable-bound application: const Applied = mixin(Root);""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "var_bound.js", + """ +class Root {} +function mixin(Base) { + return class extends Base {}; +} +const Applied = mixin(Root); +""", + ) + assert len(facts.factory_applications) == 1 + app = facts.factory_applications[0] + stem = _file_stem(file_path) + expected_target_nid = _make_id(stem, "Applied") + assert app.target_nid == expected_target_nid + assert app.factory_name == "mixin" + assert app.arg_names == ("Root",) + + +def test_dynamic_heritage_application(tmp_path): + """6. Dynamic class heritage: class Child extends mixin(Root) {}""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "dyn_heritage.js", + """ +class Root {} +function mixin(Base) { + return class extends Base {}; +} +class Child extends mixin(Root) {} +""", + ) + assert len(facts.factory_applications) == 1 + app = facts.factory_applications[0] + stem = _file_stem(file_path) + expected_target_nid = _make_id(stem, "Child") + assert app.target_nid == expected_target_nid + assert app.factory_name == "mixin" + assert app.arg_names == ("Root",) + + +def test_multi_argument_application(tmp_path): + """Application with multiple simple identifier arguments.""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "multi_args.js", + """ +const Applied = mixin(Config, Root); +""", + ) + assert len(facts.factory_applications) == 1 + app = facts.factory_applications[0] + stem = _file_stem(file_path) + expected_target_nid = _make_id(stem, "Applied") + assert app.target_nid == expected_target_nid + assert app.factory_name == "mixin" + assert app.arg_names == ("Config", "Root") + + +def test_ts_dynamic_heritage_application(tmp_path): + """TypeScript dynamic class heritage application.""" + facts, file_path = _collect_facts_for_source( + tmp_path, + "ts_dyn.ts", + """ +class Root {} +class Child extends mixin(Root) {} +""", + ) + assert len(facts.factory_applications) == 1 + app = facts.factory_applications[0] + stem = _file_stem(file_path) + expected_target_nid = _make_id(stem, "Child") + assert app.target_nid == expected_target_nid + assert app.factory_name == "mixin" + assert app.arg_names == ("Root",) + + +# --- Negative Cases Tests --- + +def test_dynamic_argument_rejected(tmp_path): + """7. Dynamic argument: call expression in argument is rejected.""" + facts, _ = _collect_facts_for_source( + tmp_path, + "dyn_arg.js", + """ +const Applied = mixin(getBase()); +""", + ) + assert len(facts.factory_applications) == 0 + + +def test_complex_expression_argument_rejected(tmp_path): + """Complex expression in argument (logical OR, ternary, etc.) is rejected.""" + facts, _ = _collect_facts_for_source( + tmp_path, + "complex_args.js", + """ +const A = mixin(X || Y); +const B = mixin(flag ? X : Y); +const C = mixin(m1(Root)); +""", + ) + assert len(facts.factory_applications) == 0 + + +def test_non_class_return_rejected(tmp_path): + """8. Non-class return: function returning non-class is not a factory.""" + facts, _ = _collect_facts_for_source( + tmp_path, + "non_class_ret.js", + """ +function mixin(Base) { + return Base; +} +""", + ) + assert len(facts.class_factories) == 0 + + +def test_multiple_returns_rejected(tmp_path): + """9. Multiple returns: ambiguous control-flow is rejected (Rule A).""" + facts, _ = _collect_facts_for_source( + tmp_path, + "multi_returns.js", + """ +function mixin(Base) { + if (x) return class extends Base {}; + return class extends Base {}; +} +""", + ) + assert len(facts.class_factories) == 0 + + +def test_multiple_returns_with_early_null_rejected(tmp_path): + """Early return of null followed by class return is rejected (Rule A).""" + facts, _ = _collect_facts_for_source( + tmp_path, + "early_null.js", + """ +function mixin(Base) { + if (condition) return null; + return class extends Base {}; +} +""", + ) + assert len(facts.class_factories) == 0 + + +def test_returned_class_methods_do_not_count_as_multiple_returns(tmp_path): + """Methods inside the returned class containing return statements do not disqualify the factory.""" + facts, _ = _collect_facts_for_source( + tmp_path, + "class_with_methods.js", + """ +function mixin(Base) { + return class extends Base { + foo() { + return 42; + } + bar() { + return "hello"; + } + }; +} +""", + ) + assert len(facts.class_factories) == 1 + assert facts.class_factories[0].factory_name == "mixin" + + +def test_dynamic_returned_base_rejected(tmp_path): + """10. Dynamic returned base: class extends getBase() is rejected (Rule C).""" + facts, _ = _collect_facts_for_source( + tmp_path, + "dyn_base.js", + """ +function mixin(Base) { + return class extends getBase() {}; +} +""", + ) + assert len(facts.class_factories) == 0 + + +def test_member_expression_returned_base_rejected(tmp_path): + """Class extends Foo.Bar is rejected (Rule C).""" + facts, _ = _collect_facts_for_source( + tmp_path, + "member_base.js", + """ +function mixin(Base) { + return class extends Foo.Bar {}; +} +""", + ) + assert len(facts.class_factories) == 0 + + +def test_destructured_parameter_rejected(tmp_path): + """11. Destructured parameter: function mixin({ Base }) is rejected (Rule D).""" + facts, _ = _collect_facts_for_source( + tmp_path, + "destructured.js", + """ +function mixin({ Base }) { + return class extends Base {}; +} +""", + ) + assert len(facts.class_factories) == 0 + + +def test_regression_no_fabricate_inherits(tmp_path): + """12. Regression: Ensure Pass 2 collection does not create an inherits edge to mixin.""" + src_file = tmp_path / "src" / "a.js" + src_file.parent.mkdir(parents=True, exist_ok=True) + src_file.write_text( + """ +class Animal {} +function mixin(base) { + return class extends base {}; +} +class Dog extends mixin(Animal) {} +""", + encoding="utf-8", + ) + result = extract([src_file], cache_root=tmp_path) + inherits_edges = [ + e for e in result["edges"] + if e["relation"] == "inherits" and "Dog" in e["source"] + ] + assert len(inherits_edges) == 0 diff --git a/tests/test_js_mixin_resolution.py b/tests/test_js_mixin_resolution.py new file mode 100644 index 0000000000..fdcaa67f31 --- /dev/null +++ b/tests/test_js_mixin_resolution.py @@ -0,0 +1,300 @@ +"""Tests for Phase 9D: Applying JS/TS mixin factory facts to symbol resolution.""" +from __future__ import annotations + +from pathlib import Path +import pytest + +from graphify.extract import _file_stem, _make_id, extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _nid(rel_path: str, *syms: str) -> str: + stem = _file_stem(Path(rel_path)) + return _make_id(stem, *syms) + + +# --- Test 1: Basic factory application --- + +def test_basic_factory_application(tmp_path): + """1. Basic factory application: Applied mixes in returned class, returned class inherits Root.""" + f = _write( + tmp_path / "src" / "a.js", + """ +class Root {} +function mixin(Base) { + return class extends Base {}; +} +const Applied = mixin(Root); +""", + ) + result = extract([f], cache_root=tmp_path) + + applied_nid = _nid("src/a.js", "Applied") + returned_class_nid = _nid("src/a.js", "mixin", "mixin@class") + root_nid = _nid("src/a.js", "Root") + + assert any( + e["source"] == applied_nid and e["target"] == returned_class_nid and e["relation"] == "mixes_in" + for e in result["edges"] + ) + assert any( + e["source"] == returned_class_nid and e["target"] == root_nid and e["relation"] == "inherits" + for e in result["edges"] + ) + + +# --- Test 2: Dynamic heritage application --- + +def test_dynamic_heritage_application(tmp_path): + """2. Dynamic heritage application: Child mixes in returned class, returned class inherits Root.""" + f = _write( + tmp_path / "src" / "a.js", + """ +class Root {} +function mixin(Base) { + return class extends Base {}; +} +class Child extends mixin(Root) {} +""", + ) + result = extract([f], cache_root=tmp_path) + + child_nid = _nid("src/a.js", "Child") + returned_class_nid = _nid("src/a.js", "mixin", "mixin@class") + root_nid = _nid("src/a.js", "Root") + + assert any( + e["source"] == child_nid and e["target"] == returned_class_nid and e["relation"] == "mixes_in" + for e in result["edges"] + ) + assert any( + e["source"] == returned_class_nid and e["target"] == root_nid and e["relation"] == "inherits" + for e in result["edges"] + ) + + +# --- Test 3: Non-first base parameter --- + +def test_non_first_base_parameter(tmp_path): + """3. Non-first base parameter: Root at index 1 is selected as the base.""" + f = _write( + tmp_path / "src" / "a.js", + """ +class ConfigValue {} +class Root {} +function mixin(Config, Base) { + return class extends Base {}; +} +const Applied = mixin(ConfigValue, Root); +""", + ) + result = extract([f], cache_root=tmp_path) + + applied_nid = _nid("src/a.js", "Applied") + returned_class_nid = _nid("src/a.js", "mixin", "mixin@class") + root_nid = _nid("src/a.js", "Root") + config_nid = _nid("src/a.js", "ConfigValue") + + assert any( + e["source"] == applied_nid and e["target"] == returned_class_nid and e["relation"] == "mixes_in" + for e in result["edges"] + ) + assert any( + e["source"] == returned_class_nid and e["target"] == root_nid and e["relation"] == "inherits" + for e in result["edges"] + ) + # mixin@class should NOT inherit from ConfigValue + assert not any( + e["source"] == returned_class_nid and e["target"] == config_nid and e["relation"] == "inherits" + for e in result["edges"] + ) + + +# --- Test 4: Multiple applications --- + +def test_multiple_applications(tmp_path): + """4. Multiple applications: A -> mixin@class and B -> mixin@class without cross-wiring.""" + f = _write( + tmp_path / "src" / "a.js", + """ +class RootA {} +class RootB {} +function mixin(Base) { + return class extends Base {}; +} +const A = mixin(RootA); +const B = mixin(RootB); +""", + ) + result = extract([f], cache_root=tmp_path) + + a_nid = _nid("src/a.js", "A") + b_nid = _nid("src/a.js", "B") + returned_class_nid = _nid("src/a.js", "mixin", "mixin@class") + root_a_nid = _nid("src/a.js", "RootA") + root_b_nid = _nid("src/a.js", "RootB") + + # Both mix in the factory class + assert any(e["source"] == a_nid and e["target"] == returned_class_nid and e["relation"] == "mixes_in" for e in result["edges"]) + assert any(e["source"] == b_nid and e["target"] == returned_class_nid and e["relation"] == "mixes_in" for e in result["edges"]) + + # Factory class inherits both bases + assert any(e["source"] == returned_class_nid and e["target"] == root_a_nid and e["relation"] == "inherits" for e in result["edges"]) + assert any(e["source"] == returned_class_nid and e["target"] == root_b_nid and e["relation"] == "inherits" for e in result["edges"]) + + +# --- Test 5: Multiple factories --- + +def test_multiple_factories(tmp_path): + """5. Multiple factories: each application resolves to its own returned class.""" + f = _write( + tmp_path / "src" / "a.js", + """ +class Root {} +function mixinA(Base) { + return class extends Base {}; +} +function mixinB(Base) { + return class extends Base {}; +} +const A = mixinA(Root); +const B = mixinB(Root); +""", + ) + result = extract([f], cache_root=tmp_path) + + a_nid = _nid("src/a.js", "A") + b_nid = _nid("src/a.js", "B") + ret_a_nid = _nid("src/a.js", "mixinA", "mixinA@class") + ret_b_nid = _nid("src/a.js", "mixinB", "mixinB@class") + + assert any(e["source"] == a_nid and e["target"] == ret_a_nid and e["relation"] == "mixes_in" for e in result["edges"]) + assert not any(e["source"] == a_nid and e["target"] == ret_b_nid for e in result["edges"]) + + assert any(e["source"] == b_nid and e["target"] == ret_b_nid and e["relation"] == "mixes_in" for e in result["edges"]) + assert not any(e["source"] == b_nid and e["target"] == ret_a_nid for e in result["edges"]) + + +# --- Test 6: TypeScript generic factory --- + +def test_typescript_generic_factory(tmp_path): + """6. TypeScript generic factory: correctly resolves mixes_in and inherits in TS.""" + f = _write( + tmp_path / "src" / "a.ts", + """ +class Root {} +function mixin(Base: T) { + return class extends Base {}; +} +class Child extends mixin(Root) {} +const Applied = mixin(Root); +""", + ) + result = extract([f], cache_root=tmp_path) + + child_nid = _nid("src/a.ts", "Child") + applied_nid = _nid("src/a.ts", "Applied") + returned_class_nid = _nid("src/a.ts", "mixin", "mixin@class") + root_nid = _nid("src/a.ts", "Root") + + assert any(e["source"] == child_nid and e["target"] == returned_class_nid and e["relation"] == "mixes_in" for e in result["edges"]) + assert any(e["source"] == applied_nid and e["target"] == returned_class_nid and e["relation"] == "mixes_in" for e in result["edges"]) + assert any(e["source"] == returned_class_nid and e["target"] == root_nid and e["relation"] == "inherits" for e in result["edges"]) + + +# --- Test 7: Dynamic factory argument --- + +def test_dynamic_factory_argument(tmp_path): + """7. Dynamic factory argument: mixin(getBase()) produces no fabricated semantic edge.""" + f = _write( + tmp_path / "src" / "a.js", + """ +function getBase() { return class {}; } +function mixin(Base) { + return class extends Base {}; +} +const Applied = mixin(getBase()); +""", + ) + result = extract([f], cache_root=tmp_path) + applied_nid = _nid("src/a.js", "Applied") + + assert not any(e["source"] == applied_nid and e["relation"] == "mixes_in" for e in result["edges"]) + + +# --- Test 8: Dynamic factory heritage --- + +def test_dynamic_factory_heritage(tmp_path): + """8. Dynamic factory heritage: class Child extends getMixin()(Root) produces no fabricated edge.""" + f = _write( + tmp_path / "src" / "a.js", + """ +class Root {} +function getMixin() { + return function(Base) { return class extends Base {}; }; +} +class Child extends getMixin()(Root) {} +""", + ) + result = extract([f], cache_root=tmp_path) + child_nid = _nid("src/a.js", "Child") + + assert not any(e["source"] == child_nid and e["relation"] in ("mixes_in", "inherits") for e in result["edges"]) + + +# --- Test 9: Existing regression --- + +def test_dynamic_base_regression(tmp_path): + """9. Existing regression: class Child extends getBase() still does not fabricate an inherits target.""" + f = _write( + tmp_path / "src" / "a.js", + """ +function getBase() { return class {}; } +class Child extends getBase() {} +""", + ) + result = extract([f], cache_root=tmp_path) + child_nid = _nid("src/a.js", "Child") + + assert not any(e["source"] == child_nid and e["relation"] in ("mixes_in", "inherits") for e in result["edges"]) + + +# --- Test 10: Cross-file mixin factory application --- + +def test_cross_file_mixin_application(tmp_path): + """Cross-file mixin factory: factory in one file, imported and applied in another.""" + fac_file = _write( + tmp_path / "src" / "factory.js", + """ +export function mixin(Base) { + return class extends Base {}; +} +""", + ) + app_file = _write( + tmp_path / "src" / "app.js", + """ +import { mixin } from './factory.js'; +export class Root {} +export class Child extends mixin(Root) {} +""", + ) + result = extract([app_file, fac_file], cache_root=tmp_path) + + child_nid = _nid("src/app.js", "Child") + root_nid = _nid("src/app.js", "Root") + returned_class_nid = _nid("src/factory.js", "mixin", "mixin@class") + + assert any( + e["source"] == child_nid and e["target"] == returned_class_nid and e["relation"] == "mixes_in" + for e in result["edges"] + ) + assert any( + e["source"] == returned_class_nid and e["target"] == root_nid and e["relation"] == "inherits" + for e in result["edges"] + ) diff --git a/tests/test_owned_type_sources.py b/tests/test_owned_type_sources.py index 193ac892a8..d26cc3c256 100644 --- a/tests/test_owned_type_sources.py +++ b/tests/test_owned_type_sources.py @@ -28,8 +28,10 @@ def test_callback_local_method_preserved_named_function_local_omitted(tmp_path): edges = triples(g) assert ("local_visible_method", "base_result", "references") in edges assert ("local_visible", "base_base", "inherits") in edges + assert ("local_make_hidden", "local_make_hidden_method", "method") in edges assert not any(e["source"] not in ids for e in g["edges"]) - assert not any("hidden" in e["source"] for e in g["edges"]) + assert not any(e["target"] not in ids for e in g["edges"]) + assert not any(r in ("inherits", "references", "implements") for s, _, r in edges if "hidden" in s) def test_same_basename_sources_do_not_suppress_other_callers(tmp_path):