@zalom/plastic 1.0.0-beta.1 → 1.0.0-beta.11

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 (50) hide show
  1. package/PLASTIC.md +21 -5
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-executor.md +10 -0
  4. package/agents/plastic-intent-curator.md +7 -5
  5. package/agents/plastic-planner.md +11 -1
  6. package/agents/plastic-spec-specialist.md +9 -1
  7. package/hooks/statusline +150 -41
  8. package/package.json +1 -1
  9. package/scripts/agent-report +142 -0
  10. package/scripts/dashboard.rb +5 -3
  11. package/scripts/doctor.rb +243 -0
  12. package/scripts/lib/bridge.rb +72 -24
  13. package/scripts/lib/frontmatter_writer.rb +130 -0
  14. package/scripts/lib/graph_rebuild.rb +328 -0
  15. package/scripts/lib/installer_core.rb +5 -0
  16. package/scripts/lib/intent_validator.rb +79 -0
  17. package/scripts/lib/links_projection.rb +160 -0
  18. package/scripts/lib/links_section.rb +207 -0
  19. package/scripts/lib/power_tools.rb +76 -0
  20. package/scripts/lib/qmd_hook.rb +38 -25
  21. package/scripts/lib/qmd_sync.rb +21 -0
  22. package/scripts/new-intent +172 -22
  23. package/scripts/project-links +287 -0
  24. package/scripts/qmd-sync +50 -3
  25. package/scripts/rebuild-graph +244 -0
  26. package/scripts/spawn-preamble +18 -1
  27. package/skills/auto/SKILL.md +24 -9
  28. package/skills/auto/evals/evals.json +48 -0
  29. package/skills/auto/references/agent-architecture.md +27 -4
  30. package/skills/auto/references/agent-report-contract.md +86 -0
  31. package/skills/brainstorming/SKILL.md +1 -0
  32. package/skills/brainstorming/evals/evals.json +22 -0
  33. package/skills/continuing/SKILL.md +8 -1
  34. package/skills/continuing/evals/evals.json +9 -0
  35. package/skills/creating-intent/SKILL.md +28 -8
  36. package/skills/creating-intent/evals/evals.json +72 -0
  37. package/skills/creating-intent/references/lifecycle.md +12 -4
  38. package/skills/dashboard/SKILL.md +5 -0
  39. package/skills/dashboard/evals/evals.json +22 -0
  40. package/skills/executing-plan/SKILL.md +2 -2
  41. package/skills/intent-curator/SKILL.md +3 -1
  42. package/skills/intent-curator/evals/evals.json +22 -0
  43. package/skills/linking-intents/SKILL.md +17 -6
  44. package/skills/linking-intents/evals/evals.json +22 -0
  45. package/skills/linking-intents/references/zettelkasten.md +15 -3
  46. package/skills/managing-index/SKILL.md +6 -0
  47. package/skills/managing-index/evals/evals.json +22 -0
  48. package/skills/managing-index/references/zettelkasten-linking.md +7 -2
  49. package/skills/research/SKILL.md +8 -0
  50. package/skills/research/evals/evals.json +22 -0
package/scripts/doctor.rb CHANGED
@@ -17,6 +17,9 @@ require "digest"
17
17
 
18
18
  require_relative "lib/qmd_sync"
19
19
  require_relative "lib/intent_validator"
20
+ require_relative "lib/graph_rebuild"
21
+ require_relative "lib/links_projection"
22
+ require_relative "lib/links_section"
20
23
 
21
24
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
22
25
  # run it hermetically (no eval, no global-constant rewriting).
@@ -473,9 +476,249 @@ class Doctor
473
476
  )
474
477
  end
475
478
 
