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/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()