rncrn8 0.2.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 (61) hide show
  1. rncrn8/__init__.py +7 -0
  2. rncrn8/__main__.py +6 -0
  3. rncrn8/adapters/__init__.py +69 -0
  4. rncrn8/adapters/base.py +19 -0
  5. rncrn8/adapters/diff.py +478 -0
  6. rncrn8/adapters/document.py +40 -0
  7. rncrn8/adapters/usages.py +302 -0
  8. rncrn8/adapters/video.py +246 -0
  9. rncrn8/cli.py +1108 -0
  10. rncrn8/config.py +174 -0
  11. rncrn8/connect.py +64 -0
  12. rncrn8/doctor.py +206 -0
  13. rncrn8/engine/__init__.py +37 -0
  14. rncrn8/engine/beta.py +54 -0
  15. rncrn8/engine/calibration.py +146 -0
  16. rncrn8/engine/decision.py +50 -0
  17. rncrn8/engine/evidence.py +188 -0
  18. rncrn8/engine/learning.py +34 -0
  19. rncrn8/engine/metrics.py +75 -0
  20. rncrn8/engine/panel.py +103 -0
  21. rncrn8/followup.py +110 -0
  22. rncrn8/mcp_server.py +623 -0
  23. rncrn8/memory/__init__.py +12 -0
  24. rncrn8/memory/legacy.py +80 -0
  25. rncrn8/memory/lock.py +67 -0
  26. rncrn8/memory/models.py +101 -0
  27. rncrn8/memory/store.py +342 -0
  28. rncrn8/models/__init__.py +37 -0
  29. rncrn8/models/base.py +57 -0
  30. rncrn8/models/claude.py +220 -0
  31. rncrn8/models/dryrun.py +27 -0
  32. rncrn8/onboard.py +931 -0
  33. rncrn8/outcome_source.py +55 -0
  34. rncrn8/packet.py +188 -0
  35. rncrn8/persona/__init__.py +5 -0
  36. rncrn8/persona/loader.py +96 -0
  37. rncrn8/persona/models.py +49 -0
  38. rncrn8/persona/templates.py +278 -0
  39. rncrn8/replay.py +188 -0
  40. rncrn8/review/__init__.py +3 -0
  41. rncrn8/review/debate.py +198 -0
  42. rncrn8/review/digest.py +84 -0
  43. rncrn8/review/prompts.py +250 -0
  44. rncrn8/review/report.py +194 -0
  45. rncrn8/review/runner.py +1000 -0
  46. rncrn8/review/summary.py +101 -0
  47. rncrn8/service.py +472 -0
  48. rncrn8/sources/__init__.py +26 -0
  49. rncrn8/sources/extract.py +231 -0
  50. rncrn8/sources/history.py +137 -0
  51. rncrn8/sources/notes.py +53 -0
  52. rncrn8/sources/project.py +105 -0
  53. rncrn8/sources/questionnaire.py +27 -0
  54. rncrn8/sources/research.py +96 -0
  55. rncrn8/track.py +135 -0
  56. rncrn8-0.2.0.dist-info/METADATA +533 -0
  57. rncrn8-0.2.0.dist-info/RECORD +61 -0
  58. rncrn8-0.2.0.dist-info/WHEEL +4 -0
  59. rncrn8-0.2.0.dist-info/entry_points.txt +2 -0
  60. rncrn8-0.2.0.dist-info/licenses/LICENSE +202 -0
  61. rncrn8-0.2.0.dist-info/licenses/NOTICE +4 -0
