@zalom/plastic 1.0.0-beta.8 → 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/package.json +1 -1
- package/scripts/doctor.rb +172 -0
- 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,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
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# rebuild-graph — repair the store-wide sources/chain frontmatter graph across the
|
|
6
|
+
# global, plastic, and knowdb stores (intent 49). Deterministic, idempotent, and
|
|
7
|
+
# one-directional (intent 68 I-invariants): dedupe, I3 (formative edge wins), I1
|
|
8
|
+
# in-store backlinks, I2 preserved; cross-store refs resolved via a multi-hop
|
|
9
|
+
# relocation map (relocation wins over coincidental id reuse). Emits a
|
|
10
|
+
# before/after audit, then writes minimal style-preserving frontmatter.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# rebuild-graph [--plastic-home PATH] [--dry-run] [--audit-path PATH]
|
|
14
|
+
#
|
|
15
|
+
# Pure-Ruby (no bash). The pure logic lives in lib/graph_rebuild.rb and
|
|
16
|
+
# lib/frontmatter_writer.rb; this shell does only discovery, IO, and reporting.
|
|
17
|
+
# Never pushes ~/.plastic (no git ops here).
|
|
18
|
+
|
|
19
|
+
require "yaml"
|
|
20
|
+
require "date"
|
|
21
|
+
require "time"
|
|
22
|
+
require "fileutils"
|
|
23
|
+
|
|
24
|
+
require_relative "lib/graph_rebuild"
|
|
25
|
+
require_relative "lib/frontmatter_writer"
|
|
26
|
+
require_relative "lib/intent_validator"
|
|
27
|
+
|
|
28
|
+
class RebuildGraph
|
|
29
|
+
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
30
|
+
|
|
31
|
+
# The 49 intent dir audit destination (relative to plastic_home).
|
|
32
|
+
DEFAULT_AUDIT_REL =
|
|
33
|
+
"projects/plastic/store/49--store-wide-double-link-symmetry/resources/audit--graph-rebuild.md"
|
|
34
|
+
|
|
35
|
+
KIND_LABELS = {
|
|
36
|
+
dedupe: "Dedupes",
|
|
37
|
+
i3: "I3 resolutions (kept in sources, dropped from chain)",
|
|
38
|
+
repoint: "Cross-store repoints",
|
|
39
|
+
collapse: "Cross-store collapses (to bare same-store id)",
|
|
40
|
+
drop: "Dropped dead refs",
|
|
41
|
+
i1_backlink: "I1 backlinks added",
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
KIND_ORDER = %i[dedupe i3 repoint collapse drop i1_backlink].freeze
|
|
45
|
+
|
|
46
|
+
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
|
|
47
|
+
@plastic_home = plastic_home
|
|
48
|
+
@dry_run = dry_run
|
|
49
|
+
|
|
50
|
+
# A dry run must NOT stomp the canonical audit (the spec/checklist tell humans
|
|
51
|
+
# to run --dry-run to review the plan). When no explicit --audit-path is given,
|
|
52
|
+
# a dry run writes to a distinct `.dry-run.md` sibling, leaving the canonical
|
|
53
|
+
# real-run audit untouched. An explicit --audit-path is always honored verbatim
|
|
54
|
+
# (it is the caller's responsibility, and tests inject it).
|
|
55
|
+
canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
|
|
56
|
+
@audit_path =
|
|
57
|
+
if audit_path
|
|
58
|
+
audit_path
|
|
59
|
+
elsif dry_run
|
|
60
|
+
canonical.sub(/\.md\z/, ".dry-run.md")
|
|
61
|
+
else
|
|
62
|
+
canonical
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :plastic_home, :dry_run, :audit_path
|
|
67
|
+
|
|
68
|
+
# The three in-scope stores, each as { key:, root:, store:, index: }.
|
|
69
|
+
# `root` is the dir holding INDEX.md; `store` is the intents dir.
|
|
70
|
+
def stores
|
|
71
|
+
list = []
|
|
72
|
+
global_store = File.join(plastic_home, "store")
|
|
73
|
+
list << { key: "global", root: plastic_home, store: global_store,
|
|
74
|
+
index: File.join(plastic_home, "INDEX.md") } if File.directory?(global_store)
|
|
75
|
+
|
|
76
|
+
%w[plastic knowdb].each do |slug|
|
|
77
|
+
root = File.join(plastic_home, "projects", slug)
|
|
78
|
+
store = File.join(root, "store")
|
|
79
|
+
next unless File.directory?(store)
|
|
80
|
+
|
|
81
|
+
list << { key: "project:#{slug}", root: root, store: store,
|
|
82
|
+
index: File.join(root, "INDEX.md") }
|
|
83
|
+
end
|
|
84
|
+
list
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# { id => { sources:, chain:, path: } } for one store.
|
|
88
|
+
def load_nodes(store_dir)
|
|
89
|
+
nodes = {}
|
|
90
|
+
Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
91
|
+
dir = File.join(store_dir, entry)
|
|
92
|
+
next unless File.directory?(dir)
|
|
93
|
+
|
|
94
|
+
md = File.join(dir, "#{entry}.md")
|
|
95
|
+
next unless File.exist?(md)
|
|
96
|
+
|
|
97
|
+
fm = IntentValidator.parse_frontmatter(md)
|
|
98
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
99
|
+
|
|
100
|
+
nodes[fm["id"].to_s] = {
|
|
101
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
102
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
103
|
+
path: md,
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
nodes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run
|
|
110
|
+
store_list = stores
|
|
111
|
+
nodes_by_store = {}
|
|
112
|
+
index_texts = {}
|
|
113
|
+
store_index = {}
|
|
114
|
+
|
|
115
|
+
store_list.each do |s|
|
|
116
|
+
nodes_by_store[s[:key]] = load_nodes(s[:store])
|
|
117
|
+
index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
|
|
118
|
+
store_index[s[:key]] = nodes_by_store[s[:key]].keys
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
relocation_map = GraphRebuild.build_relocation_map(index_texts)
|
|
122
|
+
|
|
123
|
+
results = {}
|
|
124
|
+
store_list.each do |s|
|
|
125
|
+
key = s[:key]
|
|
126
|
+
input = nodes_by_store[key].transform_values { |v| { sources: v[:sources], chain: v[:chain] } }
|
|
127
|
+
results[key] = GraphRebuild.rebuild_store(
|
|
128
|
+
input,
|
|
129
|
+
referer_store: key,
|
|
130
|
+
relocation_map: relocation_map,
|
|
131
|
+
store_index: store_index
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
write_back(store_list, nodes_by_store, results) unless dry_run
|
|
136
|
+
emit_audit(store_list, nodes_by_store, results)
|
|
137
|
+
|
|
138
|
+
results
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Write changed frontmatter back via the minimal style-preserving writer.
|
|
142
|
+
def write_back(store_list, nodes_by_store, results)
|
|
143
|
+
store_list.each do |s|
|
|
144
|
+
key = s[:key]
|
|
145
|
+
new_nodes = results[key][:nodes]
|
|
146
|
+
nodes_by_store[key].each do |id, original|
|
|
147
|
+
rebuilt = new_nodes[id]
|
|
148
|
+
next if rebuilt.nil?
|
|
149
|
+
next if rebuilt[:sources] == original[:sources] && rebuilt[:chain] == original[:chain]
|
|
150
|
+
|
|
151
|
+
content = File.read(original[:path])
|
|
152
|
+
updated = FrontmatterWriter.rewrite_arrays(content,
|
|
153
|
+
sources: rebuilt[:sources],
|
|
154
|
+
chain: rebuilt[:chain])
|
|
155
|
+
File.write(original[:path], updated) if updated != content
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Render the audit and write it (always, even in dry-run, so the human reviews
|
|
161
|
+
# the dry-run plan). Returns the rendered string.
|
|
162
|
+
def emit_audit(store_list, _nodes_by_store, results)
|
|
163
|
+
text = render_audit(store_list, results)
|
|
164
|
+
FileUtils.mkdir_p(File.dirname(audit_path))
|
|
165
|
+
File.write(audit_path, text)
|
|
166
|
+
text
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# PURE-ish formatter (string from results). Per-store, grouped by kind.
|
|
170
|
+
def render_audit(store_list, results)
|
|
171
|
+
total = store_list.sum { |s| results[s[:key]][:changes].size }
|
|
172
|
+
lines = []
|
|
173
|
+
lines << "# Audit: store-wide sources/chain graph rebuild (intent 49)"
|
|
174
|
+
lines << ""
|
|
175
|
+
lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
|
|
176
|
+
lines << ""
|
|
177
|
+
lines << "Total changes across all stores: #{total}"
|
|
178
|
+
lines << ""
|
|
179
|
+
|
|
180
|
+
store_list.each do |s|
|
|
181
|
+
key = s[:key]
|
|
182
|
+
changes = results[key][:changes]
|
|
183
|
+
lines << "## #{key}"
|
|
184
|
+
lines << ""
|
|
185
|
+
if changes.empty?
|
|
186
|
+
lines << "No changes."
|
|
187
|
+
lines << ""
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
KIND_ORDER.each do |kind|
|
|
192
|
+
group = changes.select { |c| c[:kind] == kind }
|
|
193
|
+
next if group.empty?
|
|
194
|
+
|
|
195
|
+
lines << "### #{KIND_LABELS[kind]} (#{group.size})"
|
|
196
|
+
group.each { |c| lines << "- #{format_change(c)}" }
|
|
197
|
+
lines << ""
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
lines.join("\n") + "\n"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_change(c)
|
|
205
|
+
case c[:kind]
|
|
206
|
+
when :dedupe
|
|
207
|
+
"#{c[:intent]}: sources #{c[:before][:sources].inspect} → #{c[:after][:sources].inspect}, " \
|
|
208
|
+
"chain #{c[:before][:chain].inspect} → #{c[:after][:chain].inspect}"
|
|
209
|
+
when :i3
|
|
210
|
+
"#{c[:intent]}: #{c[:before]} kept in sources, dropped from chain"
|
|
211
|
+
when :repoint
|
|
212
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (relocated cross-store)"
|
|
213
|
+
when :collapse
|
|
214
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (collapsed to bare same-store id)"
|
|
215
|
+
when :drop
|
|
216
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} dropped (resolves nowhere)"
|
|
217
|
+
when :i1_backlink
|
|
218
|
+
"#{c[:intent]}.chain += #{c[:backlink]} (formative backlink)"
|
|
219
|
+
else
|
|
220
|
+
c.inspect
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if $PROGRAM_NAME == __FILE__
|
|
226
|
+
home = RebuildGraph::DEFAULT_HOME
|
|
227
|
+
dry = false
|
|
228
|
+
audit = nil
|
|
229
|
+
i = 0
|
|
230
|
+
while i < ARGV.length
|
|
231
|
+
case ARGV[i]
|
|
232
|
+
when "--plastic-home" then home = ARGV[i + 1]; i += 2
|
|
233
|
+
when "--dry-run" then dry = true; i += 1
|
|
234
|
+
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
235
|
+
else i += 1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
tool = RebuildGraph.new(plastic_home: home, dry_run: dry, audit_path: audit)
|
|
240
|
+
results = tool.run
|
|
241
|
+
total = results.values.sum { |r| r[:changes].size }
|
|
242
|
+
puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
|
|
243
|
+
puts "Audit: #{tool.audit_path}"
|
|
244
|
+
end
|
|
@@ -42,10 +42,15 @@ field.
|
|
|
42
42
|
|
|
43
43
|
## `## Links`
|
|
44
44
|
|
|
45
|
-
The human-readable projection of the local knowledge graph
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
The human-readable projection of the local knowledge graph, mirroring the
|
|
46
|
+
frontmatter exactly. Each entry is `- [[id--slug|<target's full intent: text>]]`,
|
|
47
|
+
a clickable `id--slug` wikilink target with the target intent's full `intent:`
|
|
48
|
+
text as the label (cross-store targets render
|
|
49
|
+
`- [[store:id--slug|<target's full intent: text>]]`). Ordering is mandatory: all
|
|
50
|
+
`sources` first (top), then all `chain`, frontmatter order preserved within each
|
|
51
|
+
group. Sources never appear at the end. No source/chain tags, no sub-grouping. An
|
|
52
|
+
intent with empty `sources` and `chain` carries the empty-state comment. Counterpart
|
|
53
|
+
to the frontmatter `sources` / `chain` edges, for Obsidian graph navigation.
|
|
49
54
|
|
|
50
55
|
## Conventions — Filesystem as Schema
|
|
51
56
|
|
|
@@ -10,6 +10,13 @@ Plastic implements three Zettelkasten structures:
|
|
|
10
10
|
|
|
11
11
|
INDEX.md is a structure note (hub), not a table of contents.
|
|
12
12
|
|
|
13
|
+
`## Links` mirrors the frontmatter graph exactly. Each entry is
|
|
14
|
+
`- [[id--slug|<target's full intent: text>]]` (cross-store: `- [[store:id--slug|...]]`),
|
|
15
|
+
a clickable `id--slug` target with the target's full `intent:` text as the label.
|
|
16
|
+
Ordering is mandatory: all `sources` first (top), then all `chain`, frontmatter order
|
|
17
|
+
preserved within each group. Sources never appear at the end. No source/chain tags, no
|
|
18
|
+
sub-grouping. An intent with empty `sources` and `chain` carries the empty-state comment.
|
|
19
|
+
|
|
13
20
|
## Folgezettel IDs
|
|
14
21
|
|
|
15
22
|
IDs encode lineage using Luhmann's alternating convention:
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
## Three Connection Types (Ranked)
|
|
10
10
|
|
|
11
|
-
1. **Direct links** (strongest)
|
|
11
|
+
1. **Direct links** (strongest): wikilinks in the `## Links` section, the projection of the
|
|
12
|
+
frontmatter graph. Each entry is `- [[id--slug|<target's full intent: text>]]` (cross-store:
|
|
13
|
+
`- [[store:id--slug|...]]`), a clickable `id--slug` target labeled with the target's full
|
|
14
|
+
`intent:` text. Ordering is mandatory: all `sources` first (top), then all `chain`,
|
|
15
|
+
frontmatter order preserved within each group. Sources never appear at the end. No
|
|
16
|
+
source/chain tags, no sub-grouping.
|
|
12
17
|
2. **Sources/Chain** (knowledge graph): `sources` = direct ascendants this was created from (formation, acyclic, must-load); `chain` = forward continuations and related successors (may cycle, lighter context). See `docs/concepts/how-plastic-sources-and-chains-intents.md` for the full model.
|
|
13
18
|
3. **Tags** (weakest) — shared tags, `project-<name>` for project membership
|
|
14
19
|
|