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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- 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)
|