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.
3183 lines
125 KiB
Python
Executable File
3183 lines
125 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Belief Map Builder -- Architecture Graph Generator.
|
|
|
|
Generates ``belief_map.sexp``, a flat S-expression graph of all modules,
|
|
entities, and relationships in a codebase. Each line is a self-contained
|
|
fact, queryable with ``rg`` or ``scripts/belief_search.py``.
|
|
|
|
Based on the Theory of Code Space (ToCS) methodology for reducing the
|
|
"Active-Passive Gap" in AI code agents.
|
|
|
|
Parsers
|
|
-------
|
|
- **TypeScript/TSX**: tree-sitter (``tree-sitter-typescript``). Handles
|
|
decorators, heritage, all methods, object literals, arrow functions.
|
|
- **Python**: stdlib ``ast``. Full class/function/import extraction.
|
|
|
|
Modes
|
|
-----
|
|
**Default** (fast, ~5-8s for full workspace):
|
|
Parses all .py/.ts/.tsx files, builds import/reference/data-flow edges.
|
|
|
|
**LSP-enhanced** (``--lsp``, ~1-5min):
|
|
After default pass, starts ``typescript-language-server`` and
|
|
``pyright-langserver`` per project to query call hierarchy and
|
|
references. Adds precise ``calls`` edges and ``:lsp`` annotations.
|
|
|
|
Edge Types
|
|
----------
|
|
- ``imports`` -- module imports module. ``:via-base`` if target exports
|
|
abstract types.
|
|
- ``calls-api`` -- implements/extends abstract type. ``:via-ifc``.
|
|
- ``data-flow`` -- data flows to validation module. ``:validated``.
|
|
- ``refs`` -- entity-level cross-file reference (``Module::Entity``).
|
|
- ``calls`` (LSP only) -- function calls function with call-site lines.
|
|
|
|
Output Format
|
|
-------------
|
|
Flat S-expression facts (``belief_map.sexp``)::
|
|
|
|
(node module/id ts "purpose description")
|
|
(cls module/id ClassName 49 (:deco Injectable) (:methods create update delete))
|
|
(fn module/id functionName 126)
|
|
(imports module/a module/b :via-base)
|
|
(refs module/a module/b::EntityName)
|
|
(calls module/a::fnA module/b::fnB :lines 52 67 :lsp)
|
|
|
|
Requirements
|
|
------------
|
|
::
|
|
|
|
pip3 install tree-sitter tree-sitter-typescript tree-sitter-python
|
|
|
|
Optional for ``--lsp``: ``typescript-language-server``, ``pyright-langserver``.
|
|
|
|
Usage
|
|
-----
|
|
::
|
|
|
|
python3 scripts/build_belief_map.py # incremental (~5-8s)
|
|
python3 scripts/build_belief_map.py --full # full rebuild
|
|
python3 scripts/build_belief_map.py --lsp # with call hierarchy
|
|
python3 scripts/build_belief_map.py ./app-service # target sub-repo
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
from urllib.parse import quote, unquote
|
|
|
|
import tree_sitter_typescript as _tst
|
|
from tree_sitter import Language as _TsLanguage, Parser as _TsParser
|
|
|
|
_TS_LANG = _TsLanguage(_tst.language_typescript())
|
|
_TSX_LANG = _TsLanguage(_tst.language_tsx())
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SKIP_DIRS = {
|
|
"node_modules", ".venv", "venv", "__pycache__", ".git", "dist",
|
|
".next", ".turbo", "build", "coverage", ".tox", "site-packages",
|
|
".mypy_cache", ".pytest_cache", ".ruff_cache", "target",
|
|
".worktrees", ".ralphy-worktrees", ".ralphy-sandboxes",
|
|
}
|
|
|
|
CACHE_FILE = ".belief_map_cache.json"
|
|
OUTPUT_FILE = ".belief_map.sexp"
|
|
|
|
# Default timeout for LSP requests (seconds)
|
|
LSP_REQUEST_TIMEOUT = 15.0
|
|
# Timeout for LSP server initialization (seconds)
|
|
LSP_INIT_TIMEOUT = 60.0
|
|
|
|
# -- Python regex patterns --
|
|
|
|
PY_ABC_RE = re.compile(r"""class\s+\w+\s*\(.*(?:ABC|ABCMeta|Protocol).*\)""")
|
|
PY_INHERITS_RE = re.compile(r"""class\s+(\w+)\s*\(([^)]+)\)""")
|
|
|
|
# Naming convention detectors
|
|
SNAKE_CASE_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
CAMEL_CASE_RE = re.compile(r"^[a-z][a-zA-Z0-9]*$")
|
|
PASCAL_CASE_RE = re.compile(r"^[A-Z][a-zA-Z0-9]*$")
|
|
KEBAB_CASE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Entity:
|
|
name: str
|
|
kind: str # class, function, interface, type, enum, method, decorator
|
|
line: int
|
|
methods: list[str] # method names (for classes)
|
|
decorators: list[str] # decorator names
|
|
bases: list[str] # parent classes / implemented interfaces
|
|
|
|
def to_dict(self) -> dict:
|
|
d: dict = {"name": self.name, "kind": self.kind, "line": self.line}
|
|
if self.methods:
|
|
d["methods"] = self.methods
|
|
if self.decorators:
|
|
d["decorators"] = self.decorators
|
|
if self.bases:
|
|
d["bases"] = self.bases
|
|
return d
|
|
|
|
|
|
@dataclass
|
|
class ImportedName:
|
|
"""A single name imported from another module."""
|
|
local_name: str # name used locally
|
|
original_name: str # name in source module ("default" for default imports)
|
|
module: str # raw import specifier
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"local": self.local_name,
|
|
"original": self.original_name,
|
|
"module": self.module,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class FileResult:
|
|
path: str
|
|
language: str
|
|
repo: str
|
|
mtime: float
|
|
content_hash: str
|
|
imports: list[str]
|
|
exports_abstract: list[str]
|
|
implements: list[str]
|
|
extends: list[str]
|
|
purpose: str
|
|
naming_convention: str
|
|
has_validation: bool
|
|
entities: list[dict] # Entity.to_dict() results
|
|
imported_names: list[dict] # ImportedName.to_dict() results
|
|
exported_names: list[str] # names exported from this file
|
|
|
|
|
|
@dataclass
|
|
class LspEdge:
|
|
"""An edge discovered by LSP analysis."""
|
|
source: str # "node_id::entity_name" or "node_id"
|
|
target: str # "node_id::entity_name" or "node_id"
|
|
edge_type: str # "CALLS", "REFERENCES"
|
|
metadata: dict = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class ProjectGroup:
|
|
"""A group of files belonging to the same LSP project."""
|
|
root: str # absolute path to project root
|
|
language: str # "typescript" or "python"
|
|
config_file: str # path to tsconfig.json or pyrightconfig.json
|
|
files: list[FileResult] = field(default_factory=list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def file_hash(path: str) -> str:
|
|
try:
|
|
st = os.stat(path)
|
|
with open(path, "rb") as f:
|
|
chunk = f.read(8192)
|
|
return hashlib.md5(chunk + str(st.st_size).encode()).hexdigest()
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def detect_naming(filename: str) -> str:
|
|
stem = Path(filename).stem
|
|
for suffix in (".test", ".spec", ".e2e", ".stories", ".d"):
|
|
if stem.endswith(suffix):
|
|
stem = stem[: -len(suffix)]
|
|
if SNAKE_CASE_RE.match(stem):
|
|
return "snake_case"
|
|
if KEBAB_CASE_RE.match(stem):
|
|
return "kebab-case"
|
|
if PASCAL_CASE_RE.match(stem):
|
|
return "PascalCase"
|
|
if CAMEL_CASE_RE.match(stem):
|
|
return "camelCase"
|
|
return "mixed"
|
|
|
|
|
|
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 _fuzzy_suffix_match(name: str, suffix: str, max_dist: int = 1) -> bool:
|
|
"""Check if the tail of `name` fuzzy-matches `suffix` within edit distance."""
|
|
if len(suffix) < 6:
|
|
return False # too short for fuzzy -- "Store"/"Storey" false positives
|
|
tail = name[-len(suffix) - max_dist:] # grab suffix + slack for insertions
|
|
return _levenshtein(tail.lower(), suffix.lower()) <= max_dist
|
|
|
|
|
|
def infer_purpose(path: str, content: str, language: str, entities: Optional[list] = None) -> str:
|
|
"""Infer architectural purpose from path, content, and entity names."""
|
|
parts = []
|
|
rel = path.lower()
|
|
|
|
# Path-based heuristics
|
|
if "/domain/" in rel:
|
|
parts.append("domain logic")
|
|
elif "/infrastructure/" in rel or "/infra/" in rel:
|
|
parts.append("infrastructure adapter")
|
|
elif "/api/" in rel:
|
|
parts.append("API layer")
|
|
elif "/app/" in rel or "/application/" in rel:
|
|
parts.append("application service")
|
|
elif "/components/" in rel or "/ui/" in rel:
|
|
parts.append("UI component")
|
|
elif "/hooks/" in rel:
|
|
parts.append("React hook")
|
|
elif "/pages/" in rel or "/routes/" in rel:
|
|
parts.append("page/route")
|
|
elif "/migrations/" in rel:
|
|
parts.append("database migration")
|
|
elif "/test" in rel or ".test." in rel or ".spec." in rel:
|
|
parts.append("test")
|
|
elif "/config" in rel:
|
|
parts.append("configuration")
|
|
elif "/utils/" in rel or "/helpers/" in rel or "/commons/" in rel:
|
|
parts.append("shared utilities")
|
|
elif "/repositories/" in rel or "/repo/" in rel:
|
|
parts.append("repository")
|
|
elif "/services/" in rel:
|
|
parts.append("service")
|
|
elif "/dto/" in rel:
|
|
parts.append("DTO")
|
|
elif "/guards/" in rel or "guard" in rel:
|
|
parts.append("access guard")
|
|
elif "/store/" in rel or "/stores/" in rel:
|
|
parts.append("state store")
|
|
elif "/schema" in rel:
|
|
parts.append("schema")
|
|
elif "/sdk/" in rel:
|
|
parts.append("SDK")
|
|
elif "/api-client/" in rel or "/api_client/" in rel:
|
|
parts.append("API client")
|
|
|
|
# Docstring extraction
|
|
head = content[:2048]
|
|
if language == "python":
|
|
match = re.search(r'^"""(.*?)"""', head, re.DOTALL)
|
|
if not match:
|
|
match = re.search(r"^'''(.*?)'''", head, re.DOTALL)
|
|
if match:
|
|
parts.append(match.group(1).strip().split("\n")[0][:120])
|
|
else:
|
|
match = re.search(r"/\*\*\s*\n?\s*\*?\s*(.+?)(?:\n|\*/)", head)
|
|
if match:
|
|
parts.append(match.group(1).strip()[:120])
|
|
|
|
# Entity-name heuristics (Gap 3 fix: reduces "general module" fallback)
|
|
# -- Suffix -> (purpose label, dedup keyword) --
|
|
_ENTITY_SUFFIX_MAP = [
|
|
# Core architecture roles
|
|
("Service", "service", "service"),
|
|
("Controller", "API controller", "controller"),
|
|
("Repository", "repository", "repository"),
|
|
("Repo", "repository", "repository"),
|
|
("Gateway", "gateway", "gateway"),
|
|
("Registry", "registry", "registry"),
|
|
("Store", "state store", "store"),
|
|
("Guard", "access guard", "guard"),
|
|
("Module", "module", "module"),
|
|
("Adapter", "adapter", "adapter"),
|
|
("Provider", "provider", "provider"),
|
|
("Processor", "processor", "processor"),
|
|
("Agent", "agent", "agent"),
|
|
("Resolver", "resolver", "resolver"),
|
|
("Scheduler", "scheduler", "scheduler"),
|
|
("Worker", "worker", "worker"),
|
|
("Client", "client", "client"),
|
|
("Connector", "connector", "connector"),
|
|
("Manager", "manager", "manager"),
|
|
("Subscriber", "subscriber", "subscriber"),
|
|
("Dispatcher", "dispatcher", "dispatcher"),
|
|
("Emitter", "emitter", "emitter"),
|
|
("Interceptor", "interceptor", "interceptor"),
|
|
("Pipe", "validation pipe", "pipe"),
|
|
("Handler", "handler", "handler"),
|
|
("Listener", "event listener", "listener"),
|
|
# GoF patterns
|
|
("Factory", "Factory pattern", "factory"),
|
|
("Builder", "Builder pattern", "builder"),
|
|
("Singleton", "Singleton pattern", "singleton"),
|
|
("Observer", "Observer pattern", "observer"),
|
|
("Strategy", "Strategy pattern", "strategy"),
|
|
("Command", "Command pattern", "command"),
|
|
("Mediator", "Mediator pattern", "mediator"),
|
|
("Visitor", "Visitor pattern", "visitor"),
|
|
("Decorator", "Decorator pattern", "decorator"),
|
|
("Proxy", "Proxy pattern", "proxy"),
|
|
("Facade", "Facade pattern", "facade"),
|
|
("Composite", "Composite pattern", "composite"),
|
|
("Iterator", "Iterator pattern", "iterator"),
|
|
("ChainHandler", "Chain of Responsibility", "chain"),
|
|
# Fowler / DDD patterns
|
|
("Specification", "Specification pattern", "specification"),
|
|
("Spec", "Specification pattern", "specification"),
|
|
("ValueObject", "value object", "value"),
|
|
("Aggregate", "aggregate root", "aggregate"),
|
|
("UnitOfWork", "Unit of Work", "unitofwork"),
|
|
("DomainEvent", "domain event", "domainevent"),
|
|
("IdentityMap", "Identity Map", "identitymap"),
|
|
("Policy", "policy", "policy"),
|
|
("Plugin", "plugin", "plugin"),
|
|
# SDK / API
|
|
("Sdk", "SDK", "sdk"),
|
|
("SDK", "SDK", "sdk"),
|
|
("Api", "API client", "api client"),
|
|
("API", "API client", "api client"),
|
|
]
|
|
|
|
if entities:
|
|
joined_lower = " ".join(parts).lower()
|
|
for ent in entities:
|
|
ename = ent.get("name", "") if isinstance(ent, dict) else ent.name
|
|
ekind = ent.get("kind", "") if isinstance(ent, dict) else ent.kind
|
|
if ekind not in ("class", "interface"):
|
|
continue
|
|
# Exact suffix match first
|
|
matched = False
|
|
for suffix, purpose_label, dedup_kw in _ENTITY_SUFFIX_MAP:
|
|
if ename.endswith(suffix):
|
|
if dedup_kw not in joined_lower:
|
|
parts.append(purpose_label)
|
|
joined_lower = " ".join(parts).lower()
|
|
matched = True
|
|
break
|
|
# Fuzzy suffix fallback (distance <= 1, suffixes >= 4 chars)
|
|
if not matched and len(ename) >= 5:
|
|
for suffix, purpose_label, dedup_kw in _ENTITY_SUFFIX_MAP:
|
|
if _fuzzy_suffix_match(ename, suffix):
|
|
if dedup_kw not in joined_lower:
|
|
parts.append(purpose_label)
|
|
joined_lower = " ".join(parts).lower()
|
|
break
|
|
|
|
# Pattern detection -- only flag patterns when the file DEFINES one,
|
|
# not when it merely imports from a module named factory/strategy.
|
|
joined_check = " ".join(parts).lower()
|
|
|
|
# GoF Creational
|
|
if "factory" not in joined_check:
|
|
if re.search(r"class \w*Factory|def (?:create|make|build)_\w+.*->|def get_\w+.*factory", head):
|
|
parts.append("Factory pattern")
|
|
if "strategy" not in joined_check:
|
|
if re.search(r"class \w*Strategy|strategy_map|STRATEGIES", head):
|
|
parts.append("Strategy pattern")
|
|
if "builder" not in joined_check:
|
|
if re.search(r"class \w+Builder\b", head):
|
|
parts.append("Builder pattern")
|
|
if "singleton" not in joined_check:
|
|
if re.search(r"getInstance\s*\(|_instance\s*[:=]|private\s+static\s+instance", head):
|
|
parts.append("Singleton pattern")
|
|
|
|
# GoF Structural
|
|
if "composite" not in joined_check:
|
|
if re.search(r"class \w+Composite\b|addChild\s*\(|getChildren\s*\(", head):
|
|
parts.append("Composite pattern")
|
|
if "proxy" not in joined_check:
|
|
if re.search(r"class \w+Proxy\b", head):
|
|
parts.append("Proxy pattern")
|
|
if "facade" not in joined_check:
|
|
if re.search(r"class \w+Facade\b", head):
|
|
parts.append("Facade pattern")
|
|
|
|
# GoF Behavioral
|
|
if "observer" not in joined_check:
|
|
if re.search(r"class \w+Observer\b|\.subscribe\s*\(|EventEmitter", head):
|
|
parts.append("Observer pattern")
|
|
if "command" not in joined_check:
|
|
if re.search(r"class \w+Command\b", head):
|
|
parts.append("Command pattern")
|
|
if "mediator" not in joined_check:
|
|
if re.search(r"class \w+Mediator\b", head):
|
|
parts.append("Mediator pattern")
|
|
if "visitor" not in joined_check:
|
|
if re.search(r"class \w+Visitor\b|\.accept\s*\(\s*visitor", head):
|
|
parts.append("Visitor pattern")
|
|
if "chain" not in joined_check:
|
|
if re.search(r"class \w+Chain\w*\b|setNext\s*\(|handleNext\s*\(", head):
|
|
parts.append("Chain of Responsibility")
|
|
if "memento" not in joined_check:
|
|
if re.search(r"class \w+Memento\b", head):
|
|
parts.append("Memento pattern")
|
|
|
|
# Fowler / DDD patterns
|
|
if "specification" not in joined_check:
|
|
if re.search(r"class \w+Specification\b|isSatisfiedBy\s*\(", head):
|
|
parts.append("Specification pattern")
|
|
if "aggregate" not in joined_check:
|
|
if re.search(r"class \w+Aggregate\b", head):
|
|
parts.append("aggregate root")
|
|
if "unitofwork" not in joined_check.replace(" ", ""):
|
|
if re.search(r"class \w+UnitOfWork\b", head):
|
|
parts.append("Unit of Work")
|
|
if "domainevent" not in joined_check.replace(" ", ""):
|
|
if re.search(r"class \w+DomainEvent\b|class \w+Event\b(?!Emitter|Handler|Listener)", head):
|
|
parts.append("domain event")
|
|
if "identitymap" not in joined_check.replace(" ", ""):
|
|
if re.search(r"class \w+IdentityMap\b", head):
|
|
parts.append("Identity Map")
|
|
if "value object" not in joined_check:
|
|
if re.search(r"class \w+ValueObject\b", head):
|
|
parts.append("value object")
|
|
|
|
# NestJS decorators -- only label when entity-name heuristics didn't
|
|
# provide a more specific purpose (controller, service, gateway, etc.)
|
|
_specific_kws = {"controller", "service", "repository", "guard", "gateway",
|
|
"adapter", "provider", "processor", "registry", "agent",
|
|
"resolver", "scheduler", "worker", "store", "handler",
|
|
"interceptor", "pipe", "listener", "subscriber",
|
|
"dispatcher", "emitter", "factory", "builder", "observer",
|
|
"strategy", "command", "mediator", "visitor", "proxy",
|
|
"facade", "composite", "specification", "aggregate",
|
|
"policy", "plugin", "manager", "client", "connector",
|
|
"sdk", "api client"}
|
|
joined_after = " ".join(parts).lower()
|
|
has_specific = any(kw in joined_after for kw in _specific_kws)
|
|
if not has_specific:
|
|
if re.search(r"@Controller\b", head):
|
|
parts.append("API controller")
|
|
elif re.search(r"@Module\b", head):
|
|
parts.append("NestJS wiring module")
|
|
elif re.search(r"@Injectable\b", head):
|
|
parts.append("NestJS injectable")
|
|
|
|
if re.search(r"Router|router\s*\(|createRouter", head):
|
|
if "router" not in " ".join(parts).lower():
|
|
parts.append("router")
|
|
if re.search(r"middleware|Middleware", head):
|
|
if "middleware" not in " ".join(parts).lower():
|
|
parts.append("middleware")
|
|
|
|
# --- File-stem based heuristics ---
|
|
stem = Path(path).stem.lower()
|
|
for sfx in (".test", ".spec", ".e2e", ".stories", ".d"):
|
|
if stem.endswith(sfx):
|
|
stem = stem[: -len(sfx)]
|
|
|
|
if not parts:
|
|
if stem.endswith("dto") or stem.endswith("dtos"):
|
|
parts.append("data transfer object")
|
|
elif stem.endswith("mapper") or stem.endswith("mappers"):
|
|
parts.append("data mapper")
|
|
elif stem.endswith("helpers") or stem.endswith("helper") or stem.endswith("utils") or stem.endswith("util"):
|
|
parts.append("utility helpers")
|
|
elif stem.endswith("constants") or stem.endswith("const"):
|
|
parts.append("constants")
|
|
elif stem.endswith("types") or stem.endswith("type"):
|
|
parts.append("type definitions")
|
|
elif stem.endswith("interfaces") or stem.endswith("interface"):
|
|
parts.append("interface definitions")
|
|
elif stem.endswith("enums") or stem.endswith("enum"):
|
|
parts.append("enum definitions")
|
|
elif stem.endswith("models") or stem.endswith("model"):
|
|
parts.append("data model")
|
|
elif stem.endswith("entities") or stem.endswith("entity"):
|
|
parts.append("domain entity")
|
|
elif stem.endswith("errors") or stem.endswith("error") or stem.endswith("exceptions") or stem.endswith("exception"):
|
|
parts.append("error definitions")
|
|
elif stem.endswith("factories") or stem.endswith("factory"):
|
|
parts.append("factory")
|
|
elif stem.endswith("validators") or stem.endswith("validator"):
|
|
parts.append("validation")
|
|
elif stem.endswith("handlers") or stem.endswith("handler"):
|
|
parts.append("event handler")
|
|
elif stem.endswith("listeners") or stem.endswith("listener"):
|
|
parts.append("event listener")
|
|
elif stem.endswith("middleware"):
|
|
parts.append("middleware")
|
|
elif stem.endswith("interceptor"):
|
|
parts.append("interceptor")
|
|
elif stem.endswith("guard"):
|
|
parts.append("access guard")
|
|
elif stem.endswith("pipe"):
|
|
parts.append("validation pipe")
|
|
elif stem.endswith("decorators") or stem.endswith("decorator"):
|
|
parts.append("decorator")
|
|
elif stem.endswith("migration"):
|
|
parts.append("database migration")
|
|
elif stem.endswith("seed") or stem.endswith("seeder"):
|
|
parts.append("database seed")
|
|
elif stem.endswith("fixtures") or stem.endswith("fixture"):
|
|
parts.append("test fixture")
|
|
elif stem.endswith("mocks") or stem.endswith("mock"):
|
|
parts.append("test mock")
|
|
# Core architecture roles (stem-based)
|
|
elif stem.endswith("controller") or stem.endswith("controllers"):
|
|
parts.append("API controller")
|
|
elif stem.endswith("service") or stem.endswith("services"):
|
|
parts.append("service")
|
|
elif stem.endswith("repository") or stem.endswith("repositories"):
|
|
parts.append("repository")
|
|
elif stem.endswith("gateway") or stem.endswith("gateways"):
|
|
parts.append("gateway")
|
|
elif stem.endswith("registry") or stem.endswith("registries"):
|
|
parts.append("registry")
|
|
elif stem.endswith("agent") or stem.endswith("agents"):
|
|
parts.append("agent")
|
|
elif stem.endswith("store") or stem.endswith("stores"):
|
|
parts.append("state store")
|
|
elif stem.endswith("policy") or stem.endswith("policies"):
|
|
parts.append("policy")
|
|
elif stem.endswith("resolver") or stem.endswith("resolvers"):
|
|
parts.append("resolver")
|
|
elif stem.endswith("scheduler") or stem.endswith("schedulers"):
|
|
parts.append("scheduler")
|
|
elif stem.endswith("worker") or stem.endswith("workers"):
|
|
parts.append("worker")
|
|
elif stem.endswith("client") or stem.endswith("clients"):
|
|
parts.append("client")
|
|
elif stem.endswith("connector") or stem.endswith("connectors"):
|
|
parts.append("connector")
|
|
elif stem.endswith("manager") or stem.endswith("managers"):
|
|
parts.append("manager")
|
|
elif stem.endswith("subscriber") or stem.endswith("subscribers"):
|
|
parts.append("subscriber")
|
|
elif stem.endswith("dispatcher") or stem.endswith("dispatchers"):
|
|
parts.append("dispatcher")
|
|
elif stem.endswith("emitter") or stem.endswith("emitters"):
|
|
parts.append("emitter")
|
|
elif stem.endswith("adapter") or stem.endswith("adapters"):
|
|
parts.append("adapter")
|
|
elif stem.endswith("provider") or stem.endswith("providers"):
|
|
parts.append("provider")
|
|
elif stem.endswith("processor") or stem.endswith("processors"):
|
|
parts.append("processor")
|
|
# GoF pattern stems
|
|
elif stem.endswith("observer"):
|
|
parts.append("Observer pattern")
|
|
elif stem.endswith("builder"):
|
|
parts.append("Builder pattern")
|
|
elif stem.endswith("visitor"):
|
|
parts.append("Visitor pattern")
|
|
elif stem.endswith("mediator"):
|
|
parts.append("Mediator pattern")
|
|
elif stem.endswith("facade"):
|
|
parts.append("Facade pattern")
|
|
elif stem.endswith("proxy"):
|
|
parts.append("Proxy pattern")
|
|
elif stem.endswith("strategy") or stem.endswith("strategies"):
|
|
parts.append("Strategy pattern")
|
|
elif stem.endswith("command") or stem.endswith("commands"):
|
|
parts.append("Command pattern")
|
|
elif stem.endswith("singleton"):
|
|
parts.append("Singleton pattern")
|
|
elif stem.endswith("composite"):
|
|
parts.append("Composite pattern")
|
|
# Fowler / DDD pattern stems
|
|
elif stem.endswith("aggregate") or stem.endswith("aggregates"):
|
|
parts.append("aggregate root")
|
|
elif stem.endswith("specification") or stem.endswith("specifications"):
|
|
parts.append("Specification pattern")
|
|
elif stem.endswith("plugin") or stem.endswith("plugins"):
|
|
parts.append("plugin")
|
|
elif stem.endswith("sdk"):
|
|
parts.append("SDK")
|
|
elif stem.endswith("api"):
|
|
parts.append("API client")
|
|
elif stem in ("index", "__init__"):
|
|
parts.append("module entry point")
|
|
elif stem in ("main", "app"):
|
|
parts.append("application entry point")
|
|
elif stem in ("setup", "bootstrap"):
|
|
parts.append("application bootstrap")
|
|
|
|
# --- Fuzzy stem fallback (distance <= 1, stems >= 4 chars) ---
|
|
_STEM_SUFFIX_MAP = [
|
|
("controller", "API controller"), ("service", "service"),
|
|
("repository", "repository"), ("gateway", "gateway"),
|
|
("registry", "registry"), ("agent", "agent"),
|
|
("store", "state store"), ("factory", "factory"),
|
|
("strategy", "Strategy pattern"), ("observer", "Observer pattern"),
|
|
("builder", "Builder pattern"), ("command", "Command pattern"),
|
|
("mediator", "Mediator pattern"), ("visitor", "Visitor pattern"),
|
|
("facade", "Facade pattern"), ("proxy", "Proxy pattern"),
|
|
("composite", "Composite pattern"), ("aggregate", "aggregate root"),
|
|
("specification", "Specification pattern"), ("adapter", "adapter"),
|
|
("provider", "provider"), ("processor", "processor"),
|
|
("handler", "event handler"), ("listener", "event listener"),
|
|
("middleware", "middleware"), ("interceptor", "interceptor"),
|
|
("scheduler", "scheduler"), ("worker", "worker"),
|
|
("dispatcher", "dispatcher"), ("subscriber", "subscriber"),
|
|
("resolver", "resolver"), ("manager", "manager"),
|
|
("connector", "connector"), ("client", "client"),
|
|
("policy", "policy"), ("plugin", "plugin"),
|
|
]
|
|
if not parts and len(stem) >= 5:
|
|
for sfx, purpose_label in _STEM_SUFFIX_MAP:
|
|
if _fuzzy_suffix_match(stem, sfx):
|
|
parts.append(purpose_label)
|
|
break
|
|
|
|
# --- Content-based heuristics (checked against first 4K) ---
|
|
head4k = content[:4096]
|
|
if not parts:
|
|
if re.search(r"export\s+(?:const\s+)?enum\s+", head4k):
|
|
parts.append("enum definitions")
|
|
elif re.search(r"export\s+(?:type|interface)\s+", head4k) and not re.search(
|
|
r"export\s+(?:class|function|const\s+\w+\s*=)", head4k
|
|
):
|
|
parts.append("type definitions")
|
|
elif re.search(r"^export\s+\{", head4k, re.MULTILINE) and not re.search(
|
|
r"^(?:class|function|const|let|var)\s", head4k, re.MULTILINE
|
|
):
|
|
parts.append("barrel re-exports")
|
|
elif re.search(r"export\s+(?:default\s+)?(?:const\s+)?(?:config|CONFIG|configuration)\b", head4k):
|
|
parts.append("configuration")
|
|
elif (
|
|
language == "python"
|
|
and re.search(r"^from\s+\.\w+\s+import\s+", head4k, re.MULTILINE)
|
|
and len(content) < 500
|
|
):
|
|
parts.append("package re-exports")
|
|
|
|
# --- Entity-kind heuristic: only type/interface/enum entities => type definitions ---
|
|
if not parts and entities:
|
|
kinds = {
|
|
(ent.get("kind", "") if isinstance(ent, dict) else ent.kind)
|
|
for ent in entities
|
|
}
|
|
if kinds and kinds <= {"interface", "type", "enum"}:
|
|
if kinds == {"enum"}:
|
|
parts.append("enum definitions")
|
|
else:
|
|
parts.append("type definitions")
|
|
|
|
return "; ".join(parts) if parts else "general module"
|
|
|
|
|
|
def detect_validation(content: str, language: str) -> bool:
|
|
if language == "python":
|
|
# Explicit validation frameworks
|
|
if re.search(
|
|
r"pydantic|BaseModel|validator|field_validator|Annotated\[|attrs|@attr",
|
|
content[:4096],
|
|
):
|
|
return True
|
|
# Typed dataclass schemas used as validation boundary (frozen=True or type-annotated)
|
|
if re.search(r"@dataclass", content[:4096]) and re.search(
|
|
r"frozen\s*=\s*True|def\s+__post_init__|:\s*(?:str|int|float|bool|list|dict|Optional)", content[:4096],
|
|
):
|
|
return True
|
|
# TypedDict / NamedTuple schemas
|
|
if re.search(r"TypedDict|NamedTuple", content[:4096]):
|
|
return True
|
|
return False
|
|
return bool(re.search(
|
|
r"\.safeParse|\.parse\(|zod\.|z\.|Joi\.|yup\.|ajv|validateSchema",
|
|
content[:4096],
|
|
))
|
|
|
|
|
|
def path_to_uri(path: str) -> str:
|
|
"""Convert an absolute file path to a file:// URI."""
|
|
abs_path = os.path.abspath(path)
|
|
return "file://" + quote(abs_path, safe="/:")
|
|
|
|
|
|
def uri_to_path(uri: str) -> str:
|
|
"""Convert a file:// URI to an absolute file path."""
|
|
if uri.startswith("file://"):
|
|
return unquote(uri[7:])
|
|
return uri
|
|
|
|
|
|
def find_entity_column(content: str, entity_name: str, line_1based: int) -> int:
|
|
"""Find the 0-based column of *entity_name* on the given 1-based line."""
|
|
lines = content.split("\n")
|
|
if line_1based < 1 or line_1based > len(lines):
|
|
return 0
|
|
line_text = lines[line_1based - 1]
|
|
idx = line_text.find(entity_name)
|
|
return max(idx, 0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Python parser
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def parse_python(path: str, content: str, repo: str, mtime: float) -> FileResult:
|
|
imports: list[str] = []
|
|
exports_abstract: list[str] = []
|
|
implements: list[str] = []
|
|
extends: list[str] = []
|
|
entities: list[Entity] = []
|
|
imported_names: list[ImportedName] = []
|
|
exported_names: list[str] = []
|
|
|
|
try:
|
|
tree = ast.parse(content, filename=path)
|
|
|
|
for node in ast.iter_child_nodes(tree):
|
|
# -- imports --
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
imports.append(alias.name)
|
|
local = alias.asname or alias.name.split(".")[-1]
|
|
imported_names.append(ImportedName(
|
|
local_name=local,
|
|
original_name=alias.name,
|
|
module=alias.name,
|
|
))
|
|
elif isinstance(node, ast.ImportFrom):
|
|
mod = node.module or ""
|
|
if mod:
|
|
imports.append(mod)
|
|
for alias in node.names:
|
|
local = alias.asname or alias.name
|
|
imported_names.append(ImportedName(
|
|
local_name=local,
|
|
original_name=alias.name,
|
|
module=mod,
|
|
))
|
|
|
|
# -- classes --
|
|
elif isinstance(node, ast.ClassDef):
|
|
methods = []
|
|
decorators = [
|
|
_decorator_name(d) for d in node.decorator_list
|
|
]
|
|
for item in node.body:
|
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
methods.append(item.name)
|
|
bases_list = [_name_of(b) for b in node.bases if _name_of(b)]
|
|
entities.append(Entity(
|
|
name=node.name,
|
|
kind="class",
|
|
line=node.lineno,
|
|
methods=methods,
|
|
decorators=decorators,
|
|
bases=bases_list,
|
|
))
|
|
exported_names.append(node.name)
|
|
extends.extend(bases_list)
|
|
for b in bases_list:
|
|
if b in ("ABC", "Protocol") or "Base" in b:
|
|
implements.append(b)
|
|
|
|
# -- functions --
|
|
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
decorators = [
|
|
_decorator_name(d) for d in node.decorator_list
|
|
]
|
|
entities.append(Entity(
|
|
name=node.name,
|
|
kind="function",
|
|
line=node.lineno,
|
|
methods=[],
|
|
decorators=decorators,
|
|
bases=[],
|
|
))
|
|
exported_names.append(node.name)
|
|
|
|
# -- top-level assignments (constants, type aliases) --
|
|
elif isinstance(node, ast.Assign):
|
|
for target in node.targets:
|
|
if isinstance(target, ast.Name) and target.id.isupper():
|
|
exported_names.append(target.id)
|
|
|
|
except SyntaxError:
|
|
# Fallback: regex for imports only
|
|
for m in re.finditer(
|
|
r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))",
|
|
content, re.MULTILINE,
|
|
):
|
|
imp = m.group(1) or m.group(2)
|
|
if imp:
|
|
imports.append(imp)
|
|
|
|
# Detect ABC / Protocol definitions
|
|
for m in PY_ABC_RE.finditer(content):
|
|
name = m.group(0).split("(")[0].split()[-1]
|
|
if name not in exports_abstract:
|
|
exports_abstract.append(name)
|
|
|
|
return FileResult(
|
|
path=path, language="python", repo=repo, mtime=mtime,
|
|
content_hash=file_hash(path),
|
|
imports=imports, exports_abstract=exports_abstract,
|
|
implements=implements, extends=extends,
|
|
purpose=infer_purpose(path, content, "python", [e.to_dict() for e in entities]),
|
|
naming_convention=detect_naming(path),
|
|
has_validation=detect_validation(content, "python"),
|
|
entities=[e.to_dict() for e in entities],
|
|
imported_names=[n.to_dict() for n in imported_names],
|
|
exported_names=exported_names,
|
|
)
|
|
|
|
|
|
def _decorator_name(node: ast.expr) -> str:
|
|
if isinstance(node, ast.Name):
|
|
return node.id
|
|
if isinstance(node, ast.Attribute):
|
|
return node.attr
|
|
if isinstance(node, ast.Call):
|
|
return _decorator_name(node.func)
|
|
return ""
|
|
|
|
|
|
def _name_of(node: ast.expr) -> str:
|
|
if isinstance(node, ast.Name):
|
|
return node.id
|
|
if isinstance(node, ast.Attribute):
|
|
return node.attr
|
|
return ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TypeScript parser (tree-sitter)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ts_get_decorators(node) -> list[str]: # type: ignore[no-untyped-def]
|
|
"""Extract @Decorator names from a node's preceding decorator nodes."""
|
|
decorators = []
|
|
for child in node.children:
|
|
if child.type == "decorator":
|
|
# decorator -> @expression -- get the identifier
|
|
for dchild in child.children:
|
|
if dchild.type == "identifier":
|
|
decorators.append(dchild.text.decode("utf-8"))
|
|
elif dchild.type == "call_expression":
|
|
fn = dchild.child_by_field_name("function")
|
|
if fn:
|
|
decorators.append(fn.text.decode("utf-8").split(".")[-1])
|
|
return decorators
|
|
|
|
|
|
def _ts_get_heritage(node) -> tuple[list[str], list[str]]: # type: ignore[no-untyped-def]
|
|
"""Extract extends and implements from class_heritage."""
|
|
extends: list[str] = []
|
|
implements: list[str] = []
|
|
for child in node.children:
|
|
if child.type == "class_heritage":
|
|
for hchild in child.children:
|
|
if hchild.type == "extends_clause":
|
|
for t in hchild.children:
|
|
if t.type in ("type_identifier", "identifier"):
|
|
extends.append(t.text.decode("utf-8"))
|
|
elif hchild.type == "implements_clause":
|
|
for t in hchild.children:
|
|
if t.type in ("type_identifier", "identifier"):
|
|
implements.append(t.text.decode("utf-8"))
|
|
return extends, implements
|
|
|
|
|
|
def _ts_get_methods(node) -> list[str]: # type: ignore[no-untyped-def]
|
|
"""Extract method/property names from a class or interface body."""
|
|
methods = []
|
|
body = node.child_by_field_name("body")
|
|
if not body:
|
|
return methods
|
|
skip = {"constructor", "if", "for", "while", "switch", "return"}
|
|
for member in body.children:
|
|
if member.type in ("method_definition", "method_signature",
|
|
"public_field_definition", "property_signature",
|
|
"abstract_method_signature"):
|
|
name_node = member.child_by_field_name("name")
|
|
if name_node:
|
|
name = name_node.text.decode("utf-8")
|
|
if name not in skip:
|
|
methods.append(name)
|
|
return methods
|
|
|
|
|
|
# NestJS decorators that imply constructor-parameter dependency injection.
|
|
_NESTJS_INJECTION_DECOS: frozenset[str] = frozenset({
|
|
"Injectable", "Controller", "Module", "Guard", "Interceptor", "Pipe", "Resolver",
|
|
})
|
|
|
|
|
|
def _ts_get_constructor_injections(node) -> list[str]: # type: ignore[no-untyped-def]
|
|
"""Extract interface/type names from constructor parameter types and @Inject decorators.
|
|
|
|
Handles NestJS constructor injection patterns where typed constructor parameters
|
|
declare interface dependencies. Only PascalCase names are returned to avoid
|
|
tracking primitive or built-in types.
|
|
"""
|
|
injected: list[str] = []
|
|
body = node.child_by_field_name("body")
|
|
if not body:
|
|
return injected
|
|
for member in body.children:
|
|
if member.type != "method_definition":
|
|
continue
|
|
name_node = member.child_by_field_name("name")
|
|
if not name_node or not name_node.text:
|
|
continue
|
|
if name_node.text.decode("utf-8") != "constructor":
|
|
continue
|
|
params = member.child_by_field_name("parameters")
|
|
if not params:
|
|
continue
|
|
for param in params.children:
|
|
if param.type not in ("required_parameter", "optional_parameter"):
|
|
continue
|
|
# Check for @Inject(TOKEN) decorator on the parameter
|
|
for child in param.children:
|
|
if child.type == "decorator":
|
|
for dc in child.children:
|
|
if dc.type == "call_expression":
|
|
fn = dc.child_by_field_name("function")
|
|
if fn and fn.text and fn.text.decode("utf-8") == "Inject":
|
|
args = dc.child_by_field_name("arguments")
|
|
if args:
|
|
for arg in args.children:
|
|
if arg.type in ("identifier", "string", "template_string"):
|
|
text = arg.text
|
|
if text:
|
|
token = text.decode("utf-8").strip("'\"`")
|
|
if token and token[0].isupper():
|
|
injected.append(token)
|
|
# Check type annotation on the parameter
|
|
type_ann = param.child_by_field_name("type")
|
|
if type_ann:
|
|
for tc in type_ann.children:
|
|
if tc.type in ("type_identifier", "identifier"):
|
|
ttext = tc.text
|
|
if ttext:
|
|
tname = ttext.decode("utf-8")
|
|
if tname and tname[0].isupper():
|
|
injected.append(tname)
|
|
return injected
|
|
|
|
|
|
def _ts_get_obj_keys(node) -> list[str]: # type: ignore[no-untyped-def]
|
|
"""Extract top-level property names from an object literal."""
|
|
keys = []
|
|
for child in node.children:
|
|
if child.type in ("pair", "method_definition", "shorthand_property_identifier"):
|
|
if child.type == "shorthand_property_identifier":
|
|
keys.append(child.text.decode("utf-8"))
|
|
else:
|
|
key = child.child_by_field_name("key")
|
|
if key:
|
|
keys.append(key.text.decode("utf-8"))
|
|
return keys
|
|
|
|
|
|
def parse_typescript_treesitter(
|
|
path: str, content: str, repo: str, mtime: float,
|
|
) -> FileResult:
|
|
"""Parse a TypeScript file using tree-sitter for precise entity extraction."""
|
|
lang = _TSX_LANG if path.endswith(".tsx") else _TS_LANG
|
|
parser = _TsParser(lang)
|
|
tree = parser.parse(content.encode("utf-8"))
|
|
root = tree.root_node
|
|
|
|
imports: list[str] = []
|
|
exports_abstract: list[str] = []
|
|
implements_list: list[str] = []
|
|
extends_list: list[str] = []
|
|
entities: list[Entity] = []
|
|
imported_names: list[ImportedName] = []
|
|
exported_names: list[str] = []
|
|
|
|
for node in root.children:
|
|
is_export = node.type == "export_statement"
|
|
target = node
|
|
if is_export:
|
|
# The actual declaration is a child of export_statement.
|
|
# Skip keywords, decorators, and punctuation to find the declaration.
|
|
for child in node.children:
|
|
if child.type in ("export", "default", "comment", "decorator",
|
|
"{", "}", ",", ";"):
|
|
continue
|
|
target = child
|
|
break
|
|
|
|
# -- Imports --
|
|
if node.type == "import_statement":
|
|
source_node = node.child_by_field_name("source")
|
|
if source_node:
|
|
specifier = source_node.text.decode("utf-8").strip("'\"")
|
|
imports.append(specifier)
|
|
# Extract named imports
|
|
for child in node.children:
|
|
if child.type == "import_clause":
|
|
for ic in child.children:
|
|
if ic.type == "identifier":
|
|
# default import
|
|
imported_names.append(ImportedName(
|
|
local_name=ic.text.decode("utf-8"),
|
|
original_name="default",
|
|
module=specifier,
|
|
))
|
|
elif ic.type == "named_imports":
|
|
for spec in ic.children:
|
|
if spec.type == "import_specifier":
|
|
name_node = spec.child_by_field_name("name")
|
|
alias_node = spec.child_by_field_name("alias")
|
|
if name_node:
|
|
orig = name_node.text.decode("utf-8")
|
|
local = alias_node.text.decode("utf-8") if alias_node else orig
|
|
imported_names.append(ImportedName(
|
|
local_name=local,
|
|
original_name=orig,
|
|
module=specifier,
|
|
))
|
|
|
|
# -- Class --
|
|
elif target.type == "class_declaration":
|
|
name_node = target.child_by_field_name("name")
|
|
if not name_node:
|
|
continue
|
|
name = name_node.text.decode("utf-8")
|
|
line = target.start_point[0] + 1
|
|
decorators = _ts_get_decorators(node if is_export else target)
|
|
ext, impl = _ts_get_heritage(target)
|
|
methods = _ts_get_methods(target)
|
|
extends_list.extend(ext)
|
|
implements_list.extend(impl)
|
|
# NestJS @Injectable/@Controller/@Module: constructor param types are
|
|
# interface dependencies that should create CALLS_API edges.
|
|
if _NESTJS_INJECTION_DECOS.intersection(decorators):
|
|
constructor_deps = _ts_get_constructor_injections(target)
|
|
implements_list.extend(constructor_deps)
|
|
# Check if abstract
|
|
is_abstract = any(c.type == "abstract" for c in target.children)
|
|
if is_abstract:
|
|
exports_abstract.append(name)
|
|
entities.append(Entity(
|
|
name=name, kind="class", line=line,
|
|
methods=methods, decorators=decorators, bases=ext + impl,
|
|
))
|
|
exported_names.append(name)
|
|
|
|
# -- Interface --
|
|
elif target.type == "interface_declaration":
|
|
name_node = target.child_by_field_name("name")
|
|
if not name_node:
|
|
continue
|
|
name = name_node.text.decode("utf-8")
|
|
line = target.start_point[0] + 1
|
|
methods = _ts_get_methods(target)
|
|
exports_abstract.append(name)
|
|
entities.append(Entity(
|
|
name=name, kind="interface", line=line,
|
|
methods=methods, decorators=[], bases=[],
|
|
))
|
|
exported_names.append(name)
|
|
|
|
# -- Type alias --
|
|
elif target.type == "type_alias_declaration":
|
|
name_node = target.child_by_field_name("name")
|
|
if not name_node:
|
|
continue
|
|
name = name_node.text.decode("utf-8")
|
|
line = target.start_point[0] + 1
|
|
entities.append(Entity(
|
|
name=name, kind="type", line=line,
|
|
methods=[], decorators=[], bases=[],
|
|
))
|
|
exported_names.append(name)
|
|
|
|
# -- Enum --
|
|
elif target.type == "enum_declaration":
|
|
name_node = target.child_by_field_name("name")
|
|
if not name_node:
|
|
continue
|
|
name = name_node.text.decode("utf-8")
|
|
line = target.start_point[0] + 1
|
|
entities.append(Entity(
|
|
name=name, kind="enum", line=line,
|
|
methods=[], decorators=[], bases=[],
|
|
))
|
|
exported_names.append(name)
|
|
|
|
# -- Function --
|
|
elif target.type == "function_declaration":
|
|
name_node = target.child_by_field_name("name")
|
|
if not name_node:
|
|
continue
|
|
name = name_node.text.decode("utf-8")
|
|
line = target.start_point[0] + 1
|
|
decorators = _ts_get_decorators(node if is_export else target)
|
|
entities.append(Entity(
|
|
name=name, kind="function", line=line,
|
|
methods=[], decorators=decorators, bases=[],
|
|
))
|
|
exported_names.append(name)
|
|
|
|
# -- Lexical declaration (const/let) --
|
|
elif target.type == "lexical_declaration":
|
|
for declarator in target.children:
|
|
if declarator.type != "variable_declarator":
|
|
continue
|
|
name_node = declarator.child_by_field_name("name")
|
|
if not name_node or name_node.type != "identifier":
|
|
continue
|
|
name = name_node.text.decode("utf-8")
|
|
line = declarator.start_point[0] + 1
|
|
value = declarator.child_by_field_name("value")
|
|
if not value:
|
|
continue
|
|
# Object literal: extract keys as methods
|
|
if value.type == "object":
|
|
obj_keys = _ts_get_obj_keys(value)
|
|
entities.append(Entity(
|
|
name=name, kind="function", line=line,
|
|
methods=obj_keys, decorators=[], bases=[],
|
|
))
|
|
# new Constructor(...)
|
|
elif value.type == "new_expression":
|
|
entities.append(Entity(
|
|
name=name, kind="function", line=line,
|
|
methods=[], decorators=[], bases=[],
|
|
))
|
|
# Arrow function or call expression
|
|
elif value.type in ("arrow_function", "call_expression"):
|
|
entities.append(Entity(
|
|
name=name, kind="function", line=line,
|
|
methods=[], decorators=[], bases=[],
|
|
))
|
|
# Other const (e.g., z.object, string literal, etc.)
|
|
else:
|
|
entities.append(Entity(
|
|
name=name, kind="function", line=line,
|
|
methods=[], decorators=[], bases=[],
|
|
))
|
|
exported_names.append(name)
|
|
|
|
file_lang = "tsx" if path.endswith(".tsx") else "typescript"
|
|
return FileResult(
|
|
path=path, language=file_lang, repo=repo, mtime=mtime,
|
|
content_hash=file_hash(path),
|
|
imports=imports, exports_abstract=exports_abstract,
|
|
implements=implements_list, extends=extends_list,
|
|
purpose=infer_purpose(path, content, "typescript", [e.to_dict() for e in entities]),
|
|
naming_convention=detect_naming(path),
|
|
has_validation=detect_validation(content, "typescript"),
|
|
entities=[e.to_dict() for e in entities],
|
|
imported_names=[n.to_dict() for n in imported_names],
|
|
exported_names=exported_names,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Worker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def parse_file(args: tuple[str, str, str]) -> Optional[FileResult]:
|
|
path, language, repo = args
|
|
try:
|
|
mtime = os.path.getmtime(path)
|
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
content = f.read()
|
|
if language == "python":
|
|
return parse_python(path, content, repo, mtime)
|
|
return parse_typescript_treesitter(path, content, repo, mtime)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def discover_files(root: str) -> list[tuple[str, str, str]]:
|
|
results: list[tuple[str, str, str]] = []
|
|
root_path = Path(root).resolve()
|
|
|
|
def _walk(dir_path: Path, repo: str):
|
|
try:
|
|
with os.scandir(dir_path) as it:
|
|
for entry in it:
|
|
if entry.name in SKIP_DIRS:
|
|
continue
|
|
if entry.is_dir(follow_symlinks=False):
|
|
child_repo = repo
|
|
if entry.name != ".git" and (dir_path / entry.name / ".git").exists():
|
|
child_repo = entry.name
|
|
_walk(dir_path / entry.name, child_repo)
|
|
elif entry.is_file(follow_symlinks=False):
|
|
name = entry.name
|
|
if name.endswith(".py"):
|
|
results.append((entry.path, "python", repo))
|
|
elif (name.endswith(".ts") or name.endswith(".tsx")) and not name.endswith(".d.ts"):
|
|
results.append((entry.path, "typescript", repo))
|
|
except PermissionError:
|
|
pass
|
|
|
|
_walk(root_path, root_path.name)
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Incremental cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def load_cache(root: str) -> dict:
|
|
cache_path = os.path.join(root, CACHE_FILE)
|
|
if os.path.exists(cache_path):
|
|
try:
|
|
with open(cache_path, "r") as f:
|
|
return json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
return {}
|
|
|
|
|
|
def save_cache(root: str, cache: dict):
|
|
cache_path = os.path.join(root, CACHE_FILE)
|
|
with open(cache_path, "w") as f:
|
|
json.dump(cache, f)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Import resolution
|
|
# ---------------------------------------------------------------------------
|
|
# TypeScript path alias resolution (tsconfig.json "paths")
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_ts_path_aliases(root: str) -> dict[str, str]:
|
|
"""Scan for tsconfig.json files and build a global alias -> absolute directory map.
|
|
|
|
For each tsconfig with compilerOptions.paths, resolves alias patterns like
|
|
``@sdk/* -> ['../../sdk/src/*']`` relative to the tsconfig's directory.
|
|
|
|
Returns {alias_prefix: absolute_directory} e.g. {"@sdk/": "/abs/path/to/sdk/src/"}.
|
|
"""
|
|
import json as _json
|
|
|
|
alias_map: dict[str, str] = {}
|
|
# Walk up to 4 directory levels for tsconfig files (covers monorepo structures)
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
# Skip node_modules, .git, dist, build
|
|
dirnames[:] = [
|
|
d for d in dirnames
|
|
if d not in ("node_modules", ".git", "dist", "build", ".next", ".cache")
|
|
]
|
|
depth = dirpath[len(root):].count(os.sep)
|
|
if depth > 5:
|
|
dirnames.clear()
|
|
continue
|
|
for fname in filenames:
|
|
if fname == "tsconfig.json":
|
|
tsconfig_path = os.path.join(dirpath, fname)
|
|
try:
|
|
with open(tsconfig_path, "r") as f:
|
|
raw = f.read()
|
|
# Strip single-line comments (tsconfig allows them)
|
|
raw = re.sub(r"//.*$", "", raw, flags=re.MULTILINE)
|
|
# Strip trailing commas before } or ]
|
|
raw = re.sub(r",(\s*[}\]])", r"\1", raw)
|
|
data = _json.loads(raw)
|
|
paths = data.get("compilerOptions", {}).get("paths", {})
|
|
for alias_pattern, targets in paths.items():
|
|
if not alias_pattern.endswith("/*") or not targets:
|
|
continue
|
|
prefix = alias_pattern[:-1] # "@sdk/*" -> "@sdk/"
|
|
target = targets[0] # Take first target
|
|
if not target.endswith("/*"):
|
|
continue
|
|
target_dir = target[:-2] # "./*" -> ".", "../../sdk/src/*" -> "../../sdk/src"
|
|
abs_target = os.path.normpath(os.path.join(dirpath, target_dir))
|
|
# If target dir has a `src/` subdirectory, prefer it
|
|
# (common monorepo pattern: @sdk/* -> ['./*'] maps to pkg root,
|
|
# but actual source lives in pkg/src/)
|
|
src_subdir = os.path.join(abs_target, "src")
|
|
effective_target = src_subdir if os.path.isdir(src_subdir) else abs_target
|
|
if os.path.isdir(abs_target):
|
|
# Longer (more specific) prefixes take priority
|
|
if prefix not in alias_map or len(effective_target) > len(alias_map[prefix]):
|
|
alias_map[prefix] = effective_target
|
|
except Exception:
|
|
continue
|
|
return alias_map
|
|
|
|
|
|
def _resolve_ts_path_alias(
|
|
imp: str,
|
|
source_path: str,
|
|
root: str,
|
|
path_to_id: dict[str, str],
|
|
alias_map: dict[str, str],
|
|
) -> Optional[str]:
|
|
"""Resolve a TypeScript import like '@sdk/llm/llmClientApi' using tsconfig paths.
|
|
|
|
Tries the most specific alias prefix first (longest match).
|
|
"""
|
|
if not alias_map:
|
|
return None
|
|
|
|
# Sort by prefix length descending so "@agent-upgraide/" matches before "@a/"
|
|
for prefix in sorted(alias_map, key=len, reverse=True):
|
|
if imp.startswith(prefix):
|
|
remainder = imp[len(prefix):]
|
|
base_dir = alias_map[prefix]
|
|
resolved = os.path.normpath(os.path.join(base_dir, remainder))
|
|
for ext in ("", ".ts", ".tsx", "/index.ts", "/index.tsx", ".js", "/index.js"):
|
|
candidate = resolved + ext
|
|
if candidate in path_to_id:
|
|
return path_to_id[candidate]
|
|
# path_to_id may use relative paths -- try relative form too
|
|
try:
|
|
rel_candidate = os.path.relpath(candidate, root) if root else candidate
|
|
except ValueError:
|
|
rel_candidate = candidate
|
|
if rel_candidate in path_to_id:
|
|
return path_to_id[rel_candidate]
|
|
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def resolve_import(
|
|
imp: str,
|
|
source_path: str,
|
|
root: str,
|
|
path_to_id: dict[str, str],
|
|
language: str,
|
|
ts_path_aliases: "dict[str, str] | None" = None,
|
|
) -> Optional[str]:
|
|
if language == "typescript":
|
|
if not imp.startswith(".") and not imp.startswith("/"):
|
|
# Try tsconfig paths alias resolution before giving up
|
|
resolved_via_alias = _resolve_ts_path_alias(
|
|
imp, source_path, root, path_to_id, ts_path_aliases or {},
|
|
)
|
|
if resolved_via_alias:
|
|
return resolved_via_alias
|
|
return None
|
|
source_dir = os.path.dirname(source_path)
|
|
resolved = os.path.normpath(os.path.join(source_dir, imp))
|
|
for ext in ("", ".ts", ".tsx", "/index.ts", "/index.tsx", ".js", "/index.js"):
|
|
candidate = resolved + ext
|
|
if candidate in path_to_id:
|
|
return path_to_id[candidate]
|
|
elif language == "python":
|
|
parts = imp.split(".")
|
|
# Build candidate base directories: walk up from source_dir to root,
|
|
# collecting every directory that contains the first import segment.
|
|
bases: list[str] = [root, os.path.dirname(source_path)]
|
|
first_segment = parts[0] if parts else ""
|
|
cur = os.path.dirname(source_path)
|
|
while cur and cur >= root:
|
|
if os.path.isdir(os.path.join(cur, first_segment)):
|
|
if cur not in bases:
|
|
bases.append(cur)
|
|
parent = os.path.dirname(cur)
|
|
if parent == cur:
|
|
break
|
|
cur = parent
|
|
for base in bases:
|
|
# Try direct .py file first, then __init__.py
|
|
for ext in (".py", "/__init__.py"):
|
|
if ext == ".py":
|
|
candidate = os.path.join(base, *parts) + ext
|
|
else:
|
|
candidate = os.path.join(base, *parts, "__init__.py")
|
|
candidate = os.path.normpath(candidate)
|
|
if candidate in path_to_id:
|
|
return path_to_id[candidate]
|
|
# Gap 4 fix: "from modules.commons import json_doc_sqlite"
|
|
# The AST records module="modules.commons", name="json_doc_sqlite".
|
|
# Check if the imported names include a sub-module file.
|
|
if len(parts) >= 2:
|
|
pkg_dir = os.path.join(base, *parts)
|
|
if os.path.isdir(pkg_dir):
|
|
# This is a package -- imported names may be sub-modules.
|
|
# resolve_import is called per import specifier (module level),
|
|
# so we already resolved to __init__.py above. The sub-module
|
|
# resolution happens in _resolve_submodule_imports below.
|
|
pass
|
|
return None
|
|
|
|
|
|
def _resolve_submodule_imports(
|
|
result: "FileResult",
|
|
root: str,
|
|
path_to_id: dict[str, str],
|
|
) -> list[str]:
|
|
"""
|
|
Fix Python 'from package import submodule' patterns.
|
|
|
|
When `from modules.commons import json_doc_sqlite` is parsed, the AST
|
|
records module='modules.commons' and name='json_doc_sqlite'. If
|
|
json_doc_sqlite.py exists as a sibling file, add it as an extra import.
|
|
"""
|
|
extra_imports: list[str] = []
|
|
if result.language != "python":
|
|
return extra_imports
|
|
|
|
# Build base dirs: walk up from source to root (same as resolve_import)
|
|
all_bases: list[str] = [root, os.path.dirname(result.path)]
|
|
for imp_name in result.imported_names:
|
|
mod = imp_name.get("module", "") if isinstance(imp_name, dict) else imp_name.module
|
|
orig = imp_name.get("original", "") if isinstance(imp_name, dict) else imp_name.original_name
|
|
if not mod or not orig:
|
|
continue
|
|
parts = mod.split(".")
|
|
first_seg = parts[0] if parts else ""
|
|
|
|
# Collect all viable bases including walk-up
|
|
bases = list(all_bases)
|
|
cur = os.path.dirname(result.path)
|
|
while cur and cur >= root:
|
|
if os.path.isdir(os.path.join(cur, first_seg)):
|
|
if cur not in bases:
|
|
bases.append(cur)
|
|
parent = os.path.dirname(cur)
|
|
if parent == cur:
|
|
break
|
|
cur = parent
|
|
|
|
for base in bases:
|
|
submod_path = os.path.normpath(os.path.join(base, *parts, orig + ".py"))
|
|
if submod_path in path_to_id:
|
|
target_id = path_to_id[submod_path]
|
|
if target_id not in extra_imports:
|
|
extra_imports.append(target_id)
|
|
break
|
|
|
|
return extra_imports
|
|
|
|
|
|
def make_node_id(path: str, root: str) -> str:
|
|
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
|
for ext in (".tsx", ".ts", ".py"):
|
|
if rel.endswith(ext):
|
|
rel = rel[: -len(ext)]
|
|
break
|
|
if rel.endswith("/index"):
|
|
rel = rel[: -len("/index")]
|
|
return rel
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Graph building
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_graph(
|
|
results: list[FileResult], root: str,
|
|
) -> tuple[list[dict], list[dict]]:
|
|
path_to_id: dict[str, str] = {}
|
|
id_to_result: dict[str, FileResult] = {}
|
|
|
|
for r in results:
|
|
node_id = make_node_id(r.path, root)
|
|
path_to_id[r.path] = node_id
|
|
id_to_result[node_id] = r
|
|
|
|
# Load tsconfig.json path aliases for resolving @sdk/*, @agent-*/* etc.
|
|
ts_path_aliases = _load_ts_path_aliases(root)
|
|
if ts_path_aliases:
|
|
print(f"[belief-map] Loaded {len(ts_path_aliases)} TS path aliases", file=sys.stderr)
|
|
|
|
# Map: abstract/interface name -> defining node id
|
|
abstract_providers: dict[str, str] = {}
|
|
for nid, r in id_to_result.items():
|
|
for ab in r.exports_abstract:
|
|
abstract_providers[ab] = nid
|
|
|
|
# Also register ALL interface-kind entities regardless of whether they are in
|
|
# exports_abstract (which only captures explicit exports). Non-exported interfaces
|
|
# used locally and interfaces from imported modules are resolved by bare name at
|
|
# the CALLS_API detection step, so they must be present in this map.
|
|
for nid, r in id_to_result.items():
|
|
for ent in r.entities:
|
|
ent_kind = ent.get("kind", "") if isinstance(ent, dict) else getattr(ent, "kind", "")
|
|
ent_name = ent.get("name", "") if isinstance(ent, dict) else getattr(ent, "name", "")
|
|
if ent_kind == "interface" and ent_name and ent_name not in abstract_providers:
|
|
abstract_providers[ent_name] = nid
|
|
|
|
# Map: (node_id, entity_name) -> True (for cross-file entity lookups)
|
|
entity_index: dict[str, str] = {} # "EntityName" -> "node_id"
|
|
for nid, r in id_to_result.items():
|
|
for name in r.exported_names:
|
|
# First definition wins (avoids overwrites from re-exports)
|
|
if name not in entity_index:
|
|
entity_index[name] = nid
|
|
|
|
# Register any entity that is used as a base class by other classes.
|
|
# This catches BaseModel, CanActivate, BaseProcessor, BaseAgent, etc.
|
|
# that are defined in the codebase but not marked as abstract/interface.
|
|
all_bases: set[str] = set()
|
|
for r in results:
|
|
for base in r.implements + r.extends:
|
|
all_bases.add(base)
|
|
for base_name in all_bases:
|
|
if base_name not in abstract_providers and base_name in entity_index:
|
|
abstract_providers[base_name] = entity_index[base_name]
|
|
|
|
nodes: list[dict] = []
|
|
edges: list[dict] = []
|
|
seen_edges: set[tuple] = set()
|
|
|
|
for node_id, r in id_to_result.items():
|
|
rel_path = os.path.relpath(r.path, root)
|
|
|
|
# Gap 3 fix: enrich purpose with entity-name heuristics
|
|
purpose = r.purpose
|
|
if r.entities and purpose in ("general module", "NestJS wiring module", "NestJS injectable"):
|
|
enhanced = infer_purpose(r.path, "", r.language, r.entities)
|
|
if enhanced not in ("general module", "NestJS wiring module", "NestJS injectable"):
|
|
purpose = enhanced
|
|
|
|
# -- build node with entities --
|
|
node: dict = {
|
|
"id": node_id,
|
|
"language": r.language,
|
|
"path": rel_path,
|
|
"repo": r.repo,
|
|
"invariant": {
|
|
"naming": r.naming_convention,
|
|
"package": r.repo,
|
|
},
|
|
"purpose": purpose,
|
|
}
|
|
if r.entities:
|
|
node["entities"] = r.entities
|
|
nodes.append(node)
|
|
|
|
# -- Gap 4: sub-module Python imports --
|
|
extra_submod_ids = _resolve_submodule_imports(r, root, path_to_id)
|
|
for target_id in extra_submod_ids:
|
|
if target_id != node_id:
|
|
edge_key = (node_id, target_id, "IMPORTS")
|
|
if edge_key not in seen_edges:
|
|
seen_edges.add(edge_key)
|
|
target_r = id_to_result.get(target_id)
|
|
via_base = bool(target_r and target_r.exports_abstract)
|
|
edges.append({
|
|
"source": node_id,
|
|
"target": target_id,
|
|
"type": "IMPORTS",
|
|
"via_base": via_base,
|
|
})
|
|
|
|
# -- module-level IMPORTS edges --
|
|
for imp in r.imports:
|
|
target_id = resolve_import(imp, r.path, root, path_to_id, r.language, ts_path_aliases)
|
|
if target_id and target_id != node_id:
|
|
edge_key = (node_id, target_id, "IMPORTS")
|
|
if edge_key not in seen_edges:
|
|
seen_edges.add(edge_key)
|
|
target_r = id_to_result.get(target_id)
|
|
via_base = bool(target_r and target_r.exports_abstract)
|
|
edges.append({
|
|
"source": node_id,
|
|
"target": target_id,
|
|
"type": "IMPORTS",
|
|
"via_base": via_base,
|
|
})
|
|
|
|
# -- CALLS_API edges (implements/extends + entity :bases) --
|
|
# Source 1: FileResult.implements + extends (from heritage clauses)
|
|
calls_api_targets: set[str] = set()
|
|
for iface in r.implements + r.extends:
|
|
provider_id = abstract_providers.get(iface)
|
|
if provider_id and provider_id != node_id:
|
|
calls_api_targets.add(provider_id)
|
|
|
|
# Source 2: Entity :bases cross-reference (catches bases not in
|
|
# heritage clauses, and bases defined in other modules)
|
|
for ent in r.entities:
|
|
for base_name in ent.get("bases", []):
|
|
provider_id = abstract_providers.get(base_name)
|
|
if provider_id and provider_id != node_id:
|
|
calls_api_targets.add(provider_id)
|
|
|
|
for provider_id in calls_api_targets:
|
|
edge_key = (node_id, provider_id, "CALLS_API")
|
|
if edge_key not in seen_edges:
|
|
seen_edges.add(edge_key)
|
|
edges.append({
|
|
"source": node_id,
|
|
"target": provider_id,
|
|
"type": "CALLS_API",
|
|
"via_interface": True,
|
|
})
|
|
|
|
# -- DATA_FLOWS_TO edges --
|
|
for imp in r.imports:
|
|
target_id = resolve_import(imp, r.path, root, path_to_id, r.language, ts_path_aliases)
|
|
if target_id and target_id != node_id:
|
|
target_r = id_to_result.get(target_id)
|
|
if target_r and target_r.has_validation:
|
|
edge_key = (node_id, target_id, "DATA_FLOWS_TO")
|
|
if edge_key not in seen_edges:
|
|
seen_edges.add(edge_key)
|
|
edges.append({
|
|
"source": node_id,
|
|
"target": target_id,
|
|
"type": "DATA_FLOWS_TO",
|
|
"validated": True,
|
|
})
|
|
|
|
# -- entity-level REFERENCES edges --
|
|
# For each imported name, link source_entity -> target_entity
|
|
for imp_name in r.imported_names:
|
|
target_id = resolve_import(
|
|
imp_name["module"], r.path, root, path_to_id, r.language, ts_path_aliases,
|
|
)
|
|
if not target_id or target_id == node_id:
|
|
continue
|
|
target_r = id_to_result.get(target_id)
|
|
if not target_r:
|
|
continue
|
|
|
|
orig = imp_name["original"]
|
|
local = imp_name["local"]
|
|
|
|
# Find matching entity in target
|
|
target_entity = None
|
|
for ent in target_r.entities:
|
|
if ent["name"] == orig or (orig == "default" and ent["name"] == local):
|
|
target_entity = ent["name"]
|
|
break
|
|
if not target_entity:
|
|
# Still useful: the name is exported even if not a class/function entity
|
|
if orig in target_r.exported_names:
|
|
target_entity = orig
|
|
elif orig == "default" and target_r.exported_names:
|
|
target_entity = target_r.exported_names[0]
|
|
else:
|
|
continue
|
|
|
|
# Find which local entities reference this imported name
|
|
referencing_entities = _find_local_references(r.entities, local)
|
|
|
|
if referencing_entities:
|
|
for ref_ent in referencing_entities:
|
|
edge_key = (
|
|
f"{node_id}::{ref_ent}",
|
|
f"{target_id}::{target_entity}",
|
|
"REFERENCES",
|
|
)
|
|
if edge_key not in seen_edges:
|
|
seen_edges.add(edge_key)
|
|
edges.append({
|
|
"source": f"{node_id}::{ref_ent}",
|
|
"target": f"{target_id}::{target_entity}",
|
|
"type": "REFERENCES",
|
|
})
|
|
else:
|
|
# Module-level reference (not inside a specific entity)
|
|
edge_key = (node_id, f"{target_id}::{target_entity}", "REFERENCES")
|
|
if edge_key not in seen_edges:
|
|
seen_edges.add(edge_key)
|
|
edges.append({
|
|
"source": node_id,
|
|
"target": f"{target_id}::{target_entity}",
|
|
"type": "REFERENCES",
|
|
})
|
|
|
|
# -- Gap 2: BOUNDARY violation detection --
|
|
# -- HTTP_CALLS edges (cross-service HTTP client detection) --
|
|
# Detect modules with entities named *Client, *ClientService, *ClientApi,
|
|
# *External, *Remote and infer the target service from the class name.
|
|
_HTTP_CLIENT_SUFFIXES = ("Client", "ClientService", "ClientApi", "External", "Remote")
|
|
_REPO_NAMES = {r.repo for r in results}
|
|
for node_id, r in id_to_result.items():
|
|
for ent in r.entities:
|
|
ent_name = ent.get("name", "")
|
|
if not any(ent_name.endswith(sfx) for sfx in _HTTP_CLIENT_SUFFIXES):
|
|
continue
|
|
# Extract target service name from class name:
|
|
# AgentExternalClient -> agent, McpHubClientService -> mcp-hub
|
|
# LLMClientRemote -> llm
|
|
stem = ent_name
|
|
for sfx in sorted(_HTTP_CLIENT_SUFFIXES, key=len, reverse=True):
|
|
if stem.endswith(sfx):
|
|
stem = stem[: -len(sfx)]
|
|
break
|
|
if not stem:
|
|
continue
|
|
# Convert PascalCase to kebab-case for repo matching
|
|
kebab = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", stem).lower()
|
|
# Try to match against known repo names
|
|
target_repo = None
|
|
for repo in _REPO_NAMES:
|
|
if repo == r.repo:
|
|
continue # skip self
|
|
repo_lower = repo.lower()
|
|
if kebab == repo_lower or kebab.startswith(repo_lower) or repo_lower.startswith(kebab):
|
|
target_repo = repo
|
|
break
|
|
if target_repo:
|
|
edge_key = (node_id, target_repo, "HTTP_CALLS")
|
|
if edge_key not in seen_edges:
|
|
seen_edges.add(edge_key)
|
|
edges.append({
|
|
"source": node_id,
|
|
"target": target_repo,
|
|
"type": "HTTP_CALLS",
|
|
"transport": "http",
|
|
"client_entity": ent_name,
|
|
})
|
|
|
|
# Domain modules should not import directly from infrastructure without
|
|
# going through an interface (via_base). Flag violations.
|
|
for edge in edges:
|
|
if edge["type"] != "IMPORTS":
|
|
continue
|
|
src = edge["source"]
|
|
tgt = edge["target"]
|
|
src_purpose = id_to_result.get(src, None)
|
|
tgt_purpose = id_to_result.get(tgt, None)
|
|
if not src_purpose or not tgt_purpose:
|
|
continue
|
|
src_p = (src_purpose.purpose or "").lower()
|
|
tgt_p = (tgt_purpose.purpose or "").lower()
|
|
# Domain importing infrastructure without going through base class
|
|
if ("domain" in src_p and "infrastructure" in tgt_p and not edge.get("via_base")):
|
|
edges.append({
|
|
"source": src, "target": tgt,
|
|
"type": "VIOLATION",
|
|
"rule": "boundary",
|
|
"detail": "domain imports infrastructure without abstract base",
|
|
})
|
|
# UI/component importing infrastructure directly
|
|
if ("ui component" in src_p and "infrastructure" in tgt_p and not edge.get("via_base")):
|
|
edges.append({
|
|
"source": src, "target": tgt,
|
|
"type": "VIOLATION",
|
|
"rule": "boundary",
|
|
"detail": "UI imports infrastructure without abstract base",
|
|
})
|
|
|
|
return nodes, edges
|
|
|
|
|
|
def _find_local_references(entities: list[dict], name: str) -> list[str]:
|
|
"""
|
|
Heuristic: check which local entities might reference the imported name.
|
|
If a class extends/implements the name, or has it in bases, it references it.
|
|
"""
|
|
refs = []
|
|
for ent in entities:
|
|
# Class extends/implements
|
|
if name in ent.get("bases", []):
|
|
refs.append(ent["name"])
|
|
# Decorator usage
|
|
if name in ent.get("decorators", []):
|
|
refs.append(ent["name"])
|
|
return refs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LSP Client -- JSON-RPC over stdio
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LspClient:
|
|
"""
|
|
Minimal Language Server Protocol client using JSON-RPC over stdio.
|
|
|
|
Starts an LSP server as a subprocess, communicates via Content-Length
|
|
framed JSON-RPC messages, and dispatches responses to waiting callers
|
|
via a background reader thread.
|
|
|
|
Supports:
|
|
- initialize / initialized / shutdown / exit lifecycle
|
|
- textDocument/didOpen, textDocument/didClose
|
|
- textDocument/prepareCallHierarchy
|
|
- callHierarchy/outgoingCalls, callHierarchy/incomingCalls
|
|
- textDocument/references
|
|
"""
|
|
|
|
def __init__(self, cmd: list[str], cwd: str):
|
|
self._proc = subprocess.Popen(
|
|
cmd,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
cwd=cwd,
|
|
)
|
|
assert self._proc.stdin is not None
|
|
assert self._proc.stdout is not None
|
|
assert self._proc.stderr is not None
|
|
self._stdin = self._proc.stdin
|
|
self._stdout = self._proc.stdout
|
|
self._stderr = self._proc.stderr
|
|
self._msg_id = 0
|
|
self._lock = threading.Lock()
|
|
self._events: dict[int, threading.Event] = {}
|
|
self._results: dict[int, Any] = {}
|
|
self._errors: dict[int, Any] = {}
|
|
self._alive = True
|
|
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
self._reader.start()
|
|
self._stderr_reader = threading.Thread(target=self._drain_stderr, daemon=True)
|
|
self._stderr_reader.start()
|
|
|
|
# -- Transport layer --
|
|
|
|
def _next_id(self) -> int:
|
|
with self._lock:
|
|
self._msg_id += 1
|
|
return self._msg_id
|
|
|
|
def _send(self, msg: dict) -> None:
|
|
"""Send a JSON-RPC message with Content-Length header."""
|
|
body = json.dumps(msg).encode("utf-8")
|
|
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
|
|
try:
|
|
self._stdin.write(header + body)
|
|
self._stdin.flush()
|
|
except (BrokenPipeError, OSError):
|
|
self._alive = False
|
|
|
|
def _read_loop(self) -> None:
|
|
"""Background thread: read Content-Length framed messages from stdout."""
|
|
# Use os.read() for non-blocking reads. Python's BufferedReader.read(n)
|
|
# blocks until exactly n bytes are available, which deadlocks on pipes.
|
|
fd = self._stdout.fileno()
|
|
buf = b""
|
|
while self._alive:
|
|
try:
|
|
# Read until we have the full header separator
|
|
while b"\r\n\r\n" not in buf:
|
|
chunk = os.read(fd, 65536)
|
|
if not chunk:
|
|
self._alive = False
|
|
self._wake_all()
|
|
return
|
|
buf += chunk
|
|
|
|
header_end = buf.index(b"\r\n\r\n")
|
|
headers = buf[:header_end].decode("ascii")
|
|
buf = buf[header_end + 4:]
|
|
|
|
content_length = 0
|
|
for line in headers.split("\r\n"):
|
|
if line.lower().startswith("content-length:"):
|
|
content_length = int(line.split(":", 1)[1].strip())
|
|
|
|
# Read body
|
|
while len(buf) < content_length:
|
|
chunk = os.read(fd, 65536)
|
|
if not chunk:
|
|
self._alive = False
|
|
self._wake_all()
|
|
return
|
|
buf += chunk
|
|
|
|
body = buf[:content_length]
|
|
buf = buf[content_length:]
|
|
|
|
try:
|
|
msg = json.loads(body.decode("utf-8"))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
# Dispatch response
|
|
msg_id = msg.get("id")
|
|
if msg_id is not None and msg_id in self._events:
|
|
if "result" in msg:
|
|
self._results[msg_id] = msg["result"]
|
|
elif "error" in msg:
|
|
self._errors[msg_id] = msg["error"]
|
|
else:
|
|
# Null result is valid (e.g., empty references)
|
|
self._results[msg_id] = None
|
|
self._events[msg_id].set()
|
|
# Server-initiated notifications (diagnostics, progress) are ignored.
|
|
|
|
except Exception:
|
|
if self._alive:
|
|
continue
|
|
return
|
|
|
|
def _drain_stderr(self) -> None:
|
|
"""Drain stderr to prevent buffer deadlock."""
|
|
fd = self._stderr.fileno()
|
|
try:
|
|
while self._alive:
|
|
data = os.read(fd, 65536)
|
|
if not data:
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
def _wake_all(self) -> None:
|
|
"""Wake all pending requests (server died or shutting down)."""
|
|
for event in self._events.values():
|
|
event.set()
|
|
|
|
# -- Request / Notification helpers --
|
|
|
|
def _request(self, method: str, params: Any, timeout: float = LSP_REQUEST_TIMEOUT) -> Any:
|
|
"""Send a request and wait for the response (blocking)."""
|
|
if not self._alive:
|
|
return None
|
|
msg_id = self._next_id()
|
|
event = threading.Event()
|
|
self._events[msg_id] = event
|
|
self._send({"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params})
|
|
if event.wait(timeout):
|
|
self._events.pop(msg_id, None)
|
|
if msg_id in self._errors:
|
|
self._errors.pop(msg_id)
|
|
return None
|
|
return self._results.pop(msg_id, None)
|
|
# Timeout
|
|
self._events.pop(msg_id, None)
|
|
return None
|
|
|
|
def _request_fire(self, method: str, params: Any) -> int:
|
|
"""Send a request without waiting. Returns the message ID for later collection."""
|
|
if not self._alive:
|
|
return -1
|
|
msg_id = self._next_id()
|
|
event = threading.Event()
|
|
self._events[msg_id] = event
|
|
self._send({"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params})
|
|
return msg_id
|
|
|
|
def _request_collect(self, msg_id: int, timeout: float = LSP_REQUEST_TIMEOUT) -> Any:
|
|
"""Wait for a previously fired request's response."""
|
|
if msg_id < 0 or msg_id not in self._events:
|
|
return None
|
|
event = self._events[msg_id]
|
|
if event.wait(timeout):
|
|
self._events.pop(msg_id, None)
|
|
if msg_id in self._errors:
|
|
self._errors.pop(msg_id)
|
|
return None
|
|
return self._results.pop(msg_id, None)
|
|
self._events.pop(msg_id, None)
|
|
return None
|
|
|
|
def _notify(self, method: str, params: Any) -> None:
|
|
"""Send a notification (no response expected)."""
|
|
self._send({"jsonrpc": "2.0", "method": method, "params": params})
|
|
|
|
# -- LSP lifecycle --
|
|
|
|
def initialize(self, root_uri: str) -> bool:
|
|
"""Send initialize request. Returns True on success."""
|
|
result = self._request("initialize", {
|
|
"processId": os.getpid(),
|
|
"rootUri": root_uri,
|
|
"capabilities": {
|
|
"textDocument": {
|
|
"callHierarchy": {
|
|
"dynamicRegistration": False,
|
|
},
|
|
"references": {
|
|
"dynamicRegistration": False,
|
|
},
|
|
"synchronization": {
|
|
"didOpen": True,
|
|
"didClose": True,
|
|
},
|
|
},
|
|
},
|
|
"workspaceFolders": [{"uri": root_uri, "name": os.path.basename(uri_to_path(root_uri))}],
|
|
}, timeout=LSP_INIT_TIMEOUT)
|
|
if result is not None:
|
|
self._notify("initialized", {})
|
|
return True
|
|
return False
|
|
|
|
def shutdown(self) -> None:
|
|
"""Gracefully shut down the LSP server."""
|
|
if self._alive:
|
|
self._request("shutdown", None, timeout=5.0)
|
|
self._notify("exit", None)
|
|
self._alive = False
|
|
try:
|
|
self._proc.terminate()
|
|
self._proc.wait(timeout=5)
|
|
except Exception:
|
|
try:
|
|
self._proc.kill()
|
|
except Exception:
|
|
pass
|
|
|
|
@property
|
|
def alive(self) -> bool:
|
|
return self._alive and self._proc.poll() is None
|
|
|
|
# -- Document operations --
|
|
|
|
def did_open(self, uri: str, language_id: str, text: str) -> None:
|
|
"""Notify server that a document was opened."""
|
|
self._notify("textDocument/didOpen", {
|
|
"textDocument": {
|
|
"uri": uri,
|
|
"languageId": language_id,
|
|
"version": 1,
|
|
"text": text,
|
|
},
|
|
})
|
|
|
|
def did_close(self, uri: str) -> None:
|
|
"""Notify server that a document was closed."""
|
|
self._notify("textDocument/didClose", {
|
|
"textDocument": {"uri": uri},
|
|
})
|
|
|
|
# -- Query operations --
|
|
|
|
def document_symbols(
|
|
self, uri: str, timeout: float = LSP_REQUEST_TIMEOUT,
|
|
) -> list[dict]:
|
|
"""
|
|
Get all symbols in a document. Returns a tree of DocumentSymbol objects.
|
|
|
|
Each symbol has: name, kind (int), range, selectionRange, children.
|
|
SymbolKind: 5=Class, 6=Method, 11=Interface, 12=Function, 13=Variable,
|
|
10=Enum, 26=TypeParameter, 15=Namespace.
|
|
"""
|
|
result = self._request("textDocument/documentSymbol", {
|
|
"textDocument": {"uri": uri},
|
|
}, timeout=timeout)
|
|
return result if isinstance(result, list) else []
|
|
|
|
def prepare_call_hierarchy(
|
|
self, uri: str, line: int, character: int, timeout: float = LSP_REQUEST_TIMEOUT,
|
|
) -> list[dict]:
|
|
"""
|
|
Prepare call hierarchy at position. Returns list of CallHierarchyItem.
|
|
Line and character are 0-based (LSP convention).
|
|
"""
|
|
result = self._request("textDocument/prepareCallHierarchy", {
|
|
"textDocument": {"uri": uri},
|
|
"position": {"line": line, "character": character},
|
|
}, timeout=timeout)
|
|
return result if isinstance(result, list) else []
|
|
|
|
def outgoing_calls(
|
|
self, item: dict, timeout: float = LSP_REQUEST_TIMEOUT,
|
|
) -> list[dict]:
|
|
"""Get outgoing calls from a CallHierarchyItem."""
|
|
result = self._request("callHierarchy/outgoingCalls", {
|
|
"item": item,
|
|
}, timeout=timeout)
|
|
return result if isinstance(result, list) else []
|
|
|
|
def incoming_calls(
|
|
self, item: dict, timeout: float = LSP_REQUEST_TIMEOUT,
|
|
) -> list[dict]:
|
|
"""Get incoming calls to a CallHierarchyItem."""
|
|
result = self._request("callHierarchy/incomingCalls", {
|
|
"item": item,
|
|
}, timeout=timeout)
|
|
return result if isinstance(result, list) else []
|
|
|
|
def references(
|
|
self, uri: str, line: int, character: int, timeout: float = LSP_REQUEST_TIMEOUT,
|
|
) -> list[dict]:
|
|
"""
|
|
Find all references to the symbol at position.
|
|
Returns list of Location {uri, range}.
|
|
"""
|
|
result = self._request("textDocument/references", {
|
|
"textDocument": {"uri": uri},
|
|
"position": {"line": line, "character": character},
|
|
"context": {"includeDeclaration": False},
|
|
}, timeout=timeout)
|
|
return result if isinstance(result, list) else []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Project discovery for LSP
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def discover_projects(root: str, file_results: list[FileResult]) -> list[ProjectGroup]:
|
|
"""
|
|
Group files by LSP project root, detected by presence of config files.
|
|
|
|
For TypeScript: looks for tsconfig.json.
|
|
For Python: looks for pyrightconfig.json or pyproject.toml.
|
|
|
|
Files that do not belong to any detected project are skipped.
|
|
"""
|
|
# Find all config files
|
|
ts_configs: list[str] = []
|
|
py_configs: list[str] = []
|
|
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
# Skip unwanted directories
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
for fname in filenames:
|
|
full = os.path.join(dirpath, fname)
|
|
if fname == "tsconfig.json":
|
|
ts_configs.append(full)
|
|
elif fname in ("pyrightconfig.json", "pyproject.toml"):
|
|
py_configs.append(full)
|
|
|
|
# Sort configs by depth (deepest first) so files match the most specific project
|
|
ts_configs.sort(key=lambda p: -p.count(os.sep))
|
|
py_configs.sort(key=lambda p: -p.count(os.sep))
|
|
|
|
projects: list[ProjectGroup] = []
|
|
assigned_files: set[str] = set()
|
|
|
|
# Assign TypeScript files to projects
|
|
for config in ts_configs:
|
|
proj_root = os.path.dirname(config)
|
|
proj = ProjectGroup(
|
|
root=proj_root,
|
|
language="typescript",
|
|
config_file=config,
|
|
)
|
|
for r in file_results:
|
|
if r.language == "typescript" and r.path not in assigned_files:
|
|
if r.path.startswith(proj_root + os.sep):
|
|
proj.files.append(r)
|
|
assigned_files.add(r.path)
|
|
if proj.files:
|
|
projects.append(proj)
|
|
|
|
# Assign Python files to projects
|
|
for config in py_configs:
|
|
proj_root = os.path.dirname(config)
|
|
proj = ProjectGroup(
|
|
root=proj_root,
|
|
language="python",
|
|
config_file=config,
|
|
)
|
|
for r in file_results:
|
|
if r.language == "python" and r.path not in assigned_files:
|
|
if r.path.startswith(proj_root + os.sep):
|
|
proj.files.append(r)
|
|
assigned_files.add(r.path)
|
|
if proj.files:
|
|
projects.append(proj)
|
|
|
|
return projects
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LSP analysis -- call hierarchy + references
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _language_id(language: str, path: str) -> str:
|
|
"""Map internal language + file extension to LSP languageId."""
|
|
if language == "python":
|
|
return "python"
|
|
if path.endswith(".tsx"):
|
|
return "typescriptreact"
|
|
return "typescript"
|
|
|
|
|
|
# LSP SymbolKind constants
|
|
_SK_CLASS = 5
|
|
_SK_METHOD = 6
|
|
_SK_PROPERTY = 7
|
|
_SK_ENUM = 10
|
|
_SK_INTERFACE = 11
|
|
_SK_FUNCTION = 12
|
|
_SK_VARIABLE = 13
|
|
_SK_CONSTANT = 14
|
|
_SK_ENUM_MEMBER = 22
|
|
_SK_TYPE_PARAMETER = 26
|
|
|
|
_SK_TO_KIND = {
|
|
_SK_CLASS: "class",
|
|
_SK_METHOD: "method",
|
|
_SK_INTERFACE: "interface",
|
|
_SK_FUNCTION: "function",
|
|
_SK_VARIABLE: "function", # const exports
|
|
_SK_CONSTANT: "function",
|
|
_SK_ENUM: "enum",
|
|
_SK_TYPE_PARAMETER: "type",
|
|
_SK_PROPERTY: "function",
|
|
}
|
|
|
|
|
|
def _sym_line(sym: dict) -> int:
|
|
"""Extract 1-based line from a DocumentSymbol or SymbolInformation."""
|
|
# DocumentSymbol format: { selectionRange: { start: { line } } }
|
|
sel = sym.get("selectionRange")
|
|
if sel:
|
|
return sel.get("start", {}).get("line", 0) + 1
|
|
# SymbolInformation format: { location: { range: { start: { line } } } }
|
|
loc = sym.get("location")
|
|
if loc:
|
|
return loc.get("range", {}).get("start", {}).get("line", 0) + 1
|
|
# Fallback to range
|
|
rng = sym.get("range")
|
|
if rng:
|
|
return rng.get("start", {}).get("line", 0) + 1
|
|
return 0
|
|
|
|
|
|
def _lsp_symbols_to_entities(symbols: list[dict]) -> tuple[list[dict], list[str]]:
|
|
"""
|
|
Convert LSP symbol results into Entity dicts and exported names.
|
|
|
|
Handles both DocumentSymbol (nested, has children) and SymbolInformation
|
|
(flat, has location) formats. Filters to top-level definitions only:
|
|
classes, interfaces, functions, types, enums, and exported constants.
|
|
"""
|
|
entities: list[dict] = []
|
|
exported_names: list[str] = []
|
|
seen_names: set[str] = set()
|
|
|
|
# Detect format: DocumentSymbol has "children" or "selectionRange",
|
|
# SymbolInformation has "location"
|
|
is_doc_symbol = any("selectionRange" in s or "children" in s for s in symbols[:5])
|
|
|
|
if is_doc_symbol:
|
|
# DocumentSymbol: only process top-level symbols
|
|
for sym in symbols:
|
|
_process_doc_symbol(sym, entities, exported_names, seen_names)
|
|
else:
|
|
# SymbolInformation: flat list, filter to top-level kinds
|
|
for sym in symbols:
|
|
# Skip methods/properties (they belong to a class, not top-level)
|
|
if sym.get("containerName", ""):
|
|
continue
|
|
_process_symbol_info(sym, entities, exported_names, seen_names)
|
|
|
|
return entities, exported_names
|
|
|
|
|
|
def _process_doc_symbol(
|
|
sym: dict,
|
|
entities: list[dict],
|
|
exported_names: list[str],
|
|
seen_names: set[str],
|
|
) -> None:
|
|
"""Process a single DocumentSymbol (with children)."""
|
|
kind_int = sym.get("kind", 0)
|
|
name = sym.get("name", "")
|
|
line = _sym_line(sym)
|
|
|
|
if not name or name in seen_names:
|
|
return
|
|
|
|
children = sym.get("children", [])
|
|
|
|
if kind_int in (_SK_CLASS, _SK_INTERFACE):
|
|
methods = []
|
|
for child in children:
|
|
child_kind = child.get("kind", 0)
|
|
child_name = child.get("name", "")
|
|
if child_kind in (_SK_METHOD, _SK_PROPERTY, _SK_FUNCTION) and child_name:
|
|
methods.append(child_name)
|
|
kind = "class" if kind_int == _SK_CLASS else "interface"
|
|
entities.append({
|
|
"name": name, "kind": kind, "line": line,
|
|
"methods": methods, "bases": [], "decorators": [],
|
|
})
|
|
seen_names.add(name)
|
|
exported_names.append(name)
|
|
|
|
elif kind_int == _SK_ENUM:
|
|
members = [c.get("name", "") for c in children if c.get("kind") == _SK_ENUM_MEMBER]
|
|
entities.append({
|
|
"name": name, "kind": "enum", "line": line,
|
|
"methods": members, "bases": [], "decorators": [],
|
|
})
|
|
seen_names.add(name)
|
|
exported_names.append(name)
|
|
|
|
elif kind_int == _SK_FUNCTION:
|
|
entities.append({
|
|
"name": name, "kind": "function", "line": line,
|
|
"methods": [], "bases": [], "decorators": [],
|
|
})
|
|
seen_names.add(name)
|
|
exported_names.append(name)
|
|
|
|
elif kind_int in (_SK_VARIABLE, _SK_CONSTANT):
|
|
# Only capture exported-looking names (PascalCase or camelCase identifiers)
|
|
# Skip destructured locals, parameters, etc.
|
|
if len(name) >= 2 and (name[0].isupper() or not name.startswith("_")):
|
|
ent_kind = "type" if name[0].isupper() and not children else "function"
|
|
methods = []
|
|
if children:
|
|
for child in children:
|
|
child_name = child.get("name", "")
|
|
if child_name:
|
|
methods.append(child_name)
|
|
entities.append({
|
|
"name": name, "kind": ent_kind, "line": line,
|
|
"methods": methods, "bases": [], "decorators": [],
|
|
})
|
|
seen_names.add(name)
|
|
exported_names.append(name)
|
|
|
|
elif kind_int == _SK_TYPE_PARAMETER:
|
|
entities.append({
|
|
"name": name, "kind": "type", "line": line,
|
|
"methods": [], "bases": [], "decorators": [],
|
|
})
|
|
seen_names.add(name)
|
|
exported_names.append(name)
|
|
|
|
|
|
def _process_symbol_info(
|
|
sym: dict,
|
|
entities: list[dict],
|
|
exported_names: list[str],
|
|
seen_names: set[str],
|
|
) -> None:
|
|
"""Process a single SymbolInformation (flat, no children)."""
|
|
kind_int = sym.get("kind", 0)
|
|
name = sym.get("name", "")
|
|
line = _sym_line(sym)
|
|
|
|
if not name or name in seen_names:
|
|
return
|
|
|
|
kind = _SK_TO_KIND.get(kind_int)
|
|
if not kind:
|
|
return
|
|
|
|
entities.append({
|
|
"name": name, "kind": kind, "line": line,
|
|
"methods": [], "bases": [], "decorators": [],
|
|
})
|
|
seen_names.add(name)
|
|
exported_names.append(name)
|
|
|
|
|
|
def _drain_prepare_batch(
|
|
client: "LspClient",
|
|
batch: list[tuple[int, str, dict]],
|
|
path_to_node_id: dict[str, str],
|
|
source_node_id: str,
|
|
lsp_edges: list[LspEdge],
|
|
timeout: float,
|
|
) -> int:
|
|
"""
|
|
Collect prepareCallHierarchy results, pipeline outgoingCalls, collect those.
|
|
|
|
Returns number of CALLS edges found.
|
|
"""
|
|
calls_found = 0
|
|
# Collect prepare results and fire outgoingCalls
|
|
outgoing_pending: list[tuple[int, str, str]] = [] # (msg_id, ent_name, node_id)
|
|
for mid, ent_name, _ent in batch:
|
|
items = client._request_collect(mid, timeout=timeout)
|
|
if not isinstance(items, list):
|
|
continue
|
|
for item in items:
|
|
if item.get("name") != ent_name:
|
|
continue
|
|
oc_mid = client._request_fire("callHierarchy/outgoingCalls", {"item": item})
|
|
if oc_mid >= 0:
|
|
outgoing_pending.append((oc_mid, ent_name, source_node_id))
|
|
|
|
# Collect outgoingCalls results
|
|
for oc_mid, ent_name, node_id in outgoing_pending:
|
|
outgoing = client._request_collect(oc_mid, timeout=timeout)
|
|
if not isinstance(outgoing, list):
|
|
continue
|
|
for call in outgoing:
|
|
target = call.get("to", {})
|
|
target_uri = target.get("uri", "")
|
|
target_name = target.get("name", "")
|
|
target_path = uri_to_path(target_uri)
|
|
target_node_id = path_to_node_id.get(target_path)
|
|
if not target_node_id:
|
|
continue
|
|
from_ranges = [fr["start"]["line"] + 1 for fr in call.get("fromRanges", [])]
|
|
lsp_edges.append(LspEdge(
|
|
source=f"{node_id}::{ent_name}",
|
|
target=f"{target_node_id}::{target_name}",
|
|
edge_type="CALLS",
|
|
metadata={"from_lines": from_ranges, "lsp_verified": True},
|
|
))
|
|
calls_found += 1
|
|
return calls_found
|
|
|
|
|
|
def _find_enclosing_entity(
|
|
entities: list[dict], line_0based: int,
|
|
) -> Optional[str]:
|
|
"""
|
|
Find the entity that encloses the given 0-based line.
|
|
|
|
Uses a simple heuristic: the entity whose line is closest to (but not
|
|
after) the target line.
|
|
"""
|
|
best: Optional[dict] = None
|
|
for ent in entities:
|
|
ent_line_0 = ent["line"] - 1 # entity lines are 1-based
|
|
if ent_line_0 <= line_0based:
|
|
if best is None or ent_line_0 > best["line"] - 1:
|
|
best = ent
|
|
return best["name"] if best else None
|
|
|
|
|
|
def analyze_project_lsp(
|
|
project: ProjectGroup,
|
|
root: str,
|
|
path_to_node_id: dict[str, str],
|
|
node_id_to_result: dict[str, FileResult],
|
|
request_timeout: float,
|
|
) -> list[LspEdge]:
|
|
"""
|
|
Analyze a single project with LSP for precise call graph and references.
|
|
|
|
Returns a list of LspEdge instances to merge into the main graph.
|
|
"""
|
|
# Determine LSP server command
|
|
if project.language == "typescript":
|
|
cmd = ["typescript-language-server", "--stdio"]
|
|
else:
|
|
cmd = ["pyright-langserver", "--stdio"]
|
|
|
|
# Check server availability
|
|
server_bin = cmd[0]
|
|
from shutil import which
|
|
if not which(server_bin):
|
|
print(f" [lsp] WARNING: {server_bin} not found, skipping {project.root}")
|
|
return []
|
|
|
|
proj_name = os.path.relpath(project.root, root)
|
|
print(f" [lsp] Starting {server_bin} for {proj_name} ({len(project.files)} files)")
|
|
|
|
client = LspClient(cmd, cwd=project.root)
|
|
lsp_edges: list[LspEdge] = []
|
|
|
|
try:
|
|
# Initialize
|
|
root_uri = path_to_uri(project.root)
|
|
if not client.initialize(root_uri):
|
|
print(f" [lsp] WARNING: initialize failed for {proj_name}")
|
|
return []
|
|
|
|
# Open all project files (batch)
|
|
file_contents: dict[str, str] = {}
|
|
for r in project.files:
|
|
try:
|
|
with open(r.path, "r", encoding="utf-8", errors="replace") as f:
|
|
content = f.read()
|
|
file_contents[r.path] = content
|
|
uri = path_to_uri(r.path)
|
|
lang_id = _language_id(r.language, r.path)
|
|
client.did_open(uri, lang_id, content)
|
|
except OSError:
|
|
continue
|
|
|
|
# Give the server a moment to process opened files
|
|
time.sleep(1.0)
|
|
|
|
# -- Phase 1: Merge LSP documentSymbol with regex entities --
|
|
# LSP gives precise line numbers and finds entities regex misses
|
|
# (object literals, Zod schemas). Regex gives method lists via
|
|
# brace scanning. Merge: take LSP entities as base, carry over
|
|
# regex methods for classes/interfaces that LSP returns without children.
|
|
symbols_replaced = 0
|
|
for r in project.files:
|
|
if not client.alive:
|
|
break
|
|
node_id = path_to_node_id.get(r.path)
|
|
if not node_id:
|
|
continue
|
|
uri = path_to_uri(r.path)
|
|
lsp_syms = client.document_symbols(uri, timeout=request_timeout)
|
|
if lsp_syms:
|
|
new_entities, new_exports = _lsp_symbols_to_entities(lsp_syms)
|
|
# Build regex method index: entity_name -> methods list
|
|
regex_methods: dict[str, list[str]] = {}
|
|
for old_ent in r.entities:
|
|
if old_ent.get("methods"):
|
|
regex_methods[old_ent["name"]] = old_ent["methods"]
|
|
# Merge: carry over regex methods when LSP has none
|
|
for ent in new_entities:
|
|
if ent["kind"] in ("class", "interface") and not ent.get("methods"):
|
|
regex_m = regex_methods.get(ent["name"])
|
|
if regex_m:
|
|
ent["methods"] = regex_m
|
|
# Also carry over bases/decorators from regex
|
|
if not ent.get("bases") or not ent.get("decorators"):
|
|
for old_ent in r.entities:
|
|
if old_ent["name"] == ent["name"]:
|
|
if not ent.get("bases") and old_ent.get("bases"):
|
|
ent["bases"] = old_ent["bases"]
|
|
if not ent.get("decorators") and old_ent.get("decorators"):
|
|
ent["decorators"] = old_ent["decorators"]
|
|
break
|
|
# Add regex entities not found by LSP (e.g., Zod schemas)
|
|
lsp_names = {e["name"] for e in new_entities}
|
|
for old_ent in r.entities:
|
|
if old_ent["name"] not in lsp_names:
|
|
new_entities.append(old_ent)
|
|
if old_ent["name"] not in new_exports:
|
|
new_exports.append(old_ent["name"])
|
|
r.entities = new_entities
|
|
r.exported_names = list(set(new_exports))
|
|
symbols_replaced += 1
|
|
|
|
if symbols_replaced:
|
|
print(f" [lsp] {proj_name}: merged entities in {symbols_replaced} files via documentSymbol")
|
|
|
|
# -- Phase 2: Pipelined call hierarchy + references --
|
|
# Strategy: for each file, fire all prepareCallHierarchy and
|
|
# references requests at once (pipelined), then collect results.
|
|
# This overlaps network round-trips with server processing.
|
|
total_entities = sum(len(r.entities) for r in project.files)
|
|
processed = 0
|
|
calls_found = 0
|
|
refs_found = 0
|
|
PIPELINE_BATCH = 40 # max concurrent in-flight requests
|
|
|
|
for r in project.files:
|
|
if not client.alive:
|
|
print(f" [lsp] WARNING: server died while processing {proj_name}")
|
|
break
|
|
|
|
content = file_contents.get(r.path)
|
|
if not content:
|
|
continue
|
|
|
|
node_id = path_to_node_id.get(r.path)
|
|
if not node_id:
|
|
continue
|
|
|
|
uri = path_to_uri(r.path)
|
|
|
|
# -- Batch 1: fire prepareCallHierarchy + references for all entities --
|
|
pending_prepare: list[tuple[int, str, dict]] = [] # (msg_id, ent_name, ent)
|
|
pending_refs: list[tuple[int, str, str]] = [] # (msg_id, ent_name, node_id)
|
|
|
|
for ent in r.entities:
|
|
ent_name = ent["name"]
|
|
ent_line_0 = ent["line"] - 1
|
|
ent_col = find_entity_column(content, ent_name, ent["line"])
|
|
|
|
if ent["kind"] in ("function", "class"):
|
|
mid = client._request_fire("textDocument/prepareCallHierarchy", {
|
|
"textDocument": {"uri": uri},
|
|
"position": {"line": ent_line_0, "character": ent_col},
|
|
})
|
|
if mid >= 0:
|
|
pending_prepare.append((mid, ent_name, ent))
|
|
|
|
if ent_name in r.exported_names:
|
|
mid = client._request_fire("textDocument/references", {
|
|
"textDocument": {"uri": uri},
|
|
"position": {"line": ent_line_0, "character": ent_col},
|
|
"context": {"includeDeclaration": False},
|
|
})
|
|
if mid >= 0:
|
|
pending_refs.append((mid, ent_name, node_id))
|
|
|
|
# Drain pipeline if too many in-flight
|
|
while len(pending_prepare) + len(pending_refs) >= PIPELINE_BATCH:
|
|
calls_found += _drain_prepare_batch(
|
|
client, pending_prepare[:PIPELINE_BATCH // 2],
|
|
path_to_node_id, node_id, lsp_edges, request_timeout,
|
|
)
|
|
pending_prepare = pending_prepare[PIPELINE_BATCH // 2:]
|
|
|
|
# -- Collect remaining prepareCallHierarchy results --
|
|
calls_found += _drain_prepare_batch(
|
|
client, pending_prepare, path_to_node_id, node_id, lsp_edges, request_timeout,
|
|
)
|
|
|
|
# -- Collect references results --
|
|
for mid, ent_name, src_node_id in pending_refs:
|
|
ref_locations = client._request_collect(mid, timeout=request_timeout)
|
|
if not isinstance(ref_locations, list):
|
|
continue
|
|
for loc in ref_locations:
|
|
ref_uri = loc.get("uri", "")
|
|
ref_path = uri_to_path(ref_uri)
|
|
ref_node_id = path_to_node_id.get(ref_path)
|
|
if not ref_node_id or ref_node_id == src_node_id:
|
|
continue
|
|
ref_result = node_id_to_result.get(ref_node_id)
|
|
ref_line_0 = loc.get("range", {}).get("start", {}).get("line", 0)
|
|
ref_entity_name = None
|
|
if ref_result:
|
|
ref_entity_name = _find_enclosing_entity(
|
|
ref_result.entities, ref_line_0,
|
|
)
|
|
source_key = f"{ref_node_id}::{ref_entity_name}" if ref_entity_name else ref_node_id
|
|
target_key = f"{src_node_id}::{ent_name}"
|
|
lsp_edges.append(LspEdge(
|
|
source=source_key,
|
|
target=target_key,
|
|
edge_type="REFERENCES",
|
|
metadata={"lsp_verified": True, "ref_line": ref_line_0 + 1},
|
|
))
|
|
refs_found += 1
|
|
|
|
processed += len(r.entities)
|
|
if processed % 200 < len(r.entities):
|
|
print(f" [lsp] {proj_name}: {processed}/{total_entities} entities "
|
|
f"({calls_found} calls, {refs_found} refs)")
|
|
|
|
client.did_close(uri)
|
|
|
|
print(f" [lsp] {proj_name}: done -- {calls_found} calls, {refs_found} refs from {processed} entities")
|
|
|
|
except Exception as exc:
|
|
print(f" [lsp] ERROR in {proj_name}: {exc}")
|
|
finally:
|
|
client.shutdown()
|
|
|
|
return lsp_edges
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LSP merge -- integrate LSP edges into the graph
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def merge_lsp_edges(
|
|
edges: list[dict],
|
|
lsp_edges: list[LspEdge],
|
|
) -> list[dict]:
|
|
"""
|
|
Merge LSP-derived edges into the existing edge list.
|
|
|
|
- New CALLS edges are added directly.
|
|
- REFERENCES edges that match existing ones get ``lsp_verified: true``.
|
|
- New REFERENCES edges not found by the regex pass are added.
|
|
"""
|
|
# Index existing edges for fast lookup
|
|
existing_keys: dict[tuple, int] = {}
|
|
for idx, edge in enumerate(edges):
|
|
key = (edge["source"], edge["target"], edge["type"])
|
|
existing_keys[key] = idx
|
|
|
|
new_edges: list[dict] = []
|
|
verified_count = 0
|
|
new_count = 0
|
|
|
|
for le in lsp_edges:
|
|
key = (le.source, le.target, le.edge_type)
|
|
if key in existing_keys:
|
|
# Mark existing edge as LSP-verified
|
|
edges[existing_keys[key]]["lsp_verified"] = True
|
|
verified_count += 1
|
|
else:
|
|
# Add new edge
|
|
edge_dict: dict = {
|
|
"source": le.source,
|
|
"target": le.target,
|
|
"type": le.edge_type,
|
|
}
|
|
edge_dict.update(le.metadata)
|
|
new_edges.append(edge_dict)
|
|
new_count += 1
|
|
# Track so we don't add duplicates
|
|
existing_keys[key] = -1
|
|
|
|
print(f"[belief-map] LSP merge: {verified_count} verified, {new_count} new edges")
|
|
return edges + new_edges
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LSP enrichment orchestrator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def enrich_with_lsp(
|
|
root: str,
|
|
results: list[FileResult],
|
|
nodes: list[dict],
|
|
edges: list[dict],
|
|
request_timeout: float,
|
|
) -> tuple[list[dict], list[dict]]:
|
|
"""
|
|
Run LSP analysis on all detected projects and merge edges into the graph.
|
|
|
|
For TypeScript: replaces regex-extracted entities with LSP documentSymbol
|
|
results, then queries call hierarchy and references for precise edges.
|
|
For Python: queries call hierarchy and references only (keeps AST entities).
|
|
"""
|
|
# Build lookup tables
|
|
path_to_node_id: dict[str, str] = {}
|
|
node_id_to_result: dict[str, FileResult] = {}
|
|
for r in results:
|
|
nid = make_node_id(r.path, root)
|
|
path_to_node_id[r.path] = nid
|
|
node_id_to_result[nid] = r
|
|
|
|
# Discover projects
|
|
projects = discover_projects(root, results)
|
|
if not projects:
|
|
print("[belief-map] No LSP-compatible projects found (no tsconfig.json or pyrightconfig.json)")
|
|
return nodes, edges
|
|
|
|
ts_projects = [p for p in projects if p.language == "typescript"]
|
|
py_projects = [p for p in projects if p.language == "python"]
|
|
ts_files = sum(len(p.files) for p in ts_projects)
|
|
py_files = sum(len(p.files) for p in py_projects)
|
|
print(f"[belief-map] LSP: {len(projects)} projects "
|
|
f"({len(ts_projects)} TS/{ts_files} files, {len(py_projects)} Py/{py_files} files)")
|
|
|
|
all_lsp_edges: list[LspEdge] = []
|
|
|
|
for project in projects:
|
|
proj_edges = analyze_project_lsp(
|
|
project, root, path_to_node_id, node_id_to_result, request_timeout,
|
|
)
|
|
all_lsp_edges.extend(proj_edges)
|
|
|
|
if all_lsp_edges:
|
|
edges = merge_lsp_edges(edges, all_lsp_edges)
|
|
|
|
# Rebuild nodes with LSP-replaced entities
|
|
node_by_id = {n["id"]: n for n in nodes}
|
|
for r in results:
|
|
nid = path_to_node_id.get(r.path)
|
|
if nid and nid in node_by_id:
|
|
node_by_id[nid]["entities"] = r.entities
|
|
nodes = list(node_by_id.values())
|
|
|
|
return nodes, edges
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Output formatters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _sexp_escape(s: str) -> str:
|
|
"""Escape a string for S-expression output (double-quote delimited)."""
|
|
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
|
|
|
|
def _entity_id(module_path: str, entity_name: str) -> str:
|
|
"""Generate a short unique ID for an entity based on path + name SHA."""
|
|
raw = f"{module_path}::{entity_name}"
|
|
return hashlib.sha1(raw.encode()).hexdigest()[:8]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Path trie for compressed path definitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_path_tree(path_sid_pairs: list[tuple[str, str]]) -> list[str]:
|
|
"""
|
|
Build a compressed path trie and emit as indented S-expression tree.
|
|
|
|
Shares common path prefixes to reduce repetition. Collapses single-child
|
|
chains into combined segments (path compression).
|
|
|
|
Output format::
|
|
|
|
(paths
|
|
(agent-hub/packages/agent-research/src
|
|
(ah0 ReasoningEngine)
|
|
(ah1 ResearchAgent))
|
|
(app-service/app-service/src/operatives
|
|
(asp340 operatives.controller)))
|
|
"""
|
|
# Build trie: dict of dicts, leaves are {"_sid": "ah0"}
|
|
# A node can be BOTH a directory (has children) and a leaf (has _sid)
|
|
# when index.ts strips to the directory name.
|
|
root: dict = {}
|
|
for full_path, sid in path_sid_pairs:
|
|
segments = full_path.split("/")
|
|
node = root
|
|
for seg in segments[:-1]:
|
|
node = node.setdefault(seg, {})
|
|
# Leaf: store sid. If the key already exists as a dict (directory),
|
|
# merge _sid into it rather than overwriting children.
|
|
leaf_key = segments[-1]
|
|
existing = node.get(leaf_key)
|
|
if isinstance(existing, dict) and "_sid" not in existing:
|
|
existing["_sid"] = sid
|
|
else:
|
|
node[leaf_key] = {"_sid": sid}
|
|
|
|
# Compress: collapse single-child internal nodes
|
|
def compress(node: dict) -> dict:
|
|
result: dict = {}
|
|
for key, child in node.items():
|
|
if isinstance(child, dict) and "_sid" in child:
|
|
result[key] = child # leaf, keep as-is
|
|
elif isinstance(child, dict):
|
|
compressed = compress(child)
|
|
# If single non-leaf child, merge keys
|
|
while len(compressed) == 1:
|
|
only_key, only_val = next(iter(compressed.items()))
|
|
if isinstance(only_val, dict) and "_sid" not in only_val:
|
|
key = f"{key}/{only_key}"
|
|
compressed = only_val
|
|
else:
|
|
break
|
|
result[key] = compressed
|
|
else:
|
|
result[key] = child
|
|
return result
|
|
|
|
compressed = compress(root)
|
|
|
|
# Emit as indented S-expressions
|
|
def emit(node: dict, depth: int) -> list[str]:
|
|
lines: list[str] = []
|
|
indent = " " * depth
|
|
for key in sorted(node.keys()):
|
|
if key == "_sid":
|
|
continue # handled by parent
|
|
child = node[key]
|
|
if isinstance(child, dict) and "_sid" in child:
|
|
# Check if this is BOTH a leaf and a directory (has other keys)
|
|
other_keys = [k for k in child if k != "_sid"]
|
|
if other_keys:
|
|
# Emit the _sid as a :self entry, then recurse for children
|
|
lines.append(f"{indent}({key}")
|
|
lines.append(f"{indent} ({child['_sid']} :self)")
|
|
lines.extend(emit(child, depth + 1))
|
|
lines.append(f"{indent})")
|
|
else:
|
|
lines.append(f"{indent}({child['_sid']} {key})")
|
|
elif isinstance(child, dict):
|
|
lines.append(f"{indent}({key}")
|
|
lines.extend(emit(child, depth + 1))
|
|
lines.append(f"{indent})")
|
|
return lines
|
|
|
|
return emit(compressed, 1)
|
|
|
|
|
|
def write_sexp(
|
|
output_path: str,
|
|
nodes: list[dict],
|
|
edges: list[dict],
|
|
_root: str,
|
|
mode: str,
|
|
total_files: int,
|
|
) -> None:
|
|
"""
|
|
Write the belief map as flat S-expression facts with path-map compression.
|
|
|
|
The file starts with ``(def <id> <full-path>)`` bindings that assign short
|
|
numeric IDs to every module path. All subsequent lines use these IDs instead
|
|
of full paths, reducing file size by ~60-70%.
|
|
|
|
Format::
|
|
|
|
; --- paths ---
|
|
(def 0 agent-hub/packages/agent-research/src/ReasoningEngine)
|
|
(def 1 agent-hub/packages/agent-research/src/ResearchAgent)
|
|
|
|
; --- nodes ---
|
|
(node 0 ty "general module" :naming PascalCase :pkg agent-hub)
|
|
|
|
; --- entities ---
|
|
(fn 0 isToolIncluded 5)
|
|
(cls 1 ResearchAgent 322 (:bases BaseAgent) (:methods ...))
|
|
|
|
; --- edges ---
|
|
(imports 1 0 :via-base)
|
|
(refs 1 0::ReasoningSchemaType)
|
|
|
|
; --- violations ---
|
|
(violation boundary 42 99 "domain imports infrastructure without abstract base")
|
|
"""
|
|
sorted_nodes = sorted(nodes, key=lambda n: n["id"])
|
|
|
|
# Build path map: full path -> short prefixed ID (repo-prefix + seq number)
|
|
# e.g. ah0 = agent-hub first module, dr42 = drive 43rd module
|
|
_REPO_PREFIX: dict[str, str] = {}
|
|
_repo_counters: dict[str, int] = {}
|
|
path_to_sid: dict[str, str] = {}
|
|
|
|
for node in sorted_nodes:
|
|
repo = node.get("repo", "unknown")
|
|
if repo not in _REPO_PREFIX:
|
|
# Build 2-char prefix from repo name
|
|
parts = repo.replace("-", " ").split()
|
|
if len(parts) >= 2:
|
|
prefix = parts[0][0] + parts[1][0]
|
|
else:
|
|
prefix = repo[:2]
|
|
# Deduplicate prefixes
|
|
base = prefix
|
|
idx = 2
|
|
while prefix in _REPO_PREFIX.values():
|
|
prefix = base + repo[idx:idx + 1] if idx < len(repo) else base + str(idx)
|
|
idx += 1
|
|
_REPO_PREFIX[repo] = prefix
|
|
_repo_counters[repo] = 0
|
|
|
|
seq = _repo_counters[repo]
|
|
_repo_counters[repo] = seq + 1
|
|
sid = f"{_REPO_PREFIX[repo]}{seq}"
|
|
path_to_sid[node["id"]] = sid
|
|
|
|
def _pid(path: str) -> str:
|
|
"""Resolve a path (possibly with ::entity suffix) to short ID."""
|
|
if "::" in path:
|
|
mod, ent = path.split("::", 1)
|
|
sid = path_to_sid.get(mod)
|
|
return f"{sid}::{ent}" if sid is not None else path
|
|
sid = path_to_sid.get(path)
|
|
return sid if sid is not None else path
|
|
|
|
lines: list[str] = []
|
|
violations = [e for e in edges if e.get("type") == "VIOLATION"]
|
|
real_edges = [e for e in edges if e.get("type") != "VIOLATION"]
|
|
|
|
lines.append(f"; Belief Map")
|
|
lines.append(f"; generated {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}")
|
|
lines.append(f"; mode {mode}")
|
|
lines.append(f"; {total_files} files {len(sorted_nodes)} nodes {len(real_edges)} edges {len(violations)} violations")
|
|
lines.append("")
|
|
|
|
# -- Path tree (compressed trie) --
|
|
lines.append("; --- paths ---")
|
|
trie_pairs = [(node["id"], path_to_sid[node["id"]]) for node in sorted_nodes]
|
|
lines.append("(paths")
|
|
lines.extend(_build_path_tree(trie_pairs))
|
|
lines.append(")")
|
|
lines.append("")
|
|
|
|
# -- Nodes with invariant (Gap 1 fix) --
|
|
lines.append("; --- nodes ---")
|
|
for node in sorted_nodes:
|
|
sid = path_to_sid[node["id"]]
|
|
_LANG_SHORT = {"python": "py", "typescript": "ts", "tsx": "tsx", "jsx": "jsx", "javascript": "js"}
|
|
lang = _LANG_SHORT.get(node["language"], node["language"][:2])
|
|
purpose = _sexp_escape(node.get("purpose", ""))
|
|
inv = node.get("invariant", {})
|
|
naming = inv.get("naming", "mixed")
|
|
pkg = inv.get("package", "")
|
|
lines.append(f'(node {sid} {lang} "{purpose}" :naming {naming} :pkg {pkg})')
|
|
lines.append("")
|
|
|
|
# -- Entities (each has a unique eid based on sha(path::name)) --
|
|
lines.append("; --- entities ---")
|
|
for node in sorted_nodes:
|
|
sid = path_to_sid[node["id"]]
|
|
for ent in node.get("entities", []):
|
|
kind = ent["kind"]
|
|
name = ent["name"]
|
|
line_num = ent.get("line", 0)
|
|
eid = _entity_id(node["id"], name)
|
|
kind_tag = {
|
|
"class": "cls", "function": "fn", "interface": "ifc",
|
|
"type": "typ", "enum": "enm",
|
|
}.get(kind, kind[:3])
|
|
|
|
bases = ent.get("bases", [])
|
|
deco = ent.get("decorators", [])
|
|
methods = ent.get("methods", [])
|
|
|
|
if not bases and not deco and not methods:
|
|
lines.append(f"({kind_tag} {sid} {name} {line_num} :eid {eid})")
|
|
else:
|
|
parts = [f"({kind_tag} {sid} {name} {line_num} :eid {eid}"]
|
|
if bases:
|
|
parts.append(f"(:bases {' '.join(bases)})")
|
|
if deco:
|
|
parts.append(f"(:deco {' '.join(deco)})")
|
|
if methods:
|
|
parts.append(f"(:methods {' '.join(methods)})")
|
|
lines.append(" ".join(parts) + ")")
|
|
lines.append("")
|
|
|
|
# -- Edges --
|
|
lines.append("; --- edges ---")
|
|
sorted_edges = sorted(real_edges, key=lambda e: (e["type"], e["source"], e["target"]))
|
|
for edge in sorted_edges:
|
|
src = _pid(edge["source"])
|
|
tgt = _pid(edge["target"])
|
|
etype = edge["type"]
|
|
|
|
if etype == "IMPORTS":
|
|
flags = " :via-base" if edge.get("via_base") else ""
|
|
lsp = " :lsp" if edge.get("lsp_verified") else ""
|
|
lines.append(f"(imports {src} {tgt}{flags}{lsp})")
|
|
elif etype == "CALLS_API":
|
|
lsp = " :lsp" if edge.get("lsp_verified") else ""
|
|
lines.append(f"(calls-api {src} {tgt} :via-ifc{lsp})")
|
|
elif etype == "DATA_FLOWS_TO":
|
|
flags = " :validated" if edge.get("validated") else ""
|
|
lines.append(f"(data-flow {src} {tgt}{flags})")
|
|
elif etype == "REFERENCES":
|
|
lsp = " :lsp" if edge.get("lsp_verified") else ""
|
|
lines.append(f"(refs {src} {tgt}{lsp})")
|
|
elif etype == "CALLS":
|
|
fl = edge.get("from_lines", [])
|
|
lns = f" :lines {' '.join(str(l) for l in fl)}" if fl else ""
|
|
lines.append(f"(calls {src} {tgt}{lns} :lsp)")
|
|
elif etype == "HTTP_CALLS":
|
|
client = edge.get("client_entity", "")
|
|
client_str = f" :client {client}" if client else ""
|
|
lines.append(f"(http-calls {src} {tgt} :transport http{client_str})")
|
|
|
|
# -- Violations (Gap 2 fix) --
|
|
if violations:
|
|
lines.append("")
|
|
lines.append("; --- violations ---")
|
|
for v in violations:
|
|
src = _pid(v["source"])
|
|
tgt = _pid(v["target"])
|
|
rule = v.get("rule", "unknown")
|
|
detail = _sexp_escape(v.get("detail", ""))
|
|
lines.append(f'(violation {rule} {src} {tgt} "{detail}")')
|
|
|
|
with open(output_path, "w") as f:
|
|
f.write("\n".join(lines))
|
|
f.write("\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
full_rebuild = "--full" in args
|
|
use_lsp = "--lsp" in args
|
|
lsp_timeout = LSP_REQUEST_TIMEOUT
|
|
|
|
# Parse --lsp-timeout N
|
|
known_flags = {"--full", "--lsp"}
|
|
clean_args = []
|
|
i = 0
|
|
while i < len(args):
|
|
if args[i] == "--lsp-timeout" and i + 1 < len(args):
|
|
try:
|
|
lsp_timeout = float(args[i + 1])
|
|
except ValueError:
|
|
print(f"Error: invalid --lsp-timeout value: {args[i + 1]}", file=sys.stderr)
|
|
sys.exit(1)
|
|
i += 2
|
|
continue
|
|
if args[i] not in known_flags:
|
|
clean_args.append(args[i])
|
|
i += 1
|
|
|
|
root = os.path.abspath(clean_args[0] if clean_args else ".")
|
|
if not os.path.isdir(root):
|
|
print(f"Error: {root} is not a directory", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
t0 = time.monotonic()
|
|
mode = "LSP-enhanced" if use_lsp else "regex/AST"
|
|
print(f"[belief-map] Scanning {root} ... (mode: {mode})")
|
|
|
|
files = discover_files(root)
|
|
py_count = sum(1 for _, l, _ in files if l == "python")
|
|
ts_count = sum(1 for _, l, _ in files if l == "typescript")
|
|
print(f"[belief-map] Found {len(files)} source files ({py_count} py, {ts_count} ts)")
|
|
|
|
cache = {} if full_rebuild else load_cache(root)
|
|
to_parse: list[tuple[str, str, str]] = []
|
|
cached_results: list[FileResult] = []
|
|
|
|
for path, lang, repo in files:
|
|
entry = cache.get(path)
|
|
if entry and not full_rebuild:
|
|
try:
|
|
cur_mtime = os.path.getmtime(path)
|
|
if cur_mtime <= entry.get("mtime", 0):
|
|
cached_results.append(FileResult(**entry["result"]))
|
|
continue
|
|
except OSError:
|
|
pass
|
|
to_parse.append((path, lang, repo))
|
|
|
|
print(f"[belief-map] Parsing {len(to_parse)} changed files ({len(cached_results)} cached)")
|
|
|
|
new_results: list[FileResult] = []
|
|
if to_parse:
|
|
workers = min(os.cpu_count() or 4, len(to_parse))
|
|
with ProcessPoolExecutor(max_workers=workers) as pool:
|
|
futures = {pool.submit(parse_file, item): item for item in to_parse}
|
|
for fut in as_completed(futures):
|
|
result = fut.result()
|
|
if result:
|
|
new_results.append(result)
|
|
|
|
# Update cache
|
|
new_cache: dict = {}
|
|
all_results = cached_results + new_results
|
|
for r in all_results:
|
|
new_cache[r.path] = {
|
|
"mtime": r.mtime,
|
|
"result": {
|
|
"path": r.path,
|
|
"language": r.language,
|
|
"repo": r.repo,
|
|
"mtime": r.mtime,
|
|
"content_hash": r.content_hash,
|
|
"imports": r.imports,
|
|
"exports_abstract": r.exports_abstract,
|
|
"implements": r.implements,
|
|
"extends": r.extends,
|
|
"purpose": r.purpose,
|
|
"naming_convention": r.naming_convention,
|
|
"has_validation": r.has_validation,
|
|
"entities": r.entities,
|
|
"imported_names": r.imported_names,
|
|
"exported_names": r.exported_names,
|
|
},
|
|
}
|
|
save_cache(root, new_cache)
|
|
|
|
# Build graph (regex/AST pass)
|
|
nodes, edges = build_graph(all_results, root)
|
|
|
|
t_regex = time.monotonic() - t0
|
|
print(f"[belief-map] Regex/AST pass: {t_regex:.2f}s -- {len(nodes)} nodes, {len(edges)} edges")
|
|
|
|
# LSP enrichment pass (if requested)
|
|
if use_lsp:
|
|
t_lsp_start = time.monotonic()
|
|
print(f"[belief-map] Starting LSP enrichment (timeout={lsp_timeout}s per request) ...")
|
|
nodes, edges = enrich_with_lsp(root, all_results, nodes, edges, lsp_timeout)
|
|
t_lsp = time.monotonic() - t_lsp_start
|
|
print(f"[belief-map] LSP pass: {t_lsp:.2f}s")
|
|
|
|
# Write output
|
|
output_path = os.path.join(root, OUTPUT_FILE)
|
|
write_sexp(output_path, nodes, edges, root, mode, len(all_results))
|
|
|
|
elapsed = time.monotonic() - t0
|
|
print(f"[belief-map] Done in {elapsed:.2f}s -- {len(nodes)} nodes, {len(edges)} edges")
|
|
print(f"[belief-map] Output: {output_path}")
|
|
|
|
# Stats
|
|
repos = sorted(set(n["repo"] for n in nodes))
|
|
print(f"[belief-map] Repos: {', '.join(repos)}")
|
|
|
|
from collections import Counter
|
|
edge_types = Counter(e["type"] for e in edges)
|
|
total_entities = sum(len(n.get("entities", [])) for n in nodes)
|
|
lsp_verified = sum(1 for e in edges if e.get("lsp_verified"))
|
|
print(f"[belief-map] Entities: {total_entities}")
|
|
for etype, count in sorted(edge_types.items()):
|
|
print(f"[belief-map] {etype}: {count}")
|
|
if lsp_verified:
|
|
print(f"[belief-map] LSP-verified: {lsp_verified}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|