orchestrata_ultimate.knowledge_graph.code_parser
Reference › orchestrata_ultimate › knowledge_graph › code_parser
CodeParser extracts structure from source files for the knowledge graph (Spec 008, REQ-008-017 and REQ-008-056 to -060). Python files are parsed with the standard ast module in _parse_python, which collects classes with their methods (_extract_class_with_methods), functions, imports and decorators; Prolog files are parsed with regular expressions in _parse_prolog into predicates and rules. parse_file dispatches by extension and returns a FileAnalysis including errors, line count and parse time. get_file_hash gives a SHA-256 of a file, which the synchronizer uses to skip unchanged files, and create_file_node builds a FileNode.
Three batch methods turn many analyses into edges. resolve_calls builds a name index of all parsed functions and matches each function's call names with file scope first, then package scope, then global scope, producing caller and callee ids with a confidence score from _call_confidence (same file above same package above global) and the unresolved call_name. resolve_inheritance maps base-class names to ClassNodes and resolve_imports maps import statements to File nodes, using _file_path_to_module. The parser reads files but writes nothing to the graph. ast_analyzer and the synchronizer import it; result types come from types.
At a glance
| Source | src/orchestrata_ultimate/knowledge_graph/code_parser.py |
| Lines of code | 637 |
| Classes | 1 |
| Functions | 3 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.knowledge_graph.types |
| Imported by | orchestrata_ultimate.knowledge_graph.ast_analyzer, orchestrata_ultimate.knowledge_graph.interfaces, orchestrata_ultimate.knowledge_graph.synchronizer |
| Tests that import it | tests/knowledge_graph/test_anomaly_detector.py, tests/knowledge_graph/test_anomaly_detector_extended.py, tests/knowledge_graph/test_code_intelligence.py, tests/knowledge_graph/test_code_intelligence_coverage.py, tests/knowledge_graph/test_code_parser.py, tests/knowledge_graph/test_file_watcher.py, tests/knowledge_graph/test_knowledge_parser.py, tests/knowledge_graph/test_knowledge_performance.py, tests/knowledge_graph/test_knowledge_signatures.py, tests/knowledge_graph/test_knowledge_sync.py, tests/test_knowledge_graph.py |
Class diagram
Dependency diagram
API
Classes
CodeParser
Decorators: traces(spec='008', story='US-004', reqs=['REQ-008-017']) · Line 31
Parses source files to extract structural information.
| Method | Signature | Summary |
|---|---|---|
parse_file |
parse_file(file_path: str) -> FileAnalysis |
Parse a single file and return analysis results. |
get_file_hash |
get_file_hash(file_path: str) -> str |
Get SHA256 hash of a file. |
create_file_node |
create_file_node(file_path: str) -> FileNode |
Create a FileNode from a file path. |
_parse_python |
_parse_python(file_path: str, content: str) -> FileAnalysis |
Parse a Python file using ast module. |
_extract_class_with_methods |
_extract_class_with_methods(node: ast.ClassDef, file_path: str) -> tuple[ClassNode, list[FunctionNode]] |
Extract class and its methods as separate FunctionNodes. |
_extract_class |
_extract_class(node: ast.ClassDef, file_path: str) -> ClassNode |
Extract class information from an AST node. |
_extract_function |
_extract_function(node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: str) -> FunctionNode |
Extract function information from an AST node. |
_extract_import |
_extract_import(node: ast.Import | ast.ImportFrom, file_path: str) -> ImportNode | None |
Extract import information. |
_is_method |
_is_method(node: ast.AST, tree: ast.Module) -> bool |
Check if a function is a method (inside a class). |
_parse_prolog |
_parse_prolog(file_path: str, content: str) -> FileAnalysis |
Parse a Prolog file using regex-based extraction. |
resolve_calls |
resolve_calls(all_analyses: list[FileAnalysis]) -> list[dict[str, Any]] |
Resolve function call names to target FunctionNodes. |
_call_edges_for staticmethod |
_call_edges_for(func: Any, caller_file: str, call_name: str, candidates: list[tuple[str, str]], seen: set[tuple[str, str]]) -> list[dict[str, Any]] |
Build deduplicated CALLS edges from one caller to its candidates. |
resolve_inheritance |
resolve_inheritance(all_analyses: list[FileAnalysis]) -> list[dict[str, str]] |
Resolve base class names to target ClassNodes. |
resolve_imports |
resolve_imports(all_analyses: list[FileAnalysis]) -> list[dict[str, Any]] |
Resolve import statements to target File nodes. |
Functions
| Function | Signature | Summary |
|---|---|---|
_same_package traces |
_same_package(file_a: str, file_b: str) -> bool |
Check if two files are in the same package directory. |
_call_confidence traces |
_call_confidence(caller_file: str, callee_file: str, num_candidates: int) -> float |
Score a resolved call edge: same file > same package > global. |
_file_path_to_module traces |
_file_path_to_module(file_path: str) -> str |
Convert a file path to a Python module path. |
Source
"""Code parser using Python's ast module.
Parses Python and Prolog source files to extract structural
information for the knowledge graph. Uses Python's built-in
ast module for Python files, and regex-based parsing for Prolog.
"""
import ast
import hashlib
import logging
import re
import time
from pathlib import Path
from typing import Any
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.knowledge_graph.types import (
ClassNode,
FileAnalysis,
FileNode,
FunctionNode,
ImportNode,
PredicateNode,
RuleNode,
)
logger = logging.getLogger(__name__)
@traces(spec="008", story="US-004", reqs=["REQ-008-017"])
class CodeParser:
"""Parses source files to extract structural information.
Supports Python (.py) and Prolog (.pl) files.
"""
SUPPORTED_EXTENSIONS = {".py", ".pl"}
@traces(spec="008", story="US-004", reqs=["REQ-008-017", "REQ-008-018"])
def parse_file(self, file_path: str) -> FileAnalysis:
"""Parse a single file and return analysis results."""
start = time.perf_counter()
path = Path(file_path)
if path.suffix not in self.SUPPORTED_EXTENSIONS:
return FileAnalysis(
file_path=file_path,
language="unknown",
errors=[f"Unsupported file type: {path.suffix}"],
)
if not path.exists():
return FileAnalysis(
file_path=file_path,
errors=[f"File not found: {file_path}"],
)
try:
content = path.read_text(encoding="utf-8", errors="replace")
except Exception as exc:
return FileAnalysis(
file_path=file_path,
errors=[f"Read error: {exc}"],
)
line_count = content.count("\n") + 1
if path.suffix == ".py":
analysis = self._parse_python(file_path, content)
elif path.suffix == ".pl":
analysis = self._parse_prolog(file_path, content)
else:
analysis = FileAnalysis(file_path=file_path)
analysis.line_count = line_count
analysis.parse_time_ms = (time.perf_counter() - start) * 1000
return analysis
@traces(spec="008", story="US-004", reqs=["REQ-008-021"])
def get_file_hash(self, file_path: str) -> str:
"""Get SHA256 hash of a file."""
path = Path(file_path)
if not path.exists():
return ""
content = path.read_bytes()
return hashlib.sha256(content).hexdigest()
def create_file_node(self, file_path: str) -> FileNode:
"""Create a FileNode from a file path."""
path = Path(file_path)
lang = (
"python"
if path.suffix == ".py"
else "prolog" if path.suffix == ".pl" else "unknown"
)
try:
stat = path.stat()
content = path.read_text(encoding="utf-8", errors="replace")
line_count = content.count("\n") + 1
except Exception as exc:
logger.warning(
"Failed to read file stats for %s: %s", path, exc, exc_info=True
)
stat = None
line_count = 0
return FileNode(
path=str(path),
name=path.name,
language=lang,
line_count=line_count,
size_bytes=stat.st_size if stat else 0,
hash=self.get_file_hash(file_path),
)
# ── Python Parsing ────────────────────────────────────────────
@traces(spec="008", story="US-004", reqs=["REQ-008-017"])
def _parse_python(self, file_path: str, content: str) -> FileAnalysis:
"""Parse a Python file using ast module."""
analysis = FileAnalysis(file_path=file_path, language="python")
try:
tree = ast.parse(content, filename=file_path)
except SyntaxError as exc:
analysis.errors.append(f"Syntax error: {exc}")
return analysis
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
class_node, method_nodes = self._extract_class_with_methods(
node, file_path
)
analysis.classes.append(class_node)
analysis.functions.extend(method_nodes)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
# Only top-level functions (not methods inside classes)
if not self._is_method(node, tree):
func_node = self._extract_function(node, file_path)
analysis.functions.append(func_node)
elif isinstance(node, (ast.Import, ast.ImportFrom)):
import_node = self._extract_import(node, file_path)
if import_node:
analysis.imports.append(import_node)
return analysis
@traces(
spec="008",
story="US-004",
reqs=[
"REQ-008-072",
"REQ-008-073",
"REQ-008-075",
],
)
def _extract_class_with_methods(
self, node: ast.ClassDef, file_path: str
) -> tuple[ClassNode, list[FunctionNode]]:
"""Extract class and its methods as separate FunctionNodes.
Returns:
Tuple of (ClassNode, list of method FunctionNodes with class_id set).
"""
class_node = self._extract_class(node, file_path)
method_nodes: list[FunctionNode] = []
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
func_node = self._extract_function(item, file_path)
func_node.class_id = class_node.id
method_nodes.append(func_node)
return class_node, method_nodes
@traces(spec="008", story="US-004", reqs=["REQ-008-065"])
def _extract_class(self, node: ast.ClassDef, file_path: str) -> ClassNode:
"""Extract class information from an AST node."""
methods = []
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
methods.append(item.name)
base_classes = []
for base in node.bases:
if isinstance(base, ast.Name):
base_classes.append(base.id)
elif isinstance(base, ast.Attribute):
base_classes.append(ast.dump(base))
decorators = []
for dec in node.decorator_list:
if isinstance(dec, ast.Name):
decorators.append(dec.id)
elif isinstance(dec, ast.Attribute):
decorators.append(ast.dump(dec))
docstring = ast.get_docstring(node) or ""
return ClassNode(
name=node.name,
file_id=file_path,
line_start=node.lineno,
line_end=node.end_lineno or node.lineno,
docstring=docstring,
methods=methods,
base_classes=base_classes,
decorators=decorators,
)
@traces(spec="008", story="US-004", reqs=["REQ-008-074", "REQ-008-101"])
def _extract_function(
self, node: ast.FunctionDef | ast.AsyncFunctionDef, file_path: str
) -> FunctionNode:
"""Extract function information from an AST node."""
params = [arg.arg for arg in node.args.args]
return_type = ""
if node.returns:
try:
return_type = ast.unparse(node.returns)
except Exception as exc:
logger.warning(
"Failed to unparse return type annotation: %s", exc, exc_info=True
)
decorators = []
for dec in node.decorator_list:
if isinstance(dec, ast.Name):
decorators.append(dec.id)
calls = []
for child in ast.walk(node):
if isinstance(child, ast.Call):
if isinstance(child.func, ast.Name):
calls.append(child.func.id)
elif isinstance(child.func, ast.Attribute):
calls.append(child.func.attr)
docstring = ast.get_docstring(node) or ""
return FunctionNode(
name=node.name,
file_id=file_path,
line_start=node.lineno,
line_end=node.end_lineno or node.lineno,
signature=f"def {node.name}({', '.join(params)})",
parameters=params,
return_type=return_type,
decorators=decorators,
calls=list(set(calls)),
is_async=isinstance(node, ast.AsyncFunctionDef),
docstring=docstring,
)
@traces(spec="008", story="US-004", reqs=["REQ-008-017"])
def _extract_import(
self, node: ast.Import | ast.ImportFrom, file_path: str
) -> ImportNode | None:
"""Extract import information."""
if isinstance(node, ast.Import):
for alias in node.names:
return ImportNode(
source_file_id=file_path,
target_module=alias.name,
imported_names=[alias.asname or alias.name],
line_number=node.lineno,
)
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
names = [alias.name for alias in node.names]
return ImportNode(
source_file_id=file_path,
target_module=module,
imported_names=names,
is_relative=node.level > 0,
line_number=node.lineno,
)
return None
@traces(spec="008", story="US-004", reqs=["REQ-008-017"])
def _is_method(self, node: ast.AST, tree: ast.Module) -> bool:
"""Check if a function is a method (inside a class)."""
for cls_node in ast.walk(tree):
if isinstance(cls_node, ast.ClassDef):
for item in cls_node.body:
if item is node:
return True
return False
# ── Prolog Parsing ────────────────────────────────────────────
@traces(spec="008", story="US-004", reqs=["REQ-008-018"])
def _parse_prolog(self, file_path: str, content: str) -> FileAnalysis:
"""Parse a Prolog file using regex-based extraction."""
analysis = FileAnalysis(file_path=file_path, language="prolog")
# Extract module declaration
module_match = re.search(r":- module\((\w+),", content)
module_name = module_match.group(1) if module_match else ""
# Extract exported predicates from module declaration
exports_match = re.search(r":- module\(\w+,\s*\[(.*?)\]\)", content, re.DOTALL)
exported = set()
if exports_match:
exported = {
f"{m.group(1)}/{m.group(2)}"
for m in re.finditer(r"(\w+)/(\d+)", exports_match.group(1))
}
# Extract dynamic declarations
dynamics = {
f"{m.group(1)}/{m.group(2)}"
for m in re.finditer(r":- dynamic\s+(\w+)/(\d+)", content)
}
# Extract predicates (head :- body or head.)
predicate_map: dict[str, PredicateNode] = {}
lines = content.split("\n")
for i, line in enumerate(lines, 1):
stripped = line.strip()
if not stripped or stripped.startswith("%") or stripped.startswith(":-"):
continue
# Match predicate head
head_match = re.match(r"(\w+)\(([^)]*)\)", stripped)
if head_match:
name = head_match.group(1)
args = head_match.group(2)
arity = len([a.strip() for a in args.split(",")]) if args.strip() else 0
functor = f"{name}/{arity}"
if functor not in predicate_map:
pred = PredicateNode(
name=name,
arity=arity,
file_id=file_path,
module=module_name,
line_start=i,
line_end=i,
is_dynamic=functor in dynamics,
is_exported=functor in exported,
)
predicate_map[functor] = pred
else:
predicate_map[functor].line_end = i
# Extract rule body
if ":-" in stripped:
body_part = stripped.split(":-", 1)[1].strip()
rule = RuleNode(
head=stripped.split(":-")[0].strip(),
body=body_part.rstrip("."),
predicate_id=predicate_map[functor].id,
file_id=file_path,
line_start=i,
line_end=i,
)
analysis.rules.append(rule)
analysis.predicates = list(predicate_map.values())
return analysis
# ── Graph Resolution (FR-014, FR-015, FR-016) ────────────────
@traces(
spec="008",
story="US-004",
reqs=[
"REQ-008-056",
"REQ-008-057",
"REQ-008-058",
"REQ-008-060",
],
)
def resolve_calls(self, all_analyses: list[FileAnalysis]) -> list[dict[str, Any]]:
"""Resolve function call names to target FunctionNodes.
Builds a name index from all parsed functions, then matches each
function's ``calls[]`` list against the index using file scope →
package scope → global scope resolution.
Args:
all_analyses: List of FileAnalysis from a batch sync.
Returns:
List of dicts with keys: caller_id, callee_id, confidence,
call_name (the unresolved name that produced this edge — lets
the graph-write layer narrow its own name-match fallback to
only the pairs this scoped resolution could not reach).
"""
# Build name → [(function_id, file_path)] index
name_index: dict[str, list[tuple[str, str]]] = {}
for analysis in all_analyses:
for func in analysis.functions:
name_index.setdefault(func.name, []).append(
(func.id, analysis.file_path)
)
edges: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for analysis in all_analyses:
for func in analysis.functions:
for call_name in func.calls:
candidates = name_index.get(call_name, [])
if not candidates:
continue
edges.extend(
self._call_edges_for(
func, analysis.file_path, call_name, candidates, seen
)
)
return edges
@staticmethod
@traces(spec="008", story="US-004", reqs=["REQ-008-057", "REQ-008-058"])
def _call_edges_for(
func: Any,
caller_file: str,
call_name: str,
candidates: list[tuple[str, str]],
seen: set[tuple[str, str]],
) -> list[dict[str, Any]]:
"""Build deduplicated CALLS edges from one caller to its candidates.
Scopes confidence by call site: same file > same package > global,
further attenuated when a name is ambiguous (multiple candidates).
``seen`` is mutated to track caller→callee pairs already emitted.
"""
edges: list[dict[str, Any]] = []
for cand_id, cand_file in candidates:
if cand_id == func.id:
continue # skip self-calls
pair = (func.id, cand_id)
if pair in seen:
continue
seen.add(pair)
confidence = _call_confidence(caller_file, cand_file, len(candidates))
edges.append(
{
"caller_id": func.id,
"callee_id": cand_id,
"confidence": round(confidence, 3),
"call_name": call_name,
}
)
return edges
@traces(
spec="008",
story="US-004",
reqs=[
"REQ-008-062",
"REQ-008-063",
"REQ-008-064",
"REQ-008-066",
],
)
def resolve_inheritance(
self, all_analyses: list[FileAnalysis]
) -> list[dict[str, str]]:
"""Resolve base class names to target ClassNodes.
Args:
all_analyses: List of FileAnalysis from a batch sync.
Returns:
List of dicts with keys: child_id, parent_id, base_name (the
unresolved name that produced this edge — lets the graph-write
layer narrow its own name-match fallback to only the pairs
this scoped resolution could not reach).
"""
# Build class name → [(class_id, file_path)] index
name_index: dict[str, list[tuple[str, str]]] = {}
for analysis in all_analyses:
for cls in analysis.classes:
name_index.setdefault(cls.name, []).append((cls.id, analysis.file_path))
edges: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for analysis in all_analyses:
for cls in analysis.classes:
for base_name in cls.base_classes:
# Strip ast.dump output for attribute access (e.g. "Attribute(...)")
clean_name = (
base_name.split("(")[0] if "(" in base_name else base_name
)
candidates = name_index.get(clean_name, [])
for cand_id, _ in candidates:
if cand_id == cls.id:
continue
pair = (cls.id, cand_id)
if pair in seen:
continue
seen.add(pair)
edges.append(
{
"child_id": cls.id,
"parent_id": cand_id,
"base_name": clean_name,
}
)
return edges
@traces(
spec="008",
story="US-004",
reqs=[
"REQ-008-067",
"REQ-008-068",
"REQ-008-069",
"REQ-008-071",
],
)
def resolve_imports(self, all_analyses: list[FileAnalysis]) -> list[dict[str, Any]]:
"""Resolve import statements to target File nodes.
Builds a module path → file path mapping, then resolves each
ImportNode's target_module to a known file.
Args:
all_analyses: List of FileAnalysis from a batch sync.
Returns:
List of dicts with keys: source_file, target_file,
imported_names, line_number, is_relative.
"""
# Build module → file mapping
module_to_file: dict[str, str] = {}
for analysis in all_analyses:
module_path = _file_path_to_module(analysis.file_path)
if module_path:
module_to_file[module_path] = analysis.file_path
edges: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for analysis in all_analyses:
for imp in analysis.imports:
target_file = module_to_file.get(imp.target_module)
if not target_file:
# Try partial match (import orchestrata_ultimate.cxp matches orchestrata.cxp.__init__)
for mod, fpath in module_to_file.items():
if mod.startswith(
imp.target_module + "."
) or imp.target_module.startswith(mod + "."):
target_file = fpath
break
if not target_file or target_file == analysis.file_path:
continue
pair = (analysis.file_path, target_file)
if pair in seen:
continue
seen.add(pair)
edges.append(
{
"source_file": analysis.file_path,
"target_file": target_file,
"imported_names": imp.imported_names,
"line_number": imp.line_number,
"is_relative": imp.is_relative,
}
)
return edges
@traces(spec="008", story="US-004", reqs=["REQ-008-060"])
def _same_package(file_a: str, file_b: str) -> bool:
"""Check if two files are in the same package directory."""
parent_a = str(Path(file_a).parent)
parent_b = str(Path(file_b).parent)
return parent_a == parent_b
@traces(spec="008", story="US-004", reqs=["REQ-008-060"])
def _call_confidence(caller_file: str, callee_file: str, num_candidates: int) -> float:
"""Score a resolved call edge: same file > same package > global.
Ambiguous names (multiple candidates) are attenuated so no single
edge claims high confidence when the resolution is uncertain.
"""
if callee_file == caller_file:
confidence = 1.0
elif _same_package(callee_file, caller_file):
confidence = 0.9
else:
confidence = 0.8
if num_candidates > 1:
confidence = min(confidence, 0.8 / num_candidates)
return confidence
@traces(spec="008", story="US-004", reqs=["REQ-008-067"])
def _file_path_to_module(file_path: str) -> str:
"""Convert a file path to a Python module path.
Strips common prefixes (packages/*/src/, src/) and converts
directory separators to dots. Removes .py and __init__.
Examples:
packages/orchestrata-cxp/src/orchestrata/cxp/packet.py
→ orchestrata.cxp.packet
src/orchestrata/knowledge/types.py
→ orchestrata.knowledge.types
"""
p = file_path.replace("\\", "/")
# Strip packages/*/src/ prefix or leading src/
if "/src/" in p:
p = p.split("/src/", 1)[1]
elif p.startswith("src/"):
p = p[4:]
# Remove .py extension
if p.endswith(".py"):
p = p[:-3]
# Remove trailing __init__
if p.endswith("/__init__"):
p = p[: -len("/__init__")]
# Convert path separators to dots
module = p.replace("/", ".")
return module