Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ this baseline. Other changed files remain in `files[]` and receive a scoped
`UNSUPPORTED_LANGUAGE` warning. Syntax/decoding failures receive a scoped
`PARSE_FAILURE` warning and do not produce invented symbol changes. Import
targets are explicitly labeled unresolved/external; no project-wide resolution
is claimed. Every file records old/new paths, modes, Git object IDs, and content
SHA-256 values in structural evidence, while symbol/relationship evidence names
the parser package, query revision, and source blob identity.
is claimed. Python call edges are emitted only for bare calls that resolve to an
unambiguous function or class in the same file. Attribute dispatch, imported
calls, and names shadowed by parameters or local assignments remain absent
rather than being guessed. Every file records old/new paths, modes, Git object
IDs, and content SHA-256 values in structural evidence, while
symbol/relationship evidence names the parser package, query revision, source
blob identity, and exact call site where applicable.

#### CLI and offline contract

Expand Down
153 changes: 147 additions & 6 deletions diffgraph/structural.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
)

ANALYZER = "diffgraph-python-tree-sitter"
QUERY_VERSION = "python-structure-v1"
QUERY_VERSION = "python-structure-v2"
_PARSER_STATE = threading.local()


Expand All @@ -59,6 +59,14 @@ class _Import:
snippet: str


@dataclass(frozen=True)
class _Call:
caller: Optional[str]
name: str
line: int
snippet: str


def _blob(repository: str, oid: Optional[str]) -> Optional[bytes]:
if oid is None:
return None
Expand Down Expand Up @@ -138,7 +146,9 @@ def _name_child(node):
return next((item for item in node.children if item.type == "identifier"), None)


def _parse_python(content: bytes) -> Tuple[List[_Symbol], List[_Import]]:
def _parse_python(
content: bytes,
) -> Tuple[List[_Symbol], List[_Import], List[_Call], Dict[Optional[str], set]]:
# Do not silently replace undecodable source: the warning must identify the
# exact side that could not be structurally analyzed.
content.decode("utf-8")
Expand All @@ -148,8 +158,18 @@ def _parse_python(content: bytes) -> Tuple[List[_Symbol], List[_Import]]:

symbols: List[_Symbol] = []
imports: List[_Import] = []
calls: List[_Call] = []
bindings: Dict[Optional[str], set] = {}
symbol_occurrences: Dict[str, int] = {}

def identifiers(node) -> set:
found = set()
if node.type == "identifier":
found.add(_node_text(content, node))
for child in node.children:
found.update(identifiers(child))
return found

def visit(node, parents: Tuple[Tuple[str, str], ...] = ()) -> None:
next_parents = parents
if node.type in ("class_definition", "function_definition"):
Expand Down Expand Up @@ -203,12 +223,48 @@ def visit(node, parents: Tuple[Tuple[str, str], ...] = ()) -> None:
module_node = node.child_by_field_name("module_name")
if module_node is not None:
imports.append(_Import(_node_text(content, module_node), node.start_point[0] + 1, snippet))
elif node.type == "call":
function = node.child_by_field_name("function")
if function is not None and function.type == "identifier":
caller = parents[-1][0] if parents else None
ancestor = node.parent
while ancestor is not None:
if ancestor.type in ("class_definition", "function_definition"):
body = ancestor.child_by_field_name("body")
if body is not None and not (
body.start_byte <= node.start_byte
and node.end_byte <= body.end_byte
):
caller = parents[-2][0] if len(parents) > 1 else None
break
ancestor = ancestor.parent
calls.append(
_Call(
caller,
_node_text(content, function),
node.start_point[0] + 1,
_node_text(content, node),
)
)

scope = parents[-1][0] if parents else None
if node.type == "parameters" or node.type in (
"import_statement", "import_from_statement"
):
bindings.setdefault(scope, set()).update(identifiers(node))
elif node.type in ("assignment", "annotated_assignment", "for_statement"):
left = node.child_by_field_name("left")
if left is not None:
bindings.setdefault(scope, set()).update(identifiers(left))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for child in node.children:
visit(child, next_parents)

