agentpack-cli 0.1.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.
Files changed (80) hide show
  1. agentpack/__init__.py +3 -0
  2. agentpack/adapters/__init__.py +0 -0
  3. agentpack/adapters/base.py +22 -0
  4. agentpack/adapters/claude.py +32 -0
  5. agentpack/adapters/codex.py +26 -0
  6. agentpack/adapters/cursor.py +29 -0
  7. agentpack/adapters/generic.py +18 -0
  8. agentpack/adapters/windsurf.py +26 -0
  9. agentpack/analysis/__init__.py +0 -0
  10. agentpack/analysis/dependency_graph.py +80 -0
  11. agentpack/analysis/go_imports.py +32 -0
  12. agentpack/analysis/java_imports.py +19 -0
  13. agentpack/analysis/js_ts_imports.py +53 -0
  14. agentpack/analysis/python_imports.py +45 -0
  15. agentpack/analysis/ranking.py +400 -0
  16. agentpack/analysis/rust_imports.py +32 -0
  17. agentpack/analysis/symbols.py +154 -0
  18. agentpack/analysis/tests.py +30 -0
  19. agentpack/application/__init__.py +0 -0
  20. agentpack/application/pack_service.py +352 -0
  21. agentpack/cli.py +33 -0
  22. agentpack/commands/__init__.py +0 -0
  23. agentpack/commands/_shared.py +13 -0
  24. agentpack/commands/benchmark.py +302 -0
  25. agentpack/commands/claude_cmd.py +55 -0
  26. agentpack/commands/diff.py +46 -0
  27. agentpack/commands/doctor.py +185 -0
  28. agentpack/commands/explain.py +238 -0
  29. agentpack/commands/init.py +79 -0
  30. agentpack/commands/install.py +252 -0
  31. agentpack/commands/monitor.py +105 -0
  32. agentpack/commands/pack.py +188 -0
  33. agentpack/commands/scan.py +51 -0
  34. agentpack/commands/session.py +204 -0
  35. agentpack/commands/stats.py +138 -0
  36. agentpack/commands/status.py +37 -0
  37. agentpack/commands/summarize.py +64 -0
  38. agentpack/commands/watch.py +185 -0
  39. agentpack/core/__init__.py +0 -0
  40. agentpack/core/bootstrap.py +46 -0
  41. agentpack/core/cache.py +41 -0
  42. agentpack/core/config.py +101 -0
  43. agentpack/core/context_pack.py +222 -0
  44. agentpack/core/diff.py +40 -0
  45. agentpack/core/git.py +145 -0
  46. agentpack/core/git_hooks.py +8 -0
  47. agentpack/core/global_install.py +14 -0
  48. agentpack/core/ignore.py +66 -0
  49. agentpack/core/merkle.py +8 -0
  50. agentpack/core/models.py +115 -0
  51. agentpack/core/redactor.py +99 -0
  52. agentpack/core/scanner.py +150 -0
  53. agentpack/core/snapshot.py +60 -0
  54. agentpack/core/token_estimator.py +26 -0
  55. agentpack/core/vscode_tasks.py +5 -0
  56. agentpack/data/agentpack.md +160 -0
  57. agentpack/installers/__init__.py +0 -0
  58. agentpack/installers/claude.py +160 -0
  59. agentpack/installers/codex.py +54 -0
  60. agentpack/installers/cursor.py +76 -0
  61. agentpack/installers/windsurf.py +50 -0
  62. agentpack/integrations/__init__.py +0 -0
  63. agentpack/integrations/git_hooks.py +109 -0
  64. agentpack/integrations/global_install.py +221 -0
  65. agentpack/integrations/vscode_tasks.py +85 -0
  66. agentpack/renderers/__init__.py +3 -0
  67. agentpack/renderers/compact.py +75 -0
  68. agentpack/renderers/markdown.py +144 -0
  69. agentpack/renderers/receipts.py +10 -0
  70. agentpack/session/__init__.py +33 -0
  71. agentpack/session/state.py +105 -0
  72. agentpack/summaries/__init__.py +0 -0
  73. agentpack/summaries/base.py +42 -0
  74. agentpack/summaries/llm.py +100 -0
  75. agentpack/summaries/offline.py +97 -0
  76. agentpack_cli-0.1.0.dist-info/METADATA +1391 -0
  77. agentpack_cli-0.1.0.dist-info/RECORD +80 -0
  78. agentpack_cli-0.1.0.dist-info/WHEEL +4 -0
  79. agentpack_cli-0.1.0.dist-info/entry_points.txt +2 -0
  80. agentpack_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,400 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from agentpack.core.models import DependencyGraph, FileInfo
