github-agent-cli 1.2.1__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.
ghalib/util.py ADDED
@@ -0,0 +1,522 @@
1
+ """Shared primitives for ghalib: text hygiene, GitHub anchors, markdown parsing, findings.
2
+
3
+ Standard library only. Every helper here is exercised by tests/run_tests.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import unicodedata
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ PACK_NAME = "github-agent"
20
+ PACK_VERSION = "1.2.1"
21
+
22
+ EXIT_OK = 0
23
+ EXIT_FAIL = 1
24
+ EXIT_USAGE = 2
25
+
26
+ # Directories that are never part of a repository's own documentation surface.
27
+ EXCLUDED_DIRS = {
28
+ ".git", ".hg", ".svn", ".ragraf", ".zwork", ".agents", ".dev_agent", ".pi",
29
+ "node_modules", "vendor", "bower_components", ".venv", "venv", "env",
30
+ "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache",
31
+ "dist", "build", "target", "coverage", "htmlcov", ".tox", ".idea", ".vscode",
32
+ }
33
+
34
+ TEXT_SUFFIXES = {
35
+ ".md", ".markdown", ".mdown", ".txt", ".rst", ".yml", ".yaml", ".json",
36
+ ".toml", ".ini", ".cfg", ".cff", ".py", ".ps1", ".psm1", ".sh", ".bash",
37
+ ".js", ".mjs", ".ts", ".php", ".css", ".html", ".xml", ".sql", ".bat", ".cmd",
38
+ }
39
+
40
+ MARKDOWN_SUFFIXES = {".md", ".markdown", ".mdown"}
41
+
42
+
43
+ # --------------------------------------------------------------------------
44
+ # text input / output
45
+ # --------------------------------------------------------------------------
46
+
47
+ @dataclass
48
+ class TextMeta:
49
+ path: Path
50
+ has_bom: bool
51
+ crlf_count: int
52
+ lf_count: int
53
+ has_tab_indent: bool
54
+ ends_with_newline: bool
55
+ size: int
56
+
57
+ @property
58
+ def has_crlf(self) -> bool:
59
+ return self.crlf_count > 0
60
+
61
+ @property
62
+ def mixed_eol(self) -> bool:
63
+ return self.crlf_count > 0 and self.lf_count > 0
64
+
65
+
66
+ def read_text(path: Path) -> tuple[str, TextMeta]:
67
+ raw = Path(path).read_bytes()
68
+ has_bom = raw.startswith(b"\xef\xbb\xbf")
69
+ if has_bom:
70
+ raw = raw[3:]
71
+ try:
72
+ text = raw.decode("utf-8")
73
+ except UnicodeDecodeError as exc: # invalid UTF-8 is a finding, not a crash
74
+ raise ValueError(f"{path}: not valid UTF-8 ({exc.reason} at byte {exc.start})") from exc
75
+ meta = TextMeta(
76
+ path=Path(path),
77
+ has_bom=has_bom,
78
+ crlf_count=text.count("\r\n"),
79
+ lf_count=text.count("\n") - text.count("\r\n"),
80
+ has_tab_indent=bool(re.search(r"^\t+", text, re.MULTILINE)),
81
+ ends_with_newline=text.endswith("\n"),
82
+ size=len(raw),
83
+ )
84
+ return text.replace("\r\n", "\n").replace("\r", "\n"), meta
85
+
86
+
87
+ def write_text(path: Path, text: str) -> None:
88
+ """Write UTF-8 without BOM and with LF endings, atomically."""
89
+ target = Path(path)
90
+ target.parent.mkdir(parents=True, exist_ok=True)
91
+ data = text.replace("\r\n", "\n").replace("\r", "\n").encode("utf-8")
92
+ fd, tmp = tempfile.mkstemp(dir=str(target.parent), prefix=".ghagent-", suffix=".tmp")
93
+ try:
94
+ with os.fdopen(fd, "wb") as handle:
95
+ handle.write(data)
96
+ os.replace(tmp, target)
97
+ finally:
98
+ if os.path.exists(tmp):
99
+ os.unlink(tmp)
100
+
101
+
102
+ def force_utf8_stdio() -> None:
103
+ """Write stdout and stderr as UTF-8 even when they are redirected.
104
+
105
+ On Windows a redirected stream is encoded with the locale codec (cp1252, cp866,
106
+ cp1251), which cannot carry the arrows, ellipses and non-Latin scripts the reports
107
+ are made of: the command would die with UnicodeEncodeError instead of printing its
108
+ finding. PEP 528 already gives the console UTF-8, so only the redirected case needs
109
+ this. `backslashreplace` keeps the exit code and the rest of the report intact if a
110
+ stream still refuses a character. Every failure is swallowed on purpose: a stream
111
+ that cannot be reconfigured (bytes, a test harness, an already wrapped console) must
112
+ never turn a usable run into a crash.
113
+ """
114
+ for stream in (sys.stdout, sys.stderr):
115
+ reconfigure = getattr(stream, "reconfigure", None)
116
+ if reconfigure is None:
117
+ continue
118
+ try:
119
+ reconfigure(encoding="utf-8", errors="backslashreplace")
120
+ except (AttributeError, OSError, ValueError):
121
+ pass
122
+
123
+
124
+ def iter_text_files(root: Path, *, suffix_set: set[str] | None = None):
125
+ root = Path(root)
126
+ suffixes = suffix_set if suffix_set is not None else TEXT_SUFFIXES
127
+ for dirpath, dirnames, filenames in os.walk(root):
128
+ dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDED_DIRS and not d.startswith(".ghagent-"))
129
+ for name in sorted(filenames):
130
+ path = Path(dirpath) / name
131
+ if path.suffix.lower() in suffixes:
132
+ yield path
133
+
134
+
135
+ def rel(path: Path, root: Path) -> str:
136
+ try:
137
+ return str(Path(path).resolve().relative_to(Path(root).resolve())).replace("\\", "/")
138
+ except ValueError:
139
+ return str(path).replace("\\", "/")
140
+
141
+
142
+ # --------------------------------------------------------------------------
143
+ # GitHub heading anchors
144
+ # --------------------------------------------------------------------------
145
+
146
+ def strip_inline_markdown(text: str) -> str:
147
+ """Reduce inline markdown to the text GitHub renders, for anchor computation."""
148
+ out = text
149
+ out = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", out) # images -> alt
150
+ out = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", out) # links -> text
151
+ out = re.sub(r"\[([^\]]*)\]\[[^\]]*\]", r"\1", out) # reference links
152
+ out = re.sub(r"`([^`]*)`", r"\1", out) # inline code keeps text
153
+ out = re.sub(r"<[^>]+>", "", out) # html tags
154
+ out = re.sub(r"(\*\*|__)(.*?)\1", r"\2", out) # bold
155
+ out = re.sub(r"(\*|_)(.*?)\1", r"\2", out) # italic
156
+ out = re.sub(r"~~(.*?)~~", r"\1", out) # strike
157
+ return out.strip()
158
+
159
+
160
+ def _anchor_keep(ch: str) -> bool:
161
+ """Letters, digits and combining marks survive; punctuation is dropped.
162
+
163
+ All three mark categories are kept, not only `Mn`: Devanagari vowel signs are
164
+ spacing combining marks (`Mc`), and dropping them turns `इंस्टॉलेशन` into
165
+ `इंस्टलेशन`, so a table-of-contents link to the heading stops resolving. GitHub
166
+ keeps the mark, so the anchor keeps it too.
167
+ """
168
+ if ch in "-_ ":
169
+ return True
170
+ category = unicodedata.category(ch)
171
+ return category[0] in {"L", "N", "M"}
172
+
173
+
174
+ def slugify(heading: str) -> str:
175
+ """Reproduce GitHub's heading anchor: lowercase, drop punctuation, spaces to hyphens.
176
+
177
+ Letters, digits and combining marks survive, so scripts that write a vowel as a
178
+ separate mark (Devanagari, Thai, Vietnamese) keep it exactly as GitHub renders it.
179
+ Verified against a live repo TOC (em dash removal leaves a double hyphen).
180
+ """
181
+ text = strip_inline_markdown(heading).lower()
182
+ kept = "".join(ch for ch in text if _anchor_keep(ch))
183
+ return kept.replace(" ", "-")
184
+
185
+
186
+ class SlugRegistry:
187
+ """Anchors are unique per document; duplicates get -1, -2 suffixes like GitHub."""
188
+
189
+ def __init__(self) -> None:
190
+ self._seen: dict[str, int] = {}
191
+
192
+ def add(self, heading: str) -> str:
193
+ base = slugify(heading)
194
+ if base not in self._seen:
195
+ self._seen[base] = 1
196
+ return base
197
+ count = self._seen[base]
198
+ self._seen[base] = count + 1
199
+ candidate = f"{base}-{count}"
200
+ while candidate in self._seen:
201
+ count += 1
202
+ self._seen[base] = count
203
+ candidate = f"{base}-{count}"
204
+ self._seen[candidate] = 1
205
+ return candidate
206
+
207
+
208
+ # --------------------------------------------------------------------------
209
+ # markdown structure
210
+ # --------------------------------------------------------------------------
211
+
212
+ @dataclass
213
+ class Heading:
214
+ level: int
215
+ text: str
216
+ line: int
217
+ anchor: str = ""
218
+
219
+
220
+ @dataclass
221
+ class Link:
222
+ text: str
223
+ target: str
224
+ line: int
225
+ is_image: bool = False
226
+ kind: str = "inline" # inline | reference | autolink
227
+
228
+
229
+ @dataclass
230
+ class Fence:
231
+ line: int
232
+ marker: str
233
+ info: str
234
+ closed_at: int | None = None
235
+
236
+
237
+ FENCE_RE = re.compile(r"^(?P<indent> {0,3})(?P<marker>`{3,}|~{3,})(?P<info>.*)$")
238
+ HEADING_RE = re.compile(r"^(?P<hashes>#{1,6})(?P<space>[ \t]+)(?P<text>.*?)(?:[ \t]+#+)?[ \t]*$")
239
+ INLINE_LINK_RE = re.compile(r"(?<!!)\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
240
+ IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
241
+ REF_LINK_RE = re.compile(r"\[([^\]]+)\]\[([^\]]*)\]")
242
+ AUTOLINK_RE = re.compile(r"<((?:https?|ftp)://[^>\s]+)>")
243
+ BARE_URL_RE = re.compile(r"(?<![(<\[`\"'])\bhttps?://[^\s`<>)\]]+")
244
+
245
+
246
+ def scrub_code_spans(line: str) -> str:
247
+ """Blank out inline code so that a link written inside backticks is not treated as a link."""
248
+ return re.sub(r"``[^`]*``|`[^`]*`", " ", line)
249
+
250
+
251
+ CODE_SPAN_SPLIT = re.compile(r"(``[^`]*``|`[^`]*`)")
252
+
253
+
254
+ def map_outside_code(line: str, transform) -> str:
255
+ """Apply `transform` to the parts of a line that are not inside inline code."""
256
+ parts = CODE_SPAN_SPLIT.split(line)
257
+ for index, part in enumerate(parts):
258
+ if index % 2 == 1: # odd indexes are the code spans themselves
259
+ continue
260
+ parts[index] = transform(part)
261
+ return "".join(parts)
262
+
263
+
264
+ def scan_structure(text: str) -> tuple[list[Heading], list[Fence], list[Link], list[str]]:
265
+ """Return headings (with anchors), fences, links and the raw lines."""
266
+ lines = text.split("\n")
267
+ headings: list[Heading] = []
268
+ fences: list[Fence] = []
269
+ links: list[Link] = []
270
+ registry = SlugRegistry()
271
+ in_fence: Fence | None = None
272
+
273
+ for number, raw in enumerate(lines, start=1):
274
+ match = FENCE_RE.match(raw)
275
+ if in_fence is None:
276
+ if match:
277
+ fence = Fence(line=number, marker=match.group("marker"), info=match.group("info").strip())
278
+ fences.append(fence)
279
+ in_fence = fence
280
+ continue
281
+ heading_match = HEADING_RE.match(raw)
282
+ if heading_match:
283
+ heading_text = heading_match.group("text").strip()
284
+ headings.append(Heading(
285
+ level=len(heading_match.group("hashes")),
286
+ text=heading_text,
287
+ line=number,
288
+ anchor=registry.add(heading_text),
289
+ ))
290
+ # Links inside inline code are literal text, not links.
291
+ for image in IMAGE_RE.finditer(scrub_code_spans(raw)):
292
+ links.append(Link(text=image.group(1), target=image.group(2), line=number, is_image=True))
293
+ scrubbed = scrub_code_spans(raw)
294
+ for link in INLINE_LINK_RE.finditer(scrubbed):
295
+ links.append(Link(text=link.group(1), target=link.group(2), line=number))
296
+ for ref in REF_LINK_RE.finditer(scrubbed):
297
+ links.append(Link(text=ref.group(1), target=ref.group(2), line=number, kind="reference"))
298
+ for auto in AUTOLINK_RE.finditer(scrubbed):
299
+ links.append(Link(text=auto.group(1), target=auto.group(1), line=number, kind="autolink"))
300
+ else:
301
+ if match and match.group("marker")[0] == in_fence.marker[0] and len(match.group("marker")) >= len(in_fence.marker):
302
+ in_fence.closed_at = number
303
+ in_fence = None
304
+
305
+ return headings, fences, links, lines
306
+
307
+
308
+ def parse_table(lines: list[str], start_index: int) -> dict | None:
309
+ """Parse a pipe table starting at lines[start_index]; returns counts and rows."""
310
+ header = lines[start_index]
311
+ if start_index + 1 >= len(lines):
312
+ return None
313
+ separator = lines[start_index + 1]
314
+ if not re.match(r"^\s*\|?[\s:|-]+\|[\s:|-]*$", separator) or "-" not in separator:
315
+ return None
316
+ header_cells = split_row(header)
317
+ align_cells = split_row(separator)
318
+ rows: list[list[str]] = []
319
+ index = start_index + 2
320
+ while index < len(lines) and lines[index].strip() and "|" in lines[index]:
321
+ rows.append(split_row(lines[index]))
322
+ index += 1
323
+ return {
324
+ "start_line": start_index + 1,
325
+ "header_cells": len(header_cells),
326
+ "align_cells": len(align_cells),
327
+ "rows": rows,
328
+ "end_line": index,
329
+ }
330
+
331
+
332
+ def split_row(row: str) -> list[str]:
333
+ stripped = row.strip()
334
+ if stripped.startswith("|"):
335
+ stripped = stripped[1:]
336
+ if stripped.endswith("|") and not stripped.endswith("\\|"):
337
+ stripped = stripped[:-1]
338
+ cells: list[str] = []
339
+ current: list[str] = []
340
+ in_code = False
341
+ index = 0
342
+ while index < len(stripped):
343
+ ch = stripped[index]
344
+ if ch == "\\" and index + 1 < len(stripped) and stripped[index + 1] == "|":
345
+ current.append("|")
346
+ index += 2
347
+ continue
348
+ if ch == "`":
349
+ in_code = not in_code
350
+ if ch == "|" and not in_code:
351
+ cells.append("".join(current).strip())
352
+ current = []
353
+ index += 1
354
+ continue
355
+ current.append(ch)
356
+ index += 1
357
+ cells.append("".join(current).strip())
358
+ return cells
359
+
360
+
361
+ def prose_lines(text: str) -> list[tuple[int, str]]:
362
+ """Lines outside fenced code blocks, with their 1-based numbers."""
363
+ result: list[tuple[int, str]] = []
364
+ in_fence = False
365
+ for number, raw in enumerate(text.split("\n"), start=1):
366
+ match = FENCE_RE.match(raw)
367
+ if match:
368
+ in_fence = not in_fence
369
+ continue
370
+ if not in_fence:
371
+ result.append((number, raw))
372
+ return result
373
+
374
+
375
+ # --------------------------------------------------------------------------
376
+ # findings
377
+ # --------------------------------------------------------------------------
378
+
379
+ @dataclass
380
+ class Finding:
381
+ level: str # error | warn | info
382
+ rule: str
383
+ message: str
384
+ path: str = ""
385
+ line: int = 0
386
+ hint: str = ""
387
+
388
+ def as_dict(self) -> dict:
389
+ return {
390
+ "level": self.level,
391
+ "rule": self.rule,
392
+ "path": self.path,
393
+ "line": self.line,
394
+ "message": self.message,
395
+ "hint": self.hint,
396
+ }
397
+
398
+ def render(self) -> str:
399
+ location = self.path or "-"
400
+ if self.line:
401
+ location = f"{location}:{self.line}"
402
+ tail = f" hint: {self.hint}" if self.hint else ""
403
+ return f"[{self.level.upper():5}] {self.rule:22} {location} {self.message}{tail}"
404
+
405
+
406
+ @dataclass
407
+ class Report:
408
+ title: str
409
+ root: str = ""
410
+ findings: list[Finding] = field(default_factory=list)
411
+ data: dict = field(default_factory=dict)
412
+
413
+ def add(self, level: str, rule: str, message: str, *, path: str = "", line: int = 0, hint: str = "") -> None:
414
+ self.findings.append(Finding(level=level, rule=rule, message=message, path=path, line=line, hint=hint))
415
+
416
+ def error(self, rule: str, message: str, **kw) -> None:
417
+ self.add("error", rule, message, **kw)
418
+
419
+ def warn(self, rule: str, message: str, **kw) -> None:
420
+ self.add("warn", rule, message, **kw)
421
+
422
+ def info(self, rule: str, message: str, **kw) -> None:
423
+ self.add("info", rule, message, **kw)
424
+
425
+ @property
426
+ def errors(self) -> list[Finding]:
427
+ return [f for f in self.findings if f.level == "error"]
428
+
429
+ @property
430
+ def warnings(self) -> list[Finding]:
431
+ return [f for f in self.findings if f.level == "warn"]
432
+
433
+ def counts(self) -> dict:
434
+ return {
435
+ "error": len(self.errors),
436
+ "warn": len(self.warnings),
437
+ "info": len([f for f in self.findings if f.level == "info"]),
438
+ }
439
+
440
+ def exit_code(self) -> int:
441
+ return EXIT_FAIL if self.errors else EXIT_OK
442
+
443
+ def render(self) -> str:
444
+ lines = [self.title]
445
+ if self.root:
446
+ lines.append(f"root: {self.root}")
447
+ for finding in sorted(self.findings, key=lambda f: ({"error": 0, "warn": 1, "info": 2}[f.level], f.path, f.line)):
448
+ lines.append(finding.render())
449
+ counts = self.counts()
450
+ verdict = "FAIL" if counts["error"] else ("WARN" if counts["warn"] else "PASS")
451
+ lines.append(f"{verdict}: {counts['error']} error(s), {counts['warn']} warning(s), {counts['info']} note(s)")
452
+ return "\n".join(lines)
453
+
454
+ def as_dict(self) -> dict:
455
+ return {
456
+ "title": self.title,
457
+ "root": self.root,
458
+ "counts": self.counts(),
459
+ "findings": [f.as_dict() for f in self.findings],
460
+ "data": self.data,
461
+ }
462
+
463
+
464
+ def emit(report: Report, as_json: bool) -> int:
465
+ if as_json:
466
+ print(json.dumps(report.as_dict(), ensure_ascii=False, indent=2))
467
+ else:
468
+ print(report.render())
469
+ return report.exit_code()
470
+
471
+
472
+ # --------------------------------------------------------------------------
473
+ # process helpers
474
+ # --------------------------------------------------------------------------
475
+
476
+ def which(program: str) -> str | None:
477
+ return shutil.which(program)
478
+
479
+
480
+ def run(cmd: list[str], cwd: Path | None = None, *, check: bool = False) -> tuple[int, str, str]:
481
+ try:
482
+ proc = subprocess.run(
483
+ cmd,
484
+ cwd=str(cwd) if cwd else None,
485
+ capture_output=True,
486
+ text=True,
487
+ encoding="utf-8",
488
+ errors="replace",
489
+ )
490
+ except FileNotFoundError:
491
+ return 127, "", f"command not found: {cmd[0]}"
492
+ if check and proc.returncode != 0:
493
+ raise RuntimeError(f"{' '.join(cmd)} failed ({proc.returncode}): {proc.stderr.strip()}")
494
+ return proc.returncode, proc.stdout, proc.stderr
495
+
496
+
497
+ def git(args: list[str], cwd: Path) -> tuple[int, str, str]:
498
+ return run(["git", *args], cwd=cwd)
499
+
500
+
501
+ def repo_root_from(path: Path) -> Path:
502
+ rc, out, _ = git(["rev-parse", "--show-toplevel"], path)
503
+ if rc == 0 and out.strip():
504
+ return Path(out.strip())
505
+ return path
506
+
507
+
508
+ def guess_repo_slug(path: Path) -> tuple[str, str] | None:
509
+ """Read owner/repo from the `origin` remote, if any."""
510
+ rc, out, _ = git(["remote", "get-url", "origin"], path)
511
+ if rc != 0:
512
+ return None
513
+ url = out.strip()
514
+ match = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$", url)
515
+ if not match:
516
+ return None
517
+ return match.group(1), match.group(2)
518
+
519
+
520
+ def fail(message: str) -> int:
521
+ print(f"ghagent: {message}", file=sys.stderr)
522
+ return EXIT_USAGE