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/audit.py ADDED
@@ -0,0 +1,649 @@
1
+ """Repository audit: required files, README contract, changelog contract, promotion metadata.
2
+
3
+ Two modes:
4
+ * local — reads a working tree, never touches the network;
5
+ * remote — reads public metadata through `gh api` (or GITHUB_TOKEN) and compares it
6
+ with what the tree claims.
7
+
8
+ Scoring rule: an item is either pass, fail, warn or not-applicable. Items that could
9
+ not be examined are reported as `skipped` and are excluded from both numerator and
10
+ denominator, so a network outage lowers coverage instead of raising the score.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import urllib.error
19
+ import urllib.request
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ from . import assets as assets_module
24
+ from . import config as cfg
25
+ from . import mdcheck
26
+ from .util import (
27
+ EXIT_FAIL,
28
+ EXIT_OK,
29
+ Report,
30
+ guess_repo_slug,
31
+ read_text,
32
+ rel,
33
+ run,
34
+ scan_structure,
35
+ which,
36
+ )
37
+
38
+ REQUIRED_FILES: list[tuple[str, list[str], int, str]] = [
39
+ ("readme", ["README.md", "README.markdown", "README.rst", "readme.md"], 10, "error"),
40
+ ("license", ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"], 8, "error"),
41
+ ("changelog", ["CHANGELOG.md", "CHANGELOG", "HISTORY.md", "CHANGES.md"], 6, "error"),
42
+ ("contributing", ["CONTRIBUTING.md", ".github/CONTRIBUTING.md", "docs/CONTRIBUTING.md"], 6, "error"),
43
+ ("security", ["SECURITY.md", ".github/SECURITY.md", "docs/SECURITY.md"], 6, "error"),
44
+ ("code-of-conduct", ["CODE_OF_CONDUCT.md", ".github/CODE_OF_CONDUCT.md", "docs/CODE_OF_CONDUCT.md"], 4, "warn"),
45
+ ("support", ["SUPPORT.md", ".github/SUPPORT.md"], 2, "warn"),
46
+ ("agents", ["AGENTS.md", ".github/AGENTS.md"], 5, "warn"),
47
+ ("gitattributes", [".gitattributes"], 3, "warn"),
48
+ ("editorconfig", [".editorconfig"], 2, "warn"),
49
+ ("gitignore", [".gitignore"], 3, "warn"),
50
+ ("pr-template", [".github/PULL_REQUEST_TEMPLATE.md", "PULL_REQUEST_TEMPLATE.md", ".github/pull_request_template.md"], 3, "warn"),
51
+ ("issue-forms", [".github/ISSUE_TEMPLATE", "docs/ISSUE_TEMPLATE"], 4, "warn"),
52
+ ("ci", [".github/workflows", ".gitlab-ci.yml", "azure-pipelines.yml"], 6, "warn"),
53
+ ("codeowners", [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"], 2, "warn"),
54
+ ("dependabot", [".github/dependabot.yml", ".github/dependabot.yaml"], 2, "warn"),
55
+ ("citation", ["CITATION.cff", ".github/CITATION.cff"], 2, "info"),
56
+ ]
57
+
58
+ BADGE_RE = re.compile(r"!\[[^\]]*\]\(https?://[^)]*\)|\[!\[[^\]]*\]\(https?://[^)]*\)\]\([^)]*\)")
59
+ SUPERLATIVES = re.compile(
60
+ r"\b(blazing|revolutionary|best-in-class|world-class|cutting-edge|state-of-the-art|"
61
+ r"amazing|awesome|ultimate|next-gen|game-chang\w+|seamless)\b",
62
+ re.IGNORECASE,
63
+ )
64
+ EMOJI_RE = re.compile(
65
+ "[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF\u2b00-\u2bff]"
66
+ )
67
+
68
+
69
+ @dataclass
70
+ class Item:
71
+ ident: str
72
+ title: str
73
+ weight: int
74
+ status: str = "skipped" # pass | fail | warn | skipped
75
+ detail: str = ""
76
+ severity: str = "error"
77
+
78
+ @property
79
+ def scored(self) -> bool:
80
+ return self.status in {"pass", "warn", "fail"}
81
+
82
+ @property
83
+ def points(self) -> float:
84
+ if self.status == "pass":
85
+ return self.weight
86
+ if self.status == "warn":
87
+ return self.weight * 0.5
88
+ return 0.0
89
+
90
+ def as_dict(self) -> dict:
91
+ return {
92
+ "id": self.ident,
93
+ "title": self.title,
94
+ "weight": self.weight,
95
+ "status": self.status,
96
+ "detail": self.detail,
97
+ }
98
+
99
+
100
+ @dataclass
101
+ class AuditResult:
102
+ report: Report
103
+ items: list[Item] = field(default_factory=list)
104
+
105
+ def score(self) -> tuple[float, int, int]:
106
+ scored = [item for item in self.items if item.scored]
107
+ total = sum(item.weight for item in scored)
108
+ got = sum(item.points for item in scored)
109
+ percentage = round(100.0 * got / total, 1) if total else 0.0
110
+ return percentage, len(scored), len([i for i in self.items if not i.scored])
111
+
112
+
113
+ # --------------------------------------------------------------------------
114
+ # local audit
115
+ # --------------------------------------------------------------------------
116
+
117
+ def _first_existing(root: Path, candidates: list[str]) -> Path | None:
118
+ for candidate in candidates:
119
+ path = root / candidate
120
+ if path.exists():
121
+ if path.is_dir():
122
+ if any(path.iterdir()):
123
+ return path
124
+ continue
125
+ if path.stat().st_size > 0:
126
+ return path
127
+ return None
128
+
129
+
130
+ def audit_local(root: Path, *, config: cfg.Config | None = None, doc_report: Report | None = None) -> AuditResult:
131
+ root = Path(root)
132
+ config = config or cfg.load(root)
133
+ report = Report(title="ghagent audit", root=str(root))
134
+ result = AuditResult(report=report)
135
+
136
+ if not root.exists():
137
+ report.error("repo.missing", f"path does not exist: {root}")
138
+ return result
139
+
140
+ for ident, candidates, weight, severity in REQUIRED_FILES:
141
+ item = Item(ident=ident, title=candidates[0], weight=weight, severity=severity)
142
+ found = _first_existing(root, candidates)
143
+ if found is None:
144
+ item.status = "fail"
145
+ item.detail = "missing"
146
+ if severity == "info":
147
+ report.info(f"repo.{ident}", f"{candidates[0]} is absent (optional)")
148
+ elif severity == "warn":
149
+ report.warn(f"repo.{ident}", f"{candidates[0]} is absent", hint=f"add {candidates[0]}")
150
+ else:
151
+ report.error(f"repo.{ident}", f"{candidates[0]} is absent", hint=f"add {candidates[0]}")
152
+ else:
153
+ item.status = "pass"
154
+ item.detail = rel(found, root)
155
+ result.items.append(item)
156
+
157
+ for extra in config.require_files:
158
+ target = root / extra
159
+ if not target.exists():
160
+ report.error("repo.required-file", f"required by {cfg.CONFIG_NAME}: {extra} is absent")
161
+ result.items.append(Item(ident=f"required:{extra}", title=extra, weight=2, status="fail", detail="missing"))
162
+
163
+ readme_path = _first_existing(root, [config.readme, "README.md", "README.markdown"])
164
+ if readme_path is not None:
165
+ result.items.extend(_audit_readme(readme_path, root, report))
166
+ else:
167
+ report.error("readme.missing", "README.md is absent; README contract cannot be examined")
168
+
169
+ changelog_path = _first_existing(root, [config.changelog, "CHANGELOG.md", "HISTORY.md"])
170
+ if changelog_path is not None:
171
+ result.items.extend(_audit_changelog(changelog_path, root, report))
172
+ else:
173
+ report.error("changelog.missing", "CHANGELOG.md is absent; changelog contract cannot be examined")
174
+
175
+ agents_path = _first_existing(root, [config.agents, "AGENTS.md"])
176
+ if agents_path is not None:
177
+ result.items.extend(_audit_agents(agents_path, root, report))
178
+
179
+ wiki_dir = root / config.wiki_dir
180
+ if wiki_dir.is_dir():
181
+ item = Item(ident="wiki-source", title=f"{config.wiki_dir}/", weight=3)
182
+ item.status = "pass"
183
+ item.detail = f"{len(list(wiki_dir.glob('*.md')))} page(s)"
184
+ result.items.append(item)
185
+ manifest = root / config.wiki_manifest
186
+ if not manifest.exists():
187
+ report.warn("wiki.manifest", f"{config.wiki_manifest} is absent",
188
+ hint="wiki build cannot order pages without a manifest")
189
+ else:
190
+ result.items.append(Item(ident="wiki-source", title=f"{config.wiki_dir}/", weight=3,
191
+ status="fail", detail="no wiki source directory"))
192
+
193
+ assets_report = assets_module.check(root, report=report)
194
+ reference_item = Item(ident="assets.banner", title="README banner", weight=4)
195
+ if assets_report.data.get("readme_references"):
196
+ reference_item.status = "pass"
197
+ reference_item.detail = f"{assets_report.data['readme_references']} image(s)"
198
+ else:
199
+ reference_item.status = "fail"
200
+ reference_item.detail = "no image in README"
201
+ result.items.append(reference_item)
202
+
203
+ social_item = Item(ident="assets.social-preview", title="social preview 1280x640", weight=4)
204
+ if assets_report.data.get("social_preview_ready"):
205
+ social_item.status = "pass"
206
+ social_item.detail = str(assets_report.data.get("social_preview_dimensions"))
207
+ elif assets_report.data.get("social_preview_file"):
208
+ social_item.status = "warn"
209
+ social_item.detail = str(assets_report.data.get("social_preview_dimensions"))
210
+ else:
211
+ social_item.status = "warn"
212
+ social_item.detail = "no assets/social-preview.*"
213
+ result.items.append(social_item)
214
+
215
+ if doc_report is None:
216
+ options = mdcheck.DocCheckOptions(ignore=set(config.ignore_rules))
217
+ checker = mdcheck.DocChecker(root, report, options)
218
+ for doc in _primary_documents(root, config):
219
+ checker.check_file(doc)
220
+ checker._report_coverage()
221
+ else:
222
+ report.findings.extend(doc_report.findings)
223
+ report.data.update(doc_report.data)
224
+
225
+ percentage, scored, skipped = result.score()
226
+ report.data.update({
227
+ "score": percentage,
228
+ "scored_items": scored,
229
+ "skipped_items": skipped,
230
+ "items": [item.as_dict() for item in result.items],
231
+ "mode": "local",
232
+ })
233
+ if scored == 0:
234
+ report.error("audit.no-items", "no items were examined; the score is meaningless")
235
+ elif percentage < 60:
236
+ report.error("audit.score", f"hygiene score {percentage}% is below 60%")
237
+ elif percentage < 80:
238
+ report.warn("audit.score", f"hygiene score {percentage}% is below 80%")
239
+ return result
240
+
241
+
242
+ def _primary_documents(root: Path, config: cfg.Config) -> list[Path]:
243
+ docs: list[Path] = []
244
+ for name in (config.readme, config.agents, config.changelog, "CONTRIBUTING.md", "SECURITY.md",
245
+ "CODE_OF_CONDUCT.md", "SUPPORT.md", "GOVERNANCE.md"):
246
+ path = root / name
247
+ if path.exists() and path.suffix.lower() == ".md":
248
+ docs.append(path)
249
+ docs_dir = root / config.docs_dir
250
+ if docs_dir.is_dir():
251
+ docs.extend(sorted(p for p in docs_dir.glob("*.md")))
252
+ return docs
253
+
254
+
255
+ def _summary_after_h1(lines: list[str], start_index: int) -> str:
256
+ """First prose line after the H1, ignoring blank lines and HTML comments.
257
+
258
+ A block of `<!-- ... -->` comments is invisible to a reader, so it cannot be the
259
+ summary: the i18n switcher marker sits directly after the H1 in the translated
260
+ READMEs. Comment-only blocks are skipped; a comment followed by prose on the same
261
+ line leaves the prose behind.
262
+ """
263
+ in_comment = False
264
+ for raw in lines[start_index:]:
265
+ index = 0
266
+ while index < len(raw):
267
+ if in_comment:
268
+ close = raw.find("-->", index)
269
+ if close == -1:
270
+ index = len(raw)
271
+ break
272
+ in_comment = False
273
+ index = close + 3
274
+ continue
275
+ start = raw.find("<!--", index)
276
+ candidate = raw[index:len(raw) if start == -1 else start].strip()
277
+ if candidate:
278
+ return candidate
279
+ if start == -1:
280
+ index = len(raw)
281
+ break
282
+ close = raw.find("-->", start + 4)
283
+ if close == -1:
284
+ in_comment = True
285
+ index = len(raw)
286
+ break
287
+ index = close + 3
288
+ return ""
289
+
290
+
291
+ def _audit_readme(path: Path, root: Path, report: Report) -> list[Item]:
292
+ relative = rel(path, root)
293
+ text, _ = read_text(path)
294
+ headings, fences, links, lines = scan_structure(text)
295
+ items: list[Item] = []
296
+
297
+ item = Item(ident="readme.h1", title="README has one H1", weight=8)
298
+ h1 = [h for h in headings if h.level == 1]
299
+ if len(h1) == 1:
300
+ item.status = "pass"
301
+ item.detail = h1[0].text
302
+ if len(h1[0].text) > 60:
303
+ report.warn("readme.title", f"H1 is {len(h1[0].text)} characters", path=relative, line=h1[0].line,
304
+ hint="keep the title under 60 characters")
305
+ elif not h1:
306
+ item.status = "fail"
307
+ item.detail = "no H1"
308
+ report.error("readme.h1", "README has no H1", path=relative, line=1)
309
+ else:
310
+ item.status = "fail"
311
+ item.detail = f"{len(h1)} H1"
312
+ report.error("readme.h1", f"README has {len(h1)} H1 headings", path=relative, line=h1[1].line)
313
+ items.append(item)
314
+
315
+ item = Item(ident="readme.summary", title="one-paragraph summary after the title", weight=6)
316
+ summary = _summary_after_h1(lines, h1[0].line) if h1 else ""
317
+ if summary and not summary.startswith(("#", "!", "[![", "|", ">", "<")):
318
+ item.status = "pass"
319
+ item.detail = summary[:80]
320
+ if EMOJI_RE.search(summary):
321
+ report.warn("readme.emoji", "emoji in the summary line", path=relative, hint="plain words read better in search results")
322
+ if SUPERLATIVES.search(summary):
323
+ report.warn("readme.superlative", "superlative in the summary line", path=relative,
324
+ hint="state a capability a reader can check")
325
+ if len(summary) > 400:
326
+ report.warn("readme.summary", f"summary paragraph is {len(summary)} characters",
327
+ path=relative, hint="one or two sentences; details belong in sections")
328
+ else:
329
+ item.status = "fail"
330
+ item.detail = summary[:80] or "no prose after the title"
331
+ report.error("readme.summary", "no summary paragraph directly after the H1", path=relative,
332
+ hint="one sentence: what it is and for whom")
333
+ items.append(item)
334
+
335
+ item = Item(ident="readme.badges", title="at least three badges", weight=5)
336
+ badge_lines = [raw for raw in lines[:40] if BADGE_RE.search(raw)]
337
+ count = len(badge_lines)
338
+ if count >= 3:
339
+ item.status = "pass"
340
+ item.detail = f"{count} badge(s)"
341
+ elif count:
342
+ item.status = "warn"
343
+ item.detail = f"{count} badge(s)"
344
+ report.warn("readme.badges", f"only {count} badge(s)", path=relative, hint="3-8 badges: CI, release, license, runtime")
345
+ else:
346
+ item.status = "fail"
347
+ item.detail = "no badges"
348
+ report.error("readme.badges", "no status badges", path=relative, hint="add CI, release and license badges")
349
+ items.append(item)
350
+
351
+ item = Item(ident="readme.toc", title="table of contents", weight=4)
352
+ has_toc = any(h.text.strip().lower().strip(":") in mdcheck.TOC_TITLES for h in headings)
353
+ item.status = "pass" if has_toc else "warn"
354
+ item.detail = "present" if has_toc else "absent"
355
+ if not has_toc:
356
+ report.warn("readme.toc", "no table of contents", path=relative,
357
+ hint="needed once the README passes ~200 lines")
358
+ items.append(item)
359
+
360
+ section_titles = {h.text.strip().lower() for h in headings}
361
+ for ident, weight, aliases, label in [
362
+ ("readme.install", 6, ("installation", "install", "установка", "быстрый старт", "quick start", "getting started"), "installation"),
363
+ ("readme.usage", 6, ("usage", "quick start", "быстрый старт", "использование", "примеры", "examples", "сценарии использования"), "usage"),
364
+ ("readme.license", 5, ("license", "лицензия"), "license"),
365
+ ]:
366
+ item = Item(ident=ident, title=f"{label} section", weight=weight)
367
+ found = any(alias in title for title in section_titles for alias in aliases)
368
+ item.status = "pass" if found else "fail"
369
+ item.detail = "present" if found else "absent"
370
+ if not found:
371
+ report.error(ident, f"README has no {label} section", path=relative,
372
+ hint=f"add a '## {label.capitalize()}' section")
373
+ items.append(item)
374
+
375
+ if "```" in text:
376
+ fences_without_lang = [f for f in fences if f.info == "" and f.closed_at]
377
+ if fences_without_lang:
378
+ report.warn("readme.code-fence", f"{len(fences_without_lang)} fence(s) without a language",
379
+ path=relative, line=fences_without_lang[0].line)
380
+
381
+ for link in links:
382
+ if link.target.startswith("http") and "github.com" in link.target and link.target.endswith((".md",)):
383
+ report.info("readme.absolute-link", f"absolute link to a markdown file: {link.target}",
384
+ path=relative, line=link.line, hint="a relative link renders in the tree and in the wiki mirror")
385
+ return items
386
+
387
+
388
+ CHANGELOG_ALLOWED_EN = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
389
+ CHANGELOG_ALLOWED_RU = ["Добавлено", "Изменено", "Устарело", "Удалено", "Исправлено", "Безопасность"]
390
+ VERSION_HEADING_RE = re.compile(
391
+ r"^##\s+\[?(?P<version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.\-]+)?)\]?"
392
+ r"(?:\s*[-—–]\s*(?P<date>\d{4}-\d{2}-\d{2}))?\s*$"
393
+ )
394
+
395
+
396
+ def _audit_changelog(path: Path, root: Path, report: Report) -> list[Item]:
397
+ relative = rel(path, root)
398
+ text, _ = read_text(path)
399
+ headings, _, _, lines = scan_structure(text)
400
+ items: list[Item] = []
401
+
402
+ item = Item(ident="changelog.versions", title="versions use semantic versioning with ISO dates", weight=6)
403
+ versions = []
404
+ for heading in headings:
405
+ if heading.level != 2:
406
+ continue
407
+ match = VERSION_HEADING_RE.match(f"## {heading.text}")
408
+ if match:
409
+ versions.append((heading, match.group("version"), match.group("date")))
410
+ elif heading.text.strip().strip("[]").lower() not in {"unreleased", "не выпущено", "невыпущено"}:
411
+ report.warn("changelog.heading", f"H2 '{heading.text}' is neither a version nor Unreleased",
412
+ path=relative, line=heading.line)
413
+ if versions:
414
+ item.status = "pass"
415
+ item.detail = f"{len(versions)} version(s)"
416
+ previous = None
417
+ for heading, version, date in versions:
418
+ if not date:
419
+ report.warn("changelog.date", f"version {version} has no ISO date", path=relative, line=heading.line,
420
+ hint="## [x.y.z] - YYYY-MM-DD")
421
+ if previous and date and date > previous:
422
+ report.error("changelog.order", f"version {version} is dated after a newer entry",
423
+ path=relative, line=heading.line, hint="newest release first")
424
+ previous = date or previous
425
+ else:
426
+ item.status = "fail"
427
+ item.detail = "no version heading"
428
+ report.error("changelog.versions", "no '## [x.y.z] - YYYY-MM-DD' heading", path=relative)
429
+ items.append(item)
430
+
431
+ item = Item(ident="changelog.unreleased", title="Unreleased section", weight=3)
432
+ has_unreleased = any(h.level == 2 and h.text.strip().strip("[]").lower() in {"unreleased", "не выпущено"} for h in headings)
433
+ item.status = "pass" if has_unreleased else "warn"
434
+ item.detail = "present" if has_unreleased else "absent"
435
+ if not has_unreleased:
436
+ report.warn("changelog.unreleased", "no Unreleased section", path=relative,
437
+ hint="## [Unreleased] keeps pending work visible")
438
+ items.append(item)
439
+
440
+ item = Item(ident="changelog.types", title="change types from the standard set", weight=5)
441
+ allowed = set(CHANGELOG_ALLOWED_EN) | set(CHANGELOG_ALLOWED_RU)
442
+ h3 = [h for h in headings if h.level == 3]
443
+ unknown = [h for h in h3 if h.text.strip() not in allowed]
444
+ if h3 and not unknown:
445
+ item.status = "pass"
446
+ item.detail = f"{len(h3)} typed section(s)"
447
+ elif unknown:
448
+ item.status = "warn"
449
+ item.detail = f"unknown: {', '.join(h.text for h in unknown[:3])}"
450
+ for heading in unknown:
451
+ report.warn("changelog.types", f"unknown change type '{heading.text}'", path=relative, line=heading.line,
452
+ hint="allowed: " + ", ".join(CHANGELOG_ALLOWED_EN))
453
+ else:
454
+ item.status = "fail"
455
+ item.detail = "no typed subsections"
456
+ report.error("changelog.types", "versions have no Added/Changed/Fixed subsections", path=relative)
457
+ items.append(item)
458
+ return items
459
+
460
+
461
+ def _audit_agents(path: Path, root: Path, report: Report) -> list[Item]:
462
+ relative = rel(path, root)
463
+ text, _ = read_text(path)
464
+ items = [Item(ident="agents.present", title="AGENTS.md present", weight=5, status="pass", detail=relative)]
465
+ lowered = text.lower()
466
+ for ident, needles, weight, label in [
467
+ ("agents.commands", ("```", "`"), 4, "copy-paste commands"),
468
+ ("agents.verify", ("проверк", "verify", "check"), 3, "a verification step"),
469
+ ("agents.traps", ("ловушк", "trap", "не делай", "do not", "запрещ"), 2, "explicit prohibitions"),
470
+ ]:
471
+ item = Item(ident=ident, title=label, weight=weight)
472
+ found = any(needle in lowered for needle in needles)
473
+ item.status = "pass" if found else "warn"
474
+ item.detail = "present" if found else "absent"
475
+ if not found:
476
+ report.warn(ident, f"AGENTS.md has no {label}", path=relative)
477
+ items.append(item)
478
+ return items
479
+
480
+
481
+ # --------------------------------------------------------------------------
482
+ # remote audit
483
+ # --------------------------------------------------------------------------
484
+
485
+ def _api(path: str) -> dict | None:
486
+ if which("gh"):
487
+ rc, out, err = run(["gh", "api", path])
488
+ if rc == 0 and out.strip():
489
+ try:
490
+ return json.loads(out)
491
+ except json.JSONDecodeError:
492
+ return None
493
+ if rc != 0 and "gh auth" in err.lower():
494
+ return None
495
+ return None
496
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
497
+ if not token:
498
+ return None
499
+ request = urllib.request.Request(
500
+ f"https://api.github.com/{path.lstrip('/')}",
501
+ headers={"User-Agent": "ghagent", "Authorization": f"Bearer {token}",
502
+ "Accept": "application/vnd.github+json"},
503
+ )
504
+ try:
505
+ with urllib.request.urlopen(request, timeout=25) as response:
506
+ return json.loads(response.read().decode("utf-8"))
507
+ except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, TimeoutError):
508
+ return None
509
+
510
+
511
+ def audit_remote(slug: str, *, config: cfg.Config | None = None, check_http: bool = False) -> AuditResult:
512
+ report = Report(title=f"ghagent audit --repo {slug}")
513
+ result = AuditResult(report=report)
514
+ repo = _api(f"repos/{slug}")
515
+ if repo is None:
516
+ report.error("remote.unavailable", f"cannot read {slug} through gh api or GITHUB_TOKEN",
517
+ hint="run `gh auth status`; without metadata no remote item can be examined")
518
+ return result
519
+
520
+ description = (repo.get("description") or "").strip()
521
+ topics = repo.get("topics") or []
522
+ homepage = (repo.get("homepage") or "").strip()
523
+ limit = (config or cfg.Config(root=Path("."))).description_max
524
+ topic_min, topic_max = 8, 20
525
+ if config:
526
+ topic_min, topic_max = config.topic_min, config.topic_max
527
+
528
+ def item(ident: str, title: str, weight: int, ok: bool, detail: str, level: str = "warn") -> None:
529
+ entry = Item(ident=ident, title=title, weight=weight, status="pass" if ok else "fail", detail=detail)
530
+ result.items.append(entry)
531
+ if not ok:
532
+ report.add(level, ident, detail)
533
+
534
+ item("meta.description", "description set", 8, bool(description), f"{len(description)} character(s)", "error")
535
+ if description:
536
+ item("meta.description-length", f"description under {limit} characters", 4, len(description) <= limit,
537
+ f"{len(description)} character(s)")
538
+ lowered = description.lower()
539
+ bad_prefix = lowered.startswith(("a tool", "a simple", "this is", "the tool"))
540
+ item("meta.description-form", "description starts with the category noun", 4, not bad_prefix,
541
+ "starts with a wasted prefix" if bad_prefix else "ok")
542
+ if EMOJI_RE.search(description):
543
+ report.warn("meta.emoji", "emoji in the repository description", hint="descriptions are metadata, not chat")
544
+ if SUPERLATIVES.search(description):
545
+ report.warn("meta.superlative", "superlative in the repository description")
546
+ item("meta.topics-count", f"{topic_min}-{topic_max} topics", 8, topic_min <= len(topics) <= topic_max,
547
+ f"{len(topics)} topic(s)")
548
+ bad_topics = [t for t in topics if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", t)]
549
+ item("meta.topics-form", "topics are lowercase and hyphenated", 4, not bad_topics,
550
+ f"offenders: {', '.join(bad_topics[:5])}" if bad_topics else "ok")
551
+ if len(topics) > 20:
552
+ report.error("meta.topics-overflow", f"{len(topics)} topics requested; the API stores at most 20")
553
+ item("meta.homepage", "homepage set", 4, bool(homepage), homepage or "empty", "info")
554
+ item("wiki.enabled", "wiki enabled", 4, bool(repo.get("has_wiki")), f"has_wiki={repo.get('has_wiki')}", "info")
555
+ license_info = (repo.get("license") or {}).get("spdx_id") or ""
556
+ item("meta.license", "license detected by GitHub", 6, license_info not in ("", "NOASSERTION"),
557
+ license_info or "not detected", "error")
558
+ item("meta.releases", "at least one release", 4, True, "unchecked here", "info")
559
+
560
+ releases = _api(f"repos/{slug}/releases?per_page=1")
561
+ if releases is None:
562
+ result.items[-1].status = "skipped"
563
+ report.info("remote.releases", "release list unavailable")
564
+ else:
565
+ result.items[-1] = Item(ident="meta.releases", title="at least one release", weight=4,
566
+ status="pass" if releases else "warn",
567
+ detail=f"{len(releases)} release(s) visible")
568
+ if not releases:
569
+ report.warn("meta.releases", "no release: the project reads as unpublished",
570
+ hint="tag vX.Y.Z with notes from CHANGELOG")
571
+
572
+ profile = _api(f"repos/{slug}/community/profile")
573
+ if profile is None:
574
+ report.info("remote.community", "community profile unavailable")
575
+ else:
576
+ percentage = profile.get("health_percentage")
577
+ entry = Item(ident="meta.community-health", title="community health", weight=5,
578
+ status="pass" if (percentage or 0) >= 80 else "warn",
579
+ detail=f"{percentage}%")
580
+ result.items.append(entry)
581
+ if (percentage or 0) < 80:
582
+ report.warn("meta.community-health", f"community health {percentage}%",
583
+ hint="add CODE_OF_CONDUCT, SUPPORT, issue forms, PR template")
584
+
585
+ if check_http and homepage:
586
+ ok = _http_ok(homepage)
587
+ item("meta.homepage-live", "homepage returns 200", 4, ok, homepage if ok else "did not answer 200")
588
+
589
+ percentage, scored, skipped = result.score()
590
+ report.data.update({
591
+ "score": percentage,
592
+ "scored_items": scored,
593
+ "skipped_items": skipped,
594
+ "items": [i.as_dict() for i in result.items],
595
+ "mode": "remote",
596
+ "repository": {
597
+ "full_name": repo.get("full_name"),
598
+ "description": description,
599
+ "topics": topics,
600
+ "homepage": homepage,
601
+ "has_wiki": repo.get("has_wiki"),
602
+ "has_pages": repo.get("has_pages"),
603
+ "license": license_info,
604
+ "default_branch": repo.get("default_branch"),
605
+ },
606
+ })
607
+ if scored == 0:
608
+ report.error("audit.no-items", "no remote item could be examined")
609
+ return result
610
+
611
+
612
+ def _http_ok(url: str) -> bool:
613
+ try:
614
+ request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "ghagent"})
615
+ with urllib.request.urlopen(request, timeout=20) as response:
616
+ return 200 <= response.status < 400
617
+ except Exception: # noqa: BLE001 - any transport failure means "not proven alive"
618
+ return False
619
+
620
+
621
+ # --------------------------------------------------------------------------
622
+ # markdown rendering
623
+ # --------------------------------------------------------------------------
624
+
625
+ def render_markdown(result: AuditResult, *, limit: int = 40) -> str:
626
+ report = result.report
627
+ percentage, scored, skipped = result.score()
628
+ lines = [
629
+ f"## Audit — {report.root or report.title}",
630
+ "",
631
+ f"* hygiene score: **{percentage}%** ({scored} item(s) scored, {skipped} skipped)",
632
+ f"* findings: {len(report.errors)} error(s), {len(report.warnings)} warning(s)",
633
+ "",
634
+ "| Item | Status | Weight | Detail |",
635
+ "|---|---|---:|---|",
636
+ ]
637
+ for item in result.items:
638
+ lines.append(f"| `{item.ident}` | {item.status} | {item.weight} | {item.detail} |")
639
+ problems = [f for f in report.findings if f.level in {"error", "warn"}]
640
+ if problems:
641
+ lines += ["", f"### Findings (first {min(limit, len(problems))} of {len(problems)})", ""]
642
+ for finding in problems[:limit]:
643
+ location = f"`{finding.path}:{finding.line}`" if finding.line else (f"`{finding.path}`" if finding.path else "—")
644
+ lines.append(f"* **{finding.level}** `{finding.rule}` {location} — {finding.message}")
645
+ return "\n".join(lines)
646
+
647
+
648
+ def exit_code_for(result: AuditResult) -> int:
649
+ return EXIT_FAIL if result.report.errors else EXIT_OK