@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,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: all `sources`
46
- first (top, named), then all `chain` (named), as `[[id]]` wikilinks plus a
47
- short label. Counterpart to the frontmatter `sources` / `chain` edges, for
48
- Obsidian graph navigation.
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) wikilinks in `## Links` section
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