jekyll-theme-zer0 1.28.0 → 1.30.0

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.
Files changed (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1143 -13
  3. data/_data/README.md +2 -0
  4. data/_data/ai.yml +5 -3
  5. data/_data/ai_pricing.yml +36 -0
  6. data/_data/backlog.yml +507 -2
  7. data/_data/consumers.yml +157 -9
  8. data/_data/features.yml +303 -20
  9. data/_data/feedback_types.yml +17 -12
  10. data/_data/i18n/fr.yml +12 -7
  11. data/_data/i18n/manifest.yml +39 -12
  12. data/_data/ingredient_densities.yml +122 -0
  13. data/_data/landing.yml +5 -2
  14. data/_data/navigation/main.yml +16 -0
  15. data/_data/navigation/quickstart.yml +4 -0
  16. data/_data/recipe_courses.yml +64 -0
  17. data/_data/site_builder.yml +874 -0
  18. data/_data/theme-manifest.yml +160 -124
  19. data/_data/ui-text.yml +26 -0
  20. data/_includes/README.md +26 -2
  21. data/_includes/analytics/posthog.html +2 -2
  22. data/_includes/components/admin-links.html +2 -2
  23. data/_includes/components/admin-tabs.html +2 -2
  24. data/_includes/components/ai-chat.html +14 -11
  25. data/_includes/components/analytics-dashboard.html +8 -8
  26. data/_includes/components/author-bio.html +1 -1
  27. data/_includes/components/author-card.html +10 -2
  28. data/_includes/components/author-eeat.html +4 -4
  29. data/_includes/components/background-customizer.html +10 -10
  30. data/_includes/components/background-image.html +114 -0
  31. data/_includes/components/background-settings.html +28 -15
  32. data/_includes/components/collection-manager.html +5 -5
  33. data/_includes/components/component-showcase.html +13 -13
  34. data/_includes/components/config-editor.html +12 -12
  35. data/_includes/components/config-viewer.html +8 -8
  36. data/_includes/components/cookie-consent.html +15 -15
  37. data/_includes/components/cta-button.html +7 -2
  38. data/_includes/components/dev-shortcuts.html +7 -7
  39. data/_includes/components/env-dashboard.html +8 -8
  40. data/_includes/components/env-switcher.html +9 -9
  41. data/_includes/components/feature-card.html +2 -2
  42. data/_includes/components/halfmoon.html +2 -2
  43. data/_includes/components/info-section.html +42 -37
  44. data/_includes/components/js-cdn.html +15 -15
  45. data/_includes/components/language-toggle.html +168 -21
  46. data/_includes/components/mermaid.html +72 -435
  47. data/_includes/components/nanobar.html +5 -5
  48. data/_includes/components/nav-editor.html +2 -2
  49. data/_includes/components/nav-export.html +2 -2
  50. data/_includes/components/nav-overview.html +2 -2
  51. data/_includes/components/page-feedback.html +45 -30
  52. data/_includes/components/page-views-init.html +55 -0
  53. data/_includes/components/page-views.html +33 -0
  54. data/_includes/components/post-card.html +22 -22
  55. data/_includes/components/post-type-badge.html +2 -2
  56. data/_includes/components/powered-by.html +2 -2
  57. data/_includes/components/preview-image.html +6 -0
  58. data/_includes/components/quick-index.html +2 -2
  59. data/_includes/components/recipe-card.html +67 -0
  60. data/_includes/components/recipe-duration.html +50 -0
  61. data/_includes/components/recipe-grams.html +58 -0
  62. data/_includes/components/recipe-index.html +96 -0
  63. data/_includes/components/recipe-ingredients.html +90 -0
  64. data/_includes/components/recipe-meta.html +96 -0
  65. data/_includes/components/recipe-nutrition.html +57 -0
  66. data/_includes/components/recipe-qty.html +73 -0
  67. data/_includes/components/recipe-ratio.html +151 -0
  68. data/_includes/components/recipe-scaler.html +73 -0
  69. data/_includes/components/recipe-steps.html +86 -0
  70. data/_includes/components/recipe-temp.html +45 -0
  71. data/_includes/components/search-modal.html +29 -4
  72. data/_includes/components/searchbar.html +2 -2
  73. data/_includes/components/shortcuts-modal.html +3 -0
  74. data/_includes/components/svg-background.html +2 -2
  75. data/_includes/components/theme-customizer.html +2 -2
  76. data/_includes/components/theme-info.html +14 -7
  77. data/_includes/components/theme-preview-gallery.html +22 -22
  78. data/_includes/content/giscus.html +2 -2
  79. data/_includes/content/intro.html +8 -8
  80. data/_includes/content/jsonld-faq.html +2 -2
  81. data/_includes/content/jsonld-software.html +24 -5
  82. data/_includes/content/seo.html +4 -4
  83. data/_includes/content/sitemap.html +27 -27
  84. data/_includes/content/toc.html +183 -183
  85. data/_includes/core/branding.html +6 -6
  86. data/_includes/core/console-capture.html +32 -74
  87. data/_includes/core/favicon.html +49 -7
  88. data/_includes/core/footer-fabs.html +17 -3
  89. data/_includes/core/footer.html +49 -34
  90. data/_includes/core/head.html +110 -86
  91. data/_includes/core/header.html +76 -54
  92. data/_includes/docs/bootstrap-docs.html +8 -8
  93. data/_includes/landing/landing-install-cards.html +2 -2
  94. data/_includes/landing/landing-quick-links.html +1 -1
  95. data/_includes/navigation/admin-nav.html +2 -2
  96. data/_includes/navigation/nav-tree.html +8 -8
  97. data/_includes/navigation/navbar.html +12 -12
  98. data/_includes/navigation/section-sidebar.html +109 -27
  99. data/_includes/navigation/sidebar-config.html +36 -2
  100. data/_includes/navigation/sidebar-left.html +17 -16
  101. data/_includes/navigation/sidebar-right.html +8 -7
  102. data/_includes/obsidian/full-graph.html +2 -2
  103. data/_includes/setup/claude-session.html +72 -0
  104. data/_includes/setup/prereq-checklist.html +90 -0
  105. data/_includes/setup/wizard.html +924 -222
  106. data/_includes/stats/stats-categories.html +8 -8
  107. data/_includes/stats/stats-header.html +14 -14
  108. data/_includes/stats/stats-metrics.html +14 -14
  109. data/_includes/stats/stats-no-data.html +12 -12
  110. data/_includes/stats/stats-overview.html +6 -6
  111. data/_includes/stats/stats-tags.html +8 -8
  112. data/_layouts/404.html +38 -24
  113. data/_layouts/README.md +2 -0
  114. data/_layouts/admin.html +24 -24
  115. data/_layouts/article.html +43 -33
  116. data/_layouts/author.html +20 -20
  117. data/_layouts/authors.html +2 -2
  118. data/_layouts/book-abc.html +12 -12
  119. data/_layouts/book-story.html +15 -15
  120. data/_layouts/book.html +12 -12
  121. data/_layouts/collection.html +33 -33
  122. data/_layouts/cookbook.html +88 -0
  123. data/_layouts/default.html +31 -28
  124. data/_layouts/home.html +23 -23
  125. data/_layouts/index.html +10 -10
  126. data/_layouts/landing.html +17 -17
  127. data/_layouts/news.html +44 -44
  128. data/_layouts/note.html +38 -38
  129. data/_layouts/notebook.html +34 -34
  130. data/_layouts/recipe.html +274 -0
  131. data/_layouts/root.html +92 -55
  132. data/_layouts/section.html +62 -33
  133. data/_layouts/setup.html +3 -3
  134. data/_layouts/sitemap-collection.html +49 -49
  135. data/_layouts/stats.html +40 -40
  136. data/_layouts/tag.html +12 -12
  137. data/_layouts/welcome.html +21 -21
  138. data/_sass/components/_callout.scss +1 -1
  139. data/_sass/components/_footer.scss +37 -1
  140. data/_sass/components/_mermaid.scss +375 -0
  141. data/_sass/components/_page-views.scss +36 -0
  142. data/_sass/components/_recipe.scss +506 -0
  143. data/_sass/components/_setup-wizard.scss +764 -0
  144. data/_sass/components/_ui-enhancements.scss +6 -6
  145. data/_sass/core/_navbar.scss +261 -46
  146. data/_sass/layouts/_landing.scss +2 -2
  147. data/_sass/layouts/_navbar-extras.scss +14 -4
  148. data/_sass/tokens/_color.scss +6 -0
  149. data/_sass/tokens/_index.scss +2 -0
  150. data/_sass/tokens/_radius.scss +21 -0
  151. data/_sass/tokens/_typography.scss +4 -0
  152. data/_sass/utilities/_focus.scss +14 -0
  153. data/assets/css/main.scss +4 -0
  154. data/assets/js/ai-chat.js +47 -5
  155. data/assets/js/fleet-feedback-capture.js +124 -0
  156. data/assets/js/fleet-feedback.js +853 -0
  157. data/assets/js/mermaid-diagrams.js +1267 -0
  158. data/assets/js/modules/navigation/config.js +9 -6
  159. data/assets/js/modules/navigation/navbar.js +55 -0
  160. data/assets/js/modules/navigation/scroll-spy.js +315 -80
  161. data/assets/js/modules/theme/appearance.js +8 -2
  162. data/assets/js/obsidian-wiki-links.js +8 -3
  163. data/assets/js/page-feedback.js +125 -192
  164. data/assets/js/page-views.js +372 -0
  165. data/assets/js/recipe-scaler.js +501 -0
  166. data/assets/js/search-modal.js +36 -0
  167. data/assets/js/setup-wizard.js +2279 -226
  168. data/assets/js/site-builder.js +1834 -0
  169. data/assets/js/ui-enhancements.js +11 -3
  170. data/scripts/README.md +44 -0
  171. data/scripts/ai/README.md +38 -0
  172. data/scripts/ai/api_call.rb +124 -0
  173. data/scripts/ai/usage.rb +314 -0
  174. data/scripts/ai/usage_report.rb +225 -0
  175. data/scripts/bin/audit-consumer +39 -7
  176. data/scripts/bin/giscus-discussions +213 -14
  177. data/scripts/bin/manifest +35 -12
  178. data/scripts/ci/agent_review_result.py +164 -0
  179. data/scripts/ci/test_agent_review_result.py +172 -0
  180. data/scripts/ci/test_visual_evidence_autogen.py +341 -0
  181. data/scripts/ci/visual_evidence_autogen.py +1060 -0
  182. data/scripts/content-review.rb +20 -1
  183. data/scripts/design-system-check.rb +170 -0
  184. data/scripts/lib/audit.sh +42 -2
  185. data/scripts/lint-liquid-raw.rb +137 -0
  186. data/scripts/test/integration/mermaid +22 -8
  187. data/scripts/test/lib/run_tests.sh +3 -1
  188. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  189. data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
  190. data/scripts/translate.rb +94 -16
  191. metadata +48 -2
data/scripts/bin/manifest CHANGED
@@ -67,19 +67,40 @@ THEME_DATA_PATHS=(
67
67
  "_data/ui-text.yml"
68
68
  )
69
69
 
70
- # Plugins that remote_theme consumers MUST vendor locally (GitHub Pages won't
71
- # load plugins from a remote theme).
72
- REQUIRED_PLUGIN_PATHS=(
73
- "_plugins/obsidian_links.rb"
74
- )
70
+ # Plugins that remote_theme consumers MUST vendor locally.
71
+ #
72
+ # Deliberately empty. Nothing the theme ships is mandatory for a stock
73
+ # remote_theme consumer: GitHub Pages builds in safe mode, so it does not load
74
+ # ANY local plugin — vendoring one there is inert, not required.
75
+ REQUIRED_PLUGIN_PATHS=()
75
76
 
76
- # Optional plugins that consumers can vendor for enhanced functionality.
77
+ # Optional plugins a consumer can vendor for enhanced functionality. These only
78
+ # execute on a self-built (vanilla Jekyll) site, never on default GitHub Pages.
79
+ #
80
+ # `obsidian_links.rb` sits here, not above, because obsidian.instructions.md is
81
+ # explicit that it is opt-in ("skipped under the github-pages gem"; "an
82
+ # SEO-quality enhancement for forks that self-build") and that the Liquid + JS
83
+ # path is the primary one. Listing it as required made the audit demand a file
84
+ # that cannot run, flagging MISSING_PLUGIN on seven fleet consumers that have
85
+ # no Obsidian content at all.
77
86
  OPTIONAL_PLUGIN_PATHS=(
87
+ "_plugins/obsidian_links.rb"
78
88
  "_plugins/admin_page_urls.rb"
79
89
  "_plugins/content_statistics_generator.rb"
80
90
  "_plugins/theme_version.rb"
81
91
  )
82
92
 
93
+ # Emit YAML list items, or an empty flow sequence when there are none.
94
+ # `printf ' - %s\n'` with zero args would otherwise emit a bare ` - `, i.e. a
95
+ # phantom empty entry in the list.
96
+ yaml_list_items() {
97
+ if [[ $# -eq 0 ]]; then
98
+ echo " []"
99
+ else
100
+ printf ' - %s\n' "$@"
101
+ fi
102
+ }
103
+
83
104
  # ---------------------------------------------------------------------------
84
105
  # Argument parsing
85
106
  DRY_RUN=false
@@ -153,17 +174,19 @@ $(printf ' - %s\n' "${THEMABLE_PATHS[@]}")
153
174
  theme_data_paths:
154
175
  $(printf ' - %s\n' "${THEME_DATA_PATHS[@]}")
155
176
 
156
- # Plugin files that consumers using remote_theme MUST vendor locally.
157
- # GitHub Pages does not load plugins from a remote_theme source.
177
+ # Plugin files a consumer MUST vendor locally. Empty by design: GitHub Pages
178
+ # builds in safe mode and loads no local plugins at all, so nothing the theme
179
+ # ships is mandatory for a stock remote_theme consumer.
158
180
  required_plugin_paths:
159
- $(printf ' - %s\n' "${REQUIRED_PLUGIN_PATHS[@]}")
160
- # Optional plugins consumers can vendor for enhanced functionality.
181
+ $(yaml_list_items ${REQUIRED_PLUGIN_PATHS[@]+"${REQUIRED_PLUGIN_PATHS[@]}"})
182
+ # Optional plugins consumers can vendor for enhanced functionality. These run
183
+ # only on self-built (vanilla Jekyll) sites, never on default GitHub Pages.
161
184
  optional_plugin_paths:
162
- $(printf ' - %s\n' "${OPTIONAL_PLUGIN_PATHS[@]}")
185
+ $(yaml_list_items ${OPTIONAL_PLUGIN_PATHS[@]+"${OPTIONAL_PLUGIN_PATHS[@]}"})
163
186
 
164
187
  # Legacy alias — all plugin paths combined (kept for backward compatibility).
165
188
  plugin_paths:
166
- $(printf ' - %s\n' "${REQUIRED_PLUGIN_PATHS[@]}" "${OPTIONAL_PLUGIN_PATHS[@]}")
189
+ $(yaml_list_items ${REQUIRED_PLUGIN_PATHS[@]+"${REQUIRED_PLUGIN_PATHS[@]}"} ${OPTIONAL_PLUGIN_PATHS[@]+"${OPTIONAL_PLUGIN_PATHS[@]}"})
167
190
 
168
191
  # Jekyll config keys this theme expects.
169
192
  config_schema:
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """Decide whether the Claude content review ACTUALLY ran, and fail loudly if not.
3
+
4
+ WHY THIS EXISTS (issue #418)
5
+ ----------------------------
6
+ `.github/workflows/ai-content-review.yml`'s agent step used to capture the
7
+ Claude CLI's exit status, echo it, and then never act on it. Whatever landed on
8
+ stdout was posted verbatim under the `ai-content-review-agent` sticky marker and
9
+ the step exited 0.
10
+
11
+ So when the OAuth credential was revoked (2026-08-18 → 08-24) the CLI printed
12
+
13
+ Failed to authenticate. API Error: 401 OAuth access token has been revoked.
14
+
15
+ to STDOUT, that single line became the "review", and every job reported success.
16
+ Run 32618860829 concluded `success` while posting exactly that to PR #414; run
17
+ 32656168775 shows all three agent steps green under the same conditions. Nothing
18
+ in the checks list, the run conclusion, or the annotations distinguished a
19
+ working editorial review from a dead one — for days, across several PRs.
20
+
21
+ Note what that rules out: a non-zero exit code is NOT sufficient to detect this.
22
+ The CLI reported the auth failure on stdout, and the step's own control flow
23
+ swallowed the status regardless. So all three ways the tier can be dead are
24
+ classified here:
25
+
26
+ 1. the CLI exited non-zero;
27
+ 2. it produced no output at all;
28
+ 3. it produced only a failure notice (the observed 401 case).
29
+
30
+ On any of those this writes an explicit failure notice for the sticky comment,
31
+ emits a ``::error::`` annotation so the condition is visible in the checks UI,
32
+ and exits 1 so the job goes red. The workflow's `Post agent review` step is
33
+ `if: always()`, so the comment is still posted — the reader gets a notice that
34
+ says the review did not run, instead of a raw API error dressed as a review.
35
+
36
+ Usage:
37
+ python3 scripts/ci/agent_review_result.py \\
38
+ --status <claude-exit-code> \\
39
+ --stdout <file> [--stderr <file>] \\
40
+ --out <comment-body-file>
41
+
42
+ Exit: 0 the review is real · 1 the tier failed · 2 bad invocation
43
+
44
+ Tests: scripts/ci/test_agent_review_result.py (run in CI via
45
+ scripts/test/lib/test_agent_review_result.sh → ./scripts/bin/test).
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import argparse
51
+ import re
52
+ import sys
53
+ from pathlib import Path
54
+
55
+ TITLE = "## 🤖 Claude Code Content Review"
56
+
57
+ # Failure signatures the Claude CLI prints INSTEAD of a review. Kept narrow on
58
+ # purpose: every entry is a string the CLI emits on its own.
59
+ FAILURE_SIGNATURES = re.compile(
60
+ r"Failed to authenticate"
61
+ r"|OAuth access token has been revoked"
62
+ r"|API Error: (?:401|403|429|5\d\d)"
63
+ r"|authentication_error"
64
+ r"|invalid_api_key"
65
+ r"|Invalid API key"
66
+ r"|Credit balance is too low"
67
+ r"|Please run [`'\"]?claude login",
68
+ re.IGNORECASE,
69
+ )
70
+
71
+ # A genuine review is a structured Markdown document — a verdict, per-file
72
+ # findings, a summary. A failure notice is a line or two, and the failure is the
73
+ # FIRST thing on it. Requiring all three (short body, signature, signature near
74
+ # the top) stops a review that legitimately *discusses* an auth error from being
75
+ # thrown away as a failure — reviewing this repo's own credential-troubleshooting
76
+ # page would otherwise fail its own PR. Both directions are covered by the tests,
77
+ # and the bound is deliberately tight: an ambiguous LONG output containing an
78
+ # error is better shown to a human than silently discarded. A genuinely broken
79
+ # run that is also verbose almost always exits non-zero, which rule 1 catches.
80
+ MAX_FAILURE_NOTICE_LINES = 5
81
+ FAILURE_SIGNATURE_HEAD_LINES = 3
82
+
83
+
84
+ def nonblank_lines(text: str) -> list[str]:
85
+ return [ln for ln in text.splitlines() if ln.strip()]
86
+
87
+
88
+ def classify(status: int, stdout: str) -> str | None:
89
+ """Return a human-readable failure reason, or None if the review is real."""
90
+ lines = nonblank_lines(stdout)
91
+ if status != 0:
92
+ return f"the Claude CLI exited with status `{status}`"
93
+ if not lines:
94
+ return "the agent produced no output"
95
+ head = "\n".join(lines[:FAILURE_SIGNATURE_HEAD_LINES])
96
+ if len(lines) <= MAX_FAILURE_NOTICE_LINES and FAILURE_SIGNATURES.search(head):
97
+ return "the agent reported a credential / API failure instead of a review"
98
+ return None
99
+
100
+
101
+ def _details(summary: str, body: str) -> list[str]:
102
+ tail = "\n".join(body.splitlines()[-20:])
103
+ return [f"<details><summary>{summary}</summary>", "", "```", tail, "```", "", "</details>", ""]
104
+
105
+
106
+ def render_failure(reason: str, status: int, stdout: str, stderr: str) -> str:
107
+ out = [
108
+ TITLE,
109
+ "",
110
+ "> [!CAUTION]",
111
+ f"> **The editorial review did not run** — {reason}.",
112
+ "> This comment is a failure notice, not a review. The job has been",
113
+ "> failed on purpose so the condition is visible on the checks list",
114
+ "> rather than only in this comment.",
115
+ "",
116
+ f"Claude CLI exit status: `{status}`",
117
+ "",
118
+ ]
119
+ if stdout.strip():
120
+ out += _details("Agent stdout", stdout)
121
+ if stderr.strip():
122
+ out += _details("Agent stderr (tail)", stderr)
123
+ out += [
124
+ "If this is a credential problem, rotate `CLAUDE_CODE_OAUTH_TOKEN` for this",
125
+ "repository — see `docs/TOKEN-ROTATION.md` in bamr87/bamr87. The deterministic",
126
+ "tier (`scripts/content-review.rb`) is unaffected and posts separately.",
127
+ "",
128
+ ]
129
+ return "\n".join(out)
130
+
131
+
132
+ def main(argv: list[str] | None = None) -> int:
133
+ p = argparse.ArgumentParser(description=__doc__)
134
+ p.add_argument("--status", type=int, required=True, help="claude CLI exit code")
135
+ p.add_argument("--stdout", required=True, help="file holding the agent's stdout")
136
+ p.add_argument("--stderr", default=None, help="file holding the agent's stderr")
137
+ p.add_argument("--out", required=True, help="file to write the sticky comment body to")
138
+ args = p.parse_args(argv)
139
+
140
+ stdout = Path(args.stdout).read_text(encoding="utf-8", errors="replace") \
141
+ if Path(args.stdout).exists() else ""
142
+ stderr = ""
143
+ if args.stderr and Path(args.stderr).exists():
144
+ stderr = Path(args.stderr).read_text(encoding="utf-8", errors="replace")
145
+
146
+ reason = classify(args.status, stdout)
147
+ out_path = Path(args.out)
148
+
149
+ if reason is None:
150
+ out_path.write_text(f"{TITLE}\n\n{stdout}", encoding="utf-8")
151
+ print(f"agent-review-result: review looks real "
152
+ f"({len(nonblank_lines(stdout))} non-blank lines).")
153
+ return 0
154
+
155
+ out_path.write_text(render_failure(reason, args.status, stdout, stderr), encoding="utf-8")
156
+ print(f"::error title=Claude content review did not run::{reason} "
157
+ f"(claude exit {args.status}). The sticky comment carries a failure notice, "
158
+ f"not a review.")
159
+ print(f"agent-review-result: FAILED — {reason}", file=sys.stderr)
160
+ return 1
161
+
162
+
163
+ if __name__ == "__main__":
164
+ raise SystemExit(main())
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """Unit tests for scripts/ci/agent_review_result.py (issue #418).
3
+
4
+ The bug these guard against: the Claude tier of ai-content-review.yml posted
5
+
6
+ Failed to authenticate. API Error: 401 OAuth access token has been revoked.
7
+
8
+ into PR #414 as its review, and the job still reported success. The credential
9
+ has since been rotated, but the SWALLOW is the durable defect — it would hide
10
+ the next revoked token exactly as well as it hid this one.
11
+
12
+ The first fixture below is that output verbatim, copied from the comment the
13
+ workflow actually posted (2026-08-23T04:51:47Z, run 32618860829). If the guard
14
+ ever stops failing on it, the regression is back.
15
+
16
+ Both directions are covered. A real review must NOT be flagged — including one
17
+ whose prose legitimately quotes an auth error, which is the false positive a
18
+ bare `grep 'Failed to authenticate'` would produce (and which would fire on this
19
+ repo's own troubleshooting docs).
20
+
21
+ Dependency-light on purpose: standard library only, so it runs anywhere the
22
+ workflow does.
23
+
24
+ python3 scripts/ci/test_agent_review_result.py
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ import sys
31
+ import tempfile
32
+ from pathlib import Path
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+
36
+ import agent_review_result as arr # noqa: E402
37
+
38
+ REPO_ROOT = Path(__file__).resolve().parents[2]
39
+ WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ai-content-review.yml"
40
+
41
+ CHECKS: list[tuple[str, bool]] = []
42
+
43
+
44
+ def check(label: str, ok: bool) -> None:
45
+ CHECKS.append((label, bool(ok)))
46
+
47
+
48
+ # The 401 exactly as the CLI printed it and the workflow posted it.
49
+ AUTH_401 = "Failed to authenticate. API Error: 401 OAuth access token has been revoked.\n"
50
+
51
+ REAL_REVIEW = """**Verdict:** approve with minor suggestions
52
+
53
+ ### pages/_docs/getting-started.md
54
+
55
+ - The `description` front matter reads as a keyword list rather than a sentence.
56
+ Rewrite it as prose so search snippets are readable.
57
+ - The second H2 duplicates the page title; demote it to an H3 or cut it.
58
+ - Two code fences are missing a language tag, so they render unhighlighted.
59
+ - Alt text on the architecture diagram describes the file, not the picture.
60
+
61
+ ### pages/_docs/configuration.md
62
+
63
+ - Consistent terminology: this page says "config file" where the rest of the
64
+ docs say `_config.yml`. Pick one.
65
+ - The table of collection defaults is missing the `books` collection added in
66
+ v1.19, so the page is incomplete rather than merely terse.
67
+ - Sentence length in "Advanced overrides" averages 38 words; split the longest.
68
+
69
+ ### Summary
70
+
71
+ Both files are accurate and well-organised. The findings above are polish, not
72
+ correctness — nothing here blocks the merge.
73
+ """
74
+
75
+ # A real review that TALKS about auth failures. Must not be mistaken for one.
76
+ REVIEW_ABOUT_AUTH = """**Verdict:** approve
77
+
78
+ ### docs/troubleshooting/credentials.md
79
+
80
+ - The page explains what to do when the CLI prints "Failed to authenticate. API
81
+ Error: 401 OAuth access token has been revoked." — good, that is the exact
82
+ string users will search for, so keep it verbatim rather than paraphrasing.
83
+ - Add a cross-link to the rotation runbook; the reader is told to "rotate the
84
+ token" with no pointer to how.
85
+ - The `authentication_error` example block is missing a language tag.
86
+ - Consider noting that the deterministic review tier keeps working while the
87
+ credential is broken, since that surprised at least one reader.
88
+ - Front matter `lastmod` is stale by two releases.
89
+
90
+ ### Summary
91
+
92
+ Accurate and genuinely useful. Only polish items above; the troubleshooting
93
+ steps themselves check out against the current workflow definition.
94
+ """
95
+
96
+
97
+ def run_guard(status: int, stdout: str, stderr: str = "") -> tuple[int, str]:
98
+ """Invoke the guard end-to-end; return (exit code, comment body)."""
99
+ with tempfile.TemporaryDirectory() as tmp:
100
+ d = Path(tmp)
101
+ (d / "out.txt").write_text(stdout, encoding="utf-8")
102
+ (d / "err.txt").write_text(stderr, encoding="utf-8")
103
+ rc = arr.main([
104
+ "--status", str(status),
105
+ "--stdout", str(d / "out.txt"),
106
+ "--stderr", str(d / "err.txt"),
107
+ "--out", str(d / "body.md"),
108
+ ])
109
+ return rc, (d / "body.md").read_text(encoding="utf-8")
110
+
111
+
112
+ def main() -> int:
113
+ # --- the observed failure, verbatim ------------------------------------ #
114
+ rc, body = run_guard(0, AUTH_401)
115
+ check("the verbatim PR #414 401 fails the step (exit 1) even on claude exit 0", rc == 1)
116
+ check("the 401 comment body says the review did not run", "did not run" in body)
117
+ check("the raw 401 line is no longer posted as if it were the review",
118
+ not body.startswith(f"{arr.TITLE}\n\nFailed to authenticate"))
119
+ check("the failure notice is visibly a caution, not a review", "[!CAUTION]" in body)
120
+
121
+ rc, _ = run_guard(1, AUTH_401)
122
+ check("a non-zero claude exit fails the step", rc == 1)
123
+
124
+ # --- nothing came back -------------------------------------------------- #
125
+ rc, body = run_guard(0, "")
126
+ check("empty agent output fails the step", rc == 1)
127
+ check("the empty-output notice names the reason", "no output" in body)
128
+
129
+ rc, _ = run_guard(0, "\n \n\t\n")
130
+ check("whitespace-only agent output fails the step", rc == 1)
131
+
132
+ # --- other credential failures ------------------------------------------ #
133
+ rc, _ = run_guard(0, "API Error: 400 Credit balance is too low to access the API.\n")
134
+ check("a credit-balance failure fails the step", rc == 1)
135
+ rc, _ = run_guard(0, "Invalid API key · Please run `claude login`\n")
136
+ check("an invalid-key failure fails the step", rc == 1)
137
+ rc, _ = run_guard(0, "API Error: 429 rate limit exceeded\n")
138
+ check("a rate-limit failure fails the step", rc == 1)
139
+
140
+ # --- real reviews pass --------------------------------------------------- #
141
+ rc, body = run_guard(0, REAL_REVIEW)
142
+ check("a genuine multi-section review passes", rc == 0)
143
+ check("the real review is posted under the review heading", "**Verdict:**" in body)
144
+
145
+ rc, _ = run_guard(0, REVIEW_ABOUT_AUTH)
146
+ check("a long review that QUOTES an auth error is not mistaken for a failure", rc == 0)
147
+
148
+ # --- the workflow actually uses the guard -------------------------------- #
149
+ # Without this the fix is one deleted line away from returning, and nothing
150
+ # in the fixture tests above would notice.
151
+ wf = WORKFLOW.read_text(encoding="utf-8")
152
+ check("ai-content-review.yml invokes the guard",
153
+ "scripts/ci/agent_review_result.py" in wf)
154
+ check("the agent step no longer ends by piping stdout into the comment body",
155
+ not re.search(r"cat /tmp/agent-review\.md\s*\n\s*\}\s*>\s*/tmp/agent-review-final\.md", wf))
156
+ check("the AI gate no longer implies a present credential is a working one",
157
+ "not validity" in wf)
158
+
159
+ failed = [label for label, ok in CHECKS if not ok]
160
+ print()
161
+ for label, ok in CHECKS:
162
+ print(f" {'PASS' if ok else 'FAIL'} {label}")
163
+ print()
164
+ if failed:
165
+ print(f"FAILED ({len(failed)}/{len(CHECKS)})")
166
+ return 1
167
+ print(f"OK ({len(CHECKS)} checks)")
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ raise SystemExit(main())