nooa-cli 0.0.7__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.
@@ -0,0 +1,902 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """RepoTools — code navigation that returns ShellTools Match anchors.
4
+
5
+ Use ``symbols()`` to find definitions and ``refs()`` to find usages. Both return
6
+ ``RepoResult``: printable lines plus editable ``Match`` objects for
7
+ ``self.shell.replace(result[i], new_text)``.
8
+ """
9
+
10
+ import logging
11
+ import re
12
+ import shlex
13
+ from collections.abc import Iterator
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Annotated
17
+
18
+ from nooa.agentdoc import hidden, spec
19
+ from nooa.skill import Skill
20
+ from nooa.tools._bash_session import BashSession
21
+ from nooa.tools.shell_tools import Match
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Result types
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ @dataclass
31
+ class FileMapResult:
32
+ path: Annotated[str, spec(description="File path")]
33
+ language: Annotated[str, spec(description="Detected language")]
34
+ symbols: Annotated[
35
+ list[str], spec(description="Formatted symbol lines (name, kind, line number)")
36
+ ]
37
+ truncated: Annotated[bool, spec(description="True if symbols were capped")] = False
38
+ anchors: Annotated[
39
+ list[Match],
40
+ spec(description="ShellTools-compatible anchors for each symbol line"),
41
+ ] = field(default_factory=list)
42
+
43
+ @property
44
+ def text(self) -> str:
45
+ if not self.symbols:
46
+ return f"[{self.path}] ({self.language}) — no symbols found"
47
+ header = f"[{self.path}] ({self.language}, {len(self.symbols)} symbols)"
48
+ return f"{header}\n" + "\n".join(self.symbols)
49
+
50
+ def __str__(self) -> str:
51
+ return self.text
52
+
53
+
54
+ @dataclass
55
+ class RepoMapResult:
56
+ root: Annotated[str, spec(description="Repository root path")]
57
+ summary: Annotated[str, spec(description="Formatted repo map with key files and their exports")]
58
+ num_files: Annotated[int, spec(description="Total files analyzed")]
59
+ truncated: Annotated[bool, spec(description="True if output was capped")] = False
60
+ anchors: Annotated[
61
+ list[Match],
62
+ spec(description="ShellTools-compatible anchors for symbol lines in the map"),
63
+ ] = field(default_factory=list)
64
+
65
+ @property
66
+ def text(self) -> str:
67
+ return self.summary
68
+
69
+ def __str__(self) -> str:
70
+ return self.text
71
+
72
+
73
+ @dataclass
74
+ class SymbolSearchResult:
75
+ query: Annotated[str, spec(description="Search query")]
76
+ matches: Annotated[list[str], spec(description="Matching symbol lines (file:line: kind name)")]
77
+ total_matches: Annotated[int, spec(description="Total matches found")]
78
+ truncated: Annotated[bool, spec(description="True if results were capped")] = False
79
+ anchors: Annotated[
80
+ list[Match],
81
+ spec(description="ShellTools-compatible anchors for each returned match"),
82
+ ] = field(default_factory=list)
83
+
84
+ @property
85
+ def text(self) -> str:
86
+ if not self.matches:
87
+ return f'No symbols matching "{self.query}" found.'
88
+ parts = list(self.matches)
89
+ if self.truncated:
90
+ parts.append(f"\n... ({self.total_matches} total, showing first {len(self.matches)})")
91
+ else:
92
+ parts.append(f"\n({self.total_matches} matches)")
93
+ return "\n".join(parts)
94
+
95
+ def __str__(self) -> str:
96
+ return self.text
97
+
98
+
99
+ @dataclass
100
+ class ReferenceSearchResult:
101
+ query: Annotated[str, spec(description="Symbol name searched")]
102
+ matches: Annotated[list[str], spec(description="Reference lines (file:line: context)")]
103
+ total_matches: Annotated[int, spec(description="Total references found")]
104
+ truncated: Annotated[bool, spec(description="True if results were capped")] = False
105
+ anchors: Annotated[
106
+ list[Match],
107
+ spec(description="ShellTools-compatible anchors for each returned reference"),
108
+ ] = field(default_factory=list)
109
+
110
+ @property
111
+ def text(self) -> str:
112
+ if not self.matches:
113
+ return f'No references to "{self.query}" found.'
114
+ parts = list(self.matches)
115
+ if self.truncated:
116
+ parts.append(f"\n... ({self.total_matches} total, showing first {len(self.matches)})")
117
+ else:
118
+ parts.append(f"\n({self.total_matches} references)")
119
+ return "\n".join(parts)
120
+
121
+ def __str__(self) -> str:
122
+ return self.text
123
+
124
+
125
+ @dataclass
126
+ class RepoResult:
127
+ query: Annotated[str, spec(description="Search query or symbol name")]
128
+ lines: Annotated[list[str], spec(description="Display lines: file:line: context")]
129
+ matches: Annotated[
130
+ list[Match],
131
+ spec(description="ShellTools-compatible anchors; pass an item to self.shell.replace()"),
132
+ ] = field(default_factory=list)
133
+ total_matches: Annotated[int, spec(description="Total matches found")] = 0
134
+ truncated: Annotated[bool, spec(description="True if results were capped")] = False
135
+
136
+ @property
137
+ def text(self) -> str:
138
+ if not self.lines:
139
+ return f'No matches for "{self.query}" found.'
140
+ parts = list(self.lines)
141
+ if self.truncated:
142
+ parts.append(f"\n... ({self.total_matches} total, showing first {len(self.lines)})")
143
+ else:
144
+ noun = "match" if self.total_matches == 1 else "matches"
145
+ parts.append(f"\n({self.total_matches} {noun})")
146
+ return "\n".join(parts)
147
+
148
+ def __str__(self) -> str:
149
+ return self.text
150
+
151
+ def __len__(self) -> int:
152
+ return len(self.matches)
153
+
154
+ def __bool__(self) -> bool:
155
+ return len(self.matches) > 0
156
+
157
+ def __iter__(self) -> Iterator[Match]:
158
+ return iter(self.matches)
159
+
160
+ def __getitem__(self, index: int) -> Match:
161
+ return self.matches[index]
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Language detection
166
+ # ---------------------------------------------------------------------------
167
+ _LANG_MAP = {
168
+ ".py": "python",
169
+ ".js": "javascript",
170
+ ".ts": "typescript",
171
+ ".tsx": "tsx",
172
+ ".jsx": "javascript",
173
+ ".rb": "ruby",
174
+ ".go": "go",
175
+ ".rs": "rust",
176
+ ".java": "java",
177
+ ".c": "c",
178
+ ".cpp": "cpp",
179
+ ".h": "c",
180
+ ".hpp": "cpp",
181
+ ".cs": "csharp",
182
+ ".php": "php",
183
+ ".swift": "swift",
184
+ ".kt": "kotlin",
185
+ ".scala": "scala",
186
+ ".sh": "bash",
187
+ ".lua": "lua",
188
+ ".r": "r",
189
+ }
190
+
191
+ # Regex patterns for extracting symbols per language
192
+ _SYMBOL_PATTERNS: dict[str, list[tuple[str, str]]] = {
193
+ "python": [
194
+ (r"^(\s*)def\s+(\w+)\s*\(", "function"),
195
+ (r"^(\s*)async\s+def\s+(\w+)\s*\(", "async function"),
196
+ (r"^(\s*)class\s+(\w+)", "class"),
197
+ ],
198
+ "javascript": [
199
+ (r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)", "function"),
200
+ (r"^\s*(?:export\s+)?class\s+(\w+)", "class"),
201
+ (r"^\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(", "function"),
202
+ ],
203
+ "typescript": [
204
+ (r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)", "function"),
205
+ (r"^\s*(?:export\s+)?class\s+(\w+)", "class"),
206
+ (r"^\s*(?:export\s+)?(?:interface|type)\s+(\w+)", "type"),
207
+ (r"^\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(", "function"),
208
+ ],
209
+ "go": [
210
+ (r"^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*\(", "function"),
211
+ (r"^type\s+(\w+)\s+struct", "struct"),
212
+ (r"^type\s+(\w+)\s+interface", "interface"),
213
+ ],
214
+ "rust": [
215
+ (r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)", "function"),
216
+ (r"^\s*(?:pub\s+)?struct\s+(\w+)", "struct"),
217
+ (r"^\s*(?:pub\s+)?trait\s+(\w+)", "trait"),
218
+ (r"^\s*(?:pub\s+)?enum\s+(\w+)", "enum"),
219
+ (r"^\s*impl(?:<[^>]*>)?\s+(\w+)", "impl"),
220
+ ],
221
+ "java": [
222
+ (
223
+ r"^\s*(?:public|private|protected)?\s*(?:static\s+)?(?:abstract\s+)?class\s+(\w+)",
224
+ "class",
225
+ ),
226
+ (
227
+ r"^\s*(?:public|private|protected)?\s*(?:static\s+)?(?:abstract\s+)?interface\s+(\w+)",
228
+ "interface",
229
+ ),
230
+ (r"^\s*(?:public|private|protected)?\s*(?:static\s+)?[\w<>\[\]]+\s+(\w+)\s*\(", "method"),
231
+ ],
232
+ "ruby": [
233
+ (r"^\s*def\s+(\w+)", "method"),
234
+ (r"^\s*class\s+(\w+)", "class"),
235
+ (r"^\s*module\s+(\w+)", "module"),
236
+ ],
237
+ }
238
+
239
+ # Add common aliases
240
+ _SYMBOL_PATTERNS["tsx"] = _SYMBOL_PATTERNS["typescript"]
241
+ _SYMBOL_PATTERNS["jsx"] = _SYMBOL_PATTERNS["javascript"]
242
+ _SYMBOL_PATTERNS["cpp"] = [
243
+ (r"^\s*(?:virtual\s+)?[\w:]+\s+(\w+)\s*\(", "function"),
244
+ (r"^\s*class\s+(\w+)", "class"),
245
+ (r"^\s*struct\s+(\w+)", "struct"),
246
+ (r"^\s*namespace\s+(\w+)", "namespace"),
247
+ ]
248
+ _SYMBOL_PATTERNS["c"] = _SYMBOL_PATTERNS["cpp"]
249
+
250
+
251
+ def _detect_lang(path: Path) -> str:
252
+ return _LANG_MAP.get(path.suffix.lower(), "unknown")
253
+
254
+
255
+ def _tree_sitter_available() -> bool:
256
+ try:
257
+ from nooa_cli.tools._tree_sitter_backend import TREE_SITTER_AVAILABLE
258
+ except ImportError:
259
+ return False
260
+ return TREE_SITTER_AVAILABLE
261
+
262
+
263
+ def _line_match(path: Path, line_no: int) -> Match | None:
264
+ try:
265
+ lines = path.read_text(errors="replace").splitlines(keepends=True)
266
+ except OSError:
267
+ return None
268
+ if not (1 <= line_no <= len(lines)):
269
+ return None
270
+ return Match(str(path), line_no, line_no, lines[line_no - 1])
271
+
272
+
273
+ def _symbol_anchor_pairs(path: Path, symbols: list[str]) -> list[tuple[str, Match]]:
274
+ try:
275
+ lines = path.read_text(errors="replace").splitlines(keepends=True)
276
+ except OSError:
277
+ return []
278
+ pairs: list[tuple[str, Match]] = []
279
+ for symbol in symbols:
280
+ line_text = symbol.strip()
281
+ if not line_text:
282
+ continue
283
+ line_no_text = line_text.split(maxsplit=1)[0]
284
+ try:
285
+ line_no = int(line_no_text)
286
+ except ValueError:
287
+ continue
288
+ if 1 <= line_no <= len(lines):
289
+ pairs.append((symbol, Match(str(path), line_no, line_no, lines[line_no - 1])))
290
+ return pairs
291
+
292
+
293
+ def _symbol_anchors(path: Path, symbols: list[str]) -> list[Match]:
294
+ return [anchor for _, anchor in _symbol_anchor_pairs(path, symbols)]
295
+
296
+
297
+ def _anchor_from_match_line(line: str, root: Path) -> Match | None:
298
+ path_text, sep, rest = line.partition(":")
299
+ if not sep:
300
+ return None
301
+ m = re.match(r"\s*(\d+)\b", rest)
302
+ if not m:
303
+ return None
304
+ fpath = Path(path_text)
305
+ if not fpath.is_absolute():
306
+ fpath = root / fpath
307
+ return _line_match(fpath, int(m.group(1)))
308
+
309
+
310
+ def _format_match(anchor: Match, root: Path) -> str:
311
+ path = Path(anchor.path)
312
+ try:
313
+ display_path = str(path.relative_to(root)) if path.is_absolute() else anchor.path
314
+ except ValueError:
315
+ display_path = anchor.path
316
+ return f"{display_path}:{anchor.start}: {anchor.text.strip()}"
317
+
318
+
319
+ def _is_definition_line(content: str, name: str = "") -> bool:
320
+ if any(
321
+ re.match(pattern, content)
322
+ for pattern in (
323
+ r"^\s*(?:export\s+)?(?:async\s+)?function\s+",
324
+ r"^\s*(?:export\s+)?(?:abstract\s+)?class\s+",
325
+ r"^\s*(?:export\s+)?(?:interface|type)\s+",
326
+ r"^\s*(?:async\s+)?def\s+",
327
+ r"^\s*(?:func|fn|type|struct|trait|impl|enum|module)\s+",
328
+ )
329
+ ):
330
+ return True
331
+ return bool(
332
+ name
333
+ and re.match(
334
+ rf"^\s*(?:export\s+)?(?:const|let|var)\s+{re.escape(name)}\b\s*=",
335
+ content,
336
+ )
337
+ )
338
+
339
+
340
+ def _extract_symbols(path: Path, lang: str, max_symbols: int = 200) -> list[str]:
341
+ """Extract symbol definitions from a file using tree-sitter AST (with regex fallback)."""
342
+ # Try tree-sitter first (AST-aware, more accurate)
343
+ try:
344
+ from nooa_cli.tools._tree_sitter_backend import (
345
+ TREE_SITTER_AVAILABLE,
346
+ ts_extract_symbols,
347
+ )
348
+
349
+ if TREE_SITTER_AVAILABLE:
350
+ ts_result = ts_extract_symbols(path, lang, max_symbols)
351
+ if ts_result is not None:
352
+ return ts_result
353
+ except ImportError:
354
+ pass
355
+
356
+ # Fallback: regex-based extraction
357
+ patterns = _SYMBOL_PATTERNS.get(lang, [])
358
+ if not patterns:
359
+ return []
360
+
361
+ try:
362
+ text = path.read_text(errors="replace")
363
+ except (OSError, PermissionError):
364
+ return []
365
+
366
+ symbols: list[str] = []
367
+ for i, line in enumerate(text.splitlines(), 1):
368
+ if len(symbols) >= max_symbols:
369
+ break
370
+ for pattern, kind in patterns:
371
+ m = re.match(pattern, line)
372
+ if m:
373
+ # Get the name from the last capturing group
374
+ groups = [g for g in m.groups() if g is not None]
375
+ name = groups[-1] if groups else "?"
376
+ indent = len(line) - len(line.lstrip())
377
+ prefix = " " * (indent // 4) if indent > 0 else ""
378
+ symbols.append(f" {i:4d} {prefix}{kind} {name}")
379
+ break
380
+ return symbols
381
+
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # RepoTools Skill
385
+ # ---------------------------------------------------------------------------
386
+ class RepoTools(Skill):
387
+ """Find code locations as ShellTools ``Match`` anchors.
388
+
389
+ Two methods:
390
+ symbols(path=".", query="") — definitions / file or repo overview
391
+ refs(name, path=".") — references/usages, excluding definitions
392
+
393
+ Results print like search output and index/iterate as editable matches::
394
+ r = await self.repo.symbols("src/", query="Handler")
395
+ await self.shell.replace(r[0], new_code)
396
+ """
397
+
398
+ __nosnapshot__ = True
399
+
400
+ def __init__(
401
+ self,
402
+ root: str | Path = ".",
403
+ session: BashSession | None = None,
404
+ require_tree_sitter: bool = False,
405
+ ) -> None:
406
+ self._root = Path(root).resolve()
407
+ self._session = session # shared session with ShellTools (optional)
408
+ self._has_rg: bool | None = None # lazy-checked
409
+ if not _tree_sitter_available():
410
+ message = "tree-sitter is not available; RepoTools will use regex/rg fallbacks"
411
+ if require_tree_sitter:
412
+ raise RuntimeError(message)
413
+ logger.warning(message)
414
+
415
+ def __repr__(self) -> str:
416
+ session = "shared" if self._session is not None else "none"
417
+ return f"RepoTools(root={str(self._root)!r}, session={session}, has_rg={self._has_rg!r})"
418
+
419
+ @property
420
+ @hidden
421
+ def root(self) -> Path:
422
+ """Repository root this tool is scoped to."""
423
+ return self._root
424
+
425
+ @property
426
+ @hidden
427
+ def session(self) -> BashSession | None:
428
+ """The shared bash session, if one was wired in (else None)."""
429
+ return self._session
430
+
431
+ async def symbols(
432
+ self,
433
+ path: Annotated[str, spec(description="File or directory to inspect")] = ".",
434
+ query: Annotated[str, spec(description="Optional symbol-name substring filter")] = "",
435
+ max_results: Annotated[int, spec(description="Maximum result lines")] = 50,
436
+ ) -> RepoResult:
437
+ """Find definitions under a file or directory.
438
+
439
+ Returns printable lines plus editable ``Match`` anchors; use
440
+ ``await self.shell.replace(result[0], new_text)`` to edit a hit.
441
+ """
442
+ resolved = self._resolve(path)
443
+ query_lower = query.lower()
444
+
445
+ if resolved.is_file():
446
+ file_result = await self._filemap(path, max_symbols=max_results if not query else 500)
447
+ pairs = [
448
+ (symbol, anchor)
449
+ for symbol, anchor in zip(file_result.symbols, file_result.anchors, strict=True)
450
+ if not query_lower or query_lower in symbol.lower()
451
+ ]
452
+ anchors = [anchor for _, anchor in pairs[:max_results]]
453
+ return RepoResult(
454
+ query=query or path,
455
+ lines=[_format_match(anchor, self._root) for anchor in anchors],
456
+ matches=anchors,
457
+ total_matches=len(pairs),
458
+ truncated=len(pairs) > max_results,
459
+ )
460
+
461
+ if query:
462
+ symbol_result = await self._search_symbol(query, path=path, max_results=max_results)
463
+ return RepoResult(
464
+ query=query,
465
+ lines=symbol_result.matches,
466
+ matches=symbol_result.anchors,
467
+ total_matches=symbol_result.total_matches,
468
+ truncated=symbol_result.truncated,
469
+ )
470
+
471
+ map_result = await self._repo_map(paths=[path], max_files=max_results)
472
+ anchors = map_result.anchors[:max_results]
473
+ return RepoResult(
474
+ query=path,
475
+ lines=[_format_match(anchor, self._root) for anchor in anchors],
476
+ matches=anchors,
477
+ total_matches=len(map_result.anchors),
478
+ truncated=map_result.truncated or len(map_result.anchors) > max_results,
479
+ )
480
+
481
+ async def refs(
482
+ self,
483
+ name: Annotated[str, spec(description="Symbol or qualified name to find references for")],
484
+ path: Annotated[str, spec(description="Directory to search")] = ".",
485
+ max_results: Annotated[int, spec(description="Maximum result lines")] = 50,
486
+ ) -> RepoResult:
487
+ """Find references/usages of a symbol, excluding definitions.
488
+
489
+ Returns printable lines plus editable ``Match`` anchors; use
490
+ ``await self.shell.replace(result[0], new_text)`` to edit a hit.
491
+ """
492
+ result = await self._search_references(name, path=path, max_results=max_results)
493
+ return RepoResult(
494
+ query=name,
495
+ lines=result.matches,
496
+ matches=result.anchors,
497
+ total_matches=result.total_matches,
498
+ truncated=result.truncated,
499
+ )
500
+
501
+ async def _check_rg(self) -> bool:
502
+ """Check if rg (ripgrep) is available, caching the result."""
503
+ if self._has_rg is None:
504
+ if self._session:
505
+ _, _, code = await self._session.run("command -v rg", timeout=5)
506
+ self._has_rg = code == 0
507
+ else:
508
+ import shutil
509
+
510
+ self._has_rg = shutil.which("rg") is not None
511
+ return self._has_rg
512
+
513
+ # ------------------------------------------------------------------
514
+ # filemap — show symbols in a single file
515
+ # ------------------------------------------------------------------
516
+ async def _filemap(self, path: str, max_symbols: int = 200) -> FileMapResult:
517
+ """Show the structure of a file: function and class definitions with line numbers.
518
+
519
+ Useful for getting an overview of a file without reading every line.
520
+ Shows definitions but not their bodies.
521
+
522
+ Args:
523
+ path: File path (relative to repo root).
524
+ max_symbols: Maximum symbols to show (default: 200).
525
+
526
+ Returns:
527
+ FileMapResult with symbol listing.
528
+
529
+ Examples:
530
+ r = await self.repo._filemap("src/main.py")
531
+ r = await self.repo._filemap("internal/llm/tools/edit.go")
532
+ """
533
+ resolved = self._resolve(path)
534
+ if not resolved.is_file():
535
+ from nooa.runtime.harness_metrics import get_harness_metrics
536
+
537
+ get_harness_metrics().repo_failure(
538
+ "filemap:file_not_found", f"File not found: {path}", path
539
+ )
540
+ return FileMapResult(
541
+ path=path, language="unknown", symbols=[f"Error: {path} not found"]
542
+ )
543
+
544
+ lang = _detect_lang(resolved)
545
+ symbols = _extract_symbols(resolved, lang, max_symbols=max_symbols)
546
+ truncated = len(symbols) >= max_symbols
547
+
548
+ symbol_anchor_pairs = _symbol_anchor_pairs(resolved, symbols)
549
+ return FileMapResult(
550
+ path=path,
551
+ language=lang,
552
+ symbols=[symbol for symbol, _ in symbol_anchor_pairs],
553
+ truncated=truncated,
554
+ anchors=[anchor for _, anchor in symbol_anchor_pairs],
555
+ )
556
+
557
+ # ------------------------------------------------------------------
558
+ # repo_map — overview of repository structure and key symbols
559
+ # ------------------------------------------------------------------
560
+ async def _repo_map(
561
+ self,
562
+ paths: list[str] | None = None,
563
+ depth: int = 3,
564
+ max_files: int = 50,
565
+ max_symbols_per_file: int = 20,
566
+ ) -> RepoMapResult:
567
+ """Generate a repository map showing key files and their top-level symbols.
568
+
569
+ Scans the repository (or specified paths) and produces a concise
570
+ overview of the most important files and their exports/definitions.
571
+ Files are sorted by relevance (recently modified first).
572
+
573
+ Args:
574
+ paths: Specific directories to map (default: repo root).
575
+ depth: Directory depth to scan (default: 3).
576
+ max_files: Maximum files to include (default: 50).
577
+ max_symbols_per_file: Max symbols per file in the map (default: 20).
578
+
579
+ Returns:
580
+ RepoMapResult with formatted repository overview.
581
+
582
+ Examples:
583
+ r = await self.repo._repo_map()
584
+ r = await self.repo._repo_map(paths=["src/", "lib/"])
585
+ r = await self.repo._repo_map(max_files=100, depth=4)
586
+ """
587
+ # Find source files, sorted by modification time (newest first)
588
+ search_paths = paths or ["."]
589
+ all_files: list[Path] = []
590
+
591
+ for sp in search_paths:
592
+ resolved = self._resolve(sp)
593
+ if not resolved.is_dir():
594
+ continue
595
+ # Use rg to find files respecting gitignore, or fall back to walking
596
+ if self._session and await self._check_rg():
597
+ stdout, _, _ = await self._session.run(
598
+ f"rg --files {shlex.quote(str(resolved))} 2>/dev/null | head -{max_files * 3}",
599
+ timeout=15,
600
+ )
601
+ for line in stdout.splitlines():
602
+ p = Path(line.strip())
603
+ if p.is_file() and _detect_lang(p) != "unknown":
604
+ all_files.append(p)
605
+ # Sort by mtime (newest first) — rg --sort requires v14+
606
+ all_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
607
+ else:
608
+ # Fallback: walk directory (rg not available or no session)
609
+ for ext in _LANG_MAP:
610
+ for p in sorted(resolved.rglob(f"*{ext}"))[:max_files]:
611
+ all_files.append(p)
612
+
613
+ # Deduplicate and limit
614
+ seen: set[str] = set()
615
+ unique_files: list[Path] = []
616
+ for f in all_files:
617
+ key = str(f.resolve())
618
+ if key not in seen:
619
+ seen.add(key)
620
+ unique_files.append(f)
621
+ unique_files = unique_files[:max_files]
622
+
623
+ # Build the map
624
+ sections: list[str] = []
625
+ anchors: list[Match] = []
626
+ for fpath in unique_files:
627
+ try:
628
+ rel = fpath.relative_to(self._root)
629
+ except ValueError:
630
+ rel = fpath
631
+ lang = _detect_lang(fpath)
632
+ symbols = _extract_symbols(fpath, lang, max_symbols=max_symbols_per_file)
633
+ symbol_anchor_pairs = _symbol_anchor_pairs(fpath, symbols)
634
+ if symbol_anchor_pairs:
635
+ sections.append(f"\n{rel}:")
636
+ sections.extend(symbol for symbol, _ in symbol_anchor_pairs)
637
+ anchors.extend(anchor for _, anchor in symbol_anchor_pairs)
638
+ else:
639
+ sections.append(f"\n{rel}: ({lang})")
640
+
641
+ summary = "\n".join(sections).strip()
642
+ if not summary:
643
+ from nooa.runtime.harness_metrics import get_harness_metrics
644
+
645
+ get_harness_metrics().repo_failure(
646
+ "repo_map:no_files", "No source files found", str(search_paths)[:200]
647
+ )
648
+ summary = "(no source files found)"
649
+
650
+ return RepoMapResult(
651
+ root=str(self._root),
652
+ summary=summary,
653
+ num_files=len(unique_files),
654
+ truncated=len(all_files) > max_files,
655
+ anchors=anchors,
656
+ )
657
+
658
+ # ------------------------------------------------------------------
659
+ # search_symbol — find definitions across the codebase
660
+ # ------------------------------------------------------------------
661
+ async def _search_symbol(
662
+ self,
663
+ name: str,
664
+ path: str = ".",
665
+ max_results: int = 50,
666
+ ) -> SymbolSearchResult:
667
+ """Search for function, class, or type definitions by name.
668
+
669
+ Searches across all source files for symbol definitions matching
670
+ the query (case-insensitive substring match).
671
+
672
+ Args:
673
+ name: Symbol name or partial name to search for.
674
+ path: Directory to search (default: repo root).
675
+ max_results: Maximum results (default: 50).
676
+
677
+ Returns:
678
+ SymbolSearchResult with matching definitions.
679
+
680
+ Examples:
681
+ r = await self.repo._search_symbol("calculate_score")
682
+ r = await self.repo._search_symbol("Handler", path="src/")
683
+ """
684
+ resolved = self._resolve(path)
685
+ matches: list[str] = []
686
+
687
+ # Use grep to find definition patterns matching the name
688
+ if self._session and await self._check_rg():
689
+ # Build a regex that matches common definition patterns
690
+ pattern = f"(def|class|function|func|struct|trait|interface|type|impl|module|const)\\s+\\w*{re.escape(name)}\\w*"
691
+ stdout, _, _ = await self._session.run(
692
+ f"rg -n -i --color=never {shlex.quote(pattern)} {shlex.quote(str(resolved))} 2>/dev/null | head -{max_results * 2}",
693
+ timeout=30,
694
+ )
695
+ for line in stdout.splitlines():
696
+ if line.strip():
697
+ matches.append(line.strip())
698
+ else:
699
+ # Fallback: walk files and check symbols (rg not available or no session)
700
+ name_lower = name.lower()
701
+ for ext in _LANG_MAP:
702
+ for fpath in resolved.rglob(f"*{ext}"):
703
+ lang = _detect_lang(fpath)
704
+ symbols = _extract_symbols(fpath, lang, max_symbols=200)
705
+ for sym in symbols:
706
+ if name_lower in sym.lower():
707
+ try:
708
+ rel = fpath.relative_to(self._root)
709
+ except ValueError:
710
+ rel = fpath
711
+ matches.append(f"{rel}:{sym.strip()}")
712
+ if len(matches) >= max_results:
713
+ break
714
+ if len(matches) >= max_results:
715
+ break
716
+ if len(matches) >= max_results:
717
+ break
718
+
719
+ total = len(matches)
720
+ paired = [
721
+ (match, anchor)
722
+ for match, anchor in (
723
+ (m, _anchor_from_match_line(m, self._root)) for m in matches[:max_results]
724
+ )
725
+ if anchor is not None
726
+ ]
727
+ if total == 0:
728
+ from nooa.runtime.harness_metrics import get_harness_metrics
729
+
730
+ get_harness_metrics().repo_failure(
731
+ "search_symbol:no_results", f"No matches for '{name}'", f"path={path}"
732
+ )
733
+ truncated = total >= max_results
734
+ return SymbolSearchResult(
735
+ query=name,
736
+ matches=[m for m, _ in paired],
737
+ total_matches=total,
738
+ truncated=truncated,
739
+ anchors=[a for _, a in paired],
740
+ )
741
+
742
+ # ------------------------------------------------------------------
743
+ # search_references — find call sites / usages
744
+ # ------------------------------------------------------------------
745
+ async def _search_references(
746
+ self,
747
+ name: Annotated[
748
+ str,
749
+ spec(
750
+ description="Symbol name to find references for (e.g. 'from_file' or 'TraceExplorer.from_file')"
751
+ ),
752
+ ],
753
+ path: Annotated[str, spec(description="Directory to search")] = ".",
754
+ max_results: Annotated[int, spec(description="Maximum results")] = 50,
755
+ ) -> ReferenceSearchResult:
756
+ """Find all references (call sites, usages) of a symbol — excludes definitions.
757
+
758
+ Uses tree-sitter AST analysis when available for accurate results,
759
+ falling back to ripgrep with heuristic filtering.
760
+
761
+ Args:
762
+ name: Symbol name or qualified name (e.g. 'TraceExplorer.from_file').
763
+ path: Directory to search (default: repo root).
764
+ max_results: Maximum results (default: 50).
765
+
766
+ Returns:
767
+ ReferenceSearchResult with matching reference lines.
768
+
769
+ Examples:
770
+ r = await self.repo._search_references("from_file")
771
+ r = await self.repo._search_references("TraceExplorer.from_file")
772
+ """
773
+ resolved = self._resolve(path)
774
+
775
+ # Try tree-sitter first for accurate AST-aware reference finding
776
+ try:
777
+ from nooa_cli.tools._tree_sitter_backend import (
778
+ TREE_SITTER_AVAILABLE,
779
+ ts_find_references,
780
+ )
781
+
782
+ if TREE_SITTER_AVAILABLE:
783
+ all_matches: list[str] = []
784
+ for fpath in self._iter_source_files(resolved, max_files=500):
785
+ lang = _detect_lang(fpath)
786
+ if lang == "unknown":
787
+ continue
788
+ refs = ts_find_references(
789
+ fpath, lang, name, max_results=max_results - len(all_matches)
790
+ )
791
+ if refs:
792
+ try:
793
+ rel = fpath.relative_to(self._root)
794
+ except ValueError:
795
+ rel = fpath
796
+ for line_no, context in refs:
797
+ all_matches.append(f"{rel}:{line_no}: {context}")
798
+ if len(all_matches) >= max_results:
799
+ break
800
+ if all_matches:
801
+ truncated = len(all_matches) >= max_results
802
+ paired = [
803
+ (match, anchor)
804
+ for match, anchor in (
805
+ (m, _anchor_from_match_line(m, self._root))
806
+ for m in all_matches[:max_results]
807
+ )
808
+ if anchor is not None
809
+ ]
810
+ return ReferenceSearchResult(
811
+ query=name,
812
+ matches=[m for m, _ in paired],
813
+ total_matches=len(all_matches),
814
+ truncated=truncated,
815
+ anchors=[a for _, a in paired],
816
+ )
817
+ except ImportError:
818
+ pass
819
+
820
+ # Fallback: ripgrep-based reference search with heuristic filtering
821
+ search_name = re.escape(name)
822
+ pattern = f"\\b{search_name}\\b"
823
+
824
+ if self._session:
825
+ cmd = f"rg -n --color=never {shlex.quote(pattern)} {shlex.quote(str(resolved))} 2>/dev/null | head -{max_results * 3}"
826
+ stdout, _, code = await self._session.run(cmd, timeout=30)
827
+ if code != 0 or not stdout:
828
+ return ReferenceSearchResult(query=name, matches=[], total_matches=0)
829
+ raw_lines = stdout.strip().splitlines()
830
+ else:
831
+ raw_lines = []
832
+ for fpath in self._iter_source_files(resolved, max_files=200):
833
+ try:
834
+ text = fpath.read_text(errors="replace")
835
+ for i, line in enumerate(text.splitlines(), 1):
836
+ if re.search(rf"\b{re.escape(name)}\b", line):
837
+ rel = fpath.relative_to(self._root)
838
+ raw_lines.append(f"{rel}:{i}:{line}")
839
+ except OSError:
840
+ continue
841
+
842
+ # Filter out definitions, comments, and string-only lines.
843
+ matches: list[str] = []
844
+ anchors: list[Match] = []
845
+ for raw in raw_lines:
846
+ if len(matches) >= max_results:
847
+ break
848
+ # Parse file:line:content
849
+ parts = raw.split(":", 2)
850
+ if len(parts) < 3:
851
+ continue
852
+ content = parts[2].strip()
853
+ # Skip definitions
854
+ if _is_definition_line(content, name.split(".")[-1]):
855
+ continue
856
+ # Skip comment-only lines
857
+ stripped = content.lstrip()
858
+ if stripped.startswith("#") or stripped.startswith("//"):
859
+ continue
860
+ anchor = _anchor_from_match_line(raw, self._root)
861
+ if anchor is None:
862
+ continue
863
+ matches.append(f"{parts[0]}:{parts[1]}: {content}")
864
+ anchors.append(anchor)
865
+
866
+ truncated = len(matches) >= max_results
867
+ return ReferenceSearchResult(
868
+ query=name,
869
+ matches=matches[:max_results],
870
+ total_matches=len(matches),
871
+ truncated=truncated,
872
+ anchors=anchors[:max_results],
873
+ )
874
+
875
+ # ------------------------------------------------------------------
876
+ # Helpers
877
+ # ------------------------------------------------------------------
878
+ def _iter_source_files(self, root: Path, max_files: int = 500):
879
+ """Yield source files under *root*, skipping non-source and common build dirs."""
880
+ count = 0
881
+ for fpath in sorted(root.rglob("*")):
882
+ if count >= max_files:
883
+ break
884
+ if not fpath.is_file():
885
+ continue
886
+ if _detect_lang(fpath) == "unknown":
887
+ continue
888
+ # Skip common non-source dirs
889
+ parts = fpath.parts
890
+ if any(
891
+ p.startswith(".")
892
+ or p in ("node_modules", "__pycache__", "venv", ".venv", "build", "dist")
893
+ for p in parts
894
+ ):
895
+ continue
896
+ yield fpath
897
+ count += 1
898
+
899
+ def _resolve(self, path: str) -> Path:
900
+ """Resolve a path relative to the repo root."""
901
+ p = Path(path)
902
+ return p if p.is_absolute() else self._root / p