omphalos 1.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omphalos/__init__.py +0 -0
- omphalos/cache.py +58 -0
- omphalos/cli.py +181 -0
- omphalos/ignore.py +168 -0
- omphalos/parser.py +519 -0
- omphalos/render.py +69 -0
- omphalos-1.1.1.dist-info/METADATA +231 -0
- omphalos-1.1.1.dist-info/RECORD +10 -0
- omphalos-1.1.1.dist-info/WHEEL +4 -0
- omphalos-1.1.1.dist-info/entry_points.txt +4 -0
omphalos/__init__.py
ADDED
|
File without changes
|
omphalos/cache.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# omphalos/cache.py
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CACHE_FILE = ".omphcache"
|
|
9
|
+
# Increment this number when the cache data structure changes to invalidate old caches.
|
|
10
|
+
# v3: symbols stored as structured dicts (kind/name/line/doc/label/children)
|
|
11
|
+
# v4: function/method symbols gained optional `calls` and `raises` lists
|
|
12
|
+
# v5: calls exclude signature/decorator/defaults and collapse chained callees
|
|
13
|
+
CACHE_VERSION = 5
|
|
14
|
+
|
|
15
|
+
def calculate_sha256(content: bytes) -> str:
|
|
16
|
+
"""Calculate the SHA-256 checksum of raw file bytes for change detection."""
|
|
17
|
+
return hashlib.sha256(content).hexdigest()
|
|
18
|
+
|
|
19
|
+
def load_cache(root_dir: Path) -> dict:
|
|
20
|
+
"""Load symbol cache from .omphcache.
|
|
21
|
+
|
|
22
|
+
Returns an empty dict if the cache file does not exist, fails to parse,
|
|
23
|
+
or has an incompatible cache version.
|
|
24
|
+
"""
|
|
25
|
+
cache_path = root_dir / CACHE_FILE
|
|
26
|
+
if not cache_path.exists():
|
|
27
|
+
return {}
|
|
28
|
+
try:
|
|
29
|
+
raw = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
30
|
+
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
|
31
|
+
return {}
|
|
32
|
+
# Discard cache created with a different schema version
|
|
33
|
+
if not isinstance(raw, dict) or raw.get("version") != CACHE_VERSION:
|
|
34
|
+
return {}
|
|
35
|
+
files = raw.get("files")
|
|
36
|
+
return files if isinstance(files, dict) else {}
|
|
37
|
+
|
|
38
|
+
def save_cache(root_dir: Path, cache: dict):
|
|
39
|
+
"""Save symbol cache to .omphcache atomically.
|
|
40
|
+
|
|
41
|
+
Writes to a temporary file first and replaces the target file via os.replace
|
|
42
|
+
to prevent file corruption if interrupted.
|
|
43
|
+
"""
|
|
44
|
+
cache_path = root_dir / CACHE_FILE
|
|
45
|
+
payload = {"version": CACHE_VERSION, "files": cache}
|
|
46
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
47
|
+
dir=cache_path.parent, prefix=f"{CACHE_FILE}.", suffix=".tmp"
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
51
|
+
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
52
|
+
os.replace(tmp_name, cache_path)
|
|
53
|
+
except BaseException:
|
|
54
|
+
try:
|
|
55
|
+
os.unlink(tmp_name)
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
raise
|
omphalos/cli.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# omphalos/cli.py
|
|
2
|
+
import sys
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from omphalos.cache import calculate_sha256, load_cache, save_cache
|
|
9
|
+
from omphalos.ignore import (
|
|
10
|
+
IGNORE_FILE,
|
|
11
|
+
NotAGitRepoError,
|
|
12
|
+
collect_files,
|
|
13
|
+
collect_git_files,
|
|
14
|
+
ensure_ignore_file,
|
|
15
|
+
load_ignore_spec,
|
|
16
|
+
)
|
|
17
|
+
from omphalos.parser import SUPPORTED_EXTENSIONS, extract_symbols
|
|
18
|
+
from omphalos.render import render_json, render_markdown
|
|
19
|
+
|
|
20
|
+
INDEX_FILE = "INDEX.md"
|
|
21
|
+
FORMATS = ("markdown", "json")
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
add_completion=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _package_version() -> str:
|
|
29
|
+
"""Read the installed package version (single source of truth: pyproject.toml)."""
|
|
30
|
+
try:
|
|
31
|
+
return version("omphalos")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
return "unknown"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _version_callback(value: bool):
|
|
37
|
+
"""Callback for --version flag to print the version and exit immediately."""
|
|
38
|
+
if value:
|
|
39
|
+
typer.echo(f"omph {_package_version()} (omphalos)")
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback(invoke_without_command=True)
|
|
44
|
+
def main(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
version: bool = typer.Option(
|
|
47
|
+
False, "--version", callback=_version_callback, is_eager=True,
|
|
48
|
+
help="Show version and exit.",
|
|
49
|
+
)
|
|
50
|
+
):
|
|
51
|
+
"""Omphalos - Codebase symbol indexer for LLMs.
|
|
52
|
+
|
|
53
|
+
Quick start:
|
|
54
|
+
|
|
55
|
+
omph scan Scan repository and generate INDEX.md
|
|
56
|
+
|
|
57
|
+
omph scan --check Verify index is up to date (exit 1 if stale)
|
|
58
|
+
|
|
59
|
+
omph scan --git-only Scan only files tracked by Git
|
|
60
|
+
|
|
61
|
+
omph scan --format json Export index as JSON
|
|
62
|
+
|
|
63
|
+
omph init Create a default .omphignore file
|
|
64
|
+
|
|
65
|
+
For full options of each command, run: omph COMMAND --help
|
|
66
|
+
"""
|
|
67
|
+
if ctx.invoked_subcommand is None:
|
|
68
|
+
typer.echo(ctx.get_help())
|
|
69
|
+
raise typer.Exit()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def init(
|
|
74
|
+
target_dir: str = typer.Argument(".", help="Target directory to initialize")
|
|
75
|
+
):
|
|
76
|
+
"""Create a default .omphignore file if not present in the target directory."""
|
|
77
|
+
root = Path(target_dir).resolve()
|
|
78
|
+
created = ensure_ignore_file(root)
|
|
79
|
+
if created:
|
|
80
|
+
typer.echo(f"Created {created.name}")
|
|
81
|
+
else:
|
|
82
|
+
typer.echo(f"Ignore file already exists ({IGNORE_FILE}).")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def scan(
|
|
87
|
+
target_dir: str = typer.Argument(".", help="Target directory to scan"),
|
|
88
|
+
output: Path = typer.Option(
|
|
89
|
+
None, "--output", "-o",
|
|
90
|
+
help=f"Index output path (default: {INDEX_FILE} in the target directory)",
|
|
91
|
+
),
|
|
92
|
+
fmt: str = typer.Option(
|
|
93
|
+
"markdown", "--format", help=f"Output format: {' or '.join(FORMATS)}"
|
|
94
|
+
),
|
|
95
|
+
no_cache: bool = typer.Option(
|
|
96
|
+
False, "--no-cache", help="Ignore cached symbols and re-parse every file"
|
|
97
|
+
),
|
|
98
|
+
check: bool = typer.Option(
|
|
99
|
+
False, "--check",
|
|
100
|
+
help="Verify the index is up to date without writing; exit 1 if stale",
|
|
101
|
+
),
|
|
102
|
+
git_only: bool = typer.Option(
|
|
103
|
+
False, "--git-only", help="Only scan files tracked by git (requires a git repository)"
|
|
104
|
+
),
|
|
105
|
+
):
|
|
106
|
+
"""Scan multi-language codebase files and generate a symbol index with line numbers and docstrings."""
|
|
107
|
+
root = Path(target_dir).resolve()
|
|
108
|
+
if fmt not in FORMATS:
|
|
109
|
+
typer.echo(f"error: invalid --format '{fmt}' (choose from {', '.join(FORMATS)})", err=True)
|
|
110
|
+
raise typer.Exit(2)
|
|
111
|
+
|
|
112
|
+
if not check:
|
|
113
|
+
created = ensure_ignore_file(root)
|
|
114
|
+
if created:
|
|
115
|
+
typer.echo(f"Created default {created.name}")
|
|
116
|
+
|
|
117
|
+
spec = load_ignore_spec(root)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
if git_only:
|
|
121
|
+
target_files = collect_git_files(root, spec, SUPPORTED_EXTENSIONS)
|
|
122
|
+
else:
|
|
123
|
+
target_files = collect_files(root, spec, SUPPORTED_EXTENSIONS)
|
|
124
|
+
except NotAGitRepoError as e:
|
|
125
|
+
typer.echo(f"error: --git-only requires a git repository ({e})", err=True)
|
|
126
|
+
raise typer.Exit(1) from e
|
|
127
|
+
|
|
128
|
+
cache = {} if no_cache else load_cache(root)
|
|
129
|
+
new_cache = {}
|
|
130
|
+
files_data: dict[str, dict] = {}
|
|
131
|
+
failed = 0
|
|
132
|
+
|
|
133
|
+
with typer.progressbar(target_files, label="Indexing", file=sys.stderr) as progress:
|
|
134
|
+
for file_path in progress:
|
|
135
|
+
rel_posix = file_path.relative_to(root).as_posix()
|
|
136
|
+
try:
|
|
137
|
+
content = file_path.read_bytes()
|
|
138
|
+
except OSError as e:
|
|
139
|
+
typer.echo(f"error: cannot read {rel_posix}: {e}", err=True)
|
|
140
|
+
failed += 1
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
file_hash = calculate_sha256(content)
|
|
144
|
+
# Check whether cached symbol data is reusable
|
|
145
|
+
cached = cache.get(rel_posix)
|
|
146
|
+
if cached and cached.get("hash") == file_hash and isinstance(cached.get("data"), dict):
|
|
147
|
+
data = cached["data"]
|
|
148
|
+
else:
|
|
149
|
+
data = extract_symbols(file_path, content)
|
|
150
|
+
|
|
151
|
+
new_cache[rel_posix] = {"hash": file_hash, "data": data}
|
|
152
|
+
files_data[rel_posix] = data
|
|
153
|
+
|
|
154
|
+
if data.get("error"):
|
|
155
|
+
typer.echo(f"error: cannot parse {rel_posix}: {data['error']}", err=True)
|
|
156
|
+
failed += 1
|
|
157
|
+
|
|
158
|
+
renderer = render_json if fmt == "json" else render_markdown
|
|
159
|
+
rendered = renderer(files_data)
|
|
160
|
+
index_path = output if output else root / INDEX_FILE
|
|
161
|
+
|
|
162
|
+
if check:
|
|
163
|
+
existing = index_path.read_text(encoding="utf-8") if index_path.exists() else None
|
|
164
|
+
if existing == rendered:
|
|
165
|
+
typer.echo("Index is up to date.")
|
|
166
|
+
else:
|
|
167
|
+
typer.echo(f"Index is out of date: {index_path}", err=True)
|
|
168
|
+
raise typer.Exit(1)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# Write index file and persist updated cache
|
|
172
|
+
index_path.write_text(rendered, encoding="utf-8")
|
|
173
|
+
save_cache(root, new_cache)
|
|
174
|
+
summary = f"Updated {index_path.name} successfully ({len(target_files)} files scanned)."
|
|
175
|
+
if failed:
|
|
176
|
+
summary = f"Updated {index_path.name} ({len(target_files)} files scanned, {failed} failed)."
|
|
177
|
+
typer.echo(summary)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
app()
|
omphalos/ignore.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# omphalos/ignore.py
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
from collections.abc import Container
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pathspec
|
|
8
|
+
|
|
9
|
+
DEFAULT_IGNORES = [
|
|
10
|
+
".git/",
|
|
11
|
+
".omphcache",
|
|
12
|
+
".omphalos_cache.json",
|
|
13
|
+
"INDEX.md",
|
|
14
|
+
".omphalos_index",
|
|
15
|
+
".omphignore",
|
|
16
|
+
".omphalos_ignore",
|
|
17
|
+
"__pycache__/",
|
|
18
|
+
".pytest_cache/",
|
|
19
|
+
".mypy_cache/",
|
|
20
|
+
".ruff_cache/",
|
|
21
|
+
"node_modules/",
|
|
22
|
+
".venv/",
|
|
23
|
+
"venv/",
|
|
24
|
+
"dist/",
|
|
25
|
+
"build/",
|
|
26
|
+
"*.lock",
|
|
27
|
+
"*.min.js",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
IGNORE_TEMPLATE = """# .omphignore
|
|
31
|
+
# Files and directories to ignore when scanning codebase symbols.
|
|
32
|
+
# Uses gitignore pattern syntax.
|
|
33
|
+
|
|
34
|
+
# Version control & environments
|
|
35
|
+
.git/
|
|
36
|
+
.venv/
|
|
37
|
+
venv/
|
|
38
|
+
node_modules/
|
|
39
|
+
|
|
40
|
+
# Cache & build outputs
|
|
41
|
+
__pycache__/
|
|
42
|
+
.pytest_cache/
|
|
43
|
+
.mypy_cache/
|
|
44
|
+
.ruff_cache/
|
|
45
|
+
dist/
|
|
46
|
+
build/
|
|
47
|
+
*.egg-info/
|
|
48
|
+
|
|
49
|
+
# Omphalos cache & output
|
|
50
|
+
.omphcache
|
|
51
|
+
INDEX.md
|
|
52
|
+
|
|
53
|
+
# Locks & minified files
|
|
54
|
+
*.lock
|
|
55
|
+
*.min.js
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
IGNORE_FILE = ".omphignore"
|
|
59
|
+
|
|
60
|
+
def ensure_ignore_file(root_dir: Path) -> Path | None:
|
|
61
|
+
"""Create a default .omphignore file if it does not already exist.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Path of the newly created ignore file, or None if it already exists.
|
|
65
|
+
"""
|
|
66
|
+
target = root_dir / IGNORE_FILE
|
|
67
|
+
if target.exists():
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
target.write_text(IGNORE_TEMPLATE.strip() + "\n", encoding="utf-8")
|
|
71
|
+
return target
|
|
72
|
+
|
|
73
|
+
def load_ignore_spec(root_dir: Path) -> pathspec.PathSpec:
|
|
74
|
+
"""Load and merge all ignore rules into a unified PathSpec.
|
|
75
|
+
|
|
76
|
+
Precedence order:
|
|
77
|
+
1. DEFAULT_IGNORES (e.g., .git, venv, caches)
|
|
78
|
+
2. Rules from .gitignore (if present)
|
|
79
|
+
3. Rules from .omphignore (and legacy .omphalos_ignore if present)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
GitIgnoreSpec matcher for filtering paths.
|
|
83
|
+
"""
|
|
84
|
+
patterns = list(DEFAULT_IGNORES)
|
|
85
|
+
|
|
86
|
+
# Order matters: ignore files come after .gitignore to allow overriding with negation patterns
|
|
87
|
+
for filename in [".gitignore", ".omphalos_ignore", IGNORE_FILE]:
|
|
88
|
+
ignore_file = root_dir / filename
|
|
89
|
+
if ignore_file.exists():
|
|
90
|
+
patterns.extend(ignore_file.read_text(encoding="utf-8").splitlines())
|
|
91
|
+
|
|
92
|
+
# Use GitIgnoreSpec for git-compliant semantics (negation, directory matching)
|
|
93
|
+
return pathspec.GitIgnoreSpec.from_lines("gitignore", patterns)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def collect_files(
|
|
97
|
+
root_dir: Path, spec: pathspec.PathSpec, supported_extensions: Container[str]
|
|
98
|
+
) -> list[Path]:
|
|
99
|
+
"""Recursively collect supported files, pruning directories matched by ignore spec.
|
|
100
|
+
|
|
101
|
+
Note: Directories matching ignore rules are pruned immediately during os.walk,
|
|
102
|
+
matching git behavior (negation patterns cannot re-include files within an
|
|
103
|
+
ignored directory).
|
|
104
|
+
"""
|
|
105
|
+
target_files: list[Path] = []
|
|
106
|
+
for dirpath, dirnames, filenames in os.walk(root_dir):
|
|
107
|
+
rel_dir = Path(dirpath).relative_to(root_dir)
|
|
108
|
+
dirnames[:] = [
|
|
109
|
+
d for d in dirnames
|
|
110
|
+
if not spec.match_file(f"{(rel_dir / d).as_posix()}/")
|
|
111
|
+
]
|
|
112
|
+
for f in filenames:
|
|
113
|
+
p = Path(f)
|
|
114
|
+
if p.suffix.lower() in supported_extensions:
|
|
115
|
+
rel_file = (rel_dir / f).as_posix()
|
|
116
|
+
if not spec.match_file(rel_file):
|
|
117
|
+
target_files.append(root_dir / rel_file)
|
|
118
|
+
|
|
119
|
+
target_files.sort()
|
|
120
|
+
return target_files
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class NotAGitRepoError(Exception):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def collect_git_files(
|
|
128
|
+
root_dir: Path, spec: pathspec.PathSpec, supported_extensions: Container[str]
|
|
129
|
+
) -> list[Path]:
|
|
130
|
+
"""Collect only files tracked by Git (via git ls-files), filtered by spec and extension.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
root_dir: Root directory of the Git repository.
|
|
134
|
+
spec: PathSpec ignore filter.
|
|
135
|
+
supported_extensions: Collection of supported file extensions.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of matching file Paths that exist in the working tree.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
NotAGitRepoError: If root_dir is not inside a git repository or git binary is missing.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
proc = subprocess.run(
|
|
145
|
+
["git", "ls-files", "-z"], cwd=root_dir,
|
|
146
|
+
capture_output=True, check=False,
|
|
147
|
+
)
|
|
148
|
+
except FileNotFoundError as e:
|
|
149
|
+
raise NotAGitRepoError("git command not found") from e
|
|
150
|
+
if proc.returncode != 0:
|
|
151
|
+
msg = proc.stderr.decode("utf-8", errors="replace").strip()
|
|
152
|
+
raise NotAGitRepoError(msg or "not a git repository")
|
|
153
|
+
|
|
154
|
+
target_files: list[Path] = []
|
|
155
|
+
for chunk in proc.stdout.split(b"\0"):
|
|
156
|
+
if not chunk:
|
|
157
|
+
continue
|
|
158
|
+
rel = chunk.decode("utf-8", errors="replace").replace("\\", "/")
|
|
159
|
+
p = Path(rel)
|
|
160
|
+
if p.suffix.lower() not in supported_extensions:
|
|
161
|
+
continue
|
|
162
|
+
if not (root_dir / p).is_file():
|
|
163
|
+
continue # Present in git index but deleted from working tree
|
|
164
|
+
if not spec.match_file(rel):
|
|
165
|
+
target_files.append(root_dir / p)
|
|
166
|
+
|
|
167
|
+
target_files.sort()
|
|
168
|
+
return target_files
|
omphalos/parser.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
# omphalos/parser.py
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import tree_sitter as ts
|
|
6
|
+
import tree_sitter_go as tsgo
|
|
7
|
+
import tree_sitter_javascript as tsjs
|
|
8
|
+
import tree_sitter_python as tspython
|
|
9
|
+
import tree_sitter_rust as tsrust
|
|
10
|
+
import tree_sitter_typescript as tsts
|
|
11
|
+
|
|
12
|
+
# Initialize Languages
|
|
13
|
+
_LANG_PYTHON = ts.Language(tspython.language())
|
|
14
|
+
_LANG_TS = ts.Language(tsts.language_typescript())
|
|
15
|
+
_LANG_TSX = ts.Language(tsts.language_tsx())
|
|
16
|
+
_LANG_JS = ts.Language(tsjs.language())
|
|
17
|
+
_LANG_GO = ts.Language(tsgo.language())
|
|
18
|
+
_LANG_RUST = ts.Language(tsrust.language())
|
|
19
|
+
|
|
20
|
+
# Parsers
|
|
21
|
+
_PARSER_PYTHON = ts.Parser(_LANG_PYTHON)
|
|
22
|
+
_PARSER_TS = ts.Parser(_LANG_TS)
|
|
23
|
+
_PARSER_TSX = ts.Parser(_LANG_TSX)
|
|
24
|
+
_PARSER_JS = ts.Parser(_LANG_JS)
|
|
25
|
+
_PARSER_GO = ts.Parser(_LANG_GO)
|
|
26
|
+
_PARSER_RUST = ts.Parser(_LANG_RUST)
|
|
27
|
+
|
|
28
|
+
SUPPORTED_EXTENSIONS = {
|
|
29
|
+
".py": "python",
|
|
30
|
+
".ts": "typescript",
|
|
31
|
+
".tsx": "tsx",
|
|
32
|
+
".js": "javascript",
|
|
33
|
+
".jsx": "tsx",
|
|
34
|
+
".mjs": "javascript",
|
|
35
|
+
".cjs": "javascript",
|
|
36
|
+
".go": "go",
|
|
37
|
+
".rs": "rust",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _text(node: ts.Node) -> str:
|
|
42
|
+
"""Decode Tree-sitter AST node text bytes to a UTF-8 string."""
|
|
43
|
+
return (node.text or b"").decode("utf-8", errors="replace")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _mk_symbol(kind: str, name: str, node: ts.Node, label: str, doc: str = "") -> dict[str, Any]:
|
|
47
|
+
"""Create a standardized symbol schema dictionary.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
kind: Type of symbol (e.g., function, class, method, struct, interface).
|
|
51
|
+
name: Identifier name of the symbol.
|
|
52
|
+
node: Originating Tree-sitter AST node for extracting 1-indexed start line.
|
|
53
|
+
label: Human/LLM-readable display label.
|
|
54
|
+
doc: First-line summary or docstring (if available).
|
|
55
|
+
"""
|
|
56
|
+
return {
|
|
57
|
+
"kind": kind,
|
|
58
|
+
"name": name,
|
|
59
|
+
"label": label,
|
|
60
|
+
"line": node.start_point[0] + 1,
|
|
61
|
+
"doc": doc,
|
|
62
|
+
"exported": False,
|
|
63
|
+
"children": [],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _prev_comment_block(node: ts.Node) -> list[ts.Node]:
|
|
68
|
+
"""Return contiguous comment nodes immediately preceding the node (in top-to-bottom order).
|
|
69
|
+
|
|
70
|
+
Note: Certain grammars (like Rust) include the trailing newline in line_comment,
|
|
71
|
+
causing end_point to fall on the next line. We strip trailing newlines before checking contiguity.
|
|
72
|
+
"""
|
|
73
|
+
def content_end_row(c: ts.Node) -> int:
|
|
74
|
+
text = c.text or b""
|
|
75
|
+
return c.end_point[0] - (len(text) - len(text.rstrip(b"\r\n")))
|
|
76
|
+
|
|
77
|
+
block = []
|
|
78
|
+
cur = node.prev_named_sibling
|
|
79
|
+
row = node.start_point[0]
|
|
80
|
+
while cur is not None and cur.type in ("comment", "line_comment") and content_end_row(cur) == row - 1:
|
|
81
|
+
block.append(cur)
|
|
82
|
+
row = cur.start_point[0]
|
|
83
|
+
cur = cur.prev_named_sibling
|
|
84
|
+
block.reverse()
|
|
85
|
+
return block
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_py_docstring(node: ts.Node) -> str:
|
|
89
|
+
"""Extract the first line of a Python function or class docstring, if present."""
|
|
90
|
+
body = node.child_by_field_name("body")
|
|
91
|
+
if body and len(body.named_children) > 0:
|
|
92
|
+
first = body.named_children[0]
|
|
93
|
+
if first.type == "expression_statement" and len(first.named_children) > 0:
|
|
94
|
+
str_node = first.named_children[0]
|
|
95
|
+
if str_node.type == "string":
|
|
96
|
+
# Intentional charset strip: remove repeated quotes (""" or ''') and surrounding whitespace
|
|
97
|
+
raw = _text(str_node).strip("\"' \n\r\t") # noqa: B005
|
|
98
|
+
return raw.splitlines()[0].strip() if raw else ""
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _go_doc(node: ts.Node) -> str:
|
|
103
|
+
"""Go doc comment: first line of comment block attached above declaration (skipping //go: directives)."""
|
|
104
|
+
for c in _prev_comment_block(node):
|
|
105
|
+
t = _text(c).strip()
|
|
106
|
+
if t.startswith("//go:"):
|
|
107
|
+
continue
|
|
108
|
+
return t.removeprefix("//").strip()
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _rust_doc(node: ts.Node) -> str:
|
|
113
|
+
"""Rust doc comment: first line of /// comment block attached above an item."""
|
|
114
|
+
for c in _prev_comment_block(node):
|
|
115
|
+
t = _text(c).strip()
|
|
116
|
+
if t.startswith("///"):
|
|
117
|
+
line = t.removeprefix("///").strip()
|
|
118
|
+
if line:
|
|
119
|
+
return line
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _ts_doc(node: ts.Node) -> str:
|
|
124
|
+
"""JSDoc comment: first line containing content within a /** ... */ block attached above declaration."""
|
|
125
|
+
for c in _prev_comment_block(node):
|
|
126
|
+
t = _text(c).strip()
|
|
127
|
+
if not t.startswith("/**"):
|
|
128
|
+
continue
|
|
129
|
+
for raw in t.splitlines():
|
|
130
|
+
line = raw.strip().removeprefix("/**").removeprefix("*").removesuffix("*/").strip()
|
|
131
|
+
if line:
|
|
132
|
+
return line
|
|
133
|
+
return ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _unwrap_decorated(node: ts.Node) -> ts.Node:
|
|
137
|
+
"""Unwrap core definition node (function/class) from a Python decorated_definition."""
|
|
138
|
+
if node.type == "decorated_definition":
|
|
139
|
+
for child in node.named_children:
|
|
140
|
+
if child.type in ("function_definition", "async_function_definition", "class_definition"):
|
|
141
|
+
return child
|
|
142
|
+
return node
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Node-type rules for internal logic extraction, per language family:
|
|
146
|
+
# {call-like node type: field name holding the callee expression}
|
|
147
|
+
_CALL_FIELDS = {
|
|
148
|
+
"python": {"call": "function"},
|
|
149
|
+
"ts_js": {"call_expression": "function", "new_expression": "constructor"},
|
|
150
|
+
"go": {"call_expression": "function"},
|
|
151
|
+
"rust": {"call_expression": "function"},
|
|
152
|
+
}
|
|
153
|
+
# Statement types that propagate errors outward (Go/Rust use return values instead)
|
|
154
|
+
_RAISE_NODES = {"python": "raise_statement", "ts_js": "throw_statement"}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _raise_target(node: ts.Node) -> str:
|
|
158
|
+
"""Extract the exception type from a raise/throw statement.
|
|
159
|
+
|
|
160
|
+
Covers `raise X`, `raise X(...)`, `throw new X(...)`, and bare identifier re-throws.
|
|
161
|
+
"""
|
|
162
|
+
if not node.named_children:
|
|
163
|
+
return ""
|
|
164
|
+
first = node.named_children[0]
|
|
165
|
+
if first.type in ("call", "call_expression"):
|
|
166
|
+
callee = first.child_by_field_name("function")
|
|
167
|
+
return _text(callee) if callee else ""
|
|
168
|
+
if first.type == "new_expression":
|
|
169
|
+
ctor = first.child_by_field_name("constructor")
|
|
170
|
+
return _text(ctor) if ctor else ""
|
|
171
|
+
if first.type in ("identifier", "scoped_identifier", "scoped_type_identifier"):
|
|
172
|
+
return _text(first)
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _is_simple_chain(node: ts.Node) -> bool:
|
|
177
|
+
"""True ถ้า node เป็นโซ่ identifier เรียบ ๆ เช่น `self`, `log`, `file_path.relative_to`
|
|
178
|
+
|
|
179
|
+
ใช้แยกว่า callee ควรแสดงเต็ม (`self._flush`) หรือย่อเหลือ property ปลาย
|
|
180
|
+
เมื่อ object ต้นทางเป็นนิพจน์ซับซ้อน (`(a / b).as_posix`, `f(x).strip`)
|
|
181
|
+
"""
|
|
182
|
+
if node.type in ("identifier", "type_identifier", "self", "this", "super",
|
|
183
|
+
"field_identifier", "property_identifier"):
|
|
184
|
+
return True
|
|
185
|
+
kids = node.named_children
|
|
186
|
+
if node.type in ("attribute", "member_expression", "selector_expression", "field_expression",
|
|
187
|
+
"scoped_identifier", "scoped_type_identifier") and kids:
|
|
188
|
+
return _is_simple_chain(kids[0])
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _callee_name(callee: ts.Node) -> str:
|
|
193
|
+
"""Compact callee name: dotted chain เต็มถ้าต้นทางเรียบ, ไม่งั้นเหลือเฉพาะชื่อปลาย"""
|
|
194
|
+
kids = callee.named_children
|
|
195
|
+
if len(kids) < 2 or _is_simple_chain(kids[0]):
|
|
196
|
+
return _text(callee)
|
|
197
|
+
return _text(kids[-1])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _collect_logic(node: ts.Node, family: str) -> tuple[list[str], list[str]]:
|
|
201
|
+
"""Collect deduplicated internal call targets and raised exception types of a function body.
|
|
202
|
+
|
|
203
|
+
Only names are recorded (not arguments) to keep the index compact yet signal-dense.
|
|
204
|
+
Raise/throw subtrees are not descended into: the raised expression is an error type,
|
|
205
|
+
not a call target, and would otherwise appear in both lists.
|
|
206
|
+
"""
|
|
207
|
+
call_fields = _CALL_FIELDS[family]
|
|
208
|
+
raise_type = _RAISE_NODES.get(family)
|
|
209
|
+
calls: list[str] = []
|
|
210
|
+
raises: list[str] = []
|
|
211
|
+
stack = [node]
|
|
212
|
+
while stack:
|
|
213
|
+
n = stack.pop()
|
|
214
|
+
if raise_type and n.type == raise_type:
|
|
215
|
+
name = _raise_target(n)
|
|
216
|
+
if name and name not in raises:
|
|
217
|
+
raises.append(name)
|
|
218
|
+
continue
|
|
219
|
+
field = call_fields.get(n.type)
|
|
220
|
+
if field:
|
|
221
|
+
callee = n.child_by_field_name(field)
|
|
222
|
+
if callee:
|
|
223
|
+
name = _callee_name(callee)
|
|
224
|
+
if name and name not in calls:
|
|
225
|
+
calls.append(name)
|
|
226
|
+
stack.extend(reversed(n.named_children))
|
|
227
|
+
return calls, raises
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _annotate_logic(sym: dict[str, Any], node: ts.Node, family: str, force: bool = False):
|
|
231
|
+
"""Attach `calls`/`raises` to function-like symbols (keys added only when non-empty).
|
|
232
|
+
|
|
233
|
+
Scans only the function *body*: calls appearing in signatures, decorators,
|
|
234
|
+
or parameter defaults are declaration-time expressions, not runtime logic.
|
|
235
|
+
"""
|
|
236
|
+
if not force and sym["kind"] not in ("function", "method"):
|
|
237
|
+
return
|
|
238
|
+
body = node.child_by_field_name("body") or node
|
|
239
|
+
calls, raises = _collect_logic(body, family)
|
|
240
|
+
if calls:
|
|
241
|
+
sym["calls"] = calls
|
|
242
|
+
if raises:
|
|
243
|
+
sym["raises"] = raises
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _extract_python(root_node: ts.Node) -> list[dict[str, Any]]:
|
|
247
|
+
"""Extract all symbols from a Python AST (classes, methods, functions)."""
|
|
248
|
+
symbols = []
|
|
249
|
+
for raw_node in root_node.children:
|
|
250
|
+
node = _unwrap_decorated(raw_node)
|
|
251
|
+
if node.type == "class_definition":
|
|
252
|
+
name_node = node.child_by_field_name("name")
|
|
253
|
+
if name_node:
|
|
254
|
+
name = _text(name_node)
|
|
255
|
+
cls = _mk_symbol("class", name, node, f"class `{name}`", _get_py_docstring(node))
|
|
256
|
+
|
|
257
|
+
body = node.child_by_field_name("body")
|
|
258
|
+
for child_raw in (body.named_children if body else []):
|
|
259
|
+
child = _unwrap_decorated(child_raw)
|
|
260
|
+
if child.type in ("function_definition", "async_function_definition"):
|
|
261
|
+
m_name_node = child.child_by_field_name("name")
|
|
262
|
+
if m_name_node:
|
|
263
|
+
m_name = _text(m_name_node)
|
|
264
|
+
m = _mk_symbol("method", m_name, child, f"`{m_name}()`", _get_py_docstring(child))
|
|
265
|
+
_annotate_logic(m, child, "python")
|
|
266
|
+
cls["children"].append(m)
|
|
267
|
+
symbols.append(cls)
|
|
268
|
+
|
|
269
|
+
elif node.type in ("function_definition", "async_function_definition"):
|
|
270
|
+
name_node = node.child_by_field_name("name")
|
|
271
|
+
if name_node:
|
|
272
|
+
name = _text(name_node)
|
|
273
|
+
fn = _mk_symbol("function", name, node, f"`{name}()`", _get_py_docstring(node))
|
|
274
|
+
_annotate_logic(fn, node, "python")
|
|
275
|
+
symbols.append(fn)
|
|
276
|
+
|
|
277
|
+
return symbols
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _ts_symbol(target: ts.Node, doc_node: ts.Node, exported: bool) -> dict[str, Any] | None:
|
|
281
|
+
"""Extract a structured symbol dictionary from a TypeScript/JavaScript declaration node."""
|
|
282
|
+
prefix = "export " if exported else ""
|
|
283
|
+
|
|
284
|
+
if target.type == "interface_declaration":
|
|
285
|
+
name_node = target.child_by_field_name("name")
|
|
286
|
+
if name_node:
|
|
287
|
+
return _mk_symbol("interface", _text(name_node), target,
|
|
288
|
+
f"{prefix}interface `{_text(name_node)}`", _ts_doc(doc_node))
|
|
289
|
+
|
|
290
|
+
elif target.type == "type_alias_declaration":
|
|
291
|
+
name_node = target.child_by_field_name("name")
|
|
292
|
+
if name_node:
|
|
293
|
+
return _mk_symbol("type", _text(name_node), target,
|
|
294
|
+
f"{prefix}type `{_text(name_node)}`", _ts_doc(doc_node))
|
|
295
|
+
|
|
296
|
+
elif target.type == "function_declaration":
|
|
297
|
+
name_node = target.child_by_field_name("name")
|
|
298
|
+
if name_node:
|
|
299
|
+
fn = _mk_symbol("function", _text(name_node), target,
|
|
300
|
+
f"{prefix}function `{_text(name_node)}()`", _ts_doc(doc_node))
|
|
301
|
+
_annotate_logic(fn, target, "ts_js")
|
|
302
|
+
return fn
|
|
303
|
+
|
|
304
|
+
elif target.type == "class_declaration":
|
|
305
|
+
name_node = target.child_by_field_name("name")
|
|
306
|
+
if name_node:
|
|
307
|
+
cls = _mk_symbol("class", _text(name_node), target,
|
|
308
|
+
f"{prefix}class `{_text(name_node)}`", _ts_doc(doc_node))
|
|
309
|
+
body = target.child_by_field_name("body")
|
|
310
|
+
if body:
|
|
311
|
+
for item in body.named_children:
|
|
312
|
+
if item.type == "method_definition":
|
|
313
|
+
m_name_node = item.child_by_field_name("name")
|
|
314
|
+
if m_name_node:
|
|
315
|
+
m = _mk_symbol(
|
|
316
|
+
"method", _text(m_name_node), item, f"`{_text(m_name_node)}()`", _ts_doc(item))
|
|
317
|
+
_annotate_logic(m, item, "ts_js")
|
|
318
|
+
cls["children"].append(m)
|
|
319
|
+
return cls
|
|
320
|
+
|
|
321
|
+
elif target.type == "lexical_declaration":
|
|
322
|
+
kind_word = _text(target.children[0]) if target.children else "const"
|
|
323
|
+
kind = "constant" if kind_word == "const" else "variable"
|
|
324
|
+
for decl in target.named_children:
|
|
325
|
+
if decl.type == "variable_declarator":
|
|
326
|
+
name_node = decl.child_by_field_name("name")
|
|
327
|
+
value_node = decl.child_by_field_name("value")
|
|
328
|
+
if name_node:
|
|
329
|
+
name = _text(name_node)
|
|
330
|
+
is_fn = value_node and value_node.type in ("arrow_function", "function_expression")
|
|
331
|
+
suffix = "()" if is_fn else ""
|
|
332
|
+
sym = _mk_symbol(kind, name, target, f"{prefix}{kind_word} `{name}{suffix}`", _ts_doc(doc_node))
|
|
333
|
+
if is_fn and value_node:
|
|
334
|
+
# Arrow/function-expression constants behave as functions: collect their logic
|
|
335
|
+
_annotate_logic(sym, value_node, "ts_js", force=True)
|
|
336
|
+
return sym
|
|
337
|
+
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _extract_ts_js(root_node: ts.Node) -> list[dict[str, Any]]:
|
|
342
|
+
"""Extract symbols from TypeScript and JavaScript ASTs (functions, classes, interfaces, types, exports)."""
|
|
343
|
+
symbols = []
|
|
344
|
+
for node in root_node.children:
|
|
345
|
+
exported = False
|
|
346
|
+
target = node
|
|
347
|
+
|
|
348
|
+
if node.type == "export_statement":
|
|
349
|
+
exported = True
|
|
350
|
+
named = [c for c in node.named_children if c.type not in ("comment",)]
|
|
351
|
+
if named:
|
|
352
|
+
target = named[0]
|
|
353
|
+
|
|
354
|
+
# Doc comment is attached above export_statement (or declaration itself if not exported)
|
|
355
|
+
sym = _ts_symbol(target, node, exported)
|
|
356
|
+
if sym:
|
|
357
|
+
sym["exported"] = exported
|
|
358
|
+
symbols.append(sym)
|
|
359
|
+
|
|
360
|
+
return symbols
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _extract_go(root_node: ts.Node) -> list[dict[str, Any]]:
|
|
364
|
+
"""Extract symbols from Go AST (types, structs, interfaces, functions, methods with receivers)."""
|
|
365
|
+
symbols = []
|
|
366
|
+
for node in root_node.children:
|
|
367
|
+
if node.type == "type_declaration":
|
|
368
|
+
specs = [c for c in node.named_children if c.type == "type_spec"]
|
|
369
|
+
for spec in specs:
|
|
370
|
+
name_node = spec.child_by_field_name("name")
|
|
371
|
+
type_val = spec.child_by_field_name("type")
|
|
372
|
+
if not name_node or type_val is None:
|
|
373
|
+
continue
|
|
374
|
+
t_kind = {"struct_type": "struct", "interface_type": "interface"}.get(type_val.type, "type")
|
|
375
|
+
suffix = {"struct": " (struct)", "interface": " (interface)"}.get(t_kind, "")
|
|
376
|
+
name = _text(name_node)
|
|
377
|
+
# Grouped type block: doc comment sits above type_spec; single type: above type_declaration
|
|
378
|
+
doc = _go_doc(spec)
|
|
379
|
+
if not doc and len(specs) == 1:
|
|
380
|
+
doc = _go_doc(node)
|
|
381
|
+
symbols.append(_mk_symbol(t_kind, name, spec, f"type `{name}`{suffix}", doc))
|
|
382
|
+
|
|
383
|
+
elif node.type == "function_declaration":
|
|
384
|
+
name_node = node.child_by_field_name("name")
|
|
385
|
+
if name_node:
|
|
386
|
+
name = _text(name_node)
|
|
387
|
+
fn = _mk_symbol("function", name, node, f"func `{name}()`", _go_doc(node))
|
|
388
|
+
_annotate_logic(fn, node, "go")
|
|
389
|
+
symbols.append(fn)
|
|
390
|
+
|
|
391
|
+
elif node.type == "method_declaration":
|
|
392
|
+
recv = node.child_by_field_name("receiver")
|
|
393
|
+
name_node = node.child_by_field_name("name")
|
|
394
|
+
if name_node:
|
|
395
|
+
name = _text(name_node)
|
|
396
|
+
recv_str = _text(recv) if recv else ""
|
|
397
|
+
m = _mk_symbol("method", name, node, f"func `{recv_str} {name}()`", _go_doc(node))
|
|
398
|
+
_annotate_logic(m, node, "go")
|
|
399
|
+
symbols.append(m)
|
|
400
|
+
|
|
401
|
+
return symbols
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _rust_vis(node: ts.Node) -> str:
|
|
405
|
+
"""Extract visibility modifier string (e.g., 'pub') from a Rust item node."""
|
|
406
|
+
for c in node.children:
|
|
407
|
+
if c.type == "visibility_modifier":
|
|
408
|
+
return _text(c)
|
|
409
|
+
return ""
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _extract_rust(root_node: ts.Node) -> list[dict[str, Any]]:
|
|
413
|
+
"""Extract symbols from Rust AST (structs, enums, traits, functions, and impl blocks)."""
|
|
414
|
+
symbols = []
|
|
415
|
+
for node in root_node.children:
|
|
416
|
+
vis = _rust_vis(node)
|
|
417
|
+
vis_str = f"{vis} " if vis else ""
|
|
418
|
+
|
|
419
|
+
if node.type == "struct_item":
|
|
420
|
+
name_node = node.child_by_field_name("name")
|
|
421
|
+
if name_node:
|
|
422
|
+
name = _text(name_node)
|
|
423
|
+
symbols.append(_mk_symbol("struct", name, node, f"{vis_str}struct `{name}`", _rust_doc(node)))
|
|
424
|
+
|
|
425
|
+
elif node.type == "enum_item":
|
|
426
|
+
name_node = node.child_by_field_name("name")
|
|
427
|
+
if name_node:
|
|
428
|
+
name = _text(name_node)
|
|
429
|
+
symbols.append(_mk_symbol("enum", name, node, f"{vis_str}enum `{name}`", _rust_doc(node)))
|
|
430
|
+
|
|
431
|
+
elif node.type == "trait_item":
|
|
432
|
+
name_node = node.child_by_field_name("name")
|
|
433
|
+
if name_node:
|
|
434
|
+
name = _text(name_node)
|
|
435
|
+
symbols.append(_mk_symbol("trait", name, node, f"{vis_str}trait `{name}`", _rust_doc(node)))
|
|
436
|
+
|
|
437
|
+
elif node.type == "function_item":
|
|
438
|
+
name_node = node.child_by_field_name("name")
|
|
439
|
+
if name_node:
|
|
440
|
+
name = _text(name_node)
|
|
441
|
+
fn = _mk_symbol("function", name, node, f"{vis_str}fn `{name}()`", _rust_doc(node))
|
|
442
|
+
_annotate_logic(fn, node, "rust")
|
|
443
|
+
symbols.append(fn)
|
|
444
|
+
|
|
445
|
+
elif node.type == "impl_item":
|
|
446
|
+
trait_node = node.child_by_field_name("trait")
|
|
447
|
+
type_node = node.child_by_field_name("type")
|
|
448
|
+
impl_header = ""
|
|
449
|
+
if trait_node and type_node:
|
|
450
|
+
impl_header = f"{_text(trait_node)} for {_text(type_node)}"
|
|
451
|
+
elif type_node:
|
|
452
|
+
impl_header = _text(type_node)
|
|
453
|
+
|
|
454
|
+
if impl_header:
|
|
455
|
+
impl = _mk_symbol("impl", impl_header, node, f"impl `{impl_header}`", _rust_doc(node))
|
|
456
|
+
body = node.child_by_field_name("body")
|
|
457
|
+
if body:
|
|
458
|
+
for item in body.named_children:
|
|
459
|
+
if item.type == "function_item":
|
|
460
|
+
m_vis = _rust_vis(item)
|
|
461
|
+
m_vis_str = f"{m_vis} " if m_vis else ""
|
|
462
|
+
m_name_node = item.child_by_field_name("name")
|
|
463
|
+
if m_name_node:
|
|
464
|
+
m_name = _text(m_name_node)
|
|
465
|
+
m = _mk_symbol(
|
|
466
|
+
"method", m_name, item, f"`{m_vis_str}fn {m_name}()`", _rust_doc(item))
|
|
467
|
+
_annotate_logic(m, item, "rust")
|
|
468
|
+
impl["children"].append(m)
|
|
469
|
+
symbols.append(impl)
|
|
470
|
+
|
|
471
|
+
return symbols
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def extract_symbols(file_path: Path, content: bytes | None = None) -> dict[str, Any]:
|
|
475
|
+
"""Parse file content using the appropriate Tree-sitter language grammar and extract code symbols.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
file_path: Path to the target source file (used for extension detection and reading content if not given).
|
|
479
|
+
content: Optional raw bytes content of the file.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Dictionary containing extracted symbols or an error message if parsing fails.
|
|
483
|
+
"""
|
|
484
|
+
ext = file_path.suffix.lower()
|
|
485
|
+
lang_type = SUPPORTED_EXTENSIONS.get(ext)
|
|
486
|
+
if not lang_type:
|
|
487
|
+
return {"symbols": []}
|
|
488
|
+
|
|
489
|
+
if content is None:
|
|
490
|
+
try:
|
|
491
|
+
content = file_path.read_bytes()
|
|
492
|
+
except Exception as e:
|
|
493
|
+
return {"error": str(e), "symbols": []}
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
if lang_type == "python":
|
|
497
|
+
tree = _PARSER_PYTHON.parse(content)
|
|
498
|
+
symbols = _extract_python(tree.root_node)
|
|
499
|
+
elif lang_type == "typescript":
|
|
500
|
+
tree = _PARSER_TS.parse(content)
|
|
501
|
+
symbols = _extract_ts_js(tree.root_node)
|
|
502
|
+
elif lang_type == "tsx":
|
|
503
|
+
tree = _PARSER_TSX.parse(content)
|
|
504
|
+
symbols = _extract_ts_js(tree.root_node)
|
|
505
|
+
elif lang_type == "javascript":
|
|
506
|
+
tree = _PARSER_JS.parse(content)
|
|
507
|
+
symbols = _extract_ts_js(tree.root_node)
|
|
508
|
+
elif lang_type == "go":
|
|
509
|
+
tree = _PARSER_GO.parse(content)
|
|
510
|
+
symbols = _extract_go(tree.root_node)
|
|
511
|
+
elif lang_type == "rust":
|
|
512
|
+
tree = _PARSER_RUST.parse(content)
|
|
513
|
+
symbols = _extract_rust(tree.root_node)
|
|
514
|
+
else:
|
|
515
|
+
symbols = []
|
|
516
|
+
except Exception as e:
|
|
517
|
+
return {"error": str(e), "symbols": []}
|
|
518
|
+
|
|
519
|
+
return {"symbols": symbols}
|
omphalos/render.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# omphalos/render.py
|
|
2
|
+
"""Render structured symbol data (from parser/cache) into target output formats."""
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def render_markdown(files_data: dict[str, dict[str, Any]]) -> str:
|
|
8
|
+
"""Render structured symbol data into Markdown format for LLM context or human reading.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
files_data: Symbol mapping keyed by relative file paths.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
Formatted Markdown index grouping symbols under file headers.
|
|
15
|
+
"""
|
|
16
|
+
lines = ["# Codebase Symbol Index\n"]
|
|
17
|
+
for path, data in files_data.items():
|
|
18
|
+
symbols = data.get("symbols", [])
|
|
19
|
+
if not symbols:
|
|
20
|
+
continue
|
|
21
|
+
lines.append(f"## {path}")
|
|
22
|
+
_render_md_symbols(symbols, depth=0, lines=lines)
|
|
23
|
+
lines.append("")
|
|
24
|
+
return "\n".join(lines)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _render_md_symbols(symbols: list[dict[str, Any]], depth: int, lines: list[str]):
|
|
28
|
+
"""Recursively render symbols as nested Markdown bullet points.
|
|
29
|
+
|
|
30
|
+
Includes label, @line number, docstring summary, and nested child symbols
|
|
31
|
+
(such as methods within classes or trait implementations).
|
|
32
|
+
"""
|
|
33
|
+
for sym in symbols:
|
|
34
|
+
text = f"{' ' * depth}- {sym['label']}"
|
|
35
|
+
if sym.get("line"):
|
|
36
|
+
text += f" @{sym['line']}"
|
|
37
|
+
if sym.get("doc"):
|
|
38
|
+
text += f": {sym['doc']}"
|
|
39
|
+
# Internal logic detail: deduplicated call targets and raised exceptions (inline, keeps tree flat)
|
|
40
|
+
detail = []
|
|
41
|
+
if sym.get("calls"):
|
|
42
|
+
detail.append("calls: " + ", ".join(f"`{c}`" for c in sym["calls"]))
|
|
43
|
+
if sym.get("raises"):
|
|
44
|
+
detail.append("raises: " + ", ".join(f"`{r}`" for r in sym["raises"]))
|
|
45
|
+
if detail:
|
|
46
|
+
text += f" ({'; '.join(detail)})"
|
|
47
|
+
lines.append(text)
|
|
48
|
+
_render_md_symbols(sym.get("children", []), depth + 1, lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_json(files_data: dict[str, dict[str, Any]]) -> str:
|
|
52
|
+
"""Render structured symbol data into a JSON string for programmatic consumption.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
files_data: Symbol mapping keyed by relative file paths.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Indented JSON string conforming to the Omphalos schema format.
|
|
59
|
+
"""
|
|
60
|
+
payload = {
|
|
61
|
+
"tool": "omphalos",
|
|
62
|
+
"format_version": 1,
|
|
63
|
+
"files": {
|
|
64
|
+
path: {"symbols": data.get("symbols", [])}
|
|
65
|
+
for path, data in files_data.items()
|
|
66
|
+
if data.get("symbols")
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
return json.dumps(payload, indent=2, ensure_ascii=False)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: omphalos
|
|
3
|
+
Version: 1.1.1
|
|
4
|
+
Summary: Codebase symbol index for LLMs
|
|
5
|
+
Keywords: llm,indexer,tree-sitter,ast,codebase,symbols,agent
|
|
6
|
+
Author: snui1s
|
|
7
|
+
Author-email: snui1s <snailsqz@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Dist: typer>=0.12.0
|
|
21
|
+
Requires-Dist: pathspec>=1.1
|
|
22
|
+
Requires-Dist: tree-sitter>=0.24.0
|
|
23
|
+
Requires-Dist: tree-sitter-python>=0.25.0
|
|
24
|
+
Requires-Dist: tree-sitter-typescript>=0.23.0
|
|
25
|
+
Requires-Dist: tree-sitter-javascript>=0.25.0
|
|
26
|
+
Requires-Dist: tree-sitter-go>=0.25.0
|
|
27
|
+
Requires-Dist: tree-sitter-rust>=0.24.0
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Project-URL: Homepage, https://github.com/snui1s/Omphalos
|
|
30
|
+
Project-URL: Repository, https://github.com/snui1s/Omphalos
|
|
31
|
+
Project-URL: Issues, https://github.com/snui1s/Omphalos/issues
|
|
32
|
+
Project-URL: Changelog, https://github.com/snui1s/Omphalos/releases
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# Omphalos
|
|
36
|
+
|
|
37
|
+
[](https://github.com/RichardLitt/standard-readme)
|
|
38
|
+
[](https://github.com/snui1s/Omphalos/actions/workflows/ci.yml)
|
|
39
|
+
[](https://www.python.org/)
|
|
40
|
+
[](LICENSE)
|
|
41
|
+
|
|
42
|
+
> Codebase symbol indexer for LLMs.
|
|
43
|
+
|
|
44
|
+
Omphalos generates a compact, high-signal symbol map (`INDEX.md`) of your repository. It provides large language models and developers with an instant architectural overview and precise symbol locations—minimizing token usage and eliminating the need to read entire source files just to find declarations.
|
|
45
|
+
|
|
46
|
+
## Table of Contents
|
|
47
|
+
|
|
48
|
+
- [Omphalos](#omphalos)
|
|
49
|
+
- [Table of Contents](#table-of-contents)
|
|
50
|
+
- [Background](#background)
|
|
51
|
+
- [Install](#install)
|
|
52
|
+
- [Usage](#usage)
|
|
53
|
+
- [Quick Start](#quick-start)
|
|
54
|
+
- [CLI Reference](#cli-reference)
|
|
55
|
+
- [LLM / Agent Integration](#llm--agent-integration)
|
|
56
|
+
- [Development](#development)
|
|
57
|
+
- [Related Efforts](#related-efforts)
|
|
58
|
+
- [Maintainers](#maintainers)
|
|
59
|
+
- [Contributing](#contributing)
|
|
60
|
+
- [License](#license)
|
|
61
|
+
|
|
62
|
+
## Background
|
|
63
|
+
|
|
64
|
+
When providing a full codebase as context to Large Language Models (LLMs) or AI coding agents, feeding entire file contents quickly exhausts the model's context window, degrades reasoning performance, and incurs high token costs.
|
|
65
|
+
|
|
66
|
+
In practice, an agent rarely needs the implementation bodies of every file at once; it only needs an accurate structural index: what functions, classes, interfaces, and methods exist, where they are defined, and what their docstrings say.
|
|
67
|
+
|
|
68
|
+
Omphalos was created to solve this problem:
|
|
69
|
+
|
|
70
|
+
1. **Multi-language AST Parsing**: Uses Tree-sitter for deterministic parsing across Python, TypeScript/JavaScript, Go, and Rust.
|
|
71
|
+
2. **Precise Line Numbers (`@<line>`)**: Symbols indicate exact declaration line numbers so agents can inspect or slice specific ranges on demand.
|
|
72
|
+
3. **Internal Logic Hints**: Function-like symbols list their deduplicated call targets (`calls`) and raised exception types (`raises`), giving agents a control-flow preview without opening the file.
|
|
73
|
+
4. **Atomic Incremental Cache**: Tracks SHA-256 content hashes to re-parse only changed files, guaranteeing rapid re-indexes.
|
|
74
|
+
5. **CI/CD Integration**: Supports `--check` mode to ensure codebase indexes stay synchronized with changes.
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
### Using [uv](https://github.com/astral-sh/uv) (Recommended)
|
|
79
|
+
|
|
80
|
+
Run directly without installing:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
$ uvx --from git+https://github.com/snui1s/Omphalos.git omph scan
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or install as a standalone CLI tool:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
$ uv tool install git+https://github.com/snui1s/Omphalos.git
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Using pip
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
$ pip install git+https://github.com/snui1s/Omphalos.git
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Using npm / npx
|
|
99
|
+
|
|
100
|
+
Run directly via npx:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
$ npx omphalos scan
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Or install globally:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
$ npm install -g omphalos
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### From Source
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
$ git clone https://github.com/snui1s/Omphalos.git
|
|
116
|
+
$ cd Omphalos
|
|
117
|
+
$ uv sync
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Usage
|
|
121
|
+
|
|
122
|
+
### Quick Start
|
|
123
|
+
|
|
124
|
+
1. **Initialize Ignore File** (creates default `.omphignore`):
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
$ omph init
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
2. **Generate the Symbol Index**:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
$ omph scan
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This generates `INDEX.md` in the root directory:
|
|
137
|
+
|
|
138
|
+
```markdown
|
|
139
|
+
# Codebase Symbol Index
|
|
140
|
+
|
|
141
|
+
## src/omphalos/cache.py
|
|
142
|
+
|
|
143
|
+
- `calculate_sha256()` @15: Calculate the SHA-256 checksum of raw file bytes for change detection. (calls: `hexdigest`, `hashlib.sha256`)
|
|
144
|
+
- `save_cache()` @38: Save symbol cache to .omphcache atomically. (calls: `tempfile.mkstemp`, `os.fdopen`, `json.dump`, `os.replace`, `os.unlink`)
|
|
145
|
+
|
|
146
|
+
## src/omphalos/cli.py
|
|
147
|
+
|
|
148
|
+
- `_version_callback()` @33: Callback for --version flag to print the version and exit immediately. (calls: `typer.echo`, `_package_version`; raises: `typer.Exit`)
|
|
149
|
+
|
|
150
|
+
## tests/fixtures/sample.ts
|
|
151
|
+
|
|
152
|
+
- export interface `UserProfile` @1
|
|
153
|
+
- export type `AuthToken` @6
|
|
154
|
+
- export function `verifyToken()` @8
|
|
155
|
+
- export class `SessionManager` @18
|
|
156
|
+
- `createSession()` @19
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
3. **Instruct AI Agents via `AGENTS.md`**:
|
|
160
|
+
|
|
161
|
+
Create or add to `AGENTS.md` (or `CLAUDE.md` / `.cursorrules`) at the root of your repository so AI agents read `INDEX.md` before exploring code:
|
|
162
|
+
|
|
163
|
+
```markdown
|
|
164
|
+
# Agent Guidelines
|
|
165
|
+
|
|
166
|
+
## Codebase Navigation & Exploration
|
|
167
|
+
|
|
168
|
+
Before searching, running grep, or opening entire source files across the codebase:
|
|
169
|
+
|
|
170
|
+
1. **Always read [`INDEX.md`](INDEX.md) first.** It provides an instant symbol map of the entire repository with exact line numbers.
|
|
171
|
+
2. Use the symbols, line locations (`@<line>`), and internal logic hints (`calls:`, `raises:`) in [`INDEX.md`](INDEX.md) to pinpoint definitions directly.
|
|
172
|
+
3. Inspect or slice-read only the specific line ranges or files needed for the task, rather than loading entire files into context.
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### CLI Reference
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
$ omph scan [DIRECTORY] [OPTIONS]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The binary is installed as `omph`; the full name `omphalos` is also available as an alias.
|
|
182
|
+
|
|
183
|
+
| Option | Flag | Description |
|
|
184
|
+
| ---------------- | ------------------ | ------------------------------------------------------------------------- |
|
|
185
|
+
| `--output`, `-o` | `PATH` | Custom path for index output (default: `INDEX.md`). |
|
|
186
|
+
| `--format` | `markdown \| json` | Output format (default: `markdown`). |
|
|
187
|
+
| `--check` | flag | Verify index is up to date without writing. Exits with code `1` if stale. |
|
|
188
|
+
| `--git-only` | flag | Scan only files tracked by Git (`git ls-files`). |
|
|
189
|
+
| `--no-cache` | flag | Bypass cache and re-parse all files. |
|
|
190
|
+
| `--version` | flag | Show version and exit. |
|
|
191
|
+
| `--help` | flag | Show help message. |
|
|
192
|
+
|
|
193
|
+
### LLM / Agent Integration
|
|
194
|
+
|
|
195
|
+
- **Automatic Agent Discovery via `AGENTS.md`**: Coding agents (Antigravity, Cursor, Claude Code, GitHub Copilot) automatically load `AGENTS.md` at conversation start. By telling the agent to consult `INDEX.md` first, the agent pinpoints definitions immediately without burning tokens on repository-wide grep searches.
|
|
196
|
+
- **Surgical Inspection**: Because each symbol includes `@<line>`, an LLM can request precise lines via tools (e.g. `head -n 50` or view tool slice) rather than ingesting entire files.
|
|
197
|
+
- **Machine-Readable Formats**: Use `--format json` to integrate with custom RAG systems or agent tools:
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
$ omph scan --format json -o index.json
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Development
|
|
204
|
+
|
|
205
|
+
```sh
|
|
206
|
+
# Install development dependencies
|
|
207
|
+
$ uv sync --dev
|
|
208
|
+
|
|
209
|
+
# Run tests
|
|
210
|
+
$ uv run pytest
|
|
211
|
+
|
|
212
|
+
# Run linter and type checker
|
|
213
|
+
$ uv run ruff check .
|
|
214
|
+
$ uv run mypy src
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Related Efforts
|
|
218
|
+
|
|
219
|
+
- [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) - Fast, incremental parsing system used under the hood.
|
|
220
|
+
- [Universal Ctags](https://ctags.io/) - Traditional code indexing system.
|
|
221
|
+
- [Repomix](https://github.com/yamadashy/repomix) - Repository packager for AI context.
|
|
222
|
+
|
|
223
|
+
## Contributing
|
|
224
|
+
|
|
225
|
+
Feel free to dive in! [Open an issue](https://github.com/snui1s/Omphalos/issues/new) or submit a Pull Request.
|
|
226
|
+
|
|
227
|
+
Omphalos follows the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) Code of Conduct.
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
[MIT](LICENSE) © 2026 snui1s
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
omphalos/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
omphalos/cache.py,sha256=iZ9qNQFST5Yl6UDkw94WZiaEX5tqpFZufNfgGqDjkAY,2059
|
|
3
|
+
omphalos/cli.py,sha256=0h0gxowXlBifbWhmBKjbC6DaRaQwdpPna5KAsKvmBcs,5913
|
|
4
|
+
omphalos/ignore.py,sha256=qzxNjoBgbW7huxkEA7AlPHn1jmlAaR14ekWmtoz6_eU,4792
|
|
5
|
+
omphalos/parser.py,sha256=CUjoUBWhNPfi6Z4x1jVZiHig88jedfsrt_Ry9N-yn9c,21295
|
|
6
|
+
omphalos/render.py,sha256=bwRZbpO6DdNbLkDWruL84aOy_R-72DMRaM9iIdp2WgQ,2437
|
|
7
|
+
omphalos-1.1.1.dist-info/WHEEL,sha256=4OL6Foqnnp3xRY5wMkjgc25_i5YJC6dKsC6LPcjqEoU,80
|
|
8
|
+
omphalos-1.1.1.dist-info/entry_points.txt,sha256=i2JZB6H1rM4_ujOCeCyRjfkmkG6nx4PNnQ917-cseJs,71
|
|
9
|
+
omphalos-1.1.1.dist-info/METADATA,sha256=61lrsgjZiHEqyKKrX7DUHECmR7HPvyyXm4sX7aq5Pyc,8998
|
|
10
|
+
omphalos-1.1.1.dist-info/RECORD,,
|