479
+ # graph_invariants — cross-intent I1/I3/I4 checks (intent 68). I1/I3/I4 are
480
+ # defined within a single store's id space (bare ids resolve within the same
481
+ # store), so build the `nodes` map per scope and run validate_graph per scope.
482
+ # I2 asymmetry (a relational chain entry with no reciprocal sources) is NEVER
483
+ # flagged: validate_graph does not compute it.
484
+ checks.concat(graph_invariant_checks(intent_dirs))
485
+
486
+ # cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
487
+ # `store:id` ref against the FULL store family via the relocation map
488
+ # (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
489
+ # Resolution always spans all stores even under `--store` scoping; only the
490
+ # REPORTED findings are filtered to refs originating in the scoped store(s).
491
+ checks << cross_store_resolution_check(scopes: scopes)
492
+
493
+ # graph_links_projection — the `## Links` section of every intent must EQUAL its
494
+ # canonical I5 frontmatter projection (intent 72), in BOTH set membership AND
495
+ # ordering (sources first, then chain). Recomputes the projection from each
496
+ # intent's sources/chain + the on-disk basenames using the SAME resolver the
497
+ # scripts/project-links tool uses, so the two can never diverge. Resolution
498
+ # spans all stores; only the REPORTED findings are filtered to the scoped store.
499
+ checks << links_projection_check(scopes: scopes)
500
+
501
+ checks
502
+ end
503
+
504
+ # Build the cross-store node maps (basename + label per store) + relocation map
505
+ # from ALL stores, then for every intent compute its canonical `## Links`
506
+ # projection and flag any whose ACTUAL `## Links` section differs (membership or
507
+ # ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
508
+ # run) filters only the REPORTED findings by origin scope.
509
+ def links_projection_check(scopes: nil)
510
+ all_dirs = all_intent_dirs
511
+
512
+ store_index = Hash.new { |h, k| h[k] = [] }
513
+ node_index = Hash.new { |h, k| h[k] = {} }
514
+ intents = [] # { scope:, id:, sources:, chain:, path: }
515
+
516
+ all_dirs.each do |d|
517
+ md = File.join(d[:path], "#{d[:name]}.md")
518
+ next unless File.exist?(md)
519
+
520
+ fm = parse_frontmatter(md)
521
+ next unless fm.is_a?(Hash) && fm["id"]
522
+
523
+ id = fm["id"].to_s
524
+ store_index[d[:scope]] << id
525
+ node_index[d[:scope]][id] = { basename: d[:name], label: fm["intent"].to_s.strip }
526
+ intents << {
527
+ scope: d[:scope], id: id, path: md,
528
+ sources: Array(fm["sources"]).map(&:to_s),
529
+ chain: Array(fm["chain"]).map(&:to_s),
530
+ }
531
+ end
532
+
533
+ relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
534
+
535
+ findings = []
536
+ intents.each do |node|
537
+ next if scopes && !scopes.include?(node[:scope])
538
+
539
+ resolve = ->(ref) do
540
+ LinksProjection.resolve_ref_projection(
541
+ ref, referer_store: node[:scope],
542
+ relocation_map: relocation_map, store_index: store_index, node_index: node_index
543
+ )
544
+ end
545
+
546
+ begin
547
+ expected = LinksProjection.section(sources: node[:sources], chain: node[:chain], resolve: resolve)
548
+ actual = actual_links_section(node[:path])
549
+ rescue LinksProjection::UnresolvedRef => e
550
+ findings << "#{node[:id]} ## Links projection failed: #{e.message}"
551
+ next
552
+ rescue LinksSection::AmbiguousLinks => e
553
+ findings << "#{node[:id]} ## Links ambiguous: #{e.message}"
554
+ next
555
+ end
556
+
557
+ next if actual == expected
558
+
559
+ findings << "#{node[:id]} ## Links does not match its frontmatter projection (membership/ordering drift)"
560
+ end
561
+
562
+ graph_finding_check(
563
+ "graph_links_projection", findings,
564
+ "Every intent's ## Links equals its frontmatter projection (membership and ordering)",
565
+ "Run scripts/project-links to regenerate the canonical ## Links sections"
566
+ )
567
+ end
568
+
569
+ # Extract a file's ACTUAL REAL `## Links` section text (FENCE-AWARE), normalized
570
+ # to the canonical block shape the projection emits. Delegates to the shared
571
+ # LinksSection.extract_section so the doctor check and the project-links tool
572
+ # agree on the section location and never match a `## Links` heading inside an
573
+ # example code fence. Returns "" when the section is absent (which differs from
574
+ # any real projection, so a missing section is a finding).
575
+ def actual_links_section(path)
576
+ LinksSection.extract_section(IntentValidator.body_of(File.read(path)))
577
+ end
578
+
579
+ # Build the relocation map + cross-store store_index from ALL stores, then for
580
+ # every intent's cross-store `sources`/`chain` ref resolve it and flag:
581
+ # - DEAD: the target resolves nowhere
582
+ # - RELOCATED-STALE: the ref points at an old location the relocation log has
583
+ # moved (the resolved location differs from the literal ref), e.g. the
584
+ # `global:24` id-reuse hazard that direct resolution would silently accept.
585
+ # `scopes` (nil = full run) filters only the REPORTED findings by origin scope.
586
+ def cross_store_resolution_check(scopes: nil)
587
+ all_dirs = all_intent_dirs
588
+
589
+ # Per-scope node maps + store_index over the WHOLE family.
590
+ nodes_by_scope = Hash.new { |h, k| h[k] = {} }
591
+ store_index = Hash.new { |h, k| h[k] = [] }
592
+ all_dirs.each do |d|
593
+ md = File.join(d[:path], "#{d[:name]}.md")
594
+ next unless File.exist?(md)
595
+
596
+ fm = parse_frontmatter(md)
597
+ next unless fm.is_a?(Hash) && fm["id"]
598
+
599
+ id = fm["id"].to_s
600
+ store_index[d[:scope]] << id
601
+ nodes_by_scope[d[:scope]][id] = {
602
+ sources: Array(fm["sources"]).map(&:to_s),
603
+ chain: Array(fm["chain"]).map(&:to_s),
604
+ }
605
+ end
606
+
607
+ relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
608
+
609
+ findings = []
610
+ nodes_by_scope.each do |scope, nodes|
611
+ next if scopes && !scopes.include?(scope)
612
+
613
+ nodes.each do |id, edges|
614
+ %i[sources chain].each do |field|
615
+ edges[field].each do |ref|
616
+ next unless ref.include?(":") # only cross-store refs are resolved here
617
+
618
+ res = GraphRebuild.resolve_ref(ref, referer_store: scope,
619
+ relocation_map: relocation_map,
620
+ store_index: store_index)
621
+ case res[:status]
622
+ when :dead
623
+ findings << "#{id}.#{field} cross-store ref #{ref} resolves to no intent (dead)"
624
+ when :same_store
625
+ findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now same-store #{res[:id]})"
626
+ when :cross_store
627
+ findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now #{res[:ref]})" if res[:ref] != ref
628
+ end
629
+ end
630
+ end
631
+ end
632
+ end
633
+
634
+ graph_finding_check(
635
+ "graph_cross_store_resolution", findings,
636
+ "Every cross-store sources/chain ref resolves to a live, current intent",
637
+ "Run scripts/rebuild-graph to repoint/collapse/drop stale cross-store refs"
638
+ )
639
+ end
640
+
641
+ # { store_key => INDEX.md text } for every store (global + all projects), for the
642
+ # relocation-map builder. Reads INDEX.md one level above each store dir.
643
+ def cross_store_index_texts
644
+ texts = {}
645
+ global_index = File.join(plastic_home, "INDEX.md")
646
+ texts["global"] = File.read(global_index) if File.exist?(global_index)
647
+
648
+ projects_root = File.join(plastic_home, "projects")
649
+ if File.directory?(projects_root)
650
+ Dir.children(projects_root).each do |project|
651
+ idx = File.join(projects_root, project, "INDEX.md")
652
+ texts["project:#{project}"] = File.read(idx) if File.exist?(idx)
653
+ end
654
+ end
655
+ texts
656
+ end
657
+
658
+ # Build a per-scope `nodes` map and surface IntentValidator.validate_graph
659
+ # findings as warn-level checks. Scope-aware (the caller already filtered
660
+ # `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
661
+ # when only a `project:` store is loaded, and vice versa.
662
+ def graph_invariant_checks(intent_dirs)
663
+ nodes_by_scope = Hash.new { |h, k| h[k] = {} }
664
+ intent_dirs.each do |d|
665
+ md_path = File.join(d[:path], "#{d[:name]}.md")
666
+ next unless File.exist?(md_path)
667
+
668
+ fm = parse_frontmatter(md_path)
669
+ next unless fm.is_a?(Hash) && fm["id"]
670
+
671
+ nodes_by_scope[d[:scope]][fm["id"].to_s] = {
672
+ sources: Array(fm["sources"]).map(&:to_s),
673
+ chain: Array(fm["chain"]).map(&:to_s),
674
+ }
675
+ end
676
+
677
+ i1 = []
678
+ i3 = []
679
+ i4 = []
680
+ nodes_by_scope.each_value do |nodes|
681
+ findings = IntentValidator.validate_graph(nodes)
682
+ i1.concat(findings[:i1])
683
+ i3.concat(findings[:i3])
684
+ i4.concat(findings[:i4])
685
+ end
686
+
687
+ checks = []
688
+ checks << graph_finding_check(
689
+ "graph_i1_reciprocity", i1,
690
+ "Every sources edge has its reciprocal chain entry (I1)",
691
+ "Run new-intent / the rebuild so each source intent's chain backlinks the child"
692
+ )
693
+ checks << graph_finding_check(
694
+ "graph_i3_disjoint", i3,
695
+ "No intent lists the same id in both sources and chain (I3)",
696
+ "Remove the overlapping id from either sources or chain"
697
+ )
698
+ checks << graph_finding_check(
699
+ "graph_i4_danglers", i4,
700
+ "Every sources/chain id resolves to a real intent (I4)",
701
+ "Fix or remove the dangling id reference"
702
+ )
476
703
  checks
