witan-code 0.2.0__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.
- witan_code/__init__.py +0 -0
- witan_code/__main__.py +4 -0
- witan_code/_detach.py +27 -0
- witan_code/bridge.py +392 -0
- witan_code/bridge_extractors.py +642 -0
- witan_code/cli.py +722 -0
- witan_code/config.py +65 -0
- witan_code/context.py +98 -0
- witan_code/edges.py +171 -0
- witan_code/elicit.py +92 -0
- witan_code/extensions/pi/codegraph.ts +121 -0
- witan_code/graph.py +271 -0
- witan_code/hooks.py +116 -0
- witan_code/indexer.py +1042 -0
- witan_code/maintenance.py +130 -0
- witan_code/package_map.py +74 -0
- witan_code/queries/bridge.gq +193 -0
- witan_code/queries/code_mutations.gq +81 -0
- witan_code/queries/code_read.gq +154 -0
- witan_code/queries/delete.gq +16 -0
- witan_code/queries_ts/bash.scm +9 -0
- witan_code/queries_ts/hcl.scm +16 -0
- witan_code/queries_ts/python.scm +29 -0
- witan_code/queries_ts/sql.scm +20 -0
- witan_code/queries_ts/typescript.scm +51 -0
- witan_code/queries_ts/yaml.scm +5 -0
- witan_code/repo.py +282 -0
- witan_code/schema/bridge-schema.pg +82 -0
- witan_code/schema/code-schema.pg +51 -0
- witan_code/server.py +859 -0
- witan_code/setup.py +230 -0
- witan_code/skills/witan-code/SKILL.md +97 -0
- witan_code/stitch.py +177 -0
- witan_code/store.py +116 -0
- witan_code/visualize.py +347 -0
- witan_code-0.2.0.dist-info/METADATA +476 -0
- witan_code-0.2.0.dist-info/RECORD +39 -0
- witan_code-0.2.0.dist-info/WHEEL +4 -0
- witan_code-0.2.0.dist-info/entry_points.txt +2 -0
witan_code/indexer.py
ADDED
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
"""Tree-sitter code indexer for the Layer-2 code graph.
|
|
2
|
+
|
|
3
|
+
Walks a repo, extracts symbols (functions, methods, classes, modules) and
|
|
4
|
+
best-effort relationship edges, and writes them to a per-repo Omnigraph store.
|
|
5
|
+
|
|
6
|
+
Call/Reference/Import/Inherits resolution is HEURISTIC: identifiers are matched
|
|
7
|
+
to known Symbol names within the same repo, preferring same-file definitions,
|
|
8
|
+
then imported modules, then any repo-wide match. It is intentionally syntactic
|
|
9
|
+
and will miss dynamic dispatch and produce occasional false links.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import hashlib
|
|
14
|
+
import importlib
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import bridge as bridge_module
|
|
21
|
+
from . import bridge_extractors
|
|
22
|
+
from . import config as cfg_module
|
|
23
|
+
from . import package_map
|
|
24
|
+
from . import repo as repo_module
|
|
25
|
+
from .bridge_extractors import ParsedBinding
|
|
26
|
+
from .graph import OmnigraphClient
|
|
27
|
+
from .store import ensure_store
|
|
28
|
+
|
|
29
|
+
# ── Language support ──────────────────────────────────────────────
|
|
30
|
+
#
|
|
31
|
+
# Adding a language = adding one LanguageSpec: file extensions, the
|
|
32
|
+
# tree-sitter grammar name, the .scm query file, and the capture→kind map.
|
|
33
|
+
|
|
34
|
+
_QUERIES_TS_DIR = Path(__file__).parent / "queries_ts"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class LanguageSpec:
|
|
39
|
+
name: str
|
|
40
|
+
extensions: tuple[str, ...]
|
|
41
|
+
grammar: str
|
|
42
|
+
# None = no hand-written query: bootstrap from the grammar wheel's own
|
|
43
|
+
# bundled `tags.scm` (see _translate_tags_captures) instead of a file in
|
|
44
|
+
# queries_ts/. Only viable for grammars that ship one (most of the
|
|
45
|
+
# tree-sitter org's own; not community/config grammars like sql/hcl/yaml).
|
|
46
|
+
scm: str | None
|
|
47
|
+
# capture prefix "symbol.<kind>" → Symbol kind
|
|
48
|
+
kinds: dict[str, str]
|
|
49
|
+
# Node kinds that scope nesting/qualified-name resolution (_walk_defs).
|
|
50
|
+
# PER-LANGUAGE, not shared: node-type-name strings collide across
|
|
51
|
+
# unrelated grammars (Python's statement suite is also called "block",
|
|
52
|
+
# which is what HCL calls its resource/variable/… blocks) with very
|
|
53
|
+
# different nesting semantics, so a single global set is unsafe.
|
|
54
|
+
def_node_types: frozenset[str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# All JS/TS variants use the `tsx` grammar (a superset of TS, JS, and JSX): the
|
|
58
|
+
# plain `javascript` grammar rejects the TS node types in typescript.scm.
|
|
59
|
+
_TS_KINDS = {
|
|
60
|
+
"function": "function",
|
|
61
|
+
"method": "method",
|
|
62
|
+
"class": "class",
|
|
63
|
+
"interface": "interface",
|
|
64
|
+
"type": "type",
|
|
65
|
+
"enum": "enum",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_TS_DEF_NODE_TYPES = frozenset(
|
|
69
|
+
{
|
|
70
|
+
"function_declaration",
|
|
71
|
+
"generator_function_declaration",
|
|
72
|
+
"class_declaration",
|
|
73
|
+
"method_definition",
|
|
74
|
+
"public_field_definition", # class arrow methods
|
|
75
|
+
"interface_declaration",
|
|
76
|
+
"type_alias_declaration",
|
|
77
|
+
"enum_declaration",
|
|
78
|
+
"variable_declarator",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
_LANGUAGES: tuple[LanguageSpec, ...] = (
|
|
83
|
+
LanguageSpec(
|
|
84
|
+
name="python",
|
|
85
|
+
extensions=(".py", ".pyi"),
|
|
86
|
+
grammar="python",
|
|
87
|
+
scm="python.scm",
|
|
88
|
+
kinds={"function": "function", "class": "class"},
|
|
89
|
+
def_node_types=frozenset({"function_definition", "class_definition"}),
|
|
90
|
+
),
|
|
91
|
+
LanguageSpec(
|
|
92
|
+
name="typescript",
|
|
93
|
+
extensions=(".ts", ".mts", ".cts", ".tsx"),
|
|
94
|
+
grammar="tsx",
|
|
95
|
+
scm="typescript.scm",
|
|
96
|
+
kinds=_TS_KINDS,
|
|
97
|
+
def_node_types=_TS_DEF_NODE_TYPES,
|
|
98
|
+
),
|
|
99
|
+
LanguageSpec(
|
|
100
|
+
name="javascript",
|
|
101
|
+
extensions=(".js", ".jsx", ".mjs", ".cjs"),
|
|
102
|
+
grammar="tsx",
|
|
103
|
+
scm="typescript.scm",
|
|
104
|
+
kinds=_TS_KINDS,
|
|
105
|
+
def_node_types=_TS_DEF_NODE_TYPES,
|
|
106
|
+
),
|
|
107
|
+
LanguageSpec(
|
|
108
|
+
name="bash",
|
|
109
|
+
extensions=(".sh", ".bash", ".zsh"),
|
|
110
|
+
grammar="bash",
|
|
111
|
+
scm="bash.scm",
|
|
112
|
+
kinds={"function": "function"},
|
|
113
|
+
def_node_types=frozenset({"function_definition"}),
|
|
114
|
+
),
|
|
115
|
+
LanguageSpec(
|
|
116
|
+
name="yaml",
|
|
117
|
+
extensions=(".yaml", ".yml"),
|
|
118
|
+
grammar="yaml",
|
|
119
|
+
scm="yaml.scm",
|
|
120
|
+
kinds={"key": "key"},
|
|
121
|
+
def_node_types=frozenset({"block_mapping_pair"}),
|
|
122
|
+
),
|
|
123
|
+
LanguageSpec(
|
|
124
|
+
name="go",
|
|
125
|
+
extensions=(".go",),
|
|
126
|
+
grammar="go",
|
|
127
|
+
scm=None, # bootstrapped from tree_sitter_go's bundled tags.scm
|
|
128
|
+
kinds={"function": "function", "method": "method", "type": "type"},
|
|
129
|
+
def_node_types=frozenset(
|
|
130
|
+
{"function_declaration", "method_declaration", "type_spec"}
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
LanguageSpec(
|
|
134
|
+
name="sql",
|
|
135
|
+
extensions=(".sql",),
|
|
136
|
+
grammar="sql",
|
|
137
|
+
scm="sql.scm",
|
|
138
|
+
kinds={"table": "table", "function": "function", "cte": "cte"},
|
|
139
|
+
def_node_types=frozenset(
|
|
140
|
+
{"create_table", "create_view", "create_function", "cte"}
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
LanguageSpec(
|
|
144
|
+
name="hcl",
|
|
145
|
+
extensions=(".hcl", ".tf"),
|
|
146
|
+
grammar="hcl",
|
|
147
|
+
scm="hcl.scm",
|
|
148
|
+
kinds={"block": "block"},
|
|
149
|
+
def_node_types=frozenset({"block"}),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
_EXT_TO_SPEC: dict[str, LanguageSpec] = {
|
|
154
|
+
ext: spec for spec in _LANGUAGES for ext in spec.extensions
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Standalone tree-sitter grammar wheels (no language-pack): grammar name → the
|
|
158
|
+
# (module, factory) that yields the compiled grammar capsule. Adding a language =
|
|
159
|
+
# add its `tree-sitter-<lang>` wheel to pyproject + an entry here.
|
|
160
|
+
_GRAMMAR_MODULES: dict[str, tuple[str, str]] = {
|
|
161
|
+
"python": ("tree_sitter_python", "language"),
|
|
162
|
+
"tsx": ("tree_sitter_typescript", "language_tsx"),
|
|
163
|
+
"bash": ("tree_sitter_bash", "language"),
|
|
164
|
+
"yaml": ("tree_sitter_yaml", "language"),
|
|
165
|
+
"go": ("tree_sitter_go", "language"),
|
|
166
|
+
"sql": ("tree_sitter_sql", "language"),
|
|
167
|
+
"hcl": ("tree_sitter_hcl", "language"),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@functools.lru_cache(maxsize=None)
|
|
172
|
+
def _ts_language(grammar: str):
|
|
173
|
+
"""Build (and cache) a ``tree_sitter.Language`` from its standalone wheel."""
|
|
174
|
+
from tree_sitter import Language
|
|
175
|
+
|
|
176
|
+
module_name, factory = _GRAMMAR_MODULES[grammar]
|
|
177
|
+
module = importlib.import_module(module_name)
|
|
178
|
+
return Language(getattr(module, factory)())
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_SKIP_DIRS = {
|
|
182
|
+
".git",
|
|
183
|
+
"node_modules",
|
|
184
|
+
".venv",
|
|
185
|
+
"venv",
|
|
186
|
+
"__pycache__",
|
|
187
|
+
"dist",
|
|
188
|
+
"build",
|
|
189
|
+
".mypy_cache",
|
|
190
|
+
".pytest_cache",
|
|
191
|
+
".ruff_cache",
|
|
192
|
+
".tox",
|
|
193
|
+
".next",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── Extracted-symbol records ──────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class ParsedSymbol:
|
|
202
|
+
id: str
|
|
203
|
+
name: str
|
|
204
|
+
qualified_name: str
|
|
205
|
+
kind: str
|
|
206
|
+
start_line: int
|
|
207
|
+
end_line: int
|
|
208
|
+
signature: str | None
|
|
209
|
+
docstring: str | None
|
|
210
|
+
decorators: list[str] | None = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class ParsedFile:
|
|
215
|
+
file_id: str
|
|
216
|
+
path: str
|
|
217
|
+
language: str
|
|
218
|
+
content_hash: str
|
|
219
|
+
symbols: list[ParsedSymbol] = field(default_factory=list)
|
|
220
|
+
# (container_qualified_name | None, child_qualified_name) for Contains
|
|
221
|
+
contains: list[tuple[str | None, str]] = field(default_factory=list)
|
|
222
|
+
# raw call/reference identifier names seen in the file
|
|
223
|
+
call_names: set[str] = field(default_factory=set)
|
|
224
|
+
# (enclosing_qualified_name, call_identifier_name) for precise Calls edges
|
|
225
|
+
calls: list[tuple[str, str]] = field(default_factory=list)
|
|
226
|
+
# base class identifier names per class qualified_name
|
|
227
|
+
inherits: dict[str, list[str]] = field(default_factory=dict)
|
|
228
|
+
# imported identifier names
|
|
229
|
+
imports: set[str] = field(default_factory=set)
|
|
230
|
+
# cross-repo interface bindings (env vars, packages, endpoints) in this file
|
|
231
|
+
bindings: list[ParsedBinding] = field(default_factory=list)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class IndexStats:
|
|
236
|
+
scanned: int = 0
|
|
237
|
+
indexed: int = 0
|
|
238
|
+
skipped: int = 0
|
|
239
|
+
symbols: int = 0
|
|
240
|
+
edges: int = 0
|
|
241
|
+
bindings: int = 0
|
|
242
|
+
errors: int = 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ── Public entry points ───────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def index_path(
|
|
249
|
+
target: Path,
|
|
250
|
+
*,
|
|
251
|
+
force: bool = False,
|
|
252
|
+
repo_override: str | None = None,
|
|
253
|
+
config: cfg_module.Config | None = None,
|
|
254
|
+
) -> IndexStats:
|
|
255
|
+
"""Index ``target`` (a file or directory) into the repo's code store.
|
|
256
|
+
|
|
257
|
+
Incremental by default: unchanged files (matching content_hash) are skipped.
|
|
258
|
+
``force`` re-indexes regardless of hash.
|
|
259
|
+
"""
|
|
260
|
+
cfg = config or cfg_module.load()
|
|
261
|
+
target = target.resolve()
|
|
262
|
+
|
|
263
|
+
repo_root = repo_module.root(target if target.is_dir() else target.parent)
|
|
264
|
+
slug = repo_module.detect(override=repo_override, start=repo_root or target)
|
|
265
|
+
if slug is None:
|
|
266
|
+
# No git context: use the directory name of the target.
|
|
267
|
+
slug = (target if target.is_dir() else target.parent).name
|
|
268
|
+
base = repo_root or (target if target.is_dir() else target.parent)
|
|
269
|
+
|
|
270
|
+
# Non-default git branches index onto the same-named omnigraph branch,
|
|
271
|
+
# forked from main on first write (docs/BRANCH_INDEXING.md), so in-flight
|
|
272
|
+
# work never overwrites the shared main view and stays visible per-branch.
|
|
273
|
+
branch = repo_module.store_branch(base) if repo_root else None
|
|
274
|
+
|
|
275
|
+
store = ensure_store(slug, cfg)
|
|
276
|
+
client = OmnigraphClient(str(store), cfg.queries_dir, branch=branch)
|
|
277
|
+
# The hash read below never forks; create the branch before reading.
|
|
278
|
+
client.ensure_branch()
|
|
279
|
+
|
|
280
|
+
# One query for all existing file hashes → the incremental skip check is
|
|
281
|
+
# in-memory, not a query per file.
|
|
282
|
+
existing: dict[str, str] = {}
|
|
283
|
+
if not force:
|
|
284
|
+
for row in client.read("code_read.gq", "all_file_hashes", {}):
|
|
285
|
+
existing[row["slug"]] = row.get("content_hash")
|
|
286
|
+
|
|
287
|
+
stats = IndexStats()
|
|
288
|
+
records: list[dict] = []
|
|
289
|
+
reindexed_file_ids: list[str] = []
|
|
290
|
+
bindings: list[ParsedBinding] = []
|
|
291
|
+
touched_files: list[str] = []
|
|
292
|
+
|
|
293
|
+
for path in _collect_files(target):
|
|
294
|
+
stats.scanned += 1
|
|
295
|
+
try:
|
|
296
|
+
result = _parse_for_index(path, base, slug, existing, force=force)
|
|
297
|
+
except Exception as exc: # noqa: BLE001 — one bad file must not abort
|
|
298
|
+
stats.errors += 1
|
|
299
|
+
print(f"codegraph: failed to index {path}: {exc}", file=sys.stderr)
|
|
300
|
+
continue
|
|
301
|
+
if result is None:
|
|
302
|
+
stats.skipped += 1
|
|
303
|
+
continue
|
|
304
|
+
parsed, was_existing = result
|
|
305
|
+
if was_existing:
|
|
306
|
+
reindexed_file_ids.append(parsed.file_id)
|
|
307
|
+
records.extend(_file_records(parsed, slug, stats))
|
|
308
|
+
bindings.extend(parsed.bindings)
|
|
309
|
+
touched_files.append(parsed.path)
|
|
310
|
+
stats.indexed += 1
|
|
311
|
+
|
|
312
|
+
# Drop stale data for changed files (new files have nothing to delete), then
|
|
313
|
+
# bulk-load every node and edge in a single omnigraph call.
|
|
314
|
+
for file_id in reindexed_file_ids:
|
|
315
|
+
_delete_file_data(file_id, client)
|
|
316
|
+
client.load(_dedupe(records), mode="merge")
|
|
317
|
+
|
|
318
|
+
# Cross-repo bridge — a SEPARATE phase after the per-repo store write, so the
|
|
319
|
+
# two stores' write locks never nest. A full-repo index (target is the repo
|
|
320
|
+
# root) also runs the repo-level provider extractors and clears bindings for
|
|
321
|
+
# files deleted from disk; all purging is per-file so unchanged (skipped)
|
|
322
|
+
# files keep their bindings.
|
|
323
|
+
#
|
|
324
|
+
# A non-default branch targets its repo-qualified bridge branch overlay
|
|
325
|
+
# (docs/BRANCH_INDEXING.md § Bridge store) rather than skipping the bridge
|
|
326
|
+
# entirely: the shared main view still never sees in-flight bindings, but
|
|
327
|
+
# they're no longer dropped on the floor either.
|
|
328
|
+
full_repo = target.is_dir() and target.resolve() == base.resolve()
|
|
329
|
+
if full_repo:
|
|
330
|
+
bindings.extend(bridge_extractors.extract_repo_bindings(base, slug))
|
|
331
|
+
try:
|
|
332
|
+
stats.bindings = bridge_module.write_bindings(
|
|
333
|
+
bindings,
|
|
334
|
+
slug,
|
|
335
|
+
cfg,
|
|
336
|
+
full_repo=full_repo,
|
|
337
|
+
touched_files=tuple(touched_files),
|
|
338
|
+
identity=package_map.load(base, slug),
|
|
339
|
+
base=base,
|
|
340
|
+
branch=branch,
|
|
341
|
+
)
|
|
342
|
+
except Exception as exc: # noqa: BLE001 — bridge is best-effort, never fatal
|
|
343
|
+
print(f"codegraph: bridge update failed: {exc}", file=sys.stderr)
|
|
344
|
+
|
|
345
|
+
return stats
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _dedupe(records: list[dict]) -> list[dict]:
|
|
349
|
+
"""Drop duplicate node slugs / edges so one collision can't fail the load.
|
|
350
|
+
|
|
351
|
+
Real code yields occasional duplicate qualified names (overloads, a def named
|
|
352
|
+
after its file). Omnigraph's load rejects the whole batch on a single
|
|
353
|
+
``@unique`` violation, so keep the first occurrence of each node slug and edge.
|
|
354
|
+
"""
|
|
355
|
+
seen_nodes: set[str] = set()
|
|
356
|
+
seen_edges: set[tuple[str, str, str]] = set()
|
|
357
|
+
out: list[dict] = []
|
|
358
|
+
for record in records:
|
|
359
|
+
if "type" in record:
|
|
360
|
+
slug = record["data"]["slug"]
|
|
361
|
+
if slug in seen_nodes:
|
|
362
|
+
continue
|
|
363
|
+
seen_nodes.add(slug)
|
|
364
|
+
else:
|
|
365
|
+
key = (record["edge"], record["from"], record["to"])
|
|
366
|
+
if key in seen_edges:
|
|
367
|
+
continue
|
|
368
|
+
seen_edges.add(key)
|
|
369
|
+
out.append(record)
|
|
370
|
+
return out
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _collect_files(target: Path) -> list[Path]:
|
|
374
|
+
if target.is_file():
|
|
375
|
+
return [target] if target.suffix in _EXT_TO_SPEC else []
|
|
376
|
+
|
|
377
|
+
out: list[Path] = []
|
|
378
|
+
for path in target.rglob("*"):
|
|
379
|
+
if path.is_dir():
|
|
380
|
+
continue
|
|
381
|
+
if any(part in _SKIP_DIRS for part in path.parts):
|
|
382
|
+
continue
|
|
383
|
+
if path.suffix in _EXT_TO_SPEC:
|
|
384
|
+
out.append(path)
|
|
385
|
+
return out
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ── Per-file indexing ─────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _parse_for_index(
|
|
392
|
+
path: Path,
|
|
393
|
+
base: Path,
|
|
394
|
+
slug: str,
|
|
395
|
+
existing: dict[str, str],
|
|
396
|
+
*,
|
|
397
|
+
force: bool,
|
|
398
|
+
) -> tuple[ParsedFile, bool] | None:
|
|
399
|
+
"""Parse ``path`` unless unchanged. Returns (parsed, file_already_indexed)."""
|
|
400
|
+
spec = _EXT_TO_SPEC[path.suffix]
|
|
401
|
+
raw = path.read_bytes()
|
|
402
|
+
content_hash = hashlib.sha256(raw).hexdigest()
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
rel = path.resolve().relative_to(base.resolve()).as_posix()
|
|
406
|
+
except ValueError:
|
|
407
|
+
rel = path.name
|
|
408
|
+
file_id = f"{slug}#{rel}"
|
|
409
|
+
|
|
410
|
+
if not force and existing.get(file_id) == content_hash:
|
|
411
|
+
return None # unchanged
|
|
412
|
+
|
|
413
|
+
parsed = _parse_file(raw, path, spec, slug, file_id, rel, content_hash)
|
|
414
|
+
if parsed is None:
|
|
415
|
+
return None
|
|
416
|
+
return parsed, (file_id in existing)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _delete_file_data(file_id: str, client: OmnigraphClient) -> None:
|
|
420
|
+
# Deletes must not be mixed with inserts; run as standalone change() calls.
|
|
421
|
+
client.change("delete.gq", "delete_symbols_in_file", {"file_id": file_id})
|
|
422
|
+
client.change("delete.gq", "delete_file", {"id": file_id})
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _edge(edge_type: str, from_id: str, to_id: str) -> dict:
|
|
426
|
+
return {"edge": edge_type, "from": from_id, "to": to_id}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _file_records(parsed: ParsedFile, slug: str, stats: IndexStats) -> list[dict]:
|
|
430
|
+
"""Build the load() records (node + edge JSONL dicts) for one parsed file."""
|
|
431
|
+
now = _now_iso()
|
|
432
|
+
records: list[dict] = [
|
|
433
|
+
{
|
|
434
|
+
"type": "CodeFile",
|
|
435
|
+
"data": {
|
|
436
|
+
"slug": parsed.file_id,
|
|
437
|
+
"repo": slug,
|
|
438
|
+
"path": parsed.path,
|
|
439
|
+
"language": parsed.language,
|
|
440
|
+
"content_hash": parsed.content_hash,
|
|
441
|
+
"indexed_at": now,
|
|
442
|
+
},
|
|
443
|
+
}
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
by_qualified: dict[str, ParsedSymbol] = {}
|
|
447
|
+
by_name: dict[str, list[ParsedSymbol]] = {}
|
|
448
|
+
for sym in parsed.symbols:
|
|
449
|
+
records.append(
|
|
450
|
+
{
|
|
451
|
+
"type": "Symbol",
|
|
452
|
+
"data": {
|
|
453
|
+
"slug": sym.id,
|
|
454
|
+
"repo": slug,
|
|
455
|
+
"file_id": parsed.file_id,
|
|
456
|
+
"name": sym.name,
|
|
457
|
+
"qualified_name": sym.qualified_name,
|
|
458
|
+
"kind": sym.kind,
|
|
459
|
+
"start_line": sym.start_line,
|
|
460
|
+
"end_line": sym.end_line,
|
|
461
|
+
"signature": sym.signature,
|
|
462
|
+
"docstring": sym.docstring,
|
|
463
|
+
"decorators": sym.decorators,
|
|
464
|
+
"indexed_at": now,
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
)
|
|
468
|
+
stats.symbols += 1
|
|
469
|
+
records.append(_edge("Defines", parsed.file_id, sym.id))
|
|
470
|
+
stats.edges += 1
|
|
471
|
+
by_qualified[sym.qualified_name] = sym
|
|
472
|
+
by_name.setdefault(sym.name, []).append(sym)
|
|
473
|
+
|
|
474
|
+
# Contains: lexical nesting within this file.
|
|
475
|
+
for container_qn, child_qn in parsed.contains:
|
|
476
|
+
if container_qn and container_qn in by_qualified and child_qn in by_qualified:
|
|
477
|
+
records.append(
|
|
478
|
+
_edge(
|
|
479
|
+
"Contains", by_qualified[container_qn].id, by_qualified[child_qn].id
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
stats.edges += 1
|
|
483
|
+
|
|
484
|
+
# Heuristic Calls/References: each call identifier is attributed to the
|
|
485
|
+
# qualified name of its nearest enclosing definition (computed at parse
|
|
486
|
+
# time) and resolved to a same-file symbol by name. Falls back to a stable
|
|
487
|
+
# file-level origin when the enclosing def isn't itself a known symbol.
|
|
488
|
+
fallback = _reference_origin(parsed)
|
|
489
|
+
seen_calls: set[tuple[str, str]] = set()
|
|
490
|
+
for origin_qn, cname in parsed.calls:
|
|
491
|
+
origin = by_qualified.get(origin_qn) or fallback
|
|
492
|
+
if origin is None:
|
|
493
|
+
continue
|
|
494
|
+
target = _resolve_local(cname, by_name)
|
|
495
|
+
if target is None or target.id == origin.id:
|
|
496
|
+
continue
|
|
497
|
+
if (origin.id, target.id) in seen_calls:
|
|
498
|
+
continue
|
|
499
|
+
seen_calls.add((origin.id, target.id))
|
|
500
|
+
records.append(_edge("Calls", origin.id, target.id))
|
|
501
|
+
records.append(_edge("References", origin.id, target.id))
|
|
502
|
+
stats.edges += 2
|
|
503
|
+
|
|
504
|
+
# Heuristic Inherits: resolve base class names to same-file class symbols.
|
|
505
|
+
for class_qn, bases in parsed.inherits.items():
|
|
506
|
+
child = by_qualified.get(class_qn)
|
|
507
|
+
if child is None:
|
|
508
|
+
continue
|
|
509
|
+
for base_name in bases:
|
|
510
|
+
target = _resolve_local(base_name, by_name)
|
|
511
|
+
if target is not None and target.id != child.id:
|
|
512
|
+
records.append(_edge("Inherits", child.id, target.id))
|
|
513
|
+
stats.edges += 1
|
|
514
|
+
|
|
515
|
+
# Heuristic Imports: resolve imported names to same-file symbols (best-effort;
|
|
516
|
+
# cross-file resolution is left to query-time lookups by name).
|
|
517
|
+
for iname in parsed.imports:
|
|
518
|
+
target = _resolve_local(iname, by_name)
|
|
519
|
+
if target is not None:
|
|
520
|
+
records.append(_edge("Imports", parsed.file_id, target.id))
|
|
521
|
+
stats.edges += 1
|
|
522
|
+
|
|
523
|
+
return records
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _reference_origin(parsed: ParsedFile) -> ParsedSymbol | None:
|
|
527
|
+
"""Pick a stable symbol to attribute file-level references to.
|
|
528
|
+
|
|
529
|
+
Prefers the first top-level (non-nested) symbol; falls back to the first
|
|
530
|
+
symbol overall. Returns None for empty files.
|
|
531
|
+
"""
|
|
532
|
+
nested = {child for _, child in parsed.contains}
|
|
533
|
+
for sym in parsed.symbols:
|
|
534
|
+
if sym.qualified_name not in nested:
|
|
535
|
+
return sym
|
|
536
|
+
return parsed.symbols[0] if parsed.symbols else None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _resolve_local(
|
|
540
|
+
name: str, by_name: dict[str, list[ParsedSymbol]]
|
|
541
|
+
) -> ParsedSymbol | None:
|
|
542
|
+
matches = by_name.get(name)
|
|
543
|
+
return matches[0] if matches else None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ── Parsing ───────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _parse_file(
|
|
550
|
+
raw: bytes,
|
|
551
|
+
path: Path,
|
|
552
|
+
spec: LanguageSpec,
|
|
553
|
+
slug: str,
|
|
554
|
+
file_id: str,
|
|
555
|
+
rel: str,
|
|
556
|
+
content_hash: str,
|
|
557
|
+
) -> ParsedFile | None:
|
|
558
|
+
from tree_sitter import Parser
|
|
559
|
+
|
|
560
|
+
language = _ts_language(spec.grammar)
|
|
561
|
+
# The Parser/Query/QueryCursor all come from the standalone `tree_sitter`
|
|
562
|
+
# package bound to this Language. parse() wants bytes; _node_text slices into
|
|
563
|
+
# the same bytes.
|
|
564
|
+
parser = Parser(language)
|
|
565
|
+
tree = parser.parse(raw)
|
|
566
|
+
root = _root(tree)
|
|
567
|
+
|
|
568
|
+
parsed = ParsedFile(
|
|
569
|
+
file_id=file_id,
|
|
570
|
+
path=rel,
|
|
571
|
+
language=spec.name,
|
|
572
|
+
content_hash=content_hash,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# Module-level symbol (the file itself as a module). Its qualified_name uses
|
|
576
|
+
# a sentinel so a top-level def named after the file (e.g. `def foo` in
|
|
577
|
+
# foo.py) doesn't collide with the module on `slug`.
|
|
578
|
+
module_name = Path(rel).stem
|
|
579
|
+
module = ParsedSymbol(
|
|
580
|
+
id=f"{file_id}::<module>",
|
|
581
|
+
name=module_name,
|
|
582
|
+
qualified_name="<module>",
|
|
583
|
+
kind="module",
|
|
584
|
+
start_line=1,
|
|
585
|
+
end_line=_end_line(root),
|
|
586
|
+
signature=None,
|
|
587
|
+
docstring=None,
|
|
588
|
+
)
|
|
589
|
+
parsed.symbols.append(module)
|
|
590
|
+
|
|
591
|
+
# Definition nodes (functions/classes/methods/…): walk the tree so we can
|
|
592
|
+
# compute lexical qualified names and Contains nesting.
|
|
593
|
+
def_capture_nodes = _query_captures(language, spec, root)
|
|
594
|
+
|
|
595
|
+
_walk_defs(root, raw, spec, file_id, module, parsed, def_capture_nodes)
|
|
596
|
+
|
|
597
|
+
# Imports gathered flat. Calls are attributed to their enclosing def inside
|
|
598
|
+
# _walk_defs (which has the qualified-name machinery); inherit.base likewise.
|
|
599
|
+
for cap_name, node in def_capture_nodes:
|
|
600
|
+
if cap_name.startswith("import."):
|
|
601
|
+
parsed.imports.add(_node_text(node, raw))
|
|
602
|
+
|
|
603
|
+
# Cross-repo interface bindings (env vars, packages, endpoint consumers).
|
|
604
|
+
# Attribute each to its enclosing symbol by line containment.
|
|
605
|
+
parsed.bindings = bridge_extractors.extract_file_bindings(
|
|
606
|
+
raw.decode("utf-8", "replace"), spec.name, rel
|
|
607
|
+
)
|
|
608
|
+
for binding in parsed.bindings:
|
|
609
|
+
binding.symbol_id = _symbol_at_line(parsed, binding.line)
|
|
610
|
+
|
|
611
|
+
return parsed
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _symbol_at_line(parsed: ParsedFile, line: int | None) -> str | None:
|
|
615
|
+
"""The smallest non-module symbol whose range contains ``line``.
|
|
616
|
+
|
|
617
|
+
Falls back to the module symbol so every binding has a stable owner.
|
|
618
|
+
"""
|
|
619
|
+
if line is None:
|
|
620
|
+
return parsed.symbols[0].id if parsed.symbols else None
|
|
621
|
+
best: ParsedSymbol | None = None
|
|
622
|
+
for sym in parsed.symbols:
|
|
623
|
+
if sym.qualified_name == "<module>":
|
|
624
|
+
continue
|
|
625
|
+
if sym.start_line <= line <= sym.end_line:
|
|
626
|
+
if best is None or (sym.end_line - sym.start_line) < (
|
|
627
|
+
best.end_line - best.start_line
|
|
628
|
+
):
|
|
629
|
+
best = sym
|
|
630
|
+
if best is not None:
|
|
631
|
+
return best.id
|
|
632
|
+
return parsed.symbols[0].id if parsed.symbols else None
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _walk_defs(
|
|
636
|
+
root,
|
|
637
|
+
raw: bytes,
|
|
638
|
+
spec: LanguageSpec,
|
|
639
|
+
file_id: str,
|
|
640
|
+
module: ParsedSymbol,
|
|
641
|
+
parsed: ParsedFile,
|
|
642
|
+
captures: list[tuple[str, object]],
|
|
643
|
+
) -> None:
|
|
644
|
+
# Map each captured definition-name node key → (kind, name_text). HCL's
|
|
645
|
+
# captured name is the block's `string_lit` label node (quotes included,
|
|
646
|
+
# since the label isn't a direct child otherwise reachable — see
|
|
647
|
+
# queries_ts/hcl.scm); strip the quotes there only. No other language
|
|
648
|
+
# captures a string_lit as its name, so this can't affect them.
|
|
649
|
+
name_nodes: dict[tuple, tuple[str, str]] = {}
|
|
650
|
+
for cap_name, node in captures:
|
|
651
|
+
if cap_name.startswith("symbol."):
|
|
652
|
+
kind = spec.kinds.get(cap_name.split(".", 1)[1])
|
|
653
|
+
if kind:
|
|
654
|
+
text = _node_text(node, raw)
|
|
655
|
+
if spec.name == "hcl" and _kind(node) == "string_lit":
|
|
656
|
+
text = text.strip("\"'")
|
|
657
|
+
name_nodes[_node_key(node)] = (kind, text)
|
|
658
|
+
|
|
659
|
+
def enclosing_def(node):
|
|
660
|
+
cur = _parent(node)
|
|
661
|
+
while cur is not None:
|
|
662
|
+
if _kind(cur) in spec.def_node_types:
|
|
663
|
+
return cur
|
|
664
|
+
cur = _parent(cur)
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
def def_name_node(def_node):
|
|
668
|
+
for child in _children(def_node):
|
|
669
|
+
if _node_key(child) in name_nodes:
|
|
670
|
+
return child
|
|
671
|
+
# variable_declarator: name field
|
|
672
|
+
nf = _child_by_field_name(def_node, "name")
|
|
673
|
+
if nf is not None and _node_key(nf) in name_nodes:
|
|
674
|
+
return nf
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
# Build qualified names by ascending the def hierarchy.
|
|
678
|
+
def qualified(def_node) -> tuple[str, str, str] | None:
|
|
679
|
+
nn = def_name_node(def_node)
|
|
680
|
+
if nn is None:
|
|
681
|
+
return None
|
|
682
|
+
kind, name = name_nodes[_node_key(nn)]
|
|
683
|
+
parts = [name]
|
|
684
|
+
parent_def = enclosing_def(def_node)
|
|
685
|
+
while parent_def is not None:
|
|
686
|
+
pnn = def_name_node(parent_def)
|
|
687
|
+
if pnn is not None:
|
|
688
|
+
parts.append(name_nodes[_node_key(pnn)][1])
|
|
689
|
+
parent_def = enclosing_def(parent_def)
|
|
690
|
+
parts.reverse()
|
|
691
|
+
return kind, name, ".".join(parts)
|
|
692
|
+
|
|
693
|
+
seen: set[str] = set()
|
|
694
|
+
for cap_name, node in captures:
|
|
695
|
+
if not cap_name.startswith("symbol."):
|
|
696
|
+
continue
|
|
697
|
+
def_node = _parent(node)
|
|
698
|
+
while def_node is not None and _kind(def_node) not in spec.def_node_types:
|
|
699
|
+
def_node = _parent(def_node)
|
|
700
|
+
if def_node is None:
|
|
701
|
+
continue
|
|
702
|
+
q = qualified(def_node)
|
|
703
|
+
if q is None:
|
|
704
|
+
continue
|
|
705
|
+
kind, name, qn = q
|
|
706
|
+
if qn in seen:
|
|
707
|
+
continue
|
|
708
|
+
seen.add(qn)
|
|
709
|
+
|
|
710
|
+
sym = ParsedSymbol(
|
|
711
|
+
id=f"{file_id}::{qn}",
|
|
712
|
+
name=name,
|
|
713
|
+
qualified_name=qn,
|
|
714
|
+
kind=kind,
|
|
715
|
+
start_line=_start_line(def_node),
|
|
716
|
+
end_line=_end_line(def_node),
|
|
717
|
+
signature=_signature(def_node, raw),
|
|
718
|
+
docstring=_docstring(def_node, raw, spec),
|
|
719
|
+
decorators=_decorators(def_node, raw, spec),
|
|
720
|
+
)
|
|
721
|
+
parsed.symbols.append(sym)
|
|
722
|
+
|
|
723
|
+
parent_def = enclosing_def(def_node)
|
|
724
|
+
parent_q = qualified(parent_def) if parent_def is not None else None
|
|
725
|
+
container_qn = parent_q[2] if parent_q else module.qualified_name
|
|
726
|
+
parsed.contains.append((container_qn, qn))
|
|
727
|
+
|
|
728
|
+
# Inherits: base identifiers within this class def.
|
|
729
|
+
if kind == "class":
|
|
730
|
+
bases = _class_bases(def_node, raw, captures)
|
|
731
|
+
if bases:
|
|
732
|
+
parsed.inherits[qn] = bases
|
|
733
|
+
|
|
734
|
+
# Attribute each call to the qualified name of its nearest enclosing def
|
|
735
|
+
# (falls back to the module symbol for top-level calls).
|
|
736
|
+
for cap_name, node in captures:
|
|
737
|
+
if not cap_name.startswith("call."):
|
|
738
|
+
continue
|
|
739
|
+
cname = _node_text(node, raw)
|
|
740
|
+
parsed.call_names.add(cname)
|
|
741
|
+
def_node = enclosing_def(node)
|
|
742
|
+
origin_qn = module.qualified_name
|
|
743
|
+
if def_node is not None:
|
|
744
|
+
q = qualified(def_node)
|
|
745
|
+
if q is not None:
|
|
746
|
+
origin_qn = q[2]
|
|
747
|
+
parsed.calls.append((origin_qn, cname))
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _class_bases(def_node, raw: bytes, captures) -> list[str]:
|
|
751
|
+
bases: list[str] = []
|
|
752
|
+
for cap_name, node in captures:
|
|
753
|
+
if cap_name != "inherit.base":
|
|
754
|
+
continue
|
|
755
|
+
target_key = _node_key(def_node)
|
|
756
|
+
cur = _parent(node)
|
|
757
|
+
while cur is not None:
|
|
758
|
+
if _node_key(cur) == target_key:
|
|
759
|
+
bases.append(_node_text(node, raw))
|
|
760
|
+
break
|
|
761
|
+
cur = _parent(cur)
|
|
762
|
+
return bases
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ── tree-sitter helpers ───────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _query_captures(language, spec: LanguageSpec, root) -> list[tuple[str, object]]:
|
|
769
|
+
from tree_sitter import Query
|
|
770
|
+
|
|
771
|
+
if spec.scm is not None:
|
|
772
|
+
scm = (_QUERIES_TS_DIR / spec.scm).read_text()
|
|
773
|
+
bootstrapped = False
|
|
774
|
+
else:
|
|
775
|
+
scm = _tags_query_text(spec)
|
|
776
|
+
bootstrapped = True
|
|
777
|
+
|
|
778
|
+
try:
|
|
779
|
+
query = Query(language, scm)
|
|
780
|
+
except Exception: # noqa: BLE001 — fall back to Language.query
|
|
781
|
+
query = language.query(scm)
|
|
782
|
+
|
|
783
|
+
# The capture API moved across py-tree-sitter versions: 0.23+ exposes
|
|
784
|
+
# QueryCursor whose captures() returns {name: [nodes]}; older versions had
|
|
785
|
+
# Query.captures() returning [(node, name)] tuples. Support both.
|
|
786
|
+
try:
|
|
787
|
+
from tree_sitter import QueryCursor
|
|
788
|
+
|
|
789
|
+
raw = QueryCursor(query).captures(root)
|
|
790
|
+
except ImportError:
|
|
791
|
+
raw = query.captures(root)
|
|
792
|
+
|
|
793
|
+
out: list[tuple[str, object]] = []
|
|
794
|
+
if isinstance(raw, dict):
|
|
795
|
+
for cap_name, nodes in raw.items():
|
|
796
|
+
out.extend((cap_name, node) for node in nodes)
|
|
797
|
+
else:
|
|
798
|
+
for node, cap_name in raw:
|
|
799
|
+
out.append((cap_name, node))
|
|
800
|
+
return _translate_tags_captures(out) if bootstrapped else out
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _tags_query_text(spec: LanguageSpec) -> str:
|
|
804
|
+
"""Query text for a language with no hand-written queries_ts/*.scm.
|
|
805
|
+
|
|
806
|
+
Bootstrapped from the grammar wheel's own bundled ``tags.scm`` — the
|
|
807
|
+
standard tree-sitter "code navigation" query convention (captures
|
|
808
|
+
``@definition.<kind>`` / ``@name`` / ``@reference.call``), shipped as
|
|
809
|
+
package data and exposed as ``TAGS_QUERY`` by most tree-sitter-org
|
|
810
|
+
grammars (see docs/LANGUAGE_SUPPORT.md). Not every grammar ships one —
|
|
811
|
+
notably config/declarative grammars (sql, hcl, yaml, dockerfile) don't,
|
|
812
|
+
so those still need a hand-written queries_ts/*.scm like python.scm.
|
|
813
|
+
"""
|
|
814
|
+
module_name, _ = _GRAMMAR_MODULES[spec.grammar]
|
|
815
|
+
module = importlib.import_module(module_name)
|
|
816
|
+
tags_query = getattr(module, "TAGS_QUERY", None)
|
|
817
|
+
if tags_query is None:
|
|
818
|
+
raise ValueError(
|
|
819
|
+
f"{spec.name}: no queries_ts/{spec.name}.scm and grammar module "
|
|
820
|
+
f"{module_name!r} bundles no TAGS_QUERY — write a hand-written "
|
|
821
|
+
f"query file instead of leaving LanguageSpec.scm=None"
|
|
822
|
+
)
|
|
823
|
+
return tags_query
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _translate_tags_captures(
|
|
827
|
+
captures: list[tuple[str, object]],
|
|
828
|
+
) -> list[tuple[str, object]]:
|
|
829
|
+
"""Adapt the standard tags.scm convention onto witan's own captures.
|
|
830
|
+
|
|
831
|
+
tags.scm wraps a name: ``(function_definition name: (identifier) @name)
|
|
832
|
+
@definition.function`` — the kind lives on the outer definition/reference
|
|
833
|
+
node, while ``@name`` sits on the identifier itself. witan's own
|
|
834
|
+
hand-written queries instead put the kind directly on the identifier
|
|
835
|
+
(``@symbol.function`` / ``@call.name``), which is what ``_walk_defs``
|
|
836
|
+
expects. Reattach each ``@name`` node to its smallest enclosing
|
|
837
|
+
``@definition.*``/``@reference.call`` wrapper to bridge the two.
|
|
838
|
+
|
|
839
|
+
A ``@name`` node with no enclosing wrapper (e.g. tags.scm's bare
|
|
840
|
+
top-level `@name` on imports/package clauses/var decls) is dropped —
|
|
841
|
+
those aren't modeled as witan Symbols today.
|
|
842
|
+
"""
|
|
843
|
+
name_nodes = [node for cap, node in captures if cap == "name"]
|
|
844
|
+
wrappers: list[tuple[str, object]] = []
|
|
845
|
+
for cap, node in captures:
|
|
846
|
+
if cap.startswith("definition."):
|
|
847
|
+
wrappers.append((f"symbol.{cap.split('.', 1)[1]}", node))
|
|
848
|
+
elif cap == "reference.call":
|
|
849
|
+
wrappers.append(("call.name", node))
|
|
850
|
+
|
|
851
|
+
out: list[tuple[str, object]] = []
|
|
852
|
+
for name_node in name_nodes:
|
|
853
|
+
best: tuple[str, int] | None = None
|
|
854
|
+
for cap_name, wrapper in wrappers:
|
|
855
|
+
if not _contains(wrapper, name_node):
|
|
856
|
+
continue
|
|
857
|
+
size = _end_byte(wrapper) - _start_byte(wrapper)
|
|
858
|
+
if best is None or size < best[1]:
|
|
859
|
+
best = (cap_name, size)
|
|
860
|
+
if best is not None:
|
|
861
|
+
out.append((best[0], name_node))
|
|
862
|
+
return out
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _contains(container, node) -> bool:
|
|
866
|
+
return _start_byte(container) <= _start_byte(node) and _end_byte(node) <= _end_byte(
|
|
867
|
+
container
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _a(obj, name, *args):
|
|
872
|
+
"""Resolve attribute-or-zero/one-arg-method, version-robustly.
|
|
873
|
+
|
|
874
|
+
In tree-sitter 0.25 (Rust/pyo3) Node members (`kind`, `byte_range`,
|
|
875
|
+
`start_byte`, `child`, `parent`, …) are zero/one-arg methods; in the
|
|
876
|
+
classic C binding they were plain attributes. Call when callable.
|
|
877
|
+
"""
|
|
878
|
+
val = getattr(obj, name)
|
|
879
|
+
return val(*args) if callable(val) else val
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _root(tree):
|
|
883
|
+
return _a(tree, "root_node")
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _kind(node) -> str:
|
|
887
|
+
# tree_sitter 0.25 Node exposes `.type`; the pack binding exposes `.kind`.
|
|
888
|
+
# Both attrs may exist but one returns None — prefer whichever is set.
|
|
889
|
+
return _a(node, "type") or _a(node, "kind")
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _parent(node):
|
|
893
|
+
return _a(node, "parent")
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _prev_sibling(node):
|
|
897
|
+
return _a(node, "prev_sibling")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _start_byte(node) -> int:
|
|
901
|
+
return _a(node, "start_byte")
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _end_byte(node) -> int:
|
|
905
|
+
return _a(node, "end_byte")
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _child_by_field_name(node, field: str):
|
|
909
|
+
return _a(node, "child_by_field_name", field)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _children(node) -> list:
|
|
913
|
+
children = getattr(node, "children", None)
|
|
914
|
+
if children is not None and not callable(children):
|
|
915
|
+
return list(children)
|
|
916
|
+
count = _a(node, "child_count")
|
|
917
|
+
return [_a(node, "child", i) for i in range(count)]
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _node_key(node):
|
|
921
|
+
"""Hashable identity for a node (no `.id` in 0.25): use byte range."""
|
|
922
|
+
return (_start_byte(node), _end_byte(node))
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _point(node, which: str):
|
|
926
|
+
# tree_sitter 0.25 Node: `.start_point`/`.end_point` (Point attrs).
|
|
927
|
+
# pack binding: `.start_position`/`.end_position` (callable).
|
|
928
|
+
p = getattr(node, f"{which}_point", None)
|
|
929
|
+
if p is None:
|
|
930
|
+
p = _a(node, f"{which}_position")
|
|
931
|
+
return p
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _start_line(node) -> int:
|
|
935
|
+
return _point(node, "start").row + 1
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _end_line(node) -> int:
|
|
939
|
+
return _point(node, "end").row + 1
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _node_text(node, raw: bytes) -> str:
|
|
943
|
+
return raw[_start_byte(node) : _end_byte(node)].decode("utf-8", "replace")
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _signature(def_node, raw: bytes) -> str | None:
|
|
947
|
+
"""The definition header — name + full (multi-line) params + return type.
|
|
948
|
+
|
|
949
|
+
Everything from the def start up to its body, whitespace-collapsed, with the
|
|
950
|
+
trailing block opener (``:`` / ``{``) dropped. Falls back to the first line
|
|
951
|
+
when there's no body field (e.g. arrow consts, yaml keys).
|
|
952
|
+
"""
|
|
953
|
+
body = _child_by_field_name(def_node, "body")
|
|
954
|
+
if body is not None:
|
|
955
|
+
header = raw[_start_byte(def_node) : _start_byte(body)].decode(
|
|
956
|
+
"utf-8", "replace"
|
|
957
|
+
)
|
|
958
|
+
else:
|
|
959
|
+
lines = _node_text(def_node, raw).splitlines()
|
|
960
|
+
header = lines[0] if lines else ""
|
|
961
|
+
sig = " ".join(header.split()).rstrip()
|
|
962
|
+
if sig.endswith(("{", ":")):
|
|
963
|
+
sig = sig[:-1].rstrip()
|
|
964
|
+
return sig[:300] or None
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _docstring(def_node, raw: bytes, spec: LanguageSpec) -> str | None:
|
|
968
|
+
if spec.name == "python":
|
|
969
|
+
body = _child_by_field_name(def_node, "body")
|
|
970
|
+
if body is None:
|
|
971
|
+
return None
|
|
972
|
+
for child in _children(body):
|
|
973
|
+
if _kind(child) == "expression_statement":
|
|
974
|
+
grandchildren = _children(child)
|
|
975
|
+
inner = grandchildren[0] if grandchildren else None
|
|
976
|
+
if inner is not None and _kind(inner) == "string":
|
|
977
|
+
doc = _node_text(inner, raw).strip().strip("'\"")
|
|
978
|
+
return doc[:500] or None
|
|
979
|
+
break
|
|
980
|
+
return None
|
|
981
|
+
if spec.name in ("typescript", "javascript"):
|
|
982
|
+
return _jsdoc(def_node, raw)
|
|
983
|
+
return None
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _jsdoc(def_node, raw: bytes) -> str | None:
|
|
987
|
+
"""The ``/** … */`` block immediately preceding a TS/JS def.
|
|
988
|
+
|
|
989
|
+
Walks preceding siblings (skipping decorators) of the def and, when the def
|
|
990
|
+
is wrapped (e.g. ``export_statement``), of its parent.
|
|
991
|
+
"""
|
|
992
|
+
candidates = [def_node]
|
|
993
|
+
parent = _parent(def_node)
|
|
994
|
+
if parent is not None and _kind(parent) in ("export_statement",):
|
|
995
|
+
candidates.append(parent)
|
|
996
|
+
for node in candidates:
|
|
997
|
+
prev = _prev_sibling(node)
|
|
998
|
+
while prev is not None and _kind(prev) == "decorator":
|
|
999
|
+
prev = _prev_sibling(prev)
|
|
1000
|
+
if prev is not None and _kind(prev) == "comment":
|
|
1001
|
+
text = _node_text(prev, raw).strip()
|
|
1002
|
+
if text.startswith("/**"):
|
|
1003
|
+
inner = text.removeprefix("/**").removesuffix("*/")
|
|
1004
|
+
lines = [ln.strip().lstrip("*").strip() for ln in inner.splitlines()]
|
|
1005
|
+
cleaned = " ".join(ln for ln in lines if ln)
|
|
1006
|
+
return cleaned[:500] or None
|
|
1007
|
+
return None
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _decorators(def_node, raw: bytes, spec: LanguageSpec) -> list[str] | None:
|
|
1011
|
+
"""Decorator strings on a def (``@app.route(...)``, ``@Input()``, …)."""
|
|
1012
|
+
out: list[str] = []
|
|
1013
|
+
if spec.name == "python":
|
|
1014
|
+
parent = _parent(def_node)
|
|
1015
|
+
if parent is not None and _kind(parent) == "decorated_definition":
|
|
1016
|
+
out = [
|
|
1017
|
+
_node_text(c, raw).strip()
|
|
1018
|
+
for c in _children(parent)
|
|
1019
|
+
if _kind(c) == "decorator"
|
|
1020
|
+
]
|
|
1021
|
+
elif spec.name in ("typescript", "javascript"):
|
|
1022
|
+
# class decorators are own children; method decorators are prev siblings
|
|
1023
|
+
own = [
|
|
1024
|
+
_node_text(c, raw).strip()
|
|
1025
|
+
for c in _children(def_node)
|
|
1026
|
+
if _kind(c) == "decorator"
|
|
1027
|
+
]
|
|
1028
|
+
preceding: list[str] = []
|
|
1029
|
+
prev = _prev_sibling(def_node)
|
|
1030
|
+
while prev is not None and _kind(prev) == "decorator":
|
|
1031
|
+
preceding.append(_node_text(prev, raw).strip())
|
|
1032
|
+
prev = _prev_sibling(prev)
|
|
1033
|
+
out = list(reversed(preceding)) + own
|
|
1034
|
+
out = [d[:200] for d in out if d]
|
|
1035
|
+
return out or None
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
# ── Misc ──────────────────────────────────────────────────────────
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _now_iso() -> str:
|
|
1042
|
+
return datetime.now(timezone.utc).isoformat()
|