@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.
- package/PLASTIC.md +9 -2
- package/hooks/statusline +27 -2
- package/package.json +1 -1
- package/scripts/doctor.rb +172 -0
- package/scripts/lib/bridge.rb +11 -2
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +4 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/new-intent +129 -28
- package/scripts/project-links +287 -0
- package/scripts/rebuild-graph +244 -0
- package/skills/creating-intent/references/lifecycle.md +9 -4
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
|
@@ -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
|
package/scripts/new-intent
CHANGED
|
@@ -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
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
return unless idx
|
|
172
|
+
fm = IntentValidator.parse_frontmatter(md)
|
|
173
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
118
174
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
127
|
-
File.write(file_path,
|
|
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`).
|
|
180
|
-
#
|
|
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.
|
|
192
|
-
# the
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|