From ab4e5424ca5019b05d7304f8273016afe3df59f6 Mon Sep 17 00:00:00 2001 From: TheFedaikin Date: Sun, 24 May 2026 22:38:10 +0300 Subject: [PATCH] feat: add cross-language semantic contexts for Python, JS/TS, C#, and Java (#996) --- graphify/extract.py | 610 ++++++++++++++++++++++++- graphify/serve.py | 43 +- tests/fixtures/sample.cs | 15 +- tests/fixtures/sample.java | 11 +- tests/test_extract.py | 23 + tests/test_js_import_resolution.py | 40 ++ tests/test_languages.py | 56 ++- tests/test_python_import_resolution.py | 33 ++ tests/test_serve.py | 35 ++ 9 files changed, 835 insertions(+), 31 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 45011c5f..44f37988 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -62,6 +62,45 @@ _JS_RESOLVE_EXTS = (".ts", ".tsx", ".svelte", ".js", ".jsx", ".mjs") _JS_INDEX_FILES = ("index.ts", "index.tsx", "index.svelte", "index.js", "index.jsx", "index.mjs") +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, + } + + def _resolve_js_import_path(candidate: Path) -> Path: """Resolve a JS/TS/Svelte import target to a local file when it exists.""" candidate = Path(os.path.normpath(candidate)) @@ -366,6 +405,238 @@ def _read_text(node, source: bytes) -> str: return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") +_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", +}) + + +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: + 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: + 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: + 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" + + +def _csharp_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a C# type expression; append (name, role) tuples (role is 'type' or 'generic_arg').""" + if node is None: + return + t = node.type + if t == "predefined_type": + return + if t == "identifier": + name = _read_text(node, source) + if name: + out.append((name, "generic_arg" if generic else "type")) + return + if t == "qualified_name": + text = _read_text(node, source).rsplit(".", 1)[-1] + if text: + out.append((text, "generic_arg" if generic else "type")) + 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: + name = _read_text(name_child, source) + if name: + out.append((name, "generic_arg" if generic else "type")) + 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) + 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) + return + if node.is_named: + for c in node.children: + if c.is_named: + _csharp_collect_type_refs(c, source, generic, out) + + +def _csharp_attribute_names(method_node, source: bytes) -> list[str]: + """Collect attribute names from a C# method/declaration's attribute_list children.""" + names: list[str] = [] + 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: + text = _read_text(name_node, source).rsplit(".", 1)[-1] + if text: + names.append(text) + return names + + +def _java_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a Java type expression; append (name, role) tuples.""" + if node is None: + return + 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: + 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: + 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: + 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) + return + if t == "array_type": + for c in node.children: + if c.is_named: + _java_collect_type_refs(c, source, generic, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _java_collect_type_refs(c, source, generic, out) + + +def _java_method_annotation_names(method_node, source: bytes) -> list[str]: + """Collect annotation names from a Java method's `modifiers` child.""" + names: list[str] = [] + modifiers = None + for child in method_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 _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 _resolve_name(node, source: bytes, config: LanguageConfig) -> str | None: """Get the name from a node using config.name_field, falling back to child types.""" if config.resolve_function_name_fn is not None: @@ -1047,6 +1318,7 @@ _TS_CONFIG = LanguageConfig( ts_language_fn="language_typescript", class_types=frozenset({ "class_declaration", + "abstract_class_declaration", # TS abstract class "interface_declaration", # parity with Java/C# "enum_declaration", # named enums "type_alias_declaration", # named type aliases @@ -1354,6 +1626,10 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: # for a corpus-level merge after every file has been parsed. swift_extensions: list[dict] = [] + csharp_interface_names: set[str] = set() + if config.ts_module == "tree_sitter_c_sharp": + csharp_interface_names = _csharp_pre_scan_interfaces(root, source) + def add_node(nid: str, label: str, line: int) -> None: if nid not in seen_ids: seen_ids.add(nid) @@ -1477,27 +1753,50 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: # C#-specific: inheritance / interface implementation via base_list if config.ts_module == "tree_sitter_c_sharp": for child in node.children: - if child.type == "base_list": - for sub in child.children: - if sub.type in ("identifier", "generic_name"): - if sub.type == "generic_name": - name_child = sub.child_by_field_name("name") - base = _read_text(name_child, source) if name_child else _read_text(sub.children[0], source) - else: - base = _read_text(sub, source) - 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: - nodes.append({ - "id": base_nid, - "label": base, - "file_type": "code", - "source_file": "", - "source_location": "", - }) - seen_ids.add(base_nid) - add_edge(class_nid, base_nid, "inherits", line) + if child.type != "base_list": + continue + for sub in child.children: + if sub.type not in ("identifier", "generic_name", "qualified_name"): + continue + if sub.type == "generic_name": + name_child = sub.child_by_field_name("name") + base = ( + _read_text(name_child, source) if name_child + else _read_text(sub.children[0], source) + ) + elif sub.type == "qualified_name": + base = _read_text(sub, source).rsplit(".", 1)[-1] + else: + base = _read_text(sub, source) + if not base: + continue + 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: + nodes.append({ + "id": base_nid, + "label": base, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(base_nid) + relation = _csharp_classify_base(base, csharp_interface_names) + add_edge(class_nid, base_nid, relation, line) + if sub.type == "generic_name": + for tal in sub.children: + if tal.type != "type_argument_list": + continue + for arg in tal.children: + if not arg.is_named: + continue + refs: list[tuple[str, str]] = [] + _csharp_collect_type_refs(arg, source, True, refs) + for ref_name, _role in refs: + target = ensure_named_node(ref_name, line) + add_edge(class_nid, target, "references", line, + context="generic_arg") # Java-specific: extends (superclass) / implements (interfaces) / interface-extends if config.ts_module == "tree_sitter_java": @@ -1522,7 +1821,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: if sup is not None: for sub in sup.children: if sub.type == "type_identifier": - _emit_java_parent(_read_text(sub, source), "extends", line) + _emit_java_parent(_read_text(sub, source), "inherits", line) break ifs = node.child_by_field_name("interfaces") @@ -1540,7 +1839,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: if sub.type == "type_list": for tid in sub.children: if tid.type == "type_identifier": - _emit_java_parent(_read_text(tid, source), "extends", line) + _emit_java_parent(_read_text(tid, source), "inherits", line) # C++-specific: inheritance via base_class_clause (class and struct). # tree-sitter-cpp shape: @@ -1714,6 +2013,83 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: add_node(func_nid, f"{func_name}()", line) add_edge(file_nid, func_nid, "contains", line) + if config.ts_module == "tree_sitter_python": + params_node = node.child_by_field_name("parameters") + for ref_name, role in _python_collect_param_refs(params_node, source): + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + edges.append( + _semantic_reference_edge(func_nid, target_nid, ctx, str_path, line) + ) + return_type_node = node.child_by_field_name("return_type") + if return_type_node is not None: + return_refs: list[tuple[str, str]] = [] + _python_collect_type_refs(return_type_node, source, False, return_refs) + for ref_name, role in return_refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + edges.append( + _semantic_reference_edge(func_nid, target_nid, ctx, str_path, line) + ) + + if config.ts_module == "tree_sitter_c_sharp": + params_node = node.child_by_field_name("parameters") + if params_node is not None: + for p in params_node.children: + if p.type != "parameter": + continue + type_node = p.child_by_field_name("type") + refs: list[tuple[str, str]] = [] + _csharp_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + return_node = node.child_by_field_name("returns") + if return_node is not None: + refs = [] + _csharp_collect_type_refs(return_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + for attr_name in _csharp_attribute_names(node, source): + target_nid = ensure_named_node(attr_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context="attribute") + + if config.ts_module == "tree_sitter_java": + params_node = node.child_by_field_name("parameters") + if params_node is not None: + for p in params_node.children: + if p.type != "formal_parameter": + continue + type_node = p.child_by_field_name("type") + refs = [] + _java_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + return_node = node.child_by_field_name("type") + if return_node is not None: + refs = [] + _java_collect_type_refs(return_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + for anno_name in _java_method_annotation_names(node, source): + target_nid = ensure_named_node(anno_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context="attribute") + body = _find_body(node, config) if body: function_bodies.append((func_nid, body)) @@ -4576,12 +4952,17 @@ def _apply_symbol_resolution_facts( return node_id existing_edges = { - (str(edge.get("source")), str(edge.get("target")), str(edge.get("relation"))) + ( + str(edge.get("source")), + str(edge.get("target")), + str(edge.get("relation")), + str(edge.get("context") or ""), + ) for edge in edges } def add_edge(source: str, target: str, relation: str, context: str, line: int, source_path: Path) -> None: - key = (source, target, relation) + key = (source, target, relation, context or "") if key in existing_edges: return existing_edges.add(key) @@ -4868,6 +5249,162 @@ def _js_call_identifier(node, source: bytes) -> str | None: return None +_JS_PRIMITIVE_TYPES = frozenset({ + "string", "number", "boolean", "any", "unknown", "void", "never", + "object", "null", "undefined", "bigint", "symbol", "this", +}) + + +def _ts_heritage_clause_entries(clause_node, source: bytes) -> list[str]: + """Return base/interface type names from an extends_clause or implements_clause.""" + out: list[str] = [] + for child in clause_node.children: + if not child.is_named: + continue + if child.type in ("identifier", "type_identifier"): + name = _read_text(child, source) + if name: + out.append(name) + elif child.type == "generic_type": + name_node = child.child_by_field_name("name") + if name_node is None: + for sub in child.children: + if sub.type in ("type_identifier", "nested_type_identifier", "identifier"): + name_node = sub + break + if name_node is not None: + text = _read_text(name_node, source).rsplit(".", 1)[-1] + if text: + out.append(text) + elif child.type == "nested_type_identifier": + text = _read_text(child, source).rsplit(".", 1)[-1] + if text: + out.append(text) + return out + + +def _ts_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a TS type annotation tree; append (name, role) tuples. + + role is 'type' for the outermost type position and 'generic_arg' for entries + that appear inside `type_arguments`. + """ + if node is None: + return + t = node.type + if t == "type_annotation": + for c in node.children: + if c.is_named: + _ts_collect_type_refs(c, source, generic, out) + return + if t in ("type_identifier", "identifier"): + name = _read_text(node, source) + if name and name not in _JS_PRIMITIVE_TYPES: + out.append((name, "generic_arg" if generic else "type")) + return + if t == "nested_type_identifier": + tail = _read_text(node, source).rsplit(".", 1)[-1] + if tail and tail not in _JS_PRIMITIVE_TYPES: + out.append((tail, "generic_arg" if generic else "type")) + return + if t == "generic_type": + name_node = node.child_by_field_name("name") + if name_node is not None: + text = _read_text(name_node, source).rsplit(".", 1)[-1] + if text and text not in _JS_PRIMITIVE_TYPES: + out.append((text, "generic_arg" if generic else "type")) + else: + for c in node.children: + if c.type in ("type_identifier", "nested_type_identifier"): + text = _read_text(c, source).rsplit(".", 1)[-1] + if text and text not in _JS_PRIMITIVE_TYPES: + out.append((text, "generic_arg" if generic else "type")) + break + for c in node.children: + if c.type == "type_arguments": + for sub in c.children: + if sub.is_named: + _ts_collect_type_refs(sub, source, True, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _ts_collect_type_refs(c, source, generic, out) + + +def _ts_walk_class_members(class_node, source: bytes, path: Path, class_nid: str, + facts: _SymbolResolutionFacts) -> None: + """Emit type-relation and type-reference use facts for a class declaration node.""" + line = class_node.start_point[0] + 1 + for child in class_node.children: + if child.type == "class_heritage": + for clause in child.children: + if clause.type == "extends_clause": + for name in _ts_heritage_clause_entries(clause, source): + facts.uses.append( + _SymbolUseFact(path, class_nid, name, "inherits", "type", + clause.start_point[0] + 1) + ) + elif clause.type == "implements_clause": + for name in _ts_heritage_clause_entries(clause, source): + facts.uses.append( + _SymbolUseFact(path, class_nid, name, "implements", "type", + clause.start_point[0] + 1) + ) + + body = class_node.child_by_field_name("body") + if body is None: + return + + for member in body.children: + m_line = member.start_point[0] + 1 + if member.type in ("method_definition", "method_signature", "abstract_method_signature"): + name_node = member.child_by_field_name("name") + if name_node is None: + continue + method_name = _read_text(name_node, source) + method_nid = _make_id(class_nid, method_name) + params = member.child_by_field_name("parameters") + if params is not None: + for p in params.children: + if p.type not in ("required_parameter", "optional_parameter"): + continue + type_anno = p.child_by_field_name("type") + if type_anno is None: + continue + refs: list[tuple[str, str]] = [] + _ts_collect_type_refs(type_anno, source, False, refs) + for name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + facts.uses.append( + _SymbolUseFact(path, method_nid, name, "references", ctx, m_line) + ) + return_type = member.child_by_field_name("return_type") + if return_type is not None: + refs = [] + _ts_collect_type_refs(return_type, source, False, refs) + for name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + facts.uses.append( + _SymbolUseFact(path, method_nid, name, "references", ctx, m_line) + ) + elif member.type in ("public_field_definition", "property_signature"): + type_anno = None + for c in member.children: + if c.type == "type_annotation": + type_anno = c + break + if type_anno is None: + continue + refs = [] + _ts_collect_type_refs(type_anno, source, False, refs) + for name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + facts.uses.append( + _SymbolUseFact(path, class_nid, name, "references", ctx, m_line) + ) + + def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolutionFacts) -> None: js_paths = [ path for path in paths @@ -5002,6 +5539,29 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut ) ) + for path in js_paths: + resolved_path = path.resolve() + parsed = trees.get(resolved_path) + if parsed is None: + continue + source, root_node = parsed + stem = _file_stem(path) + for node in _walk_js_tree(root_node): + if node.type not in ( + "class_declaration", + "abstract_class_declaration", + "interface_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: + continue + class_nid = _make_id(stem, class_name) + _ts_walk_class_members(node, source, path, class_nid, facts) + def _parse_python_tree(path: Path): try: diff --git a/graphify/serve.py b/graphify/serve.py index 72e19eaf..591d322d 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -150,6 +150,44 @@ _CONTEXT_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( ) +_CONTEXT_FILTER_ALIASES: dict[str, str] = { + "param": "parameter_type", + "params": "parameter_type", + "parameter": "parameter_type", + "parameters": "parameter_type", + "argument": "parameter_type", + "arguments": "parameter_type", + "arg": "parameter_type", + "args": "parameter_type", + "return": "return_type", + "returns": "return_type", + "returned": "return_type", + "generic": "generic_arg", + "generics": "generic_arg", + "template": "generic_arg", + "templates": "generic_arg", + "annotation": "attribute", + "annotations": "attribute", + "decorator": "attribute", + "decorators": "attribute", + "calls": "call", + "called": "call", + "invoke": "call", + "invocation": "call", + "fields": "field", + "property": "field", + "properties": "field", + "member": "field", + "members": "field", + "imports": "import", + "imported": "import", + "module": "import", + "modules": "import", + "exports": "export", + "exported": "export", +} + + def _normalize_context_filters(filters: list[str] | None) -> list[str]: if not filters: return [] @@ -157,7 +195,10 @@ def _normalize_context_filters(filters: list[str] | None) -> list[str]: seen: set[str] = set() for value in filters: key = _strip_diacritics(str(value)).strip().lower() - if key and key not in seen: + if not key: + continue + key = _CONTEXT_FILTER_ALIASES.get(key, key) + if key not in seen: seen.add(key) normalized.append(key) return normalized diff --git a/tests/fixtures/sample.cs b/tests/fixtures/sample.cs index 11fa5b0e..4ee1b06e 100644 --- a/tests/fixtures/sample.cs +++ b/tests/fixtures/sample.cs @@ -9,7 +9,15 @@ namespace GraphifyDemo List Process(List items); } - public class DataProcessor : IProcessor + public class Processor + { + } + + public class Result + { + } + + public class DataProcessor : Processor, IProcessor { private readonly HttpClient _client; @@ -23,6 +31,11 @@ namespace GraphifyDemo return Validate(items); } + public Result Build(HttpClient client) + { + return null; + } + private List Validate(List items) { var result = new List(); diff --git a/tests/fixtures/sample.java b/tests/fixtures/sample.java index f161f74e..826cfaae 100644 --- a/tests/fixtures/sample.java +++ b/tests/fixtures/sample.java @@ -1,7 +1,11 @@ import java.util.List; import java.util.ArrayList; -public class DataProcessor { +class BaseProcessor {} + +class Result {} + +public class DataProcessor extends BaseProcessor implements Processor { private List items; public DataProcessor() { @@ -16,6 +20,11 @@ public class DataProcessor { return validate(items); } + @Override + public Result build(HttpClient client) { + return null; + } + private List validate(List data) { List result = new ArrayList<>(); for (String s : data) { diff --git a/tests/test_extract.py b/tests/test_extract.py index f6ca4319..fb280699 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -988,6 +988,29 @@ def test_barrel_reexport_confidence_extracted(): assert e["confidence"] == "EXTRACTED" +def test_semantic_reference_edges_carry_context_and_source(): + from graphify.extract import _semantic_reference_edge + + edge = _semantic_reference_edge( + "source_node", + "target_node", + "parameter_type", + "/repo/src/Foo.cs", + 12, + ) + + assert edge == { + "source": "source_node", + "target": "target_node", + "relation": "references", + "context": "parameter_type", + "confidence": "EXTRACTED", + "source_file": "/repo/src/Foo.cs", + "source_location": "L12", + "weight": 1.0, + } + + def test_pure_export_no_from_not_treated_as_reexport(): """export { localVar } without 'from' should NOT create re_exports edges.""" from graphify.extract import extract_js diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index bb85f756..5a9d1d7b 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -397,3 +397,43 @@ def test_workspace_package_cache_refreshes_between_extract_calls(tmp_path: Path) second = _extract_for([target, importer], tmp_path) assert _has_edge(second, "apps/web/src/page.ts", "packages/types/src/index.ts") + + +def test_ts_type_relationships_and_contexts(tmp_path: Path): + base = _write( + tmp_path / "src/lib/base.ts", + "export interface IProcessor { run(input: T): Result }\n" + "export abstract class BaseProcessor {}\n" + "export type Result = { value: T }\n" + "export class Payload {}\n", + ) + impl = _write( + tmp_path / "src/lib/impl.ts", + "import type { IProcessor, BaseProcessor, Result, Payload } from './base'\n" + "export abstract class DataProcessor extends BaseProcessor implements IProcessor {\n" + " current!: Result\n" + " run(input: Payload): Result { return this.current }\n" + "}\n", + ) + + result = _extract_for([base, impl], tmp_path) + labels = {node["id"]: node["label"] for node in result["nodes"]} + + def _norm(label: str) -> str: + return label.strip("()").lstrip(".") + + reference_contexts = { + ( + _norm(labels.get(edge["source"], edge["source"])), + _norm(labels.get(edge["target"], edge["target"])), + edge.get("context"), + ) + for edge in result["edges"] + if edge.get("relation") == "references" + } + + assert _has_symbol_to_symbol_edge(result, "src/lib/impl.ts", "DataProcessor", "src/lib/base.ts", "BaseProcessor", "inherits") + assert _has_symbol_to_symbol_edge(result, "src/lib/impl.ts", "DataProcessor", "src/lib/base.ts", "IProcessor", "implements") + assert ("run", "Payload", "parameter_type") in reference_contexts + assert ("run", "Result", "return_type") in reference_contexts + assert ("run", "Payload", "generic_arg") in reference_contexts diff --git a/tests/test_languages.py b/tests/test_languages.py index aa650005..2785cc4f 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -42,6 +42,29 @@ def _edges_with_relation(r, *relations): return [e for e in r["edges"] if e["relation"] in relations] +def _normalize_symbol_label(label: str) -> str: + return label.strip("()").lstrip(".") + + +def _node_by_label(result: dict, label: str) -> dict: + for node in result["nodes"]: + if node.get("label") == label or _normalize_symbol_label(node.get("label", "")) == label: + return node + raise AssertionError(f"missing node label {label!r}") + + +def _edge_labels(result: dict, relation: str, context: str | None = None) -> set[tuple[str, str]]: + labels = {node["id"]: _normalize_symbol_label(node["label"]) for node in result["nodes"]} + pairs = set() + for edge in result["edges"]: + if edge.get("relation") != relation: + continue + if context is not None and edge.get("context") != context: + continue + pairs.add((labels.get(edge["source"], edge["source"]), labels.get(edge["target"], edge["target"]))) + return pairs + + # ── Java ────────────────────────────────────────────────────────────────────── def test_java_no_error(): @@ -222,15 +245,42 @@ def test_csharp_inherits_edge(): inherits = [e for e in r["edges"] if e["relation"] == "inherits"] assert len(inherits) >= 1 -def test_csharp_inherits_iprocessor(): +def test_csharp_implements_iprocessor(): r = extract_csharp(FIXTURES / "sample.cs") node_by_id = {n["id"]: n["label"] for n in r["nodes"]} found = any( "DataProcessor" in node_by_id.get(e["source"], "") and "IProcessor" in node_by_id.get(e["target"], "") - for e in r["edges"] if e["relation"] == "inherits" + for e in r["edges"] if e["relation"] == "implements" ) - assert found, "DataProcessor should have inherits edge to IProcessor" + assert found, "DataProcessor should have implements edge to IProcessor" + + +def test_csharp_splits_inherits_and_implements_edges(): + result = extract_csharp(FIXTURES / "sample.cs") + assert ("DataProcessor", "Processor") in _edge_labels(result, "inherits") + assert ("DataProcessor", "IProcessor") in _edge_labels(result, "implements") + + +def test_csharp_parameter_return_and_generic_contexts(): + result = extract_csharp(FIXTURES / "sample.cs") + assert ("Build", "HttpClient") in _edge_labels(result, "references", "parameter_type") + assert ("Build", "Result") in _edge_labels(result, "references", "return_type") + assert ("Build", "DataProcessor") in _edge_labels(result, "references", "generic_arg") + + +def test_java_normalizes_inherits_and_implements(): + result = extract_java(FIXTURES / "sample.java") + assert ("DataProcessor", "BaseProcessor") in _edge_labels(result, "inherits") + assert ("DataProcessor", "Processor") in _edge_labels(result, "implements") + + +def test_java_parameter_return_generic_and_attribute_contexts(): + result = extract_java(FIXTURES / "sample.java") + assert ("build", "HttpClient") in _edge_labels(result, "references", "parameter_type") + assert ("build", "Result") in _edge_labels(result, "references", "return_type") + assert ("build", "DataProcessor") in _edge_labels(result, "references", "generic_arg") + assert ("build", "Override") in _edge_labels(result, "references", "attribute") def test_csharp_field_type_references_have_field_context(): diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index fccdda30..2a517aae 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -51,3 +51,36 @@ def test_python_package_reexport_resolves_import_and_call_to_origin_symbol(tmp_p assert _has_edge(result, barrel_file, origin_file, "re_exports") assert _has_edge(result, consumer_file, origin_symbol, "imports") assert _has_edge(result, consumer_symbol, origin_symbol, "calls") + + +def test_python_parameter_return_and_generic_contexts(tmp_path: Path): + model = tmp_path / "pkg" / "model.py" + model.parent.mkdir(parents=True) + model.write_text( + "class Payload:\n" + " pass\n\n" + "class Result:\n" + " pass\n", + encoding="utf-8", + ) + service = tmp_path / "pkg" / "service.py" + service.write_text( + "from .model import Payload, Result\n\n" + "def process(item: Payload) -> Result:\n" + " return Result()\n\n" + "def process_many(items: list[Payload]) -> Result:\n" + " return Result()\n", + encoding="utf-8", + ) + + result = extract([model, service], cache_root=tmp_path) + labels = {node["id"]: node["label"] for node in result["nodes"]} + edges = [edge for edge in result["edges"] if edge.get("relation") == "references"] + pairs = { + (labels.get(e["source"], e["source"]), labels.get(e["target"], e["target"]), e.get("context")) + for e in edges + } + + assert ("process()", "Payload", "parameter_type") in pairs + assert ("process()", "Result", "return_type") in pairs + assert ("process_many()", "Payload", "generic_arg") in pairs diff --git a/tests/test_serve.py b/tests/test_serve.py index ce7461ee..abb90dcd 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -406,3 +406,38 @@ def test_query_seeds_from_identifier_not_noise(): text = _query_graph_text(G, "FooBarService error handling", mode="bfs", depth=2) assert "FooBarService" in text assert "ServiceClient" in text + + +def test_query_graph_text_parameter_type_context_filter_changes_traversal(): + import networkx as nx + from graphify.serve import _query_graph_text + + graph = nx.Graph() + graph.add_node("process", label="process", source_file="sample.cs", source_location="L20") + graph.add_node("payload", label="Payload", source_file="sample.cs", source_location="L5") + graph.add_node("other", label="PayloadFactory", source_file="sample.cs", source_location="L40") + graph.add_edge("process", "payload", relation="references", context="parameter_type", confidence="EXTRACTED") + graph.add_edge("process", "other", relation="calls", context="call", confidence="EXTRACTED") + + text = _query_graph_text(graph, "who accepts Payload", context_filters=["parameter_type"]) + + assert "parameter_type" in text + assert "Payload" in text + assert "PayloadFactory" not in text + + +def test_query_graph_text_context_filter_aliases_resolve(): + import networkx as nx + from graphify.serve import _normalize_context_filters + + assert _normalize_context_filters(["param"]) == ["parameter_type"] + assert _normalize_context_filters(["parameter"]) == ["parameter_type"] + assert _normalize_context_filters(["return"]) == ["return_type"] + assert _normalize_context_filters(["returns"]) == ["return_type"] + assert _normalize_context_filters(["generic"]) == ["generic_arg"] + assert _normalize_context_filters(["generics"]) == ["generic_arg"] + assert _normalize_context_filters(["annotation"]) == ["attribute"] + assert _normalize_context_filters(["decorator"]) == ["attribute"] + # Pass-through for already-canonical values + assert _normalize_context_filters(["parameter_type"]) == ["parameter_type"] + assert _normalize_context_filters(["field"]) == ["field"]