jekyll-theme-zer0 1.29.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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +715 -2
  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 +169 -18
  7. data/_data/consumers.yml +48 -5
  8. data/_data/features.yml +146 -20
  9. data/_data/feedback_types.yml +17 -12
  10. data/_data/landing.yml +5 -2
  11. data/_data/navigation/quickstart.yml +4 -0
  12. data/_data/site_builder.yml +874 -0
  13. data/_data/theme-manifest.yml +122 -114
  14. data/_data/ui-text.yml +18 -0
  15. data/_includes/README.md +11 -1
  16. data/_includes/analytics/posthog.html +2 -2
  17. data/_includes/components/admin-links.html +2 -2
  18. data/_includes/components/admin-tabs.html +2 -2
  19. data/_includes/components/ai-chat.html +14 -11
  20. data/_includes/components/analytics-dashboard.html +8 -8
  21. data/_includes/components/author-bio.html +1 -1
  22. data/_includes/components/author-card.html +10 -2
  23. data/_includes/components/author-eeat.html +4 -4
  24. data/_includes/components/background-customizer.html +8 -8
  25. data/_includes/components/background-image.html +114 -0
  26. data/_includes/components/background-settings.html +28 -15
  27. data/_includes/components/collection-manager.html +5 -5
  28. data/_includes/components/component-showcase.html +13 -13
  29. data/_includes/components/config-editor.html +12 -12
  30. data/_includes/components/config-viewer.html +8 -8
  31. data/_includes/components/cookie-consent.html +11 -11
  32. data/_includes/components/cta-button.html +7 -2
  33. data/_includes/components/dev-shortcuts.html +7 -7
  34. data/_includes/components/env-dashboard.html +8 -8
  35. data/_includes/components/env-switcher.html +9 -9
  36. data/_includes/components/feature-card.html +2 -2
  37. data/_includes/components/halfmoon.html +2 -2
  38. data/_includes/components/info-section.html +36 -36
  39. data/_includes/components/js-cdn.html +15 -15
  40. data/_includes/components/language-toggle.html +4 -4
  41. data/_includes/components/mermaid.html +72 -435
  42. data/_includes/components/nanobar.html +5 -5
  43. data/_includes/components/nav-editor.html +2 -2
  44. data/_includes/components/nav-export.html +2 -2
  45. data/_includes/components/nav-overview.html +2 -2
  46. data/_includes/components/page-feedback.html +45 -30
  47. data/_includes/components/page-views-init.html +1 -1
  48. data/_includes/components/post-card.html +22 -22
  49. data/_includes/components/post-type-badge.html +2 -2
  50. data/_includes/components/powered-by.html +2 -2
  51. data/_includes/components/preview-image.html +6 -0
  52. data/_includes/components/quick-index.html +2 -2
  53. data/_includes/components/search-modal.html +27 -2
  54. data/_includes/components/searchbar.html +2 -2
  55. data/_includes/components/svg-background.html +2 -2
  56. data/_includes/components/theme-customizer.html +2 -2
  57. data/_includes/components/theme-info.html +6 -6
  58. data/_includes/components/theme-preview-gallery.html +22 -22
  59. data/_includes/content/giscus.html +2 -2
  60. data/_includes/content/intro.html +8 -8
  61. data/_includes/content/jsonld-faq.html +2 -2
  62. data/_includes/content/jsonld-software.html +2 -2
  63. data/_includes/content/seo.html +4 -4
  64. data/_includes/content/sitemap.html +27 -27
  65. data/_includes/content/toc.html +183 -183
  66. data/_includes/core/branding.html +6 -6
  67. data/_includes/core/console-capture.html +32 -74
  68. data/_includes/core/favicon.html +49 -7
  69. data/_includes/core/footer-fabs.html +17 -3
  70. data/_includes/core/footer.html +31 -18
  71. data/_includes/core/head.html +102 -90
  72. data/_includes/core/header.html +71 -52
  73. data/_includes/docs/bootstrap-docs.html +8 -8
  74. data/_includes/landing/landing-install-cards.html +2 -2
  75. data/_includes/landing/landing-quick-links.html +1 -1
  76. data/_includes/navigation/admin-nav.html +2 -2
  77. data/_includes/navigation/nav-tree.html +8 -8
  78. data/_includes/navigation/navbar.html +12 -12
  79. data/_includes/navigation/section-sidebar.html +16 -16
  80. data/_includes/navigation/sidebar-config.html +36 -2
  81. data/_includes/navigation/sidebar-left.html +15 -15
  82. data/_includes/navigation/sidebar-right.html +6 -6
  83. data/_includes/obsidian/full-graph.html +2 -2
  84. data/_includes/setup/claude-session.html +72 -0
  85. data/_includes/setup/prereq-checklist.html +90 -0
  86. data/_includes/setup/wizard.html +906 -291
  87. data/_includes/stats/stats-categories.html +8 -8
  88. data/_includes/stats/stats-header.html +14 -14
  89. data/_includes/stats/stats-metrics.html +12 -12
  90. data/_includes/stats/stats-no-data.html +12 -12
  91. data/_includes/stats/stats-overview.html +6 -6
  92. data/_includes/stats/stats-tags.html +8 -8
  93. data/_layouts/404.html +38 -24
  94. data/_layouts/admin.html +22 -22
  95. data/_layouts/article.html +39 -34
  96. data/_layouts/author.html +20 -20
  97. data/_layouts/authors.html +2 -2
  98. data/_layouts/book-abc.html +12 -12
  99. data/_layouts/book-story.html +15 -15
  100. data/_layouts/book.html +12 -12
  101. data/_layouts/collection.html +33 -33
  102. data/_layouts/cookbook.html +12 -12
  103. data/_layouts/default.html +27 -24
  104. data/_layouts/home.html +23 -23
  105. data/_layouts/index.html +10 -10
  106. data/_layouts/landing.html +17 -17
  107. data/_layouts/news.html +44 -44
  108. data/_layouts/note.html +38 -38
  109. data/_layouts/notebook.html +34 -34
  110. data/_layouts/recipe.html +24 -24
  111. data/_layouts/root.html +73 -55
  112. data/_layouts/section.html +23 -23
  113. data/_layouts/setup.html +3 -3
  114. data/_layouts/sitemap-collection.html +49 -49
  115. data/_layouts/stats.html +40 -40
  116. data/_layouts/tag.html +12 -12
  117. data/_layouts/welcome.html +21 -21
  118. data/_sass/components/_mermaid.scss +375 -0
  119. data/_sass/components/_setup-wizard.scss +569 -40
  120. data/_sass/core/_navbar.scss +11 -31
  121. data/_sass/layouts/_navbar-extras.scss +14 -4
  122. data/assets/css/main.scss +1 -0
  123. data/assets/js/ai-chat.js +47 -5
  124. data/assets/js/fleet-feedback-capture.js +124 -0
  125. data/assets/js/fleet-feedback.js +853 -0
  126. data/assets/js/mermaid-diagrams.js +1267 -0
  127. data/assets/js/modules/navigation/config.js +9 -6
  128. data/assets/js/modules/navigation/scroll-spy.js +315 -80
  129. data/assets/js/modules/theme/appearance.js +8 -2
  130. data/assets/js/obsidian-wiki-links.js +8 -3
  131. data/assets/js/page-feedback.js +125 -192
  132. data/assets/js/search-modal.js +26 -0
  133. data/assets/js/setup-wizard.js +2112 -361
  134. data/assets/js/site-builder.js +1834 -0
  135. data/assets/js/ui-enhancements.js +11 -3
  136. data/scripts/README.md +15 -0
  137. data/scripts/ai/README.md +38 -0
  138. data/scripts/ai/api_call.rb +124 -0
  139. data/scripts/ai/usage.rb +314 -0
  140. data/scripts/ai/usage_report.rb +225 -0
  141. data/scripts/ci/test_visual_evidence_autogen.py +341 -0
  142. data/scripts/ci/visual_evidence_autogen.py +1060 -0
  143. data/scripts/content-review.rb +20 -1
  144. data/scripts/test/integration/mermaid +22 -8
  145. data/scripts/test/lib/run_tests.sh +1 -0
  146. data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
  147. data/scripts/translate.rb +23 -1
  148. metadata +19 -2
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env ruby
2
+ # =============================================================================
3
+ # usage_report.rb — publish a job's AI usage records (summary, artifact, PR comment)
4
+ # -----------------------------------------------------------------------------
5
+ # The reporting half of AI metering. usage.rb captured one JSONL record per AI
6
+ # call into AI_USAGE_DIR/records.jsonl; this script, run at the end of the
7
+ # job (the claude-run composite calls it automatically), does four things:
8
+ #
9
+ # 1. ATTRIBUTE — resolve which PR the spend belongs to: an explicit --pr,
10
+ # the pull_request event payload, or the pr-result.txt file
11
+ # the factory/fleet agents write after opening a PR (that
12
+ # run's records become the PR's CREATION cost).
13
+ # 2. CONSUME — move records.jsonl to a reported-*.jsonl file (so a second
14
+ # AI step in the same job never double-reports) and print
15
+ # `file=` / `name=` to $GITHUB_OUTPUT for the artifact upload.
16
+ # 3. SUMMARIZE — append a per-call table to $GITHUB_STEP_SUMMARY.
17
+ # 4. COMMENT — upsert ONE sticky "AI usage & cost" comment on the PR,
18
+ # found by its <!-- lh-ai-usage --> marker (never
19
+ # `--edit-last`, which grabs whatever the bot said last).
20
+ # The comment embeds its own base64 data blob, so each run
21
+ # merges records by id — cumulative, idempotent, and safe to
22
+ # re-run. Concurrent jobs can still race the read-merge-write
23
+ # (last writer wins for the VIEW); the artifacts + nightly
24
+ # ledger remain the source of truth.
25
+ #
26
+ # Every dollar figure is API-equivalent: what the tokens would bill at list
27
+ # prices. Subscription (OAuth) runs cost $0 marginal — the label says so.
28
+ # Best-effort by design: metering must never fail the job. Stdlib + `gh` only.
29
+ #
30
+ # ruby scripts/ai/usage_report.rb [--pr N] [--pr-result pr-result.txt]
31
+ # =============================================================================
32
+ require 'json'
33
+ require 'time'
34
+ require 'digest'
35
+ require 'securerandom'
36
+ require_relative 'usage'
37
+
38
+ MARKER = '<!-- lh-ai-usage -->'.freeze
39
+ DATA_HEAD = '<!-- lh-ai-usage-data:'.freeze
40
+ MAX_BLOB_RECORDS = 150 # older records fold into a rollup so the comment stays < 64KB
41
+ MAX_TABLE_ROWS = 30
42
+
43
+ pr_arg = nil
44
+ pr_result_file = 'pr-result.txt'
45
+ args = ARGV.dup
46
+ until args.empty?
47
+ case (a = args.shift)
48
+ when '--pr' then pr_arg = args.shift.to_i
49
+ when '--pr-result' then pr_result_file = args.shift.to_s
50
+ end
51
+ end
52
+
53
+ # --- 1. load this job's records ------------------------------------------------
54
+ src = AIUsage.records_path
55
+ records = File.exist?(src) ? File.read(src, encoding: 'UTF-8').split("\n").map { |l| JSON.parse(l) rescue nil }.compact : []
56
+ if records.empty?
57
+ warn '[usage_report] no AI usage records this job — nothing to report.'
58
+ File.open(ENV['GITHUB_OUTPUT'], 'a') { |io| io.puts('file='); io.puts('name=') } if ENV['GITHUB_OUTPUT']
59
+ exit 0
60
+ end
61
+
62
+ # --- 2. attribute to a PR --------------------------------------------------------
63
+ pr = nil
64
+ pr_source = nil
65
+ if pr_arg && pr_arg > 0
66
+ pr = pr_arg
67
+ pr_source = 'event'
68
+ elsif ENV['GITHUB_EVENT_PATH'] && File.exist?(ENV['GITHUB_EVENT_PATH'])
69
+ ev = JSON.parse(File.read(ENV['GITHUB_EVENT_PATH'])) rescue {}
70
+ n = ev.dig('pull_request', 'number')
71
+ if n
72
+ pr = n.to_i
73
+ pr_source = 'event'
74
+ end
75
+ end
76
+ if pr.nil? && File.exist?(pr_result_file)
77
+ # The factory/fleet convention: the agent writes the PR/issue URL(s) it opened
78
+ # to pr-result.txt. The first pull URL is the PR this run CREATED — its spend
79
+ # is that PR's creation cost.
80
+ if (m = File.read(pr_result_file, encoding: 'UTF-8')[%r{/pull/(\d+)}, 1])
81
+ pr = m.to_i
82
+ pr_source = 'created'
83
+ end
84
+ end
85
+ records.each { |r| r['pr'] ||= pr; r['pr_source'] ||= pr_source if pr }
86
+
87
+ # --- 3. consume: move to a reported file, hand the path to the uploader ----------
88
+ reported = File.join(AIUsage.dir, "reported-#{Time.now.utc.strftime('%H%M%S')}-#{SecureRandom.hex(3)}.jsonl")
89
+ File.open(reported, 'w') { |io| records.each { |r| io.puts(JSON.generate(r)) } }
90
+ File.delete(src)
91
+ if ENV['GITHUB_OUTPUT']
92
+ artifact = "ai-usage-#{ENV['GITHUB_RUN_ID'] || 'local'}-#{(ENV['GITHUB_JOB'] || 'job').gsub(/[^A-Za-z0-9_-]/, '_')}-#{SecureRandom.hex(3)}"
93
+ File.open(ENV['GITHUB_OUTPUT'], 'a') { |io| io.puts("file=#{reported}"); io.puts("name=#{artifact}") }
94
+ end
95
+
96
+ fmt_usd = ->(v) { format('$%.4f', v.to_f) }
97
+ fmt_tok = ->(v) { v.to_i >= 10_000 ? "#{(v.to_i / 1000.0).round}k" : v.to_i.to_s }
98
+
99
+ # --- 4. step summary --------------------------------------------------------------
100
+ if ENV['GITHUB_STEP_SUMMARY']
101
+ total = records.sum { |r| r['cost_usd'].to_f }
102
+ lines = []
103
+ lines << '## 🤖 AI usage (this job)'
104
+ lines << ''
105
+ lines << '| role | model | turns | in | out | cache r/w | cost (API-equiv) | via |'
106
+ lines << '|---|---|---|---|---|---|---|---|'
107
+ records.each do |r|
108
+ t = r['tokens'] || {}
109
+ lines << "| #{r['agent'].to_s.empty? ? '—' : r['agent']} | #{r['model']} | #{r['num_turns'] || '—'} " \
110
+ "| #{fmt_tok.call(t['input'])} | #{fmt_tok.call(t['output'])} " \
111
+ "| #{fmt_tok.call(t['cache_read'])}/#{fmt_tok.call(t['cache_creation'])} " \
112
+ "| #{fmt_usd.call(r['cost_usd'])}#{r['cost_source'] == 'estimated' ? '*' : ''} | #{r['auth']} |"
113
+ end
114
+ lines << ''
115
+ # A failed call bills nothing, so it is invisible in the table above — spell
116
+ # out what the model refused, right where the operator is already looking.
117
+ records.select { |r| r['error'] }.each do |r|
118
+ e = r['error']
119
+ lines << "> ❌ **#{r['agent'].to_s.empty? ? 'AI call' : r['agent']} failed** " \
120
+ "(`#{e['subtype'].to_s.empty? ? "exit #{e['exit_code']}" : e['subtype']}`): #{e['message']}"
121
+ lines << ''
122
+ end
123
+ lines << "**Job total: #{fmt_usd.call(total)}** (API-equivalent#{records.any? { |r| r['cost_source'] == 'estimated' } ? '; * = estimated from _data/ai_pricing.yml' : ''})."
124
+ lines << ''
125
+ File.open(ENV['GITHUB_STEP_SUMMARY'], 'a') { |io| io.puts(lines.join("\n")) }
126
+ end
127
+
128
+ # --- 5. sticky PR comment (best-effort) --------------------------------------------
129
+ repo = ENV['GITHUB_REPOSITORY'].to_s
130
+ exit 0 if pr.nil? || repo.empty?
131
+ unless system('gh --version > /dev/null 2>&1') && !(ENV['GH_TOKEN'].to_s + ENV['GITHUB_TOKEN'].to_s).empty?
132
+ warn '[usage_report] no gh/token — skipping the PR comment (records still in the artifact).'
133
+ exit 0
134
+ end
135
+
136
+ def gh_json(args)
137
+ out = IO.popen(['gh'] + args, err: %i[child out], &:read)
138
+ return nil unless $?.success?
139
+ JSON.parse(out)
140
+ rescue StandardError
141
+ nil
142
+ end
143
+
144
+ # Find the existing sticky comment by MARKER (any author, any position).
145
+ existing = nil
146
+ page = 1
147
+ loop do
148
+ batch = gh_json(['api', "repos/#{repo}/issues/#{pr}/comments?per_page=100&page=#{page}"])
149
+ break unless batch.is_a?(Array)
150
+ existing = batch.find { |c| c['body'].to_s.start_with?(MARKER) }
151
+ break if existing || batch.size < 100 || page >= 10
152
+ page += 1
153
+ end
154
+
155
+ # Merge this job's records into the comment's embedded blob (dedup by id).
156
+ blob = { 'records' => [], 'folded' => nil }
157
+ if existing && (m = existing['body'].to_s[/#{Regexp.escape(DATA_HEAD)}([A-Za-z0-9+\/=]+) -->/, 1])
158
+ blob = JSON.parse(m.unpack1('m')) rescue { 'records' => [], 'folded' => nil }
159
+ end
160
+ compact = ->(r) do
161
+ t = r['tokens'] || {}
162
+ { 'i' => r['id'], 't' => r['ts'], 'w' => r['workflow'], 'j' => r['job'], 'r' => r['run_id'],
163
+ 'a' => r['agent'], 'm' => r['model'], 'ti' => t['input'].to_i, 'to' => t['output'].to_i,
164
+ 'tr' => t['cache_read'].to_i, 'tc' => t['cache_creation'].to_i,
165
+ 'c' => r['cost_usd'].to_f.round(6), 's' => r['cost_source'], 'au' => r['auth'],
166
+ 'st' => r['status'], 'ps' => r['pr_source'] }
167
+ end
168
+ known = blob['records'].map { |r| r['i'] }
169
+ records.each { |r| blob['records'] << compact.call(r) unless known.include?(r['id']) }
170
+ blob['records'].sort_by! { |r| r['t'].to_s }
171
+ while blob['records'].size > MAX_BLOB_RECORDS
172
+ old = blob['records'].shift
173
+ f = blob['folded'] ||= { 'n' => 0, 'c' => 0.0, 'ti' => 0, 'to' => 0 }
174
+ f['n'] += 1
175
+ f['c'] = (f['c'] + old['c'].to_f).round(6)
176
+ f['ti'] += old['ti'].to_i
177
+ f['to'] += old['to'].to_i
178
+ end
179
+
180
+ all = blob['records']
181
+ folded = blob['folded']
182
+ total = all.sum { |r| r['c'].to_f } + (folded ? folded['c'].to_f : 0.0)
183
+ creation = all.select { |r| r['ps'] == 'created' }.sum { |r| r['c'].to_f }
184
+ downstream = total - creation
185
+ estimated = all.any? { |r| r['s'] == 'estimated' }
186
+ oauth_only = all.all? { |r| r['au'] == 'oauth' }
187
+
188
+ body = []
189
+ body << MARKER
190
+ body << '## 🤖 AI usage & cost for this PR'
191
+ body << ''
192
+ body << "**Total: #{fmt_usd.call(total)} API-equivalent** across #{all.size + (folded ? folded['n'] : 0)} AI call(s) — " \
193
+ "creation #{fmt_usd.call(creation)}, reviews/fixes/checks #{fmt_usd.call(downstream)}."
194
+ body << ''
195
+ body << '| when (UTC) | workflow · job | role | model | out tok | cost |'
196
+ body << '|---|---|---|---|---|---|'
197
+ body << "| _earlier_ | _#{folded['n']} older call(s), folded_ | | | #{fmt_tok.call(folded['to'])} | #{fmt_usd.call(folded['c'])} |" if folded
198
+ all.last(MAX_TABLE_ROWS).each do |r|
199
+ run_link = r['r'].to_s.empty? ? (r['w'].to_s.empty? ? 'local' : r['w']) : "[#{r['w']} · #{r['j']}](https://github.com/#{repo}/actions/runs/#{r['r']})"
200
+ body << "| #{r['t'].to_s[5, 11]} | #{run_link} | #{r['a'].to_s.empty? ? '—' : r['a']}#{r['ps'] == 'created' ? ' 🌱' : ''} " \
201
+ "| #{r['m']} | #{fmt_tok.call(r['to'])} | #{fmt_usd.call(r['c'])}#{r['s'] == 'estimated' ? '*' : ''} |"
202
+ end
203
+ body << "| | _…#{all.size - MAX_TABLE_ROWS} more in the ledger_ | | | | |" if all.size > MAX_TABLE_ROWS
204
+ body << ''
205
+ notes = ['🌱 = the run that opened this PR (creation cost).']
206
+ notes << '\\* = estimated from `_data/ai_pricing.yml` (that path reports tokens, not dollars).' if estimated
207
+ notes << (oauth_only ? 'All calls ran on Claude Code subscription auth (OAuth) — $0 marginal spend; the figure is what these tokens would bill at API list prices.' \
208
+ : 'Some calls used a metered API key — those dollars are real.')
209
+ notes << 'Updated automatically after every AI job; full history at [/docs/ai-usage/](https://lifehacker.dev/docs/ai-usage/).'
210
+ body << notes.map { |n| "_#{n}_" }.join(' ')
211
+ body << ''
212
+ body << "#{DATA_HEAD}#{[JSON.generate(blob)].pack('m0')} -->"
213
+
214
+ payload = JSON.generate('body' => body.join("\n"))
215
+ tmp = File.join(AIUsage.dir, 'comment-payload.json')
216
+ File.write(tmp, payload)
217
+ ok =
218
+ if existing
219
+ system('gh', 'api', '-X', 'PATCH', "repos/#{repo}/issues/comments/#{existing['id']}", '--input', tmp, out: File::NULL, err: %i[child out])
220
+ else
221
+ system('gh', 'api', "repos/#{repo}/issues/#{pr}/comments", '--input', tmp, out: File::NULL, err: %i[child out])
222
+ end
223
+ warn(ok ? "[usage_report] PR ##{pr} cost comment #{existing ? 'updated' : 'created'} (total #{fmt_usd.call(total)})." \
224
+ : "[usage_report] PR ##{pr} comment update failed (non-fatal; records are in the artifact).")
225
+ exit 0
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env python3
2
+ # Feature: ZER0-085
3
+ """Unit tests for scripts/ci/visual_evidence_autogen.py — the deterministic half
4
+ of the visual-evidence autogen lane.
5
+
6
+ What these pin, and why:
7
+
8
+ * the PLAN — from a PR's changed files alone, does the lane decide to act, and
9
+ on what? The shape of PR #454 (UI + spec + generator + README-only evidence)
10
+ must yield exactly one bespoke job; a docs-only PR must yield nothing; the
11
+ loop guard and the budget must stop it cold;
12
+ * the DECISION — only a well-formed `intentional` verdict on a genuinely failing
13
+ verify pass may bless baselines (issue #417: a blessed regression and a green
14
+ check are indistinguishable, so "the check is red" must never be enough);
15
+ * the CONTRACT with the workflows — the evidence gate's UI paths, ci.yml's
16
+ `styling` filter coverage, the hooks update-snapshots.sh exposes, and the
17
+ subcommands the workflow calls all exist where this script expects them.
18
+
19
+ Standard library only; no Docker, no network.
20
+
21
+ python3 scripts/ci/test_visual_evidence_autogen.py
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+
32
+ import visual_evidence_autogen as vea # noqa: E402
33
+
34
+ REPO_ROOT = Path(__file__).resolve().parents[2]
35
+ FAILURES: list[str] = []
36
+ PASSED = 0
37
+
38
+
39
+ def check(label: str, ok: bool) -> None:
40
+ global PASSED
41
+ if ok:
42
+ PASSED += 1
43
+ print(f" ✓ {label}")
44
+ else:
45
+ FAILURES.append(label)
46
+ print(f" ✗ {label}")
47
+
48
+
49
+ class FakeFS:
50
+ """A head tree: {path: content}; PNGs are any key ending in .png."""
51
+
52
+ def __init__(self, files: dict[str, str]):
53
+ self.files = files
54
+
55
+ def exists(self, path: str) -> bool:
56
+ return path in self.files
57
+
58
+ def pngs(self, folder: str) -> list[str]:
59
+ return sorted(p for p in self.files if p.startswith(folder + "/") and p.endswith(".png"))
60
+
61
+ def read(self, path: str) -> str:
62
+ return self.files[path]
63
+
64
+ def generators(self) -> list[str]:
65
+ return sorted(p for p in self.files if vea.GENERATOR_RE.match(p))
66
+
67
+
68
+ GEN_405 = "test/visual/navbar-tiers-evidence.mjs"
69
+ GEN_405_SRC = "import { generateEvidence } from './evidence-kit.mjs';\nawait generateEvidence({\n slug: 'navbar-tiers-405',\n});\n"
70
+ SHA = "e34e4065b99983b2960015e8e27e2d09fc1bf2c6"
71
+
72
+
73
+ def plan_for(changed, files, *, head_subject="feat(navigation): tiers", subjects=(), default_slug="agent-issue-405"):
74
+ return vea.make_plan(changed=changed, head_sha=SHA, head_subject=head_subject,
75
+ branch_subjects=list(subjects), fs=FakeFS(files),
76
+ default_slug=default_slug, base_ref="origin/main", head_ref="agent/issue-405")
77
+
78
+
79
+ def test_detect_slug() -> None:
80
+ print("detect_slug")
81
+ check("reads `slug: '…'` from a kit-based generator", vea.detect_slug(GEN_405_SRC) == "navbar-tiers-405")
82
+ check("reads OUT = 'test/visual/evidence/<slug>' from a hand-rolled one",
83
+ vea.detect_slug("const OUT = 'test/visual/evidence/navbar-fit';") == "navbar-fit")
84
+ check("returns None when nothing names a folder", vea.detect_slug("console.log('hi')") is None)
85
+
86
+
87
+ def test_plan_pr_454_shape() -> None:
88
+ print("plan — PR #454: UI + spec + generator + README-only evidence")
89
+ changed = ["_sass/core/_navbar.scss", "_includes/core/header.html", "CHANGELOG.md",
90
+ "test/visual/features/navbar-tiers.spec.js", GEN_405,
91
+ "test/visual/evidence/navbar-tiers-405/README.md"]
92
+ files = {GEN_405: GEN_405_SRC, "test/visual/evidence/navbar-tiers-405/README.md": "# pending"}
93
+ plan = plan_for(changed, files)
94
+ check("needed", plan["needed"] is True)
95
+ check("exactly one job", len(plan["jobs"]) == 1)
96
+ job = plan["jobs"][0] if plan["jobs"] else {}
97
+ check("it is the bespoke generator for navbar-tiers-405",
98
+ job.get("kind") == "bespoke" and job.get("generator") == GEN_405 and job.get("slug") == "navbar-tiers-405")
99
+ check("its folder is the evidence dir", job.get("dir") == "test/visual/evidence/navbar-tiers-405")
100
+ check("the pixel tier is in scope (sass changed)", plan["snapshots_in_scope"] is True)
101
+ check("no skip reason", plan["skip_reason"] is None)
102
+ check("the spec is recorded", plan["specs"] == ["test/visual/features/navbar-tiers.spec.js"])
103
+
104
+
105
+ def test_plan_loop_guard_and_budget() -> None:
106
+ print("plan — loop guard + budget")
107
+ changed = ["_sass/core/_navbar.scss", "test/visual/evidence/x/01.png"]
108
+ files = {"test/visual/evidence/x/01.png": ""}
109
+ plan = plan_for(changed, files, head_subject=f"test(visual): auto-generate evidence {vea.MARKER}")
110
+ check("an autogen head commit stops the lane", plan["needed"] is False and "loop guard" in plan["skip_reason"])
111
+ subjects = [f"x {vea.MARKER}"] * vea.MAX_COMMITS + ["feat: something"]
112
+ plan = plan_for(changed, files, subjects=subjects)
113
+ check(f"{vea.MAX_COMMITS} prior autogen commits exhaust the budget",
114
+ plan["needed"] is False and "budget" in plan["skip_reason"])
115
+ plan = plan_for(changed, files, subjects=[f"x {vea.MARKER}"] * (vea.MAX_COMMITS - 1))
116
+ check("one under the budget still runs", plan["needed"] is True)
117
+
118
+
119
+ def test_plan_generic_fallback() -> None:
120
+ print("plan — UI change with no evidence at all → generic generator")
121
+ plan = plan_for(["_layouts/home.html"], {})
122
+ check("one generic job named after the branch", len(plan["jobs"]) == 1 and plan["jobs"][0]["kind"] == "generic"
123
+ and plan["jobs"][0]["slug"] == "agent-issue-405" and plan["jobs"][0]["generator"] is None)
124
+ plan = plan_for(["_layouts/home.html"], {}, default_slug=None)
125
+ check("…but only when a default slug is known", plan["jobs"] == [] and plan["needed"] is True)
126
+
127
+
128
+ def test_plan_respects_author_evidence() -> None:
129
+ print("plan — author-produced evidence is never regenerated")
130
+ folder = "test/visual/evidence/hover-flicker"
131
+ changed = ["_sass/core/_navbar.scss", f"{folder}/01-box-shift.png", f"{folder}/metrics.json"]
132
+ files = {f"{folder}/01-box-shift.png": "", f"{folder}/metrics.json": "{}"}
133
+ plan = plan_for(changed, files)
134
+ check("proof present + no manifest → no job", plan["jobs"] == [])
135
+ check("…the pixel tier still gets verified", plan["needed"] is True and plan["snapshots_in_scope"] is True)
136
+ files[f"{folder}/{vea.MANIFEST}"] = '{"rendered_from": "0000000deadbeef"}'
137
+ plan = plan_for(changed + [f"{folder}/{vea.MANIFEST}"], files)
138
+ check("autogen evidence from an OLDER head is refreshed", len(plan["jobs"]) == 1 and "rendered from" in plan["jobs"][0]["why"])
139
+ files[f"{folder}/{vea.MANIFEST}"] = f'{{"rendered_from": "{SHA}"}}'
140
+ plan = plan_for(changed + [f"{folder}/{vea.MANIFEST}"], files)
141
+ check("autogen evidence from THIS head is left alone", plan["jobs"] == [])
142
+
143
+
144
+ def test_plan_out_of_scope() -> None:
145
+ print("plan — nothing visual")
146
+ plan = plan_for(["docs/systems/x.md", "pages/_posts/2026-09-05-x.md", "_data/backlog.yml"], {})
147
+ check("docs/content/backlog → not needed", plan["needed"] is False and plan["jobs"] == []
148
+ and plan["snapshots_in_scope"] is False)
149
+ plan = plan_for(["_data/ui-text.yml"], {})
150
+ check("a chrome data file alone → pixel tier in scope, no evidence job",
151
+ plan["needed"] is True and plan["jobs"] == [] and plan["snapshots_in_scope"] is True)
152
+
153
+
154
+ def test_styling_matches_ci_filter() -> None:
155
+ print("contract — styling scope covers every chrome-affecting file lint-workflows.yml pins")
156
+ for f in ["_data/navigation/main.yml", "_data/ui-text.yml", "_data/i18n/en.yml", "_data/theme_skins.yml",
157
+ "_includes/components/theme-info.html", "_layouts/default.html", "_sass/main.scss",
158
+ "test/visual/features/x.spec.js", "test/playwright.config.js", "assets/js/x.js"]:
159
+ check(f"is_styling({f})", vea.is_styling(f))
160
+ check("a backlog edit is not styling", not vea.is_styling("_data/backlog.yml"))
161
+
162
+
163
+ def test_ui_prefixes_match_gate() -> None:
164
+ print("contract — UI paths agree with evidence-gate.yml")
165
+ gate = (REPO_ROOT / ".github/workflows/evidence-gate.yml").read_text(encoding="utf-8")
166
+ m = re.search(r"grep -E '\^\(([^)]+)\)'", gate)
167
+ gate_prefixes = tuple(m.group(1).split("|")) if m else ()
168
+ check("gate regex parsed", bool(gate_prefixes))
169
+ check("same prefix set", set(gate_prefixes) == set(vea.UI_PREFIXES))
170
+ check("gate requires generated proof (metrics.json)", "metrics.json" in gate)
171
+ check("gate points at the autogen", "visual_evidence_autogen.py" in gate)
172
+
173
+
174
+ def test_parse_results() -> None:
175
+ print("parse_playwright_results")
176
+ results = {"suites": [{"title": "appearance-snapshot.spec.js", "suites": [{"title": "Theme skins", "suites": [
177
+ {"title": "skin: sunrise", "specs": [{"title": "homepage visual snapshot", "tests": [{"results": [
178
+ {"status": "failed", "error": {"message": "\x1b[2mexpect(\x1b[22mpage\x1b[2m).\x1b[22mtoHaveScreenshot failed\n\n 1939 pixels (ratio 0.01 of all image pixels) are different."},
179
+ "attachments": [
180
+ {"name": "homepage-sunrise-expected", "path": "/work/test/visual-results/output/a/homepage-sunrise-expected.png"},
181
+ {"name": "homepage-sunrise-actual", "path": "/work/test/visual-results/output/a/homepage-sunrise-actual.png"},
182
+ {"name": "homepage-sunrise-diff", "path": "/work/test/visual-results/output/a/homepage-sunrise-diff.png"}]}]}]}]},
183
+ {"title": "skin: air", "specs": [{"title": "homepage visual snapshot", "tests": [{"results": [{"status": "passed"}]}]}]},
184
+ ]}]}]}
185
+ parsed = vea.parse_playwright_results(results)
186
+ check("2 tests, 1 passed", parsed["total"] == 2 and parsed["passed"] == 1)
187
+ check("one failure", len(parsed["failures"]) == 1)
188
+ f = parsed["failures"][0] if parsed["failures"] else {}
189
+ check("skin taken from the describe title", f.get("skin") == "sunrise")
190
+ check("pixel count parsed through the ANSI noise", f.get("diff_px") == 1939)
191
+ check("container paths made repo-relative",
192
+ f.get("diff") == "test/visual-results/output/a/homepage-sunrise-diff.png")
193
+
194
+
195
+ def test_decide() -> None:
196
+ print("decide — only `intentional` on a failing verify may bless")
197
+ fail = {"snapshots": {"verified": "fail"}}
198
+ ok = {"snapshots": {"verified": "pass"}}
199
+ v = lambda kind, summary="looks right": {"schema": "visual-evidence-verdict/v1", "snapshots": {"verdict": kind, "summary": summary}}
200
+ check("fail + intentional → bless", vea.decide(fail, v("intentional"))[0] is True)
201
+ check("fail + regression → no", vea.decide(fail, v("regression"))[0] is False)
202
+ check("fail + unclear → no", vea.decide(fail, v("unclear"))[0] is False)
203
+ check("fail + no verdict → no (missing)", vea.decide(fail, None) == (False, "missing", vea.decide(fail, None)[2]))
204
+ check("fail + wrong schema → no", vea.decide(fail, {"snapshots": {"verdict": "intentional"}})[0] is False)
205
+ check("fail + invented verdict → no", vea.decide(fail, v("ship-it"))[0] is False)
206
+ check("pass + intentional → nothing to bless", vea.decide(ok, v("intentional")) [1] == "not-applicable")
207
+ check("error + intentional → nothing to bless", vea.decide({"snapshots": {"verified": "error"}}, v("intentional"))[0] is False)
208
+
209
+
210
+ def test_stage_and_messages() -> None:
211
+ print("stage / commit message / comment")
212
+ state = {"plan": "", "jobs": [{"slug": "navbar-tiers-405", "kind": "bespoke", "generator": GEN_405,
213
+ "dir": "test/visual/evidence/navbar-tiers-405", "why": "w",
214
+ "files": ["test/visual/evidence/navbar-tiers-405/01-x.png"], "has_proof": True},
215
+ {"slug": "nope", "kind": "generic", "generator": None,
216
+ "dir": "test/visual/evidence/nope", "why": "w", "files": [], "has_proof": False}],
217
+ "snapshots": {"in_scope": True, "verified": "fail", "total": 9, "passed": 0,
218
+ "failures": [{"skin": "air", "diff_px": 1939}], "montage": None},
219
+ "generators_status": "1"}
220
+ check("stage: only folders with proof, no snapshots when not blessed",
221
+ vea.stage_paths(state) == ["test/visual/evidence/navbar-tiers-405"])
222
+ state["blessed"] = {"baselines": ["test/visual/snapshots/x.png"], "evidence_home": "test/visual/evidence/navbar-tiers-405"}
223
+ state["decision"] = {"bless": True, "verdict": "intentional", "reason": "subtitle moved to the hero"}
224
+ check("stage: snapshots included once blessed", vea.SNAPSHOT_DIR in vea.stage_paths(state))
225
+ msg = vea.render_commit_message(state, "https://example/run/1")
226
+ check("commit subject carries the loop-guard marker", msg.splitlines()[0].endswith(vea.MARKER))
227
+ check("commit body carries the trailer", vea.TRAILER in msg and "baselines=yes" in msg)
228
+ plan = {"head_sha": SHA, "head_ref": "agent/issue-405", "base_ref": "origin/main"}
229
+ body = vea.render_comment(plan=plan, state=state, verdict=None, repo="bamr87/zer0-mistakes",
230
+ sha="abc1234def", run_url="https://example/run/1", pushed=True)
231
+ check("comment carries the sticky marker", body.startswith(vea.COMMENT_MARKER))
232
+ check("comment names the blessed verdict and the reviewer duty", "Verdict: intentional" in body and "Files tab" in body)
233
+ check("comment lists the evidence that could not be generated", "could not be generated" in body)
234
+ quiet = {"plan": "", "jobs": [], "snapshots": {"in_scope": True, "verified": "pass", "failures": []}}
235
+ check("nothing noteworthy → empty comment",
236
+ vea.render_comment(plan=plan, state=quiet, verdict=None, repo="r", sha=None, run_url="", pushed=False) == "")
237
+
238
+
239
+ def test_stage_command_output() -> None:
240
+ print("stage — the command's stdout is what the workflow `mapfile`s")
241
+ import argparse
242
+ import contextlib
243
+ import io
244
+ import json
245
+ import tempfile
246
+ with tempfile.TemporaryDirectory() as tmp:
247
+ empty = Path(tmp) / "empty.json"
248
+ empty.write_text(json.dumps({"jobs": [], "snapshots": {"verified": "pass"}}), encoding="utf-8")
249
+ buf = io.StringIO()
250
+ with contextlib.redirect_stdout(buf):
251
+ vea.cmd_stage(argparse.Namespace(state=str(empty)))
252
+ check("nothing to add → prints nothing, not even a newline (run 33992712400 died on `git add -- \"\"`)",
253
+ buf.getvalue() == "")
254
+ some = Path(tmp) / "some.json"
255
+ some.write_text(json.dumps({"jobs": [{"dir": "test/visual/evidence/x", "has_proof": True}]}), encoding="utf-8")
256
+ buf = io.StringIO()
257
+ with contextlib.redirect_stdout(buf):
258
+ vea.cmd_stage(argparse.Namespace(state=str(some)))
259
+ check("one path per line when there is something to add", buf.getvalue() == "test/visual/evidence/x\n")
260
+
261
+
262
+ def test_lane_tooling_contract() -> None:
263
+ """The regression that killed the lane's first real run on a real PR.
264
+
265
+ visual-evidence-autogen.yml checks out the PR's branch, but the orchestrator
266
+ lives on main. On #454 — a branch cut before the lane shipped — the job died
267
+ in 20 seconds with "can't open file scripts/ci/visual_evidence_autogen.py",
268
+ on the very PR the lane was built to unstick. Every open PR on the day this
269
+ ships has the same shape, so the fix (restore the lane's tooling from the
270
+ base branch) is pinned here rather than left to review.
271
+ """
272
+ print("contract — the lane's tooling comes from the BASE branch")
273
+ wf = (REPO_ROOT / ".github/workflows/visual-evidence-autogen.yml").read_text(encoding="utf-8")
274
+
275
+ check("the orchestrator itself is restored (the #454 crash)",
276
+ "scripts/ci/visual_evidence_autogen.py" in vea.LANE_TOOLING)
277
+ check("update-snapshots.sh is restored (an older copy lacks the hooks and silently no-ops)",
278
+ "test/update-snapshots.sh" in vea.LANE_TOOLING)
279
+ check("both shared generators are restored",
280
+ "test/visual/pr-evidence.mjs" in vea.LANE_TOOLING
281
+ and "test/visual/snapshot-diff-montage.mjs" in vea.LANE_TOOLING)
282
+ check("the PR's OWN evidence spec is NOT overwritten",
283
+ not any(p.endswith("-evidence.mjs") and "pr-evidence" not in p for p in vea.LANE_TOOLING))
284
+ check("evidence-kit.mjs is left to the PR (a shared library it may extend)",
285
+ "test/visual/evidence-kit.mjs" not in vea.LANE_TOOLING)
286
+
287
+ # The workflow's env list and the module constant must not drift apart.
288
+ m = re.search(r"LANE_TOOLING: >-\n((?:[ ]{8}\S+\n)+)", wf)
289
+ check("the workflow declares LANE_TOOLING", bool(m))
290
+ if m:
291
+ declared = tuple(m.group(1).split())
292
+ check("workflow LANE_TOOLING == the module's constant (single source of truth)",
293
+ declared == vea.LANE_TOOLING)
294
+
295
+ check("it restores from the base ref, not the head",
296
+ 'git checkout "origin/${BASE_REF}" -- $LANE_TOOLING' in wf)
297
+ check("…and unstages at once, so restored files can never be committed",
298
+ 'git reset --quiet HEAD -- $LANE_TOOLING' in wf)
299
+ # Ordering: the restore must happen before anything invokes the orchestrator.
300
+ restore_at = wf.find('git checkout "origin/${BASE_REF}" -- $LANE_TOOLING')
301
+ first_use = wf.find("python3 scripts/ci/visual_evidence_autogen.py plan")
302
+ check("the restore runs BEFORE the first orchestrator call",
303
+ restore_at != -1 and first_use != -1 and restore_at < first_use)
304
+
305
+
306
+ def test_workflow_wiring() -> None:
307
+ print("contract — the workflow, the hooks, the hand-offs")
308
+ wf = REPO_ROOT / ".github/workflows/visual-evidence-autogen.yml"
309
+ check("workflow exists", wf.exists())
310
+ text = wf.read_text(encoding="utf-8") if wf.exists() else ""
311
+ for sub in ("plan", "generate", "decide", "bless", "stage", "commit-message", "comment", "teardown"):
312
+ check(f"workflow calls `{sub}`", f"visual_evidence_autogen.py {sub}" in text)
313
+ check("same-repo guard", "head.repo.full_name == github.repository" in text)
314
+ check("kill switch", "VISUAL_EVIDENCE_AUTOGEN_ENABLED" in text)
315
+ check("evidence-gate opt-out labels honoured", "skip-evidence" in text and "no-visual-change" in text)
316
+ check("the reviewer agent is the one invoked", "agent: visual-evidence-reviewer" in text)
317
+ check("never `git add -A`", not re.search(r"^\s*git add -A", text, re.M) and 'git add -- "${paths[@]}"' in text)
318
+ check("reviewer agent file exists", (REPO_ROOT / ".claude/agents/visual-evidence-reviewer.md").exists())
319
+ us = (REPO_ROOT / "test/update-snapshots.sh").read_text(encoding="utf-8")
320
+ for hook in ("PRE_TEST_SCRIPT", "POST_TEST_SCRIPT", "SKIP_PLAYWRIGHT"):
321
+ check(f"update-snapshots.sh exposes {hook}", hook in us)
322
+ for f in (vea.GENERIC_GENERATOR, vea.DIFF_MONTAGE_SCRIPT):
323
+ check(f"{f} exists", (REPO_ROOT / f).exists())
324
+ repair = (REPO_ROOT / ".github/workflows/ci-self-repair.yml").read_text(encoding="utf-8")
325
+ check("ci-self-repair hands a red Visual Snapshots job to this lane", "Visual Snapshots" in repair and "visual-evidence-autogen" in repair)
326
+
327
+
328
+ def main() -> int:
329
+ for t in (test_detect_slug, test_plan_pr_454_shape, test_plan_loop_guard_and_budget, test_plan_generic_fallback,
330
+ test_plan_respects_author_evidence, test_plan_out_of_scope, test_styling_matches_ci_filter,
331
+ test_ui_prefixes_match_gate, test_parse_results, test_decide, test_stage_and_messages,
332
+ test_stage_command_output, test_lane_tooling_contract, test_workflow_wiring):
333
+ t()
334
+ print(f"\n{PASSED} passed, {len(FAILURES)} failed")
335
+ for f in FAILURES:
336
+ print(f" FAILED: {f}")
337
+ return 1 if FAILURES else 0
338
+
339
+
340
+ if __name__ == "__main__":
341
+ sys.exit(main())