477
704
  end
478
705
 
706
+ # One graph check: pass when `findings` is empty, otherwise warn (never fail, so
707
+ # an existing store does not turn red on a graph finding). I1/I4 are auto-fixable.
708
+ def graph_finding_check(name, findings, pass_message, fix_hint)
709
+ if findings.empty?
710
+ check(category: "conventions", name: name, status: "pass", message: pass_message)
711
+ else
712
+ check(
713
+ category: "conventions", name: name, status: "warn",
714
+ message: "#{findings.size} #{name} violation(s)",
715
+ details: findings,
716
+ fixable: name != "graph_i3_disjoint",
717
+ fix_hint: fix_hint
718
+ )
719
+ end
720
+ end
721
+
479
722
  # --- Check category 3: Agent registration ---
480
723
 
481
724
  def check_agent_registration(agent_key)
@@ -18,13 +18,15 @@ module Bridge
18
18
  # (<id>--<slug>.md) is never sentineled; it is born complete.
19
19
  PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
20
20
 
21
- # Stale-bridge purge window (intent 67). The bridge file is ephemeral
22
- # live-session gate state, NOT a continuation source: an intent is resumed from
23
- # its savepoint.md ledger, never from a /tmp bridge. So any bridge older than
24
- # this window is dead weight and safe to purge, regardless of arm state. No
25
- # real session stays live for two days, so a 48h cutoff never removes a bridge
26
- # an active run depends on.
27
- PURGE_AGE_SECONDS = 48 * 3600 # 48 hours
21
+ # Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
22
+ # weight ONLY once its intent is terminal (no longer in its store's INDEX.md
23
+ # `## Active` block); such bridges are purged. An Active intent's bridge is kept
24
+ # unconditionally, because while the intent is live the bridge is still load-
25
+ # bearing: it is the continuation signal (a parked or interrupted run resumes
26
+ # from it) and the anti-collision lock (it keys the per-session statusline so
27
+ # parallel sessions do not overwrite each other). An age window was the wrong
28
+ # axis: it left dead bridges resident for ~2 days AND could reap bridges of
29
+ # interrupted-but-still-active intents, which are exactly the ones to preserve.
28
30
 
