@zalom/plastic 1.9.0 → 1.10.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 (92) hide show
  1. package/PLASTIC.md +23 -8
  2. package/README.md +15 -4
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-discovery.md +7 -0
  5. package/bin/plastic.js +17 -8
  6. package/hooks/auto-arm +2 -2
  7. package/hooks/bash-gate +1 -1
  8. package/hooks/check-update +1 -1
  9. package/hooks/continue +2 -2
  10. package/hooks/edit-gates +1 -1
  11. package/hooks/future-intent-check +2 -2
  12. package/hooks/gate-check +3 -3
  13. package/hooks/power-tools +1 -1
  14. package/hooks/session-start +1 -1
  15. package/package.json +1 -1
  16. package/scripts/codex-hook +50 -106
  17. package/scripts/doctor.rb +132 -1080
  18. package/scripts/exec-worktree +103 -0
  19. package/scripts/hash-intent +1 -1
  20. package/scripts/hook-bash-gate +19 -0
  21. package/scripts/hook-code-gate +4 -1
  22. package/scripts/hook-continue +2 -2
  23. package/scripts/hook-create-gate +6 -3
  24. package/scripts/hook-gate-check +17 -0
  25. package/scripts/hook-links-gate +4 -1
  26. package/scripts/hook-lock-gate +7 -3
  27. package/scripts/hook-savepoint-pre +4 -1
  28. package/scripts/hook-session-start +21 -15
  29. package/scripts/lib/apply_patch_envelope.rb +46 -13
  30. package/scripts/lib/bridge.rb +83 -15
  31. package/scripts/lib/codex_edit_gates.rb +138 -0
  32. package/scripts/lib/doctor_core.rb +1087 -0
  33. package/scripts/lib/edit_gates.rb +61 -5
  34. package/scripts/lib/exec_worktree.rb +325 -0
  35. package/scripts/lib/harness_text.rb +57 -0
  36. package/scripts/lib/hook_registry.rb +32 -28
  37. package/scripts/lib/installer_core.rb +67 -7
  38. package/scripts/lib/lock.rb +196 -47
  39. package/scripts/lib/ruby_probe.rb +60 -0
  40. package/scripts/lib/scaffold_intent.rb +392 -0
  41. package/scripts/lib/spec_header.rb +83 -0
  42. package/scripts/lib/start_intent.rb +296 -0
  43. package/scripts/lib/verify_intent.rb +262 -0
  44. package/scripts/lib/worktree.rb +15 -1
  45. package/scripts/link-suggest +1 -1
  46. package/scripts/maintenance-run +5 -5
  47. package/scripts/migrate-to-global +2 -2
  48. package/scripts/restore-intent-v1 +1 -1
  49. package/scripts/scaffold-intent +120 -0
  50. package/scripts/start-intent +89 -0
  51. package/scripts/verify-intent +73 -0
  52. package/skills/agent-advisor/SKILL.md +5 -5
  53. package/skills/auto/SKILL.md +42 -32
  54. package/skills/auto/references/agent-architecture.md +1 -1
  55. package/skills/auto/references/agent-report-contract.md +1 -1
  56. package/skills/auto/references/human-report-contract.md +22 -3
  57. package/skills/auto/references/tiers.md +24 -2
  58. package/skills/conventions/references/completion-and-done.md +3 -0
  59. package/skills/conventions/references/gates-and-enforcement.md +28 -12
  60. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  61. package/skills/conventions/references/tiers-and-dispatch.md +7 -6
  62. package/skills/dashboard/SKILL.md +1 -1
  63. package/skills/doctor/SKILL.md +6 -5
  64. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  65. package/skills/doctor/report.md +1 -1
  66. package/skills/install/SKILL.md +1 -1
  67. package/skills/intent-brainstorming/SKILL.md +0 -2
  68. package/skills/intent-creating/SKILL.md +6 -6
  69. package/skills/intent-creating/references/lifecycle.md +1 -1
  70. package/skills/intent-discovering/SKILL.md +10 -3
  71. package/skills/intent-ending/SKILL.md +8 -7
  72. package/skills/intent-executing/SKILL.md +27 -19
  73. package/skills/intent-grilling/SKILL.md +5 -3
  74. package/skills/intent-planning/SKILL.md +7 -3
  75. package/skills/intent-researching/SKILL.md +0 -2
  76. package/skills/intent-starting/SKILL.md +10 -2
  77. package/skills/project-creating/SKILL.md +0 -2
  78. package/skills/project-creating/references/project-scaffolding.md +1 -1
  79. package/skills/releasing/SKILL.md +1 -1
  80. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  81. package/skills/releasing/references/release-lines.md +1 -1
  82. package/skills/skill-creating/SKILL.md +5 -2
  83. package/skills/store-indexing/SKILL.md +8 -5
  84. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  85. package/skills/tutorial/references/track-1-guided.md +2 -2
  86. package/skills/tutorial/references/track-2-auto.md +9 -6
  87. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  88. package/skills/uninstall/SKILL.md +6 -9
  89. package/templates/agents.md +12 -12
  90. package/templates/config.yml +6 -7
  91. package/templates/index.md +6 -3
  92. package/templates/spec.md +1 -1