visit(tree.root_node)
return sorted(symbols, key=lambda s: (s.qualified_name, s.start_line)), sorted(
imports, key=lambda item: (item.line, item.module, item.snippet)
return (
sorted(symbols, key=lambda s: (s.qualified_name, s.start_line)),
sorted(imports, key=lambda item: (item.line, item.module, item.snippet)),
sorted(calls, key=lambda item: (item.line, item.caller or "", item.name, item.snippet)),
bindings,
)


Expand Down Expand Up @@ -290,6 +346,50 @@ def _keyed_imports(items: List[_Import]) -> Dict[Tuple[str, int], _Import]:
return result


def _resolve_call_target(
call: _Call,
symbols: Dict[str, _Symbol],
bindings: Dict[Optional[str], set],
) -> Optional[str]:
"""Resolve only syntax-grounded, same-file Python calls.

Bare identifiers shadowed by a parameter, assignment, loop target, or import
are deliberately left unresolved. Attribute calls and ambiguous duplicate
definitions are likewise omitted rather than guessed.
"""
candidates: List[str] = []
current_name = call.caller
while current_name is not None:
current = symbols.get(current_name)
if current is None:
break
# Function and method scopes participate in lexical lookup. Class
# namespaces do not: a bare name in a method never resolves through
# sibling class attributes or methods.
if current.kind in ("function", "method"):
if call.name in bindings.get(current_name, set()):
return None
candidates.append("{}.{}".format(current_name, call.name))
current_name = current.parent

if call.name in bindings.get(None, set()):
return None
candidates.append(call.name)

for candidate in candidates:
matches = [
qname
for qname, symbol in symbols.items()
if symbol.kind in ("function", "class")
and (qname == candidate or qname.startswith(candidate + "#"))
]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
return None
return None


def analyze_local_diff(
repository: str = ".", *, staged: bool = False,
pathspecs: Optional[Sequence[str]] = None,
Expand Down Expand Up @@ -386,13 +486,17 @@ def analyze_local_diff(
UnicodeDecodeError, ValueError, RuntimeError, OSError, TypeError
)
try:
old_symbols, old_imports = _parse_python(old) if old is not None else ([], [])
old_symbols, old_imports, _old_calls, _old_bindings = (
_parse_python(old) if old is not None else ([], [], [], {})
)
except parser_errors as error:
warnings.append(_warning("PARSE_FAILURE", entry.old_path or path, "pre-change: {}: {}".format(type(error).__name__, error)))
skipped += 1
continue
try:
new_symbols, new_imports = _parse_python(new) if new is not None else ([], [])
new_symbols, new_imports, new_calls, new_bindings = (
_parse_python(new) if new is not None else ([], [], [], {})
)
except parser_errors as error:
warnings.append(_warning("PARSE_FAILURE", path, "post-change: {}: {}".format(type(error).__name__, error)))
skipped += 1
Expand Down Expand Up @@ -506,6 +610,43 @@ def analyze_local_diff(
}
)

call_occurrences: Dict[Tuple[str, str], int] = {}
for call in new_calls:
target_qname = _resolve_call_target(call, new_map, new_bindings)
if target_qname is None:
continue
source = (
_symbol_id(output_path, call.caller)
if call.caller is not None
else "file::" + output_path
)
target = _symbol_id(output_path, target_qname)
edge = (source, target)
occurrence = call_occurrences.get(edge, 0)
call_occurrences[edge] = occurrence + 1
suffix = "" if occurrence == 0 else "#{}".format(occurrence)
relationships.append(
{
"id": "rel::{}->{}{}".format(source, target, suffix),
"kind": "calls",
"source_id": source,
"target_id": target,
"analysis_source": "structural",
"resolution_method": "resolved",
"confidence": None,
"evidence": [
{
"kind": "call_site",
"file": output_path,
"line_start": call.line,
"line_end": call.line,
"snippet": call.snippet,
"detail": _parser_provenance(entry.new_oid),
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
}
)

files.sort(key=lambda item: item["path"])
symbols.sort(key=lambda item: item["id"])
relationships.sort(key=lambda item: (item["id"], item["kind"]))
Expand Down
42 changes: 42 additions & 0 deletions tests/fixtures/python_calls.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[
{
"id": "rel::file::calls.py->sym::calls.py::helper",
"source_id": "file::calls.py",
"target_id": "sym::calls.py::helper",
"resolution_method": "resolved",
"line": 23,
"snippet": "helper()"
},
{
"id": "rel::file::calls.py->sym::calls.py::helper#1",
"source_id": "file::calls.py",
"target_id": "sym::calls.py::helper",
"resolution_method": "resolved",
"line": 26,
"snippet": "helper()"
},
{
"id": "rel::sym::calls.py::caller->sym::calls.py::helper",
"source_id": "sym::calls.py::caller",
"target_id": "sym::calls.py::helper",
"resolution_method": "resolved",
"line": 5,
"snippet": "helper()"
},
{
"id": "rel::sym::calls.py::caller->sym::calls.py::helper#1",
"source_id": "sym::calls.py::caller",
"target_id": "sym::calls.py::helper",
"resolution_method": "resolved",
"line": 6,
"snippet": "helper()"
},
{
"id": "rel::sym::calls.py::outer->sym::calls.py::outer.nested",
"source_id": "sym::calls.py::outer",
"target_id": "sym::calls.py::outer.nested",
"resolution_method": "resolved",
"line": 11,
"snippet": "nested()"
}
]
73 changes: 73 additions & 0 deletions tests/test_structural.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,3 +911,76 @@ def without_spinner(name, *args, **kwargs):
assert result.exit_code == 1
assert "requires additional dependencies" in result.output
assert "Traceback" not in result.output


def test_python_calls_are_conservative_schema_valid_and_golden(tmp_path):
root = repo(tmp_path)
write(
root,
"calls.py",
"def helper():\n"
" return 1\n\n"
"def caller():\n"
" helper()\n"
" helper()\n\n"
"def outer():\n"
" def nested():\n"
" return 1\n"
" nested()\n\n"
"def parameter_shadow(helper):\n"
" helper()\n\n"
"def assignment_shadow():\n"
" helper = lambda: 2\n"
" helper()\n\n"
"def attribute_call(service):\n"
" service.helper()\n\n"
"def default_call(value=helper()):\n"
" return value\n\n"
"helper()\n\n"
"def closure_shadow():\n"
" helper = lambda: 3\n"
" def inner():\n"
" helper()\n\n"
"class MethodShadow:\n"
" def method(self):\n"
" helper = lambda: 4\n"
" def inner():\n"
" helper()\n",
)
git(root, "add", "calls.py")

artifact = analyze_local_diff(str(root), staged=True)
assert_valid(artifact)
calls = [item for item in artifact["relationships"] if item["kind"] == "calls"]
actual = [
{
"id": item["id"],
"source_id": item["source_id"],
"target_id": item["target_id"],
"resolution_method": item["resolution_method"],
"line": item["evidence"][0]["line_start"],
"snippet": item["evidence"][0]["snippet"],
}
for item in calls
]
golden_path = Path(__file__).parent / "fixtures/python_calls.json"
expected = json.loads(golden_path.read_text())
if os.environ.get("UPDATE_GOLDEN"):
golden_path.write_text(json.dumps(actual, indent=2) + "\n")
pytest.skip("golden fixture regenerated")
assert actual == expected

# Parameter/local bindings and attribute dispatch are intentionally not
# guessed. Every emitted edge has exact call-site/parser/blob evidence.
assert len(calls) == 5
assert all(item["analysis_source"] == "structural" for item in calls)
assert all(item["confidence"] is None for item in calls)
shadowed_callers = {
"sym::calls.py::closure_shadow.inner",
"sym::calls.py::MethodShadow.method.inner",
}
symbol_ids = {item["id"] for item in artifact["symbols"]}
assert shadowed_callers <= symbol_ids
assert shadowed_callers.isdisjoint(item["source_id"] for item in calls)
assert all("query=python-structure-v2" in item["evidence"][0]["detail"] for item in calls)
assert all("blob=" in item["evidence"][0]["detail"] for item in calls)
Loading