github-agent-cli 1.2.2__py3-none-any.whl → 1.3.0__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 CHANGED
@@ -27,16 +27,16 @@ HERE = Path(__file__).resolve().parent
27
27
  if str(HERE) not in sys.path:
28
28
  sys.path.insert(0, str(HERE))
29
29
 
30
- from ghalib import assets as assets_module # noqa: E402
31
- from ghalib import audit as audit_module # noqa: E402
32
- from ghalib import changelog as changelog_module # noqa: E402
33
- from ghalib import config as cfg # noqa: E402
34
- from ghalib import install as install_module # noqa: E402
35
- from ghalib import mdcheck # noqa: E402
36
- from ghalib import meta as meta_module # noqa: E402
37
- from ghalib import report as report_module # noqa: E402
38
- from ghalib import wiki as wiki_module # noqa: E402
39
- from ghalib.util import ( # noqa: E402
30
+ from ghalib import assets as assets_module # noqa: E402
31
+ from ghalib import audit as audit_module # noqa: E402
32
+ from ghalib import changelog as changelog_module # noqa: E402
33
+ from ghalib import config as cfg # noqa: E402
34
+ from ghalib import install as install_module # noqa: E402
35
+ from ghalib import mdcheck # noqa: E402
36
+ from ghalib import meta as meta_module # noqa: E402
37
+ from ghalib import report as report_module # noqa: E402
38
+ from ghalib import wiki as wiki_module # noqa: E402
39
+ from ghalib.util import ( # noqa: E402
40
40
  EXIT_FAIL,
41
41
  EXIT_OK,
42
42
  EXIT_USAGE,
@@ -46,7 +46,6 @@ from ghalib.util import ( # noqa: E402
46
46
  emit,
47
47
  fail,
48
48
  force_utf8_stdio,
49
- read_text,
50
49
  rel,
51
50
  write_text,
52
51
  )
@@ -138,22 +137,33 @@ def cmd_audit(args: argparse.Namespace) -> int:
138
137
 
139
138
  def cmd_doc(args: argparse.Namespace) -> int:
140
139
  root = Path(args.root)
140
+ config = cfg.load(root)
141
+ # `.ghagent.json` carries `ignore_rules` for the whole pack; `audit` applied them and
142
+ # `doc` did not, so the two commands disagreed about the same tree.
143
+ ignore = {item.strip() for item in args.ignore.split(",") if item.strip()}
144
+ ignore |= {item.strip() for item in config.ignore_rules if item.strip()}
141
145
  options = mdcheck.DocCheckOptions(
142
- ignore={item.strip() for item in args.ignore.split(",") if item.strip()},
146
+ ignore=ignore,
143
147
  privacy=not args.no_privacy,
144
148
  toc=not args.no_toc,
149
+ safe_email_domains=set(config.safe_email_domains),
150
+ wiki_dirs={config.wiki_dir} if config.wiki_dir else set(),
145
151
  )
146
152
  report = Report(title="ghagent doc", root=str(root))
147
153
  checker = mdcheck.DocChecker(root, report, options)
148
- # Wiki sources use wiki-namespace links (page names without .md); they are checked by
149
- # `wiki build`/`wiki verify`, not as repository links. Opt in with --include-wiki.
150
- skip_dirs = {".git", "node_modules", "vendor", ".ghagent", ".venv", "__pycache__"}
151
- if not args.include_wiki:
152
- skip_dirs.add("wiki")
153
- targets = [Path(item) for item in args.paths] if args.paths else sorted(
154
- path for path in root.rglob("*.md")
155
- if not (set(path.parts) & skip_dirs)
156
- )
154
+ if args.paths:
155
+ targets = [Path(item) for item in args.paths]
156
+ else:
157
+ # One walker for the whole pack: it knows which directories are build output or
158
+ # tool state (`.git`, `.swarm`, `node_modules`), so `doc` and `privacy` see the
159
+ # same tree. The wiki sources keep their own contract (`wiki build`/`wiki verify`).
160
+ targets = []
161
+ exclude = set(config.exclude_dirs)
162
+ for path in mdcheck.iter_text_files(root, suffix_set=set(mdcheck.MARKDOWN_SUFFIXES), exclude=exclude):
163
+ if not args.include_wiki and config.wiki_dir in path.relative_to(root).parts:
164
+ continue
165
+ targets.append(path)
166
+ targets.sort()
157
167
  if not targets:
158
168
  return fail("no markdown files found to check")
159
169
  for path in targets:
@@ -171,7 +181,7 @@ def cmd_privacy(args: argparse.Namespace) -> int:
171
181
  "agents.md-length"}
172
182
  report = Report(title="ghagent privacy", root=str(root))
173
183
  checker = mdcheck.DocChecker(root, report, options)
174
- for path in mdcheck.iter_text_files(root):
184
+ for path in mdcheck.iter_text_files(root, exclude=set(cfg.load(root).exclude_dirs)):
175
185
  checker.check_file(path)
176
186
  checker._report_coverage()
177
187
  return emit(report, args.json)
@@ -180,19 +190,23 @@ def cmd_privacy(args: argparse.Namespace) -> int:
180
190
  def cmd_wiki(args: argparse.Namespace) -> int:
181
191
  root = Path(args.root)
182
192
  config = cfg.load(root)
193
+ # None means "not given"; the default is applied here so that `verify` can tell an
194
+ # explicit `--wiki-dir <clone>` from the default `wiki/`.
195
+ wiki_dir_given = args.wiki_dir is not None
196
+ wiki_dir = args.wiki_dir or "wiki"
183
197
  if args.action == "init":
184
198
  report = wiki_module.scaffold(root, slug=args.repo or config.owner_repo, title=args.title,
185
- wiki_dir=args.wiki_dir, from_docs=not args.no_docs)
199
+ wiki_dir=wiki_dir, from_docs=not args.no_docs)
186
200
  return emit(report, args.json)
187
201
  if args.action == "build":
