open-code-review-toolkit 0.1.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 (38) hide show
  1. ocr_toolkit/__init__.py +1 -0
  2. ocr_toolkit/_version.py +24 -0
  3. ocr_toolkit/cli.py +56 -0
  4. ocr_toolkit/common/__init__.py +1 -0
  5. ocr_toolkit/common/language.py +60 -0
  6. ocr_toolkit/common/markdown.py +214 -0
  7. ocr_toolkit/common/redaction.py +324 -0
  8. ocr_toolkit/config_writer.py +106 -0
  9. ocr_toolkit/configure.py +194 -0
  10. ocr_toolkit/context/__init__.py +1 -0
  11. ocr_toolkit/context/__main__.py +8 -0
  12. ocr_toolkit/context/ansible.py +561 -0
  13. ocr_toolkit/context/categorize.py +162 -0
  14. ocr_toolkit/context/instructions.py +269 -0
  15. ocr_toolkit/context/manifests.py +459 -0
  16. ocr_toolkit/context/planner.py +227 -0
  17. ocr_toolkit/context/render.py +955 -0
  18. ocr_toolkit/context/repo.py +672 -0
  19. ocr_toolkit/context/settings.py +97 -0
  20. ocr_toolkit/mcp_config.py +257 -0
  21. ocr_toolkit/posting/__init__.py +1 -0
  22. ocr_toolkit/posting/__main__.py +8 -0
  23. ocr_toolkit/posting/comments.py +87 -0
  24. ocr_toolkit/posting/formatting.py +755 -0
  25. ocr_toolkit/posting/gitlab.py +853 -0
  26. ocr_toolkit/posting/markers.py +284 -0
  27. ocr_toolkit/posting/payloads.py +141 -0
  28. ocr_toolkit/posting/result.py +116 -0
  29. ocr_toolkit/posting/settings.py +181 -0
  30. ocr_toolkit/posting/snapshot.py +468 -0
  31. ocr_toolkit/posting/workflow.py +873 -0
  32. ocr_toolkit/preflight.py +395 -0
  33. ocr_toolkit/py.typed +1 -0
  34. open_code_review_toolkit-0.1.0.dist-info/METADATA +283 -0
  35. open_code_review_toolkit-0.1.0.dist-info/RECORD +38 -0
  36. open_code_review_toolkit-0.1.0.dist-info/WHEEL +4 -0
  37. open_code_review_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  38. open_code_review_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,672 @@
