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/install.py ADDED
@@ -0,0 +1,189 @@
1
+ """Install this pack into an agent's skill directory and verify the installation.
2
+
3
+ Three consumers are supported by name, because their skill roots are measured, not guessed:
4
+
5
+ autoclaw %USERPROFILE%\\.pi\\agent\\skills (AutoClaw / AutoClaw2)
6
+ harness %USERPROFILE%\\.dsh\\skills (HARNESS)
7
+ saga no fixed root — pass --target explicitly
8
+
9
+ `install` copies the pack and writes `.install.json` with a sha256 for every file, so
10
+ `verify` answers the only question that matters later: is the installed copy still the
11
+ one that was tested, or has it drifted?
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import shutil
20
+ from pathlib import Path
21
+
22
+ from .util import PACK_NAME, PACK_VERSION, Report, read_text, rel, write_text
23
+
24
+ PROFILE_ROOTS = {
25
+ "autoclaw": Path(".pi") / "agent" / "skills",
26
+ "harness": Path(".dsh") / "skills",
27
+ }
28
+ PACK_CONTENT = ("AGENTS.md", "README.md", "CHANGELOG.md", "CONTRIBUTING.md", "SECURITY.md",
29
+ "SUPPORT.md", "CODE_OF_CONDUCT.md", "LICENSE", "CITATION.cff",
30
+ "skills", "tools", "tests", "reference", "docs", ".github", "wiki")
31
+ INSTALL_MANIFEST = ".install.json"
32
+ SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", ".ghagent"}
33
+
34
+
35
+ def user_home() -> Path:
36
+ for variable in ("USERPROFILE", "HOME"):
37
+ value = os.environ.get(variable)
38
+ if value:
39
+ return Path(value)
40
+ return Path.home()
41
+
42
+
43
+ def resolve_target(profile: str, target: str | None) -> tuple[Path | None, str]:
44
+ if target:
45
+ return Path(target).expanduser(), ""
46
+ if profile in PROFILE_ROOTS:
47
+ return user_home() / PROFILE_ROOTS[profile], ""
48
+ return None, f"profile '{profile}' has no fixed skill root; pass --target <dir>"
49
+
50
+
51
+ def _hash(path: Path) -> str:
52
+ digest = hashlib.sha256()
53
+ digest.update(path.read_bytes())
54
+ return digest.hexdigest()
55
+
56
+
57
+ def collect_files(source: Path) -> dict[str, str]:
58
+ files: dict[str, str] = {}
59
+ for name in PACK_CONTENT:
60
+ path = source / name
61
+ if not path.exists():
62
+ continue
63
+ if path.is_dir():
64
+ for child in sorted(path.rglob("*")):
65
+ if child.is_file() and not (set(child.parts) & SKIP_DIRS):
66
+ files[rel(child, source)] = _hash(child)
67
+ else:
68
+ files[name] = _hash(path)
69
+ return files
70
+
71
+
72
+ def install(source: Path, target: Path, *, write: bool = False, report: Report | None = None) -> Report:
73
+ source = Path(source)
74
+ target = Path(target)
75
+ report = report or Report(title=f"ghagent install -> {target}")
76
+ if not (source / "tools" / "ghagent.py").exists():
77
+ report.error("install.source", f"{source} does not look like the {PACK_NAME} pack")
78
+ return report
79
+ files = collect_files(source)
80
+ if not files:
81
+ report.error("install.empty", "no files collected from the source pack")
82
+ return report
83
+
84
+ destination = target / PACK_NAME
85
+ report.data.update({"destination": str(destination), "files": len(files), "written": False})
86
+ if not write:
87
+ report.info("install.dry-run", f"would copy {len(files)} file(s) to {destination}",
88
+ hint="pass --write to install")
89
+ return report
90
+
91
+ destination.mkdir(parents=True, exist_ok=True)
92
+ copied = 0
93
+ for name, digest in files.items():
94
+ origin = source / name
95
+ if not origin.exists():
96
+ continue
97
+ aim = destination / name
98
+ aim.parent.mkdir(parents=True, exist_ok=True)
99
+ shutil.copy2(origin, aim)
100
+ copied += 1
101
+ manifest = {
102
+ "pack": PACK_NAME,
103
+ "version": PACK_VERSION,
104
+ "installed_to": str(destination),
105
+ "files": files,
106
+ }
107
+ write_text(destination / INSTALL_MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
108
+ report.data.update({"written": True, "copied": copied})
109
+ report.info("install.done", f"{copied} file(s) installed; run `ghagent selftest` from {destination}")
110
+ return report
111
+
112
+
113
+ def verify(installed: Path, *, report: Report | None = None) -> Report:
114
+ installed = Path(installed)
115
+ report = report or Report(title=f"ghagent verify {installed}")
116
+ if installed.name == PACK_NAME:
117
+ root = installed
118
+ else:
119
+ root = installed / PACK_NAME
120
+ manifest_path = root / INSTALL_MANIFEST
121
+ if not manifest_path.exists():
122
+ report.error("verify.manifest", f"{manifest_path} is absent: this copy was not installed by ghagent",
123
+ hint="run `ghagent install --write`")
124
+ return report
125
+ manifest = json.loads(read_text(manifest_path)[0])
126
+ expected: dict[str, str] = manifest.get("files", {})
127
+ if not expected:
128
+ report.error("verify.empty", "the install manifest lists no files")
129
+ return report
130
+ missing: list[str] = []
131
+ changed: list[str] = []
132
+ for name, digest in expected.items():
133
+ path = root / name
134
+ if not path.exists():
135
+ missing.append(name)
136
+ continue
137
+ if _hash(path) != digest:
138
+ changed.append(name)
139
+ present = {rel(p, root) for p in root.rglob("*") if p.is_file() and not (set(p.parts) & SKIP_DIRS)}
140
+ extra = sorted(present - set(expected) - {INSTALL_MANIFEST})
141
+ for name in missing:
142
+ report.error("verify.missing", f"file is absent: {name}")
143
+ for name in changed:
144
+ report.error("verify.changed", f"file differs from the installed copy: {name}")
145
+ for name in extra:
146
+ report.info("verify.extra", f"file not in the manifest: {name}")
147
+ report.data.update({
148
+ "version": manifest.get("version"),
149
+ "expected": len(expected),
150
+ "missing": missing,
151
+ "changed": changed,
152
+ "extra": len(extra),
153
+ })
154
+ if not missing and not changed:
155
+ report.info("verify.ok", f"{len(expected)} file(s) match version {manifest.get('version')}")
156
+ return report
157
+
158
+
159
+ def snippet(target: Path | None = None) -> str:
160
+ root = (Path(target) if target else Path("<skill-root>")) / PACK_NAME
161
+ return "\n".join([
162
+ "## Agent pack: github-agent",
163
+ "",
164
+ f"Skill root: `{root}`",
165
+ "",
166
+ "1. Read `AGENTS.md` in that directory before any repository or wiki work.",
167
+ f"2. Verify the installation: `python \"{root / 'tools' / 'ghagent.py'}\" selftest`",
168
+ "3. Use one command per job instead of reading files by hand:",
169
+ " `ghagent.py audit`, `doc`, `wiki build|publish`, `changelog check|release`, `meta apply`.",
170
+ "4. Never publish a wiki without `wiki verify` passing and `--push` given explicitly.",
171
+ "",
172
+ ])
173
+
174
+
175
+ def remove(installed: Path, *, write: bool = False, report: Report | None = None) -> Report:
176
+ """Remove an installed pack. Refuses unless the directory carries our manifest."""
177
+ installed = Path(installed)
178
+ report = report or Report(title=f"ghagent uninstall {installed}")
179
+ root = installed if installed.name == PACK_NAME else installed / PACK_NAME
180
+ manifest_path = root / INSTALL_MANIFEST
181
+ if not manifest_path.exists():
182
+ report.error("uninstall.refused", f"{root} has no {INSTALL_MANIFEST}; refusing to delete an unknown directory")
183
+ return report
184
+ if not write:
185
+ report.info("uninstall.dry-run", f"would delete {root}", hint="pass --write to remove")
186
+ return report
187
+ shutil.rmtree(root)
188
+ report.info("uninstall.done", f"removed {root}")
189
+ return report
ghalib/mdcheck.py ADDED
@@ -0,0 +1,424 @@
1
+ """Documentation checks: text hygiene, GitHub-flavoured markdown structure, links, privacy.
2
+
3
+ Every rule reports the number of objects it actually examined; a rule that examined
4
+ nothing is reported as `skipped`, never as `pass` (a green check that inspected zero
5
+ objects is a defect, not a success).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from urllib.parse import unquote, urlparse
14
+
15
+ from .util import (
16
+ FENCE_RE,
17
+ IMAGE_RE,
18
+ INLINE_LINK_RE,
19
+ MARKDOWN_SUFFIXES,
20
+ Report,
21
+ iter_text_files,
22
+ parse_table,
23
+ prose_lines,
24
+ read_text,
25
+ rel,
26
+ scan_structure,
27
+ )
28
+
29
+ ALLOWED_ALERTS = {"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"}
30
+ # AGENTS.md is the import entry point an agent reads first: a map, not a manual. The
31
+ # canonical root file is the only one this rule measures — translations and nested
32
+ # copies are checked by their own contracts.
33
+ AGENTS_MAX_LINES = 120
34
+ TOC_TITLES = {"содержание", "оглавление", "contents", "table of contents", "toc", "навигация"}
35
+ SECRET_PATTERNS = [
36
+ ("github-token", re.compile(r"\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b")),
37
+ ("aws-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
38
+ ("private-key", re.compile(r"-----BEGIN (?:RSA |OPENSSH |EC |PGP )?PRIVATE KEY-----")),
39
+ ("slack-token", re.compile(r"\bxox[abprs]-[0-9A-Za-z-]{10,}\b")),
40
+ ("bearer-literal", re.compile(r"\bBearer\s+[A-Za-z0-9._\-]{20,}\b")),
41
+ ]
42
+ ABS_PATH_PATTERNS = [
43
+ re.compile(r"\b[A-Za-z]:[\\/]{1,2}(?:Users|Documents and Settings)[\\/]{1,2}[^\\/\s`'\"]+"),
44
+ re.compile(r"(?<![\w.])/(?:home|Users)/[a-z0-9._-]+/", re.IGNORECASE),
45
+ ]
46
+ PERSONAL_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b")
47
+ SAFE_EMAIL_DOMAINS = {
48
+ "users.noreply.github.com", "example.com", "example.org", "example.net",
49
+ "localhost", "deynekin.com",
50
+ }
51
+ KEBAB_TOPIC_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
52
+
53
+
54
+ @dataclass
55
+ class RuleStats:
56
+ examined: int = 0
57
+ skipped_reason: str = ""
58
+
59
+ def touched(self) -> None:
60
+ self.examined += 1
61
+
62
+
63
+ @dataclass
64
+ class DocCheckOptions:
65
+ ignore: set[str] = field(default_factory=set)
66
+ privacy: bool = True
67
+ toc: bool = True
68
+ line_length: int = 200
69
+ max_line_rule: bool = True
70
+
71
+
72
+ class DocChecker:
73
+ def __init__(self, root: Path, report: Report, options: DocCheckOptions | None = None) -> None:
74
+ self.root = Path(root)
75
+ self.report = report
76
+ self.options = options or DocCheckOptions()
77
+ self.stats: dict[str, RuleStats] = {}
78
+ self.anchor_cache: dict[Path, set[str]] = {}
79
+
80
+ # -- infrastructure ----------------------------------------------------
81
+ def _rule(self, rule: str) -> bool:
82
+ """Return True when the rule is active, and register it as examined."""
83
+ stats = self.stats.setdefault(rule, RuleStats())
84
+ if rule in self.options.ignore:
85
+ stats.skipped_reason = "ignored by --ignore"
86
+ return False
87
+ return True
88
+
89
+ def _hit(self, rule: str) -> None:
90
+ self.stats.setdefault(rule, RuleStats()).touched()
91
+
92
+ def anchors_of(self, path: Path) -> set[str]:
93
+ resolved = path.resolve()
94
+ if resolved not in self.anchor_cache:
95
+ try:
96
+ text, _ = read_text(path)
97
+ except (OSError, ValueError):
98
+ self.anchor_cache[resolved] = set()
99
+ else:
100
+ headings, _, _, _ = scan_structure(text)
101
+ self.anchor_cache[resolved] = {h.anchor for h in headings}
102
+ return self.anchor_cache[resolved]
103
+
104
+ # -- entry point -------------------------------------------------------
105
+ def check_tree(self, *, suffixes: set[str] | None = None) -> None:
106
+ files = list(iter_text_files(self.root, suffix_set=suffixes))
107
+ if not files:
108
+ self.report.error("doc.no-text-files", f"no text files found under {self.root}; nothing was examined")
109
+ return
110
+ for path in files:
111
+ self.check_file(path)
112
+ self._report_coverage()
113
+
114
+ def check_file(self, path: Path) -> None:
115
+ relative = rel(path, self.root)
116
+ try:
117
+ text, meta = read_text(path)
118
+ except ValueError as exc:
119
+ if self._rule("doc.utf8"):
120
+ self._hit("doc.utf8")
121
+ self.report.error("doc.utf8", str(exc), path=relative,
122
+ hint="re-save the file as UTF-8 (no conversion table)")
123
+ return
124
+ except OSError as exc:
125
+ self.report.error("doc.unreadable", str(exc), path=relative)
126
+ return
127
+
128
+ if self._rule("doc.bom"):
129
+ self._hit("doc.bom")
130
+ if meta.has_bom:
131
+ self.report.error("doc.bom", "file starts with a UTF-8 BOM", path=relative, line=1,
132
+ hint="save as UTF-8 without BOM")
133
+ if self._rule("doc.crlf"):
134
+ self._hit("doc.crlf")
135
+ if meta.has_crlf:
136
+ self.report.error("doc.crlf", f"{meta.crlf_count} CRLF line ending(s)",
137
+ path=relative, line=1, hint="normalise to LF")
138
+ if self._rule("doc.final-newline"):
139
+ self._hit("doc.final-newline")
140
+ if not meta.ends_with_newline and meta.size:
141
+ self.report.warn("doc.final-newline", "file does not end with a newline", path=relative)
142
+ if self._rule("doc.tab-indent"):
143
+ self._hit("doc.tab-indent")
144
+ if meta.has_tab_indent:
145
+ self.report.warn("doc.tab-indent", "tab-indented block", path=relative,
146
+ hint="use spaces (4) or a fence")
147
+
148
+ if path.suffix.lower() in MARKDOWN_SUFFIXES:
149
+ self.check_markdown(path, text, relative)
150
+ if self.options.privacy:
151
+ self.check_privacy(relative, text)
152
+
153
+ # -- markdown ----------------------------------------------------------
154
+ def check_markdown(self, path: Path, text: str, relative: str) -> None:
155
+ headings, fences, links, lines = scan_structure(text)
156
+ # A template carries unresolved placeholders on purpose: `<owner>`, `(<path>)`.
157
+ # The marker is explicit so that a normal document cannot opt out by accident.
158
+ is_template = "<!-- ghagent: template -->" in text
159
+ if is_template:
160
+ self.report.data.setdefault("templates", []).append(relative)
161
+
162
+ if self._rule("md.h1"):
163
+ self._hit("md.h1")
164
+ h1 = [h for h in headings if h.level == 1]
165
+ is_template = ".github" in path.parts or "template" in path.name.lower()
166
+ is_template = is_template or "<!-- ghagent: template -->" in text
167
+ if not h1:
168
+ if is_template:
169
+ self.report.info("md.h1", "template without an H1 (expected: the body starts at H2)",
170
+ path=relative, line=1)
171
+ else:
172
+ self.report.error("md.h1", "no H1 heading", path=relative, line=1,
173
+ hint="a document needs exactly one H1")
174
+ elif len(h1) > 1:
175
+ extra = ", ".join(str(h.line) for h in h1[1:])
176
+ self.report.error("md.h1", f"{len(h1)} H1 headings (extra at line {extra})",
177
+ path=relative, line=h1[1].line, hint="keep one H1, demote the rest")
178
+
179
+ if self._rule("md.heading-skip"):
180
+ self._hit("md.heading-skip")
181
+ previous = 0
182
+ for heading in headings:
183
+ if previous and heading.level - previous > 1:
184
+ self.report.warn("md.heading-skip",
185
+ f"H{previous} -> H{heading.level} skips a level",
186
+ path=relative, line=heading.line)
187
+ previous = heading.level
188
+
189
+ self._check_agents_length(path, text, relative)
190
+
191
+ if self._rule("md.fence"):
192
+ for fence in fences:
193
+ self._hit("md.fence")
194
+ if fence.closed_at is None:
195
+ self.report.error("md.fence", f"unclosed {fence.marker} fence",
196
+ path=relative, line=fence.line, hint="close the code block")
197
+ elif not fence.info:
198
+ self.report.warn("md.fence", "code fence without a language tag",
199
+ path=relative, line=fence.line,
200
+ hint="add the language (`bash`, `json`, `text`)")
201
+
202
+ self._check_tables(lines, relative)
203
+
204
+ structural_links = not is_template
205
+
206
+ if self._rule("md.image-alt"):
207
+ for link in links:
208
+ if link.is_image:
209
+ self._hit("md.image-alt")
210
+ if not link.text.strip():
211
+ self.report.warn("md.image-alt", f"image without alt text: {link.target}",
212
+ path=relative, line=link.line)
213
+
214
+ self._check_alerts(lines, relative)
215
+
216
+ if self.options.max_line_rule and self._rule("doc.line-length"):
217
+ for number, raw in prose_lines(text):
218
+ self._hit("doc.line-length")
219
+ stripped = raw.strip()
220
+ if len(raw) > self.options.line_length and not stripped.startswith("|") and "http" not in stripped:
221
+ self.report.info("doc.line-length",
222
+ f"prose line is {len(raw)} characters",
223
+ path=relative, line=number,
224
+ hint=f"wrap around {self.options.line_length - 40}")
225
+ if raw.rstrip("\n").endswith(" ") and raw.strip():
226
+ stripped_no_break = raw.rstrip()
227
+ if stripped_no_break.endswith(" "):
228
+ self.report.warn("md.hard-break", "trailing double space (markdown hard break)",
229
+ path=relative, line=number, hint="use a blank line instead")
230
+
231
+ self._check_links(path, relative, links, headings, text) if structural_links else None
232
+ if self.options.toc and structural_links:
233
+ self._check_toc(relative, headings, lines)
234
+
235
+ def _check_agents_length(self, path: Path, text: str, relative: str) -> None:
236
+ """AGENTS.md is a map: warn when the canonical root file becomes a manual.
237
+
238
+ Only `<root>/AGENTS.md` is measured. A translation (`AGENTS.ru.md`) mirrors the
239
+ English file, and a nested copy is a different document with its own contract;
240
+ warning about either would teach a reader to ignore the rule.
241
+ """
242
+ if not self._rule("agents.md-length"):
243
+ return
244
+ if path.resolve() != (self.root / "AGENTS.md").resolve():
245
+ return
246
+ self._hit("agents.md-length")
247
+ line_count = len(text.splitlines())
248
+ if line_count > AGENTS_MAX_LINES:
249
+ self.report.warn("agents.md-length",
250
+ f"AGENTS.md is {line_count} lines (limit {AGENTS_MAX_LINES})",
251
+ path=relative, line=1,
252
+ hint="AGENTS.md is a map: keep it short and move detail into docs/")
253
+
254
+ def _check_tables(self, lines: list[str], relative: str) -> None:
255
+ if not self._rule("md.table"):
256
+ return
257
+ index = 0
258
+ while index < len(lines):
259
+ if "|" in lines[index] and index + 1 < len(lines) and re.match(r"^\s*\|?[\s:|-]+\|[\s:|-]*$", lines[index + 1]) and "-" in lines[index + 1]:
260
+ table = parse_table(lines, index)
261
+ if table:
262
+ self._hit("md.table")
263
+ if table["header_cells"] != table["align_cells"]:
264
+ self.report.error("md.table",
265
+ f"header has {table['header_cells']} cell(s), separator has {table['align_cells']}",
266
+ path=relative, line=table["start_line"] + 1)
267
+ for offset, row in enumerate(table["rows"]):
268
+ if len(row) != table["header_cells"]:
269
+ self.report.error("md.table",
270
+ f"row has {len(row)} cell(s), header has {table['header_cells']}",
271
+ path=relative, line=table["start_line"] + 2 + offset)
272
+ index = table["end_line"]
273
+ continue
274
+ index += 1
275
+
276
+ def _check_alerts(self, lines: list[str], relative: str) -> None:
277
+ if not self._rule("md.alert"):
278
+ return
279
+ for number, raw in enumerate(lines, start=1):
280
+ match = re.match(r"^\s*>\s*\[!([A-Za-z]+)\]", raw)
281
+ if not match:
282
+ continue
283
+ self._hit("md.alert")
284
+ if match.group(1).upper() not in ALLOWED_ALERTS:
285
+ self.report.warn("md.alert", f"unknown alert type [!{match.group(1)}]",
286
+ path=relative, line=number,
287
+ hint="allowed: " + ", ".join(sorted(ALLOWED_ALERTS)))
288
+
289
+ def _check_links(self, path: Path, relative: str, links, headings, text: str) -> None:
290
+ local_anchors = {h.anchor for h in headings}
291
+ for link in links:
292
+ if link.kind != "inline":
293
+ continue
294
+ target = link.target.strip()
295
+ if link.is_image and target.startswith(("http://", "//")):
296
+ if self._rule("md.image-http"):
297
+ self._hit("md.image-http")
298
+ self.report.warn("md.image-http", f"image loaded over plain http: {target}",
299
+ path=relative, line=link.line,
300
+ hint="use https: a mixed-content image is blocked by browsers")
301
+ if not target or target.startswith(("http://", "https://", "mailto:", "tel:", "ftp://")):
302
+ continue
303
+ if any(token in target for token in ("<", ">", "{{", "}}", "${")):
304
+ continue # placeholder kept on purpose in templates
305
+ if self._rule("md.link-local"):
306
+ self._hit("md.link-local")
307
+ self._resolve_link(path, relative, link, local_anchors)
308
+
309
+ def _resolve_link(self, path: Path, relative: str, link, local_anchors: set[str]) -> None:
310
+ target = link.target.strip()
311
+ anchorless = target.split("#", 1)[0]
312
+ anchor = target.split("#", 1)[1] if "#" in target else ""
313
+ if anchorless:
314
+ candidate = (path.parent / unquote(anchorless)).resolve()
315
+ if candidate.is_dir():
316
+ level = "info" if anchorless.endswith("/") else "warn"
317
+ self.report.add(level, "md.link-dir", f"link points to a directory: {target}",
318
+ path=relative, line=link.line,
319
+ hint="a trailing slash is a browsable directory on GitHub; a file link is better for readers")
320
+ return
321
+ if not candidate.exists():
322
+ self.report.error("md.link-local", f"target does not exist: {target}",
323
+ path=relative, line=link.line,
324
+ hint="fix the path or drop the link")
325
+ return
326
+ if anchor and candidate.suffix.lower() in MARKDOWN_SUFFIXES:
327
+ if anchor.lower() not in {a.lower() for a in self.anchors_of(candidate)}:
328
+ self.report.error("md.link-anchor",
329
+ f"anchor #{anchor} not found in {anchorless}",
330
+ path=relative, line=link.line)
331
+ return
332
+ if anchor:
333
+ normalised = anchor.lower()
334
+ if normalised.startswith("user-content-"):
335
+ normalised = normalised[len("user-content-"):]
336
+ if normalised not in {a.lower() for a in local_anchors}:
337
+ self.report.error("md.link-anchor", f"anchor #{anchor} not found in this document",
338
+ path=relative, line=link.line,
339
+ hint="heading anchors are lowercased, punctuation dropped, spaces to hyphens")
340
+
341
+ def _check_toc(self, relative: str, headings, lines: list[str]) -> None:
342
+ if not self._rule("md.toc"):
343
+ return
344
+ toc_start = None
345
+ for heading in headings:
346
+ if heading.text.strip().lower().strip(":") in TOC_TITLES:
347
+ toc_start = heading
348
+ break
349
+ if toc_start is None:
350
+ return
351
+ self._hit("md.toc")
352
+ listed: set[str] = set()
353
+ start_index = toc_start.line # 0-based index of the line after the heading
354
+ for raw in lines[start_index:]:
355
+ if re.match(r"^#{1,6}\s", raw):
356
+ break
357
+ for match in INLINE_LINK_RE.finditer(raw):
358
+ target = match.group(2)
359
+ if target.startswith("#"):
360
+ listed.add(target[1:].lower())
361
+ known = {h.anchor.lower() for h in headings if h.anchor}
362
+ for anchor in sorted(listed - known):
363
+ self.report.error("md.toc", f"table of contents lists #{anchor}, which no heading produces",
364
+ path=relative, line=toc_start.line)
365
+ missing = [h for h in headings
366
+ if h.level == 2
367
+ and h is not toc_start
368
+ and h.anchor.lower() not in listed]
369
+ if missing:
370
+ sample = ", ".join(f"H2 '{h.text[:40]}'" for h in missing[:5])
371
+ self.report.warn("md.toc", f"{len(missing)} H2 section(s) missing from the table of contents: {sample}",
372
+ path=relative, line=toc_start.line)
373
+
374
+ # -- privacy -----------------------------------------------------------
375
+ def check_privacy(self, relative: str, text: str) -> None:
376
+ if self._rule("privacy.secret"):
377
+ for name, pattern in SECRET_PATTERNS:
378
+ for match in pattern.finditer(text):
379
+ self._hit("privacy.secret")
380
+ line = text.count("\n", 0, match.start()) + 1
381
+ self.report.error("privacy.secret", f"{name} literal in file",
382
+ path=relative, line=line,
383
+ hint="remove the value; reference an environment variable by name")
384
+ if self._rule("privacy.abs-path"):
385
+ for pattern in ABS_PATH_PATTERNS:
386
+ for match in pattern.finditer(text):
387
+ self._hit("privacy.abs-path")
388
+ line = text.count("\n", 0, match.start()) + 1
389
+ self.report.error("privacy.abs-path",
390
+ "absolute machine path in a published file",
391
+ path=relative, line=line,
392
+ hint="use a relative path or a <placeholder>")
393
+ if self._rule("privacy.email"):
394
+ for match in PERSONAL_EMAIL_RE.finditer(text):
395
+ domain = match.group(1).lower()
396
+ if domain in SAFE_EMAIL_DOMAINS:
397
+ continue
398
+ self._hit("privacy.email")
399
+ line = text.count("\n", 0, match.start()) + 1
400
+ self.report.warn("privacy.email",
401
+ f"personal e-mail address ({domain})",
402
+ path=relative, line=line,
403
+ hint="use a role address or a noreply address")
404
+
405
+ # -- coverage ----------------------------------------------------------
406
+ def _report_coverage(self) -> None:
407
+ skipped = {rule: s for rule, s in self.stats.items() if s.examined == 0 and s.skipped_reason}
408
+ untouched = {rule: s for rule, s in self.stats.items() if s.examined == 0 and not s.skipped_reason}
409
+ for rule in sorted(untouched):
410
+ self.report.info("coverage.skipped", f"rule {rule} examined nothing on this tree")
411
+ self.report.data["rules_examined"] = {rule: s.examined for rule, s in sorted(self.stats.items())}
412
+ self.report.data["rules_ignored"] = sorted(skipped)
413
+
414
+
415
+ def check_paths(root: Path, targets: list[Path], options: DocCheckOptions | None = None) -> Report:
416
+ report = Report(title="ghagent doc", root=str(root))
417
+ checker = DocChecker(root, report, options)
418
+ for path in targets:
419
+ if path.suffix.lower() in MARKDOWN_SUFFIXES or path.suffix.lower() in {".txt", ".yml", ".yaml", ".json", ".cff", ".ps1", ".py", ".sh"}:
420
+ checker.check_file(path)
421
+ else:
422
+ checker.check_file(path)
423
+ checker._report_coverage()
424
+ return report