@zalom/plastic 1.0.0-beta.7 → 1.0.0-beta.9

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.
@@ -0,0 +1,207 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_validator"
5
+
6
+ # LinksSection — pure, minimal, style-preserving rewrite of ONLY the `## Links`
7
+ # section of an intent file's content string (intent 72). Mirrors the discipline of
8
+ # FrontmatterWriter: pure (no file IO, no eval, no global/ENV state), and returns
9
+ # the original content UNCHANGED when nothing changed (idempotency).
10
+ #
11
+ # `## Links` is canonically the LAST sanctioned section
12
+ # (IntentValidator::SANCTIONED_SECTIONS = Intent, Context, Outcome, Insights,
13
+ # Links), and real intents put it last. So a file with an existing `## Links` has
14
+ # its section replaced in place (from the heading to the next top-level `## `
15
+ # heading or EOF), and a file WITHOUT a `## Links` gets one appended at end-of-body,
16
+ # separated by exactly one blank line, preserving the body's trailing-newline shape.
17
+ #
18
+ # FENCE AWARENESS (intent 72 corruption fix): a `## Links` heading INSIDE a fenced
19
+ # code block (``` or ~~~, possibly with an info string like ```markdown) is part of
20
+ # an EXAMPLE, not a real section. All section scanning here IGNORES headings inside
21
+ # fences and only ever targets the REAL `## Links` section (outside any fence). The
22
+ # section end is the next `## ` heading that is ALSO outside a fence, so a replace
23
+ # never consumes or unbalances a code fence. If more than one REAL `## Links`
24
+ # heading exists, #rewrite raises AmbiguousLinks rather than guess.
25
+ #
26
+ # Frontmatter is NEVER touched: the leading `---`...`---` block is preserved
27
+ # byte-for-byte and only the body is rebuilt.
28
+ module LinksSection
29
+ module_function
30
+
31
+ HEADING = "## Links"
32
+
33
+ # A fence delimiter: ``` or ~~~ (any length >= 3), optional leading whitespace,
34
+ # optional info string (e.g. ```markdown). Mirrors CommonMark fenced-code rules
35
+ # closely enough for intent bodies.
36
+ FENCE_RE = /\A\s*(`{3,}|~{3,})/
37
+
38
+ # Raised when a body has more than one REAL `## Links` heading outside any fence;
39
+ # the tool must fail loud rather than guess which one to rewrite.
40
+ class AmbiguousLinks < StandardError
41
+ def initialize(count)
42
+ super("found #{count} real `## Links` headings outside code fences; refusing to guess")
43
+ end
44
+ end
45
+
46
+ # PURE. Replace (or insert) the REAL `## Links` section in `content` with
47
+ # `section_text` (the canonical block from LinksProjection.section, which begins
48
+ # with the `## Links` heading line and ends with a single trailing newline).
49
+ # Returns the new content, or the original when nothing changed. Raises
50
+ # AmbiguousLinks when more than one real `## Links` heading exists.
51
+ def rewrite(content, section_text)
52
+ return content unless content.is_a?(String)
53
+
54
+ fm, body = split_frontmatter(content)
55
+ new_body = rewrite_body(body, section_text)
56
+ updated = "#{fm}#{new_body}"
57
+ updated == content ? content : updated
58
+ end
59
+
60
+ # Split content into [frontmatter_with_delimiters, body]. When there is no
61
+ # frontmatter block, the frontmatter part is "" and the whole content is the
62
+ # body. The frontmatter part is preserved byte-for-byte by the caller.
63
+ def split_frontmatter(content)
64
+ return ["", content] unless content.start_with?("---")
65
+
66
+ parts = content.split("---", 3)
67
+ return ["", content] if parts.length < 3
68
+
69
+ ["---#{parts[1]}---", parts[2]]
70
+ end
71
+
72
+ # Rewrite ONLY the REAL `## Links` section within the body text.
73
+ def rewrite_body(body, section_text)
74
+ bounds = links_bounds(body)
75
+ if bounds
76
+ replace_section(body, section_text, bounds)
77
+ else
78
+ insert_section(body, section_text)
79
+ end
80
+ end
81
+
82
+ # PURE. Locate the REAL `## Links` section (fence-aware). Returns
83
+ # [start_index, end_index] line indices into body.lines, where start_index is the
84
+ # `## Links` heading line and end_index is the index of the next out-of-fence
85
+ # `## ` heading (or lines.length at EOF). Returns nil when there is no real
86
+ # `## Links` heading. Raises AmbiguousLinks when more than one exists.
87
+ def links_bounds(body)
88
+ lines = body.to_s.lines
89
+ starts = real_links_heading_indices(lines)
90
+ return nil if starts.empty?
91
+ raise AmbiguousLinks, starts.length if starts.length > 1
92
+
93
+ start = starts.first
94
+ stop = next_out_of_fence_heading(lines, start + 1)
95
+ [start, stop]
96
+ end
97
+
98
+ # PURE. Indices of every `## Links` heading line that is OUTSIDE any code fence.
99
+ # Accepts a body String or an Array of lines.
100
+ def real_links_heading_indices(body_or_lines)
101
+ lines = body_or_lines.is_a?(Array) ? body_or_lines : body_or_lines.to_s.lines
102
+ indices = []
103
+ in_fence = false
104
+ fence_marker = nil
105
+ lines.each_with_index do |line, i|
106
+ if (m = fence_open_close(line, in_fence, fence_marker))
107
+ in_fence = m[:in_fence]
108
+ fence_marker = m[:marker]
109
+ next
110
+ end
111
+ indices << i if !in_fence && line.rstrip == HEADING
112
+ end
113
+ indices
114
+ end
115
+
116
+ # PURE. Index of the first `## ` heading at or after `from` that is OUTSIDE any
117
+ # code fence. Returns lines.length when none (EOF). Fence state is recomputed
118
+ # from the top so nested example fences after the real heading are respected.
119
+ def next_out_of_fence_heading(lines, from)
120
+ in_fence = false
121
+ fence_marker = nil
122
+ lines.each_with_index do |line, i|
123
+ if (m = fence_open_close(line, in_fence, fence_marker))
124
+ in_fence = m[:in_fence]
125
+ fence_marker = m[:marker]
126
+ next
127
+ end
128
+ return i if i >= from && !in_fence && line.start_with?("## ")
129
+ end
130
+ lines.length
131
+ end
132
+
133
+ # PURE. Given the current fence state, decide whether `line` is a fence delimiter
134
+ # and return the new state, or nil when the line is not a fence delimiter.
135
+ # An opening fence records its marker family (` or ~); a closing fence must use a
136
+ # marker of the SAME family and carry no info string.
137
+ def fence_open_close(line, in_fence, fence_marker)
138
+ m = line.match(FENCE_RE)
139
+ return nil unless m
140
+
141
+ marker = m[1]
142
+ family = marker[0] # "`" or "~"
143
+ if in_fence
144
+ # A closing fence uses the same family, length >= the opener, no info string.
145
+ rest = line.sub(FENCE_RE, "").strip
146
+ if family == fence_marker && rest.empty?
147
+ { in_fence: false, marker: nil }
148
+ else
149
+ # A delimiter of the OTHER family (or an info-string line) inside a fence is
150
+ # literal content, not a fence event.
151
+ nil
152
+ end
153
+ else
154
+ { in_fence: true, marker: family }
155
+ end
156
+ end
157
+
158
+ # True iff the body has a REAL (out-of-fence) `## Links` heading. Used by callers
159
+ # to classify regenerate-vs-add without re-deriving fence state.
160
+ def links_heading?(body)
161
+ !real_links_heading_indices(body.to_s.lines).empty?
162
+ end
163
+
164
+ # Replace the REAL `## Links` section (the [start, stop] line bounds) with
165
+ # `section_text`, preserving everything before the heading and after the section
166
+ # byte-for-byte (including any fenced example that lives BEFORE the real section).
167
+ def replace_section(body, section_text, bounds)
168
+ lines = body.lines
169
+ start, stop = bounds
170
+ before = lines[0...start].join
171
+ tail = (lines[stop..] || [])
172
+
173
+ # `section_text` already ends with exactly one newline. When there is trailing
174
+ # content (another section follows), separate the block from it with one blank
175
+ # line; otherwise the section ends the body.
176
+ block = tail.empty? ? section_text : "#{section_text}\n"
177
+ "#{before}#{block}#{tail.join}"
178
+ end
179
+
180
+ # Append a `## Links` section at end-of-body, after the last existing section,
181
+ # separated by exactly one blank line, preserving the body's trailing newline.
182
+ def insert_section(body, section_text)
183
+ trimmed = body.to_s.sub(/\s+\z/, "")
184
+ if trimmed.empty?
185
+ # An empty body (no sections) just becomes the section.
186
+ section_text
187
+ else
188
+ "#{trimmed}\n\n#{section_text}"
189
+ end
190
+ end
191
+
192
+ # PURE. Extract the REAL `## Links` section text (fence-aware), normalized to the
193
+ # canonical block shape the projection emits: heading line + entry lines + a
194
+ # single trailing newline. Returns "" when there is no real section. Shared by
195
+ # the IO shell's audit and the doctor drift check so all three agree on the
196
+ # location. Raises AmbiguousLinks when more than one real heading exists.
197
+ def extract_section(body)
198
+ bounds = links_bounds(body)
199
+ return "" if bounds.nil?
200
+
201
+ start, stop = bounds
202
+ lines = body.to_s.lines
203
+ section = lines[(start + 1)...stop].join.sub(/\n+\z/, "\n")
204
+ section = "" if section.strip.empty?
205
+ "#{HEADING}\n#{section}"
206
+ end
207
+ end
@@ -26,6 +26,9 @@ require "fileutils"
26
26
  require "date"
27
27
  require_relative "lib/bridge"
28
28
  require_relative "lib/intent_validator"
29
+ require_relative "lib/graph_rebuild"
30
+ require_relative "lib/links_projection"
31
+ require_relative "lib/links_section"
29
32
 
30
33
  # --- Explicit flag parsing (no eval, no global injection) ------------------
31
34
 
@@ -106,25 +109,119 @@ def add_to_chain(file_path, new_id)
106
109
  File.write(file_path, ["", new_fm, parts[2]].join("---"))
107
110
  end
108
111
 
109
- # Append a wikilink line under the file's `## Links` section, idempotently.
110
- def append_link(file_path, link_line)
111
- return unless File.exist?(file_path)
112
- content = File.read(file_path)
113
- return if content.include?(link_line)
112
+ # Derive [plastic_home, referer_store_key] from a store directory path so the
113
+ # cross-store resolver (shared with project-links and the doctor check) can be
114
+ # built. A global store is `<home>/store` (key "global"); a project store is
115
+ # `<home>/projects/<slug>/store` (key "project:<slug>"). Returns
116
+ # [nil, "global"] only if the layout is unrecognized, in which case Links
117
+ # projection falls back to single-store resolution rooted at this store.
118
+ def store_context(store)
119
+ store = File.expand_path(store)
120
+ parent = File.dirname(store) # `<home>` or `<home>/projects/<slug>`
121
+ if File.basename(store) == "store" && File.basename(File.dirname(parent)) == "projects"
122
+ slug = File.basename(parent)
123
+ home = File.dirname(File.dirname(parent)) # strip projects/<slug>
124
+ [home, "project:#{slug}"]
125
+ elsif File.basename(store) == "store"
126
+ [parent, "global"] # `<home>/store`
127
+ else
128
+ [parent, "global"]
129
+ end
130
+ end
131
+
132
+ # The in-scope stores under `plastic_home`, each { key:, store: }. Mirrors
133
+ # project-links/RebuildGraph#stores so cross-store resolution spans the family.
134
+ def family_stores(plastic_home)
135
+ list = []
136
+ global_store = File.join(plastic_home, "store")
137
+ list << { key: "global", store: global_store } if File.directory?(global_store)
138
+ %w[plastic knowdb].each do |slug|
139
+ store = File.join(plastic_home, "projects", slug, "store")
140
+ list << { key: "project:#{slug}", store: store } if File.directory?(store)
141
+ end
142
+ list
143
+ end
144
+
145
+ # Build the cross-store maps the LinksProjection resolver needs:
146
+ # store_index => { store_key => [bare ids] }
147
+ # node_index => { store_key => { id => { basename:, label: } } }
148
+ # relocation_map => from GraphRebuild.build_relocation_map over every INDEX.md
149
+ # `fallback_store` is the store the new intent lives in; it is always included so
150
+ # resolution works even for a brand-new store with no INDEX.md yet.
151
+ def build_cross_store_maps(plastic_home, fallback_store_key, fallback_store_dir)
152
+ stores = family_stores(plastic_home)
153
+ # Ensure the fallback store is represented even if family discovery missed it.
154
+ unless stores.any? { |s| s[:key] == fallback_store_key }
155
+ stores << { key: fallback_store_key, store: fallback_store_dir }
156
+ end
157
+
158
+ store_index = Hash.new { |h, k| h[k] = [] }
159
+ node_index = Hash.new { |h, k| h[k] = {} }
160
+ index_texts = {}
161
+
162
+ stores.each do |s|
163
+ next unless File.directory?(s[:store])
164
+
165
+ Dir.children(s[:store]).reject { |e| e.start_with?(".") }.sort.each do |entry|
166
+ dir = File.join(s[:store], entry)
167
+ next unless File.directory?(dir)
168
+
169
+ md = File.join(dir, "#{entry}.md")
170
+ next unless File.exist?(md)
114
171
 
115
- lines = content.lines
116
- idx = lines.index { |l| l.strip == "## Links" }
117
- return unless idx
172
+ fm = IntentValidator.parse_frontmatter(md)
173
+ next unless fm.is_a?(Hash) && fm["id"]
118
174
 
119
- insert_at = lines.length
120
- ((idx + 1)...lines.length).each do |j|
121
- if lines[j].strip.start_with?("## ")
122
- insert_at = j
123
- break
175
+ id = fm["id"].to_s
176
+ store_index[s[:key]] << id
177
+ node_index[s[:key]][id] = { basename: entry, label: fm["intent"].to_s.strip }
124
178
  end
179
+
180
+ idx = File.join(File.dirname(s[:store]), "INDEX.md")
181
+ index_texts[s[:key]] = File.read(idx) if File.exist?(idx)
182
+ end
183
+
184
+ relocation_map = GraphRebuild.build_relocation_map(index_texts)
185
+ { store_index: store_index, node_index: node_index, relocation_map: relocation_map }
186
+ end
187
+
188
+ # Re-project ONE intent file's `## Links` as the canonical I5 projection of its
189
+ # OWN frontmatter sources+chain, using the shared cross-store resolver. Writes the
190
+ # file only if the section changed (idempotent). Born-canonical: a fresh root with
191
+ # no edges gets the empty-state comment; the fence-aware rewriter touches only the
192
+ # real `## Links` section. Returns true on success, false when the intent could
193
+ # not be read or a ref was unresolvable (left unwritten, never a guessed link).
194
+ def project_links_for(file_path, referer_store_key, maps)
195
+ return false unless File.exist?(file_path)
196
+
197
+ content = File.read(file_path)
198
+ fm = IntentValidator.parse_frontmatter_text(content)
199
+ return false unless fm.is_a?(Hash)
200
+
201
+ resolve = lambda do |ref|
202
+ LinksProjection.resolve_ref_projection(
203
+ ref,
204
+ referer_store: referer_store_key,
205
+ relocation_map: maps[:relocation_map],
206
+ store_index: maps[:store_index],
207
+ node_index: maps[:node_index]
208
+ )
209
+ end
210
+
211
+ begin
212
+ section_text = LinksProjection.section(
213
+ sources: Array(fm["sources"]).map(&:to_s),
214
+ chain: Array(fm["chain"]).map(&:to_s),
215
+ resolve: resolve
216
+ )
217
+ updated = LinksSection.rewrite(content, section_text)
218
+ rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
219
+ warn "new-intent: could not project ## Links for #{File.basename(file_path)}: #{e.message}"
220
+ return false
125
221
  end
126
- lines.insert(insert_at, "#{link_line}\n")
127
- File.write(file_path, lines.join)
222
+
223
+ File.write(file_path, updated) if updated != content
224
+ true
128
225
  end
129
226
 
130
227
  def main(argv)
@@ -176,8 +273,9 @@ def main(argv)
176
273
  # 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
177
274
  # `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
178
275
  # `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
179
- # folds in `--parent`). Scope boundary: 68 fixes ONLY the frontmatter `chain`
180
- # backlink; the `## Links` wikilink projection for the `--sources` path is intent 72.
276
+ # folds in `--parent`). Collect the touched source files so their `## Links` can be
277
+ # re-projected once the chain edges are on disk.
278
+ source_files = []
181
279
  sources.each do |src_id|
182
280
  next if src_id.nil? || src_id.empty?
183
281
 
@@ -186,19 +284,22 @@ def main(argv)
186
284
 
187
285
  src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
188
286
  add_to_chain(src_file, id)
287
+ source_files << src_file
189
288
  end
190
289
 
191
- # 4b. Reciprocal `## Links` wikilinks: forward link to parent + back-reference in
192
- # the parent (kept parent-only; the `--sources` path's wikilink is intent 72's scope).
193
- if opts[:parent] && !opts[:parent].empty?
194
- parent_id = opts[:parent]
195
- append_link(intent_file, "- [[#{parent_id}]]")
196
- parent_dir = Dir.glob(File.join(store, "#{parent_id}--*")).find { |d| File.directory?(d) }
197
- if parent_dir
198
- parent_file = File.join(parent_dir, "#{File.basename(parent_dir)}.md")
199
- append_link(parent_file, "- [[#{id}]]")
200
- end
201
- end
290
+ # 4b. Canonical `## Links` projection (intent 72): born-canonical, drift PREVENTED
291
+ # at the source. Build the cross-store resolver maps AFTER the chain backlinks are
292
+ # written, then project the NEW intent's Links from its own frontmatter (sources at
293
+ # birth, chain empty) and RE-project each source/parent's Links (it just gained the
294
+ # new id in its chain). The fence-aware rewriter and the resolved-target dedup are
295
+ # the same ones project-links and the doctor check use, so a freshly created intent
296
+ # and its sources both PASS graph_links_projection. A no-source root gets the
297
+ # canonical empty-state comment.
298
+ plastic_home, referer_store_key = store_context(store)
299
+ maps = build_cross_store_maps(plastic_home, referer_store_key, store)
300
+
301
+ project_links_for(intent_file, referer_store_key, maps)
302
+ source_files.uniq.each { |sf| project_links_for(sf, referer_store_key, maps) }
202
303
 
203
304
  # 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
204
305
  # line; the rendered template body follows so the file is a usable starting
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # project-links — project each intent's corrected sources/chain graph into its
6
+ # canonical I5 `## Links` section, store-wide across the global, plastic, and
7
+ # knowdb stores (intent 72). Deterministic and idempotent: a second run over a
8
+ # projected store changes zero files. Touches ONLY the `## Links` section of each
9
+ # intent file; frontmatter and every other section stay byte-identical.
10
+ #
11
+ # Usage:
12
+ # project-links [--plastic-home PATH] [--dry-run] [--audit-path PATH]
13
+ #
14
+ # Pure-Ruby (no bash). The pure logic lives in lib/links_projection.rb and
15
+ # lib/links_section.rb; this shell does only discovery, IO, and reporting. It reads
16
+ # frontmatter (it NEVER writes frontmatter, that is intent 49's domain) and the
17
+ # on-disk `id--slug` directory basenames to build the cross-store resolver.
18
+ # Never pushes ~/.plastic (no git ops here).
19
+
20
+ require "time"
21
+ require "fileutils"
22
+
23
+ require_relative "lib/intent_validator"
24
+ require_relative "lib/graph_rebuild"
25
+ require_relative "lib/links_projection"
26
+ require_relative "lib/links_section"
27
+
28
+ class ProjectLinks
29
+ DEFAULT_HOME = File.join(Dir.home, ".plastic")
30
+
31
+ # The 72 intent dir audit destination (relative to plastic_home).
32
+ DEFAULT_AUDIT_REL =
33
+ "projects/plastic/store/72--links-graph-projection/resources/audit--links-projection.md"
34
+
35
+ def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
36
+ @plastic_home = plastic_home
37
+ @dry_run = dry_run
38
+
39
+ # A dry run must NOT stomp the canonical audit (humans run --dry-run to review
40
+ # the plan). With no explicit --audit-path, a dry run writes a `.dry-run.md`
41
+ # sibling, leaving the canonical real-run audit untouched. An explicit
42
+ # --audit-path is always honored verbatim (tests inject it). Mirrors
43
+ # RebuildGraph's discipline exactly.
44
+ canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
45
+ @audit_path =
46
+ if audit_path
47
+ audit_path
48
+ elsif dry_run
49
+ canonical.sub(/\.md\z/, ".dry-run.md")
50
+ else
51
+ canonical
52
+ end
53
+ end
54
+
55
+ attr_reader :plastic_home, :dry_run, :audit_path
56
+
57
+ # The three in-scope stores, each as { key:, root:, store:, index: }.
58
+ def stores
59
+ list = []
60
+ global_store = File.join(plastic_home, "store")
61
+ if File.directory?(global_store)
62
+ list << { key: "global", root: plastic_home, store: global_store,
63
+ index: File.join(plastic_home, "INDEX.md") }
64
+ end
65
+
66
+ %w[plastic knowdb].each do |slug|
67
+ root = File.join(plastic_home, "projects", slug)
68
+ store = File.join(root, "store")
69
+ next unless File.directory?(store)
70
+
71
+ list << { key: "project:#{slug}", root: root, store: store,
72
+ index: File.join(root, "INDEX.md") }
73
+ end
74
+ list
75
+ end
76
+
77
+ # { id => { sources:, chain:, basename:, label:, path: } } for one store.
78
+ def load_nodes(store_dir)
79
+ nodes = {}
80
+ Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
81
+ dir = File.join(store_dir, entry)
82
+ next unless File.directory?(dir)
83
+
84
+ md = File.join(dir, "#{entry}.md")
85
+ next unless File.exist?(md)
86
+
87
+ fm = IntentValidator.parse_frontmatter(md)
88
+ next unless fm.is_a?(Hash) && fm["id"]
89
+
90
+ nodes[fm["id"].to_s] = {
91
+ sources: Array(fm["sources"]).map(&:to_s),
92
+ chain: Array(fm["chain"]).map(&:to_s),
93
+ basename: entry, # the on-disk `id--slug` directory name
94
+ label: fm["intent"].to_s.strip,
95
+ path: md,
96
+ }
97
+ end
98
+ nodes
99
+ end
100
+
101
+ def run
102
+ store_list = stores
103
+ nodes_by_store = {}
104
+ index_texts = {}
105
+ store_index = {}
106
+ node_index = {}
107
+
108
+ store_list.each do |s|
109
+ key = s[:key]
110
+ nodes_by_store[key] = load_nodes(s[:store])
111
+ index_texts[key] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
112
+ store_index[key] = nodes_by_store[key].keys
113
+ node_index[key] = nodes_by_store[key].transform_values do |v|
114
+ { basename: v[:basename], label: v[:label] }
115
+ end
116
+ end
117
+
118
+ relocation_map = GraphRebuild.build_relocation_map(index_texts)
119
+
120
+ results = {}
121
+ store_list.each do |s|
122
+ key = s[:key]
123
+ results[key] = project_store(
124
+ key, nodes_by_store[key],
125
+ relocation_map: relocation_map, store_index: store_index, node_index: node_index
126
+ )
127
+ end
128
+
129
+ emit_audit(store_list, results)
130
+ results
131
+ end
132
+
133
+ # Project every intent in ONE store. Returns
134
+ # { entries: [ {id:, status:, before:, after:, error:} ], counts: {...} }.
135
+ def project_store(referer_store, nodes, relocation_map:, store_index:, node_index:)
136
+ entries = []
137
+ nodes.each do |id, node|
138
+ resolve = ->(ref) do
139
+ LinksProjection.resolve_ref_projection(
140
+ ref, referer_store: referer_store,
141
+ relocation_map: relocation_map, store_index: store_index, node_index: node_index
142
+ )
143
+ end
144
+
145
+ content = File.read(node[:path])
146
+
147
+ begin
148
+ had_links = LinksSection.links_heading?(IntentValidator.body_of(content))
149
+ section_text = LinksProjection.section(
150
+ sources: node[:sources], chain: node[:chain], resolve: resolve
151
+ )
152
+ updated = LinksSection.rewrite(content, section_text)
153
+ rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
154
+ entries << { id: id, status: :failed, error: e.message }
155
+ next
156
+ end
157
+
158
+ if updated == content
159
+ entries << { id: id, status: :unchanged }
160
+ next
161
+ end
162
+
163
+ status = had_links ? :regenerated : :added
164
+ File.write(node[:path], updated) unless dry_run
165
+ entries << { id: id, status: status,
166
+ before: extract_links(content), after: section_text }
167
+ end
168
+
169
+ counts = entries.each_with_object(Hash.new(0)) { |e, h| h[e[:status]] += 1 }
170
+ { entries: entries, counts: counts }
171
+ end
172
+
173
+ # Pull the current REAL `## Links` section text (fence-aware) from a file's
174
+ # content, for the audit sample. Returns "" when absent. Delegates to the shared
175
+ # LinksSection.extract_section so the audit, the rewriter, and the doctor check
176
+ # all agree on the section location (and never match a heading inside an example
177
+ # code fence).
178
+ def extract_links(content)
179
+ LinksSection.extract_section(IntentValidator.body_of(content))
180
+ rescue LinksSection::AmbiguousLinks
181
+ "(ambiguous: multiple ## Links headings)"
182
+ end
183
+
184
+ # Always write the audit (even in dry-run, so the human reviews the plan).
185
+ def emit_audit(store_list, results)
186
+ text = render_audit(store_list, results)
187
+ FileUtils.mkdir_p(File.dirname(audit_path))
188
+ File.write(audit_path, text)
189
+ text
190
+ end
191
+
192
+ STATUS_ORDER = %i[regenerated added unchanged failed].freeze
193
+ STATUS_LABELS = {
194
+ regenerated: "Regenerated (had a ## Links, content changed)",
195
+ added: "Added (no ## Links section, one inserted)",
196
+ unchanged: "Unchanged (already canonical)",
197
+ failed: "FAILED (resolver miss, NOT written)",
198
+ }.freeze
199
+
200
+ def render_audit(store_list, results)
201
+ lines = []
202
+ lines << "# Audit: store-wide ## Links projection (intent 72)"
203
+ lines << ""
204
+ lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
205
+ lines << ""
206
+
207
+ total = STATUS_ORDER.to_h do |st|
208
+ [st, store_list.sum { |s| results[s[:key]][:counts][st] }]
209
+ end
210
+ lines << "Totals across all stores: " \
211
+ "regenerated #{total[:regenerated]}, added #{total[:added]}, " \
212
+ "unchanged #{total[:unchanged]}, failed #{total[:failed]}."
213
+ lines << ""
214
+
215
+ store_list.each do |s|
216
+ key = s[:key]
217
+ res = results[key]
218
+ counts = res[:counts]
219
+ lines << "## #{key}"
220
+ lines << ""
221
+ lines << "Regenerated #{counts[:regenerated]}, added #{counts[:added]}, " \
222
+ "unchanged #{counts[:unchanged]}, failed #{counts[:failed]}."
223
+ lines << ""
224
+
225
+ failed = res[:entries].select { |e| e[:status] == :failed }
226
+ unless failed.empty?
227
+ lines << "### FAILED (#{failed.size})"
228
+ failed.each { |e| lines << "- #{e[:id]}: #{e[:error]}" }
229
+ lines << ""
230
+ end
231
+
232
+ sample = res[:entries].select { |e| %i[regenerated added].include?(e[:status]) }.first(5)
233
+ next if sample.empty?
234
+
235
+ lines << "### Sample before/after (first #{sample.size})"
236
+ sample.each do |e|
237
+ lines << "- #{e[:id]} (#{e[:status]}):"
238
+ lines << " - BEFORE:"
239
+ block_lines(e[:before]).each { |l| lines << " #{l}" }
240
+ lines << " - AFTER:"
241
+ block_lines(e[:after]).each { |l| lines << " #{l}" }
242
+ end
243
+ lines << ""
244
+ end
245
+
246
+ lines.join("\n") + "\n"
247
+ end
248
+
249
+ def block_lines(text)
250
+ s = text.to_s.strip
251
+ return ["(none)"] if s.empty?
252
+
253
+ s.lines.map(&:rstrip)
254
+ end
255
+
256
+ # True iff any intent failed (resolver miss).
257
+ def any_failed?(results)
258
+ results.values.any? { |r| r[:counts][:failed].to_i.positive? }
259
+ end
260
+ end
261
+
262
+ if $PROGRAM_NAME == __FILE__
263
+ home = ProjectLinks::DEFAULT_HOME
264
+ dry = false
265
+ audit = nil
266
+ i = 0
267
+ while i < ARGV.length
268
+ case ARGV[i]
269
+ when "--plastic-home" then home = ARGV[i + 1]; i += 2
270
+ when "--dry-run" then dry = true; i += 1
271
+ when "--audit-path" then audit = ARGV[i + 1]; i += 2
272
+ else i += 1
273
+ end
274
+ end
275
+
276
+ tool = ProjectLinks.new(plastic_home: home, dry_run: dry, audit_path: audit)
277
+ results = tool.run
278
+ totals = ProjectLinks::STATUS_ORDER.to_h do |st|
279
+ [st, results.values.sum { |r| r[:counts][st] }]
280
+ end
281
+ puts "project-links #{dry ? "DRY RUN" : "applied"}: " \
282
+ "regenerated #{totals[:regenerated]}, added #{totals[:added]}, " \
283
+ "unchanged #{totals[:unchanged]}, failed #{totals[:failed]} " \
284
+ "across #{results.size} store(s)."
285
+ puts "Audit: #{tool.audit_path}"
286
+ exit 1 if tool.any_failed?(results)
287
+ end