jekyll-theme-zer0 1.19.1 → 1.21.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +404 -0
  3. data/README.md +27 -19
  4. data/_data/authors.yml +154 -5
  5. data/_data/backlog.yml +12 -5
  6. data/_data/content_statistics.yml +273 -297
  7. data/_data/features.yml +4 -25
  8. data/_data/navigation/README.md +24 -0
  9. data/_data/navigation/about.yml +2 -0
  10. data/_data/navigation/main.yml +2 -7
  11. data/_data/roadmap.yml +86 -12
  12. data/_data/roadmap_plan.yml +37 -0
  13. data/_data/routing.yml +73 -0
  14. data/_includes/components/author-avatar-url.html +28 -0
  15. data/_includes/components/author-bio.html +86 -0
  16. data/_includes/components/author-card.html +184 -121
  17. data/_includes/components/author-eeat.html +10 -4
  18. data/_includes/components/info-section.html +1 -1
  19. data/_includes/components/mermaid.html +0 -3
  20. data/_includes/components/post-card.html +19 -9
  21. data/_includes/content/giscus.html +3 -2
  22. data/_includes/core/footer-fabs.html +28 -0
  23. data/_includes/core/footer.html +7 -17
  24. data/_includes/core/head.html +2 -2
  25. data/_includes/navigation/breadcrumbs.html +20 -2
  26. data/_includes/navigation/local-graph.html +18 -2
  27. data/_includes/obsidian/full-graph.html +4 -6
  28. data/_layouts/article.html +44 -74
  29. data/_layouts/author.html +274 -0
  30. data/_layouts/authors.html +55 -0
  31. data/_layouts/news.html +3 -3
  32. data/_layouts/note.html +21 -6
  33. data/_layouts/notebook.html +21 -6
  34. data/_layouts/root.html +31 -17
  35. data/_layouts/section.html +3 -3
  36. data/_plugins/author_pages_generator.rb +121 -0
  37. data/_sass/components/_author.scss +219 -0
  38. data/_sass/components/_content-tables.scss +16 -1
  39. data/_sass/components/_notes-index.scss +102 -0
  40. data/_sass/components/_search-modal.scss +40 -0
  41. data/_sass/components/_ui-enhancements.scss +570 -0
  42. data/_sass/core/_docs-code-examples.scss +463 -0
  43. data/_sass/core/_docs-layout.scss +0 -453
  44. data/_sass/core/_navbar.scss +253 -0
  45. data/_sass/core/_sidebar-extras.scss +79 -0
  46. data/_sass/core/_toc.scss +87 -0
  47. data/_sass/core/_variables.scss +7 -142
  48. data/_sass/custom.scss +24 -1122
  49. data/_sass/layouts/_global-chrome.scss +59 -0
  50. data/assets/css/main.scss +19 -2
  51. data/assets/js/author-profile.js +190 -0
  52. data/assets/js/modules/navigation/navbar.js +104 -0
  53. data/assets/js/obsidian-graph.js +2 -2
  54. data/assets/js/obsidian-local-graph.js +11 -5
  55. data/assets/vendor/cytoscape/cytoscape.min.js +32 -0
  56. data/scripts/README.md +39 -0
  57. data/scripts/bin/validate +11 -1
  58. data/scripts/dev/css-diff.sh +49 -0
  59. data/scripts/dev/shot.js +37 -0
  60. data/scripts/features/generate-preview-images +110 -6
  61. data/scripts/features/pixelate-preview-images +126 -0
  62. data/scripts/features/pixelate_images.py +662 -0
  63. data/scripts/github-setup.sh +0 -0
  64. data/scripts/lib/preview_generator.py +47 -3
  65. data/scripts/pixelate-preview-images.sh +12 -0
  66. data/scripts/sync-backlog.rb +149 -26
  67. data/scripts/sync-plan.rb +164 -0
  68. data/scripts/sync-plan.sh +21 -0
  69. data/scripts/test/integration/auto-version +10 -8
  70. data/scripts/test/lib/run_tests.sh +2 -0
  71. data/scripts/test/lib/test_content_review.sh +205 -0
  72. data/scripts/test/lib/test_pixelate_images.sh +108 -0
  73. metadata +29 -20
  74. data/_data/hub.yml +0 -68
  75. data/_data/hub_index.yml +0 -203
  76. data/_data/navigation/hub.yml +0 -110
  77. data/assets/vendor/font-awesome/css/all.min.css +0 -9
  78. data/assets/vendor/font-awesome/webfonts/fa-brands-400.ttf +0 -0
  79. data/assets/vendor/font-awesome/webfonts/fa-brands-400.woff2 +0 -0
  80. data/assets/vendor/font-awesome/webfonts/fa-regular-400.ttf +0 -0
  81. data/assets/vendor/font-awesome/webfonts/fa-regular-400.woff2 +0 -0
  82. data/assets/vendor/font-awesome/webfonts/fa-solid-900.ttf +0 -0
  83. data/assets/vendor/font-awesome/webfonts/fa-solid-900.woff2 +0 -0
  84. data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.ttf +0 -0
  85. data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.woff2 +0 -0
  86. data/assets/vendor/jquery/jquery-3.7.1.min.js +0 -2
  87. data/scripts/lib/hub.rb +0 -208
  88. data/scripts/provision-org-sites.rb +0 -252
  89. data/scripts/provision-org-sites.sh +0 -23
  90. data/scripts/sync-hub-metadata.rb +0 -184
  91. data/scripts/sync-hub-metadata.sh +0 -22
