graphite-code 0.3.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 (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/hookshim.py ADDED
@@ -0,0 +1,86 @@
1
+ """Pure rendering of graphite's git-hook trampolines.
2
+
3
+ Deliberately has NO filesystem or git side effects -- installation lives in
4
+ `hookinstall`. Every Windows correctness rule below was taken from aramid's
5
+ `src/aramid/hooks.py`, which had already paid for them; keeping rendering pure
6
+ is what makes them cheaply testable.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ MARKER_START = "# >>> graphite managed >>>"
13
+ MARKER_END = "# <<< graphite managed <<<"
14
+
15
+ # The events that change committed code. `post-checkout` is excluded on
16
+ # purpose: branch switching is frequent and transient, and hooking it makes
17
+ # `git bisect` expensive.
18
+ TRIGGERS: tuple[str, ...] = ("post-commit", "post-merge", "post-rewrite")
19
+
20
+ # The relocated original hook, invoked before graphite's own body. `.local`
21
+ # rather than a graphite-specific suffix so a human reading `.githooks/` can
22
+ # tell at a glance which file is theirs.
23
+ CHAINED_SUFFIX = ".local"
24
+
25
+
26
+ def sh_interpreter_path(interpreter: Path) -> str:
27
+ """`C:\\Python314\\python.exe` -> `/c/Python314/python.exe`.
28
+
29
+ Git-for-Windows' `sh` cannot exec a drive-letter path. A POSIX path is
30
+ returned unchanged.
31
+ """
32
+ resolved = interpreter.resolve()
33
+ drive = resolved.drive.rstrip(":").lower()
34
+ if not drive:
35
+ return resolved.as_posix()
36
+ rest = resolved.as_posix()[len(resolved.drive):].lstrip("/")
37
+ return f"/{drive}/{rest}"
38
+
39
+
40
+ def render_trigger_shim(hook: str, interpreter: Path) -> bytes:
41
+ """The trampoline graphite installs for one of its triggers.
42
+
43
+ Three rules, each load-bearing:
44
+
45
+ * **`if`/`fi`, never `[ -f "$C" ] && { ...; }`.** As a script's *final*
46
+ command the latter exits 1 when `$C` is absent -- the test fails, `&&`
47
+ short-circuits, and that status becomes the script's -- so a trampoline
48
+ with nothing to chain to would block every commit on a fresh clone.
49
+ Measured, and independently reproduced by aramid's agent.
50
+ * **The chain-check is always emitted**, whether or not a `.local` exists.
51
+ Baking that state into the bytes is what breaks idempotent regeneration.
52
+ * **Never a bare `python`.** This machine exposes several interpreters to
53
+ hook `sh`, including the WindowsApps store stub, so the absolute path is
54
+ baked in with `py -3` as the only fallback.
55
+ * **`-P` on both arms, always** (graphite#43). `python -m X` puts the CWD at
56
+ `sys.path[0]` and git runs hooks from the top of the working tree, so a
57
+ `graphite.py` or `graphite/` at a managed repo's root otherwise wins over
58
+ the installed package. The redirect and `|| true` below make that hijack
59
+ invisible, and the module-shaped shadow *executes* on the way to its
60
+ `ModuleNotFoundError` -- so erroring out is not a mitigation. Both arms
61
+ need it independently; one flagless arm is the whole way in.
62
+
63
+ All three triggers are `post-*`, where git ignores the exit code, so the
64
+ chained hook's failure is swallowed with `|| true` and the shim ends with
65
+ an explicit `exit 0`: a graph refresh must never fail a developer's commit.
66
+ """
67
+ interp = sh_interpreter_path(interpreter)
68
+ lines = [
69
+ "#!/bin/sh",
70
+ MARKER_START,
71
+ 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
72
+ f'CHAINED="$DIR/{hook}{CHAINED_SUFFIX}"',
73
+ 'if [ -f "$CHAINED" ]; then',
74
+ ' "$CHAINED" "$@" || true',
75
+ "fi",
76
+ f'INTERP="{interp}"',
77
+ 'if [ -x "$INTERP" ]; then',
78
+ ' "$INTERP" -P -m graphite.hook_entry >/dev/null 2>&1 || true',
79
+ "elif command -v py >/dev/null 2>&1; then",
80
+ " py -3 -P -m graphite.hook_entry >/dev/null 2>&1 || true",
81
+ "fi",
82
+ MARKER_END,
83
+ "exit 0",
84
+ "",
85
+ ]
86
+ return "\n".join(lines).encode("utf-8")
@@ -0,0 +1,247 @@
1
+ """Machine-local incident ledger: durable capture of graphite failures.
2
+
3
+ Append-only JSONL, one file per repo (``.graphite/local/incidents.jsonl``,
4
+ the usage-ledger idiom) plus one for the daemon's own state dir. Dedup is a
5
+ READ-time concern: occurrences append freely and ``fold_incidents`` groups
6
+ them by fingerprint. Triage is event-sourced: ``ack``/``resolve`` are
7
+ appended entries, never mutations; a new occurrence strictly newer than a
8
+ resolve reopens the incident. Recording is best-effort by contract — a
9
+ ledger write failure must never break the operation being recorded.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ MAX_LEDGER_BYTES = 5 * 1024 * 1024
22
+ MAX_SUBJECT_CHARS = 512
23
+ MAX_DETAIL_CHARS = 2048
24
+ MAX_LINE_BYTES = 8192
25
+ # "doctor" covers deep-probe failures (#29). It is a class of its own rather
26
+ # than folded into "build" because doctor incidents must not inflate build
27
+ # failure counts -- daemon_health reads those, and a probe that could not reach
28
+ # an MCP server says nothing about whether the graph builds.
29
+ _CLASSES = frozenset({"build", "query", "daemon", "doctor"})
30
+ _LIFECYCLE_KINDS = frozenset({"ack", "resolve"})
31
+ _KINDS = frozenset({"occurrence"}) | _LIFECYCLE_KINDS
32
+ _STATE_ORDER = {"open": 0, "acked": 1, "resolved": 2}
33
+
34
+
35
+ def repo_ledger_dir(root: Path) -> Path:
36
+ from .usage_ledger import local_dir
37
+
38
+ return local_dir(root)
39
+
40
+
41
+ def ledger_path(ledger_dir: Path) -> Path:
42
+ return ledger_dir / "incidents.jsonl"
43
+
44
+
45
+ def rotated_ledger_path(ledger_dir: Path) -> Path:
46
+ return ledger_dir / "incidents.jsonl.1"
47
+
48
+
49
+ def incident_fingerprint(klass: str, code: str, subject: str) -> str:
50
+ digest = hashlib.sha256(f"{klass}|{code}|{subject}".encode("utf-8")).hexdigest()
51
+ return digest[:16]
52
+
53
+
54
+ def _now() -> str:
55
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
56
+
57
+
58
+ def _append(ledger_dir: Path, entry: dict[str, Any]) -> None:
59
+ path = ledger_path(ledger_dir)
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ if path.exists() and path.stat().st_size > MAX_LEDGER_BYTES:
62
+ os.replace(path, rotated_ledger_path(ledger_dir))
63
+ line = json.dumps(entry, ensure_ascii=False)
64
+ overshoot = len(line.encode("utf-8")) - MAX_LINE_BYTES
65
+ if overshoot > 0:
66
+ for key in ("detail", "note"):
67
+ if key in entry:
68
+ text = str(entry[key])
69
+ entry = {**entry, key: text[: max(0, len(text) - overshoot)]}
70
+ line = json.dumps(entry, ensure_ascii=False)
71
+ break
72
+ with open(path, "a", encoding="utf-8") as handle:
73
+ handle.write(line + "\n")
74
+
75
+
76
+ def record_incident(ledger_dir: Path, *, klass: str, code: str, subject: str, detail: str) -> None:
77
+ """Append one occurrence; never raise."""
78
+ try:
79
+ if klass not in _CLASSES:
80
+ return
81
+ subject = str(subject)[:MAX_SUBJECT_CHARS]
82
+ _append(
83
+ ledger_dir,
84
+ {
85
+ "schema": 1,
86
+ "kind": "occurrence",
87
+ "fingerprint": incident_fingerprint(klass, str(code), subject),
88
+ "ts": _now(),
89
+ "class": klass,
90
+ "code": str(code),
91
+ "subject": subject,
92
+ "detail": str(detail)[:MAX_DETAIL_CHARS],
93
+ },
94
+ )
95
+ except Exception:
96
+ return
97
+
98
+
99
+ def append_lifecycle(ledger_dir: Path, fingerprint: str, kind: str, note: str | None = None) -> bool:
100
+ """Append ack/resolve for a known fingerprint; False (no write) if unknown."""
101
+ if kind not in _LIFECYCLE_KINDS:
102
+ raise ValueError("kind must be 'ack' or 'resolve'")
103
+ entries, _skipped = read_incident_entries(ledger_dir)
104
+ known = any(e.get("kind") == "occurrence" and e.get("fingerprint") == fingerprint for e in entries)
105
+ if not known:
106
+ return False
107
+ entry: dict[str, Any] = {"schema": 1, "kind": kind, "fingerprint": fingerprint, "ts": _now()}
108
+ if note:
109
+ entry["note"] = str(note)[:MAX_DETAIL_CHARS]
110
+ _append(ledger_dir, entry)
111
+ return True
112
+
113
+
114
+ def read_incident_entries(ledger_dir: Path) -> tuple[list[dict[str, Any]], int]:
115
+ """(entries, skipped): rotated generation first; corrupt lines counted; never raises.
116
+
117
+ Each generation file's read is individually guarded: if a file exists
118
+ but cannot be read or decoded (permissions, path is a directory, etc.),
119
+ that generation contributes >=1 to ``skipped`` and reading continues
120
+ with the next generation, instead of the whole read aborting and
121
+ silently presenting as "no incidents".
122
+ """
123
+ entries: list[dict[str, Any]] = []
124
+ skipped = 0
125
+ try:
126
+ for path in (rotated_ledger_path(ledger_dir), ledger_path(ledger_dir)):
127
+ try:
128
+ if not path.exists():
129
+ continue
130
+ text = path.read_text(encoding="utf-8", errors="replace")
131
+ except Exception:
132
+ skipped += 1
133
+ continue
134
+ for line in text.splitlines():
135
+ if not line.strip():
136
+ continue
137
+ if len(line.encode("utf-8", errors="replace")) > MAX_LINE_BYTES:
138
+ skipped += 1
139
+ continue
140
+ try:
141
+ entry = json.loads(line)
142
+ except json.JSONDecodeError:
143
+ skipped += 1
144
+ continue
145
+ if (
146
+ isinstance(entry, dict)
147
+ and isinstance(entry.get("fingerprint"), str)
148
+ and entry.get("kind") in _KINDS
149
+ ):
150
+ entries.append(entry)
151
+ else:
152
+ skipped += 1
153
+ except Exception:
154
+ return entries, skipped
155
+ return entries, skipped
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class IncidentView:
160
+ fingerprint: str
161
+ klass: str
162
+ code: str
163
+ subject: str
164
+ state: str
165
+ first_seen: str
166
+ last_seen: str
167
+ count: int
168
+ last_detail: str
169
+ last_note: str | None
170
+
171
+ def to_json(self) -> dict[str, Any]:
172
+ return {
173
+ "fingerprint": self.fingerprint,
174
+ "class": self.klass,
175
+ "code": self.code,
176
+ "subject": self.subject,
177
+ "state": self.state,
178
+ "first_seen": self.first_seen,
179
+ "last_seen": self.last_seen,
180
+ "count": self.count,
181
+ "last_detail": self.last_detail,
182
+ "last_note": self.last_note,
183
+ }
184
+
185
+
186
+ def fold_incidents(entries: list[dict[str, Any]]) -> list[IncidentView]:
187
+ """Group chronologically-ordered entries into per-fingerprint views."""
188
+ slots: dict[str, dict[str, Any]] = {}
189
+ for e in entries:
190
+ fp = str(e["fingerprint"])
191
+ slot = slots.setdefault(
192
+ fp,
193
+ {
194
+ "first": None,
195
+ "last": "",
196
+ "count": 0,
197
+ "klass": "",
198
+ "code": "",
199
+ "subject": "",
200
+ "detail": "",
201
+ "life": None,
202
+ "life_ts": "",
203
+ "note": None,
204
+ },
205
+ )
206
+ ts = str(e.get("ts", ""))
207
+ if e.get("kind") == "occurrence":
208
+ slot["count"] += 1
209
+ slot["klass"] = str(e.get("class", ""))
210
+ slot["code"] = str(e.get("code", ""))
211
+ slot["subject"] = str(e.get("subject", ""))
212
+ slot["detail"] = str(e.get("detail", ""))
213
+ if slot["first"] is None:
214
+ slot["first"] = ts
215
+ slot["last"] = ts
216
+ else:
217
+ slot["life"] = e["kind"]
218
+ slot["life_ts"] = ts
219
+ if e.get("note") is not None:
220
+ slot["note"] = str(e["note"])
221
+ views: list[IncidentView] = []
222
+ for fp, s in slots.items():
223
+ if s["count"] == 0:
224
+ continue
225
+ if s["life"] is None:
226
+ state = "open"
227
+ elif s["life"] == "resolve":
228
+ state = "open" if s["last"] > s["life_ts"] else "resolved"
229
+ else:
230
+ state = "acked"
231
+ views.append(
232
+ IncidentView(
233
+ fingerprint=fp,
234
+ klass=s["klass"],
235
+ code=s["code"],
236
+ subject=s["subject"],
237
+ state=state,
238
+ first_seen=s["first"] or "",
239
+ last_seen=s["last"],
240
+ count=s["count"],
241
+ last_detail=s["detail"],
242
+ last_note=s["note"],
243
+ )
244
+ )
245
+ views.sort(key=lambda v: v.last_seen, reverse=True)
246
+ views.sort(key=lambda v: _STATE_ORDER[v.state])
247
+ return views
graphite/ingest.py ADDED
@@ -0,0 +1,279 @@
1
+ """Repository ingestion: discover, classify, and hash files safely."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from itertools import islice
6
+ from dataclasses import dataclass
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import Callable, Iterable
9
+
10
+ from .cache import file_hash
11
+ from .config import Config
12
+ from .git import GitError, GitRunner
13
+
14
+
15
+ MAX_GIT_FILE_RECORDS = 100_000
16
+
17
+
18
+ class IngestError(RuntimeError):
19
+ """Raised when repository files cannot be enumerated safely."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class FileEntry:
24
+ rel_path: str # posix-style relative path
25
+ abs_path: Path
26
+ language: str | None
27
+ size: int
28
+ content_hash: str
29
+
30
+
31
+ # Extension → language classification.
32
+ LANGUAGE_BY_EXT: dict[str, str] = {
33
+ ".ts": "typescript",
34
+ ".tsx": "tsx",
35
+ ".js": "javascript",
36
+ ".jsx": "jsx",
37
+ ".mjs": "javascript",
38
+ ".cjs": "javascript",
39
+ ".py": "python",
40
+ ".go": "go",
41
+ ".rs": "rust",
42
+ ".json": "json",
43
+ ".md": "markdown",
44
+ ".mdx": "markdown",
45
+ ".yml": "yaml",
46
+ ".yaml": "yaml",
47
+ ".css": "css",
48
+ ".scss": "scss",
49
+ ".html": "html",
50
+ ".sql": "sql",
51
+ }
52
+
53
+ # Directories and file patterns to skip entirely.
54
+ SKIP_DIRS: frozenset[str] = frozenset({
55
+ "node_modules", ".git", ".next", ".wrangler", ".open-next", "dist", "build",
56
+ "out", "coverage", ".cache", ".claude", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__", "graph-out", "graphify-out", "tools", "vendor", ".venv", "venv",
57
+ "target", # Rust/Maven build output
58
+ })
59
+
60
+ SKIP_SUFFIXES: frozenset[str] = frozenset({
61
+ ".lock", ".log", ".min.js", ".min.css", ".map", ".svg", ".png", ".jpg",
62
+ ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".mp4",
63
+ ".webm", ".ogg", ".mp3", ".wav", ".pdf", ".zip", ".tar", ".gz",
64
+ })
65
+
66
+ # Binary file detection threshold: if a chunk contains this ratio of null bytes, treat as binary.
67
+ _BINARY_NULL_RATIO = 0.001
68
+
69
+
70
+ def _is_binary(path: Path) -> bool:
71
+ try:
72
+ with open(path, "rb") as f:
73
+ chunk = f.read(8192)
74
+ except OSError:
75
+ return True
76
+ if not chunk:
77
+ return False
78
+ if b"\x00" in chunk:
79
+ return True
80
+ return chunk.count(b"\x00") / len(chunk) > _BINARY_NULL_RATIO
81
+
82
+
83
+ def _classify_language(rel_path: str) -> str | None:
84
+ lower = rel_path.lower()
85
+ # package.json is json, tsconfig.json is json, etc.
86
+ ext = Path(lower).suffix
87
+ if ext in LANGUAGE_BY_EXT:
88
+ return LANGUAGE_BY_EXT[ext]
89
+ # Shebang detection for extensionless scripts.
90
+ if ext == "":
91
+ return None
92
+ return None
93
+
94
+
95
+ def _has_component_prefix(parts: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
96
+ if len(parts) < len(prefix):
97
+ return False
98
+ return all(
99
+ os.path.normcase(part) == os.path.normcase(expected)
100
+ for part, expected in zip(parts, prefix)
101
+ )
102
+
103
+
104
+ def _dynamic_exclusions(root: Path, cfg: Config) -> tuple[tuple[str, ...], ...]:
105
+ exclusions: set[tuple[str, ...]] = set()
106
+ for configured in (cfg.output_dir, cfg.cache_dir):
107
+ try:
108
+ relative = configured.resolve().relative_to(root)
109
+ except (OSError, ValueError):
110
+ continue
111
+ if relative != Path("."):
112
+ exclusions.add(relative.parts)
113
+ return tuple(sorted(exclusions))
114
+
115
+
116
+ def _should_skip(
117
+ rel_path: str,
118
+ cfg: Config,
119
+ dynamic_exclusions: tuple[tuple[str, ...], ...] = (),
120
+ ) -> bool:
121
+ parts = Path(rel_path).parts
122
+ if any(part in SKIP_DIRS for part in parts):
123
+ return True
124
+ if any(_has_component_prefix(parts, prefix) for prefix in dynamic_exclusions):
125
+ return True
126
+ lower = rel_path.lower()
127
+ if any(lower.endswith(s) for s in SKIP_SUFFIXES):
128
+ return True
129
+ if not cfg.include_dotfiles:
130
+ if any(part.startswith(".") for part in parts):
131
+ return True
132
+ return False
133
+
134
+
135
+ def _resolve_contained_path(root: Path, candidate: Path) -> Path | None:
136
+ """Resolve *candidate* and return it only when strictly below *root*."""
137
+ try:
138
+ resolved = candidate.resolve()
139
+ relative = resolved.relative_to(root)
140
+ except (OSError, ValueError):
141
+ return None
142
+ if relative == Path("."):
143
+ return None
144
+ return resolved
145
+
146
+
147
+ def _normalize_git_path(root: Path, value: str) -> str | None:
148
+ posix_path = PurePosixPath(value)
149
+ if not value or value == "." or posix_path.is_absolute() or ".." in posix_path.parts:
150
+ return None
151
+ native_path = Path(value)
152
+ if native_path.is_absolute():
153
+ return None
154
+ resolved = _resolve_contained_path(root, root / native_path)
155
+ if resolved is None:
156
+ return None
157
+ return native_path.as_posix()
158
+
159
+
160
+ def _git_ls_files(
161
+ root: Path,
162
+ *,
163
+ max_files: int | None = None,
164
+ is_eligible: Callable[[str], bool] | None = None,
165
+ ) -> list[str] | None:
166
+ """Return bounded Git files, or None only when *root* is not a Git repository."""
167
+ git_dir = root / ".git"
168
+ if not os.path.lexists(git_dir):
169
+ if any(os.path.lexists(ancestor / ".git") for ancestor in root.parents):
170
+ raise IngestError("nested Git roots are unsupported")
171
+ return None
172
+ try:
173
+ result = GitRunner(root).run(
174
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
175
+ timeout_seconds=30,
176
+ )
177
+ except GitError as exc:
178
+ raise IngestError("unable to enumerate Git repository safely") from exc
179
+ if result.returncode != 0:
180
+ raise IngestError("unable to enumerate Git repository safely")
181
+ try:
182
+ output = result.stdout.decode("utf-8")
183
+ except UnicodeDecodeError as exc:
184
+ raise IngestError("unable to enumerate Git repository safely") from exc
185
+ if not output:
186
+ return []
187
+ if not output.endswith("\x00"):
188
+ raise IngestError("unable to enumerate Git repository safely")
189
+
190
+ selected: list[str] = []
191
+ start = 0
192
+ record_count = 0
193
+ while start < len(output) and (
194
+ max_files is None or len(selected) < max_files
195
+ ):
196
+ end = output.find("\x00", start)
197
+ if end < 0:
198
+ raise IngestError("unable to enumerate Git repository safely")
199
+ record_count += 1
200
+ if record_count > MAX_GIT_FILE_RECORDS:
201
+ raise IngestError("unable to enumerate Git repository safely")
202
+ normalized = _normalize_git_path(root, output[start:end])
203
+ if normalized is None:
204
+ raise IngestError("unable to enumerate Git repository safely")
205
+ if is_eligible is None or is_eligible(normalized):
206
+ selected.append(normalized)
207
+ start = end + 1
208
+ return selected
209
+
210
+
211
+ def _walk_files(
212
+ root: Path,
213
+ cfg: Config,
214
+ dynamic_exclusions: tuple[tuple[str, ...], ...],
215
+ ) -> Iterable[str]:
216
+ """Fallback filesystem walk respecting SKIP_DIRS."""
217
+ for dirpath, dirnames, filenames in os.walk(root):
218
+ # Prune skip dirs in-place.
219
+ current_parts = Path(dirpath).relative_to(root).parts
220
+ dirnames[:] = [
221
+ name
222
+ for name in dirnames
223
+ if name not in SKIP_DIRS
224
+ and not any(
225
+ _has_component_prefix((*current_parts, name), prefix)
226
+ for prefix in dynamic_exclusions
227
+ )
228
+ ]
229
+ for filename in filenames:
230
+ full = Path(dirpath) / filename
231
+ rel = full.relative_to(root).as_posix()
232
+ if _should_skip(rel, cfg, dynamic_exclusions):
233
+ continue
234
+ yield rel
235
+
236
+
237
+ def collect_files(root: Path, cfg: Config) -> list[FileEntry]:
238
+ """Collect files under root, bounded by cfg.max_files as an ingestion cap."""
239
+ root = root.resolve()
240
+ dynamic_exclusions = _dynamic_exclusions(root, cfg)
241
+ tracked = _git_ls_files(
242
+ root,
243
+ max_files=cfg.max_files,
244
+ is_eligible=lambda path: not _should_skip(path, cfg, dynamic_exclusions),
245
+ )
246
+ if tracked is not None:
247
+ rel_paths = tracked
248
+ else:
249
+ walked = _walk_files(root, cfg, dynamic_exclusions)
250
+ rel_paths = list(
251
+ islice(walked, cfg.max_files)
252
+ if cfg.max_files is not None
253
+ else walked
254
+ )
255
+
256
+ entries: list[FileEntry] = []
257
+ for rel in rel_paths:
258
+ abs_path = _resolve_contained_path(root, root / Path(rel))
259
+ if abs_path is None:
260
+ continue
261
+ try:
262
+ size = abs_path.stat().st_size
263
+ except OSError:
264
+ continue
265
+ if size > cfg.max_file_size:
266
+ continue
267
+ if _is_binary(abs_path):
268
+ continue
269
+ entries.append(
270
+ FileEntry(
271
+ rel_path=rel,
272
+ abs_path=abs_path,
273
+ language=_classify_language(rel),
274
+ size=size,
275
+ content_hash=file_hash(abs_path),
276
+ )
277
+ )
278
+
279
+ return sorted(entries, key=lambda e: e.rel_path)