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 ADDED
@@ -0,0 +1,3 @@
1
+ """every - ask a yes/no question of every function in a codebase."""
2
+
3
+ __version__ = "0.1.0"
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