@@ -466,10 +466,42 @@ class PreviewGenerator:
466
466
 
467
467
  # Rate limiter for API calls
468
468
  self.rate_limiter = RateLimiter(requests_per_minute=rate_limit)
469
-
469
+
470
+ # Author data (for per-author art-style overrides). Loaded once and read
471
+ # (never mutated) per file, so it is safe to share across worker threads.
472
+ self.authors = self._load_authors()
473
+
470
474
  # Ensure output directory exists
471
475
  if not dry_run:
472
476
  self.output_dir.mkdir(parents=True, exist_ok=True)
477
+
478
+ def _load_authors(self) -> Dict[str, Any]:
479
+ """Load _data/authors.yml so posts can override the art style per author."""
480
+ authors_file = self.project_root / '_data' / 'authors.yml'
481
+ try:
482
+ data = yaml.safe_load(authors_file.read_text(encoding='utf-8'))
483
+ return data if isinstance(data, dict) else {}
484
+ except Exception:
485
+ return {}
486
+
487
+ def author_preview_overrides(self, author_key: Optional[str]) -> Dict[str, Any]:
488
+ """Return the `preview:` override block for an author key (or {}).
489
+
490
+ A post whose `author:` references an entry in _data/authors.yml that
491
+ carries a `preview:` block gets that block's settings — they win over the
492
+ site-wide style for that post's banner (e.g. AI personas cassandra/vega).
493
+ """
494
+ # Jekyll allows `author:` as a string, a list (multi-author), or a mapping
495
+ # (jekyll-seo-tag E-E-A-T). Only a hashable string can index authors.yml;
496
+ # anything else simply has no per-author override (and must not crash the
497
+ # run — keeping this path as resilient as the rest of the file).
498
+ if not isinstance(author_key, str) or not author_key:
499
+ return {}
500
+ author = self.authors.get(author_key)
501
+ if not isinstance(author, dict):
502
+ return {}
503
+ preview = author.get('preview')
504
+ return preview if isinstance(preview, dict) else {}
473
505
 
474
506
  def debug(self, msg: str):
475
507
  """Print debug message if verbose mode is enabled."""
@@ -584,14 +616,26 @@ class PreviewGenerator:
584
616
  clean_content = re.sub(r'\n+', ' ', clean_content)
585
617
  prompt_parts.append(f"Key themes: {clean_content}")
586
618
 
619
+ # Per-author art-style override (e.g. AI personas) wins over the
620
+ # configured style for this post's banner. Computed per-call from the
621
+ # document's own front matter, so it is thread-safe under parallel workers.
622
+ effective_style = self.image_style
623
+ style_modifiers = ""
624
+ overrides = self.author_preview_overrides(content.front_matter.get('author'))
625
+ if overrides.get('style'):
626
+ effective_style = str(overrides['style']).strip()
627
+ self.debug(f"Author '{content.front_matter.get('author')}' style override applied")
628
+ if overrides.get('style_modifiers'):
629
+ style_modifiers = f" Additional style: {str(overrides['style_modifiers']).strip()}."
630
+
587
631
  # Add style instructions