1
+ """Repository, filesystem, command, and glob helpers for OCR context generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import heapq
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from collections.abc import Iterable, Sequence
13
+ from dataclasses import dataclass
14
+ from functools import cache
15
+ from pathlib import Path
16
+
17
+ from ocr_toolkit.context.settings import (
18
+ DEFAULT_MAX_FILE_BYTES,
19
+ MAX_BACKGROUND_SECTION_ITEMS,
20
+ inline_code,
21
+ )
22
+
23
+ ROOT = Path.cwd()
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class CommandResult:
28
+ """Small command execution result used for optional local tool detection."""
29
+
30
+ stdout: str
31
+ stderr: str
32
+ returncode: int
33
+
34
+
35
+ DEFAULT_EXCLUDE_DIRS = frozenset(
36
+ {
37
+ ".git",
38
+ ".DS_Store",
39
+ ".review-context",
40
+ "node_modules",
41
+ "vendor",
42
+ ".venv",
43
+ "venv",
44
+ "__pycache__",
45
+ }
46
+ )
47
+
48
+ LOCAL_GUIDANCE_STATUS_PATHS = frozenset(
49
+ {
50
+ "PR_REVIEW.md",
51
+ "AGENTS.md",
52
+ "AGENTS.MD",
53
+ "CLAUDE.md",
54
+ "CLAUDE.MD",
55
+ ".cursorrules",
56
+ ".github/copilot-instructions.md",
57
+ ".opencodereview/accepted-decisions.md",
58
+ }
59
+ )
60
+
61
+
62
+ def run_command(cmd: Sequence[str], timeout: int = 10) -> CommandResult:
63
+ """Run a local command and return captured output without raising.
64
+
65
+ Commands are used only for optional local version discovery, for example
66
+ `ansible --version` when the binary is available in the CI image.
67
+ """
68
+
69
+ try:
70
+ proc = subprocess.run(
71
+ list(cmd),
72
+ cwd=ROOT,
73
+ text=True,
74
+ encoding="utf-8",
75
+ errors="replace",
76
+ stdout=subprocess.PIPE,
77
+ stderr=subprocess.PIPE,
78
+ timeout=timeout,
79
+ check=False,
80
+ )
81
+ return CommandResult(proc.stdout, proc.stderr, proc.returncode)
82
+ except subprocess.TimeoutExpired as exc:
83
+ # Surface timeouts: a silent empty result here turns into a
84
+ # missing background section downstream and is much harder to
85
+ # diagnose than a one-line log entry.
86
+ print(
87
+ f"run_command timeout after {timeout}s: {' '.join(str(c) for c in cmd)}",
88
+ file=sys.stderr,
89
+ )
90
+ return CommandResult("", str(exc), 124)
91
+ except Exception as exc:
92
+ print(
93
+ f"run_command failed: {' '.join(str(c) for c in cmd)}: {exc}",
94
+ file=sys.stderr,
95
+ )
96
+ return CommandResult("", str(exc), 127)
97
+
98
+
99
+ def resolve_repo_file(path: Path) -> Path | None:
100
+ """Return a resolved regular file path only when it stays inside ROOT."""
101
+
102
+ try:
103
+ root = ROOT.resolve()
104
+ resolved = path.resolve(strict=True)
105
+ resolved.relative_to(root)
106
+ except (OSError, ValueError):
107
+ return None
108
+
109
+ return resolved if resolved.is_file() else None
110
+
111
+
112
+ def safe_repo_match(path: Path, *, files_only: bool) -> Path | None:
113
+ """Return a repository-local match only when it is safe to count."""
114
+
115
+ if has_symlink_component(path):
116
+ return None
117
+ if files_only:
118
+ return resolve_repo_file(path)
119
+ try:
120
+ root = ROOT.resolve()
121
+ resolved = path.resolve(strict=True)
122
+ resolved.relative_to(root)
123
+ except (OSError, ValueError):
124
+ return None
125
+ return resolved
126
+
127
+
128
+ def has_symlink_component(path: Path) -> bool:
129
+ """Return whether any path component under ROOT is a symlink."""
130
+
131
+ try:
132
+ rel_path = path.relative_to(ROOT)
133
+ except ValueError:
134
+ return True
135
+
136
+ current = ROOT
137
+ for part in rel_path.parts:
138
+ current = current / part
139
+ if current.is_symlink():
140
+ return True
141
+ return False
142
+
143
+
144
+ def trusted_guidance_file(rel_path: str) -> Path | None:
145
+ """Return a trusted guidance file, or None when unsafe or missing."""
146
+
147
+ path = ROOT / rel_path
148
+ if has_symlink_component(path):
149
+ print(
150
+ f"Skipping symlinked guidance path {rel_path}; cannot trust its content for reviewer guidance.",
151
+ file=sys.stderr,
152
+ )
153
+ return None
154
+ return resolve_repo_file(path)
155
+
156
+
157
+ def resolve_output_path(value: str) -> Path | None:
158
+ """Resolve an output path limited to the repo or system temp directory."""
159
+
160
+ root_lexical = ROOT.absolute()
161
+ root = ROOT.resolve()
162
+ requested = Path(value)
163
+ logical_output = requested if requested.is_absolute() else root_lexical / requested
164
+ logical_output = logical_output.absolute()
165
+ temp_lexical = Path(tempfile.gettempdir()).absolute()
166
+ allowed_roots = [(root_lexical, root), (root, root)]
167
+ temp_resolved = temp_lexical.resolve()
168
+ allowed_roots.extend([(temp_lexical, temp_resolved), (temp_resolved, temp_resolved)])
169
+ tmp_path = Path("/tmp")
170
+ if tmp_path.exists():
171
+ tmp_lexical = tmp_path.absolute()
172
+ tmp_resolved = tmp_path.resolve()
173
+ allowed_roots.extend([(tmp_lexical, tmp_resolved), (tmp_resolved, tmp_resolved)])
174
+
175
+ for lexical_root, resolved_root in allowed_roots:
176
+ try:
177
+ logical_relative = logical_output.relative_to(lexical_root)
178
+ except ValueError:
179
+ continue
180
+ current = lexical_root
181
+ for part in logical_relative.parts:
182
+ current = current / part
183
+ if current.is_symlink():
184
+ return None
185
+ try:
186
+ output = logical_output.resolve()
187
+ output.relative_to(resolved_root)
188
+ except (OSError, ValueError):
189
+ return None
190
+ return output
191
+
192
+ return None
193
+
194
+
195
+ def read_text(path: Path, max_bytes: int = DEFAULT_MAX_FILE_BYTES) -> str:
196
+ """Read a UTF-8-ish repository text file defensively with a byte limit."""
197
+
198
+ safe_path = resolve_repo_file(path)
199
+ if safe_path is None:
200
+ return ""
201
+
202
+ try:
203
+ with safe_path.open("rb") as handle:
204
+ read_limit = max(0, max_bytes)
205
+ data = handle.read(read_limit)
206
+ truncated = read_limit > 0 and bool(handle.read(1))
207
+ except OSError:
208
+ return ""
209
+
210
+ text = data.decode("utf-8", errors="replace")
211
+ if truncated:
212
+ text = text.rstrip() + f"\n# [truncated after {read_limit} bytes]\n"
213
+ return text
214
+
215
+
216
+ def path_exists(path: str) -> bool:
217
+ """Return whether a repository-relative regular file exists."""
218
+
219
+ return resolve_repo_file(ROOT / path) is not None
220
+
221
+
222
+ def inline_ci_value(
223
+ env_name: str,
224
+ *,
225
+ fallback_git_args: Sequence[str] | None = None,
226
+ use_default_branch: bool = False,
227
+ ) -> str:
228
+ """Render CI metadata, marking local fallbacks explicitly."""
229
+
230
+ value = os.environ.get(env_name, "").strip()
231
+ if value:
232
+ return inline_code(value)
233
+
234
+ fallback_value = ""
235
+ if use_default_branch:
236
+ fallback_value = local_default_branch()
237
+ elif fallback_git_args is not None:
238
+ fallback_value = git_output(fallback_git_args)
239
+
240
+ if fallback_value:
241
+ return f"{inline_code(fallback_value)} _(local fallback; {env_name} unset)_"
242
+ return f"_(not provided; {env_name} unset)_"
243
+
244
+
245
+ def git_output(args: Sequence[str], timeout: int = 10) -> str:
246
+ """Return stripped git stdout or an empty string on failure."""
247
+
248
+ result = run_command(["git", *args], timeout=timeout)
249
+ if result.returncode != 0:
250
+ return ""
251
+ return result.stdout.strip()
252
+
253
+
254
+ def local_default_branch() -> str:
255
+ """Return the local default branch ref when GitLab CI metadata is absent."""
256
+
257
+ ref = git_output(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
258
+ if ref:
259
+ return ref
260
+
261
+ default_branch = os.environ.get("CI_DEFAULT_BRANCH", "").strip()
262
+ if default_branch:
263
+ return default_branch
264
+
265
+ return ""
266
+
267
+
268
+ def bounded_rel_glob(
269
+ patterns: Iterable[str],
270
+ limit: int = MAX_BACKGROUND_SECTION_ITEMS,
271
+ *,
272
+ files_only: bool = False,
273
+ exclude_dirs: Iterable[str] | None = None,
274
+ ) -> list[str]:
275
+ """Find bounded repository-relative glob matches without materializing all matches.
276
+
277
+ Heavy vendored/build directories are excluded by default so a single
278
+ `**/...` pattern does not walk through `node_modules`, `vendor`, etc.
279
+ Pass an explicit ``exclude_dirs`` (including an empty iterable) to
280
+ override the default set.
281
+ """
282
+
283
+ if limit <= 0:
284
+ return []
285
+
286
+ result: list[str] = []
287
+ seen: set[str] = set()
288
+ excluded_parts = DEFAULT_EXCLUDE_DIRS if exclude_dirs is None else frozenset(exclude_dirs)
289
+
290
+ for pattern in patterns:
291
+ remaining = limit - len(result)
292
+ if remaining <= 0:
293
+ return result
294
+ paths = (
295
+ iter_plain_glob(
296
+ pattern,
297
+ excluded_parts,
298
+ limit=remaining,
299
+ files_only=files_only,
300
+ skip_rel_paths=seen,
301
+ )
302
+ if "**" not in pattern
303
+ else iter_repo_glob(
304
+ pattern,
305
+ excluded_parts,
306
+ limit=remaining,
307
+ files_only=files_only,
308
+ skip_rel_paths=seen,
309
+ )
310
+ )
311
+ for path in paths:
312
+ rel_path = path.relative_to(ROOT)
313
+ rel = str(rel_path)
314
+ if rel in seen:
315
+ continue
316
+
317
+ seen.add(rel)
318
+ result.append(rel)
319
+ if len(result) >= limit:
320
+ return result
321
+
322
+ return result
323
+
324
+
325
+ def iter_repo_glob(
326
+ pattern: str,
327
+ excluded_parts: frozenset[str],
328
+ limit: int | None = None,
329
+ *,
330
+ files_only: bool = False,
331
+ skip_rel_paths: set[str] | None = None,
332
+ ) -> Iterable[Path]:
333
+ """Yield repository paths for a glob pattern while pruning excluded dirs."""
334
+
335
+ if "**" not in pattern:
336
+ for path in iter_plain_glob(
337
+ pattern,
338
+ excluded_parts,
339
+ limit=limit,
340
+ files_only=files_only,
341
+ skip_rel_paths=skip_rel_paths,
342
+ ):
343
+ yield path
344
+ return
345
+
346
+ pattern_parts = tuple(part for part in pattern.split("/") if part)
347
+
348
+ @cache
349
+ def matches(parts: tuple[str, ...], pattern_index: int = 0, path_index: int = 0) -> bool:
350
+ if pattern_index == len(pattern_parts):
351
+ return path_index == len(parts)
352
+ segment = pattern_parts[pattern_index]
353
+ if segment == "**":
354
+ return matches(parts, pattern_index + 1, path_index) or (
355
+ path_index < len(parts) and matches(parts, pattern_index, path_index + 1)
356
+ )
357
+ return (
358
+ path_index < len(parts)
359
+ and fnmatch.fnmatchcase(parts[path_index], segment)
360
+ and matches(parts, pattern_index + 1, path_index + 1)
361
+ )
362
+
363
+ yielded = 0
364
+ for dirpath, dirnames, filenames in os.walk(ROOT, topdown=True):
365
+ if limit is not None and yielded >= limit:
366
+ return
367
+ rel_dir = Path(dirpath).relative_to(ROOT)
368
+ if excluded_parts and any(part in excluded_parts for part in rel_dir.parts):
369
+ dirnames[:] = []
370
+ continue
371
+
372
+ dirnames[:] = sorted(dirnames)
373
+ filenames = sorted(filenames)
374
+
375
+ if excluded_parts:
376
+ dirnames[:] = [dirname for dirname in dirnames if dirname not in excluded_parts]
377
+
378
+ for name in [*dirnames, *filenames]:
379
+ path = Path(dirpath) / name
380
+ if safe_repo_match(path, files_only=files_only) is None:
381
+ continue
382
+ rel = path.relative_to(ROOT).as_posix()
383
+ if skip_rel_paths and rel in skip_rel_paths:
384
+ continue
385
+ if matches(tuple(Path(rel).parts)):
386
+ yielded += 1
387
+ yield path
388
+ if limit is not None and yielded >= limit:
389
+ return
390
+
391
+
392
+ def iter_plain_glob(
393
+ pattern: str,
394
+ excluded_parts: frozenset[str],
395
+ limit: int | None = None,
396
+ *,
397
+ files_only: bool = False,
398
+ skip_rel_paths: set[str] | None = None,
399
+ ) -> Iterable[Path]:
400
+ """Yield non-recursive glob matches in sorted order without ROOT.glob fanout."""
401
+
402
+ parts = pattern.split("/")
403
+ emitted = 0
404
+
405
+ def emit(path: Path) -> Iterable[Path]:
406
+ nonlocal emitted
407
+ rel = path.relative_to(ROOT).as_posix()
408
+ if excluded_parts and any(part in excluded_parts for part in Path(rel).parts):
409
+ return
410
+ if skip_rel_paths and rel in skip_rel_paths:
411
+ return
412
+ if safe_repo_match(path, files_only=files_only) is None:
413
+ return
414
+ if limit is not None and emitted >= limit:
415
+ return
416
+ emitted += 1
417
+ yield path
418
+
419
+ def walk(base: Path, index: int) -> Iterable[Path]:
420
+ if index >= len(parts):
421
+ if base.exists() and not base.is_symlink():
422
+ yield from emit(base)
423
+ return
424
+
425
+ part = parts[index]
426
+ if not part:
427
+ return
428
+
429
+ if any(char in part for char in "*?["):
430
+ try:
431
+ candidate_entries = []
432
+ final_segment = index == len(parts) - 1
433
+ for entry in base.iterdir():
434
+ if entry.is_dir() and entry.is_symlink():
435
+ continue
436
+ if excluded_parts and entry.name in excluded_parts:
437
+ continue
438
+ if not fnmatch.fnmatchcase(entry.name, part):
439
+ continue
440
+ if final_segment and safe_repo_match(entry, files_only=files_only) is None:
441
+ continue
442
+ candidate_entries.append(entry)
443
+
444
+ key = lambda item: item.relative_to(ROOT).as_posix()
445
+ if final_segment and limit is not None:
446
+ remaining = max(0, limit - emitted)
447
+ if skip_rel_paths:
448
+ candidate_entries = [
449
+ entry
450
+ for entry in candidate_entries
451
+ if entry.relative_to(ROOT).as_posix() not in skip_rel_paths
452
+ ]
453
+ entries = heapq.nsmallest(remaining, candidate_entries, key=key)
454
+ else:
455
+ entries = sorted(candidate_entries, key=key)
456
+ except OSError:
457
+ return
458
+ for entry in entries:
459
+ if limit is not None and emitted >= limit:
460
+ return
461
+ yield from walk(entry, index + 1)
462
+ return
463
+
464
+ if excluded_parts and part in excluded_parts:
465
+ return
466
+ if part == "..":
467
+ return
468
+ next_base = base / part
469
+ if next_base.is_dir() and next_base.is_symlink():
470
+ return
471
+ yield from walk(next_base, index + 1)
472
+
473
+ yield from walk(ROOT, 0)
474
+
475
+
476
+ def rel_glob(patterns: Iterable[str], limit: int = MAX_BACKGROUND_SECTION_ITEMS) -> list[str]:
477
+ """Find repository-relative paths matching any of the provided glob patterns."""
478
+
479
+ return bounded_rel_glob(patterns, limit=limit)
480
+
481
+
482
+ def parse_git_name_only_output(result: CommandResult) -> list[str] | None:
483
+ """Parse `git diff --name-only -z` output or return None on failure."""
484
+
485
+ if result.returncode != 0:
486
+ return None
487
+
488
+ return [path for path in result.stdout.split("\0") if path]
489
+
490
+
491
+ def parse_git_status_porcelain_output(result: CommandResult) -> list[str] | None:
492
+ """Parse `git status --porcelain -z` paths or return None on failure."""
493
+
494
+ if result.returncode != 0:
495
+ return None
496
+
497
+ paths: list[str] = []
498
+ parts = [part for part in result.stdout.split("\0") if part]
499
+ index = 0
500
+ while index < len(parts):
501
+ entry = parts[index]
502
+ index += 1
503
+ if len(entry) < 4:
504
+ continue
505
+
506
+ status = entry[:2]
507
+ path = entry[3:]
508
+ if path:
509
+ paths.append(path)
510
+
511
+ if "R" in status or "C" in status:
512
+ # With `-z`, rename/copy entries are followed by the original path.
513
+ # The new path above is what matters for guidance self-review checks.
514
+ index += 1
515
+
516
+ return paths
517
+
518
+
519
+ def local_changed_files() -> list[str] | None:
520
+ """Return local branch changed files when GitLab MR env is unavailable."""
521
+
522
+ source_sha = os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "").strip()
523
+ if source_sha and not set(source_sha) <= {"0"}:
524
+ head = source_sha
525
+ else:
526
+ head = os.environ.get("CI_COMMIT_SHA", "HEAD")
527
+ base_refs = [
528
+ ref
529
+ for ref in (
530
+ os.environ.get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME", ""),
531
+ local_default_branch(),
532
+ "origin/master",
533
+ )
534
+ if ref
535
+ ]
536
+
537
+ seen_bases: set[str] = set()
538
+ for base_ref in base_refs:
539
+ if base_ref in seen_bases:
540
+ continue
541
+ seen_bases.add(base_ref)
542
+
543
+ merge_base = run_command(["git", "merge-base", base_ref, head], timeout=30)
544
+ if merge_base.returncode != 0 or not merge_base.stdout.strip():
545
+ continue
546
+
547
+ # Local runs should reflect tracked working-tree edits as well as
548
+ # committed branch changes. CI uses the MR-specific path above.
549
+ result = run_command(
550
+ ["git", "diff", "--name-only", "-z", merge_base.stdout.strip()],
551
+ timeout=30,
552
+ )
553
+ files = parse_git_name_only_output(result)
554
+ if files is not None:
555
+ status = run_command(
556
+ ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
557
+ timeout=30,
558
+ )
559
+ status_files = parse_git_status_porcelain_output(status)
560
+ if status_files is not None:
561
+ seen = set(files)
562
+ for path in status_files:
563
+ if path not in LOCAL_GUIDANCE_STATUS_PATHS:
564
+ continue
565
+ if path in seen:
566
+ continue
567
+ seen.add(path)
568
+ files.append(path)
569
+ print(
570
+ f"Using local changed-files fallback against {base_ref}; GitLab MR env is unavailable.",
571
+ file=sys.stderr,
572
+ )
573
+ return files
574
+
575
+ return None
576
+
577
+
578
+ def changed_files() -> list[str] | None:
579
+ """Return files changed in the current GitLab merge request.
580
+
581
+ Prefer GitLab's MR diff base SHA because it matches the merge request diff
582
+ more closely than a plain two-dot diff against the current target branch.
583
+
584
+ Returns ``None`` when no diff strategy succeeded — distinct from an
585
+ empty list (which means "successfully computed, no files changed").
586
+ Callers downstream use the None state to fail closed on guidance/
587
+ accepted-decisions inclusion so an MR cannot self-whitelist by making
588
+ git introspection fail.
589
+ """
590
+
591
+ target = os.environ.get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME")
592
+ source_sha = os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "").strip()
593
+ if source_sha and not set(source_sha) <= {"0"}:
594
+ head = source_sha
595
+ else:
596
+ if os.environ.get("CI_MERGE_REQUEST_EVENT_TYPE") in {
597
+ "merged_result",
598
+ "merge_train",
599
+ }:
600
+ print(
601
+ "CI_MERGE_REQUEST_SOURCE_BRANCH_SHA is unavailable in a merged-result/merge-train pipeline; "
602
+ "refusing to diff the synthetic pipeline commit for review context.",
603
+ file=sys.stderr,
604
+ )
605
+ return None
606
+ head = os.environ.get("CI_COMMIT_SHA", "HEAD")
607
+ diff_base = os.environ.get("CI_MERGE_REQUEST_DIFF_BASE_SHA")
608
+
609
+ if not diff_base and not target:
610
+ local_files = local_changed_files()
611
+ if local_files is not None:
612
+ return local_files
613
+
614
+ diff_bases: list[str] = []
615
+
616
+ if diff_base:
617
+ diff_bases.append(diff_base)
618
+
619
+ if target:
620
+ merge_base = run_command(["git", "merge-base", f"origin/{target}", head], timeout=30)
621
+ if merge_base.returncode == 0 and merge_base.stdout.strip():
622
+ candidate = merge_base.stdout.strip()
623
+ if candidate not in diff_bases:
624
+ diff_bases.append(candidate)
625
+
626
+ for base in diff_bases:
627
+ result = run_command(["git", "diff", "--name-only", "-z", base, head], timeout=30)
628
+ files = parse_git_name_only_output(result)
629
+ if files is not None:
630
+ return files
631
+
632
+ print(
633
+ f"Failed to compute changed files with diff base {base}; trying next strategy.",
634
+ file=sys.stderr,
635
+ )
636
+
637
+ # No HEAD~1 fallback on purpose: on a multi-commit MR it silently
638
+ # returns only the last commit's files, hiding the failure. Surface
639
+ # the error instead.
640
+ print(
641
+ "Failed to compute changed files for MR; downstream code will "
642
+ "treat guidance and accepted-decisions as unavailable to avoid "
643
+ "self-whitelisting an unknown change set.",
644
+ file=sys.stderr,
645
+ )
646
+ return None
647
+
648
+
649
+ def rel_glob_files(
650
+ patterns: Iterable[str],
651
+ limit: int = MAX_BACKGROUND_SECTION_ITEMS,
652
+ exclude_dirs: Iterable[str] | None = None,
653
+ ) -> list[str]:
654
+ """Find repository-relative files matching any of the provided glob patterns."""
655
+
656
+ return bounded_rel_glob(
657
+ patterns,
658
+ limit=limit,
659
+ files_only=True,
660
+ exclude_dirs=exclude_dirs,
661
+ )
662
+
663
+
664
+ def tool_version(binary: str, args: Sequence[str]) -> list[str]:
665
+ """Return the first few version lines for an available local binary."""
666
+
667
+ if not shutil.which(binary):
668
+ return []
669
+ result = run_command([binary, *args], timeout=10)
670
+ if not result.stdout:
671
+ return []
672
+ return result.stdout.splitlines()[:8]