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.
- agentpack/__init__.py +3 -0
- agentpack/adapters/__init__.py +0 -0
- agentpack/adapters/base.py +22 -0
- agentpack/adapters/claude.py +32 -0
- agentpack/adapters/codex.py +26 -0
- agentpack/adapters/cursor.py +29 -0
- agentpack/adapters/generic.py +18 -0
- agentpack/adapters/windsurf.py +26 -0
- agentpack/analysis/__init__.py +0 -0
- agentpack/analysis/dependency_graph.py +80 -0
- agentpack/analysis/go_imports.py +32 -0
- agentpack/analysis/java_imports.py +19 -0
- agentpack/analysis/js_ts_imports.py +53 -0
- agentpack/analysis/python_imports.py +45 -0
- agentpack/analysis/ranking.py +400 -0
- agentpack/analysis/rust_imports.py +32 -0
- agentpack/analysis/symbols.py +154 -0
- agentpack/analysis/tests.py +30 -0
- agentpack/application/__init__.py +0 -0
- agentpack/application/pack_service.py +352 -0
- agentpack/cli.py +33 -0
- agentpack/commands/__init__.py +0 -0
- agentpack/commands/_shared.py +13 -0
- agentpack/commands/benchmark.py +302 -0
- agentpack/commands/claude_cmd.py +55 -0
- agentpack/commands/diff.py +46 -0
- agentpack/commands/doctor.py +185 -0
- agentpack/commands/explain.py +238 -0
- agentpack/commands/init.py +79 -0
- agentpack/commands/install.py +252 -0
- agentpack/commands/monitor.py +105 -0
- agentpack/commands/pack.py +188 -0
- agentpack/commands/scan.py +51 -0
- agentpack/commands/session.py +204 -0
- agentpack/commands/stats.py +138 -0
- agentpack/commands/status.py +37 -0
- agentpack/commands/summarize.py +64 -0
- agentpack/commands/watch.py +185 -0
- agentpack/core/__init__.py +0 -0
- agentpack/core/bootstrap.py +46 -0
- agentpack/core/cache.py +41 -0
- agentpack/core/config.py +101 -0
- agentpack/core/context_pack.py +222 -0
- agentpack/core/diff.py +40 -0
- agentpack/core/git.py +145 -0
- agentpack/core/git_hooks.py +8 -0
- agentpack/core/global_install.py +14 -0
- agentpack/core/ignore.py +66 -0
- agentpack/core/merkle.py +8 -0
- agentpack/core/models.py +115 -0
- agentpack/core/redactor.py +99 -0
- agentpack/core/scanner.py +150 -0
- agentpack/core/snapshot.py +60 -0
- agentpack/core/token_estimator.py +26 -0
- agentpack/core/vscode_tasks.py +5 -0
- agentpack/data/agentpack.md +160 -0
- agentpack/installers/__init__.py +0 -0
- agentpack/installers/claude.py +160 -0
- agentpack/installers/codex.py +54 -0
- agentpack/installers/cursor.py +76 -0
- agentpack/installers/windsurf.py +50 -0
- agentpack/integrations/__init__.py +0 -0
- agentpack/integrations/git_hooks.py +109 -0
- agentpack/integrations/global_install.py +221 -0
- agentpack/integrations/vscode_tasks.py +85 -0
- agentpack/renderers/__init__.py +3 -0
- agentpack/renderers/compact.py +75 -0
- agentpack/renderers/markdown.py +144 -0
- agentpack/renderers/receipts.py +10 -0
- agentpack/session/__init__.py +33 -0
- agentpack/session/state.py +105 -0
- agentpack/summaries/__init__.py +0 -0
- agentpack/summaries/base.py +42 -0
- agentpack/summaries/llm.py +100 -0
- agentpack/summaries/offline.py +97 -0
- agentpack_cli-0.1.0.dist-info/METADATA +1391 -0
- agentpack_cli-0.1.0.dist-info/RECORD +80 -0
- agentpack_cli-0.1.0.dist-info/WHEEL +4 -0
- agentpack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|