588
632
  prompt_parts.extend([
589
- f"Style: {self.image_style}.",
633
+ f"Style: {effective_style}.{style_modifiers}",
590
634
  "The image should be suitable as a blog header/preview image.",
591
635
  "Clean composition, professional look, visually appealing.",
592
636
  "No text or letters in the image.",
593
637
  ])
594
-
638
+
595
639
  return ' '.join(prompt_parts)
596
640
 
597
641
  def generate_filename(self, title: str) -> str:
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+
3
+ # ============================================================================
4
+ # WRAPPER: This script forwards to scripts/features/pixelate-preview-images
5
+ #
6
+ # The canonical location is scripts/features/pixelate-preview-images. This
7
+ # wrapper exists for backward compatibility and discoverability alongside the
8
+ # other scripts/*.sh entry points.
9
+ # ============================================================================
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ exec "$SCRIPT_DIR/features/pixelate-preview-images" "$@"
@@ -89,6 +89,7 @@ def validate(data)
89
89
  return ['Missing or empty `tasks:` list.'] unless tasks.is_a?(Array) && !tasks.empty?
90
90
 
91
91
  seen_ids = {}
92
+ seen_links = {}
92
93
  tasks.each_with_index do |task, i|
93
94
  where = "tasks[#{i}]"
94
95
  unless task.is_a?(Hash)
@@ -114,6 +115,30 @@ def validate(data)
114
115
  unless task['acceptance'].is_a?(Array) && !task['acceptance'].empty?
115
116
  errors << "#{where}: `acceptance` must be a non-empty list."
116
117
  end
118
+
119
+ # Adoption guard: an existing GitHub issue may be claimed by at most one task,
120
+ # so two tasks can never fight over (or duplicate) the same issue.
121
+ lnk = task.dig('links', 'issue')
122
+ if lnk
123
+ unless lnk.is_a?(Integer) || lnk.to_s =~ /\A\d+\z/
124
+ errors << "#{where}: `links.issue` must be an issue number (got #{lnk.inspect})."
125
+ end
126
+ key = lnk.to_s
127
+ if seen_links[key]
128
+ errors << "#{where}: `links.issue` ##{key} already claimed by #{seen_links[key]}."
129
+ else
130
+ seen_links[key] = (id || where)
131
+ end
132
+ end
133
+ # Optional routing/dependency metadata (consumed by /issue-implement + /issue-plan).
134
+ if task.key?('route') && !task['route'].is_a?(String)
135
+ errors << "#{where}: `route` must be a string."
136
+ end
137
+ if task.key?('depends_on') &&
138
+ !(task['depends_on'].is_a?(Array) &&
139
+ task['depends_on'].all? { |d| d.is_a?(String) && d =~ /\AT-\d{3,}\z/ })
140
+ errors << "#{where}: `depends_on` must be a list of T-NNN ids."
141
+ end
117
142
  end
118
143
  errors
119
144
  end
@@ -133,6 +158,52 @@ def marker(id)
133
158
  "<!-- backlog-id: #{id} -->"
134
159
  end
135
160
 
