555 lines
21 KiB
Python
555 lines
21 KiB
Python
"""Deterministic symbol indexing and conservative cross-file resolution helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import re
|
|
import unicodedata
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from collections.abc import Sequence
|
|
from typing import Any
|
|
|
|
from graphify.ids import make_id as _shared_make_id
|
|
from graphify.paths import disambiguate_ambiguous_candidates
|
|
from graphify.security import sanitize_metadata
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ImportedSymbol:
|
|
"""A Python imported name that can be used as deterministic resolution evidence."""
|
|
|
|
local_name: str
|
|
imported_name: str
|
|
module_stem: str
|
|
source_file: str
|
|
source_location: str
|
|
|
|
|
|
def normalise_callable_label(label: str) -> str:
|
|
"""Normalize a node label into the key used for call resolution."""
|
|
|
|
return label.strip().strip("()").lstrip(".").lower()
|
|
|
|
|
|
def node_is_resolvable_symbol(node: dict[str, Any]) -> bool:
|
|
"""Return True when a node is suitable for deterministic symbol lookup.
|
|
|
|
Requires ``file_type == "code"`` as the positive gate — only code-class
|
|
nodes participate as call targets. ``_EXCLUDED_FILE_TYPES`` is kept as
|
|
defensive-in-depth against legacy data, but the primary guard is the
|
|
positive code check. Document/paper/image/concept nodes (e.g. a Markdown
|
|
heading whose label happens to match a code identifier) MUST NOT become
|
|
callees for a raw code call.
|
|
"""
|
|
|
|
if node.get("file_type") != "code":
|
|
return False
|
|
label = str(node.get("label", "")).strip()
|
|
if not label:
|
|
return False
|
|
if label.endswith((".py", ".js", ".ts", ".tsx", ".java", ".go", ".rs")):
|
|
return False
|
|
return bool(normalise_callable_label(label))
|
|
|
|
|
|
def build_label_index(nodes: list[dict[str, Any]]) -> dict[str, list[str]]:
|
|
"""Build label -> node id list for conservative cross-file resolution."""
|
|
|
|
index: dict[str, list[str]] = {}
|
|
for node in nodes:
|
|
if not node_is_resolvable_symbol(node):
|
|
continue
|
|
node_id = node.get("id")
|
|
if not node_id:
|
|
continue
|
|
key = normalise_callable_label(str(node.get("label", "")))
|
|
if not key:
|
|
continue
|
|
index.setdefault(key, []).append(str(node_id))
|
|
return index
|
|
|
|
|
|
def existing_edge_pairs(edges: list[dict[str, Any]]) -> set[tuple[str, str, str]]:
|
|
"""Return all existing source/target/relation edge triples.
|
|
|
|
Includes relation so that a prior "contains" or "method" edge does not
|
|
suppress a semantically distinct "calls" edge between the same endpoints (#F5).
|
|
"""
|
|
|
|
triples: set[tuple[str, str, str]] = set()
|
|
for edge in edges:
|
|
source = edge.get("source")
|
|
target = edge.get("target")
|
|
relation = edge.get("relation", "")
|
|
if source and target:
|
|
triples.add((str(source), str(target), str(relation)))
|
|
return triples
|
|
|
|
|
|
def iter_raw_calls(per_file: Sequence[object]) -> list[dict[str, Any]]:
|
|
"""Return raw calls from all per-file extraction fragments.
|
|
|
|
Parameter is ``Sequence[object]`` (not ``Sequence[dict[str, Any] | None]``)
|
|
because external extraction output may contain arbitrary deserialized
|
|
JSON. Defensive against malformed fragments: non-dict per-file entries
|
|
are skipped, non-list ``raw_calls`` are treated as empty, and non-dict
|
|
items inside the list are silently dropped. The downstream resolvers
|
|
assume every returned item is a dict and they expect this guarantee.
|
|
"""
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
for result in per_file:
|
|
if not isinstance(result, dict):
|
|
continue
|
|
raw_calls = result.get("raw_calls", [])
|
|
if not isinstance(raw_calls, list):
|
|
continue
|
|
for raw_call in raw_calls:
|
|
if isinstance(raw_call, dict):
|
|
calls.append(raw_call)
|
|
return calls
|
|
|
|
|
|
def _module_stem(module_name: str | None) -> str:
|
|
"""Return the final module component used to match Graphify source stems."""
|
|
|
|
if not module_name:
|
|
return ""
|
|
return module_name.strip(".").split(".")[-1]
|
|
|
|
|
|
def parse_python_import_aliases(path: Path) -> dict[str, ImportedSymbol]:
|
|
"""Parse deterministic Python import aliases from one source file.
|
|
|
|
Supported forms:
|
|
from helper import transform
|
|
from helper import transform as tx
|
|
from .helper import transform
|
|
|
|
The function deliberately does not resolve plain ``import helper`` member
|
|
calls because current raw call records do not preserve the receiver name from
|
|
``helper.transform()``. That can be added later only after raw call facts are
|
|
extended to include the receiver expression.
|
|
"""
|
|
|
|
try:
|
|
source = path.read_text(encoding="utf-8", errors="replace")
|
|
tree = ast.parse(source)
|
|
except (OSError, SyntaxError):
|
|
return {}
|
|
|
|
aliases: dict[str, ImportedSymbol] = {}
|
|
source_file = str(path)
|
|
|
|
# Only top-level `from ... import ...` statements count as file-wide
|
|
# evidence. Nested/function-local imports do NOT — they're only valid
|
|
# inside their lexical scope, and our raw-call records don't currently
|
|
# carry enough scope info to match the import site safely. Walking
|
|
# ast.walk(tree) would incorrectly justify calls in other scopes.
|
|
for node in tree.body:
|
|
if not isinstance(node, ast.ImportFrom):
|
|
continue
|
|
module_stem = _module_stem(node.module)
|
|
if not module_stem:
|
|
continue
|
|
for alias in node.names:
|
|
if alias.name == "*":
|
|
continue
|
|
local_name = alias.asname or alias.name
|
|
aliases[local_name] = ImportedSymbol(
|
|
local_name=local_name,
|
|
imported_name=alias.name,
|
|
module_stem=module_stem,
|
|
source_file=source_file,
|
|
source_location=f"L{getattr(node, 'lineno', 1)}",
|
|
)
|
|
|
|
return aliases
|
|
|
|
|
|
def _node_source_stem(node: dict[str, Any]) -> str:
|
|
"""Return the stem of a node's source file."""
|
|
|
|
source_file = str(node.get("source_file", ""))
|
|
if not source_file:
|
|
return ""
|
|
return Path(source_file).stem
|
|
|
|
|
|
def build_python_symbol_index(nodes: list[dict[str, Any]]) -> dict[tuple[str, str], list[str]]:
|
|
"""Build ``(module_stem, normalized_symbol_name) -> node_ids``.
|
|
|
|
This index is stricter than the global label index. It uses both the module
|
|
stem and the symbol label, which allows import evidence to resolve calls that
|
|
global label uniqueness alone cannot safely resolve.
|
|
"""
|
|
|
|
index: dict[tuple[str, str], list[str]] = {}
|
|
for node in nodes:
|
|
if not node_is_resolvable_symbol(node):
|
|
continue
|
|
source_stem = _node_source_stem(node)
|
|
if not source_stem:
|
|
continue
|
|
label = normalise_callable_label(str(node.get("label", "")))
|
|
if not label:
|
|
continue
|
|
node_id = node.get("id")
|
|
if not node_id:
|
|
continue
|
|
index.setdefault((source_stem, label), []).append(str(node_id))
|
|
return index
|
|
|
|
|
|
def find_unique_python_symbol(
|
|
symbol_index: dict[tuple[str, str], list[str]],
|
|
imported: ImportedSymbol,
|
|
) -> str | None:
|
|
"""Resolve one imported symbol to exactly one Graphify node id."""
|
|
|
|
candidates = symbol_index.get((imported.module_stem, imported.imported_name.lower()), [])
|
|
if len(candidates) == 1:
|
|
return candidates[0]
|
|
return None
|
|
|
|
|
|
def resolve_python_import_guided_calls(
|
|
per_file: Sequence[object],
|
|
paths: Sequence[Path],
|
|
all_nodes: list[dict[str, Any]],
|
|
all_edges: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Resolve raw Python calls using explicit import evidence.
|
|
|
|
Only ``from module import symbol [as alias]`` forms are handled. Member calls
|
|
remain skipped because the current raw call fact does not carry receiver
|
|
information.
|
|
|
|
Parameter ``per_file`` is ``Sequence[object]`` because external extraction
|
|
output may contain arbitrary deserialized JSON. Non-dict slots are
|
|
treated as empty fragments, and indices past ``len(per_file)`` are also
|
|
treated as empty (paths longer than per_file is tolerated).
|
|
"""
|
|
|
|
symbol_index = build_python_symbol_index(all_nodes)
|
|
known_pairs = existing_edge_pairs(all_edges)
|
|
# Build result_by_file defensively:
|
|
# - skip indices past the end of per_file (paths shorter than per_file
|
|
# also OK; the zip-like behavior is what callers expect)
|
|
# - non-dict per_file slots fall back to the empty fragment so the
|
|
# downstream `.get("raw_calls", [])` lookup never raises
|
|
result_by_file: dict[str, dict[str, Any]] = {}
|
|
for index, path in enumerate(paths):
|
|
if path.suffix != ".py":
|
|
continue
|
|
slot: Any = per_file[index] if index < len(per_file) else None
|
|
result_by_file[str(path)] = slot if isinstance(slot, dict) else {"nodes": [], "edges": []}
|
|
resolved_edges: list[dict[str, Any]] = []
|
|
|
|
for path in paths:
|
|
if path.suffix != ".py":
|
|
continue
|
|
source_file = str(path)
|
|
aliases = parse_python_import_aliases(path)
|
|
if not aliases:
|
|
continue
|
|
file_result = result_by_file.get(source_file, {"raw_calls": []})
|
|
raw_calls = file_result.get("raw_calls", [])
|
|
if not isinstance(raw_calls, list):
|
|
continue
|
|
for raw_call in raw_calls:
|
|
if not isinstance(raw_call, dict):
|
|
continue
|
|
if raw_call.get("is_member_call"):
|
|
continue
|
|
callee = str(raw_call.get("callee", "")).strip()
|
|
if not callee:
|
|
continue
|
|
imported = aliases.get(callee)
|
|
if imported is None:
|
|
continue
|
|
target = find_unique_python_symbol(symbol_index, imported)
|
|
if target is None:
|
|
continue
|
|
caller = str(raw_call.get("caller_nid", ""))
|
|
if not caller or caller == target:
|
|
continue
|
|
pair = (caller, target, "calls")
|
|
if pair in known_pairs:
|
|
continue
|
|
known_pairs.add(pair)
|
|
resolved_edges.append(
|
|
{
|
|
"source": caller,
|
|
"target": target,
|
|
"relation": "calls",
|
|
"context": "import_guided_call",
|
|
"confidence": "EXTRACTED",
|
|
"confidence_score": 1.0,
|
|
"source_file": raw_call.get("source_file", source_file),
|
|
"source_location": raw_call.get("source_location") or imported.source_location,
|
|
"weight": 1.0,
|
|
"metadata": sanitize_metadata({
|
|
"resolver": "python_import_guided",
|
|
"local_name": imported.local_name,
|
|
"imported_name": imported.imported_name,
|
|
"module_stem": imported.module_stem,
|
|
"import_source_location": imported.source_location,
|
|
}),
|
|
}
|
|
)
|
|
|
|
return resolved_edges
|
|
|
|
|
|
def resolve_cross_file_raw_calls(
|
|
per_file: Sequence[dict[str, Any] | None],
|
|
all_nodes: list[dict[str, Any]],
|
|
all_edges: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Resolve unqualified raw calls conservatively after all files are known.
|
|
|
|
This intentionally preserves Graphify's existing behavior:
|
|
- member calls are skipped;
|
|
- ambiguous labels are skipped;
|
|
- only a single unique candidate is emitted;
|
|
- emitted edges are INFERRED because the raw call alone is not import proof.
|
|
"""
|
|
|
|
label_index = build_label_index(all_nodes)
|
|
known_pairs = existing_edge_pairs(all_edges)
|
|
# nid -> source_file, for the shared god-node tie-breakers (#1553) so a
|
|
# same-named test mock no longer erases a real cross-file call.
|
|
nid_to_source_file = {
|
|
str(n.get("id")): str(n.get("source_file", ""))
|
|
for n in all_nodes
|
|
if n.get("id")
|
|
}
|
|
resolved: list[dict[str, Any]] = []
|
|
|
|
for raw_call in iter_raw_calls(per_file):
|
|
callee = str(raw_call.get("callee", "")).strip()
|
|
if not callee:
|
|
continue
|
|
if raw_call.get("is_member_call"):
|
|
continue
|
|
candidates = label_index.get(callee.lower(), [])
|
|
if not candidates:
|
|
continue
|
|
if len(candidates) == 1:
|
|
target: str | None = candidates[0]
|
|
else:
|
|
# Ambiguous bare name. Apply the shared tie-breakers (non-test
|
|
# preference, then path proximity); resolve only if exactly one
|
|
# candidate survives, else preserve the god-node guard and skip.
|
|
target = disambiguate_ambiguous_candidates(
|
|
candidates,
|
|
{c: nid_to_source_file.get(c, "") for c in candidates},
|
|
str(raw_call.get("source_file", "")),
|
|
)
|
|
if target is None:
|
|
continue
|
|
caller = str(raw_call.get("caller_nid", ""))
|
|
if not caller:
|
|
continue
|
|
if target == caller:
|
|
continue
|
|
pair = (caller, target, "calls")
|
|
if pair in known_pairs:
|
|
continue
|
|
known_pairs.add(pair)
|
|
resolved.append(
|
|
{
|
|
"source": caller,
|
|
"target": target,
|
|
"relation": "calls",
|
|
"context": "call",
|
|
"confidence": "INFERRED",
|
|
"confidence_score": 0.8,
|
|
"source_file": raw_call.get("source_file", ""),
|
|
"source_location": raw_call.get("source_location"),
|
|
"weight": 1.0,
|
|
}
|
|
)
|
|
|
|
return resolved
|
|
|
|
|
|
def _bash_make_id(*parts: str) -> str:
|
|
"""Bash symbol node ID via the single shared recipe (#1378).
|
|
|
|
Previously an inline copy to dodge an import cycle; ``graphify.ids`` is
|
|
dependency-free, so it can be imported directly.
|
|
"""
|
|
return _shared_make_id(*parts)
|
|
|
|
|
|
from graphify.extractors.base import _file_stem as _bash_file_stem # canonical recipe (no import cycle: base imports only graphify.ids)
|
|
|
|
|
|
def _file_node_id_for_path(path: Path, root: Path) -> str:
|
|
# Produce the canonical {parent_dir}_{stem} file-node ID that extract()'s
|
|
# id_remap generates (#1033), so bash `source` edges land on the real file
|
|
# node instead of an orphan. _bash_make_id / _bash_file_stem are exact copies
|
|
# of extract._make_id / extract._file_stem, so IDs match.
|
|
try:
|
|
rel = path.resolve().relative_to(root.resolve())
|
|
except ValueError:
|
|
return _bash_make_id(str(path)) # path outside root: hash absolute path as fallback
|
|
return _bash_make_id(_bash_file_stem(rel))
|
|
|
|
|
|
def resolve_bash_source_edges(
|
|
per_file: Sequence[dict | None],
|
|
paths: Sequence[Path],
|
|
root: Path,
|
|
existing_edges: list[dict] | None = None,
|
|
) -> list[dict]:
|
|
"""Resolve Bash source/import edges and source-backed function calls.
|
|
|
|
Defensive against malformed extraction fragments: non-dict ``per_file``
|
|
entries, missing ``bash_sources``/``raw_calls`` keys, non-dict items in
|
|
those lists, and missing/empty ``id`` / ``target_path`` / ``caller_nid``
|
|
fields all yield silent skips rather than ``KeyError``.
|
|
|
|
``bash_sources[].target_path`` contract (Graphify static-analysis policy):
|
|
- Absolute paths: resolved as-is.
|
|
- Relative paths: resolved against the *source file's* directory
|
|
(i.e. ``Path(path).parent / target_path``).
|
|
NOTE: this is a deterministic static-analysis policy chosen by
|
|
Graphify, NOT bash runtime semantics. At runtime, ``source ./X``
|
|
is resolved against the shell's current working directory. We
|
|
prefer source-file-relative because static analysis cannot know
|
|
the future CWD; resolving against the file being analyzed gives
|
|
deterministic, reproducible edges across runs.
|
|
- Inputs of type ``str`` and ``pathlib.Path`` are processed.
|
|
Anything else is silently skipped.
|
|
"""
|
|
path_by_index = [Path(p).resolve() for p in paths]
|
|
file_nid_by_path = {p: _file_node_id_for_path(p, root) for p in path_by_index} # resolved paths only
|
|
|
|
functions_by_file: dict[str, dict[str, str]] = {}
|
|
for result, path in zip(per_file, path_by_index):
|
|
if not isinstance(result, dict):
|
|
continue
|
|
file_nid = file_nid_by_path[path]
|
|
nodes = result.get("nodes", [])
|
|
if not isinstance(nodes, list):
|
|
continue
|
|
for node in nodes:
|
|
if not isinstance(node, dict):
|
|
continue
|
|
metadata = node.get("metadata", {})
|
|
if not isinstance(metadata, dict):
|
|
continue
|
|
if metadata.get("kind") != "bash_function":
|
|
continue
|
|
name = str(node.get("label", "")).removesuffix("()").strip()
|
|
node_id = node.get("id")
|
|
if not name or not node_id:
|
|
continue
|
|
functions_by_file.setdefault(file_nid, {})[name] = str(node_id)
|
|
|
|
sourced_files: dict[str, set[str]] = {}
|
|
resolved_edges: list[dict] = []
|
|
existing = existing_edge_pairs(existing_edges or [])
|
|
|
|
for result, path in zip(per_file, path_by_index):
|
|
if not isinstance(result, dict):
|
|
continue
|
|
src_file_nid = file_nid_by_path[path]
|
|
bash_sources = result.get("bash_sources", [])
|
|
if not isinstance(bash_sources, list):
|
|
continue
|
|
for source in bash_sources:
|
|
if not isinstance(source, dict):
|
|
continue
|
|
raw_target = source.get("target_path")
|
|
if not isinstance(raw_target, (str, Path)) or not str(raw_target).strip():
|
|
continue
|
|
# Relative paths resolve against the source file's directory —
|
|
# Graphify static-analysis policy (NOT bash runtime semantics;
|
|
# at runtime `source ./X` is CWD-relative, but static analysis
|
|
# can't know the future CWD, so we resolve relative to the
|
|
# file being analyzed for deterministic, reproducible edges).
|
|
candidate = Path(raw_target)
|
|
if not candidate.is_absolute():
|
|
candidate = path.parent / candidate
|
|
try:
|
|
target_path = candidate.resolve()
|
|
except (OSError, RuntimeError):
|
|
continue
|
|
target_file_nid = file_nid_by_path.get(target_path)
|
|
if target_file_nid is None:
|
|
continue
|
|
sourced_files.setdefault(src_file_nid, set()).add(target_file_nid)
|
|
key = (src_file_nid, target_file_nid, "imports_from")
|
|
if key in existing:
|
|
continue
|
|
existing.add(key)
|
|
resolved_edges.append(
|
|
{
|
|
"source": src_file_nid,
|
|
"target": target_file_nid,
|
|
"relation": "imports_from",
|
|
"context": "import",
|
|
"confidence": "EXTRACTED",
|
|
"confidence_score": 1.0,
|
|
"source_file": source.get("source_file", str(path)),
|
|
"source_location": source.get("source_location", ""),
|
|
"weight": 1.0,
|
|
}
|
|
)
|
|
|
|
for result, path in zip(per_file, path_by_index):
|
|
if not isinstance(result, dict):
|
|
continue
|
|
caller_file_nid = file_nid_by_path[path]
|
|
imported_file_ids = sourced_files.get(caller_file_nid, set())
|
|
if not imported_file_ids:
|
|
continue
|
|
raw_calls = result.get("raw_calls", [])
|
|
if not isinstance(raw_calls, list):
|
|
continue
|
|
for raw_call in raw_calls:
|
|
if not isinstance(raw_call, dict):
|
|
continue
|
|
if raw_call.get("language") != "bash":
|
|
continue
|
|
callee = raw_call.get("callee")
|
|
caller_nid = raw_call.get("caller_nid")
|
|
# callee must be a non-empty string — anything else (list, dict,
|
|
# int, None, …) is silently skipped to avoid TypeError on the
|
|
# `in functions_by_file[...]` membership check below.
|
|
if not isinstance(callee, str) or not callee or not caller_nid:
|
|
continue
|
|
matches = [
|
|
functions_by_file[file_nid][callee]
|
|
for file_nid in imported_file_ids
|
|
if callee in functions_by_file.get(file_nid, {})
|
|
]
|
|
if len(matches) != 1:
|
|
continue
|
|
target = matches[0]
|
|
key = (str(caller_nid), target, "calls")
|
|
if key in existing:
|
|
continue
|
|
existing.add(key)
|
|
resolved_edges.append(
|
|
{
|
|
"source": str(caller_nid),
|
|
"target": target,
|
|
"relation": "calls",
|
|
"context": "call",
|
|
"confidence": "EXTRACTED",
|
|
"confidence_score": 1.0,
|
|
"source_file": raw_call.get("source_file", str(path)),
|
|
"source_location": raw_call.get("source_location", ""),
|
|
"weight": 1.0,
|
|
}
|
|
)
|
|
|
|
return resolved_edges
|