rncrn8/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """rncrn8: personas with separated, provenance-tagged memory.
2
+
3
+ The core is deliberately target-agnostic. Nothing in this package may know
4
+ what a video frame or a diff line is; those live in adapters.
5
+ """
6
+
7
+ __version__ = "0.2.0"
rncrn8/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m rncrn8`: the command line, run by a known interpreter. The assistant connection starts the
2
+ server this way, so it does not depend on what is on PATH when the assistant launches it."""
3
+
4
+ from .cli import main
5
+
6
+ main(prog_name="rncrn8")
@@ -0,0 +1,69 @@
1
+ """Target adapters: turn something to review into a ReviewPacket.
2
+
3
+ This is the only place that knows what a frame, a hunk or a subtitle is. The
4
+ core receives packets and never looks past them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from ..packet import KINDS, ReviewPacket
12
+ from .base import Adapter, AdapterError
13
+ from .diff import DiffAdapter, git_diff_packet
14
+ from .document import DocumentAdapter, IdeaAdapter, text_packet
15
+ from .video import VideoAdapter
16
+
17
+ ADAPTERS: tuple[Adapter, ...] = (VideoAdapter(), DiffAdapter(), DocumentAdapter(), IdeaAdapter())
18
+ """Order is the tie-break: when several adapters match a path and no kind is given, the first wins.
19
+ DocumentAdapter precedes IdeaAdapter on purpose, so a text file is a document unless --kind idea says otherwise."""
20
+
21
+
22
+ def detect(target: Path, kind: str | None = None) -> Adapter:
23
+ """Pick the adapter for a path. An explicit kind wins when more than one would do."""
24
+ target = Path(target)
25
+ if not target.exists():
26
+ raise FileNotFoundError(f"no such target: {target}")
27
+ candidates = [a for a in ADAPTERS if a.matches(target)]
28
+ if kind:
29
+ for a in candidates:
30
+ if a.kind == kind:
31
+ return a
32
+ if candidates:
33
+ return candidates[0]
34
+ if target.is_dir():
35
+ raise AdapterError(
36
+ f"{target} is a directory with no transcript or frame files; for code pass --diff <range>, for a video folder add transcript.srt or frames.json"
37
+ )
38
+ raise AdapterError(f"no adapter for '{target.suffix}'; pass a text file, a .patch, an .srt/.vtt, a video folder, or a packet JSON")
39
+
40
+
41
+ def build_packet(target: Path, kind: str | None = None, title: str | None = None, **options) -> ReviewPacket:
42
+ """Load a packet JSON directly, or run the matching adapter."""
43
+ target = Path(target)
44
+ if target.is_file() and target.suffix.lower() == ".json":
45
+ from ..packet import load_packet
46
+
47
+ return load_packet(target, kind=kind, title=title)
48
+ adapter = detect(target, kind)
49
+ packet = adapter.build(target, **options)
50
+ if kind and kind in KINDS:
51
+ packet = packet.model_copy(update={"kind": kind})
52
+ if title:
53
+ packet = packet.model_copy(update={"title": title})
54
+ return packet
55
+
56
+
57
+ __all__ = [
58
+ "ADAPTERS",
59
+ "Adapter",
60
+ "AdapterError",
61
+ "DiffAdapter",
62
+ "DocumentAdapter",
63
+ "IdeaAdapter",
64
+ "VideoAdapter",
65
+ "build_packet",
66
+ "detect",
67
+ "git_diff_packet",
68
+ "text_packet",
69
+ ]
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol
5
+
6
+ from ..packet import ReviewPacket
7
+
8
+
9
+ class AdapterError(ValueError):
10
+ """The target cannot be turned into a packet; the message says what to pass instead."""
11
+
12
+
13
+ class Adapter(Protocol):
14
+ name: str
15
+ kind: str
16
+
17
+ def matches(self, target: Path) -> bool: ...
18
+
19
+ def build(self, target: Path, **options) -> ReviewPacket: ...
@@ -0,0 +1,478 @@
1
+ """Git diffs: each hunk is a section whose ref is the new-side line range, lines numbered so a persona cites exactly.
2
+
3
+ Also used by follow-ups: a later diff's old-side ranges tell which reviewed lines were touched.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import subprocess
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from ..memory.legacy import LEGACY_WORKSPACE_DIRS
14
+ from ..memory.store import WORKSPACE_DIR
15
+ from ..packet import ReviewPacket, Section
16
+ from .base import AdapterError
17
+
18
+ _HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$")
19
+ _DIFF_GIT = re.compile(r'^diff --git (?:"a/((?:[^"\\]|\\.)*)"|a/(.*?)) (?:"b/((?:[^"\\]|\\.)*)"|b/(.*))$')
20
+
21
+
22
+ def _unquote(path: str) -> str:
23
+ """Git quotes paths with special characters and writes bytes as octal escapes; undo that."""
24
+ path = path.strip()
25
+ if len(path) >= 2 and path[0] == '"' and path[-1] == '"':
26
+ inner = path[1:-1]
27
+ try:
28
+ return inner.encode("latin-1", "backslashreplace").decode("unicode_escape").encode("latin-1").decode("utf-8")
29
+ except (UnicodeDecodeError, UnicodeEncodeError):
30
+ return inner
31
+ return path
32
+
33
+
34
+ def _strip_prefix(path: str, prefix: str) -> str:
35
+ path = _unquote(path)
36
+ return path[2:] if path.startswith(prefix) else path
37
+
38
+
39
+ @dataclass
40
+ class Hunk:
41
+ path: str
42
+ old_path: str
43
+ old_start: int
44
+ old_count: int
45
+ new_start: int
46
+ new_count: int
47
+ header: str
48
+ lines: list[str] = field(default_factory=list)
49
+
50
+ @property
51
+ def new_end(self) -> int:
52
+ return self.new_start + max(self.new_count, 1) - 1
53
+
54
+ @property
55
+ def old_end(self) -> int:
56
+ return self.old_start + max(self.old_count, 1) - 1
57
+
58
+ @property
59
+ def ref(self) -> str:
60
+ if self.new_count <= 1:
61
+ return f"{self.path}:{self.new_start}"
62
+ return f"{self.path}:{self.new_start}-{self.new_end}"
63
+
64
+ def changed_old_lines(self) -> list[tuple[int, int]]:
65
+ """Old-side line ranges this hunk actually changes: runs of removed lines, and for a pure
66
+ insertion the two old lines on either side of the insertion point. Context lines never count."""
67
+ ranges: set[tuple[int, int]] = set()
68
+ n = self.old_start + (1 if self.old_count == 0 else 0) # with no old lines, old_start is the line before the insertion
69
+ run_start: int | None = None
70
+ for raw in self.lines:
71
+ tag = raw[:1]
72
+ if tag == "-":
73
+ if run_start is None:
74
+ run_start = n
75
+ n += 1
76
+ elif tag == "+":
77
+ if run_start is None:
78
+ ranges.add((max(n - 1, 1), n))
79
+ else:
80
+ if run_start is not None:
81
+ ranges.add((run_start, n - 1))
82
+ run_start = None
83
+ if tag == " ":
84
+ n += 1
85
+ if run_start is not None:
86
+ ranges.add((run_start, n - 1))
87
+ return sorted(ranges)
88
+
89
+ def annotated(self) -> str:
90
+ """Context and added lines carry their new-side number; removed lines carry a dash."""
91
+ out = []
92
+ n = self.new_start
93
+ for raw in self.lines:
94
+ tag, text = (raw[0], raw[1:]) if raw else (" ", "")
95
+ if tag == "+":
96
+ out.append(f"{n:>5}+ {text}")
97
+ n += 1
98
+ elif tag == "-":
99
+ out.append(f"{'':>5}- {text}")
100
+ elif tag == "\\":
101
+ out.append(f"{'':>5} {raw}")
102
+ else:
103
+ out.append(f"{n:>5} {text}")
104
+ n += 1
105
+ return "\n".join(out)
106
+
107
+
108
+ @dataclass
109
+ class FileDiff:
110
+ path: str
111
+ old_path: str
112
+ status: str = "modified" # modified | added | deleted | renamed | binary
113
+ hunks: list[Hunk] = field(default_factory=list)
114
+ mode_change: bool = False
115
+
116
+
117
+ def parse_unified_diff(text: str) -> list[FileDiff]:
118
+ files: list[FileDiff] = []
119
+ current: FileDiff | None = None
120
+ hunk: Hunk | None = None
121
+ old_path = new_path = None
122
+ for line in text.splitlines():
123
+ if line.startswith("diff --git "):
124
+ m = _DIFF_GIT.match(line)
125
+ if m:
126
+ old_path = _unquote(f'"{m.group(1)}"') if m.group(1) is not None else m.group(2)
127
+ new_path = _unquote(f'"{m.group(3)}"') if m.group(3) is not None else m.group(4)
128
+ else:
129
+ old_path, new_path = None, None
130
+ current = FileDiff(path=new_path or "?", old_path=old_path or new_path or "?")
131
+ files.append(current)
132
+ hunk = None
133
+ continue
134
+ if current is None:
135
+ continue
136
+ if line.startswith("new file mode"):
137
+ current.status = "added"
138
+ elif line.startswith("deleted file mode"):
139
+ current.status = "deleted"
140
+ elif line.startswith("rename from "):
141
+ current.old_path = line[len("rename from ") :].strip()
142
+ current.status = "renamed"
143
+ elif line.startswith("rename to "):
144
+ current.path = line[len("rename to ") :].strip()
145
+ elif line.startswith("Binary files") or line.startswith("GIT binary patch"):
146
+ current.status = "binary"
147
+ elif line.startswith("--- "):
148
+ p = line[4:].strip()
149
+ if p != "/dev/null":
150
+ current.old_path = _strip_prefix(p, "a/")
151
+ elif line.startswith("+++ "):
152
+ p = line[4:].strip()
153
+ if p != "/dev/null":
154
+ current.path = _strip_prefix(p, "b/")
155
+ elif line.startswith("old mode ") or line.startswith("new mode "):
156
+ current.mode_change = True
157
+ elif line.startswith("@@"):
158
+ m = _HUNK.match(line)
159
+ if not m:
160
+ continue
161
+ hunk = Hunk(
162
+ path=current.path,
163
+ old_path=current.old_path,
164
+ old_start=int(m.group(1)),
165
+ old_count=int(m.group(2)) if m.group(2) is not None else 1,
166
+ new_start=int(m.group(3)),
167
+ new_count=int(m.group(4)) if m.group(4) is not None else 1,
168
+ header=m.group(5).strip(),
169
+ )
170
+ current.hunks.append(hunk)
171
+ elif hunk is not None and (line[:1] in ("+", "-", " ", "\\")):
172
+ hunk.lines.append(line)
173
+ for f in files:
174
+ for h in f.hunks:
175
+ h.path = f.path
176
+ h.old_path = f.old_path
177
+ return files
178
+
179
+
180
+ LOCKFILES = {"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "uv.lock", "cargo.lock", "composer.lock", "gemfile.lock", "go.sum", "pipfile.lock", "bun.lock"}
181
+ DATA_SUFFIXES = {".txt", ".json", ".jsonl", ".ndjson", ".csv", ".tsv", ".xml", ".html", ".htm", ".yaml", ".yml", ".svg", ".map", ".sql", ".srt", ".vtt"}
182
+ DATA_DIRS = {"data", "datasets", "dataset", "fixtures", "testdata", "corpus", "corpora", "resources", "assets", "vendor", "third_party", "third-party", "generated", "dist", "build", "downloads"}
183
+ CODE_SUFFIXES = {".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rs", ".java", ".kt", ".swift", ".rb", ".php", ".cs", ".scala", ".dart", ".c", ".cc", ".cpp", ".h", ".hpp", ".sh", ".ps1"}
184
+ WORKSPACE_PREFIXES = tuple(f"{d}/" for d in (WORKSPACE_DIR, *LEGACY_WORKSPACE_DIRS))
185
+ """The panel's own workspace, under its current name and the one from before the rename."""
186
+ LARGE_DATA_CHARS = 30_000
187
+ """Changes to data files shown in full, per file and across the whole diff."""
188
+ LARGE_FILE_CHARS = 100_000
189
+ HEAD_LINES, TAIL_LINES = 25, 10
190
+ HEAD_TAIL_FILES = 5
191
+ SHOWN_LINE_CHARS = 200
192
+ """Large files shown as their first and last lines; past this many, the rest are listed one line each."""
193
+ NOT_SHOWN_REF = "files not shown"
194
+
195
+
196
+ def _is_data(path: str) -> bool:
197
+ """Data by its suffix, or anything but code inside a data folder such as data/ or fixtures/."""
198
+ p = Path(path)
199
+ name, suffix = p.name.lower(), p.suffix.lower()
200
+ if suffix in DATA_SUFFIXES or name.endswith((".min.js", ".min.css")):
201
+ return True
202
+ return bool({d.lower() for d in p.parts[:-1]} & DATA_DIRS) and suffix not in CODE_SUFFIXES
203
+
204
+
205
+ def _change_size(f: "FileDiff") -> int:
206
+ return sum(len(l) for h in f.hunks for l in h.lines)
207
+
208
+
209
+ def _too_large_to_show(f: "FileDiff") -> str | None:
210
+ """Why a file's changes are shown as head and tail only, or None to show them in full. Lockfiles are
211
+ never read by a reviewer; a large data file, or any very large file such as a downloaded text saved as
212
+ markdown, would fill every persona's call with content nobody reviews line by line."""
213
+ if Path(f.path).name.lower() in LOCKFILES:
214
+ return "a lockfile"
215
+ size = _change_size(f)
216
+ if _is_data(f.path) and size > LARGE_DATA_CHARS:
217
+ return "a large data file"
218
+ if size > LARGE_FILE_CHARS:
219
+ return "a very large file"
220
+ return None
221
+
222
+
223
+ def _head_and_tail(f: "FileDiff", why: str, added: int, removed: int) -> Section:
224
+ lines = [l if len(l) <= SHOWN_LINE_CHARS else l[:SHOWN_LINE_CHARS] + " [...]" for l in "\n\n".join(h.annotated() for h in f.hunks).splitlines()]
225
+ hidden = len(lines) - HEAD_LINES - TAIL_LINES
226
+ shown = lines if hidden <= 0 else [*lines[:HEAD_LINES], f"{'':>7}[... {hidden:,} lines not shown: {why}; the full file is in the repository ...]", *lines[-TAIL_LINES:]]
227
+ status = f"{f.status}, " if f.status != "modified" else ""
228
+ return Section(ref=f.path, title=f"{f.path} ({status}{why}, +{added:,} -{removed:,} lines; first and last lines shown)", text="\n".join(shown))
229
+
230
+
231
+ def packet_from_diff(text: str, title: str, metadata: dict[str, str] | None = None) -> ReviewPacket:
232
+ parsed = parse_unified_diff(text)
233
+ # the panel's own workspace is memory about the project, not the project
234
+ files = [f for f in parsed if not (f.path.startswith(WORKSPACE_PREFIXES) or f.old_path.startswith(WORKSPACE_PREFIXES))]
235
+ workspace = len(parsed) - len(files)
236
+ workspace_dirs = ", ".join(sorted({p.split("/", 1)[0] + "/" for f in parsed for p in (f.path, f.old_path) if p.startswith(WORKSPACE_PREFIXES)}))
237
+ if parsed and not files:
238
+ raise AdapterError(f"the diff only changes {workspace_dirs}, the panel's own workspace: nothing of the project to review")
239
+ reasons = {i: why for i, f in enumerate(files) if (why := _too_large_to_show(f))}
240
+ # many medium data files add up: they share one budget, and the largest are shown as head and tail first
241
+ data = sorted((i for i, f in enumerate(files) if i not in reasons and f.hunks and _is_data(f.path)), key=lambda i: _change_size(files[i]), reverse=True)
242
+ data_total = sum(_change_size(files[i]) for i in data)
243
+ for i in data:
244
+ if data_total <= LARGE_DATA_CHARS:
245
+ break
246
+ reasons[i] = "one of many data files"
247
+ data_total -= _change_size(files[i])
248
+ sections: list[Section] = []
249
+ insertions = deletions = 0
250
+ head_tail = 0
251
+ listed: list[str] = []
252
+ for index, f in enumerate(files):
253
+ if f.status == "binary":
254
+ sections.append(Section(ref=f.path, title=f"{f.path} (binary)", text="(binary file changed)"))
255
+ continue
256
+ if f.status == "renamed" and not f.hunks:
257
+ sections.append(Section(ref=f.path, title=f"{f.old_path} -> {f.path}", text="(renamed, no content change)"))
258
+ continue
259
+ if f.mode_change and not f.hunks:
260
+ sections.append(Section(ref=f.path, title=f"{f.path} (mode change)", text="(mode change only)"))
261
+ continue
262
+ added = sum(1 for h in f.hunks for l in h.lines if l.startswith("+"))
263
+ removed = sum(1 for h in f.hunks for l in h.lines if l.startswith("-"))
264
+ insertions += added
265
+ deletions += removed
266
+ why = reasons.get(index)
267
+ if why:
268
+ # a lockfile, or one of many data files, is a line in a list; a large file shows its first and last
269
+ # lines, where sources and versions are stated, up to a few files
270
+ if why in ("a large data file", "a very large file") and head_tail < HEAD_TAIL_FILES:
271
+ sections.append(_head_and_tail(f, why, added, removed))
272
+ head_tail += 1
273
+ else:
274
+ first = next((l[1:] for h in f.hunks for l in h.lines if l[:1] in "+-" and l[1:].strip()), "")
275
+ status = f"{f.status}, " if f.status != "modified" else ""
276
+ listed.append(f"{f.path} ({status}{why}, +{added:,} -{removed:,}): {' '.join(first.split())[:120]}")
277
+ continue
278
+ for h in f.hunks:
279
+ status = "" if f.status == "modified" else f" ({f.status})"
280
+ sections.append(Section(ref=h.ref, title=f"{f.path}{status} @@ {h.header}".rstrip(" @"), text=h.annotated()))
281
+ if listed:
282
+ sections.append(Section(ref=NOT_SHOWN_REF, title=f"Files not shown in full ({len(listed)}): path, what it is, lines added and removed, first changed line", text="\n".join(listed)))
283
+ if not sections:
284
+ raise AdapterError("the diff is empty: nothing to review")
285
+ meta = {"files": str(len(files)), "insertions": str(insertions), "deletions": str(deletions)}
286
+ summarized = head_tail + len(listed)
287
+ if summarized:
288
+ meta["summarized"] = str(summarized)
289
+ if workspace:
290
+ meta["workspace_files_left_out"] = str(workspace)
291
+ meta.update(metadata or {})
292
+ summary = f"{len(files)} file(s) changed, +{insertions} -{deletions}. Line numbers are the new side; a dash marks a removed line."
293
+ if head_tail:
294
+ summary += f" {head_tail} large file(s) are shown as their first and last lines only."
295
+ if listed:
296
+ summary += f" {len(listed)} more file(s), lockfiles and data, are listed under '{NOT_SHOWN_REF}' without their content."
297
+ if workspace:
298
+ summary += f" {workspace} file(s) under {workspace_dirs}, the panel's own workspace, are left out."
299
+ return ReviewPacket(kind="code", title=title, summary=summary, sections=sections, metadata=meta)
300
+
301
+
302
+ _SHA = re.compile(r"^[0-9a-f]{40}$")
303
+
304
+
305
+ def _git(repo: Path, *args: str) -> str:
306
+ try:
307
+ # git writes UTF-8; never decode it with the console's locale (cp1252 on Windows turns "·" into "·")
308
+ # core.quotePath=false keeps non-ASCII file names as UTF-8 instead of octal escapes in quotes
309
+ res = subprocess.run(["git", "-c", "core.quotePath=false", "-C", str(repo), *args], capture_output=True, encoding="utf-8", errors="replace", check=False)
310
+ except FileNotFoundError as exc:
311
+ raise AdapterError("git is not installed or not on PATH") from exc
312
+ if res.returncode != 0:
313
+ raise AdapterError(f"git {' '.join(args)} failed: {res.stderr.strip() or res.stdout.strip()}")
314
+ return res.stdout
315
+
316
+
317
+ def resolve_commit(repo: Path, rev: str) -> str:
318
+ """A revision from a user or a file becomes a full commit SHA, or an error. `--end-of-options`
319
+ keeps a value like `--output=x` from being read as an option; the SHA is the only thing git
320
+ diff is ever given afterwards."""
321
+ if rev.startswith("-") or not rev.strip():
322
+ raise AdapterError(f"'{rev}' is not a revision")
323
+ sha = _git(repo, "rev-parse", "--verify", "--quiet", "--end-of-options", f"{rev}^{{commit}}").strip()
324
+ if not _SHA.match(sha):
325
+ raise AdapterError(f"'{rev}' did not resolve to a commit")
326
+ return sha
327
+
328
+
329
+ def git_diff_packet(repo: Path, spec: str, title: str | None = None, paths: list[str] | tuple[str, ...] | None = None) -> ReviewPacket:
330
+ """spec: 'A..B' (two commits), 'staged' (index vs HEAD), 'worktree' (unstaged), or a single
331
+ revision R (working tree against R; 'HEAD' is your uncommitted changes). `paths` limits the diff to
332
+ those files or folders."""
333
+ repo = Path(repo)
334
+ head = resolve_commit(repo, "HEAD")
335
+ meta: dict[str, str] = {"repo": str(repo.resolve()), "head": head, "target": repo.resolve().name}
336
+ clean = [p.replace("\\", "/").strip() for p in (paths or []) if p and p.strip()]
337
+ pathspec = ["--", *clean] if clean else []
338
+ if ".." in spec:
339
+ base, top = spec.split("..", 1)
340
+ top = top or "HEAD"
341
+ base_sha = resolve_commit(repo, base)
342
+ top_sha = resolve_commit(repo, top)
343
+ text = _git(repo, "diff", base_sha, top_sha, *pathspec)
344
+ meta.update({"base": base_sha, "reviewed": top_sha, "spec": spec, "committed": "true"})
345
+ label = f"{base}..{top}"
346
+ elif spec in ("staged", "--staged", "--cached"):
347
+ text = _git(repo, "diff", "--cached", *pathspec)
348
+ meta.update({"base": head, "spec": "staged", "committed": "false"})
349
+ label = "staged changes"
350
+ elif spec in ("worktree", "unstaged"):
351
+ text = _git(repo, "diff", *pathspec)
352
+ meta.update({"base": head, "spec": "worktree", "committed": "false"})
353
+ label = "unstaged changes"
354
+ else:
355
+ base_sha = resolve_commit(repo, spec)
356
+ text = _git(repo, "diff", base_sha, *pathspec)
357
+ meta.update({"base": base_sha, "spec": spec, "committed": "false"})
358
+ label = f"working tree vs {spec}"
359
+ if clean:
360
+ meta["paths"] = ", ".join(clean)
361
+ label += f" ({', '.join(clean)})"
362
+ if not text.strip():
363
+ raise AdapterError(f"git diff for '{spec}'" + (f" in {', '.join(clean)}" if clean else "") + " is empty: nothing to review")
364
+ packet = packet_from_diff(text, title or f"{repo.resolve().name}: {label}", meta)
365
+ add_usage_context(packet, repo, text, ("rev", meta["reviewed"]) if meta.get("committed") == "true" else ("index", None) if meta["spec"] == "staged" else ("worktree", None))
366
+ return packet
367
+
368
+
369
+ def add_usage_context(packet: ReviewPacket, repo: Path, text: str, version: tuple[str, str | None]) -> None:
370
+ """Append where the changed code is used elsewhere, unchanged, read at the reviewed version: a commit,
371
+ the index, or the working tree. Files the panel only sees listed or cut short are no source of names."""
372
+ from .usages import context_sections
373
+
374
+ files = [f for f in parse_unified_diff(text)
375
+ if not (f.path.startswith(WORKSPACE_PREFIXES) or f.old_path.startswith(WORKSPACE_PREFIXES)) and not _too_large_to_show(f) and not _is_data(f.path)]
376
+ sections, _counts = context_sections(repo, files, version[0], version[1])
377
+ if not sections:
378
+ return
379
+ packet.sections.extend(sections)
380
+ packet.metadata["context_sections"] = str(len(sections))
381
+ packet.summary = (packet.summary or "") + (
382
+ f" {len(sections)} section(s) titled 'unchanged' are not part of the change: they show code elsewhere that uses what the "
383
+ "change modifies or removes, so you can tell whether the change is reached and fits how it is called.")
384
+
385
+
386
+ class DiffAdapter:
387
+ name = "diff"
388
+ kind = "code"
389
+
390
+ def matches(self, target: Path) -> bool:
391
+ return target.is_file() and target.suffix.lower() in {".diff", ".patch"}
392
+
393
+ def build(self, target: Path, **options) -> ReviewPacket:
394
+ text = target.read_text(encoding="utf-8", errors="replace")
395
+ return packet_from_diff(text, options.get("title") or target.stem, {"source": str(target), "target": target.name})
396
+
397
+
398
+ # --- follow-ups -------------------------------------------------------------------------------------
399
+
400
+ _WHERE = re.compile(r"^\[?(?P<path>(?!t=)[^\s\[\]:=]*[A-Za-z_][^\s\[\]:=]*):(?P<a>\d+)(?:-(?P<b>\d+))?\]?$")
401
+
402
+
403
+ def parse_where(where: str) -> tuple[str, int, int] | None:
404
+ """'src/app.py:40-52' -> ('src/app.py', 40, 52); 'src/app.py:40' -> (path, 40, 40).
405
+ Section refs like '§3' and timestamps like 't=00:03' are not locations in a file."""
406
+ m = _WHERE.match(where.strip())
407
+ if not m:
408
+ return None
409
+ a = int(m.group("a"))
410
+ b = int(m.group("b")) if m.group("b") else a
411
+ return m.group("path"), min(a, b), max(a, b)
412
+
413
+
414
+ def touched_ranges(text: str) -> dict[str, list[tuple[int, int]]]:
415
+ """Per old-side path, the old-side lines a diff actually changes: removed lines, and for a pure
416
+ insertion the two old lines on either side of the insertion point. Context lines never count."""
417
+ out: dict[str, list[tuple[int, int]]] = {}
418
+ for f in parse_unified_diff(text):
419
+ for h in f.hunks:
420
+ out.setdefault(f.old_path, []).extend(h.changed_old_lines())
421
+ return out
422
+
423
+
424
+ _NAMED_LINES = re.compile(r"\blines?\s+(\d+(?:\s*[-\u2013]\s*\d+)?(?:\s*(?:,|and)\s*\d+(?:\s*[-\u2013]\s*\d+)?)*)", re.I)
425
+
426
+
427
+ def cited_ranges(where: str) -> tuple[str, list[tuple[int, int]]] | None:
428
+ """The file and line ranges a finding cites. 'path:a-b' gives one range. '[path:a-b] lines 212,
429
+ 1732, 1757-1758' gives the named lines, which narrow the section; named lines outside it are ignored.
430
+ None when the location is not a file and lines."""
431
+ text = " ".join(where.split())
432
+ head, _, rest = text.partition(" ")
433
+ parsed = parse_where(head) if rest else parse_where(text)
434
+ if parsed is None:
435
+ return None
436
+ path, a, b = parsed
437
+ ranges: list[tuple[int, int]] = []
438
+ m = _NAMED_LINES.search(rest)
439
+ if m:
440
+ for part in re.split(r"\s*(?:,|and)\s*", m.group(1)):
441
+ nums = [int(n) for n in re.findall(r"\d+", part)]
442
+ if not nums:
443
+ continue
444
+ lo, hi = min(nums), max(nums)
445
+ if a <= lo and hi <= b:
446
+ ranges.append((lo, hi))
447
+ return path, ranges or [(a, b)]
448
+
449
+
450
+ def ranges_touched(path: str, ranges: list[tuple[int, int]], touched: dict[str, list[tuple[int, int]]]) -> bool:
451
+ return any(lo <= b and a <= hi for a, b in ranges for lo, hi in touched.get(path, []))
452
+
453
+
454
+ def lines_touched(where: str, touched: dict[str, list[tuple[int, int]]]) -> bool:
455
+ parsed = parse_where(where)
456
+ if parsed is None:
457
+ return False
458
+ path, a, b = parsed
459
+ for lo, hi in touched.get(path, []):
460
+ if lo <= b and a <= hi:
461
+ return True
462
+ return False
463
+
464
+
465
+ def is_ancestor(repo: Path, ancestor_sha: str, descendant: str) -> bool:
466
+ if not _SHA.match(ancestor_sha):
467
+ raise AdapterError(f"'{ancestor_sha}' is not a full commit SHA")
468
+ descendant_sha = resolve_commit(repo, descendant)
469
+ res = subprocess.run(["git", "-C", str(repo), "merge-base", "--is-ancestor", ancestor_sha, descendant_sha], capture_output=True, check=False)
470
+ return res.returncode == 0
471
+
472
+
473
+ def git_diff_text(repo: Path, base_sha: str, top: str = "HEAD") -> tuple[str, str]:
474
+ """Diff between two commits. `base_sha` must already be a full SHA; `top` is resolved here."""
475
+ if not _SHA.match(base_sha):
476
+ raise AdapterError(f"'{base_sha}' is not a full commit SHA")
477
+ top_sha = resolve_commit(repo, top)
478
+ return _git(repo, "diff", base_sha, top_sha), top_sha
@@ -0,0 +1,40 @@
1
+ """Documents and pitches: text or markdown, split on headings into citable sections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..packet import TEXT_SUFFIXES, ReviewPacket, packet_from_text
8
+ from .base import AdapterError
9
+
10
+
11
+ def text_packet(text: str, title: str, kind: str = "idea") -> ReviewPacket:
12
+ """Inline text, a pitch by default. Its target identity is its title."""
13
+ packet = packet_from_text(text, title, kind)
14
+ packet.metadata["target"] = title
15
+ return packet
16
+
17
+
18
+ class DocumentAdapter:
19
+ name = "document"
20
+ kind = "document"
21
+
22
+ def matches(self, target: Path) -> bool:
23
+ return target.is_file() and (target.suffix.lower() in TEXT_SUFFIXES or target.suffix == "")
24
+
25
+ def build(self, target: Path, **options) -> ReviewPacket:
26
+ try:
27
+ text = target.read_text(encoding="utf-8")
28
+ except UnicodeDecodeError as exc:
29
+ raise AdapterError(f"{target} is not a text file") from exc
30
+ packet = packet_from_text(text, options.get("title") or target.stem, self.kind)
31
+ packet.metadata["target"] = target.name # the file, whatever the title says this time
32
+ return packet
33
+
34
+
35
+ class IdeaAdapter(DocumentAdapter):
36
+ """A pitch is a document whose readers are investors, users and skeptics. Same split, kind 'idea'.
37
+ It matches the same files as DocumentAdapter; ADAPTERS order makes it win only when --kind idea is given."""
38
+
39
+ name = "idea"
40
+ kind = "idea"