Initial import of the codespaces skill: query .belief_map.sexp for module boundaries, dependencies, blast radius, and the minimal files to read before non-trivial code changes. Includes the belief-map and infra-topology builders, the belief-search query engine and its tests, and reference docs for the S-expression notation and query patterns.
2257 lines
84 KiB
Python
2257 lines
84 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Belief Map Search -- query tool for S-expression belief maps.
|
|
|
|
Reads ``.belief_map.sexp`` and provides structured queries: module views,
|
|
dependency trees, reverse dependency analysis, entity lookups, data flow
|
|
tracing, and boundary discovery for minimal context loading.
|
|
|
|
Output is S-expression facts (one per line, greppable).
|
|
|
|
Usage::
|
|
|
|
belief_search.py analyze <id> Full analysis in one step
|
|
belief_search.py boundary <id> File paths for change boundary
|
|
belief_search.py quick <keyword> Search + analyze in one step
|
|
belief_search.py module <id> Show module with entities and edges
|
|
belief_search.py deps <id> [depth] Outgoing dependency tree (default depth 2)
|
|
belief_search.py rdeps <id> [depth] Reverse dependency tree (default depth 2)
|
|
belief_search.py entity <name> Find entity: definition + all usages
|
|
belief_search.py flow <id> <entity> Trace data/call flow from entity
|
|
belief_search.py search <pattern> Regex search across all facts
|
|
belief_search.py invariants [scope] Check naming-convention violations (default: all)
|
|
belief_search.py find_function <name> Find function/method definitions by name
|
|
belief_search.py find_type <name> Find type/class/interface/enum definitions by name
|
|
belief_search.py find_callchain <src_fn> <tgt_fn> [depth] Trace call path between two functions
|
|
belief_search.py find_callers <fn_name> [depth] Who calls this function (incoming calls)
|
|
belief_search.py find_calls <fn_name> [depth] What does this function call (outgoing calls)
|
|
belief_search.py grep_functions <pattern> Search function bodies in source files
|
|
belief_search.py diff_functions [ref] Identify changed functions in git diff (default: HEAD)
|
|
belief_search.py query <scheme-expr> Evaluate a Scheme query expression
|
|
belief_search.py repl Interactive Scheme REPL
|
|
|
|
Scheme query examples::
|
|
|
|
belief_search.py query '(boundary "operatives.controller")'
|
|
belief_search.py query '(files (intersect (deps "A" 2) (rdeps "B" 1)))'
|
|
belief_search.py query '(filter (boundary "operatives.service") "dto")'
|
|
belief_search.py query '(count (deps "operatives.service" 3))'
|
|
|
|
CLI examples::
|
|
|
|
belief_search.py analyze drive/tools/drive_vsearch
|
|
belief_search.py boundary operatives.service
|
|
belief_search.py entity DriveDbCommitResponse
|
|
belief_search.py deps drive/modules/drive_db/services/database_service 3
|
|
belief_search.py find_function createOrder
|
|
belief_search.py find_type DriveDbCommitResponse
|
|
belief_search.py find_callchain createOrder processPayment
|
|
belief_search.py find_callers handleRequest 3
|
|
belief_search.py find_calls createOrder 2
|
|
belief_search.py grep_functions "validate.*input"
|
|
belief_search.py diff_functions HEAD~3
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Node:
|
|
nid: str
|
|
lang: str
|
|
purpose: str
|
|
naming: str = "" # snake_case, camelCase, PascalCase, kebab-case, mixed
|
|
pkg: str = "" # repository/package name
|
|
|
|
|
|
@dataclass
|
|
class EntityFact:
|
|
module: str
|
|
name: str
|
|
kind: str # fn, cls, ifc, typ, enm
|
|
line: int
|
|
bases: list[str] = field(default_factory=list)
|
|
deco: list[str] = field(default_factory=list)
|
|
methods: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class Edge:
|
|
etype: str # imports, calls-api, data-flow, refs, calls
|
|
source: str
|
|
target: str
|
|
flags: list[str] = field(default_factory=list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# S-expression line parser
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def tokenize(line: str) -> list[str]:
|
|
"""Tokenize a single-line S-expression into a flat list of tokens."""
|
|
line = line.strip()
|
|
if not line or line.startswith(";"):
|
|
return []
|
|
if not line.startswith("(") or not line.endswith(")"):
|
|
return []
|
|
inner = line[1:-1]
|
|
tokens: list[str] = []
|
|
i = 0
|
|
while i < len(inner):
|
|
ch = inner[i]
|
|
if ch == " ":
|
|
i += 1
|
|
elif ch == '"':
|
|
j = i + 1
|
|
while j < len(inner):
|
|
if inner[j] == "\\" and j + 1 < len(inner):
|
|
j += 2
|
|
elif inner[j] == '"':
|
|
break
|
|
else:
|
|
j += 1
|
|
tokens.append(inner[i + 1 : j].replace('\\"', '"').replace("\\\\", "\\"))
|
|
i = j + 1
|
|
elif ch == "(":
|
|
depth = 1
|
|
j = i + 1
|
|
while j < len(inner) and depth > 0:
|
|
if inner[j] == "(":
|
|
depth += 1
|
|
elif inner[j] == ")":
|
|
depth -= 1
|
|
j += 1
|
|
tokens.append(inner[i:j])
|
|
i = j
|
|
else:
|
|
j = i
|
|
while j < len(inner) and inner[j] not in (" ", ")"):
|
|
j += 1
|
|
tokens.append(inner[i:j])
|
|
i = j
|
|
return tokens
|
|
|
|
|
|
def parse_sub_list(token: str) -> list[str]:
|
|
"""Parse a (:key a b c) token into ['a', 'b', 'c']."""
|
|
if token.startswith("(") and token.endswith(")"):
|
|
parts = token[1:-1].split()
|
|
return [p for p in parts if not p.startswith(":")]
|
|
return []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# S-expression output helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _q(s: str) -> str:
|
|
"""Quote a string for S-expression output if it contains spaces."""
|
|
if " " in s or '"' in s:
|
|
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
return s
|
|
|
|
|
|
def _sexp_list(tag: str, items: list[str]) -> str:
|
|
"""Format a tagged list: (:tag a b c)."""
|
|
if not items:
|
|
return ""
|
|
return f"(:{tag} {' '.join(items)})"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# No-match error helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _print_no_match(g: BeliefGraph, pattern: str) -> None:
|
|
"""Print a structured no-match error with fuzzy suggestions."""
|
|
suggestions = BeliefGraph._suggest_similar(pattern, g.nodes)
|
|
if suggestions:
|
|
sug_str = " ".join(suggestions)
|
|
print(f"(error no-match {_q(pattern)} :suggestions {sug_str})")
|
|
else:
|
|
print(f"(error no-match {_q(pattern)})")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File-path resolution helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TS_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx")
|
|
_PY_EXTENSIONS = (".py",)
|
|
|
|
|
|
def _module_id_to_file(module_id: str, root: str) -> str:
|
|
"""Convert a module ID back to a file path relative to root."""
|
|
base = os.path.join(root, module_id)
|
|
for ext in _TS_EXTENSIONS + _PY_EXTENSIONS:
|
|
candidate = base + ext
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
for idx in ("index.ts", "index.tsx", "index.js", "__init__.py"):
|
|
candidate = os.path.join(base, idx)
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
return module_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graph loader
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class BeliefGraph:
|
|
"""In-memory belief map graph built from S-expression facts."""
|
|
|
|
def __init__(self) -> None:
|
|
self.nodes: dict[str, Node] = {}
|
|
self.entities: dict[str, list[EntityFact]] = {} # module -> entities
|
|
self.entity_by_name: dict[str, list[EntityFact]] = {} # name -> entities
|
|
self.edges_from: dict[str, list[Edge]] = {} # source module -> edges
|
|
self.edges_to: dict[str, list[Edge]] = {} # target module -> edges
|
|
self.edges_from_entity: dict[str, list[Edge]] = {} # entity-qualified source -> edges
|
|
self.edges_by_entity_name: dict[str, list[Edge]] = {} # entity name -> edges
|
|
self.all_edges: list[Edge] = []
|
|
self.pmap: dict[str, str] = {}
|
|
self._root: str = ""
|
|
|
|
def load(self, path: str) -> None:
|
|
self._root = os.path.dirname(os.path.dirname(os.path.abspath(path)))
|
|
pmap: dict[str, str] = {}
|
|
|
|
in_paths = False
|
|
path_stack: list[str] = []
|
|
|
|
with open(path, "r") as f:
|
|
for line in f:
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith(";"):
|
|
continue
|
|
|
|
if stripped == "(paths":
|
|
in_paths = True
|
|
path_stack = []
|
|
continue
|
|
|
|
if in_paths:
|
|
if stripped == ")":
|
|
if path_stack:
|
|
path_stack.pop()
|
|
else:
|
|
in_paths = False
|
|
continue
|
|
|
|
tokens = tokenize(stripped)
|
|
if not tokens:
|
|
inner = stripped.lstrip("(").rstrip()
|
|
if inner:
|
|
path_stack.append(inner)
|
|
continue
|
|
|
|
if len(tokens) == 2 and not tokens[0].startswith(":"):
|
|
sid, leaf = tokens[0], tokens[1]
|
|
if leaf == ":self":
|
|
# Directory-as-module (index.ts stripped to dir name)
|
|
full = "/".join(path_stack) if path_stack else sid
|
|
pmap[sid] = full
|
|
else:
|
|
full = "/".join(path_stack + [leaf]) if path_stack else leaf
|
|
pmap[sid] = full
|
|
elif len(tokens) == 1:
|
|
path_stack.append(tokens[0])
|
|
continue
|
|
|
|
tokens = tokenize(stripped)
|
|
if tokens and tokens[0] == "def" and len(tokens) >= 3:
|
|
pmap[tokens[1]] = tokens[2]
|
|
|
|
self.pmap = pmap
|
|
|
|
def _resolve(sid: str) -> str:
|
|
if "::" in sid:
|
|
mod, ent = sid.split("::", 1)
|
|
return f"{pmap.get(mod, mod)}::{ent}"
|
|
return pmap.get(sid, sid)
|
|
|
|
with open(path, "r") as f:
|
|
for line in f:
|
|
tokens = tokenize(line)
|
|
if not tokens:
|
|
continue
|
|
kind = tokens[0]
|
|
|
|
if kind in ("paths", "def", ")"):
|
|
continue
|
|
|
|
if kind == "node" and len(tokens) >= 4:
|
|
nid = _resolve(tokens[1])
|
|
lang, purpose = tokens[2], tokens[3]
|
|
naming, pkg = "", ""
|
|
rest = tokens[4:]
|
|
for i, t in enumerate(rest):
|
|
if t == ":naming" and i + 1 < len(rest):
|
|
naming = rest[i + 1]
|
|
elif t == ":pkg" and i + 1 < len(rest):
|
|
pkg = rest[i + 1]
|
|
self.nodes[nid] = Node(nid, lang, purpose, naming, pkg)
|
|
|
|
elif kind in ("fn", "cls", "ifc", "typ", "enm") and len(tokens) >= 4:
|
|
module = _resolve(tokens[1])
|
|
name = tokens[2]
|
|
line_num = int(tokens[3]) if tokens[3].isdigit() else 0
|
|
bases, deco, methods = [], [], []
|
|
for t in tokens[4:]:
|
|
if t.startswith("(:bases"):
|
|
bases = parse_sub_list(t)
|
|
elif t.startswith("(:deco"):
|
|
deco = parse_sub_list(t)
|
|
elif t.startswith("(:methods"):
|
|
methods = parse_sub_list(t)
|
|
ent = EntityFact(module, name, kind, line_num, bases, deco, methods)
|
|
self.entities.setdefault(module, []).append(ent)
|
|
self.entity_by_name.setdefault(name, []).append(ent)
|
|
|
|
elif kind in ("imports", "calls-api", "data-flow", "refs", "calls", "http-calls") and len(tokens) >= 3:
|
|
source = _resolve(tokens[1])
|
|
target = _resolve(tokens[2])
|
|
flags = [t for t in tokens[3:] if t.startswith(":")]
|
|
edge = Edge(kind, source, target, flags)
|
|
self.all_edges.append(edge)
|
|
src_mod = source.split("::")[0]
|
|
tgt_mod = target.split("::")[0]
|
|
self.edges_from.setdefault(src_mod, []).append(edge)
|
|
self.edges_to.setdefault(tgt_mod, []).append(edge)
|
|
self.edges_from_entity.setdefault(source, []).append(edge)
|
|
for part in (source, target):
|
|
if "::" in part:
|
|
ent_name = part.split("::", 1)[1]
|
|
self.edges_by_entity_name.setdefault(ent_name, []).append(edge)
|
|
|
|
elif kind == "violation" and len(tokens) >= 5:
|
|
source = _resolve(tokens[2])
|
|
target = _resolve(tokens[3])
|
|
edge = Edge("violation", source, target, [f":rule={tokens[1]}"])
|
|
self.all_edges.append(edge)
|
|
|
|
def resolve_alias(self, module_id: str) -> str:
|
|
"""Resolve an internal path-map alias to a module ID when possible."""
|
|
if "::" in module_id:
|
|
mod, ent = module_id.split("::", 1)
|
|
return f"{self.pmap.get(mod, mod)}::{ent}"
|
|
return self.pmap.get(module_id, module_id)
|
|
|
|
def resolve_module(self, pattern: str) -> list[str]:
|
|
"""Find module IDs: exact > suffix > substring > case-insensitive substring.
|
|
|
|
Returns an empty list when nothing matches (caller should use
|
|
_print_no_match to report the failure with suggestions).
|
|
"""
|
|
resolved_pattern = self.resolve_alias(pattern)
|
|
if resolved_pattern in self.nodes:
|
|
return [resolved_pattern]
|
|
if pattern in self.nodes:
|
|
return [pattern]
|
|
suffix_matches = [nid for nid in self.nodes if nid.endswith("/" + pattern) or nid.endswith(pattern)]
|
|
if suffix_matches:
|
|
return sorted(suffix_matches)
|
|
sub_matches = [nid for nid in self.nodes if pattern in nid]
|
|
if sub_matches:
|
|
if len(sub_matches) > 10:
|
|
print(f"; warning: '{pattern}' matches {len(sub_matches)} modules, showing first 10", file=sys.stderr)
|
|
return sorted(sub_matches)[:10]
|
|
return sorted(sub_matches)
|
|
# Case-insensitive substring fallback
|
|
lp = pattern.lower()
|
|
ci_matches = [nid for nid in self.nodes if lp in nid.lower()]
|
|
if ci_matches:
|
|
if len(ci_matches) > 10:
|
|
print(f"; warning: '{pattern}' (case-insensitive) matches {len(ci_matches)} modules, showing first 10", file=sys.stderr)
|
|
return sorted(ci_matches)[:10]
|
|
return sorted(ci_matches)
|
|
return []
|
|
|
|
def search_modules(self, pattern: str) -> list[str]:
|
|
"""Find module IDs whose path or basename matches the regex pattern."""
|
|
regex = re.compile(pattern, re.IGNORECASE)
|
|
matches = [
|
|
nid for nid in self.nodes
|
|
if regex.search(nid) or regex.search(nid.rsplit("/", 1)[-1])
|
|
]
|
|
return sorted(matches)
|
|
|
|
@staticmethod
|
|
def _suggest_similar(pattern: str, nodes: "dict[str, Node]", max_results: int = 5) -> list[str]:
|
|
"""Return up to max_results node IDs whose path segments overlap with pattern.
|
|
|
|
Splits pattern by '/', '.', '_', '-' and counts how many parts appear
|
|
(case-insensitively) in each candidate node ID. Returns the top N
|
|
nodes with at least one matching part, sorted by match count descending.
|
|
"""
|
|
import re as _re
|
|
parts = [p for p in _re.split(r"[/._-]", pattern.lower()) if p]
|
|
if not parts:
|
|
return []
|
|
scored: list[tuple[int, str]] = []
|
|
for nid in nodes:
|
|
nid_lower = nid.lower()
|
|
count = sum(1 for p in parts if p in nid_lower)
|
|
if count > 0:
|
|
scored.append((count, nid))
|
|
scored.sort(key=lambda x: (-x[0], x[1]))
|
|
return [nid for _, nid in scored[:max_results]]
|
|
|
|
def module_to_file(self, module_id: str) -> str:
|
|
"""Resolve a module ID to a file path on disk."""
|
|
return _module_id_to_file(module_id, self._root)
|
|
|
|
def module_purpose(self, module_id: str) -> str:
|
|
"""Return the purpose string of a module. Empty if unknown."""
|
|
node = self.nodes.get(module_id)
|
|
return node.purpose if node else ""
|
|
|
|
def module_lang(self, module_id: str) -> str:
|
|
"""Return the language of a module ('py', 'ts', 'tsx'). Empty if unknown."""
|
|
node = self.nodes.get(module_id)
|
|
if not node:
|
|
return ""
|
|
# Normalize legacy 'ty' -> 'ts', 'tx' -> 'tsx'
|
|
return {"ty": "ts", "tx": "tsx"}.get(node.lang, node.lang)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared tree collectors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _collect_dep_mods(g: BeliefGraph, nid: str) -> dict[str, str]:
|
|
"""Outgoing dependency modules (deduped): module -> edge_type."""
|
|
seen: dict[str, str] = {}
|
|
for e in g.edges_from.get(nid, []):
|
|
tgt_mod = e.target.split("::")[0]
|
|
if tgt_mod != nid and tgt_mod not in seen:
|
|
seen[tgt_mod] = e.etype
|
|
return seen
|
|
|
|
|
|
def _collect_rdep_mods(g: BeliefGraph, nid: str) -> dict[str, str]:
|
|
"""Incoming dependency modules (deduped): module -> edge_type."""
|
|
seen: dict[str, str] = {}
|
|
for e in g.edges_to.get(nid, []):
|
|
src_mod = e.source.split("::")[0]
|
|
if src_mod != nid and src_mod not in seen:
|
|
seen[src_mod] = e.etype
|
|
return seen
|
|
|
|
|
|
def _walk_deps(
|
|
g: BeliefGraph, nid: str, visited: set[str], max_depth: int, depth: int,
|
|
emit: Callable[[int, str, str, bool], None],
|
|
) -> None:
|
|
if depth > max_depth:
|
|
return
|
|
for tgt, etype in sorted(_collect_dep_mods(g, nid).items()):
|
|
cycle = tgt in visited
|
|
emit(depth, etype, tgt, cycle)
|
|
if not cycle:
|
|
visited.add(tgt)
|
|
_walk_deps(g, tgt, visited, max_depth, depth + 1, emit)
|
|
|
|
|
|
def _walk_rdeps(
|
|
g: BeliefGraph, nid: str, visited: set[str], max_depth: int, depth: int,
|
|
emit: Callable[[int, str, str, bool], None],
|
|
) -> None:
|
|
if depth > max_depth:
|
|
return
|
|
for src, etype in sorted(_collect_rdep_mods(g, nid).items()):
|
|
cycle = src in visited
|
|
emit(depth, etype, src, cycle)
|
|
if not cycle:
|
|
visited.add(src)
|
|
_walk_rdeps(g, src, visited, max_depth, depth + 1, emit)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Layer classifier + boundary rules
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _classify_layer_fallback(p: str) -> str:
|
|
"""Classify a module into an architecture layer from its path.
|
|
|
|
Order matters: test/config checked first (override other patterns).
|
|
NestJS conventions: guards/controllers are api, .module files are config,
|
|
/interface/ folders are shared contracts (not api).
|
|
"""
|
|
# High-priority overrides (tests, config)
|
|
if "/tests/" in p or "/test_" in p or ".test." in p or ".spec." in p or "/e2e/" in p:
|
|
return "test"
|
|
if "/config/" in p or "/env/" in p:
|
|
return "config"
|
|
# NestJS .module files are wiring/config, not service
|
|
if ".module" in p and "/modules/" in p:
|
|
return "config"
|
|
# Interface folders are shared contracts, not api
|
|
if "/interface/" in p or "/interfaces/" in p:
|
|
return "shared"
|
|
# Architecture layers
|
|
if "/api/" in p or "/routes/" in p or "/controllers/" in p:
|
|
return "api"
|
|
if "/services/" in p or "/application/" in p:
|
|
return "service"
|
|
if "/domain/" in p or "/entities/" in p or "/schema" in p or "_schema" in p:
|
|
return "domain"
|
|
if "/repositories/" in p or "/db/" in p or "/infra/" in p or "/infrastructure/" in p or "rabbitmq" in p or "s3" in p:
|
|
return "infra"
|
|
if "/commons/" in p or "/common/" in p or "/utils/" in p or "/result" in p or "/types/" in p:
|
|
return "shared"
|
|
if "/components/" in p or "/pages/" in p or "/ui/" in p or "/hooks/" in p or "/stores/" in p:
|
|
return "ui"
|
|
# NestJS / backend patterns
|
|
if "/dto/" in p or "/dtos/" in p:
|
|
return "domain"
|
|
if "/guards/" in p or "/guard." in p:
|
|
return "api"
|
|
if "/middleware/" in p or "/middlewares/" in p or "/interceptors/" in p or "/pipes/" in p:
|
|
return "api"
|
|
if "/adapters/" in p or "/adapter." in p:
|
|
return "infra"
|
|
if "/gateways/" in p or "/gateway." in p:
|
|
return "infra"
|
|
if "/migrations/" in p or "/seeds/" in p or "/seeders/" in p:
|
|
return "infra"
|
|
# Domain patterns
|
|
if "/models/" in p or "/schemas/" in p or "/events/" in p or "/commands/" in p or "/queries/" in p:
|
|
return "domain"
|
|
if "/handlers/" in p:
|
|
return "service"
|
|
# Shared patterns
|
|
if "/lib/" in p or "/helpers/" in p or "/constants/" in p or "/decorators/" in p or "/mappers/" in p:
|
|
return "shared"
|
|
# UI patterns (state management tied to UI)
|
|
if ("/stores/" in p or "/store/" in p or "/contexts/" in p or "/providers/" in p) and "/ui/" in p:
|
|
return "ui"
|
|
# Test patterns
|
|
if "/fixtures/" in p or "/mocks/" in p or "/__mocks__/" in p or "/factories/" in p:
|
|
return "test"
|
|
# Config / tooling patterns
|
|
if "/scripts/" in p or "/tools/" in p or "/cli/" in p:
|
|
return "config"
|
|
return "other"
|
|
|
|
|
|
_PURPOSE_TO_LAYER: dict[str, str] = {
|
|
# Domain layer
|
|
"domain logic": "domain",
|
|
"domain entity": "domain",
|
|
"data model": "domain",
|
|
"data transfer object": "domain",
|
|
"schema": "domain",
|
|
"enum definitions": "domain",
|
|
"policy": "domain",
|
|
"value object": "domain",
|
|
"aggregate root": "domain",
|
|
"domain event": "domain",
|
|
"Specification pattern": "domain",
|
|
# Infrastructure layer
|
|
"infrastructure adapter": "infra",
|
|
"database migration": "infra",
|
|
"database seed": "infra",
|
|
"repository": "infra",
|
|
"adapter": "infra",
|
|
"gateway": "infra",
|
|
"client": "infra",
|
|
"connector": "infra",
|
|
"scheduler": "infra",
|
|
"worker": "infra",
|
|
"Unit of Work": "infra",
|
|
"Identity Map": "infra",
|
|
"Proxy pattern": "infra",
|
|
"SDK": "infra",
|
|
"API client": "infra",
|
|
# API layer
|
|
"API layer": "api",
|
|
"API controller": "api",
|
|
"router": "api",
|
|
"access guard": "api",
|
|
"middleware": "api",
|
|
"interceptor": "api",
|
|
"validation pipe": "api",
|
|
"resolver": "api",
|
|
# Service layer
|
|
"service": "service",
|
|
"application service": "service",
|
|
"event handler": "service",
|
|
"handler": "service",
|
|
"event listener": "service",
|
|
"processor": "service",
|
|
"provider": "service",
|
|
"registry": "service",
|
|
"agent": "service",
|
|
"manager": "service",
|
|
"subscriber": "service",
|
|
"dispatcher": "service",
|
|
"emitter": "service",
|
|
"Facade pattern": "service",
|
|
"Mediator pattern": "service",
|
|
"Observer pattern": "service",
|
|
"Command pattern": "service",
|
|
"Chain of Responsibility": "service",
|
|
"Memento pattern": "service",
|
|
# Config layer
|
|
"NestJS wiring module": "config",
|
|
"NestJS injectable": "config",
|
|
"configuration": "config",
|
|
"application bootstrap": "config",
|
|
"module entry point": "config",
|
|
# UI layer
|
|
"UI component": "ui",
|
|
"page/route": "ui",
|
|
"React hook": "ui",
|
|
"state store": "ui",
|
|
# Test layer
|
|
"test": "test",
|
|
"test fixture": "test",
|
|
"test mock": "test",
|
|
# Shared layer
|
|
"shared utilities": "shared",
|
|
"utility helpers": "shared",
|
|
"constants": "shared",
|
|
"type definitions": "shared",
|
|
"interface definitions": "shared",
|
|
"decorator": "shared",
|
|
"data mapper": "shared",
|
|
"barrel re-exports": "shared",
|
|
"plugin": "shared",
|
|
"Factory pattern": "shared",
|
|
"Builder pattern": "shared",
|
|
"Strategy pattern": "shared",
|
|
"Singleton pattern": "shared",
|
|
"Composite pattern": "shared",
|
|
"Visitor pattern": "shared",
|
|
"Iterator pattern": "shared",
|
|
"Decorator pattern": "shared",
|
|
}
|
|
|
|
|
|
def _levenshtein(a: str, b: str) -> int:
|
|
"""Compute Levenshtein edit distance between two strings."""
|
|
if len(a) < len(b):
|
|
return _levenshtein(b, a)
|
|
if not b:
|
|
return len(a)
|
|
prev = list(range(len(b) + 1))
|
|
for i, ca in enumerate(a):
|
|
curr = [i + 1]
|
|
for j, cb in enumerate(b):
|
|
curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (ca != cb)))
|
|
prev = curr
|
|
return prev[-1]
|
|
|
|
|
|
def _classify_layer_by_purpose(purpose: str) -> str:
|
|
"""Classify layer from the module's purpose string using fuzzy matching.
|
|
|
|
Tries exact match, then substring, then Levenshtein distance on
|
|
individual words from the purpose against the keyword map.
|
|
"""
|
|
if not purpose or purpose == "general module":
|
|
return "other"
|
|
pl = purpose.lower()
|
|
# 1. Exact match
|
|
for key, layer in _PURPOSE_TO_LAYER.items():
|
|
if key.lower() == pl:
|
|
return layer
|
|
# 2. Substring match
|
|
for key, layer in _PURPOSE_TO_LAYER.items():
|
|
if key.lower() in pl:
|
|
return layer
|
|
# 3. Fuzzy word match: split purpose into words, compare each word
|
|
# against keywords from the map using Levenshtein distance.
|
|
# Threshold: distance <= 2 for words >= 5 chars, <= 1 for shorter.
|
|
purpose_words = re.split(r"[\s;,/]+", pl)
|
|
best_layer = "other"
|
|
best_dist = 999
|
|
for key, layer in _PURPOSE_TO_LAYER.items():
|
|
key_words = key.lower().split()
|
|
for kw in key_words:
|
|
if len(kw) < 4:
|
|
continue # skip short keywords (api, ui, dto) -- too ambiguous for fuzzy
|
|
threshold = 2 if len(kw) >= 5 else 1
|
|
for pw in purpose_words:
|
|
if len(pw) < 4:
|
|
continue
|
|
dist = _levenshtein(pw, kw)
|
|
if dist <= threshold and dist < best_dist:
|
|
best_dist = dist
|
|
best_layer = layer
|
|
return best_layer
|
|
|
|
|
|
def _classify_layer(module_path: str, purpose: str = "") -> str:
|
|
"""Classify using path patterns first, fall back to purpose string."""
|
|
result = _classify_layer_fallback(module_path.lower())
|
|
if result == "other" and purpose:
|
|
result = _classify_layer_by_purpose(purpose)
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Commands -- S-expression output (default)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _entity_sexp(e: EntityFact, lang: str = "") -> str:
|
|
"""Format an entity as an S-expression fact with :lang."""
|
|
parts = [e.kind, e.module, e.name, str(e.line)]
|
|
if lang:
|
|
parts.append(f":lang {lang}")
|
|
if e.bases:
|
|
parts.append(_sexp_list("bases", e.bases))
|
|
if e.deco:
|
|
parts.append(_sexp_list("deco", e.deco))
|
|
if e.methods:
|
|
parts.append(_sexp_list("methods", e.methods))
|
|
return f"({' '.join(parts)})"
|
|
|
|
|
|
def _edge_sexp(e: Edge) -> str:
|
|
"""Format an edge as an S-expression fact."""
|
|
flags = " " + " ".join(e.flags) if e.flags else ""
|
|
return f"({e.etype} {e.source} {e.target}{flags})"
|
|
|
|
|
|
def cmd_module(g: BeliefGraph, module_id: str) -> None:
|
|
matches = g.resolve_module(module_id)
|
|
if not matches:
|
|
_print_no_match(g, module_id)
|
|
return
|
|
|
|
for nid in matches:
|
|
node = g.nodes[nid]
|
|
fpath = g.module_to_file(nid)
|
|
mod_lang = g.module_lang(nid)
|
|
print(f"(node {nid} :lang {mod_lang} {_q(node.purpose)} :file {_q(fpath)})")
|
|
for e in sorted(g.entities.get(nid, []), key=lambda x: x.line):
|
|
print(_entity_sexp(e, mod_lang))
|
|
for e in sorted(g.edges_from.get(nid, []), key=lambda x: (x.etype, x.target)):
|
|
print(_edge_sexp(e))
|
|
for e in sorted(g.edges_to.get(nid, []), key=lambda x: (x.etype, x.source)):
|
|
if e.source.split("::")[0] != nid:
|
|
print(_edge_sexp(e))
|
|
|
|
|
|
def cmd_deps(g: BeliefGraph, module_id: str, max_depth: int) -> None:
|
|
matches = g.resolve_module(module_id)
|
|
if not matches:
|
|
_print_no_match(g, module_id)
|
|
return
|
|
|
|
for start in matches:
|
|
visited: set[str] = {start}
|
|
print(f"(deps-root {start} :lang {g.module_lang(start)})")
|
|
_walk_deps(g, start, visited, max_depth, 1,
|
|
lambda d, et, tgt, cyc: print(f"(dep {start} {tgt} :lang {g.module_lang(tgt)} :via {et} :depth {d}{' :cycle' if cyc else ''})"))
|
|
|
|
|
|
def cmd_rdeps(g: BeliefGraph, module_id: str, max_depth: int) -> None:
|
|
matches = g.resolve_module(module_id)
|
|
if not matches:
|
|
_print_no_match(g, module_id)
|
|
return
|
|
|
|
for start in matches:
|
|
visited: set[str] = {start}
|
|
print(f"(rdeps-root {start} :lang {g.module_lang(start)})")
|
|
_walk_rdeps(g, start, visited, max_depth, 1,
|
|
lambda d, et, src, cyc: print(f"(rdep {start} {src} :lang {g.module_lang(src)} :via {et} :depth {d}{' :cycle' if cyc else ''})"))
|
|
|
|
|
|
def cmd_entity(g: BeliefGraph, name: str) -> None:
|
|
# Always try exact match first, then regex fallback
|
|
definitions: list[EntityFact] = []
|
|
if name in g.entity_by_name:
|
|
definitions = g.entity_by_name[name]
|
|
else:
|
|
pattern = re.compile(name, re.IGNORECASE)
|
|
for ename, ents in g.entity_by_name.items():
|
|
if pattern.search(ename):
|
|
definitions.extend(ents)
|
|
|
|
if not definitions:
|
|
_print_no_match(g, name)
|
|
return
|
|
|
|
# Collect refs via index
|
|
matched_names: set[str] = {d.name for d in definitions}
|
|
seen_edge_ids: set[int] = set()
|
|
unique_refs: list[Edge] = []
|
|
for ent_name in matched_names:
|
|
for e in g.edges_by_entity_name.get(ent_name, []):
|
|
eid = id(e)
|
|
if eid not in seen_edge_ids:
|
|
seen_edge_ids.add(eid)
|
|
unique_refs.append(e)
|
|
|
|
for defn in definitions:
|
|
print(f"(entity-def {defn.kind} {defn.module} {defn.name} {defn.line} :lang {g.module_lang(defn.module)} :file {_q(g.module_to_file(defn.module))})")
|
|
for e in sorted(unique_refs, key=lambda x: (x.etype, x.source)):
|
|
print(_edge_sexp(e))
|
|
|
|
|
|
def cmd_flow(g: BeliefGraph, module_id: str, entity_name: str) -> None:
|
|
matches = g.resolve_module(module_id)
|
|
if not matches:
|
|
_print_no_match(g, module_id)
|
|
return
|
|
|
|
start_mod = matches[0]
|
|
full_id = f"{start_mod}::{entity_name}"
|
|
|
|
visited: set[str] = set()
|
|
_trace_flow(g, full_id, visited, 0)
|
|
|
|
|
|
def _trace_flow(
|
|
g: BeliefGraph, entity_id: str, visited: set[str], depth: int,
|
|
) -> None:
|
|
if entity_id in visited or depth > 5:
|
|
return
|
|
visited.add(entity_id)
|
|
|
|
# Entity-level edges via index
|
|
for edge in g.edges_from_entity.get(entity_id, []):
|
|
tgt_lang = g.module_lang(edge.target.split("::")[0])
|
|
print(f"(flow {entity_id} {edge.target} :lang {tgt_lang} :via {edge.etype} :depth {depth})")
|
|
_trace_flow(g, edge.target, visited, depth + 1)
|
|
|
|
# Module-level edges for cross-module flow
|
|
mod = entity_id.split("::")[0] if "::" in entity_id else entity_id
|
|
if "::" in entity_id:
|
|
for edge in g.edges_from.get(mod, []):
|
|
if "::" in edge.source:
|
|
continue
|
|
tgt_mod = edge.target.split("::")[0]
|
|
if tgt_mod not in visited:
|
|
print(f"(flow {entity_id} {edge.target} :lang {g.module_lang(tgt_mod)} :via {edge.etype} :depth {depth} :module-level)")
|
|
_trace_flow(g, edge.target, visited, depth + 1)
|
|
|
|
|
|
def cmd_boundary(g: BeliefGraph, module_id: str, files_only: bool = False) -> None:
|
|
"""Output the minimal set of files for a change boundary.
|
|
|
|
When files_only is True, emit one bare file path per line instead of sexp.
|
|
"""
|
|
matches = g.resolve_module(module_id)
|
|
if not matches:
|
|
_print_no_match(g, module_id)
|
|
return
|
|
|
|
nid = matches[0]
|
|
node = g.nodes.get(nid)
|
|
if not node:
|
|
print(f"(error no-node {_q(nid)})")
|
|
return
|
|
|
|
target_file = g.module_to_file(nid)
|
|
import_mods = _collect_dep_mods(g, nid)
|
|
dependent_mods = _collect_rdep_mods(g, nid)
|
|
ents = g.entities.get(nid, [])
|
|
|
|
if files_only:
|
|
# Compact output: one file path per line, no sexp wrapping.
|
|
seen: set[str] = set()
|
|
for path in [target_file] + [g.module_to_file(m) for m in sorted(import_mods)] + [g.module_to_file(m) for m in sorted(dependent_mods)]:
|
|
if path not in seen:
|
|
seen.add(path)
|
|
print(path)
|
|
return
|
|
|
|
lang = g.module_lang(nid)
|
|
print(f"(boundary {nid} :lang {lang} :file {_q(target_file)} :purpose {_q(node.purpose)})")
|
|
for e in sorted(ents, key=lambda x: x.line):
|
|
print(_entity_sexp(e, lang))
|
|
for mod, etype in sorted(import_mods.items()):
|
|
print(f"(boundary-dep {nid} {mod} :lang {g.module_lang(mod)} :file {_q(g.module_to_file(mod))} :relation {etype})")
|
|
for mod, etype in sorted(dependent_mods.items()):
|
|
print(f"(boundary-rdep {nid} {mod} :lang {g.module_lang(mod)} :file {_q(g.module_to_file(mod))} :relation {etype})")
|
|
print(f"(boundary-summary {nid} :total {1 + len(import_mods) + len(dependent_mods)} :deps {len(import_mods)} :rdeps {len(dependent_mods)})")
|
|
|
|
|
|
def cmd_analyze(g: BeliefGraph, module_id: str) -> None:
|
|
"""Full architectural analysis of a module."""
|
|
matches = g.resolve_module(module_id)
|
|
if not matches:
|
|
_print_no_match(g, module_id)
|
|
return
|
|
|
|
nid = matches[0]
|
|
node = g.nodes.get(nid)
|
|
if not node:
|
|
print(f"(error no-node {_q(nid)})")
|
|
return
|
|
|
|
target_file = g.module_to_file(nid)
|
|
ents = g.entities.get(nid, [])
|
|
out_edges = g.edges_from.get(nid, [])
|
|
in_edges = g.edges_to.get(nid, [])
|
|
|
|
# Classify outgoing edges
|
|
import_targets: list[str] = []
|
|
ref_targets: list[tuple[str, str]] = []
|
|
data_flow_targets: list[str] = []
|
|
call_targets: list[tuple[str, str]] = []
|
|
for e in out_edges:
|
|
tgt_mod = e.target.split("::")[0]
|
|
if e.etype == "imports" and tgt_mod not in import_targets:
|
|
import_targets.append(tgt_mod)
|
|
elif e.etype == "refs":
|
|
ref_targets.append((e.source, e.target))
|
|
elif e.etype == "data-flow" and tgt_mod not in data_flow_targets:
|
|
data_flow_targets.append(tgt_mod)
|
|
elif e.etype == "calls":
|
|
call_targets.append((e.source, e.target))
|
|
|
|
# Classify incoming edges
|
|
importers: set[str] = set()
|
|
referencers: set[str] = set()
|
|
for e in in_edges:
|
|
src_mod = e.source.split("::")[0]
|
|
if src_mod == nid:
|
|
continue
|
|
if e.etype == "imports":
|
|
importers.add(src_mod)
|
|
else:
|
|
referencers.add(src_mod)
|
|
|
|
# Key entities cross-repo usage (indexed)
|
|
exported_types = [e for e in ents if e.kind in ("cls", "ifc", "typ") and not e.name.startswith("_")]
|
|
key_entity_usage: list[tuple[EntityFact, int, set[str]]] = []
|
|
for et in exported_types:
|
|
edges_for_name = g.edges_by_entity_name.get(et.name, [])
|
|
ref_mods: set[str] = set()
|
|
ref_count = 0
|
|
for e in edges_for_name:
|
|
src_mod = e.source.split("::")[0]
|
|
if src_mod != nid:
|
|
ref_count += 1
|
|
ref_mods.add(src_mod)
|
|
if ref_count > 0:
|
|
key_entity_usage.append((et, ref_count, ref_mods))
|
|
|
|
# Boundary files
|
|
boundary_files: list[str] = [target_file]
|
|
for t in sorted(import_targets):
|
|
f = g.module_to_file(t)
|
|
if f not in boundary_files:
|
|
boundary_files.append(f)
|
|
for src in sorted(importers | referencers):
|
|
f = g.module_to_file(src)
|
|
if f not in boundary_files:
|
|
boundary_files.append(f)
|
|
|
|
_analyze_sexp(g, nid, node, target_file, ents, import_targets,
|
|
ref_targets, data_flow_targets, call_targets,
|
|
importers, referencers, key_entity_usage,
|
|
boundary_files)
|
|
|
|
|
|
def _analyze_sexp(
|
|
g: BeliefGraph, nid: str, node: Node, target_file: str,
|
|
ents: list[EntityFact],
|
|
import_targets: list[str], ref_targets: list[tuple[str, str]],
|
|
data_flow_targets: list[str], call_targets: list[tuple[str, str]],
|
|
importers: set[str], referencers: set[str],
|
|
key_entity_usage: list[tuple[EntityFact, int, set[str]]],
|
|
boundary_files: list[str],
|
|
) -> None:
|
|
# Module header
|
|
lang = g.module_lang(nid)
|
|
print(f"(analyze {nid} :lang {lang} {_q(node.purpose)} :file {_q(target_file)})")
|
|
|
|
# Entities
|
|
for e in sorted(ents, key=lambda x: x.line):
|
|
print(_entity_sexp(e, lang))
|
|
|
|
# Imports
|
|
for t in sorted(import_targets):
|
|
print(f"(analyze-import {nid} {t} :lang {g.module_lang(t)} :file {_q(g.module_to_file(t))})")
|
|
|
|
# Data flows
|
|
for t in sorted(data_flow_targets):
|
|
print(f"(analyze-dataflow {nid} {t} :lang {g.module_lang(t)} :validated)")
|
|
|
|
# Entity refs
|
|
for src, tgt in sorted(ref_targets):
|
|
print(f"(refs {src} {tgt})")
|
|
|
|
# Call edges
|
|
for src, tgt in sorted(call_targets):
|
|
print(f"(calls {src} {tgt})")
|
|
|
|
# Dependents
|
|
all_dependents = importers | referencers
|
|
for src in sorted(all_dependents):
|
|
kinds = []
|
|
if src in importers:
|
|
kinds.append("imports")
|
|
if src in referencers:
|
|
kinds.append("refs")
|
|
print(f"(analyze-dependent {nid} {src} :lang {g.module_lang(src)} :via {' '.join(kinds)} :file {_q(g.module_to_file(src))})")
|
|
|
|
# Dependency tree (depth 2)
|
|
visited: set[str] = {nid}
|
|
_walk_deps(g, nid, visited, 2, 1,
|
|
lambda d, et, tgt, cyc: print(f"(dep {nid} {tgt} :lang {g.module_lang(tgt)} :via {et} :depth {d}{' :cycle' if cyc else ''})"))
|
|
|
|
# Reverse deps (depth 1)
|
|
visited_r: set[str] = {nid}
|
|
_walk_rdeps(g, nid, visited_r, 1, 1,
|
|
lambda d, et, src, cyc: print(f"(rdep {nid} {src} :lang {g.module_lang(src)} :via {et} :depth {d}{' :cycle' if cyc else ''})"))
|
|
|
|
# Key entities
|
|
for et, ref_count, ref_mods in key_entity_usage:
|
|
print(f"(key-entity {nid} {et.name} :lang {lang} :kind {et.kind} :line {et.line} :refs {ref_count} :modules {len(ref_mods)})")
|
|
for rm in sorted(ref_mods):
|
|
print(f"(key-entity-ref {et.name} {rm} :lang {g.module_lang(rm)})")
|
|
|
|
# Boundary files
|
|
for f in boundary_files:
|
|
print(f"(boundary-file {nid} {_q(f)})")
|
|
|
|
# Architecture layers
|
|
for t in sorted(import_targets):
|
|
layer = _classify_layer(t, g.module_purpose(t))
|
|
print(f"(layer {nid} {t.split('/')[-1]} :layer {layer})")
|
|
|
|
# Summary
|
|
print(f"(analyze-summary {nid} :entities {len(ents)} :imports {len(import_targets)} :dataflows {len(data_flow_targets)} :refs {len(ref_targets)} :dependents {len(all_dependents)} :boundary-files {len(boundary_files)})")
|
|
|
|
|
|
def cmd_quick(g: BeliefGraph, keyword: str, sexp_path: str) -> None:
|
|
"""Search for keyword then run analyze for the first match.
|
|
|
|
Resolution order:
|
|
1. resolve_module (exact/suffix/substring/case-insensitive)
|
|
2. Regex search across the raw sexp file, extract module IDs from matches
|
|
|
|
Runs cmd_analyze on the first unique module found.
|
|
"""
|
|
matches = g.resolve_module(keyword)
|
|
if matches:
|
|
cmd_analyze(g, matches[0])
|
|
return
|
|
|
|
# Fallback: grep the sexp file for the keyword and extract module IDs from lines
|
|
regex = re.compile(re.escape(keyword), re.IGNORECASE)
|
|
seen_modules: list[str] = []
|
|
seen_set: set[str] = set()
|
|
with open(sexp_path, "r") as f:
|
|
for line in f:
|
|
if not regex.search(line):
|
|
continue
|
|
tokens = tokenize(line)
|
|
if not tokens:
|
|
continue
|
|
# Heuristically grab module-like tokens and resolve legacy path-map aliases.
|
|
for tok in tokens[1:3]:
|
|
resolved = g.resolve_alias(tok)
|
|
if resolved in g.nodes and resolved not in seen_set:
|
|
seen_set.add(resolved)
|
|
seen_modules.append(resolved)
|
|
break
|
|
|
|
if not seen_modules:
|
|
_print_no_match(g, keyword)
|
|
return
|
|
|
|
cmd_analyze(g, seen_modules[0])
|
|
|
|
|
|
def cmd_search(_g: BeliefGraph, pattern: str, sexp_path: str) -> None:
|
|
"""Search module IDs first, then fall back to raw fact grep."""
|
|
module_matches = _g.search_modules(pattern)
|
|
if module_matches:
|
|
for nid in module_matches:
|
|
print(f"(result {nid})")
|
|
print(f"(result-count {len(module_matches)})")
|
|
return
|
|
|
|
regex = re.compile(pattern, re.IGNORECASE)
|
|
with open(sexp_path, "r") as f:
|
|
for line in f:
|
|
if regex.search(line):
|
|
print(line.rstrip())
|
|
|
|
|
|
def cmd_boundaries(g: BeliefGraph, module_id: str) -> None:
|
|
"""Check boundary violations inferred from architecture layer patterns.
|
|
|
|
Uses path-based layer classification and well-known architecture rules:
|
|
- domain must not import infra/api/ui
|
|
- ui must not import infra
|
|
- service must not import api
|
|
- infra must not import api
|
|
"""
|
|
# Well-known deny rules (src_layer, tgt_layer, reason)
|
|
deny_rules = [
|
|
("domain", "infra", "domain imports infrastructure directly"),
|
|
("domain", "api", "domain imports API layer"),
|
|
("domain", "ui", "domain imports UI layer"),
|
|
("ui", "infra", "UI imports infrastructure directly"),
|
|
("service","api", "service imports API layer (inversion)"),
|
|
("infra", "api", "infrastructure imports API layer"),
|
|
]
|
|
|
|
if module_id == "all":
|
|
scope_mods = set(g.nodes.keys())
|
|
else:
|
|
matches = g.resolve_module(module_id)
|
|
if not matches:
|
|
_print_no_match(g, module_id)
|
|
return
|
|
scope_mods = set(matches)
|
|
|
|
violations: list[tuple[str, str, str, str, str, str]] = []
|
|
|
|
for src_mod in sorted(scope_mods):
|
|
src_layer = _classify_layer(src_mod, g.module_purpose(src_mod))
|
|
for edge in g.edges_from.get(src_mod, []):
|
|
if edge.etype not in ("imports", "data-flow"):
|
|
continue
|
|
tgt_mod = edge.target.split("::")[0]
|
|
if tgt_mod == src_mod:
|
|
continue
|
|
tgt_layer = _classify_layer(tgt_mod, g.module_purpose(tgt_mod))
|
|
if src_layer == tgt_layer:
|
|
continue
|
|
for deny_src, deny_tgt, reason in deny_rules:
|
|
if src_layer == deny_src and tgt_layer == deny_tgt:
|
|
violations.append((src_mod, tgt_mod, src_layer, tgt_layer, edge.etype, reason))
|
|
break
|
|
|
|
if not violations:
|
|
print(f"(boundaries-ok :checked {len(scope_mods)})")
|
|
return
|
|
for src, tgt, sl, tl, etype, reason in sorted(violations):
|
|
src_file = g.module_to_file(src)
|
|
tgt_file = g.module_to_file(tgt)
|
|
print(f"(violation {src} {tgt} :src-layer {sl} :tgt-layer {tl} :via {etype} :lang {g.module_lang(src)} :src-file {_q(src_file)} :tgt-file {_q(tgt_file)} {_q(reason)})")
|
|
print(f"(boundaries-summary :violations {len(violations)} :checked {len(scope_mods)})")
|
|
|
|
|
|
def cmd_layers(g: BeliefGraph) -> None:
|
|
"""Show layer classification for all modules, grouped by layer."""
|
|
by_layer: dict[str, list[str]] = {}
|
|
for nid in sorted(g.nodes.keys()):
|
|
layer = _classify_layer(nid, g.module_purpose(nid))
|
|
by_layer.setdefault(layer, []).append(nid)
|
|
|
|
for layer_name in sorted(by_layer.keys()):
|
|
mods = by_layer[layer_name]
|
|
print(f"(layer-group {layer_name} :count {len(mods)})")
|
|
for m in mods:
|
|
print(f"(layer-member {layer_name} {m} :lang {g.module_lang(m)})")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Naming convention invariant checker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Allowed naming conventions per language. A module whose :naming value is
|
|
# NOT in the allowed set is a violation. Languages absent from this map have
|
|
# no strict rule and are always considered conforming.
|
|
_NAMING_RULES: dict[str, set[str]] = {
|
|
"py": {"snake_case", "mixed", "kebab-case"},
|
|
"tsx": {"PascalCase", "camelCase", "kebab-case", "mixed"},
|
|
"ts": {"camelCase", "PascalCase", "kebab-case", "mixed", "snake_case"},
|
|
}
|
|
|
|
|
|
def cmd_invariants(g: BeliefGraph, scope: str) -> None:
|
|
"""Check naming-convention violations across modules.
|
|
|
|
For each module that has a :naming annotation, validate the value against
|
|
the allowed set for its language. Modules without a :naming annotation
|
|
are silently skipped (no data, no opinion).
|
|
|
|
Output format::
|
|
|
|
(invariant-violation <mod> :expected snake_case :actual PascalCase :lang py :pkg <pkg>)
|
|
(invariants-summary :violations 12 :checked 3587)
|
|
(invariants-ok :checked 3587)
|
|
"""
|
|
if scope == "all":
|
|
candidates = list(g.nodes.values())
|
|
else:
|
|
matches = g.resolve_module(scope)
|
|
if not matches:
|
|
_print_no_match(g, scope)
|
|
return
|
|
candidates = [g.nodes[nid] for nid in matches if nid in g.nodes]
|
|
|
|
checked = 0
|
|
violations: list[tuple[str, str, str, str, str]] = [] # (nid, expected, actual, lang, pkg)
|
|
|
|
for node in candidates:
|
|
if not node.naming:
|
|
continue
|
|
checked += 1
|
|
lang = {"ty": "ts", "tx": "tsx"}.get(node.lang, node.lang)
|
|
allowed = _NAMING_RULES.get(lang)
|
|
if allowed is None:
|
|
# No rule defined for this language -- always conforming.
|
|
continue
|
|
if node.naming not in allowed:
|
|
expected_repr = " ".join(sorted(allowed))
|
|
violations.append((node.nid, expected_repr, node.naming, lang, node.pkg))
|
|
|
|
if not violations:
|
|
print(f"(invariants-ok :checked {checked})")
|
|
return
|
|
|
|
for nid, expected, actual, lang, pkg in sorted(violations):
|
|
pkg_part = f" :pkg {_q(pkg)}" if pkg else ""
|
|
print(
|
|
f"(invariant-violation {nid}"
|
|
f" :expected {_q(expected)}"
|
|
f" :actual {actual}"
|
|
f" :lang {lang}"
|
|
f"{pkg_part})"
|
|
)
|
|
print(f"(invariants-summary :violations {len(violations)} :checked {checked})")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Function / type search and call graph commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_find_function(g: BeliefGraph, name: str) -> None:
|
|
"""Find function/method definitions matching a name pattern.
|
|
|
|
Searches fn entities and cls methods. Exact match first, then regex.
|
|
"""
|
|
pattern = re.compile(name, re.IGNORECASE)
|
|
results: list[tuple[EntityFact, str]] = []
|
|
|
|
for ent_name, ent_list in g.entity_by_name.items():
|
|
for ent in ent_list:
|
|
if ent.kind == "fn":
|
|
if ent_name == name or pattern.search(ent_name):
|
|
results.append((ent, ""))
|
|
|
|
# Also search methods inside classes
|
|
for _mod, ent_list in g.entities.items():
|
|
for ent in ent_list:
|
|
if ent.kind == "cls" and ent.methods:
|
|
for method in ent.methods:
|
|
if method == name or pattern.search(method):
|
|
results.append((ent, method))
|
|
|
|
if not results:
|
|
_print_no_match(g, name)
|
|
return
|
|
|
|
seen: set[str] = set()
|
|
for ent, method in sorted(results, key=lambda x: (x[0].module, x[0].line)):
|
|
lang = g.module_lang(ent.module)
|
|
fpath = g.module_to_file(ent.module)
|
|
if method:
|
|
key = f"{ent.module}::{ent.name}.{method}"
|
|
if key not in seen:
|
|
seen.add(key)
|
|
print(f"(fn-def {ent.module}::{ent.name}.{method} :line {ent.line} :lang {lang} :file {_q(fpath)} :kind method)")
|
|
else:
|
|
key = f"{ent.module}::{ent.name}"
|
|
if key not in seen:
|
|
seen.add(key)
|
|
print(f"(fn-def {ent.module}::{ent.name} :line {ent.line} :lang {lang} :file {_q(fpath)} :kind function)")
|
|
|
|
|
|
def cmd_find_type(g: BeliefGraph, name: str) -> None:
|
|
"""Find type/class/interface/enum definitions matching a name pattern.
|
|
|
|
Searches cls, ifc, typ, enm entities.
|
|
"""
|
|
pattern = re.compile(name, re.IGNORECASE)
|
|
results: list[EntityFact] = []
|
|
|
|
for ent_name, ent_list in g.entity_by_name.items():
|
|
for ent in ent_list:
|
|
if ent.kind in ("cls", "ifc", "typ", "enm"):
|
|
if ent_name == name or pattern.search(ent_name):
|
|
results.append(ent)
|
|
|
|
if not results:
|
|
_print_no_match(g, name)
|
|
return
|
|
|
|
for ent in sorted(results, key=lambda x: (x.module, x.line)):
|
|
lang = g.module_lang(ent.module)
|
|
fpath = g.module_to_file(ent.module)
|
|
extras = ""
|
|
if ent.bases:
|
|
extras += " " + _sexp_list("bases", ent.bases)
|
|
if ent.methods:
|
|
extras += " " + _sexp_list("methods", ent.methods)
|
|
print(f"(type-def {ent.kind} {ent.module}::{ent.name} :line {ent.line} :lang {lang} :file {_q(fpath)}{extras})")
|
|
|
|
|
|
def _resolve_fn_entity(g: BeliefGraph, fn_name: str) -> list[tuple[str, EntityFact]]:
|
|
"""Resolve a function name to (qualified_id, entity) pairs.
|
|
|
|
Returns entity-qualified IDs like 'module::fnName'. Searches both
|
|
standalone functions and class methods.
|
|
"""
|
|
pattern = re.compile(re.escape(fn_name), re.IGNORECASE)
|
|
results: list[tuple[str, EntityFact]] = []
|
|
|
|
for ent_name, ent_list in g.entity_by_name.items():
|
|
for ent in ent_list:
|
|
if ent.kind == "fn" and (ent_name == fn_name or pattern.search(ent_name)):
|
|
results.append((f"{ent.module}::{ent_name}", ent))
|
|
|
|
# Also search class methods
|
|
for _mod, ent_list in g.entities.items():
|
|
for ent in ent_list:
|
|
if ent.kind == "cls" and ent.methods:
|
|
for method in ent.methods:
|
|
if method == fn_name or pattern.search(method):
|
|
results.append((f"{ent.module}::{method}", ent))
|
|
|
|
return results
|
|
|
|
|
|
def cmd_find_callers(g: BeliefGraph, fn_name: str, max_depth: int) -> None:
|
|
"""Find all callers of a function (incoming call graph).
|
|
|
|
Uses 'calls' edges from the belief map. Falls back to refs edges
|
|
when calls edges are unavailable.
|
|
"""
|
|
targets = _resolve_fn_entity(g, fn_name)
|
|
if not targets:
|
|
_print_no_match(g, fn_name)
|
|
return
|
|
|
|
target_mods: set[str] = set()
|
|
target_qualified: set[str] = set()
|
|
for qid, ent in targets:
|
|
target_mods.add(ent.module)
|
|
target_qualified.add(qid)
|
|
lang = g.module_lang(ent.module)
|
|
fpath = g.module_to_file(ent.module)
|
|
print(f"(callers-target {qid} :line {ent.line} :lang {lang} :file {_q(fpath)})")
|
|
|
|
# Collect direct callers via calls edges
|
|
direct_callers: list[Edge] = []
|
|
for e in g.all_edges:
|
|
if e.etype == "calls":
|
|
tgt_fn = e.target.split("::")[-1] if "::" in e.target else ""
|
|
if e.target in target_qualified or tgt_fn == fn_name:
|
|
direct_callers.append(e)
|
|
|
|
if direct_callers:
|
|
for e in sorted(direct_callers, key=lambda x: (x.source, x.target)):
|
|
src_mod = e.source.split("::")[0]
|
|
print(f"(caller {e.source} -> {e.target} :lang {g.module_lang(src_mod)} :file {_q(g.module_to_file(src_mod))} {' '.join(e.flags)})")
|
|
else:
|
|
# Fallback: use module-level edges (imports/refs) pointing at target modules
|
|
print("; no calls edges found, showing module-level callers via imports/refs")
|
|
|
|
# Walk reverse deps to given depth
|
|
for tgt_mod in target_mods:
|
|
visited: set[str] = {tgt_mod}
|
|
_walk_rdeps(g, tgt_mod, visited, max_depth, 1,
|
|
lambda d, et, src, cyc: print(
|
|
f"(caller-module {src} -> {tgt_mod} :via {et} :depth {d} "
|
|
f":lang {g.module_lang(src)} :file {_q(g.module_to_file(src))}"
|
|
f"{' :cycle' if cyc else ''})"))
|
|
|
|
|
|
def cmd_find_calls(g: BeliefGraph, fn_name: str, max_depth: int) -> None:
|
|
"""Find all functions called by a given function (outgoing call graph).
|
|
|
|
Uses 'calls' edges from the belief map.
|
|
"""
|
|
sources = _resolve_fn_entity(g, fn_name)
|
|
if not sources:
|
|
_print_no_match(g, fn_name)
|
|
return
|
|
|
|
source_mods: set[str] = set()
|
|
source_qualified: set[str] = set()
|
|
for qid, ent in sources:
|
|
source_mods.add(ent.module)
|
|
source_qualified.add(qid)
|
|
lang = g.module_lang(ent.module)
|
|
fpath = g.module_to_file(ent.module)
|
|
print(f"(calls-source {qid} :line {ent.line} :lang {lang} :file {_q(fpath)})")
|
|
|
|
# Collect direct calls via calls edges
|
|
direct_calls: list[Edge] = []
|
|
for e in g.all_edges:
|
|
if e.etype == "calls":
|
|
src_fn = e.source.split("::")[-1] if "::" in e.source else ""
|
|
if e.source in source_qualified or src_fn == fn_name:
|
|
direct_calls.append(e)
|
|
|
|
if direct_calls:
|
|
for e in sorted(direct_calls, key=lambda x: (x.source, x.target)):
|
|
tgt_mod = e.target.split("::")[0]
|
|
print(f"(calls-target {e.source} -> {e.target} :lang {g.module_lang(tgt_mod)} :file {_q(g.module_to_file(tgt_mod))} {' '.join(e.flags)})")
|
|
else:
|
|
print("; no calls edges found, showing module-level deps via imports")
|
|
|
|
# Walk forward deps to given depth
|
|
for src_mod in source_mods:
|
|
visited: set[str] = {src_mod}
|
|
_walk_deps(g, src_mod, visited, max_depth, 1,
|
|
lambda d, et, tgt, cyc: print(
|
|
f"(calls-module {src_mod} -> {tgt} :via {et} :depth {d} "
|
|
f":lang {g.module_lang(tgt)} :file {_q(g.module_to_file(tgt))}"
|
|
f"{' :cycle' if cyc else ''})"))
|
|
|
|
|
|
def cmd_find_callchain(g: BeliefGraph, src_fn: str, tgt_fn: str, max_depth: int) -> None:
|
|
"""Trace a call path between two functions via BFS on calls edges.
|
|
|
|
Falls back to module-level path if no function-level calls edges exist.
|
|
"""
|
|
src_entities = _resolve_fn_entity(g, src_fn)
|
|
tgt_entities = _resolve_fn_entity(g, tgt_fn)
|
|
|
|
if not src_entities:
|
|
print(f"(error no-match-source {_q(src_fn)})")
|
|
return
|
|
if not tgt_entities:
|
|
print(f"(error no-match-target {_q(tgt_fn)})")
|
|
return
|
|
|
|
src_ids = {qid for qid, _ in src_entities}
|
|
tgt_ids = {qid for qid, _ in tgt_entities}
|
|
tgt_fn_names = {qid.split("::")[-1] for qid in tgt_ids}
|
|
|
|
# Build adjacency from calls edges
|
|
call_adj: dict[str, list[str]] = {}
|
|
for e in g.all_edges:
|
|
if e.etype == "calls":
|
|
call_adj.setdefault(e.source, []).append(e.target)
|
|
|
|
# BFS from source to target
|
|
from collections import deque
|
|
|
|
found_path: list[str] = []
|
|
for start in src_ids:
|
|
queue: deque[list[str]] = deque([[start]])
|
|
visited: set[str] = {start}
|
|
while queue:
|
|
path = queue.popleft()
|
|
if len(path) - 1 > max_depth:
|
|
break
|
|
current = path[-1]
|
|
cur_fn = current.split("::")[-1] if "::" in current else ""
|
|
if current in tgt_ids or cur_fn in tgt_fn_names:
|
|
found_path = path
|
|
break
|
|
for nxt in call_adj.get(current, []):
|
|
if nxt not in visited:
|
|
visited.add(nxt)
|
|
queue.append(path + [nxt])
|
|
if found_path:
|
|
break
|
|
|
|
if found_path:
|
|
for i, node in enumerate(found_path):
|
|
mod = node.split("::")[0]
|
|
lang = g.module_lang(mod)
|
|
fpath = g.module_to_file(mod)
|
|
print(f"(callchain-step {i} {node} :lang {lang} :file {_q(fpath)})")
|
|
print(f"(callchain-summary :length {len(found_path)} :hops {len(found_path) - 1})")
|
|
return
|
|
|
|
# Fallback: module-level BFS using both forward deps AND reverse deps
|
|
src_mods = {ent.module for _, ent in src_entities}
|
|
tgt_mods = {ent.module for _, ent in tgt_entities}
|
|
|
|
print("; no function-level call path found, trying module-level path")
|
|
|
|
# Build module adjacency (forward deps + shared entity refs for better connectivity)
|
|
mod_adj: dict[str, set[str]] = {}
|
|
for mod in g.nodes:
|
|
neighbors = set(_collect_dep_mods(g, mod).keys())
|
|
# Also add reverse deps (bidirectional search for reachability)
|
|
neighbors.update(_collect_rdep_mods(g, mod).keys())
|
|
mod_adj[mod] = neighbors
|
|
|
|
for start_mod in src_mods:
|
|
queue_m: deque[list[str]] = deque([[start_mod]])
|
|
visited_m: set[str] = {start_mod}
|
|
mod_path: list[str] = []
|
|
while queue_m:
|
|
path_m = queue_m.popleft()
|
|
if len(path_m) - 1 > max_depth:
|
|
break
|
|
current_m = path_m[-1]
|
|
if current_m in tgt_mods:
|
|
mod_path = path_m
|
|
break
|
|
for nxt_m in sorted(mod_adj.get(current_m, set())):
|
|
if nxt_m not in visited_m:
|
|
visited_m.add(nxt_m)
|
|
queue_m.append(path_m + [nxt_m])
|
|
if mod_path:
|
|
for i, mod in enumerate(mod_path):
|
|
lang = g.module_lang(mod)
|
|
fpath = g.module_to_file(mod)
|
|
print(f"(callchain-module-step {i} {mod} :lang {lang} :file {_q(fpath)})")
|
|
print(f"(callchain-module-summary :length {len(mod_path)} :hops {len(mod_path) - 1})")
|
|
return
|
|
|
|
print(f"(callchain-none {_q(src_fn)} -> {_q(tgt_fn)} :max-depth {max_depth})")
|
|
|
|
|
|
def cmd_grep_functions(g: BeliefGraph, pattern: str) -> None:
|
|
"""Search function bodies in source files for a regex pattern.
|
|
|
|
For each function in the belief map, reads the source file and searches
|
|
the function body (from definition line to next entity or EOF).
|
|
"""
|
|
regex = re.compile(pattern, re.IGNORECASE)
|
|
results: list[tuple[str, str, int, str]] = [] # (module::fn, file, match_line, match_text)
|
|
|
|
# Group entities by file for efficient reading
|
|
file_entities: dict[str, list[tuple[str, EntityFact]]] = {}
|
|
for mod, ent_list in g.entities.items():
|
|
fpath = g.module_to_file(mod)
|
|
if fpath == mod:
|
|
continue # file not found on disk
|
|
for ent in ent_list:
|
|
if ent.kind == "fn":
|
|
file_entities.setdefault(fpath, []).append((mod, ent))
|
|
|
|
for fpath, fn_list in sorted(file_entities.items()):
|
|
if not os.path.isfile(fpath):
|
|
continue
|
|
try:
|
|
with open(fpath, "r", errors="replace") as f:
|
|
lines = f.readlines()
|
|
except OSError:
|
|
continue
|
|
|
|
# Sort functions by line number to determine body ranges
|
|
sorted_fns = sorted(fn_list, key=lambda x: x[1].line)
|
|
|
|
for idx, (mod, ent) in enumerate(sorted_fns):
|
|
start = ent.line - 1 # 0-indexed
|
|
if start < 0 or start >= len(lines):
|
|
continue
|
|
# End at next entity's line or +100 lines, whichever is smaller
|
|
if idx + 1 < len(sorted_fns):
|
|
end = min(sorted_fns[idx + 1][1].line - 1, start + 100, len(lines))
|
|
else:
|
|
end = min(start + 100, len(lines))
|
|
|
|
for line_idx in range(start, end):
|
|
line = lines[line_idx]
|
|
if regex.search(line):
|
|
results.append((
|
|
f"{mod}::{ent.name}",
|
|
fpath,
|
|
line_idx + 1,
|
|
line.rstrip(),
|
|
))
|
|
|
|
if not results:
|
|
print(f"(grep-functions-empty {_q(pattern)})")
|
|
return
|
|
|
|
for qid, fpath, line_num, text in results:
|
|
lang = g.module_lang(qid.split("::")[0])
|
|
print(f"(grep-fn {qid} :line {line_num} :lang {lang} :file {_q(fpath)} :match {_q(text)})")
|
|
print(f"(grep-functions-summary :matches {len(results)} :pattern {_q(pattern)})")
|
|
|
|
|
|
def cmd_diff_functions(g: BeliefGraph, ref: str) -> None:
|
|
"""Identify which functions were changed in a git diff.
|
|
|
|
Parses 'git diff <ref>' output to find changed line ranges,
|
|
then maps those ranges to function definitions in the belief map.
|
|
"""
|
|
import subprocess
|
|
|
|
# Use the belief map root as git working directory (not cwd)
|
|
git_cwd = g._root or None
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "diff", "--unified=0", ref],
|
|
capture_output=True, text=True, timeout=30, cwd=git_cwd,
|
|
)
|
|
if result.returncode != 0:
|
|
# Try as a commit ref
|
|
result = subprocess.run(
|
|
["git", "diff", "--unified=0", ref, "HEAD"],
|
|
capture_output=True, text=True, timeout=30, cwd=git_cwd,
|
|
)
|
|
if result.returncode != 0:
|
|
print(f"(error git-diff-failed {_q(result.stderr.strip())})")
|
|
return
|
|
except FileNotFoundError:
|
|
print("(error git-not-found)")
|
|
return
|
|
except subprocess.TimeoutExpired:
|
|
print("(error git-diff-timeout)")
|
|
return
|
|
|
|
# Parse diff output to extract file -> changed line ranges
|
|
changed_ranges: dict[str, list[tuple[int, int]]] = {} # file -> [(start, end)]
|
|
current_file = ""
|
|
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith("+++ b/"):
|
|
current_file = line[6:]
|
|
elif line.startswith("@@ ") and current_file:
|
|
# Parse hunk header: @@ -old,count +new,count @@
|
|
parts = line.split(" ")
|
|
for part in parts:
|
|
if part.startswith("+") and "," in part:
|
|
nums = part[1:].split(",")
|
|
start = int(nums[0])
|
|
count = int(nums[1]) if len(nums) > 1 else 1
|
|
if count > 0:
|
|
changed_ranges.setdefault(current_file, []).append(
|
|
(start, start + count - 1)
|
|
)
|
|
elif part.startswith("+") and part[1:].isdigit():
|
|
start = int(part[1:])
|
|
changed_ranges.setdefault(current_file, []).append(
|
|
(start, start)
|
|
)
|
|
|
|
if not changed_ranges:
|
|
print(f"(diff-functions-empty {_q(ref)})")
|
|
return
|
|
|
|
# Map changed lines to functions
|
|
# Build file -> sorted entities index
|
|
file_entities: dict[str, list[tuple[str, EntityFact]]] = {}
|
|
for mod, ent_list in g.entities.items():
|
|
fpath = g.module_to_file(mod)
|
|
for ent in ent_list:
|
|
file_entities.setdefault(fpath, []).append((mod, ent))
|
|
|
|
# Normalize paths for matching (diff paths are relative to repo root)
|
|
repo_root = g._root
|
|
results: list[tuple[str, EntityFact, str]] = [] # (qualified_id, entity, file)
|
|
|
|
for diff_file, ranges in changed_ranges.items():
|
|
# Try matching against belief map files
|
|
abs_path = os.path.join(repo_root, diff_file)
|
|
# Also try relative path
|
|
candidates = [abs_path, diff_file]
|
|
|
|
matched_ents: list[tuple[str, EntityFact]] = []
|
|
for candidate in candidates:
|
|
if candidate in file_entities:
|
|
matched_ents = file_entities[candidate]
|
|
break
|
|
|
|
if not matched_ents:
|
|
# Try suffix matching
|
|
for fpath, ent_list in file_entities.items():
|
|
if fpath.endswith(diff_file) or diff_file.endswith(fpath.lstrip("./")):
|
|
matched_ents = ent_list
|
|
break
|
|
|
|
if not matched_ents:
|
|
continue
|
|
|
|
# Sort entities by line to determine body ranges
|
|
sorted_ents = sorted(matched_ents, key=lambda x: x[1].line)
|
|
|
|
for start, end in ranges:
|
|
for idx, (mod, ent) in enumerate(sorted_ents):
|
|
ent_start = ent.line
|
|
if idx + 1 < len(sorted_ents):
|
|
ent_end = sorted_ents[idx + 1][1].line - 1
|
|
else:
|
|
ent_end = ent_start + 200 # approximate
|
|
|
|
# Check overlap
|
|
if start <= ent_end and end >= ent_start:
|
|
qid = f"{mod}::{ent.name}"
|
|
if not any(r[0] == qid for r in results):
|
|
results.append((qid, ent, diff_file))
|
|
|
|
if not results:
|
|
# Report changed files even if no function mapping
|
|
for diff_file in sorted(changed_ranges.keys()):
|
|
print(f"(diff-file {_q(diff_file)} :hunks {len(changed_ranges[diff_file])})")
|
|
print(f"(diff-functions-no-match :files {len(changed_ranges)} :ref {_q(ref)})")
|
|
return
|
|
|
|
for qid, ent, diff_file in sorted(results, key=lambda x: (x[2], x[1].line)):
|
|
lang = g.module_lang(ent.module)
|
|
fpath = g.module_to_file(ent.module)
|
|
print(f"(diff-fn {qid} :kind {ent.kind} :line {ent.line} :lang {lang} :file {_q(fpath)})")
|
|
print(f"(diff-functions-summary :changed {len(results)} :files {len(changed_ranges)} :ref {_q(ref)})")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scheme query REPL -- composable S-expression queries on the graph
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# A tiny Scheme-dialect evaluator that exposes the belief graph as queryable
|
|
# data. Every value is either a string, int, bool, list, or a "module-set"
|
|
# (set of module IDs). Functions compose naturally:
|
|
#
|
|
# (deps "operatives.controller" 2) -> module-set of dependencies
|
|
# (rdeps "operatives.controller" 1) -> module-set of reverse deps
|
|
# (boundary "operatives.controller") -> module-set (target + deps + rdeps)
|
|
# (entity "OperativesService") -> list of module IDs defining it
|
|
# (imports "operatives.controller") -> module-set of direct imports
|
|
# (dependents "operatives.controller") -> module-set of direct dependents
|
|
# (intersect (deps "A" 2) (rdeps "B" 1)) -> set intersection
|
|
# (union A B) -> set union
|
|
# (diff A B) -> set difference
|
|
# (filter SET "pattern") -> grep within set
|
|
# (files SET) -> resolve module IDs to file paths
|
|
# (count SET) -> cardinality
|
|
# (analyze "id") -> full analysis (prints sexp)
|
|
# (search "pattern") -> raw grep on sexp file
|
|
|
|
_QUERY_HELP = """\
|
|
; Scheme query language for .belief_map.sexp
|
|
;
|
|
; Primitives (return module-set or list):
|
|
; (resolve "pattern") Find module IDs matching pattern
|
|
; (deps "id" depth) Outgoing dependency set (default depth 2)
|
|
; (rdeps "id" depth) Reverse dependency set (default depth 1)
|
|
; (imports "id") Direct import targets
|
|
; (dependents "id") Direct dependents (who imports this)
|
|
; (boundary "id") Target + deps + rdeps (change boundary)
|
|
; (entity "Name") Modules defining entity
|
|
; (refs-to "Name") Modules referencing entity
|
|
; (search "pattern") Grep .belief_map.sexp
|
|
;
|
|
; Function & type search (return module-set):
|
|
; (find-function "name") Modules containing function definitions
|
|
; (find-type "name") Modules containing type/class/ifc/enum defs
|
|
; (find-callers "fn" depth) Modules that call a function (default depth 2)
|
|
; (find-calls "fn" depth) Modules that a function calls (default depth 2)
|
|
; (diff-functions "ref") Print changed functions (side-effect, returns empty)
|
|
;
|
|
; Set operations:
|
|
; (intersect A B) Set intersection
|
|
; (union A B ...) Set union
|
|
; (diff A B) A minus B
|
|
; (filter SET "pattern") Keep members matching regex
|
|
;
|
|
; Output:
|
|
; (files SET) Resolve to file paths
|
|
; (count SET) Count members
|
|
; (analyze "id") Full analysis output
|
|
;
|
|
; Composition:
|
|
; (files (intersect (deps "A" 2) (rdeps "B" 1)))
|
|
; (filter (boundary "X") "dto")
|
|
; (count (deps "operatives.service" 3))
|
|
; (files (find-callers "createOrder" 3))
|
|
; (intersect (find-function "handle") (deps "api" 2))
|
|
"""
|
|
|
|
|
|
class _SchemeEval:
|
|
"""Tiny Scheme evaluator for belief graph queries."""
|
|
|
|
def __init__(self, g: BeliefGraph, sexp_path: str) -> None:
|
|
self._g = g
|
|
self._sexp_path = sexp_path
|
|
|
|
def eval_str(self, expr_str: str) -> object:
|
|
tokens = self._tokenize(expr_str)
|
|
ast = self._parse(tokens)
|
|
return self._eval(ast)
|
|
|
|
# -- Tokenizer --
|
|
|
|
@staticmethod
|
|
def _tokenize(s: str) -> list[str]:
|
|
tokens: list[str] = []
|
|
i = 0
|
|
while i < len(s):
|
|
ch = s[i]
|
|
if ch in (" ", "\t", "\n", "\r"):
|
|
i += 1
|
|
elif ch == ";":
|
|
while i < len(s) and s[i] != "\n":
|
|
i += 1
|
|
elif ch in ("(", ")"):
|
|
tokens.append(ch)
|
|
i += 1
|
|
elif ch == '"':
|
|
j = i + 1
|
|
while j < len(s) and s[j] != '"':
|
|
if s[j] == "\\":
|
|
j += 1
|
|
j += 1
|
|
tokens.append(s[i + 1 : j])
|
|
i = j + 1
|
|
else:
|
|
j = i
|
|
while j < len(s) and s[j] not in (" ", "\t", "\n", "\r", "(", ")", '"', ";"):
|
|
j += 1
|
|
tokens.append(s[i:j])
|
|
i = j
|
|
return tokens
|
|
|
|
# -- Parser: tokens -> nested lists --
|
|
|
|
@staticmethod
|
|
def _parse(tokens: list[str]) -> object:
|
|
if not tokens:
|
|
return []
|
|
pos = [0]
|
|
|
|
def _read() -> object:
|
|
if pos[0] >= len(tokens):
|
|
return None
|
|
tok = tokens[pos[0]]
|
|
if tok == "(":
|
|
pos[0] += 1
|
|
lst: list[object] = []
|
|
while pos[0] < len(tokens) and tokens[pos[0]] != ")":
|
|
lst.append(_read())
|
|
pos[0] += 1 # skip )
|
|
return lst
|
|
if tok == ")":
|
|
pos[0] += 1
|
|
return None
|
|
pos[0] += 1
|
|
# Try int
|
|
try:
|
|
return int(tok)
|
|
except ValueError:
|
|
return tok
|
|
|
|
result = _read()
|
|
return result
|
|
|
|
# -- Evaluator --
|
|
|
|
def _eval(self, ast: object) -> object:
|
|
if isinstance(ast, (int, float)):
|
|
return ast
|
|
if isinstance(ast, str):
|
|
return ast
|
|
if not isinstance(ast, list) or len(ast) == 0:
|
|
return set()
|
|
|
|
head = ast[0]
|
|
args = ast[1:]
|
|
|
|
# Evaluate args (eager)
|
|
evaled = [self._eval(a) for a in args]
|
|
|
|
return self._apply(head, evaled)
|
|
|
|
def _apply(self, fn: str, args: list[object]) -> object:
|
|
g = self._g
|
|
|
|
if fn == "resolve":
|
|
pattern = str(args[0]) if args else ""
|
|
return set(g.resolve_module(pattern))
|
|
|
|
if fn == "deps":
|
|
pattern = str(args[0]) if args else ""
|
|
depth = int(str(args[1])) if len(args) > 1 else 2
|
|
matches = g.resolve_module(pattern)
|
|
result: set[str] = set()
|
|
for m in matches:
|
|
visited: set[str] = {m}
|
|
self._collect_deps(m, visited, depth, 1, result)
|
|
return result
|
|
|
|
if fn == "rdeps":
|
|
pattern = str(args[0]) if args else ""
|
|
depth = int(str(args[1])) if len(args) > 1 else 1
|
|
matches = g.resolve_module(pattern)
|
|
result = set()
|
|
for m in matches:
|
|
visited = {m}
|
|
self._collect_rdeps(m, visited, depth, 1, result)
|
|
return result
|
|
|
|
if fn == "imports":
|
|
pattern = str(args[0]) if args else ""
|
|
matches = g.resolve_module(pattern)
|
|
result = set()
|
|
for m in matches:
|
|
result.update(_collect_dep_mods(g, m).keys())
|
|
return result
|
|
|
|
if fn == "dependents":
|
|
pattern = str(args[0]) if args else ""
|
|
matches = g.resolve_module(pattern)
|
|
result = set()
|
|
for m in matches:
|
|
result.update(_collect_rdep_mods(g, m).keys())
|
|
return result
|
|
|
|
if fn == "boundary":
|
|
pattern = str(args[0]) if args else ""
|
|
matches = g.resolve_module(pattern)
|
|
result = set()
|
|
for m in matches:
|
|
result.add(m)
|
|
result.update(_collect_dep_mods(g, m).keys())
|
|
result.update(_collect_rdep_mods(g, m).keys())
|
|
return result
|
|
|
|
if fn == "entity":
|
|
name = str(args[0]) if args else ""
|
|
defs = g.entity_by_name.get(name, [])
|
|
if not defs:
|
|
pattern = re.compile(name, re.IGNORECASE)
|
|
for ename, ents in g.entity_by_name.items():
|
|
if pattern.search(ename):
|
|
defs.extend(ents)
|
|
return set(d.module for d in defs)
|
|
|
|
if fn == "refs-to":
|
|
name = str(args[0]) if args else ""
|
|
edges = g.edges_by_entity_name.get(name, [])
|
|
return set(e.source.split("::")[0] for e in edges)
|
|
|
|
if fn == "intersect":
|
|
a = self._to_set(args[0])
|
|
b = self._to_set(args[1]) if len(args) > 1 else set()
|
|
return a & b
|
|
|
|
if fn == "union":
|
|
result = set()
|
|
for a in args:
|
|
result |= self._to_set(a)
|
|
return result
|
|
|
|
if fn == "diff":
|
|
a = self._to_set(args[0])
|
|
b = self._to_set(args[1]) if len(args) > 1 else set()
|
|
return a - b
|
|
|
|
if fn == "filter":
|
|
s = self._to_set(args[0])
|
|
pattern = str(args[1]) if len(args) > 1 else ""
|
|
regex = re.compile(pattern, re.IGNORECASE)
|
|
return {m for m in s if regex.search(m)}
|
|
|
|
if fn == "files":
|
|
s = self._to_set(args[0])
|
|
return set(g.module_to_file(m) for m in s)
|
|
|
|
if fn == "count":
|
|
s = self._to_set(args[0])
|
|
return len(s)
|
|
|
|
if fn == "analyze":
|
|
pattern = str(args[0]) if args else ""
|
|
cmd_analyze(g, pattern)
|
|
return set()
|
|
|
|
if fn == "search":
|
|
pattern = str(args[0]) if args else ""
|
|
cmd_search(g, pattern, self._sexp_path)
|
|
return set()
|
|
|
|
if fn == "layer":
|
|
pattern = str(args[0]) if args else ""
|
|
matches = g.resolve_module(pattern)
|
|
if matches:
|
|
return _classify_layer(matches[0], g.module_purpose(matches[0]))
|
|
return "unknown"
|
|
|
|
if fn == "violations":
|
|
pattern = str(args[0]) if args else "all"
|
|
scope = set(g.nodes.keys()) if pattern == "all" else set(g.resolve_module(pattern))
|
|
deny_rules = [
|
|
("domain", "infra"), ("domain", "api"), ("domain", "ui"),
|
|
("ui", "infra"), ("service", "api"), ("infra", "api"),
|
|
]
|
|
result: set[str] = set()
|
|
for src_mod in scope:
|
|
sl = _classify_layer(src_mod, g.module_purpose(src_mod))
|
|
for edge in g.edges_from.get(src_mod, []):
|
|
if edge.etype not in ("imports", "data-flow"):
|
|
continue
|
|
tgt_mod = edge.target.split("::")[0]
|
|
tl = _classify_layer(tgt_mod, g.module_purpose(tgt_mod))
|
|
if sl != tl and (sl, tl) in deny_rules:
|
|
result.add(src_mod)
|
|
return result
|
|
|
|
if fn == "layer-of":
|
|
s = self._to_set(args[0])
|
|
target_layer = str(args[1]) if len(args) > 1 else ""
|
|
return {m for m in s if _classify_layer(m, g.module_purpose(m)) == target_layer}
|
|
|
|
if fn == "find-function" or fn == "find_function":
|
|
name = str(args[0]) if args else ""
|
|
pattern = re.compile(name, re.IGNORECASE)
|
|
result: set[str] = set()
|
|
for ent_name, ent_list in g.entity_by_name.items():
|
|
for ent in ent_list:
|
|
if ent.kind == "fn" and (ent_name == name or pattern.search(ent_name)):
|
|
result.add(ent.module)
|
|
return result
|
|
|
|
if fn == "find-type" or fn == "find_type":
|
|
name = str(args[0]) if args else ""
|
|
pattern = re.compile(name, re.IGNORECASE)
|
|
result = set()
|
|
for ent_name, ent_list in g.entity_by_name.items():
|
|
for ent in ent_list:
|
|
if ent.kind in ("cls", "ifc", "typ", "enm") and (ent_name == name or pattern.search(ent_name)):
|
|
result.add(ent.module)
|
|
return result
|
|
|
|
if fn == "find-callers" or fn == "find_callers":
|
|
fn_name = str(args[0]) if args else ""
|
|
depth = int(str(args[1])) if len(args) > 1 else 2
|
|
targets = _resolve_fn_entity(g, fn_name)
|
|
result = set()
|
|
for _qid, ent in targets:
|
|
result.add(ent.module)
|
|
# Collect callers via calls edges
|
|
tgt_qualified = {qid for qid, _ in targets}
|
|
for e in g.all_edges:
|
|
if e.etype == "calls" and e.target in tgt_qualified:
|
|
result.add(e.source.split("::")[0])
|
|
# Also walk reverse deps
|
|
for _qid, ent in targets:
|
|
visited: set[str] = {ent.module}
|
|
self._collect_rdeps(ent.module, visited, depth, 1, result)
|
|
return result
|
|
|
|
if fn == "find-calls" or fn == "find_calls":
|
|
fn_name = str(args[0]) if args else ""
|
|
depth = int(str(args[1])) if len(args) > 1 else 2
|
|
sources = _resolve_fn_entity(g, fn_name)
|
|
result = set()
|
|
src_qualified = {qid for qid, _ in sources}
|
|
for e in g.all_edges:
|
|
if e.etype == "calls" and e.source in src_qualified:
|
|
result.add(e.target.split("::")[0])
|
|
for _qid, ent in sources:
|
|
visited = {ent.module}
|
|
self._collect_deps(ent.module, visited, depth, 1, result)
|
|
return result
|
|
|
|
if fn == "diff-functions" or fn == "diff_functions":
|
|
ref = str(args[0]) if args else "HEAD"
|
|
cmd_diff_functions(g, ref)
|
|
return set()
|
|
|
|
print(f"; unknown function: {fn}")
|
|
return set()
|
|
|
|
def _collect_deps(
|
|
self, nid: str, visited: set[str], max_depth: int, depth: int, result: set[str],
|
|
) -> None:
|
|
if depth > max_depth:
|
|
return
|
|
for tgt in _collect_dep_mods(self._g, nid):
|
|
result.add(tgt)
|
|
if tgt not in visited:
|
|
visited.add(tgt)
|
|
self._collect_deps(tgt, visited, max_depth, depth + 1, result)
|
|
|
|
def _collect_rdeps(
|
|
self, nid: str, visited: set[str], max_depth: int, depth: int, result: set[str],
|
|
) -> None:
|
|
if depth > max_depth:
|
|
return
|
|
for src in _collect_rdep_mods(self._g, nid):
|
|
result.add(src)
|
|
if src not in visited:
|
|
visited.add(src)
|
|
self._collect_rdeps(src, visited, max_depth, depth + 1, result)
|
|
|
|
@staticmethod
|
|
def _to_set(val: object) -> set[str]:
|
|
if isinstance(val, set):
|
|
return val
|
|
if isinstance(val, str):
|
|
return {val}
|
|
if isinstance(val, (list, tuple)):
|
|
return set(str(v) for v in val)
|
|
return set()
|
|
|
|
|
|
def _format_result(val: object) -> None:
|
|
"""Print a Scheme query result as S-expression facts."""
|
|
if isinstance(val, set):
|
|
for item in sorted(val):
|
|
print(f"(result {_q(item)})")
|
|
if not val:
|
|
print("(result-empty)")
|
|
elif isinstance(val, int):
|
|
print(f"(result-count {val})")
|
|
elif isinstance(val, str):
|
|
print(f"(result {_q(val)})")
|
|
elif isinstance(val, list):
|
|
for item in val:
|
|
print(f"(result {_q(str(item))})")
|
|
else:
|
|
print(f"(result {_q(str(val))})")
|
|
|
|
|
|
def cmd_query(g: BeliefGraph, expr: str, sexp_path: str) -> None:
|
|
"""Evaluate a single Scheme query expression."""
|
|
evaluator = _SchemeEval(g, sexp_path)
|
|
result = evaluator.eval_str(expr)
|
|
_format_result(result)
|
|
|
|
|
|
def cmd_repl(g: BeliefGraph, sexp_path: str) -> None:
|
|
"""Interactive Scheme REPL for belief map queries."""
|
|
evaluator = _SchemeEval(g, sexp_path)
|
|
print("; belief-map query REPL")
|
|
print("; type (help) for available functions, (quit) to exit")
|
|
print()
|
|
|
|
while True:
|
|
try:
|
|
expr = input("bm> ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print()
|
|
break
|
|
if not expr or expr.startswith(";"):
|
|
continue
|
|
if expr in ("(quit)", "(exit)", "quit", "exit"):
|
|
break
|
|
if expr in ("(help)", "help"):
|
|
print(_QUERY_HELP)
|
|
continue
|
|
|
|
result = evaluator.eval_str(expr)
|
|
_format_result(result)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def find_sexp_file() -> str:
|
|
"""Find belief_map.sexp in current or parent dirs.
|
|
|
|
Searches for (in priority order per directory):
|
|
.belief_map.sexp, belief_map.sexp
|
|
|
|
If only a JSON belief map is found, prints a migration hint.
|
|
"""
|
|
_SEXP_NAMES = (".belief_map.sexp", "belief_map.sexp")
|
|
_JSON_NAMES = (".belief_map.json", "belief_map.json")
|
|
cur = os.getcwd()
|
|
json_found = ""
|
|
while True:
|
|
for name in _SEXP_NAMES:
|
|
candidate = os.path.join(cur, name)
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
if not json_found:
|
|
for name in _JSON_NAMES:
|
|
candidate = os.path.join(cur, name)
|
|
if os.path.isfile(candidate):
|
|
json_found = candidate
|
|
parent = os.path.dirname(cur)
|
|
if parent == cur:
|
|
break
|
|
cur = parent
|
|
if json_found:
|
|
print(f"Error: found JSON belief map at {json_found} but this script requires sexp format.", file=sys.stderr)
|
|
print("Rebuild with the sexp builder:", file=sys.stderr)
|
|
print(f" python3 ~/.claude/skills/codespaces/scripts/build_belief_map.py", file=sys.stderr)
|
|
else:
|
|
print("Error: .belief_map.sexp not found. Run build_belief_map.py first.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def main() -> None:
|
|
args = sys.argv[1:]
|
|
if not args or args[0] in ("-h", "--help", "help"):
|
|
print(__doc__)
|
|
sys.exit(0)
|
|
|
|
sexp_path = find_sexp_file()
|
|
g = BeliefGraph()
|
|
g.load(sexp_path)
|
|
|
|
cmd = args[0]
|
|
if cmd == "analyze" and len(args) >= 2:
|
|
cmd_analyze(g, args[1])
|
|
elif cmd == "boundary" and len(args) >= 2:
|
|
files_only = "--files-only" in args
|
|
module_arg = [a for a in args[1:] if a != "--files-only"][0]
|
|
cmd_boundary(g, module_arg, files_only=files_only)
|
|
elif cmd == "quick" and len(args) >= 2:
|
|
cmd_quick(g, args[1], sexp_path)
|
|
elif cmd == "module" and len(args) >= 2:
|
|
cmd_module(g, args[1])
|
|
elif cmd == "deps" and len(args) >= 2:
|
|
depth = int(args[2]) if len(args) >= 3 else 2
|
|
cmd_deps(g, args[1], depth)
|
|
elif cmd == "rdeps" and len(args) >= 2:
|
|
depth = int(args[2]) if len(args) >= 3 else 2
|
|
cmd_rdeps(g, args[1], depth)
|
|
elif cmd == "entity" and len(args) >= 2:
|
|
cmd_entity(g, args[1])
|
|
elif cmd == "flow" and len(args) >= 3:
|
|
cmd_flow(g, args[1], args[2])
|
|
elif cmd == "search" and len(args) >= 2:
|
|
cmd_search(g, args[1], sexp_path)
|
|
elif cmd == "boundaries":
|
|
target = args[1] if len(args) >= 2 else "all"
|
|
cmd_boundaries(g, target)
|
|
elif cmd == "layers":
|
|
cmd_layers(g)
|
|
elif cmd == "invariants":
|
|
target = args[1] if len(args) >= 2 else "all"
|
|
cmd_invariants(g, target)
|
|
elif cmd == "find_function" and len(args) >= 2:
|
|
cmd_find_function(g, args[1])
|
|
elif cmd == "find_type" and len(args) >= 2:
|
|
cmd_find_type(g, args[1])
|
|
elif cmd == "find_callchain" and len(args) >= 3:
|
|
depth = int(args[3]) if len(args) >= 4 else 5
|
|
cmd_find_callchain(g, args[1], args[2], depth)
|
|
elif cmd == "find_callers" and len(args) >= 2:
|
|
depth = int(args[2]) if len(args) >= 3 else 2
|
|
cmd_find_callers(g, args[1], depth)
|
|
elif cmd == "find_calls" and len(args) >= 2:
|
|
depth = int(args[2]) if len(args) >= 3 else 2
|
|
cmd_find_calls(g, args[1], depth)
|
|
elif cmd == "grep_functions" and len(args) >= 2:
|
|
cmd_grep_functions(g, args[1])
|
|
elif cmd == "diff_functions":
|
|
ref = args[1] if len(args) >= 2 else "HEAD"
|
|
cmd_diff_functions(g, ref)
|
|
elif cmd == "query" and len(args) >= 2:
|
|
cmd_query(g, " ".join(args[1:]), sexp_path)
|
|
elif cmd == "repl":
|
|
cmd_repl(g, sexp_path)
|
|
else:
|
|
print(f"(error unknown-command {_q(cmd if args else 'none')})")
|
|
print("; Commands: analyze, boundary, quick, boundaries, layers, invariants, module, deps, rdeps, entity, flow, search,")
|
|
print("; find_function, find_type, find_callchain, find_callers, find_calls, grep_functions, diff_functions,")
|
|
print("; query, repl")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|