every-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.
- every/__init__.py +3 -0
- every/cache.py +65 -0
- every/classify.py +23 -0
- every/cli.py +239 -0
- every/discover.py +96 -0
- every/extract.py +137 -0
- every/judge.py +288 -0
- every/neighborhood.py +45 -0
- every/report.py +63 -0
- every/selftest.py +83 -0
- every/symbols.py +109 -0
- every/units.py +34 -0
- every_cli-0.1.0.dist-info/METADATA +118 -0
- every_cli-0.1.0.dist-info/RECORD +18 -0
- every_cli-0.1.0.dist-info/WHEEL +5 -0
- every_cli-0.1.0.dist-info/entry_points.txt +2 -0
- every_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- every_cli-0.1.0.dist-info/top_level.txt +1 -0
every/__init__.py
ADDED
every/cache.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Per-repo score cache so re-runs and refined questions only pay for new judgments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
AUTOSAVE_EVERY = 50
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Cache:
|
|
13
|
+
def __init__(self, root: str, enabled: bool = True) -> None:
|
|
14
|
+
self.enabled = enabled
|
|
15
|
+
self.dir = os.path.join(root, ".every")
|
|
16
|
+
self.path = os.path.join(self.dir, "cache.json")
|
|
17
|
+
self.data: dict = {}
|
|
18
|
+
self.hits = 0
|
|
19
|
+
self._dirty = 0
|
|
20
|
+
if enabled and os.path.isfile(self.path):
|
|
21
|
+
try:
|
|
22
|
+
with open(self.path, encoding="utf-8") as fh:
|
|
23
|
+
self.data = json.load(fh)
|
|
24
|
+
except (OSError, ValueError):
|
|
25
|
+
self.data = {}
|
|
26
|
+
if not isinstance(self.data, dict):
|
|
27
|
+
self.data = {}
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def key(question: str, mode: str, text: str) -> str:
|
|
31
|
+
norm = " ".join(question.lower().split())
|
|
32
|
+
return hashlib.sha256(f"{norm}\x00{mode}\x00{text}".encode("utf-8")).hexdigest()
|
|
33
|
+
|
|
34
|
+
def get(self, key: str):
|
|
35
|
+
if not self.enabled:
|
|
36
|
+
return None
|
|
37
|
+
score = self.data.get(key)
|
|
38
|
+
if score is not None:
|
|
39
|
+
self.hits += 1
|
|
40
|
+
return score
|
|
41
|
+
|
|
42
|
+
def put(self, key: str, score: float) -> None:
|
|
43
|
+
if not self.enabled:
|
|
44
|
+
return
|
|
45
|
+
self.data[key] = score
|
|
46
|
+
self._dirty += 1
|
|
47
|
+
if self._dirty >= AUTOSAVE_EVERY:
|
|
48
|
+
self.save()
|
|
49
|
+
|
|
50
|
+
def save(self) -> None:
|
|
51
|
+
if not self.enabled:
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
55
|
+
ignore = os.path.join(self.dir, ".gitignore")
|
|
56
|
+
if not os.path.isfile(ignore):
|
|
57
|
+
with open(ignore, "w", encoding="utf-8") as fh:
|
|
58
|
+
fh.write("*\n")
|
|
59
|
+
tmp = self.path + ".tmp"
|
|
60
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
61
|
+
json.dump(self.data, fh)
|
|
62
|
+
os.replace(tmp, self.path)
|
|
63
|
+
except OSError:
|
|
64
|
+
return
|
|
65
|
+
self._dirty = 0
|
every/classify.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Decide how much context a question needs before we look at any code (Layer 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .judge import Judge
|
|
6
|
+
|
|
7
|
+
CLASSES = {
|
|
8
|
+
"body": "Answerable by reading one function's own source code, with no other code.",
|
|
9
|
+
"neighborhood": "Needs the function plus what it directly calls or what directly calls it.",
|
|
10
|
+
"global": "Needs reasoning across many functions or files: data flow, reachability, "
|
|
11
|
+
"or configuration that lives elsewhere in the repository.",
|
|
12
|
+
}
|
|
13
|
+
INSTRUCTIONS = (
|
|
14
|
+
"A tool will ask this question about every function in a code repository, one "
|
|
15
|
+
"function at a time. Choose how much surrounding code is needed to answer it "
|
|
16
|
+
"reliably for a single function."
|
|
17
|
+
)
|
|
18
|
+
DEFAULT = "neighborhood"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def classify(question: str, judge: Judge) -> str:
|
|
22
|
+
choice = judge.choice({"question": question}, INSTRUCTIONS, CLASSES)
|
|
23
|
+
return choice if choice in CLASSES else DEFAULT
|
every/cli.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""every - ask a yes/no question of every function in a codebase."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from collections import Counter
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .cache import Cache
|
|
13
|
+
from .classify import classify
|
|
14
|
+
from .discover import discover
|
|
15
|
+
from .extract import extract_all
|
|
16
|
+
from .judge import Item, Judge, Unauthorized, pack
|
|
17
|
+
from .neighborhood import stage1_text, stage2_text
|
|
18
|
+
from .report import build_rows, render_json, render_table, select_rows
|
|
19
|
+
from .symbols import SymbolIndex
|
|
20
|
+
from .units import DEFAULT_ABOVE, REQUEST_FIXED_TOKENS, USD_PER_INPUT_TOKEN
|
|
21
|
+
|
|
22
|
+
WAITLIST_URL = os.environ.get("EVERY_WAITLIST_URL", "https://github.com/sufianetaouil/every/discussions/1")
|
|
23
|
+
NO_KEY_MESSAGE = (
|
|
24
|
+
"every runs on TypeSafe Jev and needs TYPESAFE_API_KEY.\n"
|
|
25
|
+
"No access yet? Join the hosted-tier waitlist: {url}\n"
|
|
26
|
+
)
|
|
27
|
+
CANDIDATE_FLOOR = 0.30
|
|
28
|
+
CANDIDATE_CAP = 200
|
|
29
|
+
CONFIRM_ABOVE_USD = 1.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_args(argv):
|
|
33
|
+
p = argparse.ArgumentParser(prog="every",
|
|
34
|
+
description="Ask a yes/no question of every function in a codebase.")
|
|
35
|
+
p.add_argument("question", nargs="?", help="a yes/no question about a single function")
|
|
36
|
+
p.add_argument("path", nargs="?", help="directory to scan")
|
|
37
|
+
p.add_argument("--above", type=float, default=DEFAULT_ABOVE,
|
|
38
|
+
help="score at/above which a result is a hit (default 0.50)")
|
|
39
|
+
p.add_argument("--top", type=int, default=20, help="rows to show (default 20)")
|
|
40
|
+
p.add_argument("--json", action="store_true", help="JSON output (automatic when stdout is not a TTY)")
|
|
41
|
+
p.add_argument("--yes", action="store_true", help="skip the cost confirmation")
|
|
42
|
+
p.add_argument("--no-cache", action="store_true", help="ignore and do not write .every/cache.json")
|
|
43
|
+
p.add_argument("--depth", type=int, default=4, help=argparse.SUPPRESS)
|
|
44
|
+
p.add_argument("--budget", type=int, default=45_000, help=argparse.SUPPRESS)
|
|
45
|
+
p.add_argument("--selftest", action="store_true", help="run the bundled labelled set and print metrics")
|
|
46
|
+
p.add_argument("--version", action="store_true")
|
|
47
|
+
return p.parse_args(argv)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def make_repo_card(root: str, files, units) -> dict:
|
|
51
|
+
langs = Counter(f.lang for f in files).most_common(3)
|
|
52
|
+
top_level = sorted({f.path.split("/")[0] for f in files if "/" in f.path})[:12]
|
|
53
|
+
return {"name": os.path.basename(os.path.abspath(root)) or root,
|
|
54
|
+
"languages": [l for l, _ in langs], "top_level": top_level,
|
|
55
|
+
"files": len(files), "functions": len(units)}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _payload(u, text: str) -> dict:
|
|
59
|
+
return {"file": u.file, "name": u.name, "language": u.lang, "kind": u.kind,
|
|
60
|
+
"lines": f"{u.start_line}-{u.end_line}", "source": text}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _estimate(items, budget: int) -> tuple[int, float]:
|
|
64
|
+
tokens = sum(it.est for it in items) + REQUEST_FIXED_TOKENS * max(1, len(pack(items, budget)))
|
|
65
|
+
return tokens, tokens * USD_PER_INPUT_TOKEN
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _meta(args, root, files, units, unsupported, cls, coverage, stats, seconds, partial, cache_hits,
|
|
69
|
+
model) -> dict:
|
|
70
|
+
return {
|
|
71
|
+
"question": args.question, "class": cls, "coverage": coverage, "path": root,
|
|
72
|
+
"files": len(files), "units": len(units), "unsupported_files": unsupported,
|
|
73
|
+
"requests": stats.requests if stats else 0, "input_tokens": stats.input_tokens if stats else 0,
|
|
74
|
+
"output_tokens": stats.output_tokens if stats else 0,
|
|
75
|
+
"cost_usd": (stats.input_tokens if stats else 0) * USD_PER_INPUT_TOKEN, "seconds": seconds,
|
|
76
|
+
"above": args.above, "partial": partial, "cache_hits": cache_hits,
|
|
77
|
+
"failed_units": stats.failed if stats else 0, "model": model,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def confirm_cost(est_cost: float, err) -> bool:
|
|
82
|
+
"""Ask on stderr; refuse cleanly when stdin is not interactive."""
|
|
83
|
+
stdin = sys.stdin
|
|
84
|
+
if not (hasattr(stdin, "isatty") and stdin.isatty()):
|
|
85
|
+
print(f"estimated cost ${est_cost:.2f} exceeds ${CONFIRM_ABOVE_USD:.2f}; "
|
|
86
|
+
f"re-run with --yes to proceed", file=err)
|
|
87
|
+
return False
|
|
88
|
+
print(f"estimated cost ${est_cost:.2f} - proceed? [y/N] ", file=err, end="", flush=True)
|
|
89
|
+
try:
|
|
90
|
+
answer = stdin.readline()
|
|
91
|
+
except (EOFError, OSError):
|
|
92
|
+
return False
|
|
93
|
+
return answer.strip().lower() in ("y", "yes")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_query(args, judge: Judge, out, err) -> int:
|
|
97
|
+
root = os.path.abspath(args.path)
|
|
98
|
+
files = discover(root)
|
|
99
|
+
units, unsupported = extract_all(root, files)
|
|
100
|
+
if not units:
|
|
101
|
+
print("no functions found", file=err)
|
|
102
|
+
rows = []
|
|
103
|
+
meta = _meta(args, root, files, units, unsupported, "body", "full", None, 0.0, False, 0,
|
|
104
|
+
judge_model(judge))
|
|
105
|
+
use_json = args.json or not (hasattr(out, "isatty") and out.isatty())
|
|
106
|
+
if use_json:
|
|
107
|
+
print(render_json(select_rows(rows, args.above, args.top), meta), file=out)
|
|
108
|
+
else:
|
|
109
|
+
color = hasattr(out, "isatty") and out.isatty()
|
|
110
|
+
print(render_table(rows, meta, args.above, args.top, color), file=out)
|
|
111
|
+
return 0
|
|
112
|
+
index = SymbolIndex(units)
|
|
113
|
+
scores: dict = {}
|
|
114
|
+
stage: dict = {}
|
|
115
|
+
keys: dict = {}
|
|
116
|
+
by_id = {u.id: u for u in units}
|
|
117
|
+
cache = Cache(root, enabled=not args.no_cache)
|
|
118
|
+
cls = "neighborhood"
|
|
119
|
+
coverage = "partial"
|
|
120
|
+
items1 = []
|
|
121
|
+
stats = None
|
|
122
|
+
partial = False
|
|
123
|
+
t0 = time.perf_counter()
|
|
124
|
+
|
|
125
|
+
def prepare(candidates, mode, text_for):
|
|
126
|
+
items = []
|
|
127
|
+
for u in candidates:
|
|
128
|
+
text = text_for(u)
|
|
129
|
+
k = Cache.key(args.question, mode, text)
|
|
130
|
+
cached = cache.get(k)
|
|
131
|
+
if cached is not None:
|
|
132
|
+
scores[u.id] = cached
|
|
133
|
+
stage[u.id] = int(mode[-1])
|
|
134
|
+
continue
|
|
135
|
+
keys[u.id] = k
|
|
136
|
+
items.append(Item(u.id, _payload(u, text)))
|
|
137
|
+
return items
|
|
138
|
+
|
|
139
|
+
def on_result(got):
|
|
140
|
+
for uid, s in got.items():
|
|
141
|
+
scores[uid] = s
|
|
142
|
+
cache.put(keys[uid], s)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
cls = classify(args.question, judge)
|
|
146
|
+
coverage = "partial" if cls == "global" else "full"
|
|
147
|
+
repo_card = make_repo_card(root, files, units)
|
|
148
|
+
|
|
149
|
+
text1 = (lambda u: u.source) if cls == "body" else (lambda u: stage1_text(u, index))
|
|
150
|
+
items1 = prepare(units, f"{cls}:1", text1)
|
|
151
|
+
est_tokens, est_cost = _estimate(items1, args.budget)
|
|
152
|
+
print(f"scanned {len(units):,} functions in {len(files):,} files class: {cls} "
|
|
153
|
+
f"~{est_tokens:,} tokens ~${est_cost:.3f}" + (" (from cache)" if not items1 else ""), file=err)
|
|
154
|
+
if est_cost > CONFIRM_ABOVE_USD and not args.yes:
|
|
155
|
+
if not confirm_cost(est_cost, err):
|
|
156
|
+
return 2
|
|
157
|
+
|
|
158
|
+
t0 = time.perf_counter()
|
|
159
|
+
for it in items1:
|
|
160
|
+
stage[it.id] = 1
|
|
161
|
+
if cls == "body":
|
|
162
|
+
s1, stats = judge.judge_with_reask(items1, args.question, repo_card, above=args.above, on_result=on_result)
|
|
163
|
+
else:
|
|
164
|
+
s1, stats = judge.judge(items1, args.question, repo_card, on_result=on_result)
|
|
165
|
+
scores.update(s1)
|
|
166
|
+
for uid, s in s1.items():
|
|
167
|
+
if uid in keys:
|
|
168
|
+
cache.put(keys[uid], s)
|
|
169
|
+
if cls != "body":
|
|
170
|
+
cands = [by_id[uid] for uid, s in scores.items() if s >= CANDIDATE_FLOOR]
|
|
171
|
+
cands.sort(key=lambda u: -scores[u.id])
|
|
172
|
+
cands = cands[:CANDIDATE_CAP]
|
|
173
|
+
items2 = prepare(cands, f"{cls}:2", lambda u: stage2_text(u, index))
|
|
174
|
+
for it in items2:
|
|
175
|
+
stage[it.id] = 2
|
|
176
|
+
s2, stats2 = judge.judge_with_reask(items2, args.question, repo_card, above=args.above, on_result=on_result)
|
|
177
|
+
scores.update(s2)
|
|
178
|
+
for uid, s in s2.items():
|
|
179
|
+
if uid in keys:
|
|
180
|
+
cache.put(keys[uid], s)
|
|
181
|
+
stats = stats.merge(stats2)
|
|
182
|
+
except KeyboardInterrupt:
|
|
183
|
+
partial = True
|
|
184
|
+
finally:
|
|
185
|
+
cache.save()
|
|
186
|
+
seconds = time.perf_counter() - t0
|
|
187
|
+
|
|
188
|
+
rows = build_rows(units, scores, stage)
|
|
189
|
+
meta = _meta(args, root, files, units, unsupported, cls, coverage, stats, seconds, partial,
|
|
190
|
+
cache.hits, judge_model(judge))
|
|
191
|
+
if stats and stats.last_error:
|
|
192
|
+
print(f"error: {stats.last_error}", file=err)
|
|
193
|
+
use_json = args.json or not (hasattr(out, "isatty") and out.isatty())
|
|
194
|
+
if use_json:
|
|
195
|
+
print(render_json(select_rows(rows, args.above, args.top), meta), file=out)
|
|
196
|
+
else:
|
|
197
|
+
color = hasattr(out, "isatty") and out.isatty()
|
|
198
|
+
print(render_table(rows, meta, args.above, args.top, color), file=out)
|
|
199
|
+
return 130 if partial else 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def judge_model(judge: Judge) -> str:
|
|
203
|
+
from .judge import MODEL
|
|
204
|
+
return MODEL
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def main(argv=None, out=None, err=None) -> int:
|
|
208
|
+
out = out or sys.stdout
|
|
209
|
+
err = err or sys.stderr
|
|
210
|
+
args = parse_args(argv)
|
|
211
|
+
if args.version:
|
|
212
|
+
print(f"every {__version__}", file=out)
|
|
213
|
+
return 0
|
|
214
|
+
key = os.environ.get("TYPESAFE_API_KEY")
|
|
215
|
+
if not key:
|
|
216
|
+
print(NO_KEY_MESSAGE.format(url=WAITLIST_URL), file=err, end="")
|
|
217
|
+
return 2
|
|
218
|
+
judge = Judge(key, depth=args.depth, budget=args.budget)
|
|
219
|
+
if args.selftest:
|
|
220
|
+
from .selftest import run_selftest
|
|
221
|
+
return run_selftest(judge, out)
|
|
222
|
+
if not args.question or not args.path:
|
|
223
|
+
print("usage: every \"<question>\" <path>", file=err)
|
|
224
|
+
return 2
|
|
225
|
+
try:
|
|
226
|
+
return run_query(args, judge, out, err)
|
|
227
|
+
except KeyboardInterrupt:
|
|
228
|
+
print("interrupted", file=err)
|
|
229
|
+
return 130
|
|
230
|
+
except Unauthorized:
|
|
231
|
+
print("API key rejected (HTTP 401/403). Check TYPESAFE_API_KEY.", file=err)
|
|
232
|
+
return 2
|
|
233
|
+
except Exception as e:
|
|
234
|
+
print(f"error: {type(e).__name__}: {e}", file=err)
|
|
235
|
+
return 2
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
raise SystemExit(main())
|
every/discover.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""List the source files worth judging and decide how each will be split."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
# v1 languages with a function-level node map in extract.py.
|
|
10
|
+
PARSED = {
|
|
11
|
+
".py": "python",
|
|
12
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
13
|
+
".ts": "typescript", ".tsx": "tsx",
|
|
14
|
+
".go": "go",
|
|
15
|
+
".java": "java",
|
|
16
|
+
".rs": "rust",
|
|
17
|
+
".cs": "csharp",
|
|
18
|
+
".rb": "ruby",
|
|
19
|
+
".php": "php",
|
|
20
|
+
}
|
|
21
|
+
# Recognised source, but judged as 150-line chunks in v1.
|
|
22
|
+
CHUNKED = {
|
|
23
|
+
".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".hpp": "cpp",
|
|
24
|
+
".kt": "kotlin", ".swift": "swift", ".scala": "scala",
|
|
25
|
+
".sh": "shell", ".bash": "shell", ".sql": "sql", ".m": "objc",
|
|
26
|
+
".lua": "lua", ".pl": "perl", ".ex": "elixir", ".exs": "elixir",
|
|
27
|
+
".erl": "erlang", ".dart": "dart",
|
|
28
|
+
}
|
|
29
|
+
EXCLUDE_DIRS = {
|
|
30
|
+
"node_modules", "vendor", "dist", "build", ".git", ".every", "__pycache__",
|
|
31
|
+
".venv", "venv", "target", "bin", "obj", ".tox", ".mypy_cache", ".pytest_cache",
|
|
32
|
+
}
|
|
33
|
+
MAX_BYTES = 1_000_000
|
|
34
|
+
GENERATED_SUFFIXES = (".lock", ".map", ".pb.go", "_pb2.py", ".generated.ts", ".g.cs")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SourceFile:
|
|
39
|
+
path: str # relative to root, forward slashes
|
|
40
|
+
lang: str
|
|
41
|
+
parsed: bool # True -> tree-sitter units; False -> chunk fallback
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def language_for(path: str) -> tuple[str, bool] | None:
|
|
45
|
+
ext = os.path.splitext(path)[1].lower()
|
|
46
|
+
if ext in PARSED:
|
|
47
|
+
return PARSED[ext], True
|
|
48
|
+
if ext in CHUNKED:
|
|
49
|
+
return CHUNKED[ext], False
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_generated(path: str) -> bool:
|
|
54
|
+
name = os.path.basename(path).lower()
|
|
55
|
+
return ".min." in name or name.endswith(GENERATED_SUFFIXES)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _git_files(root: str) -> list[str] | None:
|
|
59
|
+
try:
|
|
60
|
+
r = subprocess.run(["git", "-C", root, "ls-files", "-z"], capture_output=True, timeout=60)
|
|
61
|
+
except (OSError, subprocess.SubprocessError):
|
|
62
|
+
return None
|
|
63
|
+
if r.returncode != 0:
|
|
64
|
+
return None
|
|
65
|
+
return [p for p in r.stdout.decode("utf-8", "replace").split("\0") if p]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _walk_files(root: str) -> list[str]:
|
|
69
|
+
out = []
|
|
70
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
71
|
+
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
|
|
72
|
+
for f in filenames:
|
|
73
|
+
rel = os.path.relpath(os.path.join(dirpath, f), root)
|
|
74
|
+
out.append(rel.replace(os.sep, "/"))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def discover(root: str) -> list[SourceFile]:
|
|
79
|
+
files = _git_files(root) if os.path.exists(os.path.join(root, ".git")) else None
|
|
80
|
+
if files is None:
|
|
81
|
+
files = _walk_files(root)
|
|
82
|
+
out: list[SourceFile] = []
|
|
83
|
+
for rel in sorted(files):
|
|
84
|
+
if any(part in EXCLUDE_DIRS for part in rel.split("/")[:-1]):
|
|
85
|
+
continue
|
|
86
|
+
info = language_for(rel)
|
|
87
|
+
if info is None or is_generated(rel):
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
size = os.path.getsize(os.path.join(root, rel))
|
|
91
|
+
except OSError:
|
|
92
|
+
continue # tracked in git but missing on disk
|
|
93
|
+
if size == 0 or size > MAX_BYTES:
|
|
94
|
+
continue
|
|
95
|
+
out.append(SourceFile(rel, info[0], info[1]))
|
|
96
|
+
return out
|
every/extract.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Split source files into judgeable units with tree-sitter; chunk what we can't parse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from tree_sitter_language_pack import get_parser
|
|
8
|
+
|
|
9
|
+
from .symbols import callee_names
|
|
10
|
+
from .units import Unit
|
|
11
|
+
|
|
12
|
+
# Function-like node types per v1 language. JS/TS assigned functions are handled separately.
|
|
13
|
+
FUNC_NODES = {
|
|
14
|
+
"python": {"function_definition"},
|
|
15
|
+
"javascript": {"function_declaration", "generator_function_declaration", "method_definition"},
|
|
16
|
+
"typescript": {"function_declaration", "generator_function_declaration", "method_definition"},
|
|
17
|
+
"tsx": {"function_declaration", "generator_function_declaration", "method_definition"},
|
|
18
|
+
"go": {"function_declaration", "method_declaration"},
|
|
19
|
+
"java": {"method_declaration", "constructor_declaration"},
|
|
20
|
+
"rust": {"function_item"},
|
|
21
|
+
"csharp": {"method_declaration", "constructor_declaration"},
|
|
22
|
+
"ruby": {"method", "singleton_method"},
|
|
23
|
+
"php": {"function_definition", "method_declaration"},
|
|
24
|
+
}
|
|
25
|
+
JS_LANGS = {"javascript", "typescript", "tsx"}
|
|
26
|
+
ASSIGNED_FUNC_VALUES = {"arrow_function", "function_expression", "function"}
|
|
27
|
+
CHUNK_LINES = 150
|
|
28
|
+
MAX_UNIT_CHARS = 3450 # ~1,500 tokens at the measured ratio
|
|
29
|
+
|
|
30
|
+
_parsers: dict = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parser_for(lang: str):
|
|
34
|
+
if lang not in _parsers:
|
|
35
|
+
_parsers[lang] = get_parser(lang)
|
|
36
|
+
return _parsers[lang]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _text(node, src: bytes) -> str:
|
|
40
|
+
return src[node.start_byte:node.end_byte].decode("utf-8", "replace")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _lines(node) -> tuple[int, int]:
|
|
44
|
+
start = node.start_point[0] + 1
|
|
45
|
+
end = node.end_point[0] + 1
|
|
46
|
+
if node.end_point[1] == 0 and end > start: # node ends at a line start: don't count that line
|
|
47
|
+
end -= 1
|
|
48
|
+
return start, end
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _name_of(node, src: bytes) -> str:
|
|
52
|
+
n = node.child_by_field_name("name")
|
|
53
|
+
return _text(n, src) if n is not None else "<anonymous>"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _make_unit(rel: str, lang: str, name: str, node, src: bytes, prefix: str) -> Unit:
|
|
57
|
+
full = f"{prefix}.{name}" if prefix else name
|
|
58
|
+
start, end = _lines(node)
|
|
59
|
+
text = _text(node, src)
|
|
60
|
+
if len(text) > MAX_UNIT_CHARS:
|
|
61
|
+
text = text[:MAX_UNIT_CHARS] + "\n[truncated]"
|
|
62
|
+
return Unit(id=f"{rel}:{start}:{full}", file=rel, lang=lang, name=full,
|
|
63
|
+
start_line=start, end_line=end, source=text, kind="function",
|
|
64
|
+
calls=callee_names(node, lang, src))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _walk(node, rel: str, lang: str, src: bytes, prefix: str, out: list) -> None:
|
|
68
|
+
"""Preorder walk with an explicit stack (deep nesting must not hit the recursion limit)."""
|
|
69
|
+
stack = [(node, prefix)]
|
|
70
|
+
while stack:
|
|
71
|
+
n, pfx = stack.pop()
|
|
72
|
+
unit_name = None
|
|
73
|
+
if n.type in FUNC_NODES[lang]:
|
|
74
|
+
unit_name = _name_of(n, src)
|
|
75
|
+
out.append(_make_unit(rel, lang, unit_name, n, src, pfx))
|
|
76
|
+
elif lang in JS_LANGS and n.type in ("variable_declarator", "pair"):
|
|
77
|
+
value = n.child_by_field_name("value")
|
|
78
|
+
if value is not None and value.type in ASSIGNED_FUNC_VALUES:
|
|
79
|
+
key = n.child_by_field_name("name") or n.child_by_field_name("key")
|
|
80
|
+
unit_name = _text(key, src) if key is not None else "<anonymous>"
|
|
81
|
+
out.append(_make_unit(rel, lang, unit_name, value, src, pfx))
|
|
82
|
+
child_prefix = pfx
|
|
83
|
+
if unit_name:
|
|
84
|
+
child_prefix = f"{pfx}.{unit_name}" if pfx else unit_name
|
|
85
|
+
for child in reversed(n.children): # reversed so the first child is popped first
|
|
86
|
+
stack.append((child, child_prefix))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def chunk_file(rel: str, lang: str, source: str, size: int = CHUNK_LINES) -> list[Unit]:
|
|
90
|
+
lines = source.splitlines()
|
|
91
|
+
out: list[Unit] = []
|
|
92
|
+
for i in range(0, len(lines), size):
|
|
93
|
+
start, end = i + 1, min(i + size, len(lines))
|
|
94
|
+
text = "\n".join(lines[i:end])
|
|
95
|
+
if len(text) > MAX_UNIT_CHARS:
|
|
96
|
+
text = text[:MAX_UNIT_CHARS] + "\n[truncated]"
|
|
97
|
+
out.append(Unit(id=f"{rel}:{start}:chunk", file=rel, lang=lang, name=f"chunk@{start}",
|
|
98
|
+
start_line=start, end_line=end, source=text, kind="chunk"))
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def extract_file(rel: str, lang: str, source: str) -> list[Unit]:
|
|
103
|
+
if lang not in FUNC_NODES:
|
|
104
|
+
return chunk_file(rel, lang, source)
|
|
105
|
+
src = source.encode("utf-8")
|
|
106
|
+
tree = parser_for(lang).parse(src)
|
|
107
|
+
out: list[Unit] = []
|
|
108
|
+
_walk(tree.root_node, rel, lang, src, "", out)
|
|
109
|
+
if not out and tree.root_node.has_error:
|
|
110
|
+
return chunk_file(rel, lang, source)
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_all(root: str, files) -> tuple[list[Unit], int]:
|
|
115
|
+
"""Units for all files; second value counts files that were chunked or unreadable."""
|
|
116
|
+
units: list[Unit] = []
|
|
117
|
+
unsupported = 0
|
|
118
|
+
for f in files:
|
|
119
|
+
try:
|
|
120
|
+
with open(os.path.join(root, f.path), encoding="utf-8", errors="replace") as fh:
|
|
121
|
+
source = fh.read()
|
|
122
|
+
except OSError:
|
|
123
|
+
unsupported += 1
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
if f.parsed and f.lang in FUNC_NODES:
|
|
127
|
+
got = extract_file(f.path, f.lang, source)
|
|
128
|
+
if got and got[0].kind == "chunk":
|
|
129
|
+
unsupported += 1
|
|
130
|
+
else:
|
|
131
|
+
got = chunk_file(f.path, f.lang, source)
|
|
132
|
+
unsupported += 1
|
|
133
|
+
except Exception:
|
|
134
|
+
unsupported += 1
|
|
135
|
+
continue
|
|
136
|
+
units.extend(got)
|
|
137
|
+
return units, unsupported
|