188
- report = wiki_module.build(root, wiki_dir=args.wiki_dir, manifest_path=args.manifest,
202
+ report = wiki_module.build(root, wiki_dir=wiki_dir, manifest_path=args.manifest,
189
203
  out_dir=Path(args.out) if args.out else None)
190
204
  if not args.json and report.data.get("out"):
191
205
  print(f"built {report.data.get('pages')} page(s) into {report.data['out']}")
192
206
  return emit(report, args.json)
193
207
  if args.action == "verify":
194
- out = Path(args.out) if args.out else root / ".ghagent" / "wiki"
195
- report = wiki_module.verify(out)
208
+ report = wiki_module.verify_target(root, out=Path(args.out) if args.out else None,
209
+ wiki_dir=wiki_dir, wiki_dir_given=wiki_dir_given)
196
210
  return emit(report, args.json)
197
211
  if args.action == "publish":
198
212
  slug = args.repo or config.owner_repo
@@ -272,9 +286,29 @@ def cmd_meta(args: argparse.Namespace) -> int:
272
286
  return report.exit_code()
273
287
  if args.action == "check":
274
288
  report = Report(title="ghagent meta check")
275
- for level, rule, message in meta_module.validate_description(args.description):
289
+ slug = args.repo or ""
290
+ description = args.description
291
+ topics = [item.strip() for item in args.topics.split(",") if item.strip()] if args.topics else []
292
+ if slug:
293
+ # `--repo` used to be accepted and ignored: the command validated the empty
294
+ # arguments and reported "description is empty, 0 topics" for a repository that
295
+ # had both. A remote that cannot be read is an error, not an empty field.
296
+ current = meta_module.read_remote(slug)
297
+ if current is None:
298
+ report.error("meta.remote", f"cannot read {slug} through gh api",
299
+ hint="run `gh auth status`; an unread remote is not an empty description")
300
+ return emit(report, args.json)
301
+ if description is None:
302
+ description = (current.get("description") or "").strip()
303
+ if not topics:
304
+ topics = list(current.get("topics") or [])
305
+ report.data["source"] = f"gh api repos/{slug}"
306
+ report.data["topics"] = topics
307
+ report.data["description"] = description
308
+ for level, rule, message in meta_module.validate_description(description):
276
309
  report.add(level, rule, message)
277
- topics = [item.strip() for item in args.topics.split(",") if item.strip()]
310
+ if not topics:
311
+ report.info("meta.source", "topics were not given and no remote was read")
278
312
  for level, rule, message in meta_module.check_topics(topics, minimum=config.topic_min,
279
313
  maximum=config.topic_max):
280
314
  report.add(level, rule, message)
@@ -283,11 +317,12 @@ def cmd_meta(args: argparse.Namespace) -> int:
283
317
  slug = args.repo or config.owner_repo
284
318
  if not slug:
285
319
  return fail("meta apply needs --repo owner/name or owner_repo in .ghagent.json")
286
- topics = [item.strip() for item in args.topics.split(",") if item.strip()] if args.topics else None
320
+ apply_topics: list[str] | None = ([item.strip() for item in args.topics.split(",") if item.strip()]
321
+ if args.topics else None)
287
322
  report = meta_module.apply_metadata(
288
323
  slug,
289
324
  description=args.description,
290
- topics=topics,
325
+ topics=apply_topics,
291
326
  homepage=args.homepage,
292
327
  merge_topics=not args.replace_topics,
293
328
  write=args.write,
@@ -431,7 +466,7 @@ def build_parser() -> argparse.ArgumentParser:
431
466
  wiki_parser.add_argument("--root", default=".")
432
467
  wiki_parser.add_argument("--repo", default="")
433
468
  wiki_parser.add_argument("--title", default="Project")
434
- wiki_parser.add_argument("--wiki-dir", default="wiki")
469
+ wiki_parser.add_argument("--wiki-dir", default=None)
435
470
  wiki_parser.add_argument("--manifest", default="wiki/wiki.json")
436
471
  wiki_parser.add_argument("--out", default="")
437
472
  wiki_parser.add_argument("--push", action="store_true", help="actually push to the wiki remote")
ghalib/assets.py CHANGED
@@ -16,20 +16,24 @@ nothing at all. A missing Pillow lowers the output format, never the honesty of
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- import base64
20
19
  import re
21
20
  import struct
22
21
  import zlib
23
22
  from dataclasses import dataclass, field
24
23
  from pathlib import Path
25
24
 
26
- from .util import IMAGE_RE, Report, iter_text_files, read_text, rel
25
+ from .util import IMAGE_RE, Report, read_text, rel
27
26
 
28
27
  SOCIAL_SIZE = (1280, 640)
29
28
  SOCIAL_MIN = (640, 320)
30
29
  SOCIAL_MAX_BYTES = 1_000_000
31
30
  IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif"}
32
31
  SOCIAL_ACCEPTED = {".png", ".jpg", ".jpeg", ".gif"}
32
+ # An emoji in an H1 is fine on GitHub and unreadable on a rendered card: the font set has
33
+ # no colour glyphs, so it lands as a box or disappears. The banner strips it.
34
+ EMOJI_RE = re.compile(
35
+ "[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF\u2b00-\u2bff\uFE0F]"
36
+ )
33
37
 
34
38
  PALETTE = {
35
39
  "bg": "#0d1117",
@@ -120,26 +124,29 @@ def _escape(text: str) -> str:
120
124
 
121
125
 
122
126
  def _wrap(text: str, limit: int, max_lines: int) -> list[str]:
127
+ """Wrap into at most `max_lines` lines of about `limit` characters.
128
+
129
+ The last line is filled before anything is dropped: an earlier version stopped at
130
+ `max_lines - 1` lines and marked the rest as cut, so a 38-character title was reported
131
+ as truncated although two lines had room for it.
132
+ """
123
133
  words = text.split()
124
134
  lines: list[str] = []
125
135
  current = ""
126
136
  for word in words:
127
137
  candidate = f"{current} {word}".strip()
128
- if len(candidate) <= limit:
138
+ if not current or len(candidate) <= limit:
129
139
  current = candidate
130
140
  continue
131
- if current:
132
- lines.append(current)
141
+ lines.append(current)
133
142
  current = word
134
- if len(lines) == max_lines - 1:
143
+ if len(lines) == max_lines:
135
144
  break
136
- if current and len(lines) < max_lines:
137
- lines.append(current)
138
- if not lines:
139
- return []
140
- remainder = " ".join(words)[len(" ".join(lines)):].strip()
141
- if remainder and lines:
142
- lines[-1] = lines[-1].rstrip(" .,;") + "…"
145
+ else:
146
+ if current:
147
+ lines.append(current)
148
+ return lines[:max_lines]
149
+ lines[-1] = lines[-1].rstrip(" .,;") + "…"
143
150
  return lines
144
151
 
145
152
 
@@ -261,6 +268,11 @@ def render_png(spec: BannerSpec) -> bytes | None:
261
268
  return buffer.getvalue()
262
269
 
263
270
 
271
+ def _truncated(lines: list[str]) -> bool:
272
+ """`_wrap` marks a loss of text with an ellipsis; the caller must say so out loud."""
273
+ return bool(lines) and lines[-1].endswith("…")
274
+
275
+
264
276
  def spec_from_repository(root: Path, *, title: str = "", tagline: str = "", command: str = "",
265
277
  chips: list[str] | None = None) -> BannerSpec:
266
278
  from . import meta as meta_module
@@ -277,12 +289,19 @@ def spec_from_repository(root: Path, *, title: str = "", tagline: str = "", comm
277
289
  if not summary:
278
290
  summary = facts.get("summary", "")
279
291
  languages = [language for language, _count in facts.get("languages", [])[:3]]
292
+ # Chips describe what the project IS, not what its tooling is written in: a PHP library
293
+ # whose scripts happen to be Python and shell was advertised as "php python shell".
294
+ # Declared keywords come first; languages are only the fallback.
295
+ keywords = [str(word) for word in facts.get("declared_keywords", []) if str(word).strip()][:3]
296
+ default_chips = keywords or languages
297
+ resolved_title = title or facts.get("title") or Path(root).name
298
+ resolved_title = EMOJI_RE.sub("", resolved_title).strip() or Path(root).name
280
299
  return BannerSpec(
281
- title=title or facts.get("title") or Path(root).name,
300
+ title=resolved_title,
282
301
  tagline=tagline or summary[:180],
283
302
  command=command,
284
- chips=(chips if chips is not None else languages + ["mit"])[:6],
285
- footer=" · ".join(filter(None, ["MIT", ", ".join(languages)])),
303
+ chips=(chips if chips is not None else default_chips + ["mit"])[:6],
304
+ footer=" · ".join(filter(None, ["MIT", languages[0] if languages else ""])),
286
305
  )
287
306
 
288
307
 
@@ -308,6 +327,17 @@ def write_assets(root: Path, *, out_dir: str = "assets", spec: BannerSpec | None
308
327
  "written": [],
309
328
  "png_unavailable": "" if png else "Pillow is not installed: only the SVG was produced",
310
329
  })
330
+ # A silently cut title is a defect of the render, not of the reader's screen: the PNG
331
+ # is what the social preview shows and it cannot grow.
332
+ if _truncated(_wrap(spec.title, 26, 2)):
333
+ report.warn("assets.truncated", f"the title is cut at two lines ({len(spec.title)} characters)",
334
+ hint="shorten --title: it renders as two lines of about 26 characters")
335
+ if _truncated(_wrap(spec.tagline, 62, 3)):
336
+ report.warn("assets.truncated", f"the tagline is cut at three lines ({len(spec.tagline)} characters)",
337
+ hint="shorten --tagline: it renders as three lines of about 62 characters")
338
+ if not spec.command:
339
+ report.info("assets.no-command", "the banner carries no command line",
340
+ hint="pass --command 'ghagent audit .' to show one")
311
341
  if not png:
312
342
  report.warn("assets.no-pillow", "Pillow is not available, so no PNG was rendered",
313
343
  hint="a social preview must be PNG/JPG/GIF; install Pillow or supply your own image")
ghalib/audit.py CHANGED
@@ -27,7 +27,6 @@ from .util import (
27
27
  EXIT_FAIL,
28
28
  EXIT_OK,
29
29
  Report,
30
- guess_repo_slug,
31
30
  read_text,
32
31
  rel,
33
32
  run,
@@ -187,8 +186,14 @@ def audit_local(root: Path, *, config: cfg.Config | None = None, doc_report: Rep
187
186
  report.warn("wiki.manifest", f"{config.wiki_manifest} is absent",
188
187
  hint="wiki build cannot order pages without a manifest")
189
188
  else:
189
+ # A repository may keep its wiki in its own git repository, or not have one at all;
190
+ # neither is a defect. Skipping keeps the item out of the score instead of charging
191
+ # three points for a file the project never intended to publish here.
190
192
  result.items.append(Item(ident="wiki-source", title=f"{config.wiki_dir}/", weight=3,
191
- status="fail", detail="no wiki source directory"))
193
+ status="skipped",
194
+ detail=f"no {config.wiki_dir}/ directory (wiki not maintained in this repository)"))
195
+ report.info("wiki.source-absent", f"no {config.wiki_dir}/ directory; the wiki item is not scored",
196
+ hint="a wiki kept in its own repository is checked in that checkout")
192
197
 
193
198
  assets_report = assets_module.check(root, report=report)
194
199
  reference_item = Item(ident="assets.banner", title="README banner", weight=4)
ghalib/changelog.py CHANGED
@@ -57,6 +57,11 @@ TYPE_TO_SECTION = {
57
57
  NOISE_TYPES = {"chore", "test", "tests", "ci", "wip", "release"}
58
58
  SECTION_ORDER = {name.lower(): index for index, name in enumerate(TYPES_EN)}
59
59
  SECTION_ORDER.update({name.lower(): index for index, name in enumerate(TYPES_RU)})
60
+ # A group heading may carry a subtitle: `### Added — template language`. The subtitle
61
+ # groups entries inside the type, it does not invent a new type, so it is dropped before
62
+ # the lookup. Measured on a real changelog: 10 false "unknown change type" warnings, one
63
+ # per grouped section, on a document that follows Keep a Changelog.
64
+ SUBTITLE_SPLIT = re.compile(r"\s+[—–-]\s+|\s*:\s+")
60
65
 
61
66
 
62
67
  @dataclass
@@ -66,10 +71,19 @@ class VersionSection:
66
71
  line: int
67
72
  sections: list[tuple[str, int]] # (heading text, line)
68
73
  items: int = 0
74
+ prose: int = 0 # non-blank body lines outside a `###` subsection
69
75
 
70
76
 
71
77
  def section_name(heading: str) -> str | None:
72
- return TYPE_ALIASES.get(heading.strip().lower())
78
+ """Map a `###` heading to a canonical change type, ignoring a grouping subtitle."""
79
+ text = heading.strip()
80
+ canonical = TYPE_ALIASES.get(text.lower())
81
+ if canonical:
82
+ return canonical
83
+ base = SUBTITLE_SPLIT.split(text, maxsplit=1)[0].strip()
84
+ if base != text:
85
+ return TYPE_ALIASES.get(base.lower())
86
+ return None
73
87
 
74
88
 
75
89
  def parse(text: str) -> tuple[list[VersionSection], dict[str, str]]:
@@ -91,6 +105,8 @@ def parse(text: str) -> tuple[list[VersionSection], dict[str, str]]:
91
105
  if level == 2:
92
106
  if current and current_section_start is not None:
93
107
  current.items += _count_items(lines, current_section_start, index)
108
+ if current:
109
+ current.prose += _count_prose(lines, current.line, index)
94
110
  current_section_start = None
95
111
  if title.strip().strip("[]").lower() in {"unreleased", "не выпущено", "невыпущено"}:
96
112
  current = VersionSection(version="Unreleased", date="", line=index, sections=[])
@@ -109,9 +125,22 @@ def parse(text: str) -> tuple[list[VersionSection], dict[str, str]]:
109
125
  current_section_start = index
110
126
  if current and current_section_start is not None:
111
127
  current.items += _count_items(lines, current_section_start, len(lines) + 1)
128
+ if current:
129
+ current.prose += _count_prose(lines, current.line, len(lines) + 1)
112
130
  return versions, references
113
131
 
114
132
 
133
+ def _count_prose(lines: list[str], start: int, end: int) -> int:
134
+ """Body lines that are neither a heading, a list item nor blank."""
135
+ count = 0
136
+ for raw in lines[start:end - 1]:
137
+ stripped = raw.strip()
138
+ if not stripped or stripped.startswith("#") or re.match(r"^[-*]\s+\S", stripped):
139
+ continue
140
+ count += 1
141
+ return count
142
+
143
+
115
144
  def _count_items(lines: list[str], start: int, end: int) -> int:
116
145
  count = 0
117
146
  for raw in lines[start:end - 1]:
@@ -207,11 +236,23 @@ def check(path: Path, *, report: Report | None = None, slug: str = "", today: dt
207
236
  previous_key = key
208
237
 
209
238
  if not section.sections:
210
- report.error("changelog.empty-version", f"version {section.version} has no change subsections",
211
- path=path.name, line=section.line)
239
+ if section.prose:
240
+ report.warn("changelog.prose-version",
241
+ f"version {section.version} describes its changes in prose, without `###` subsections",
242
+ path=path.name, line=section.line,
243
+ hint="Keep a Changelog reads a version by subsection; a paragraph is accepted but not comparable")
244
+ else:
245
+ report.error("changelog.empty-version", f"version {section.version} has no change subsections",
246
+ path=path.name, line=section.line)
212
247
  if section.sections and section.items == 0:
213
- report.error("changelog.empty-version", f"version {section.version} has headings but no entries",
214
- path=path.name, line=section.line)
248
+ if section.prose:
249
+ report.warn("changelog.prose-version",
250
+ f"version {section.version}: the subsections carry no list items",
251
+ path=path.name, line=section.line,
252
+ hint="list each change as a `- ` item so it can be compared and released")
253
+ else:
254
+ report.error("changelog.empty-version", f"version {section.version} has headings but no entries",
255
+ path=path.name, line=section.line)
215
256
 
216
257
  names: list[str] = []
217
258
  for heading, line in section.sections:
@@ -229,8 +270,12 @@ def check(path: Path, *, report: Report | None = None, slug: str = "", today: dt
229
270
  uses_brackets = any(re.match(r"^##\s+\[", raw) for raw in lines)
230
271
  if uses_brackets:
231
272
  if not references:
232
- report.error("changelog.link-refs", "versions use [x.y.z] but there are no link references at the bottom",
233
- path=path.name, hint="add `[x.y.z]: https://…/compare/…` lines")
273
+ # Keep a Changelog makes the link references optional: `## [Unreleased]` without
274
+ # a bottom-of-file `[unreleased]:` line is a legal document. Reporting it as an
275
+ # error turned a correct changelog into a red gate (measured: exit 1 on a
276
+ # repository whose only defect was the missing optional block).
277
+ report.warn("changelog.link-refs", "versions use [x.y.z] but there are no link references at the bottom",
278
+ path=path.name, hint="optional in Keep a Changelog; `[x.y.z]: https://…/compare/…` links the versions")
234
279
  else:
235
280
  for label in ["unreleased", *[v.version.lower() for v in released]]:
236
281
  if label not in references:
@@ -390,7 +435,7 @@ def suggest(root: Path, since: str, *, include_noise: bool = False, limit: int =
390
435
  breaking: list[str] = []
391
436
  for record in records:
392
437
  parts = record.split("\x1f")
393
- sha = parts[0].strip()[:7]
438
+ _sha = parts[0].strip()[:7] # the record format is `<sha>\x1f<subject>\x1f<body>`
394
439
  subject = parts[1].strip() if len(parts) > 1 else ""
395
440
  body = parts[2].strip() if len(parts) > 2 else ""
396
441
  match = CONVENTIONAL_RE.match(subject)
ghalib/config.py CHANGED
@@ -27,6 +27,13 @@ class Config:
27
27
  ci_workflow: str = ".github/workflows/ci.yml"
28
28
  require_files: list[str] = field(default_factory=list)
29
29
  ignore_rules: list[str] = field(default_factory=list)
30
+ # Domains that are role addresses in this repository (`@mycompany.com`). The built-in
31
+ # list holds only RFC examples and GitHub noreply addresses, so no project's personal
32
+ # domain is whitelisted by default for everybody else.
33
+ safe_email_domains: list[str] = field(default_factory=list)
34
+ # Extra directories the documentation walk must not enter (build output whose name the
35
+ # pack cannot know: `site`, `_site`, `public`, `out`).
36
+ exclude_dirs: list[str] = field(default_factory=list)
30
37
  topic_min: int = 8
31
38
  topic_max: int = 20
32
39
  description_max: int = 200
ghalib/mdcheck.py CHANGED
@@ -10,11 +10,9 @@ from __future__ import annotations
10
10
  import re
11
11
  from dataclasses import dataclass, field
12
12
  from pathlib import Path
13
- from urllib.parse import unquote, urlparse
13
+ from urllib.parse import unquote
14
14
 
15
15
  from .util import (
16
- FENCE_RE,
17
- IMAGE_RE,
18
16
  INLINE_LINK_RE,
19
17
  MARKDOWN_SUFFIXES,
20
18
  Report,
@@ -27,6 +25,11 @@ from .util import (
27
25
  )
28
26
 
29
27
  ALLOWED_ALERTS = {"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"}
28
+ # GitHub wiki furniture: partial pages that carry navigation, not documents. A wiki
29
+ # renders `_Sidebar.md` and `_Footer.md` on every page, so neither the H1 rule nor a
30
+ # page-shaped link contract applies to them (measured: 2 false errors on a 24-page wiki).
31
+ WIKI_FURNITURE = {"_sidebar.md", "_footer.md", "_header.md"}
32
+ WIKI_MODE_MARKERS = ("_Sidebar.md", "_Footer.md")
30
33
  # AGENTS.md is the import entry point an agent reads first: a map, not a manual. The
31
34
  # canonical root file is the only one this rule measures — translations and nested
32
35
  # copies are checked by their own contracts.
@@ -44,9 +47,11 @@ ABS_PATH_PATTERNS = [
44
47
  re.compile(r"(?<![\w.])/(?:home|Users)/[a-z0-9._-]+/", re.IGNORECASE),
45
48
  ]
46
49
  PERSONAL_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b")
50
+ # Role addresses only: a personal domain that happens to belong to the pack's author has
51
+ # no place here, or the whitelist becomes a leak of its own. A repository adds its own
52
+ # domains with `safe_email_domains` in `.ghagent.json`.
47
53
  SAFE_EMAIL_DOMAINS = {
48
- "users.noreply.github.com", "example.com", "example.org", "example.net",
49
- "localhost", "deynekin.com",
54
+ "users.noreply.github.com", "example.com", "example.org", "example.net", "localhost",
50
55
  }
51
56
  KEBAB_TOPIC_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
52
57
 
@@ -67,6 +72,13 @@ class DocCheckOptions:
67
72
  toc: bool = True
68
73
  line_length: int = 200
69
74
  max_line_rule: bool = True
75
+ # None means "decide from the tree": a directory that carries `_Sidebar.md` or
76
+ # `_Footer.md` is a wiki checkout, and a wiki addresses pages by name.
77
+ wiki_links: bool | None = None
78
+ # Directory names whose contents are wiki sources even without the generated markers
79
+ # (`wiki/` holds page-name links; the sidebar is generated into the build).
80
+ wiki_dirs: set[str] = field(default_factory=set)
81
+ safe_email_domains: set[str] = field(default_factory=set)
70
82
 
71
83
 
72
84
  class DocChecker:
@@ -76,6 +88,26 @@ class DocChecker:
76
88
  self.options = options or DocCheckOptions()
77
89
  self.stats: dict[str, RuleStats] = {}
78
90
  self.anchor_cache: dict[Path, set[str]] = {}
91
+ self.wiki_cache: dict[Path, bool] = {}
92
+
93
+ def wiki_tree(self, path: Path) -> bool:
94
+ """True when the document lives in a GitHub wiki checkout.
95
+
96
+ A wiki links its own pages by name (`[Architecture](Architecture)`), and GitHub
97
+ resolves that to `Architecture.md`. In an ordinary repository the same link is a
98
+ 404, so the resolution is enabled only where the context proves it: the directory
99
+ carries `_Sidebar.md` or `_Footer.md`. Measured on a 24-page wiki before the fix:
100
+ 119 errors, of which 117 were correct links reported as missing files.
101
+ """
102
+ if self.options.wiki_links is not None:
103
+ return self.options.wiki_links
104
+ directory = path.parent.resolve()
105
+ if directory not in self.wiki_cache:
106
+ named = bool(self.options.wiki_dirs) and any(part in self.options.wiki_dirs
107
+ for part in path.parts[:-1])
108
+ self.wiki_cache[directory] = named or any((directory / marker).exists()
109
+ for marker in WIKI_MODE_MARKERS)
110
+ return self.wiki_cache[directory]
79
111
 
80
112
  # -- infrastructure ----------------------------------------------------
81
113
  def _rule(self, rule: str) -> bool:
@@ -164,10 +196,14 @@ class DocChecker:
164
196
  h1 = [h for h in headings if h.level == 1]
165
197
  is_template = ".github" in path.parts or "template" in path.name.lower()
166
198
  is_template = is_template or "<!-- ghagent: template -->" in text
199
+ is_furniture = path.name.lower() in WIKI_FURNITURE
167
200
  if not h1:
168
201
  if is_template:
169
202
  self.report.info("md.h1", "template without an H1 (expected: the body starts at H2)",
170
203
  path=relative, line=1)
204
+ elif is_furniture:
205
+ self.report.info("md.h1", "wiki partial without an H1 (navigation is rendered on every page)",
206
+ path=relative, line=1)
171
207
  else:
172
208
  self.report.error("md.h1", "no H1 heading", path=relative, line=1,
173
209
  hint="a document needs exactly one H1")
@@ -288,6 +324,7 @@ class DocChecker:
288
324
 
289
325
  def _check_links(self, path: Path, relative: str, links, headings, text: str) -> None:
290
326
  local_anchors = {h.anchor for h in headings}
327
+ wiki = self.wiki_tree(path)
291
328
  for link in links:
292
329
  if link.kind != "inline":
293
330
  continue
@@ -304,9 +341,23 @@ class DocChecker:
304
341
  continue # placeholder kept on purpose in templates
305
342
  if self._rule("md.link-local"):
306
343
  self._hit("md.link-local")
307
- self._resolve_link(path, relative, link, local_anchors)
344
+ self._resolve_link(path, relative, link, local_anchors, wiki)
308
345
 
309
- def _resolve_link(self, path: Path, relative: str, link, local_anchors: set[str]) -> None:
346
+ @staticmethod
347
+ def wiki_page_target(candidate: Path) -> Path | None:
348
+ """Resolve a wiki page name to its file: `Architecture` -> `Architecture.md`.
349
+
350
+ A wiki page name may contain dots (`Migration-3.x-to-4.0`), so the suffix is
351
+ appended to the whole name instead of replacing what looks like an extension.
352
+ """
353
+ for suffix in (".md", ".markdown", ".mdown"):
354
+ alternative = candidate.with_name(candidate.name + suffix)
355
+ if alternative.exists():
356
+ return alternative
357
+ return None
358
+
359
+ def _resolve_link(self, path: Path, relative: str, link, local_anchors: set[str],
360
+ wiki: bool = False) -> None:
310
361
  target = link.target.strip()
311
362
  anchorless = target.split("#", 1)[0]
312
363
  anchor = target.split("#", 1)[1] if "#" in target else ""
@@ -319,10 +370,13 @@ class DocChecker:
319
370
  hint="a trailing slash is a browsable directory on GitHub; a file link is better for readers")
320
371
  return
321
372
  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
373
+ resolved = self.wiki_page_target(candidate) if wiki else None
374
+ if resolved is None:
375
+ self.report.error("md.link-local", f"target does not exist: {target}",
376
+ path=relative, line=link.line,
377
+ hint="fix the path or drop the link")
378
+ return
379
+ candidate = resolved
326
380
  if anchor and candidate.suffix.lower() in MARKDOWN_SUFFIXES:
327
381
  if anchor.lower() not in {a.lower() for a in self.anchors_of(candidate)}:
328
382
  self.report.error("md.link-anchor",
@@ -391,9 +445,10 @@ class DocChecker:
391
445
  path=relative, line=line,
392
446
  hint="use a relative path or a <placeholder>")
393
447
  if self._rule("privacy.email"):
448
+ allowed = SAFE_EMAIL_DOMAINS | {domain.lower() for domain in self.options.safe_email_domains}
394
449
  for match in PERSONAL_EMAIL_RE.finditer(text):
395
450
  domain = match.group(1).lower()
396
- if domain in SAFE_EMAIL_DOMAINS:
451
+ if domain in allowed:
397
452
  continue
398
453
  self._hit("privacy.email")
399
454
  line = text.count("\n", 0, match.start()) + 1
ghalib/meta.py CHANGED
@@ -13,7 +13,6 @@ call erases topics that were not part of the edit.
13
13
  from __future__ import annotations
14
14
 
15
15
  import json
16
- import os
17
16
  import re
18
17
  from collections import Counter
19
18
  from pathlib import Path
ghalib/report.py CHANGED
@@ -9,7 +9,7 @@ from . import changelog as changelog_module
9
9
  from . import config as cfg
10
10
  from . import mdcheck
11
11
  from . import wiki as wiki_module
12
- from .util import EXIT_FAIL, EXIT_OK, Report, rel
12
+ from .util import EXIT_FAIL, EXIT_OK, Report
13
13
 
14
14
 
15
15
  def build(root: Path) -> tuple[str, int]:
ghalib/util.py CHANGED
@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
17
17
  from pathlib import Path
18
18
 
19
19
  PACK_NAME = "github-agent"
20
- PACK_VERSION = "1.2.2"
20
+ PACK_VERSION = "1.3.0"
21
21
 
22
22
  EXIT_OK = 0
23
23
  EXIT_FAIL = 1
@@ -121,11 +121,36 @@ def force_utf8_stdio() -> None:
121
121
  pass
122
122
 
123
123
 
124
- def iter_text_files(root: Path, *, suffix_set: set[str] | None = None):
124
+ # A directory whose name starts with a dot carries tool state, not documentation: `.git`,
125
+ # `.idea`, `.ruff_cache`, and the scratch space of a coding agent (`.swarm`, `.claude`,
126
+ # `.dsh`, `.cursor`). Walking it produced privacy errors for machine paths written in a
127
+ # plan file that is never published — measured on a real repository: 6 errors in `.swarm/`
128
+ # that blocked the gate. The allowlist holds the one dot-directory that IS a published
129
+ # surface: `.github/` (issue forms, PR template) and `.well-known/`.
130
+ HIDDEN_DIR_ALLOWLIST = {".github", ".well-known"}
131
+
132
+
133
+ def visible_dirs(names, exclude=()) -> list[str]:
134
+ """Directories a documentation walk may enter: no build output, no tool state.
135
+
136
+ `exclude` carries the names a repository adds itself (`.ghagent.json` → `exclude_dirs`),
137
+ so a project with a generated `site/` directory does not teach the pack the name of its
138
+ build tool.
139
+ """
140
+ blocked = set(exclude) | EXCLUDED_DIRS
141
+ return sorted(
142
+ name for name in names
143
+ if name not in blocked
144
+ and not name.startswith(".ghagent-")
145
+ and (not name.startswith(".") or name in HIDDEN_DIR_ALLOWLIST)
146
+ )
147
+
148
+
149
+ def iter_text_files(root: Path, *, suffix_set: set[str] | None = None, exclude=()):
125
150
  root = Path(root)
126
151
  suffixes = suffix_set if suffix_set is not None else TEXT_SUFFIXES
127
152
  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-"))
153
+ dirnames[:] = visible_dirs(dirnames, exclude)
129
154
  for name in sorted(filenames):
130
155
  path = Path(dirpath) / name
131
156
  if path.suffix.lower() in suffixes:
ghalib/wiki.py CHANGED
@@ -175,7 +175,7 @@ def rewrite_page(text: str, page_path: Path, root: Path, manifest: WikiManifest,
175
175
  continue
176
176
 
177
177
  def replace_image(match: re.Match) -> str:
178
- alt, target = match.group(1), match.group(2)
178
+ target = match.group(2)
179
179
  if re.match(r"^(https?:)?//", target) or target.startswith("data:"):
180
180
  return match.group(0)
181
181
  report.error("wiki.image-relative", f"relative image in {page_name}: {target}",
@@ -264,15 +264,50 @@ def render_footer(manifest: WikiManifest, report: Report) -> str:
264
264
  return "---\n*" + " · ".join(parts) + "*\n"
265
265
 
266
266
 
267
+ # --------------------------------------------------------------------------
268
+ # verify target selection
269
+ # --------------------------------------------------------------------------
270
+
271
+ def verify_target(root: Path, *, out: Path | None, wiki_dir: str, wiki_dir_given: bool) -> Report:
272
+ """Decide what to verify: an explicit --out, a --wiki-dir that is already a wiki, or the build.
273
+
274
+ `wiki verify --root <repo> --wiki-dir <clone>` used to ignore the flag and look in
275
+ `<repo>/.ghagent/wiki`, which made the command useless for the growing number of
276
+ repositories whose wiki lives in its own checkout: the answer was always
277
+ `wiki.build-missing`, and the header named a directory the caller never passed.
278
+ """
279
+ root = Path(root)
280
+ if out is not None:
281
+ return verify(out, root=root, wiki_dir=wiki_dir)
282
+ candidate = root / wiki_dir
283
+ if wiki_dir_given and candidate.is_dir():
284
+ looks_built = any((candidate / f"{name}.md").exists() for name in ("Home", "_Sidebar", "_Footer"))
285
+ if looks_built:
286
+ return verify(candidate, root=root, wiki_dir=wiki_dir)
287
+ return verify(root / ".ghagent" / "wiki", root=root, wiki_dir=wiki_dir)
288
+
289
+
267
290
  # --------------------------------------------------------------------------
268
291
  # verify (built tree)
269
292
  # --------------------------------------------------------------------------
270
293
 
271
- def verify(build_dir: Path, *, report: Report | None = None) -> Report:
294
+ def verify(build_dir: Path, *, report: Report | None = None, root: Path | None = None,
295
+ wiki_dir: str = "wiki") -> Report:
272
296
  build_dir = Path(build_dir)
273
297
  report = report or Report(title="ghagent wiki verify", root=str(build_dir))
274
298
  if not build_dir.is_dir():
275
- report.error("wiki.build-missing", f"no built wiki at {build_dir}", hint="run `ghagent wiki build` first")
299
+ sources = Path(root) / wiki_dir if root is not None else None
300
+ if sources is not None and sources.is_dir() and any(sources.glob("*.md")):
301
+ report.error("wiki.build-missing", f"no built wiki at {build_dir}",
302
+ hint=f"sources exist at {wiki_dir}/: run `ghagent wiki build` (or `wiki publish --push`, which builds and verifies)")
303
+ else:
304
+ # Nothing to verify and nothing missing: a repository may keep its wiki in a
305
+ # separate git repository, and then there is no build here at all. Reporting
306
+ # that as an error made the command unusable for those repositories.
307
+ report.info("wiki.not-applicable",
308
+ f"no built wiki at {build_dir} and no sources at {wiki_dir}/; nothing to verify",
309
+ hint="a wiki kept in its own repository is checked in that checkout: "
310
+ "`ghagent doc --root <clone>` (wiki mode is detected from _Sidebar.md)")
276
311
  return report
277
312
 
278
313
  pages = {p.stem for p in build_dir.glob("*.md") if not p.name.startswith("_")}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github-agent-cli
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Repository and wiki steward: one command per documentation job, standard library only.
5
5
  Project-URL: Homepage, https://github.com/paulmann/github-agent
6
6
  Project-URL: Documentation, https://github.com/paulmann/github-agent/tree/main/docs
@@ -42,13 +42,20 @@ the repository — one command per job.
42
42
  [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-3776AB)](https://www.python.org/)
43
43
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
44
44
  [![Runtime dependencies](https://img.shields.io/badge/runtime%20dependencies-0-brightgreen)](pyproject.toml)
45
- [![Self-tests](https://img.shields.io/badge/self--tests-173%20checks-brightgreen)](tests/run_tests.py)
45
+ [![Self-tests](https://img.shields.io/badge/self--tests-194%20checks-brightgreen)](tests/run_tests.py)
46
46
 
47
47
  <img src="https://github.com/paulmann/github-agent/raw/main/assets/social-preview.png"
48
48
  alt="github-agent — repository and wiki steward for AI agents"
49
49
  width="100%">
50
50
 
51
- [**Installation**](#installation) · [**Usage**](#usage) · [**Wiki**](https://github.com/paulmann/github-agent/wiki) · [**AGENTS.md**](AGENTS.md) · [**MIGRATION.md**](MIGRATION.md) · [**Packaging**](docs/PACKAGING.md) · [**Changelog**](CHANGELOG.md) · [**Issues**](https://github.com/paulmann/github-agent/issues) · [**Security**](SECURITY.md) · [**Русская версия**](README.ru.md)
51
+ <img src="https://github.com/paulmann/github-agent/raw/main/assets/demo.gif"
52
+ alt="Three ghagent commands in a terminal: audit scores 100%, doc and changelog check pass"
53
+ width="100%">
54
+
55
+ <sub>The animation is produced by `tools/make_demo.py` from the real output of three commands; it
56
+ cannot show anything the tool did not print.</sub>
57
+
58
+ [**Installation**](#installation) · [**Usage**](#usage) · [**Why this exists**](#why-this-exists) · [**How it compares**](#how-it-compares) · [**Docs site**](https://paulmann.github.io/github-agent/) · [**Wiki**](https://github.com/paulmann/github-agent/wiki) · [**AGENTS.md**](AGENTS.md) · [**Packaging**](docs/PACKAGING.md) · [**Changelog**](CHANGELOG.md) · [**Issues**](https://github.com/paulmann/github-agent/issues) · [**Security**](SECURITY.md) · [**Русская версия**](README.ru.md)
52
59
 
53
60
  A repository rarely fails on code; it fails on documentation. The README has no installation
54
61
  section, the changelog has no dates, the wiki is three pages without navigation, the banner is
@@ -58,8 +65,8 @@ This pack puts a verifiable contract on each of those places and reduces the wor
58
65
  command. It does not write marketing copy and does not invent numbers: it checks what a human
59
66
  wrote and refuses to publish what is broken.
60
67
 
61
- > **Documentation status.** Every number in this README was measured on 2026-10-30 against
62
- > version 1.2.0 with the commands named in the table. Long guides live in the
68
+ > **Documentation status.** Every number in this README was measured on 2026-11-01 against
69
+ > version 1.3.0 with the commands named in the table. Long guides live in the
63
70
  > [Wiki](https://github.com/paulmann/github-agent/wiki) and [`docs/`](docs/IMPORT.md); the
64
71
  > protocol for agents is [`AGENTS.md`](AGENTS.md), the packaging and migration notes are
65
72
  > [`docs/PACKAGING.md`](docs/PACKAGING.md) and [`MIGRATION.md`](MIGRATION.md).
@@ -69,9 +76,11 @@ wrote and refuses to publish what is broken.
69
76
 
70
77
  ## Contents
71
78
 
79
+ - [Why this exists](#why-this-exists)
72
80
  - [Installation](#installation)
73
81
  - [Usage](#usage)
74
82
  - [What the pack does](#what-the-pack-does)
83
+ - [How it compares](#how-it-compares)
75
84
  - [In numbers](#in-numbers)
76
85
  - [Checks that run](#checks-that-run)
77
86
  - [Images](#images)
@@ -86,6 +95,24 @@ wrote and refuses to publish what is broken.
86
95
 
87
96
  ---
88
97
 
98
+ ## Why this exists
99
+
100
+ GitHub renders whatever you commit. It does not tell you that the installation section is
101
+ missing, that the changelog has no dates, that the wiki has no navigation, that the banner is a
102
+ broken image or that the description is one word. `git push` succeeds either way, and the defect
103
+ survives for years because nobody is looking at the repository as a product.
104
+
105
+ Three ideas decide how the pack behaves:
106
+
107
+ 1. **A check you cannot run is not a check.** Every rule is one command with an exit code:
108
+ `0` clean, `1` findings at error level, `2` usage or environment problem. A rule that examined
109
+ zero objects reports `coverage.skipped` — a green check over an empty set is a defect.
110
+ 2. **A false `PASS` is worse than a false `FAIL`.** The tool would rather stop a correct pull
111
+ request than wave a broken one through. Where that costs a real repository time, the rule is
112
+ narrowed until both answers are right — see [Field notes](https://paulmann.github.io/github-agent/FIELD-NOTES/).
113
+ 3. **Numbers over adjectives.** The README, the changelog and the metadata are checked against
114
+ what the repository can prove. Nothing here writes marketing copy.
115
+
89
116
  ## Installation
90
117
 
91
118
  Python 3.11 or newer is required; CI runs the suite on 3.11 and 3.13. There are no runtime
@@ -99,10 +126,13 @@ the wheel ships only the CLI.
99
126
  uv tool install github-agent-cli # uv, isolated tool environment
100
127
  pipx install github-agent-cli # pipx, isolated virtual environment
101
128
  pip install github-agent-cli # pip into the active environment
129
+ brew install paulmann/homebrew-github-agent-cli/github-agent-cli # macOS and Linux
130
+ scoop bucket add github-agent-cli https://github.com/paulmann/scoop-github-agent-cli
131
+ scoop install github-agent-cli # Windows
102
132
  ghagent --version
103
133
  ```
104
134
 
105
- > **Published on PyPI.** `github-agent-cli` 1.2.2 has been available since 2026-10-30:
135
+ > **Published on PyPI.** `github-agent-cli` 1.3.0 has been available since 2026-11-01:
106
136
  > <https://pypi.org/project/github-agent-cli/>. The wheel ships the CLI only; the pack
107
137
  > (skills, tests, reference, wiki sources) comes from the clone below.
108
138
 
@@ -145,7 +175,7 @@ docker run --rm -v "$PWD:/repo" ghagent audit .
145
175
 
146
176
  On Windows PowerShell the same mount is `-v "${PWD}:/repo"`. The entry point is `ghagent`, so
147
177
  the first argument after the image name is the command. No registry image is published as part
148
- of 1.2.0 and CI does not build the container: the tag is a local convention. See
178
+ of 1.3.0 and CI does not build the container: the tag is a local convention. See
149
179
  [examples/docker.md](examples/docker.md).
150
180
 
151
181
  ### GitHub Action
@@ -155,7 +185,7 @@ release and works as soon as the tag exists:
155
185
 
156
186
  ```yaml
157
187
  - uses: actions/checkout@v7
158
- - uses: paulmann/github-agent@v1.2.2 # pin the release tag
188
+ - uses: paulmann/github-agent@v1.3.0 # pin the release tag
159
189
  with:
160
190
  command: audit .
161
191
  min-score: "80"
@@ -182,7 +212,7 @@ The repository is also a pre-commit hook source; the hooks are `ghagent-doc`,
182
212
  ```yaml
183
213
  repos:
184
214
  - repo: https://github.com/paulmann/github-agent
185
- rev: v1.2.2 # placeholder: pin the release tag you decide to trust
215
+ rev: v1.3.0 # placeholder: pin the release tag you decide to trust
186
216
  hooks:
187
217
  - id: ghagent-doc
188
218
  - id: ghagent-audit
@@ -200,7 +230,7 @@ tag, because pre-commit builds the tool from the tagged tree.
200
230
  After any install, one command answers whether the CLI is the released build:
201
231
 
202
232
  ```bash
203
- ghagent --version # prints the version, for example github-agent 1.2.2
233
+ ghagent --version # prints the version, for example github-agent 1.3.0
204
234
  ghagent audit . # 0 clean, 1 findings at error level, 2 usage or environment problem
205
235
  ```
206
236
 
@@ -210,7 +240,7 @@ there exits `2` with a message that points at the clone rather than reporting a
210
240
  ```bash
211
241
  git clone https://github.com/paulmann/github-agent.git
212
242
  cd github-agent
213
- python tools/ghagent.py selftest # 173/173 checks passed
243
+ python tools/ghagent.py selftest # 194/194 checks passed
214
244
  ```
215
245
 
216
246
  ## Usage
@@ -224,7 +254,7 @@ ghagent assets check # format, dimensions, alt text, referenced fi
224
254
  ghagent wiki build # rewrite links for the wiki namespace
225
255
  ghagent wiki verify # every internal link resolves
226
256
  ghagent changelog check # Keep a Changelog and SemVer
227
- ghagent changelog release 1.2.0 --write
257
+ ghagent changelog release 1.3.0 --write
228
258
  ghagent meta suggest # description and topics
229
259
  ```
230
260
 
@@ -256,7 +286,7 @@ score: 72.5% (22 scored, 1 skipped)
256
286
  | Find secrets and machine paths | `ghagent privacy --root .` |
257
287
  | Render the banner and social preview | `ghagent assets render --write` |
258
288
  | Build, verify and publish the wiki | `ghagent wiki build`, `verify`, `publish --push` |
259
- | Release a version | `ghagent changelog release 1.2.0 --write` |
289
+ | Release a version | `ghagent changelog release 1.3.0 --write` |
260
290
  | Fix description and topics | `ghagent meta apply --repo owner/name --write` |
261
291
  | Install the skill into another agent | `ghagent install --profile harness --write` |
262
292
 
@@ -265,19 +295,34 @@ The CLI has 13 commands; the table names the ones used every day. The rest (`rep
265
295
  `changelog notes`, `meta suggest`, `wiki init`, `wiki publish`) carry their flags in
266
296
  `ghagent --help`; the recurring jobs are mapped in [`AGENTS.md`](AGENTS.md).
267
297
 
298
+ ## How it compares
299
+
300
+ The pack does not replace the tools below and does not compete with them on their ground: it
301
+ orchestrates the documentation surface of a repository and calls the same ideas from one place,
302
+ with one exit code.
303
+
304
+ | Tool | Strong at | Where this pack differs |
305
+ |---|---|---|
306
+ | `markdownlint` | markdown style, one rule id per finding | `ghagent doc` adds links, anchors, TOC, tables, privacy and the CHANGELOG contract; it also checks documents that are not markdown |
307
+ | `vale` / `textlint` | prose style with a dictionary you maintain | the pack checks structure a machine can prove and does not judge wording; only the README summary is checked for superlatives |
308
+ | `action-gh-release` | creating a release from a tag | `changelog notes <version>` produces the body from the CHANGELOG, and `changelog check` refuses a release without a dated section |
309
+ | `pre-commit` hooks | running linters before a commit | the pack ships hooks for its own commands; the hooks are the delivery, not the check |
310
+ | `release-please` | version bumps from conventional commits | the CHANGELOG stays hand-written and machine-verified; `changelog suggest` drafts entries but a human writes them |
311
+ | GitHub community health files | a checklist in the UI | `audit` scores the same files from the tree and from the remote, so a missing `SECURITY.md` is a number, not a badge nobody opens |
312
+
268
313
  ## In numbers
269
314
 
270
- Measured in this tree on 2026-10-30 against version 1.2.2:
315
+ Measured in this tree on 2026-11-01 against version 1.3.0:
271
316
 
272
317
  | Surface | Value | Measurement source |
273
318
  |---|---:|---|
274
- | Self-tests | 173 | `python tests/run_tests.py` |
319
+ | Self-tests | 194 | `python tests/run_tests.py` |
275
320
  | CLI commands | 13 | `ghagent --help` |
276
321
  | Library modules | 11 | `tools/ghalib/*.py` |
277
322
  | Document rules examined on this tree | 17 | `ghagent doc --json`, field `rules_examined` |
278
323
  | Runtime dependencies | 0 | `pyproject.toml`, `dependencies = []` |
279
324
  | Platform traps recorded | 15 | the traps table in [docs/RULES.md](docs/RULES.md) |
280
- | Python lines | 4176 | `wc -l tools/ghagent.py tools/ghalib/*.py` |
325
+ | Python lines | 4412 | `wc -l tools/ghagent.py tools/ghalib/*.py` |
281
326
 
282
327
  ## Checks that run
283
328
 
@@ -387,7 +432,7 @@ flowchart LR
387
432
  | `tools/ghalib/install.py` | skill installation, sha256 manifest, drift, removal |
388
433
  | `tools/ghalib/report.py` | findings, severities, markdown and `--json` output |
389
434
  | `tools/ghalib/config.py`, `tools/ghalib/util.py` | `.ghagent.json`, paths, text I/O, anchors |
390
- | `tests/run_tests.py` | 173 self-tests with fixtures and decoys |
435
+ | `tests/run_tests.py` | 194 self-tests with fixtures and decoys |
391
436
 
392
437
  Anchors follow GitHub's rules: lowercase, punctuation dropped, spaces to hyphens, a repeated
393
438
  heading gets `-1`. The rule was verified against a live table of contents: an em dash in a
@@ -0,0 +1,17 @@
1
+ ghagent.py,sha256=Fa_Ammn3M4yL03EIjdkSPnZ9UAyOTacnBIJoNnm2A-M,25278
2
+ ghalib/__init__.py,sha256=ur7kSxCjbmuv0oq97bo9bwE0a8QEDiDeVxxf7wACOAc,161
3
+ ghalib/assets.py,sha256=RtP0hzZ1f6C8-B5hhkY1QSpb_5y_kA4EMNCzE6d1NnM,23279
4
+ ghalib/audit.py,sha256=89_HSzHTHEUN_rSpv0xBXGUa3eHLWrUJLS1LcyxBJpU,29813
5
+ ghalib/changelog.py,sha256=LXXPX-zDusqm-ZgZCP6ic5wopUsACp7jkj-fainGm8Y,22931
6
+ ghalib/config.py,sha256=WLkY5IM-jfIrSMB2x4Rj4fJKgLEQ9eNmlt8EZMuHkio,1968
7
+ ghalib/install.py,sha256=RWku4z3pzCe76vWlERAzzJIkF-tT-ieZ_EQvIsIg3RI,7410
8
+ ghalib/mdcheck.py,sha256=grUeKev4r-fCDa4mWdkIwYQtpuuFn8UEu22_6QUerLg,24094
9
+ ghalib/meta.py,sha256=uJBEVMz-BqRt8SjgtlWCm1_bRlbNaWXENMb96pVtgCU,12471
10
+ ghalib/report.py,sha256=He-CkxUu0e9JpfPtQtIJcaveaSQ7Mo9G6HmNP2ETvaw,4093
11
+ ghalib/util.py,sha256=-a7UoN7lUVJnti6yCRgxERbbezO8g7sZtdBzWQ9ikiM,19362
12
+ ghalib/wiki.py,sha256=2oN9Iz91gglCGGKb9Zgvq6SOfG7ZmmIw2EmJ47Is5b8,24242
13
+ github_agent_cli-1.3.0.dist-info/METADATA,sha256=75umbhzCD-NS3vEjvKS03zLWJP9pUB-LfKz3ZZucXAA,23600
14
+ github_agent_cli-1.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
15
+ github_agent_cli-1.3.0.dist-info/entry_points.txt,sha256=ygzVll1rjzq2XexecCuJxEEfijExjJkWFtszftav9ys,41
16
+ github_agent_cli-1.3.0.dist-info/licenses/LICENSE,sha256=tI9-MOZ-IBbjmioMIxKVwx3x727NsJflGKEJV8seQUY,1082
17
+ github_agent_cli-1.3.0.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- ghagent.py,sha256=W_ywmCY89ZUIHA4-siPYswtXh6kX8QCbsJ73f__ODlI,23238
2
- ghalib/__init__.py,sha256=ur7kSxCjbmuv0oq97bo9bwE0a8QEDiDeVxxf7wACOAc,161
3
- ghalib/assets.py,sha256=Wak4liTekNdDYHewKZMgZL1FOW11OhmEzoTHc9vPLRQ,21302
4
- ghalib/audit.py,sha256=gaHcuP_FE_IDK6rAXDHQquoqcoKg1Btaz37U3uQpLDY,29290
5
- ghalib/changelog.py,sha256=mMAQHZGUA59AXWWRA2qTHeqNfvOBz3oADYvpVX1PiPk,20357
6
- ghalib/config.py,sha256=r_VoPi1vp8J5OZrd3Jf9Gmi5iCILwukZEx7Hr_Q02sA,1457
7
- ghalib/install.py,sha256=RWku4z3pzCe76vWlERAzzJIkF-tT-ieZ_EQvIsIg3RI,7410
8
- ghalib/mdcheck.py,sha256=k1raRBLMWUIZsT5jzy3abnk_EJ_FyheeCjurHKbYoME,20715
9
- ghalib/meta.py,sha256=mIt6NZVxo6dZhaEHjCqunVZJ3QqA4S2voxiX17cgbGo,12481
10
- ghalib/report.py,sha256=eljoa6UUnpwcCJ3zTSJSpKdMHiml2hSu4yMWZhQ-uEw,4098
11
- ghalib/util.py,sha256=hEOUmSJggNKq4-Sy0NeAXhLTbksoT4wuIzfjkzSMQcA,18246
12
- ghalib/wiki.py,sha256=s0BxCxxU9v6WccjmIJdtPz5HM6uUG3slerDPS68LYJ0,22139
13
- github_agent_cli-1.2.2.dist-info/METADATA,sha256=6Oz8ZJcK7XS1GtQnPSqzsiz69D44SXBWTTEsbRLB2k8,20260
14
- github_agent_cli-1.2.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
15
- github_agent_cli-1.2.2.dist-info/entry_points.txt,sha256=ygzVll1rjzq2XexecCuJxEEfijExjJkWFtszftav9ys,41
16
- github_agent_cli-1.2.2.dist-info/licenses/LICENSE,sha256=tI9-MOZ-IBbjmioMIxKVwx3x727NsJflGKEJV8seQUY,1082
17
- github_agent_cli-1.2.2.dist-info/RECORD,,