feat: add cross-language semantic contexts for Python, JS/TS, C#, and Java (#996)

This commit is contained in:
TheFedaikin
2026-05-24 20:38:10 +01:00
committed by GitHub
parent 32effb10a9
commit ab4e5424ca
9 changed files with 835 additions and 31 deletions
+585 -25
View File
@@ -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:
+42 -1
View File
@@ -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
+14 -1
View File
@@ -9,7 +9,15 @@ namespace GraphifyDemo
List<string> Process(List<string> items);
}
public class DataProcessor : IProcessor
public class Processor
{
}
public class Result<T>
{
}
public class DataProcessor : Processor, IProcessor
{
private readonly HttpClient _client;
@@ -23,6 +31,11 @@ namespace GraphifyDemo
return Validate(items);
}
public Result<DataProcessor> Build(HttpClient client)
{
return null;
}
private List<string> Validate(List<string> items)
{
var result = new List<string>();
+10 -1
View File
@@ -1,7 +1,11 @@
import java.util.List;
import java.util.ArrayList;
public class DataProcessor {
class BaseProcessor {}
class Result<T> {}
public class DataProcessor extends BaseProcessor implements Processor {
private List<String> items;
public DataProcessor() {
@@ -16,6 +20,11 @@ public class DataProcessor {
return validate(items);
}
@Override
public Result<DataProcessor> build(HttpClient client) {
return null;
}
private List<String> validate(List<String> data) {
List<String> result = new ArrayList<>();
for (String s : data) {
+23
View File
@@ -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
+40
View File
@@ -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<T> { run(input: T): Result<T> }\n"
"export abstract class BaseProcessor {}\n"
"export type Result<T> = { 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<Payload> {\n"
" current!: Result<Payload>\n"
" run(input: Payload): Result<Payload> { 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
+53 -3
View File
@@ -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():
+33
View File
@@ -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
+35
View File
@@ -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"]