29
31
  def self.intent_file(intent_dir)
30
32
  dir_name = File.basename(intent_dir)
@@ -58,11 +60,20 @@ module Bridge
58
60
  end
59
61
 
60
62
  # Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
61
- # then a derived key. Never returns nil/empty. Whitespace-only counts as empty.
63
+ # CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
64
+ # Whitespace-only counts as empty.
65
+ #
66
+ # The CLAUDE_CODE_SESSION_ID fallback (intent 79) is additive: it only changes
67
+ # behavior when CLAUDE_SESSION_ID is blank but CLAUDE_CODE_SESSION_ID is set —
68
+ # the bg/headless case where the real session id lives in CLAUDE_CODE_SESSION_ID.
69
+ # Keying by the real id (instead of a derived hash) lets the statusline, which
70
+ # receives that same id on stdin, find the bridge by direct filename lookup.
62
71
  def self.resolve_session(explicit, intent_id:, store:)
63
72
  return explicit.to_s.strip unless blank?(explicit)
64
73
  env = ENV["CLAUDE_SESSION_ID"]
65
74
  return env.to_s.strip unless blank?(env)
75
+ code_env = ENV["CLAUDE_CODE_SESSION_ID"]
76
+ return code_env.to_s.strip unless blank?(code_env)
66
77
  derive_key(store, intent_id)
