codereview-loop 0.13.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.
codereview/agentsmd.py ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """codereview AGENTS.md ingestion — project guidance as reviewer
3
+ context (Epic L1).
4
+
5
+ At run start the reviewer panel gets the repository's own
6
+ guidance: `AGENTS.md` at the repo root and nested along the
7
+ changed paths, loaded as context ALONGSIDE (never instead of)
8
+ `.codereview.toml`. Every target repo in the audience already
9
+ carries the file; before L1 the gate never read it, so project
10
+ rules a human reviewer would apply were invisible to the panel.
11
+
12
+ Trust model — the file is UNTRUSTED content, on exactly the
13
+ diff's terms:
14
+
15
+ * It passes through the review path's own redaction
16
+ (`core.redact_secrets`) before anything is sent to a
17
+ provider.
18
+ * It is delimited in the prompt as data (see core's
19
+ `_AGENTS_CONTEXT_HEADER`), never as instructions to the
20
+ harness: text inside it addressed to the reviewer or the
21
+ harness is ignored by construction of the prompt, and —
22
+ the load-bearing half — nothing in this module or in the
23
+ prompt path lets the file touch a gate rule, the panel
24
+ composition, or a record field. The TOML policy remains
25
+ the only machine-enforced layer; AGENTS.md content can
26
+ inform a finding, never change an outcome.
27
+ * A `## Code Review Rules` section is surfaced at the head
28
+ of the attached context as advisory review rules the panel
29
+ may cite in findings (quoting the rule's text in the
30
+ finding's evidence; the evidence_quote itself still comes
31
+ from the diff, as always).
32
+
33
+ Bounds and provenance:
34
+
35
+ * Size-capped per file (AGENTS_PER_FILE_CAP) and in total
36
+ (AGENTS_TOTAL_CAP); truncation is marked inside the text
37
+ and recorded per file — nothing is silently dropped: the
38
+ collector returns a ledger entry for every discovered
39
+ file, including skipped ones, with the reason.
40
+ * Provenance is written to the run record by run_review:
41
+ per ingested file, its repo-relative path and the sha256
42
+ of the file's raw bytes as read from disk (pre-redaction,
43
+ pre-cap — the hash identifies the source file, not the
44
+ excerpt).
45
+
46
+ Ordering — nearest-wins: discovered files are ordered from
47
+ the repo root toward the changed code (directory depth, then
48
+ path). Where two files give conflicting guidance about a
49
+ changed file, the AGENTS.md NEAREST that file's directory
50
+ governs; the prompt states the rule and the order it is
51
+ shown. Discovery itself is deterministic: the root plus the
52
+ ancestor directories of the patch's changed paths — never a
53
+ whole-tree scan, so a file off every changed path is not
54
+ ingested.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import hashlib
60
+ import os
61
+ import re
62
+
63
+ from .core import (_archived_rule, _is_within, redact_secrets,
64
+ split_patch_sections)
65
+
66
+ AGENTS_FILENAME = "AGENTS.md"
67
+ AGENTS_PER_FILE_CAP = 16000
68
+ AGENTS_TOTAL_CAP = 32000
69
+ # Below this much remaining budget a further file is skipped
70
+ # outright instead of sending a sliver (same floor the file
71
+ # context collector uses).
72
+ _MIN_BUDGET_ROOM = 1000
73
+
74
+ _RULES_HEADING = re.compile(r"^##\s+Code Review Rules\s*$",
75
+ re.IGNORECASE)
76
+ _SECTION_END = re.compile(r"^#{1,2}\s")
77
+
78
+
79
+ def extract_rules_section(text):
80
+ """The body of the `## Code Review Rules` section, or None.
81
+
82
+ The section runs from its heading to the next level-1 or
83
+ level-2 heading (or end of file); deeper sub-headings stay
84
+ part of the section. The heading line itself is not part
85
+ of the returned body — the prompt attributes the quote to
86
+ its file explicitly.
87
+ """
88
+ lines = text.splitlines()
89
+ start = None
90
+ for i, line in enumerate(lines):
91
+ if _RULES_HEADING.match(line.strip()):
92
+ start = i + 1
93
+ break
94
+ if start is None:
95
+ return None
96
+ body = []
97
+ for line in lines[start:]:
98
+ if _SECTION_END.match(line.strip()):
99
+ break
100
+ body.append(line)
101
+ out = "\n".join(body).strip("\n")
102
+ return out if out.strip() else None
103
+
104
+
105
+ def _ancestor_dirs(changed_paths):
106
+ """Repo-relative directories to look for AGENTS.md in: the
107
+ root plus every ancestor directory of every changed path."""
108
+ dirs = {""}
109
+ for path in changed_paths:
110
+ parts = [p for p in str(path).split("/")
111
+ if p not in ("", ".")]
112
+ for i in range(len(parts)):
113
+ dirs.add("/".join(parts[:i]))
114
+ return dirs
115
+
116
+
117
+ def discover_agents_files(repo, changed_paths):
118
+ """The deterministic discovery list: repo-relative paths of
119
+ the AGENTS.md files that exist at the root or nested along
120
+ `changed_paths`, ordered nearest-wins (root first, then by
121
+ directory depth, then path — later in the list is nearer
122
+ the changed code and governs on conflict)."""
123
+ found = []
124
+ for d in _ancestor_dirs(changed_paths):
125
+ rel = f"{d}/{AGENTS_FILENAME}" if d else AGENTS_FILENAME
126
+ if os.path.isfile(os.path.join(repo, *rel.split("/"))):
127
+ found.append(rel)
128
+ found.sort(key=lambda p: (p.count("/"), p))
129
+ return found
130
+
131
+
132
+ def collect_agents_context(repo, patch,
133
+ per_file_cap=AGENTS_PER_FILE_CAP,
134
+ total_cap=AGENTS_TOTAL_CAP):
135
+ """Collect the AGENTS.md context for a review of `patch`.
136
+
137
+ Returns (payload, entries). `entries` is the ledger — one
138
+ dict per discovered file stating its provenance (sha256 of
139
+ the raw bytes), size, how much was sent, and any truncation
140
+ or skip reason. `payload` is None when no file was ingested
141
+ (no AGENTS.md on any changed path, or every discovered file
142
+ skipped): the caller then passes nothing and the run is
143
+ byte-identical to the pre-L1 behaviour. Otherwise payload
144
+ is {"files": [ledger entries of the ingested files, in
145
+ prompt order], "total_chars": chars of file text sent,
146
+ "truncated": bool, "prompt_text": the delimited section
147
+ run_review appends to each reviewer prompt}.
148
+
149
+ Assembly under the total cap: the surfaced `## Code Review
150
+ Rules` blocks are the highest-value part, so they are
151
+ charged against the budget first; full file texts fill the
152
+ remaining budget in discovery order. A file whose text no
153
+ longer fits is sent truncated to the remaining room, or —
154
+ below the room floor — not sent at all; either way its
155
+ ledger entry records truncated="budget" and the file still
156
+ appears in `entries` (and its rules block, if any, has
157
+ already been charged).
158
+ """
159
+ changed = [name for name, _text in split_patch_sections(patch)
160
+ if name and name != "unknown"]
161
+ entries = []
162
+ candidates = [] # (entry, redacted_text, rules_body)
163
+ for rel in discover_agents_files(repo, changed):
164
+ entry = {"path": rel, "sha256": None, "chars": 0,
165
+ "sent_chars": 0, "truncated": None,
166
+ "skipped": None, "rules_section": False}
167
+ entries.append(entry)
168
+ if _archived_rule(rel):
169
+ entry["skipped"] = "archived"
170
+ continue
171
+ full = os.path.realpath(
172
+ os.path.join(repo, *rel.split("/")))
173
+ root = os.path.realpath(repo)
174
+ if not _is_within(full, root):
175
+ entry["skipped"] = "outside_repo"
176
+ continue
177
+ try:
178
+ with open(full, "rb") as fh:
179
+ raw = fh.read()
180
+ except OSError:
181
+ entry["skipped"] = "unreadable"
182
+ continue
183
+ entry["sha256"] = hashlib.sha256(raw).hexdigest()
184
+ text = raw.decode("utf-8", "replace")
185
+ if "\x00" in text:
186
+ entry["skipped"] = "binary"
187
+ continue
188
+ entry["chars"] = len(text)
189
+ clean = redact_secrets(text)
190
+ rules = extract_rules_section(clean)
191
+ entry["rules_section"] = rules is not None
192
+ candidates.append((entry, clean, rules))
193
+
194
+ used = 0
195
+ contributed = set() # ids of entries that reached the prompt
196
+ rules_blocks = []
197
+ for entry, _clean, rules in candidates:
198
+ if not rules:
199
+ continue
200
+ block = f"From {entry['path']}:\n{rules}"
201
+ room = total_cap - used
202
+ if len(block) > room:
203
+ if room < _MIN_BUDGET_ROOM:
204
+ entry["truncated"] = "budget"
205
+ continue
206
+ block = (rules[:room] + "\n[codereview: advisory "
207
+ "rules truncated — showing the first "
208
+ f"{room} chars]")
209
+ entry["truncated"] = "budget"
210
+ rules_blocks.append(block)
211
+ contributed.add(id(entry))
212
+ used += len(block) + 2 # joiner slack
213
+
214
+ file_blocks = []
215
+ for entry, clean, _rules in candidates:
216
+ sent = clean
217
+ if len(sent) > per_file_cap:
218
+ sent = sent[:per_file_cap] + (
219
+ "\n[codereview: AGENTS.md context truncated — "
220
+ f"showing the first {per_file_cap} of "
221
+ f"{len(clean)} chars]")
222
+ entry["truncated"] = "file_cap"
223
+ room = total_cap - used
224
+ if len(sent) > room:
225
+ if room < _MIN_BUDGET_ROOM:
226
+ entry["truncated"] = "budget"
227
+ entry["sent_chars"] = 0
228
+ continue
229
+ sent = clean[:room] + (
230
+ "\n[codereview: AGENTS.md context truncated — "
231
+ f"showing the first {room} of {len(clean)} "
232
+ "chars]")
233
+ entry["truncated"] = "budget"
234
+ used += len(sent) + 2 # joiner slack
235
+ file_blocks.append(f"===== {entry['path']} =====\n{sent}")
236
+ entry["sent_chars"] = len(sent)
237
+ contributed.add(id(entry))
238
+
239
+ if not contributed:
240
+ return None, entries
241
+ parts = []
242
+ if rules_blocks:
243
+ parts.append(
244
+ "Advisory review rules (## Code Review Rules "
245
+ "sections, quoted verbatim):\n\n"
246
+ + "\n\n".join(rules_blocks))
247
+ parts.extend(file_blocks)
248
+ # Provenance covers every file that reached the prompt —
249
+ # via its full text, its rules block, or both — in
250
+ # discovery (nearest-wins) order.
251
+ ingested = [e for e, _c, _r in candidates
252
+ if id(e) in contributed]
253
+ payload = {
254
+ "files": ingested,
255
+ "total_chars": sum(e["sent_chars"] for e in ingested),
256
+ "truncated": any(e["truncated"] for e in entries),
257
+ "prompt_text": "\n\n".join(parts),
258
+ }
259
+ return payload, entries