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/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
|