67
78
  end
68
79
 
@@ -122,25 +133,62 @@ module Bridge
122
133
  pool.max_by { |c| c[:mtime] }&.fetch(:data)
123
134
  end
124
135
 
125
- # --- Stale-bridge purge (intent 67) ---------------------------------------
126
- #
127
- # Remove stale tmp/plastic-*.json bridge files so discover_bridge's per-fire
128
- # scan stays bounded. Best-effort and non-raising: returns the array of removed
129
- # paths. Continuation does not depend on these files (an intent resumes from its
130
- # savepoint.md ledger), so the only safety rule is age: a bridge older than
131
- # max_age_seconds is purged regardless of arm state, while anything newer is kept
132
- # (it may be a live run). The current session's own bridge is never purged
136
+ # --- Terminal-state bridge purge (intent 80) -------------------------------
137
+
138
+ # True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
139
+ # the PARENT of the store/ dir the bridge records, so we resolve it from the
140
+ # bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
141
+ # bad arg) returns false, which means "not active" so the caller treats the
142
+ # bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
143
+ # Array of id strings is supplied, membership is checked against it directly
144
+ # with no file read.
145
+ def self.intent_active?(intent_id, store:, index_active_ids: nil)
146
+ target = intent_id.to_s
147
+ return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
148
+
149
+ index = File.join(File.dirname(store.to_s), "INDEX.md")
150
+ return false unless File.exist?(index)
151
+
152
+ in_active = false
153
+ File.foreach(index) do |line|
154
+ stripped = line.chomp
155
+ if stripped == "## Active"
156
+ in_active = true
157
+ next
158
+ end
159
+ next unless in_active
160
+ break if stripped.start_with?("## ") # next section ends the Active block
161
+ m = stripped.match(/^- \[(\S+) +—/)
162
+ return true if m && m[1] == target
163
+ end
164
+ false
165
+ rescue StandardError
166
+ false
167
+ end
168
+
169
+ # Remove tmp/plastic-*.json bridge files whose intent is terminal, so
170
+ # discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
171
+ # returns the array of removed paths. A bridge is purged when it cannot be
172
+ # parsed, has no intent.id, has no intent.store, or its intent is not Active in
173
+ # its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
174
+ # anti-collision lock), and the current session's own bridge is never purged
133
175
  # (preserves the disarm_auto contract that it stays readable). Wired into
134
176
  # arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
135
- # clean.
136
- def self.purge_stale_bridges(session:, now: Time.now, max_age_seconds: PURGE_AGE_SECONDS,
137
- tmp: tmp_dir)
177
+ # clean at deterministic work boundaries.
178
+ def self.purge_done_bridges(session:, tmp: tmp_dir)
138
179
  current = path(session, tmp: tmp)
139
180
  removed = []
140
181
  Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
141
182
  next if f == current
142
183
  begin
143
- next if (now - File.mtime(f)) < max_age_seconds
184
+ data = JSON.parse(File.read(f)) rescue nil
185
+ keep = false
186
+ if data
187
+ id = data.dig("intent", "id")
188
+ store = data.dig("intent", "store")
189
+ keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
190
+ end
191
+ next if keep
144
192
  File.delete(f)
145
193
  removed << f
146
194
  rescue Errno::ENOENT
@@ -152,7 +200,7 @@ module Bridge
152
200
  end
153
201
  removed
154
202
  rescue => e
155
- $stderr.puts "plastic: purge_stale_bridges failed: #{e.message}"
203
+ $stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
156
204
  removed || []
157
205
  end
158
206
 
@@ -386,13 +434,13 @@ module Bridge
386
434
  # (mid-session intent creation). Re-derives intent state, then sets build.auto.
387
435
  def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
388
436
  key = resolve_session(session, intent_id: intent_id, store: store)
389
- if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"])
437
+ if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"]) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
390
438
  $stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
391
439
  end
392
440
  data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
393
441
  data["build"]["auto"] = true
394
442
  write(key, data)
395
- purge_stale_bridges(session: key)
443
+ purge_done_bridges(session: key)
396
444
  data
397
445
  end
398
446
 
@@ -403,7 +451,7 @@ module Bridge
403
451
  data["build"] ||= {}
404
452
  data["build"]["auto"] = false
405
453
  write(session, data)
406
- purge_stale_bridges(session: session)
454
+ purge_done_bridges(session: session)
407
455
  data
408
456
  end
409
457
 
@@ -0,0 +1,130 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # FrontmatterWriter — pure, minimal, style-preserving rewrite of the `sources:`
5
+ # and `chain:` arrays in an intent file's content string (intent 49).
6
+ #
7
+ # It rewrites ONLY those two arrays and leaves every other frontmatter line and
8
+ # the entire body byte-identical. It preserves each array's existing serialization
9
+ # style independently:
10
+ # - flow style: `sources: ["40", "1a"]` (or `[]`)
11
+ # - block style: a `sources:` line followed by ` - '1a'` item lines
12
+ # When the desired array equals the file's current value (same ids, same order),
13
+ # the content is returned UNCHANGED so a re-run produces no diff (idempotency).
14
+ #
15
+ # Pure: no file IO, no eval, no global/ENV state. The IO shell reads/writes files.
16
+ module FrontmatterWriter
17
+ module_function
18
+
19
+ # Rewrite `sources:`/`chain:` in `content`. `sources`/`chain` are the desired
20
+ # final arrays of id strings. Returns the new content (or the original when
21
+ # nothing changed). Only operates within the leading `---`...`---` frontmatter
22
+ # block; never touches the body.
23
+ def rewrite_arrays(content, sources:, chain:)
24
+ return content unless content.is_a?(String) && content.start_with?("---")
25
+
26
+ parts = content.split("---", 3)
27
+ return content if parts.length < 3
28
+
29
+ fm = parts[1]
30
+ body = parts[2]
31
+
32
+ fm = rewrite_one(fm, "sources", sources)
33
+ fm = rewrite_one(fm, "chain", chain)
34
+
35
+ "---#{fm}---#{body}"
36
+ end
37
+
38
+ # Rewrite a single `key:` array within the frontmatter text `fm`, preserving the
39
+ # key's existing flow-vs-block style. No-op when the key is absent or unchanged.
40
+ def rewrite_one(fm, key, desired)
41
+ lines = fm.lines
42
+ idx = lines.index { |l| l.match?(/\A#{Regexp.escape(key)}:\s/) || l.match?(/\A#{Regexp.escape(key)}:\s*\z/) }
43
+ return fm if idx.nil?
44
+
45
+ header = lines[idx]
46
+ if block_style?(lines, idx)
47
+ rewrite_block(lines, idx, key, desired)
48
+ else
49
+ rewrite_flow(lines, idx, header, key, desired)
50
+ end
51
+ end
52
+
53
+ # The key is block style when its own line carries no inline value and the next
54
+ # non-blank line is a `-` list item.
55
+ def block_style?(lines, idx)
56
+ header = lines[idx]
57
+ inline = header.sub(/\A[^:]+:/, "").strip
58
+ return false unless inline.empty?
59
+
60
+ nxt = lines[idx + 1]
61
+ !nxt.nil? && nxt.match?(/\A\s*-\s/)
62
+ end
63
+
64
+ # Flow style: replace the inline array on the header line, preserving indentation
65
+ # and any trailing newline. No-op when the current ids already match `desired`.
66
+ def rewrite_flow(lines, idx, header, key, desired)
67
+ current = parse_flow(header)
68
+ return lines.join if current == desired
69
+
70
+ newline = header.end_with?("\n") ? "\n" : ""
71
+ lines[idx] = "#{key}: #{render_flow(desired)}#{newline}"
72
+ lines.join
73
+ end
74
+
75
+ # Parse the inline flow array from a `key: [ ... ]` header line.
76
+ def parse_flow(header)
77
+ inline = header.sub(/\A[^:]+:/, "").strip
78
+ return [] if inline.empty? || inline == "[]"
79
+
80
+ inline = inline.sub(/\A\[/, "").sub(/\]\z/, "")
81
+ inline.split(",").map { |t| t.strip.gsub(/\A['"]|['"]\z/, "") }.reject(&:empty?)
82
+ end
83
+
84
+ def render_flow(ids)
85
+ return "[]" if ids.empty?
86
+
87
+ "[#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
88
+ end
89
+
90
+ # Block style: replace the contiguous `-` item lines following the header. No-op
91
+ # when the current ids already match `desired`. Preserves the item indentation
92
+ # and quoting style sampled from the existing first item.
93
+ def rewrite_block(lines, idx, key, desired)
94
+ last = idx
95
+ item_lines = []
96
+ (idx + 1).upto(lines.length - 1) do |i|
97
+ break unless lines[i].match?(/\A\s*-\s/)
98
+
99
+ item_lines << lines[i]
100
+ last = i
101
+ end
102
+
103
+ current = item_lines.map { |l| l.sub(/\A\s*-\s*/, "").strip.gsub(/\A['"]|['"]\z/, "") }
104
+ return lines.join if current == desired
105
+
106
+ indent, quote = block_item_shape(item_lines.first)
107
+ rendered = desired.map { |id| "#{indent}- #{quote}#{id}#{quote}\n" }
108
+
109
+ # When desired is empty, collapse the block to an inline `key: []` to keep YAML
110
+ # valid (a bare `key:` with no items parses as nil, not an empty array).
111
+ rendered = ["#{key}: []\n"] if desired.empty? && rendered.empty?
112
+
113
+ if desired.empty?
114
+ new_lines = lines[0...idx] + rendered + lines[(last + 1)..]
115
+ else
116
+ new_lines = lines[0...idx] + [lines[idx]] + rendered + lines[(last + 1)..]
117
+ end
118
+ new_lines.join
119
+ end
120
+
121
+ # Sample indentation and quote char from an existing block item line.
122
+ def block_item_shape(sample)
123
+ return ["", "'"] if sample.nil?
124
+
125
+ indent = sample[/\A\s*/].to_s
126
+ value = sample.sub(/\A\s*-\s*/, "").strip
127
+ quote = value.start_with?('"') ? '"' : "'"
128
+ [indent, quote]
129
+ end
130
+ end