7
+ from agentpack.core.config import ScoringWeights
8
+
9
+ _STOPWORDS = {
10
+ "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for",
11
+ "of", "with", "by", "is", "are", "was", "were", "be", "been", "have",
12
+ "has", "had", "do", "does", "did", "will", "would", "could", "should",
13
+ "may", "might", "can", "that", "this", "these", "those", "it", "its",
14
+ "from", "not", "we", "our", "you", "your", "they", "them", "their",
15
+ "file", "files", "code", "function", "method", "class", "module",
16
+ "use", "using", "used", "how", "what", "when", "where", "why",
17
+ }
18
+
19
+ _CONCEPT_MAP: dict[str, frozenset[str]] = {
20
+ # rate limiting
21
+ "rate": frozenset({"throttle", "ratelimit", "leaky", "bucket", "debounce", "backoff", "quota"}),
22
+ "limiting": frozenset({"throttle", "ratelimit", "leaky", "bucket", "debounce", "quota"}),
23
+ "throttle": frozenset({"rate", "limit", "ratelimit", "leaky", "bucket", "quota"}),
24
+
25
+ # authentication
26
+ "auth": frozenset({"jwt", "bearer", "token", "oauth", "credential", "login", "signin", "identity", "principal"}),
27
+ "authentication": frozenset({"jwt", "bearer", "token", "oauth", "credential", "login"}),
28
+ "login": frozenset({"auth", "signin", "credential", "token", "session"}),
29
+
30
+ # caching
31
+ "cache": frozenset({"lru", "memoize", "memo", "ttl", "evict", "invalidate", "redis", "memcache"}),
32
+ "caching": frozenset({"lru", "memoize", "memo", "ttl", "evict", "redis"}),
33
+
34
+ # queue / messaging
35
+ "queue": frozenset({"broker", "pubsub", "kafka", "rabbitmq", "worker", "job", "task", "celery", "enqueue", "dequeue"}),
36
+ "message": frozenset({"queue", "broker", "pubsub", "event", "dispatch", "emit", "publish", "subscribe"}),
37
+
38
+ # database
39
+ "database": frozenset({"db", "orm", "migration", "schema", "query", "repository", "dao", "entity"}),
40
+ "db": frozenset({"database", "orm", "migration", "schema", "query", "repository"}),
41
+ "migration": frozenset({"schema", "database", "db", "alembic", "flyway", "liquibase"}),
42
+
43
+ # concurrency
44
+ "concurrency": frozenset({"mutex", "lock", "semaphore", "atomic", "thread", "async", "goroutine", "coroutine"}),
45
+ "concurrent": frozenset({"mutex", "lock", "semaphore", "atomic", "thread", "race", "goroutine"}),
46
+ "race": frozenset({"mutex", "lock", "semaphore", "atomic", "concurrent", "thread"}),
47
+ "async": frozenset({"await", "coroutine", "future", "promise", "concurrent", "thread"}),
48
+
49
+ # error handling
50
+ "error": frozenset({"exception", "fault", "failure", "retry", "fallback", "circuit", "breaker"}),
51
+ "retry": frozenset({"backoff", "error", "fault", "resilience", "circuit", "breaker"}),
52
+
53
+ # http / api
54
+ "api": frozenset({"endpoint", "route", "handler", "controller", "rest", "graphql", "grpc", "rpc"}),
55
+ "endpoint": frozenset({"route", "handler", "controller", "api", "path", "url"}),
56
+ "middleware": frozenset({"interceptor", "filter", "hook", "plugin", "decorator", "wrapper"}),
57
+
58
+ # storage / files
59
+ "storage": frozenset({"disk", "filesystem", "bucket", "blob", "upload", "download", "s3", "gcs"}),
60
+ "upload": frozenset({"storage", "blob", "bucket", "multipart", "stream", "file"}),
61
+
62
+ # security
63
+ "security": frozenset({"auth", "permission", "role", "acl", "policy", "rbac", "encrypt", "hash", "sign"}),
64
+ "permission": frozenset({"role", "acl", "policy", "rbac", "auth", "access", "grant", "deny"}),
65
+ "encrypt": frozenset({"decrypt", "cipher", "hash", "sign", "verify", "secret", "key"}),
66
+
67
+ # logging / observability
68
+ "log": frozenset({"trace", "metric", "monitor", "observe", "telemetry", "audit", "event"}),
69
+ "metric": frozenset({"log", "trace", "monitor", "observe", "telemetry", "prometheus", "gauge", "counter"}),
70
+
71
+ # search
72
+ "search": frozenset({"index", "query", "fulltext", "elasticsearch", "solr", "lucene", "rank", "score"}),
73
+
74
+ # streaming / SSE / websocket
75
+ "stream": frozenset({"sse", "websocket", "ws", "chunk", "realtime", "push", "subscribe", "channel"}),
76
+ "sse": frozenset({"stream", "eventstream", "push", "realtime", "subscribe"}),
77
+ "websocket": frozenset({"stream", "ws", "socket", "realtime", "channel", "push"}),
78
+ "realtime": frozenset({"stream", "sse", "websocket", "push", "subscribe", "channel"}),
79
+
80
+ # webhooks / events
81
+ "webhook": frozenset({"event", "callback", "notify", "dispatch", "trigger", "listener", "handler"}),
82
+ "event": frozenset({"webhook", "listener", "handler", "dispatch", "emit", "publish", "subscribe", "bus"}),
83
+
84
+ # pagination
85
+ "pagination": frozenset({"page", "cursor", "offset", "limit", "paginate", "scroll", "infinite"}),
86
+ "paginate": frozenset({"page", "cursor", "offset", "limit", "pagination"}),
87
+
88
+ # validation / schema
89
+ "validation": frozenset({"validate", "schema", "sanitize", "constraint", "rule", "pydantic", "zod", "yup"}),
90
+ "validate": frozenset({"validation", "schema", "sanitize", "constraint"}),
91
+ "schema": frozenset({"validate", "model", "serializer", "deserializer", "marshal", "unmarshal"}),
92
+
93
+ # deployment / infra
94
+ "deploy": frozenset({"release", "rollout", "container", "docker", "k8s", "kubernetes", "terraform", "ci", "cd"}),
95
+ "docker": frozenset({"container", "image", "compose", "deploy", "k8s", "registry"}),
96
+ "kubernetes": frozenset({"k8s", "pod", "deployment", "service", "ingress", "helm", "container"}),
97
+
98
+ # email / notifications
99
+ "email": frozenset({"smtp", "sendgrid", "mailgun", "ses", "template", "notification", "mailer"}),
100
+ "notification": frozenset({"email", "push", "sms", "alert", "webhook", "event"}),
101
+
102
+ # payment / billing
103
+ "payment": frozenset({"stripe", "paypal", "billing", "invoice", "charge", "subscription", "checkout"}),
104
+ "billing": frozenset({"payment", "subscription", "invoice", "charge", "plan", "tier"}),
105
+
106
+ # file / upload
107
+ "file": frozenset({"upload", "download", "storage", "s3", "blob", "multipart", "attachment", "disk"}),
108
+
109
+ # test / testing
110
+ "test": frozenset({"spec", "fixture", "mock", "stub", "assert", "expect", "describe", "jest", "pytest"}),
111
+ "mock": frozenset({"stub", "spy", "patch", "fixture", "test", "fake"}),
112
+
113
+ # config / env
114
+ "config": frozenset({"env", "settings", "environment", "dotenv", "toml", "yaml", "ini", "conf"}),
115
+ "env": frozenset({"config", "settings", "environment", "dotenv", "variable"}),
116
+
117
+ # serialization
118
+ "serialize": frozenset({"json", "marshal", "encode", "decode", "pickle", "protobuf", "msgpack"}),
119
+ "deserialize": frozenset({"json", "unmarshal", "decode", "parse", "protobuf"}),
120
+
121
+ # health check / liveness
122
+ "health": frozenset({"ping", "liveness", "readiness", "probe", "heartbeat", "status", "check"}),
123
+ }
124
+
125
+ _VARIANTS: dict[str, str] = {
126
+ "cancellation": "cancel",
127
+ "cancelled": "cancel",
128
+ "canceling": "cancel",
129
+ "authentication": "auth",
130
+ "authenticated": "auth",
131
+ "authorize": "auth",
132
+ "authorization": "auth",
133
+ "configuration": "config",
134
+ "configured": "config",
135
+ "database": "db",
136
+ "databases": "db",
137
+ "connection": "conn",
138
+ "connections": "conn",
139
+ "management": "manage",
140
+ "manager": "manage",
141
+ "implementation": "impl",
142
+ "implements": "impl",
143
+ "middleware": "middleware",
144
+ "request": "req",
145
+ "response": "res",
146
+ "session": "session",
147
+ "sessions": "session",
148
+ "error": "error",
149
+ "errors": "error",
150
+ "exception": "exception",
151
+ "exceptions": "exception",
152
+ "handler": "handler",
153
+ "handlers": "handler",
154
+ "service": "service",
155
+ "services": "service",
156
+ "endpoint": "endpoint",
157
+ "endpoints": "endpoint",
158
+ "router": "router",
159
+ "routing": "router",
160
+ "redis": "redis",
161
+ "stream": "stream",
162
+ "streaming": "stream",
163
+ "goroutine": "goroutine",
164
+ "channel": "chan",
165
+ "interface": "interface",
166
+ "struct": "struct",
167
+ "trait": "trait",
168
+ "impl": "impl",
169
+ }
170
+
171
+ CONFIG_EXTENSIONS = {
172
+ ".toml", ".yaml", ".yml", ".json", ".env", ".ini", ".cfg", ".conf",
173
+ ".dockerfile", ".makefile",
174
+ }
175
+ CONFIG_NAMES = {
176
+ "config", "settings", "configuration", "env", ".env",
177
+ "pyproject", "package", "dockerfile", "makefile", "cargo", "go",
178
+ "build", "cmake",
179
+ }
180
+
181
+ _DEFAULT_WEIGHTS = ScoringWeights()
182
+
183
+
184
+ def extract_keywords(task: str) -> set[str]:
185
+ words = re.split(r"[^a-zA-Z0-9]+", task.lower())
186
+ keywords: set[str] = set()
187
+ for word in words:
188
+ if len(word) < 3:
189
+ continue
190
+ if word in _STOPWORDS:
191
+ continue
192
+ keywords.add(word)
193
+ if word in _VARIANTS:
194
+ keywords.add(_VARIANTS[word])
195
+
196
+ # expand via concept map (one level only — no recursion to avoid explosion)
197
+ expanded: set[str] = set()
198
+ for kw in keywords:
199
+ if kw in _CONCEPT_MAP:
200
+ for synonym in _CONCEPT_MAP[kw]:
201
+ expanded.add(synonym)
202
+ # also apply _VARIANTS to expanded terms
203
+ if synonym in _VARIANTS:
204
+ expanded.add(_VARIANTS[synonym])
205
+ keywords.update(expanded)
206
+ return keywords
207
+
208
+
209
+ def enrich_keywords_from_files(
210
+ keywords: set[str],
211
+ changed_paths: set[str],
212
+ files: list[FileInfo],
213
+ max_new_keywords: int = 20,
214
+ ) -> set[str]:
215
+ """Expand keywords with high-frequency terms from changed file content.
216
+
217
+ Reads only the changed files, extracts identifier-like tokens, and adds
218
+ those that appear repeatedly — giving the ranker semantic signal beyond
219
+ the task string alone.
220
+ """
221
+ path_map = {fi.path: fi for fi in files if not fi.ignored and not fi.binary}
222
+ token_freq: dict[str, int] = {}
223
+
224
+ for path in changed_paths:
225
+ fi = path_map.get(path)
226
+ if fi is None:
227
+ continue
228
+ if fi.content is not None:
229
+ text = fi.content
230
+ elif fi.abs_path.exists():
231
+ try:
232
+ text = fi.abs_path.read_text(errors="replace")
233
+ except OSError:
234
+ continue
235
+ else:
236
+ continue
237
+ # Extract camelCase/snake_case identifiers and plain words
238
+ tokens = re.findall(r"[a-zA-Z][a-zA-Z0-9_]{2,}", text)
239
+ for raw in tokens:
240
+ # Split camelCase into parts
241
+ parts = re.sub(r"([A-Z])", r" \1", raw).lower().split()
242
+ for part in parts:
243
+ if len(part) < 3 or part in _STOPWORDS:
244
+ continue
245
+ token_freq[part] = token_freq.get(part, 0) + 1
246
+
247
+ # Keep tokens that appear ≥3 times and aren't already in keywords
248
+ new_keywords = {
249
+ tok for tok, freq in token_freq.items()
250
+ if freq >= 3 and tok not in keywords
251
+ }
252
+
253
+ # Limit to top max_new_keywords by frequency
254
+ top = sorted(new_keywords, key=lambda t: -token_freq[t])[:max_new_keywords]
255
+ return keywords | set(top)
256
+
257
+
258
+ def _path_matches_keywords(path: str, keywords: set[str]) -> bool:
259
+ path_lower = path.lower()
260
+ return any(kw in path_lower for kw in keywords)
261
+
262
+
263
+ def _content_matches_keywords(text: str, keywords: set[str]) -> int:
264
+ text_lower = text.lower()
265
+ return sum(1 for kw in keywords if kw in text_lower)
266
+
267
+
268
+ def _symbol_matches_keywords(symbols: list[str], keywords: set[str]) -> bool:
269
+ for sym in symbols:
270
+ if any(kw in sym.lower() for kw in keywords):
271
+ return True
272
+ return False
273
+
274
+
275
+ def score_files(
276
+ files: list[FileInfo],
277
+ changed_paths: set[str],
278
+ staged_paths: set[str],
279
+ recently_modified: list[str],
280
+ dep_graph: "DependencyGraph | dict",
281
+ keywords: set[str],
282
+ include_tests: bool = True,
283
+ include_configs: bool = True,
284
+ weights: ScoringWeights | None = None,
285
+ ) -> list[tuple[FileInfo, float, list[str]]]:
286
+ from agentpack.core.models import DependencyGraph as _DG
287
+ if not isinstance(dep_graph, _DG):
288
+ dep_graph = _DG()
289
+ w = weights or _DEFAULT_WEIGHTS
290
+ all_paths = {f.path for f in files}
291
+ results: list[tuple[FileInfo, float, list[str]]] = []
292
+ recently_set = set(recently_modified[:20])
293
+
294
+ for fi in files:
295
+ if fi.ignored or fi.binary:
296
+ results.append((fi, w.ignored_penalty, ["ignored/binary"]))
297
+ continue
298
+
299
+ score = 0.0
300
+ reasons: list[str] = []
301
+
302
+ if fi.path in changed_paths:
303
+ score += w.modified
304
+ reasons.append("modified")
305
+
306
+ if fi.path in staged_paths:
307
+ score += w.staged
308
+ reasons.append("staged")
309
+
310
+ if _path_matches_keywords(fi.path, keywords):
311
+ score += w.filename_keyword
312
+ reasons.append("filename keyword match")
313
+
314
+ node = dep_graph.get(fi.path)
315
+ sym_names: list[str] = [] # symbols aren't stored on DependencyNode; scoring uses path/content only
316
+ if _symbol_matches_keywords(sym_names, keywords):
317
+ score += w.symbol_keyword
318
+ reasons.append("symbol keyword match")
319
+
320
+ if fi.content is not None:
321
+ hits = _content_matches_keywords(fi.content, keywords)
322
+ if hits > 0:
323
+ score += min(w.content_keyword_max, hits * w.content_keyword_per_hit)
324
+ reasons.append(f"content keyword match ({hits})")
325
+ elif fi.abs_path.exists():
326
+ try:
327
+ text = fi.abs_path.read_text(errors="replace")
328
+ hits = _content_matches_keywords(text, keywords)
329
+ if hits > 0:
330
+ score += min(w.content_keyword_max, hits * w.content_keyword_per_hit)
331
+ reasons.append(f"content keyword match ({hits})")
332
+ except OSError:
333
+ pass
334
+
335
+ for dep_path in node.imports:
336
+ if dep_path in changed_paths or _path_matches_keywords(dep_path, keywords):
337
+ score += w.direct_dep
338
+ reasons.append("direct dependency of changed file")
339
+ break
340
+
341
+ for other_path, other_node in dep_graph.items():
342
+ if fi.path in other_node.imports and other_path in changed_paths:
343
+ score += w.reverse_dep
344
+ reasons.append("reverse dependency")
345
+ break
346
+
347
+ if include_tests:
348
+ tests = node.tests
349
+ if tests and any(t in all_paths for t in tests):
350
+ score += w.related_test
351
+ reasons.append("has related tests")
352
+
353
+ if _is_test_file(fi.path):
354
+ for src_path in changed_paths:
355
+ if _test_matches_source(fi.path, src_path):
356
+ score += w.related_test
357
+ reasons.append(f"test for {src_path}")
358
+ break
359
+
360
+ if include_configs and _is_config_file(fi.path):
361
+ score += w.config_file
362
+ reasons.append("config file")
363
+
364
+ if fi.path in recently_set:
365
+ score += w.recently_modified
366
+ reasons.append("recently modified")
367
+
368
+ if fi.too_large and score < 50:
369
+ score += w.large_unrelated_penalty
370
+ reasons.append("large unrelated file")
371
+
372
+ results.append((fi, score, reasons))
373
+
374
+ return results
375
+
376
+
377
+ def _is_test_file(path: str) -> bool:
378
+ p = Path(path)
379
+ return (
380
+ p.stem.startswith("test_")
381
+ or p.stem.endswith("_test")
382
+ or p.stem.endswith(".test")
383
+ or p.stem.endswith(".spec")
384
+ or "tests" in p.parts
385
+ or "__tests__" in p.parts
386
+ )
387
+
388
+
389
+ def _test_matches_source(test_path: str, src_path: str) -> bool:
390
+ src_stem = Path(src_path).stem
391
+ test_stem = Path(test_path).stem
392
+ return src_stem in test_stem or test_stem.replace("test_", "") == src_stem
393
+
394
+
395
+ def _is_config_file(path: str) -> bool:
396
+ p = Path(path)
397
+ return (
398
+ p.suffix.lower() in CONFIG_EXTENSIONS
399
+ or p.stem.lower() in CONFIG_NAMES
400
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ # mod foo; / mod foo { — internal module declarations
7
+ _MOD = re.compile(r"^\s*(?:pub\s+)?mod\s+(\w+)\s*[;{]", re.MULTILINE)
8
+ # use foo::bar; / use foo::{bar, baz};
9
+ _USE = re.compile(r"^\s*(?:pub\s+)?use\s+([\w::{}, ]+)\s*;", re.MULTILINE)
10
+ # extern crate foo;
11
+ _EXTERN = re.compile(r"^\s*extern\s+crate\s+(\w+)\s*;", re.MULTILINE)
12
+
13
+
14
+ def extract_imports(path: Path, text: str | None = None) -> list[str]:
15
+ if text is None:
16
+ try:
17
+ text = path.read_text(errors="replace")
18
+ except OSError:
19
+ return []
20
+
21
+ imports: list[str] = []
22
+ for m in _MOD.finditer(text):
23
+ imports.append(m.group(1))
24
+ for m in _USE.finditer(text):
25
+ # strip whitespace and braces for a clean root crate name
26
+ raw = m.group(1).split("::")[0].strip()
27
+ if raw:
28
+ imports.append(raw)
29
+ for m in _EXTERN.finditer(text):
30
+ imports.append(m.group(1))
31
+
32
+ return list(dict.fromkeys(imports)) # deduplicate, preserve order
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from agentpack.core.models import Symbol
9
+
10
+
11
+ def extract_python_symbols(path: Path) -> list[Symbol]:
12
+ try:
13
+ source = path.read_text(errors="replace")
14
+ tree = ast.parse(source)
15
+ except (SyntaxError, OSError):
16
+ return []
17
+
18
+ symbols: list[Symbol] = []
19
+
20
+ for node in ast.walk(tree):
21
+ if isinstance(node, ast.ClassDef):
22
+ sig = f"class {node.name}"
23
+ if node.bases:
24
+ bases = ", ".join(ast.unparse(b) for b in node.bases)
25
+ sig += f"({bases})"
26
+ doc = ast.get_docstring(node)
27
+ symbols.append(
28
+ Symbol(
29
+ name=node.name,
30
+ kind="class",
31
+ start_line=node.lineno,
32
+ end_line=node.end_lineno or node.lineno,
33
+ signature=sig,
34
+ summary=doc[:120] if doc else None,
35
+ body=ast.get_source_segment(source, node),
36
+ )
37
+ )
38
+ for item in node.body:
39
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
40
+ msig = f"def {item.name}({_args_str(item.args)})"
41
+ mdoc = ast.get_docstring(item)
42
+ symbols.append(
43
+ Symbol(
44
+ name=f"{node.name}.{item.name}",
45
+ kind="method",
46
+ start_line=item.lineno,
47
+ end_line=item.end_lineno or item.lineno,
48
+ signature=msig,
49
+ summary=mdoc[:120] if mdoc else None,
50
+ body=ast.get_source_segment(source, item),
51
+ )
52
+ )
53
+
54
+ # top-level functions
55
+ for node in ast.iter_child_nodes(tree):
56
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
57
+ sig = f"def {node.name}({_args_str(node.args)})"
58
+ doc = ast.get_docstring(node)
59
+ symbols.append(
60
+ Symbol(
61
+ name=node.name,
62
+ kind="function",
63
+ start_line=node.lineno,
64
+ end_line=node.end_lineno or node.lineno,
65
+ signature=sig,
66
+ summary=doc[:120] if doc else None,
67
+ body=ast.get_source_segment(source, node),
68
+ )
69
+ )
70
+
71
+ return symbols
72
+
73
+
74
+ def _args_str(args: ast.arguments) -> str:
75
+ parts: list[str] = []
76
+ for arg in args.args:
77
+ parts.append(arg.arg)
78
+ if args.vararg:
79
+ parts.append(f"*{args.vararg.arg}")
80
+ for arg in args.kwonlyargs:
81
+ parts.append(arg.arg)
82
+ if args.kwarg:
83
+ parts.append(f"**{args.kwarg.arg}")
84
+ return ", ".join(parts)
85
+
86
+
87
+
88
+ _JS_FUNC = re.compile(
89
+ r"(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(",
90
+ )
91
+ # Require => on the same line to avoid matching non-arrow assignments like:
92
+ # const result = (a + b)
93
+ _JS_ARROW = re.compile(
94
+ r"(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|\w+)\s*=>",
95
+ )
96
+ _JS_CLASS = re.compile(r"(?:export\s+)?class\s+(\w+)")
97
+
98
+
99
+ def extract_js_symbols(path: Path) -> list[Symbol]:
100
+ try:
101
+ lines = path.read_text(errors="replace").splitlines()
102
+ except OSError:
103
+ return []
104
+
105
+ symbols: list[Symbol] = []
106
+ brace_depth = 0
107
+ open_syms: list[tuple[str, str, int]] = []
108
+
109
+ for i, line in enumerate(lines, 1):
110
+ brace_depth += line.count("{") - line.count("}")
111
+ for pattern, kind in [
112
+ (_JS_CLASS, "class"),
113
+ (_JS_FUNC, "function"),
114
+ (_JS_ARROW, "function"),
115
+ ]:
116
+ m = pattern.search(line)
117
+ if m:
118
+ open_syms.append((m.group(1), kind, i))
119
+
120
+ # close any unclosed syms at end of file
121
+ end_line = len(lines)
122
+ for name, kind, start in open_syms:
123
+ symbols.append(
124
+ Symbol(
125
+ name=name,
126
+ kind=kind, # type: ignore[arg-type]
127
+ start_line=start,
128
+ end_line=end_line,
129
+ signature=lines[start - 1].strip()[:120],
130
+ body="\n".join(lines[start - 1 : min(start + 49, end_line)]),
131
+ )
132
+ )
133
+ return symbols
134
+
135
+
136
+ def extract_symbols(path: Path, language: str | None) -> list[Symbol]:
137
+ if language == "python":
138
+ return extract_python_symbols(path)
139
+ if language in ("javascript", "typescript"):
140
+ return extract_js_symbols(path)
141
+ return []
142
+
143
+
144
+ def filter_symbols_by_keywords(symbols: list[Symbol], keywords: set[str]) -> list[Symbol]:
145
+ """Return symbols whose name or summary matches any keyword."""
146
+ if not keywords:
147
+ return symbols
148
+ result = []
149
+ for s in symbols:
150
+ name_lower = s.name.lower()
151
+ summary_lower = (s.summary or "").lower()
152
+ if any(kw in name_lower or kw in summary_lower for kw in keywords):
153
+ result.append(s)
154
+ return result
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ _TEST_PATTERNS = [
6
+ # Python
7
+ lambda stem, parent: f"tests/{parent}/test_{stem}.py",
8
+ lambda stem, parent: f"test_{stem}.py",
9
+ lambda stem, parent: f"tests/test_{stem}.py",
10
+ # JS/TS
11
+ lambda stem, parent: f"{stem}.test.ts",
12
+ lambda stem, parent: f"{stem}.spec.ts",
13
+ lambda stem, parent: f"{stem}.test.js",
14
+ lambda stem, parent: f"{stem}.spec.js",
15
+ lambda stem, parent: f"__tests__/{stem}.test.ts",
16
+ lambda stem, parent: f"__tests__/{stem}.test.js",
17
+ ]
18
+
19
+
20
+ def find_related_tests(path: str, all_paths: set[str]) -> list[str]:
21
+ p = Path(path)
22
+ stem = p.stem
23
+ parent = p.parent.name
24
+
25
+ results: list[str] = []
26
+ for pattern_fn in _TEST_PATTERNS:
27
+ candidate = pattern_fn(stem, parent)
28
+ if candidate in all_paths:
29
+ results.append(candidate)
30
+ return results
File without changes