161
+ # Delimiters around the sync-owned section of an ADOPTED human issue. Text ABOVE
162
+ # the start delimiter (the author's original report) is never touched; only this
163
+ # block is upserted on each sync, so adoption is non-destructive.
164
+ MANAGED_START = '<!-- backlog-managed:start -->'
165
+ MANAGED_END = '<!-- backlog-managed:end -->'
166
+
167
+ def render_managed_block(task)
168
+ accept = (task['acceptance'] || []).map { |a| "- [ ] #{a}" }.join("\n")
169
+ roadmap = task.dig('links', 'roadmap')
170
+ meta_row = [
171
+ "**Priority:** #{task['priority']}",
172
+ "**Area:** #{task['area']}",
173
+ "**Risk:** #{task['risk']}",
174
+ "**Effort:** #{task['effort']}",
175
+ "**Source:** #{task['source']}"
176
+ ].join(' · ')
177
+
178
+ <<~BLOCK.strip
179
+ #{MANAGED_START}
180
+ #{marker(task['id'])}
181
+ > Tracked from [`_data/backlog.yml`](../blob/main/_data/backlog.yml) by `scripts/sync-backlog.rb`.
182
+ > The report above is the author's; this block is auto-managed — edit the backlog, not here.
183
+
184
+ #{meta_row}#{roadmap ? " · **Roadmap:** v#{roadmap}" : ''}
185
+
186
+ ## Acceptance criteria
187
+
188
+ #{accept}
189
+
190
+ Picked up by the IMPLEMENT routine; see [`docs/systems/continuous-evolution.md`](../blob/main/docs/systems/continuous-evolution.md).
191
+ #{MANAGED_END}
192
+ BLOCK
193
+ end
194
+
195
+ # Upsert the managed block into a (human-authored) issue body, preserving the
196
+ # author's text. Idempotent: replaces an existing block, else appends one.
197
+ def upsert_managed_block(body, task)
198
+ body = body.to_s
199
+ block = render_managed_block(task)
200
+ if body.include?(MANAGED_START) && body.include?(MANAGED_END)
201
+ body.sub(/#{Regexp.escape(MANAGED_START)}.*#{Regexp.escape(MANAGED_END)}/m, block)
202
+ else
203
+ "#{body.rstrip}\n\n#{block}\n"
204
+ end
205
+ end
206
+
136
207
  def render_body(task)
137
208
  accept = (task['acceptance'] || []).map { |a| "- [ ] #{a}" }.join("\n")
138
209
  roadmap = task.dig('links', 'roadmap')
@@ -202,26 +273,52 @@ def ensure_labels(gh)
202
273
  VALID_RISK.each { |r| gh.write(['label', 'create', "risk:#{r}", '--color', RISK_COLOR, '--force']) }
203
274
  end
204
275
 
205
- # Map of backlog id -> existing issue {number, state, labels} via the body marker.
206
- def existing_issues(gh)
276
+ # Returns [index, link_state]:
277
+ # index — backlog id -> {number,state,labels,body,marker_id} for every
278
+ # marker-bearing agent-ready issue (the already-managed ones).
279
+ # link_state — issue number -> same record, for each issue referenced by a task
280
+ # `links.issue`. A human issue may carry neither the agent-ready
281
+ # label nor a marker yet, so it is fetched by number for adoption +
282
+ # provenance checks (is it already claimed by a foreign marker?).
283
+ def existing_issues(gh, link_numbers = [])
284
+ index = {}
285
+ link_state = {}
286
+ to_rec = lambda do |issue|
287
+ body = issue['body'].to_s
288
+ rec = {
289
+ 'number' => issue['number'],
290
+ 'state' => issue['state'].to_s.downcase,
291
+ 'labels' => (issue['labels'] || []).map { |l| l['name'] },
292
+ 'body' => body,
293
+ 'marker_id' => body[/<!-- backlog-id: (T-\d+) -->/, 1]
294
+ }
295
+ index[rec['marker_id']] = rec if rec['marker_id']
296
+ rec
297
+ end
298
+
207
299
  raw = gh.read(
208
300
  ['issue', 'list', '--label', 'agent-ready', '--state', 'all', '--limit', '500',
209
301
  '--json', 'number,body,state,labels'],
210
302
  default: '[]'
211
303
  )
212
- index = {}
213
- JSON.parse(raw).each do |issue|
214
- next unless issue['body'] =~ /<!-- backlog-id: (T-\d+) -->/
304
+ begin
305
+ JSON.parse(raw).each { |issue| to_rec.call(issue) }
306
+ rescue JSON::ParserError
307
+ # leave index empty; adoption/create still work off link_state
308
+ end
215
309
 
216
- index[Regexp.last_match(1)] = {
217
- 'number' => issue['number'],
218
- 'state' => issue['state'].to_s.downcase,
219
- 'labels' => (issue['labels'] || []).map { |l| l['name'] }
220
- }
310
+ link_numbers.compact.map(&:to_i).uniq.each do |n|
311
+ raw1 = gh.read(['issue', 'view', n.to_s, '--json', 'number,body,state,labels'], default: '')
312
+ next if raw1.strip.empty?
313
+
314
+ begin
315
+ link_state[n] = to_rec.call(JSON.parse(raw1))
316
+ rescue JSON::ParserError
317
+ next
318
+ end
221
319
  end
222
- index
223
- rescue JSON::ParserError
224
- {}
320
+
321
+ [index, link_state]
225
322
  end
226
323
 
227
324
  # ---------------------------------------------------------------------------
@@ -241,29 +338,55 @@ end
241
338
 
242
339
  def sync(data, gh)
243
340
  ensure_labels(gh)
244
- index = existing_issues(gh)
245
- created = updated = closed = 0
246
-
247
- (data['tasks'] || []).each do |task|
248
- id = task['id']
249
- title = task['title']
250
- body = render_body(task)
341
+ tasks = data['tasks'] || []
342
+ link_numbers = tasks.map { |t| t.dig('links', 'issue') }.compact
343
+ index, link_state = existing_issues(gh, link_numbers)
344
+ created = adopted = updated = closed = 0
345
+
346
+ tasks.each do |task|
347
+ id = task['id']
348
+ title = task['title']
251
349
  want_open = OPEN_STATUSES.include?(task['status'])
252
- issue = index[id]
350
+ lnk = task.dig('links', 'issue')
351
+ issue = index[id] # already marker-tracked?
352
+
353
+ # Adoption: task points at an existing issue not yet marker-tracked. Adopt it
354
+ # (inject the managed block) instead of creating a duplicate — unless the
355
+ # issue already carries a DIFFERENT marker (foreign provenance → refuse).
356
+ if issue.nil? && lnk
357
+ ls = link_state[lnk.to_i]
358
+ if ls.nil?
359
+ warn "task #{id}: links.issue ##{lnk} not found — creating a new issue instead."
360
+ elsif ls['marker_id'] && ls['marker_id'] != id
361
+ warn "task #{id}: refusing to adopt ##{lnk} — already marked #{ls['marker_id']} (foreign). Skipping."
362
+ next
363
+ else
364
+ issue = ls.merge('adopt' => true)
365
+ end
366
+ end
253
367
 
254
368
  if issue.nil?
255
369
  next unless want_open # never create an issue for an already-done task
256
370
 
257
- args = ['issue', 'create', '--title', title, '--body', body]
371
+ args = ['issue', 'create', '--title', title, '--body', render_body(task)]
258
372
  managed_labels(task).each { |l| args.push('--label', l) }
259
373
  created += 1 if gh.write(args)
260
374
  next
261
375
  end
262
376
 
263
377
  number = issue['number'].to_s
264
- gh.write(['issue', 'edit', number, '--title', title, '--body', body] +
265
- label_args(managed_labels(task), issue['labels']))
266
- updated += 1
378
+ if lnk
379
+ # Human-authored (adopted) issue: NEVER overwrite its title/body — only
380
+ # upsert the managed block (preserving the author's report) + labels.
381
+ gh.write(['issue', 'edit', number, '--body', upsert_managed_block(issue['body'], task)] +
382
+ label_args(managed_labels(task), issue['labels']))
383
+ issue['adopt'] ? (adopted += 1) : (updated += 1)
384
+ else
385
+ # Bot-created issue: fully sync-owned title + body.
386
+ gh.write(['issue', 'edit', number, '--title', title, '--body', render_body(task)] +
387
+ label_args(managed_labels(task), issue['labels']))
388
+ updated += 1
389
+ end
267
390
 
268
391
  if want_open && issue['state'] != 'open'
269
392
  gh.write(['issue', 'reopen', number])
@@ -273,7 +396,7 @@ def sync(data, gh)
273
396
  end
274
397
  end
275
398
 
276
- puts "Backlog sync complete: #{created} created, #{updated} updated, #{closed} closed."
399
+ puts "Backlog sync complete: #{created} created, #{adopted} adopted, #{updated} updated, #{closed} closed."
277
400
  0
278
401
  end
279
402
 
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # =============================================================================
5
+ # sync-plan.rb
6
+ # =============================================================================
7
+ #
8
+ # Validates (and optionally mirrors) `_data/roadmap_plan.yml` — the ORDER-ONLY
9
+ # plan artifact produced by the /issue-plan committee. The plan sequences open
10
+ # backlog tasks into batches; it must NEVER re-encode anything the backlog owns
11
+ # (risk / priority / area / status / effort), which would create a second source
12
+ # of truth that drifts.
13
+ #
14
+ # ruby scripts/sync-plan.rb --check # validate the plan vs the backlog (CI/PR gate; stdlib only)
15
+ # ruby scripts/sync-plan.rb --dry-run # print the intended pinned-issue upsert
16
+ # ruby scripts/sync-plan.rb # upsert one pinned tracking issue via `gh`
17
+ #
18
+ # --check asserts:
19
+ # * every batch task id exists in _data/backlog.yml and is OPEN (not done),
20
+ # * batch `depends_on` references existing batch ids and the batch DAG is acyclic,
21
+ # * no plan entry carries a backlog-owned field (order-only invariant).
22
+ # =============================================================================
23
+
24
+ require 'yaml'
25
+ require 'date'
26
+ require 'json'
27
+ require 'optparse'
28
+ require 'open3'
29
+
30
+ ROOT = File.expand_path('..', __dir__)
31
+ PLAN_FILE = File.join(ROOT, '_data', 'roadmap_plan.yml')
32
+ BACKLOG = File.join(ROOT, '_data', 'backlog.yml')
33
+
34
+ PIN_MARKER = '<!-- roadmap-plan:pinned -->'
35
+ BACKLOG_OWNED = %w[risk priority area status effort source acceptance title].freeze
36
+ OPEN_TASK_STATUS = %w[open in-progress blocked].freeze
37
+
38
+ def load_yaml(path)
39
+ YAML.load_file(path, permitted_classes: [Date, Time])
40
+ rescue ArgumentError
41
+ YAML.safe_load(File.read(path, encoding: 'UTF-8'), permitted_classes: [Date, Time], aliases: false)
42
+ end
43
+
44
+ def backlog_status
45
+ tasks = (load_yaml(BACKLOG)['tasks'] || [])
46
+ tasks.each_with_object({}) { |t, h| h[t['id']] = t['status'] }
47
+ end
48
+
49
+ def validate(plan, statuses)
50
+ errors = []
51
+ unless plan.is_a?(Hash) && plan['batches'].is_a?(Array)
52
+ return ['plan must have a `batches:` list.']
53
+ end
54
+
55
+ batch_ids = plan['batches'].map { |b| b['id'] }.compact
56
+ seen = {}
57
+ batch_ids.each { |bid| seen[bid] ? (errors << "duplicate batch id #{bid}.") : (seen[bid] = true) }
58
+
59
+ plan['batches'].each_with_index do |batch, i|
60
+ where = batch['id'] ? "batch #{batch['id']}" : "batches[#{i}]"
61
+ errors << "#{where}: missing `id`." if batch['id'].to_s.empty?
62
+
63
+ # Order-only invariant: a batch (or its task entries) may not carry a field
64
+ # the backlog owns. Tasks must be bare id strings.
65
+ (BACKLOG_OWNED & batch.keys).each { |k| errors << "#{where}: must not carry backlog-owned field `#{k}`." }
66
+ Array(batch['tasks']).each do |t|
67
+ unless t.is_a?(String) && t =~ /\AT-\d{3,}\z/
68
+ errors << "#{where}: task entries must be bare T-NNN ids (got #{t.inspect})."
69
+ next
70
+ end
71
+ if !statuses.key?(t)
72
+ errors << "#{where}: task #{t} is not in the backlog."
73
+ elsif !OPEN_TASK_STATUS.include?(statuses[t])
74
+ errors << "#{where}: task #{t} is #{statuses[t]} (only open tasks may be planned)."
75
+ end
76
+ end
77
+
78
+ Array(batch['depends_on']).each do |dep|
79
+ errors << "#{where}: depends_on references unknown batch #{dep}." unless seen[dep]
80
+ end
81
+ end
82
+
83
+ errors.concat(cycle_errors(plan['batches']))
84
+ errors
85
+ end
86
+
87
+ # Kahn's algorithm over the batch DAG; any leftover nodes => a cycle.
88
+ def cycle_errors(batches)
89
+ deps = {}
90
+ batches.each { |b| deps[b['id']] = Array(b['depends_on']).dup }
91
+ indeg = Hash.new(0)
92
+ deps.each { |_n, ds| ds.each { |d| indeg[d] += 1 if deps.key?(d) } }
93
+ # Edge dep -> node (dep must precede node); compute order by repeatedly removing
94
+ # nodes whose deps are all satisfied.
95
+ remaining = deps.keys
96
+ progress = true
97
+ while progress
98
+ progress = false
99
+ ready = remaining.select { |n| deps[n].all? { |d| !remaining.include?(d) } }
100
+ unless ready.empty?
101
+ remaining -= ready
102
+ progress = true
103
+ end
104
+ end
105
+ remaining.empty? ? [] : ["batch dependency cycle among: #{remaining.sort.join(', ')}."]
106
+ end
107
+
108
+ def pinned_body(plan)
109
+ lines = ["#{PIN_MARKER}", '> Auto-managed by `scripts/sync-plan.rb` from `_data/roadmap_plan.yml`.',
110
+ '> Order only — risk/priority/area come from `_data/backlog.yml`.', '']
111
+ plan['batches'].each do |b|
112
+ lines << "### #{b['id']} — #{b['goal']}"
113
+ lines << "Tasks: #{Array(b['tasks']).join(', ')}"
114
+ lines << "Depends on: #{Array(b['depends_on']).join(', ')}" unless Array(b['depends_on']).empty?
115
+ lines << "Test framework: #{b['test_framework']}" if b['test_framework']
116
+ lines << ''
117
+ end
118
+ lines.join("\n").strip
119
+ end
120
+
121
+ def upsert_pinned(plan, dry_run:)
122
+ title = '📋 Roadmap plan (auto-managed)'
123
+ body = pinned_body(plan)
124
+ found = `gh issue list --search #{PIN_MARKER.inspect}\\ in:body --state open --json number --jq '.[0].number' 2>/dev/null`.strip
125
+ args =
126
+ if found.empty?
127
+ ['issue', 'create', '--title', title, '--body', body, '--label', 'agent-hold']
128
+ else
129
+ ['issue', 'edit', found, '--title', title, '--body', body]
130
+ end
131
+ if dry_run
132
+ puts "DRY-RUN gh #{args.join(' ')}"
133
+ return
134
+ end
135
+ _o, err, st = Open3.capture3('gh', *args)
136
+ warn "gh failed: #{err}" unless st.success?
137
+ end
138
+
139
+ def main
140
+ mode = :sync
141
+ OptionParser.new do |o|
142
+ o.on('--check') { mode = :check }
143
+ o.on('--dry-run') { mode = :dry_run }
144
+ end.parse!
145
+
146
+ return 0 unless File.exist?(PLAN_FILE) # no plan yet => nothing to validate
147
+
148
+ plan = load_yaml(PLAN_FILE)
149
+ errors = validate(plan, backlog_status)
150
+ unless errors.empty?
151
+ warn '✗ _data/roadmap_plan.yml failed validation:'
152
+ errors.each { |e| warn " - #{e}" }
153
+ return 1
154
+ end
155
+
156
+ if mode == :check
157
+ puts "✓ _data/roadmap_plan.yml is valid (#{plan['batches'].size} batches)."
158
+ return 0
159
+ end
160
+ upsert_pinned(plan, dry_run: mode == :dry_run)
161
+ 0
162
+ end
163
+
164
+ exit main if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # sync-plan.sh
4
+ # =============================================================================
5
+ #
6
+ # Thin wrapper around scripts/sync-plan.rb.
7
+ #
8
+ # Validates (and optionally mirrors) `_data/roadmap_plan.yml` — the order-only
9
+ # plan artifact produced by the /issue-plan committee — against the backlog.
10
+ #
11
+ # Usage:
12
+ # ./scripts/sync-plan.sh # upsert the pinned tracking issue via gh
13
+ # ./scripts/sync-plan.sh --check # validate plan vs backlog (CI/PR gate)
14
+ # ./scripts/sync-plan.sh --dry-run # print the intended gh call only
15
+ #
16
+ # =============================================================================
17
+
18
+ set -euo pipefail
19
+
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ exec ruby "${SCRIPT_DIR}/sync-plan.rb" "$@"
@@ -21,12 +21,14 @@ log_warning() { warn "$@"; }
21
21
  log_error() { error "$@"; }
22
22
  log_test() { step "$@"; }
23
23
 
24
- # Paths under test (the workflow calls the wrapper; the implementation
25
- # lives in scripts/utils/; releases go through scripts/bin/release)
24
+ # Paths under test (commit analysis lives in scripts/utils/ with the
25
+ # analyze-commits.sh wrapper; releases go through scripts/bin/release). The CI
26
+ # release path is the release-please pipeline in .github/workflows/release.yml,
27
+ # which replaced the retired version-bump.yml.
26
28
  ANALYZE_WRAPPER="$SCRIPTS_ROOT/analyze-commits.sh"
27
29
  ANALYZE_IMPL="$SCRIPTS_ROOT/utils/analyze-commits"
28
30
  RELEASE_BIN="$SCRIPTS_ROOT/bin/release"
29
- WORKFLOW_FILE="$PROJECT_ROOT/.github/workflows/version-bump.yml"
31
+ WORKFLOW_FILE="$PROJECT_ROOT/.github/workflows/release.yml"
30
32
 
31
33
  # Test counter
32
34
  TESTS_RUN=0
@@ -114,14 +116,14 @@ test_release_command() {
114
116
  test_workflow_syntax() {
115
117
  log_info "Testing GitHub Actions workflow syntax..."
116
118
 
117
- run_test "version-bump workflow exists" "test -f '$WORKFLOW_FILE'"
119
+ run_test "release workflow exists" "test -f '$WORKFLOW_FILE'"
118
120
 
119
121
  # Check if yamllint is available
120
122
  if command -v yamllint >/dev/null 2>&1; then
121
- run_test "version-bump workflow syntax" "yamllint -c '$PROJECT_ROOT/.github/config/.yamllint.yml' '$WORKFLOW_FILE'"
123
+ run_test "release workflow syntax" "yamllint -c '$PROJECT_ROOT/.github/config/.yamllint.yml' '$WORKFLOW_FILE'"
122
124
  else
123
125
  # Basic YAML syntax check with Ruby (always present for this repo)
124
- run_test "version-bump workflow syntax" "ruby -ryaml -e 'YAML.safe_load_file(\"$WORKFLOW_FILE\", aliases: true)'"
126
+ run_test "release workflow syntax" "ruby -ryaml -e 'YAML.safe_load_file(\"$WORKFLOW_FILE\", aliases: true)'"
125
127
  fi
126
128
  }
127
129
 
@@ -129,7 +131,7 @@ test_workflow_syntax() {
129
131
  test_integration() {
130
132
  log_info "Testing integration between components..."
131
133
 
132
- # The workflow invokes ./scripts/analyze-commits.sh and validates its output
134
+ # Validate that the commit-analysis wrapper returns a usable bump type
133
135
  if git rev-list --count HEAD >/dev/null 2>&1; then
134
136
  local commit_count=$(git rev-list --count HEAD)
135
137
  if [[ $commit_count -gt 0 ]]; then
@@ -195,7 +197,7 @@ DESCRIPTION:
195
197
  - Commit analysis functionality (scripts/analyze-commits.sh wrapper
196
198
  and scripts/utils/analyze-commits implementation)
197
199
  - Release command compatibility (scripts/bin/release)
198
- - Workflow file syntax validation (.github/workflows/version-bump.yml)
200
+ - Workflow file syntax validation (.github/workflows/release.yml)
199
201
  - Integration between components
200
202
  - Conventional commit detection
201
203
 
@@ -129,6 +129,8 @@ main() {
129
129
  source "$TEST_DIR/test_analyze_commits.sh"
130
130
  source "$TEST_DIR/test_locale_independence.sh"
131
131
  source "$TEST_DIR/test_migrate.sh"
132
+ source "$TEST_DIR/test_pixelate_images.sh"
133
+ source "$TEST_DIR/test_content_review.sh"
132
134
 
133
135
  # Summary
134
136
  echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"