@@ -0,0 +1,392 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "worktree"
6
+ require_relative "bridge"
7
+ require_relative "spec_header"
8
+ require_relative "intent_validator"
9
+
10
+ # ScaffoldIntent - all logic for `scripts/scaffold-intent` (intent 213). Three
11
+ # subcommands (spec|checklist|outcome), each writes one lifecycle artifact by copying or
12
+ # mechanically deriving it from an already-committed source: the intent file's
13
+ # `### Decisions` list, spec.md's `## Acceptance Criteria` list, or a `git diff --stat`
14
+ # plus an optional supplied test-summary file. No subcommand interprets or invents prose;
15
+ # a field this module cannot derive mechanically is left as the template's own stub text
16
+ # instead of being guessed at.
17
+ #
18
+ # `scaffold-intent` does not scaffold `actions/` in any form (intent 133a, D11): actions
19
+ # require judgment and stay entirely with the planner. No method here creates the
20
+ # directory, writes a `.gitkeep`, or writes a sentinel-only `ACTION_*.md`.
21
+ #
22
+ # Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
23
+ # `ENV` directly (only via the ambient `Dir.home` default, matching Bridge/Worktree
24
+ # convention). A git seam is injected as `runner:`, defaulting to
25
+ # `Worktree::ShellRunner.new`, so tests drive this in process with a fake runner and
26
+ # never touch real git. Every method returns a value; `scripts/scaffold-intent` maps the
27
+ # returned result to an exit code.
28
+ module ScaffoldIntent
29
+ module_function
30
+
31
+ SPEC_SECTIONS = [
32
+ "## Problem", "## Goals", "## Non-Goals", "## Approach",
33
+ "## Alternatives Considered", "## Decisions",
34
+ "## Acceptance Criteria", "## Open Questions",
35
+ ].freeze
36
+
37
+ SETTLED_PLACEHOLDER_COMMENT =
38
+ "<!-- Settled: yes (<reason>) optional, add this line only when the design is settled -->\n"
39
+
40
+ # --- path resolution (pure) --------------------------------------------------
41
+
42
+ def expand(path)
43
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
44
+ end
45
+
46
+ # Resolve the single "<store>/<id>--*" directory. Returns [dir, nil] on success, or
47
+ # [nil, message] on a usage failure (no match, or more than one match).
48
+ def resolve_intent_dir(store, id)
49
+ matches = Dir.glob(File.join(store, "#{id}--*")).select { |d| File.directory?(d) }
50
+ return [nil, "no intent directory matches #{id}--* under #{store}"] if matches.empty?
51
+ if matches.length > 1
52
+ return [nil, "ambiguous id #{id.inspect}: #{matches.length} matching directories under #{store}"]
53
+ end
54
+
55
+ [matches.first, nil]
56
+ end
57
+
58
+ # The templates dir, resolved the same way from this file's own directory
59
+ # (scripts/lib) as `scripts/scaffold-intent` resolves it from its own directory
60
+ # (scripts/): two levels up. A repo checkout has templates/ at the repo root; an
61
+ # installed copy has it at <plastic_home>/templates, which is also two levels up
62
+ # from <plastic_home>/scripts/lib. The explicit `<plastic_home>/templates` fallback
63
+ # covers the case where that computed path does not exist. Returns nil when neither
64
+ # exists.
65
+ def resolve_templates_dir(home: Dir.home)
66
+ primary = File.expand_path("../../templates", __dir__)
67
+ return primary if Dir.exist?(primary)
68
+
69
+ fallback = File.expand_path(File.join(home, ".plastic", "templates"))
70
+ return fallback if Dir.exist?(fallback)
71
+
72
+ nil
73
+ end
74
+
75
+ def templates_missing_message(home: Dir.home)
76
+ primary = File.expand_path("../../templates", __dir__)
77
+ fallback = File.expand_path(File.join(home, ".plastic", "templates"))
78
+ "no templates directory found; tried #{primary} and #{fallback}"
79
+ end
80
+
81
+ # --- result builders (pure) ---------------------------------------------------
82
+
83
+ def ok_result(path)
84
+ { status: :ok, code: 0, message: nil, path: path }
85
+ end
86
+
87
+ def refuse_result(path)
88
+ { status: :refused, code: 2, path: path,
89
+ message: "#{path} already has real content; pass --force to overwrite it deliberately" }
90
+ end
91
+
92
+ def error_result(message)
93
+ { status: :error, code: 3, message: message, path: nil }
94
+ end
95
+
96
+ # True iff `target` exists, carries real (non-sentinel) content, and `force` was not
97
+ # passed: the caller must refuse to write and leave the file untouched.
98
+ def refuse_without_force?(target, force)
99
+ File.exist?(target) && Bridge.stage_file_present?(target) && !force
100
+ end
101
+
102
+ # --- generic section helpers (pure) --------------------------------------------
103
+
104
+ # Split `text` into { "## Heading" => body_text } by top-level `## ` headings (never
105
+ # `### `). Each body runs from the line after its heading to the line before the next
106
+ # `## ` heading (or EOF), copied verbatim including any trailing blank line.
107
+ def sections_from(text)
108
+ sections = {}
109
+ current = nil
110
+ buf = []
111
+ text.each_line do |line|
112
+ if line.start_with?("## ")
113
+ sections[current] = buf.join if current
114
+ current = line.rstrip
115
+ buf = []
116
+ elsif current
117
+ buf << line
118
+ end
119
+ end
120
+ sections[current] = buf.join if current
121
+ sections
122
+ end
123
+
124
+ # Replace the body of `heading` in `text` with `new_body_lines` (an Array of String
125
+ # fragments), leaving every other line untouched. A no-op (returns `text` unchanged)
126
+ # when `heading` is not found.
127
+ def replace_section_body(text, heading, new_body_lines)
128
+ lines = text.lines
129
+ idx = lines.index { |l| l.rstrip == heading }
130
+ return text if idx.nil?
131
+
132
+ stop = idx + 1
133
+ stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
134
+ (lines[0..idx] + new_body_lines + lines[stop..]).join
135
+ end
136
+
137
+ # --- `### Decisions` extraction from the intent file (pure) --------------------
138
+
139
+ # Byte-for-byte body of the first `### Decisions` heading in `intent_file_content`.
140
+ # Returns [body, nil] on success, or [nil, message] when the heading is absent or its
141
+ # body has no non-blank line.
142
+ def extract_decisions(intent_file_content)
143
+ lines = intent_file_content.lines
144
+ idx = lines.index { |l| l.rstrip == "### Decisions" }
145
+ return [nil, "the intent file has no ### Decisions list to copy; the Why stage is not finished"] if idx.nil?
146
+
147
+ stop = idx + 1
148
+ stop += 1 while stop < lines.length && !(lines[stop].start_with?("## ") || lines[stop].start_with?("### "))
149
+ body_lines = lines[(idx + 1)...stop]
150
+ body_lines = strip_blank_edges(body_lines)
151
+
152
+ if body_lines.empty?
153
+ return [nil, "the intent file's ### Decisions list has no content to copy; the Why stage is not finished"]
154
+ end
155
+
156
+ [body_lines.join, nil]
157
+ end
158
+
159
+ # --- `## Acceptance Criteria` extraction from spec.md (pure) --------------------
160
+
161
+ # Byte-for-byte body of `## Acceptance Criteria` in `spec_content`. Returns
162
+ # [body, nil] on success, or [nil, message] when the heading is absent or its body
163
+ # holds no `- [ ]` line.
164
+ def extract_acceptance_criteria(spec_content)
165
+ lines = spec_content.lines
166
+ idx = lines.index { |l| l.rstrip == "## Acceptance Criteria" }
167
+ return [nil, "spec.md has no ## Acceptance Criteria section to copy"] if idx.nil?
168
+
169
+ stop = idx + 1
170
+ stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
171
+ body_lines = strip_blank_edges(lines[(idx + 1)...stop])
172
+
173
+ unless body_lines.any? { |l| l =~ /^\s*- \[ \]/ }
174
+ return [nil, "spec.md's ## Acceptance Criteria has no checklist items (no line matching '- [ ]')"]
175
+ end
176
+
177
+ [body_lines.join, nil]
178
+ end
179
+
180
+ def strip_blank_edges(lines)
181
+ lines = lines.drop_while { |l| l.strip.empty? }
182
+ lines.reverse.drop_while { |l| l.strip.empty? }.reverse
183
+ end
184
+
185
+ # --- spec subcommand ------------------------------------------------------------
186
+
187
+ def scaffold_spec(intent_dir:, force:, templates_dir: nil, home: Dir.home)
188
+ target = File.join(intent_dir, "spec.md")
189
+ return refuse_result(target) if refuse_without_force?(target, force)
190
+
191
+ intent_file = Bridge.intent_file(intent_dir)
192
+ return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
193
+
194
+ intent_content = File.read(intent_file)
195
+ fm = IntentValidator.parse_frontmatter_text(intent_content)
196
+ intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
197
+ if Bridge.blank?(intent_name)
198
+ return error_result("the intent file at #{intent_file} has no frontmatter intent name")
199
+ end
200
+
201
+ decisions_body, decisions_err = extract_decisions(intent_content)
202
+ return error_result(decisions_err) if decisions_body.nil?
203
+
204
+ tdir = templates_dir || resolve_templates_dir(home: home)
205
+ return error_result(templates_missing_message(home: home)) if tdir.nil?
206
+
207
+ spec_template_path = File.join(tdir, "spec.md")
208
+ return error_result("spec.md template not found at #{spec_template_path}") unless File.exist?(spec_template_path)
209
+
210
+ written = build_spec_content(intent_name: intent_name, decisions_body: decisions_body,
211
+ template_text: File.read(spec_template_path))
212
+
213
+ FileUtils.mkdir_p(intent_dir)
214
+ File.write(target, written)
215
+ ok_result(target)
216
+ end
217
+
218
+ def build_spec_content(intent_name:, decisions_body:, template_text:)
219
+ template_sections = sections_from(template_text)
220
+
221
+ out = []
222
+ out << SpecHeader.render(tier: nil, settled_reason: nil)
223
+ out << SETTLED_PLACEHOLDER_COMMENT
224
+ out << "\n"
225
+ out << "# Spec: #{intent_name}\n"
226
+ out << "\n"
227
+
228
+ SPEC_SECTIONS.each do |heading|
229
+ out << "#{heading}\n"
230
+ if heading == "## Decisions"
231
+ out << decisions_body
232
+ out << "\n"
233
+ else
234
+ out << (template_sections[heading] || "")
235
+ end
236
+ end
237
+
238
+ out.join
239
+ end
240
+
241
+ # --- checklist subcommand --------------------------------------------------------
242
+
243
+ def scaffold_checklist(intent_dir:, force:, templates_dir: nil, home: Dir.home)
244
+ target = File.join(intent_dir, "checklist.md")
245
+ return refuse_result(target) if refuse_without_force?(target, force)
246
+
247
+ spec_path = File.join(intent_dir, "spec.md")
248
+ unless File.exist?(spec_path) && Bridge.stage_file_present?(spec_path)
249
+ return error_result("spec.md is missing or still the scaffold placeholder at #{spec_path}")
250
+ end
251
+
252
+ ac_body, ac_err = extract_acceptance_criteria(File.read(spec_path))
253
+ return error_result(ac_err) if ac_body.nil?
254
+
255
+ intent_file = Bridge.intent_file(intent_dir)
256
+ fm = IntentValidator.parse_frontmatter(intent_file)
257
+ intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
258
+ if Bridge.blank?(intent_name)
259
+ return error_result("the intent file at #{intent_file} has no frontmatter intent name")
260
+ end
261
+
262
+ tdir = templates_dir || resolve_templates_dir(home: home)
263
+ return error_result(templates_missing_message(home: home)) if tdir.nil?
264
+
265
+ checklist_template_path = File.join(tdir, "checklist.md")
266
+ unless File.exist?(checklist_template_path)
267
+ return error_result("checklist.md template not found at #{checklist_template_path}")
268
+ end
269
+
270
+ template_text = File.read(checklist_template_path).sub("{{INTENT_NAME}}", intent_name)
271
+ written = replace_section_body(template_text, "## In Progress", [ac_body, "\n"])
272
+
273
+ FileUtils.mkdir_p(intent_dir)
274
+ File.write(target, written)
275
+ ok_result(target)
276
+ end
277
+
278
+ # --- repo / base-branch resolution (shared with ACTION_3) -----------------------
279
+
280
+ # The provisioned code worktree for this intent when it exists on disk, else the git
281
+ # toplevel of the current working directory. Returns nil when neither resolves.
282
+ def resolve_repo_dir(store:, id:, intent_dir:, home: Dir.home, runner: Worktree::ShellRunner.new)
283
+ wt_home = Worktree.home_from_store(store) || home
284
+ slug = Worktree.slug_for_store(store, home: wt_home)
285
+ intent_slug = File.basename(intent_dir).split("--", 2).last
286
+ paths = Worktree.paths(slug: slug, intent_id: id, intent_slug: intent_slug, home: wt_home)
287
+ code = paths["code"]
288
+ return code if code && Dir.exist?(code)
289
+
290
+ res = runner.run("-C", Dir.pwd, "rev-parse", "--show-toplevel")
291
+ return nil unless res.success?
292
+
293
+ top = res.stdout.to_s.strip
294
+ top.empty? ? nil : top
295
+ end
296
+
297
+ # Standard git base-branch detection, first success wins: origin/HEAD, then `main`,
298
+ # then `master`. Returns nil when none resolve.
299
+ def detect_base_branch(repo, runner: Worktree::ShellRunner.new)
300
+ res = runner.run("-C", repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
301
+ if res.success?
302
+ ref = res.stdout.to_s.strip
303
+ return ref.sub(%r{\Aorigin/}, "") unless ref.empty?
304
+ end
305
+
306
+ return "main" if runner.run("-C", repo, "rev-parse", "--verify", "--quiet", "main").success?
307
+ return "master" if runner.run("-C", repo, "rev-parse", "--verify", "--quiet", "master").success?
308
+
309
+ nil
310
+ end
311
+
312
+ # [stdout, nil] on success, [nil, stderr] on failure. Three-dot range so the diff is
313
+ # against the merge base, not the tip of the base branch.
314
+ def diffstat(repo, base, runner: Worktree::ShellRunner.new)
315
+ res = runner.run("-C", repo, "diff", "--stat", "#{base}...HEAD")
316
+ return [nil, res.stderr.to_s.strip] unless res.success?
317
+
318
+ [res.stdout.to_s, nil]
319
+ end
320
+
321
+ # --- outcome subcommand -----------------------------------------------------------
322
+
323
+ def scaffold_outcome(intent_dir:, force:, store:, id:, test_summary: nil,
324
+ home: Dir.home, runner: Worktree::ShellRunner.new, templates_dir: nil)
325
+ target = File.join(intent_dir, "outcome.md")
326
+ return refuse_result(target) if refuse_without_force?(target, force)
327
+
328
+ intent_file = Bridge.intent_file(intent_dir)
329
+ return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
330
+
331
+ fm = IntentValidator.parse_frontmatter(intent_file)
332
+ intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
333
+ if Bridge.blank?(intent_name)
334
+ return error_result("the intent file at #{intent_file} has no frontmatter intent name")
335
+ end
336
+
337
+ tdir = templates_dir || resolve_templates_dir(home: home)
338
+ return error_result(templates_missing_message(home: home)) if tdir.nil?
339
+
340
+ outcome_template_path = File.join(tdir, "outcome.md")
341
+ unless File.exist?(outcome_template_path)
342
+ return error_result("outcome.md template not found at #{outcome_template_path}")
343
+ end
344
+
345
+ verification_body = build_verification_body(store: store, id: id, intent_dir: intent_dir,
346
+ home: home, runner: runner, test_summary: test_summary)
347
+
348
+ content = File.read(outcome_template_path).sub("# Outcome: <intent name>", "# Outcome: #{intent_name}")
349
+ written = replace_section_body(content, "## Verification", [verification_body, "\n"])
350
+
351
+ FileUtils.mkdir_p(intent_dir)
352
+ File.write(target, written)
353
+ ok_result(target)
354
+ end
355
+
356
+ def build_verification_body(store:, id:, intent_dir:, home:, runner:, test_summary:)
357
+ repo = resolve_repo_dir(store: store, id: id, intent_dir: intent_dir, home: home, runner: runner)
358
+
359
+ out = []
360
+ if repo.nil?
361
+ out << "Diffstat unavailable: no repo could be resolved for this intent\n"
362
+ else
363
+ base = detect_base_branch(repo, runner: runner)
364
+ if base.nil?
365
+ out << "Diffstat unavailable: no base branch could be detected (no origin/HEAD, main, or master)\n"
366
+ else
367
+ stat, err = diffstat(repo, base, runner: runner)
368
+ if stat.nil?
369
+ out << "Diffstat unavailable: #{err}\n"
370
+ else
371
+ out << "Diffstat against #{base}:\n"
372
+ out << "```\n"
373
+ out << stat
374
+ out << "\n" unless stat.end_with?("\n")
375
+ out << "```\n"
376
+ end
377
+ end
378
+ end
379
+
380
+ unless Bridge.blank?(test_summary)
381
+ out << "\n"
382
+ out << "Test summary from #{test_summary}:\n"
383
+ out << "```\n"
384
+ content = File.read(test_summary)
385
+ out << content
386
+ out << "\n" unless content.end_with?("\n")
387
+ out << "```\n"
388
+ end
389
+
390
+ out.join
391
+ end
392
+ end
@@ -0,0 +1,83 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # SpecHeader - the single implementation that parses the `Tier:` and `Settled:` lines out
5
+ # of a spec.md's header block (intent 213). No script, skill, or hook re-implements this
6
+ # grammar anywhere else; Bridge.savepoint_tier delegates here instead of carrying its own
7
+ # copy of the Tier regex.
8
+ #
9
+ # Grammar (both lines live above the `# ` level-1 heading):
10
+ #
11
+ # Tier: L
12
+ # Settled: yes (design fixed by the 2026-07-18 Fable advisor verdict)
13
+ #
14
+ # `Tier:` is exactly one of S, M, L; anything else is unparseable and yields nil. An ABSENT
15
+ # `Settled:` line means not settled, there is no `Settled: no` variant. The parenthesised
16
+ # reason is REQUIRED for a line to count as settled: a bare `Settled: yes` with no reason
17
+ # does NOT parse as settled. D2 calls Settled a ONE-WAY DOOR (leniency accepted now can
18
+ # never be tightened later), so this stays strict from the start rather than being loosened
19
+ # once and then needing a breaking change to fix.
20
+ module SpecHeader
21
+ module_function
22
+
23
+ # Mirrors Bridge::PLACEHOLDER_SENTINEL (scripts/lib/bridge.rb:22). Not required in from
24
+ # bridge.rb to avoid a require cycle: bridge.rb requires spec_header.rb, so spec_header.rb
25
+ # must have zero require_relative dependencies of its own.
26
+ PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
27
+
28
+ HEADER_BLOCK_MAX_LINES = 10
29
+
30
+ TIER_RE = /\ATier:\s*(S|M|L)\z/
31
+ SETTLED_RE = /\ASettled:\s*yes\s*\((.*)\)\z/
32
+
33
+ # PURE. Parse a spec.md's raw text. Returns a Hash with symbol keys, always the same
34
+ # three keys: tier ("S"|"M"|"L"|nil), settled (true|false), settled_reason (String|nil).
35
+ def parse(text)
36
+ result = { tier: nil, settled: false, settled_reason: nil }
37
+ return result if text.nil?
38
+
39
+ lines = text.each_line.first(HEADER_BLOCK_MAX_LINES)
40
+ lines.each do |raw|
41
+ line = raw.chomp.strip
42
+ next if line.empty?
43
+ next if line == PLACEHOLDER_SENTINEL
44
+ break if line.start_with?("# ")
45
+
46
+ if (m = line.match(TIER_RE))
47
+ result[:tier] = m[1]
48
+ elsif (m = line.match(SETTLED_RE))
49
+ result[:settled] = true
50
+ result[:settled_reason] = m[1]
51
+ end
52
+ end
53
+
54
+ result
55
+ end
56
+
57
+ # Read at most the header block off disk and parse it. Returns the same Hash shape as
58
+ # `parse`, with every value nil/false when the path does not exist or cannot be read.
59
+ def parse_file(path)
60
+ lines = []
61
+ File.open(path) do |f|
62
+ HEADER_BLOCK_MAX_LINES.times do
63
+ line = f.gets
64
+ break if line.nil?
65
+ lines << line
66
+ end
67
+ end
68
+ parse(lines.join)
69
+ rescue StandardError
70
+ { tier: nil, settled: false, settled_reason: nil }
71
+ end
72
+
73
+ # Render the two header lines for a spec.md. `tier` is "S"|"M"|"L" or nil, `settled_reason`
74
+ # is a String or nil. Returns a String ending in one newline. A nil tier renders the
75
+ # placeholder `Tier: S|M|L`; a nil reason renders no Settled line at all (absent means not
76
+ # settled).
77
+ def render(tier: nil, settled_reason: nil)
78
+ lines = []
79
+ lines << "Tier: #{tier || 'S|M|L'}"
80
+ lines << "Settled: yes (#{settled_reason})" if settled_reason
81
+ "#{lines.join("\n")}\n"
82
+ end
83
+ end