mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-21 04:56:18 +00:00
feat: add cross-language semantic contexts for Python, JS/TS, C#, and Java (#996)
This commit is contained in:
+585
-25
@@ -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
@@ -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
|
||||
|
||||
Vendored
+14
-1
@@ -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>();
|
||||
|
||||
Vendored
+10
-1
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user