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.
- rncrn8/__init__.py +7 -0
- rncrn8/__main__.py +6 -0
- rncrn8/adapters/__init__.py +69 -0
- rncrn8/adapters/base.py +19 -0
- rncrn8/adapters/diff.py +478 -0
- rncrn8/adapters/document.py +40 -0
- rncrn8/adapters/usages.py +302 -0
- rncrn8/adapters/video.py +246 -0
- rncrn8/cli.py +1108 -0
- rncrn8/config.py +174 -0
- rncrn8/connect.py +64 -0
- rncrn8/doctor.py +206 -0
- rncrn8/engine/__init__.py +37 -0
- rncrn8/engine/beta.py +54 -0
- rncrn8/engine/calibration.py +146 -0
- rncrn8/engine/decision.py +50 -0
- rncrn8/engine/evidence.py +188 -0
- rncrn8/engine/learning.py +34 -0
- rncrn8/engine/metrics.py +75 -0
- rncrn8/engine/panel.py +103 -0
- rncrn8/followup.py +110 -0
- rncrn8/mcp_server.py +623 -0
- rncrn8/memory/__init__.py +12 -0
- rncrn8/memory/legacy.py +80 -0
- rncrn8/memory/lock.py +67 -0
- rncrn8/memory/models.py +101 -0
- rncrn8/memory/store.py +342 -0
- rncrn8/models/__init__.py +37 -0
- rncrn8/models/base.py +57 -0
- rncrn8/models/claude.py +220 -0
- rncrn8/models/dryrun.py +27 -0
- rncrn8/onboard.py +931 -0
- rncrn8/outcome_source.py +55 -0
- rncrn8/packet.py +188 -0
- rncrn8/persona/__init__.py +5 -0
- rncrn8/persona/loader.py +96 -0
- rncrn8/persona/models.py +49 -0
- rncrn8/persona/templates.py +278 -0
- rncrn8/replay.py +188 -0
- rncrn8/review/__init__.py +3 -0
- rncrn8/review/debate.py +198 -0
- rncrn8/review/digest.py +84 -0
- rncrn8/review/prompts.py +250 -0
- rncrn8/review/report.py +194 -0
- rncrn8/review/runner.py +1000 -0
- rncrn8/review/summary.py +101 -0
- rncrn8/service.py +472 -0
- rncrn8/sources/__init__.py +26 -0
- rncrn8/sources/extract.py +231 -0
- rncrn8/sources/history.py +137 -0
- rncrn8/sources/notes.py +53 -0
- rncrn8/sources/project.py +105 -0
- rncrn8/sources/questionnaire.py +27 -0
- rncrn8/sources/research.py +96 -0
- rncrn8/track.py +135 -0
- rncrn8-0.2.0.dist-info/METADATA +533 -0
- rncrn8-0.2.0.dist-info/RECORD +61 -0
- rncrn8-0.2.0.dist-info/WHEEL +4 -0
- rncrn8-0.2.0.dist-info/entry_points.txt +2 -0
- rncrn8-0.2.0.dist-info/licenses/LICENSE +202 -0
- rncrn8-0.2.0.dist-info/licenses/NOTICE +4 -0
rncrn8/__init__.py
ADDED
rncrn8/__main__.py
ADDED
|
@@ -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
|
+
]
|
rncrn8/adapters/base.py
ADDED
|
@@ -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: ...
|
rncrn8/adapters/diff.py
ADDED
|
@@ -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"
|