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.
- ghagent.py +511 -0
- ghalib/__init__.py +5 -0
- ghalib/assets.py +475 -0
- ghalib/audit.py +649 -0
- ghalib/changelog.py +473 -0
- ghalib/config.py +52 -0
- ghalib/install.py +189 -0
- ghalib/mdcheck.py +424 -0
- ghalib/meta.py +274 -0
- ghalib/report.py +90 -0
- ghalib/util.py +522 -0
- ghalib/wiki.py +512 -0
- github_agent_cli-1.2.1.dist-info/METADATA +431 -0
- github_agent_cli-1.2.1.dist-info/RECORD +17 -0
- github_agent_cli-1.2.1.dist-info/WHEEL +4 -0
- github_agent_cli-1.2.1.dist-info/entry_points.txt +2 -0
- github_agent_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
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
|