@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,328 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# GraphRebuild — pure logic for repairing the store-wide sources/chain graph
|
|
5
|
+
# (intent 49). Mirrors the pure-module style of IntentValidator: module-function
|
|
6
|
+
# helpers with no file IO. The IO shell (scripts/rebuild-graph) and doctor build
|
|
7
|
+
# the in-memory maps and feed them here.
|
|
8
|
+
#
|
|
9
|
+
# Two concerns live here:
|
|
10
|
+
# 1. Cross-store relocation. Each store's INDEX.md `## Relocated` log records
|
|
11
|
+
# moves like `global:24 → project:22c` (or backtick bare-id form `1b1a1 → 41`).
|
|
12
|
+
# build_relocation_map parses every log into a multi-hop-collapsed map; the
|
|
13
|
+
# resolver consults it BEFORE direct id resolution so a relocation always wins
|
|
14
|
+
# over a coincidentally-reused id (the `global:24` impostor hazard).
|
|
15
|
+
# 2. The per-intent rebuild transform (intent 68 I-invariants, one-directional):
|
|
16
|
+
# dedupe -> I3 (formative edge wins) -> cross-store resolve -> I1 backlinks ->
|
|
17
|
+
# I2 preserved (relational chains survive).
|
|
18
|
+
module GraphRebuild
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
RELOCATION_ARROW = "→" # the unicode → used in every Relocated log
|
|
22
|
+
|
|
23
|
+
# PURE. Parse every store's INDEX.md `## Relocated` block into a multi-hop
|
|
24
|
+
# relocation map.
|
|
25
|
+
#
|
|
26
|
+
# `index_texts` is { store_key => index_md_string } where store_key is "global"
|
|
27
|
+
# or "project:<slug>". Returns { [from_store, from_id] => [to_store, to_id] }
|
|
28
|
+
# with chains transitively collapsed to their final hop. The `to_store` token is
|
|
29
|
+
# NORMALIZED: the generic `project:` token in the global log is left as the
|
|
30
|
+
# literal it appears with; resolve_ref maps a same-family target to a bare id.
|
|
31
|
+
#
|
|
32
|
+
# Two real arrow forms are handled:
|
|
33
|
+
# - `global:24 → project:22c` (store-prefixed, global log)
|
|
34
|
+
# - `1b1a1 → 41` inside backticks (bare ids, plastic log) — these are
|
|
35
|
+
# same-store moves; from/to store both default to the store the log lives in.
|
|
36
|
+
def build_relocation_map(index_texts)
|
|
37
|
+
raw = {}
|
|
38
|
+
(index_texts || {}).each do |store_key, text|
|
|
39
|
+
next unless text.is_a?(String)
|
|
40
|
+
|
|
41
|
+
relocated_block(text).each_line do |line|
|
|
42
|
+
parse_relocation_line(line, store_key).each do |(from, to)|
|
|
43
|
+
raw[from] = to
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
collapse_multi_hop(raw)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Extract the text of the `## Relocated` section (everything from the heading to
|
|
51
|
+
# the next top-level `## ` heading or EOF). Returns "" when absent.
|
|
52
|
+
def relocated_block(text)
|
|
53
|
+
lines = text.lines
|
|
54
|
+
start = lines.index { |l| l.strip == "## Relocated" }
|
|
55
|
+
return "" if start.nil?
|
|
56
|
+
|
|
57
|
+
rest = lines[(start + 1)..] || []
|
|
58
|
+
stop = rest.index { |l| l.start_with?("## ") }
|
|
59
|
+
(stop ? rest[0...stop] : rest).join
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Parse one log line into an array of [[from_store, from_id], [to_store, to_id]]
|
|
63
|
+
# pairs. A line may pack several comma-separated pairs and carry trailing prose
|
|
64
|
+
# in parens. Lines without an arrow yield []. `home_store` is the store whose
|
|
65
|
+
# log this line came from (used as the default store for bare ids).
|
|
66
|
+
def parse_relocation_line(line, home_store)
|
|
67
|
+
body = line.sub(/\A\s*-\s*/, "") # drop list bullet
|
|
68
|
+
return [] unless body.include?(RELOCATION_ARROW)
|
|
69
|
+
|
|
70
|
+
body.split(",").filter_map do |segment|
|
|
71
|
+
seg = segment.strip
|
|
72
|
+
next nil unless seg.include?(RELOCATION_ARROW)
|
|
73
|
+
|
|
74
|
+
left, right = seg.split(RELOCATION_ARROW, 2)
|
|
75
|
+
from = parse_token(left, home_store)
|
|
76
|
+
to = parse_token(right, home_store)
|
|
77
|
+
next nil if from.nil? || to.nil?
|
|
78
|
+
|
|
79
|
+
[from, to]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Parse a single `store:id` / bare-id token (possibly wrapped in backticks or
|
|
84
|
+
# trailed by parenthetical prose) into [store_key, bare_id], or nil. A bare id
|
|
85
|
+
# defaults to `home_store`. The generic `project:` prefix is resolved later by
|
|
86
|
+
# resolve_ref against the referer's store family; here it is recorded literally.
|
|
87
|
+
def parse_token(token, home_store)
|
|
88
|
+
cleaned = token.to_s.tr("`", " ").strip
|
|
89
|
+
cleaned = cleaned.sub(/\s*\(.*\z/, "").strip # drop trailing "(prose"
|
|
90
|
+
cleaned = cleaned.split(/\s/).first.to_s # first whitespace-delimited atom
|
|
91
|
+
return nil if cleaned.empty?
|
|
92
|
+
|
|
93
|
+
if cleaned.include?(":")
|
|
94
|
+
store_tok, id = cleaned.split(":", 2)
|
|
95
|
+
return nil if id.to_s.empty?
|
|
96
|
+
|
|
97
|
+
[normalize_store_token(store_tok), id]
|
|
98
|
+
else
|
|
99
|
+
[home_store, cleaned]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Normalize a store token from a log to a store_key. "global" stays "global".
|
|
104
|
+
# The generic "project" token (used in the global log) is kept as the sentinel
|
|
105
|
+
# "project" — resolve_ref binds it to the referer's project family.
|
|
106
|
+
def normalize_store_token(tok)
|
|
107
|
+
t = tok.to_s.strip
|
|
108
|
+
t == "global" ? "global" : t
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Transitively collapse a → b → c chains so every key maps to its FINAL hop.
|
|
112
|
+
# Cycle-guarded. Keys/values are [store_key, id] pairs.
|
|
113
|
+
def collapse_multi_hop(raw)
|
|
114
|
+
raw.each_with_object({}) do |(from, _to), acc|
|
|
115
|
+
seen = [from]
|
|
116
|
+
cur = raw[from]
|
|
117
|
+
while cur && raw.key?(cur) && !seen.include?(cur)
|
|
118
|
+
seen << cur
|
|
119
|
+
cur = raw[cur]
|
|
120
|
+
end
|
|
121
|
+
acc[from] = cur
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# PURE. Resolve a single ref (a `store:id` cross-store ref, or a bare same-store
|
|
126
|
+
# id) to a final location and classification. ORDER IS LOAD-BEARING: the
|
|
127
|
+
# relocation map is consulted FIRST, so a relocation wins over a coincidentally
|
|
128
|
+
# reused id.
|
|
129
|
+
#
|
|
130
|
+
# ref — "global:24" or "22c"
|
|
131
|
+
# referer_store — store_key of the intent carrying the ref ("global"/"project:plastic")
|
|
132
|
+
# relocation_map — from build_relocation_map
|
|
133
|
+
# store_index — { store_key => Array/Set of bare ids present in that store }
|
|
134
|
+
#
|
|
135
|
+
# Returns a Hash:
|
|
136
|
+
# { status: :same_store, id: "<bare id>" } -> collapse to bare id
|
|
137
|
+
# { status: :cross_store, ref: "<store>:<id>" } -> keep/repoint store:id
|
|
138
|
+
# { status: :dead, ref: <original> } -> drop (resolves nowhere)
|
|
139
|
+
def resolve_ref(ref, referer_store:, relocation_map:, store_index:)
|
|
140
|
+
store_tok, bare = split_ref(ref, referer_store)
|
|
141
|
+
|
|
142
|
+
# 1) Relocation FIRST. Look up [store, id]; the generic "project" target token
|
|
143
|
+
# is bound to the referer's family when emitting the location.
|
|
144
|
+
reloc_key = [store_tok, bare]
|
|
145
|
+
if (relocation_map || {}).key?(reloc_key)
|
|
146
|
+
to_store, to_id = relocation_map[reloc_key]
|
|
147
|
+
to_store = bind_project_token(to_store, referer_store, store_index, to_id)
|
|
148
|
+
return classify(to_store, to_id, referer_store, store_index, ref)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# 2) Direct resolution against the live store index.
|
|
152
|
+
classify(store_tok, bare, referer_store, store_index, ref)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Split a ref into [store_key, bare_id]; bare refs take the referer's store.
|
|
156
|
+
def split_ref(ref, referer_store)
|
|
157
|
+
s = ref.to_s
|
|
158
|
+
if s.include?(":")
|
|
159
|
+
store_tok, id = s.split(":", 2)
|
|
160
|
+
[normalize_store_token(store_tok), id]
|
|
161
|
+
else
|
|
162
|
+
[referer_store, s]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The global log writes relocation targets as the generic `project:` token. Bind
|
|
167
|
+
# it to the concrete store family that actually owns the bare id. Prefer the
|
|
168
|
+
# referer's store when it holds the id; otherwise pick any store that has it.
|
|
169
|
+
def bind_project_token(to_store, referer_store, store_index, to_id)
|
|
170
|
+
return to_store unless to_store == "project"
|
|
171
|
+
|
|
172
|
+
return referer_store if ids_in(store_index, referer_store).include?(to_id)
|
|
173
|
+
|
|
174
|
+
owner = (store_index || {}).keys.find { |k| ids_in(store_index, k).include?(to_id) }
|
|
175
|
+
owner || referer_store
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Map a ref store token to the canonical store_index key. Refs in frontmatter
|
|
179
|
+
# use the slug form (`global`, `knowdb`, `plastic`); store_index keys use
|
|
180
|
+
# `global` and `project:<slug>`. "global" is canonical; anything else maps to
|
|
181
|
+
# `project:<token>` when that key exists, else the token itself (it may already
|
|
182
|
+
# be a `project:<slug>` key, e.g. a relocation target).
|
|
183
|
+
def canonical_store_key(store_tok, store_index)
|
|
184
|
+
return store_tok if store_tok == "global"
|
|
185
|
+
return store_tok if (store_index || {}).key?(store_tok)
|
|
186
|
+
|
|
187
|
+
projected = "project:#{store_tok}"
|
|
188
|
+
(store_index || {}).key?(projected) ? projected : store_tok
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The slug form of a canonical store key, for emitting a cross-store `slug:id`
|
|
192
|
+
# ref (the form used in frontmatter). "global" stays "global"; "project:<slug>"
|
|
193
|
+
# becomes "<slug>".
|
|
194
|
+
def slug_of(canonical_key)
|
|
195
|
+
canonical_key.to_s.sub(/\Aproject:/, "")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Classify a resolved (store, id) relative to the referer, using the live store
|
|
199
|
+
# index to detect dead targets:
|
|
200
|
+
# - target id present in the referer's OWN store -> :same_store (collapse to bare)
|
|
201
|
+
# - target id present in a DIFFERENT store -> :cross_store (keep slug:id)
|
|
202
|
+
# - target id present NOWHERE -> :dead (drop)
|
|
203
|
+
def classify(store_tok, bare, referer_store, store_index, original_ref)
|
|
204
|
+
canonical = canonical_store_key(store_tok, store_index)
|
|
205
|
+
if ids_in(store_index, canonical).include?(bare)
|
|
206
|
+
if canonical == referer_store
|
|
207
|
+
{ status: :same_store, id: bare }
|
|
208
|
+
else
|
|
209
|
+
{ status: :cross_store, ref: "#{slug_of(canonical)}:#{bare}" }
|
|
210
|
+
end
|
|
211
|
+
else
|
|
212
|
+
{ status: :dead, ref: original_ref.to_s }
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def ids_in(store_index, store_key)
|
|
217
|
+
Array((store_index || {})[store_key])
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# PURE. Per-store rebuild transform (intent 68 I-invariants, one-directional).
|
|
221
|
+
# Deterministic and idempotent: a second call over the result yields zero changes.
|
|
222
|
+
#
|
|
223
|
+
# nodes — ONE store's { id => { sources: [...], chain: [...] } } map
|
|
224
|
+
# referer_store — that store's key ("global" / "project:<slug>")
|
|
225
|
+
# relocation_map — from build_relocation_map (spans all stores)
|
|
226
|
+
# store_index — { store_key => bare ids present } (spans all stores)
|
|
227
|
+
#
|
|
228
|
+
# Returns { nodes: <new map>, changes: [ {intent:, kind:, before:, after:} ] }.
|
|
229
|
+
# kinds: :dedupe, :i3, :repoint, :collapse, :drop, :i1_backlink.
|
|
230
|
+
#
|
|
231
|
+
# Order is load-bearing (spec Phase 2):
|
|
232
|
+
# 1. dedupe each array order-preserving
|
|
233
|
+
# 2. I3: an id in BOTH sources and chain is kept in sources, dropped from chain
|
|
234
|
+
# 3. cross-store resolve each ref (relocation FIRST): repoint, collapse to bare
|
|
235
|
+
# same-store, or drop dead
|
|
236
|
+
# 4. I1: for every in-store source s, ensure s.chain backlinks this intent
|
|
237
|
+
# 5. I2 preserved: never synthesize a reciprocal source, never strip a
|
|
238
|
+
# relational chain entry
|
|
239
|
+
def rebuild_store(nodes, referer_store:, relocation_map:, store_index:)
|
|
240
|
+
out = {}
|
|
241
|
+
(nodes || {}).each do |id, edges|
|
|
242
|
+
edges = {} unless edges.is_a?(Hash)
|
|
243
|
+
out[id.to_s] = {
|
|
244
|
+
sources: Array(edges[:sources] || edges["sources"]).map(&:to_s),
|
|
245
|
+
chain: Array(edges[:chain] || edges["chain"]).map(&:to_s),
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
changes = []
|
|
250
|
+
|
|
251
|
+
out.each do |id, edges|
|
|
252
|
+
# 1. dedupe order-preserving
|
|
253
|
+
deduped_sources = edges[:sources].uniq
|
|
254
|
+
deduped_chain = edges[:chain].uniq
|
|
255
|
+
if deduped_sources != edges[:sources] || deduped_chain != edges[:chain]
|
|
256
|
+
changes << { intent: id, kind: :dedupe,
|
|
257
|
+
before: { sources: edges[:sources].dup, chain: edges[:chain].dup },
|
|
258
|
+
after: { sources: deduped_sources, chain: deduped_chain } }
|
|
259
|
+
end
|
|
260
|
+
edges[:sources] = deduped_sources
|
|
261
|
+
edges[:chain] = deduped_chain
|
|
262
|
+
|
|
263
|
+
# 2. I3: overlap kept in sources, dropped from chain
|
|
264
|
+
overlap = edges[:sources] & edges[:chain]
|
|
265
|
+
overlap.each do |o|
|
|
266
|
+
changes << { intent: id, kind: :i3, before: o, after: nil }
|
|
267
|
+
end
|
|
268
|
+
edges[:chain] -= overlap unless overlap.empty?
|
|
269
|
+
|
|
270
|
+
# 3. cross-store resolve sources and chain
|
|
271
|
+
%i[sources chain].each do |field|
|
|
272
|
+
rebuilt = []
|
|
273
|
+
edges[field].each do |ref|
|
|
274
|
+
unless ref.include?(":")
|
|
275
|
+
rebuilt << ref # bare same-store id, left as-is here (I4 is doctor's job)
|
|
276
|
+
next
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
res = resolve_ref(ref, referer_store: referer_store,
|
|
280
|
+
relocation_map: relocation_map, store_index: store_index)
|
|
281
|
+
case res[:status]
|
|
282
|
+
when :same_store
|
|
283
|
+
if res[:id] != ref
|
|
284
|
+
changes << { intent: id, kind: :collapse, field: field, before: ref, after: res[:id] }
|
|
285
|
+
end
|
|
286
|
+
rebuilt << res[:id]
|
|
287
|
+
when :cross_store
|
|
288
|
+
if res[:ref] != ref
|
|
289
|
+
changes << { intent: id, kind: :repoint, field: field, before: ref, after: res[:ref] }
|
|
290
|
+
end
|
|
291
|
+
rebuilt << res[:ref]
|
|
292
|
+
when :dead
|
|
293
|
+
changes << { intent: id, kind: :drop, field: field, before: ref, after: nil }
|
|
294
|
+
# dropped: not appended
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
# de-dupe again after collapse/repoint may have created duplicates
|
|
298
|
+
edges[field] = rebuilt.uniq
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# 3b. Re-apply I3 AFTER resolution: a cross-store ref that collapses to a
|
|
302
|
+
# bare same-store id can newly overlap an existing chain entry (e.g.
|
|
303
|
+
# sources:[global:14a]→[19a] meeting chain:[19a]). Formative edge wins.
|
|
304
|
+
post_overlap = edges[:sources] & edges[:chain]
|
|
305
|
+
post_overlap.each do |o|
|
|
306
|
+
changes << { intent: id, kind: :i3, before: o, after: nil }
|
|
307
|
+
end
|
|
308
|
+
edges[:chain] -= post_overlap unless post_overlap.empty?
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# 4. I1: in-store source backlinks (mutates OTHER nodes). Runs after resolution
|
|
312
|
+
# so collapsed bare ids participate. Order-preserving append.
|
|
313
|
+
out.each do |id, edges|
|
|
314
|
+
edges[:sources].each do |s|
|
|
315
|
+
next if s.include?(":") # cross-store: backlink lives in another store
|
|
316
|
+
next unless out.key?(s) # unresolved bare id is an I4 dangler, not I1
|
|
317
|
+
next if out[s][:chain].include?(id)
|
|
318
|
+
|
|
319
|
+
before_chain = out[s][:chain].dup
|
|
320
|
+
out[s][:chain] = out[s][:chain] + [id]
|
|
321
|
+
changes << { intent: s, kind: :i1_backlink, field: :chain,
|
|
322
|
+
before: before_chain, after: out[s][:chain].dup, backlink: id }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
{ nodes: out, changes: changes }
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -214,6 +214,10 @@ class InstallerCore
|
|
|
214
214
|
"scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
|
|
215
215
|
"scripts/qmd-sync" => "scripts/qmd-sync",
|
|
216
216
|
"scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
|
|
217
|
+
"scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
|
|
218
|
+
"scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
|
|
219
|
+
"scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
|
|
220
|
+
"scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
|
|
217
221
|
"scripts/validate-intent" => "scripts/validate-intent",
|
|
218
222
|
"scripts/new-intent" => "scripts/new-intent",
|
|
219
223
|
"scripts/hook-create-gate" => "scripts/hook-create-gate",
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# LinksProjection — pure logic that projects one intent's sources/chain graph into
|
|
5
|
+
# the canonical I5 `## Links` section text (intent 72). Mirrors the pure-module
|
|
6
|
+
# style of GraphRebuild: module-function helpers, no file IO, no eval, no
|
|
7
|
+
# ENV/global state. The IO shell (scripts/project-links) and doctor build the
|
|
8
|
+
# cross-store resolver and feed it here.
|
|
9
|
+
#
|
|
10
|
+
# Pinned canonical projection (the human's format call):
|
|
11
|
+
# 1. ORDERING (load-bearing): ALL sources first, in frontmatter order, THEN all
|
|
12
|
+
# chain, in frontmatter order. Sources can NEVER appear at the end. No group
|
|
13
|
+
# headings, no per-entry source/chain tags: the ordering carries the meaning.
|
|
14
|
+
# 2. ENTRY SHAPE: one list item `- [[<id>--<slug>|<target's full intent: text>]]`,
|
|
15
|
+
# where the wikilink TARGET is the target intent's resolvable `id--slug` file
|
|
16
|
+
# basename (so it clicks through in Obsidian) and the LABEL is the target's
|
|
17
|
+
# full `intent:` frontmatter text, whitespace-trimmed.
|
|
18
|
+
# 3. CROSS-STORE: a cross-store target renders
|
|
19
|
+
# `- [[<store>:<id>--<slug>|<target's full intent: text>]]`.
|
|
20
|
+
# 4. RESOLVER MISS: a ref that resolves to no intent raises UnresolvedRef. No
|
|
21
|
+
# bare-id, slug-less, or guessed link is ever emitted.
|
|
22
|
+
# 5. EMPTY-STATE: empty sources AND chain yields the heading plus a single
|
|
23
|
+
# explanatory comment.
|
|
24
|
+
module LinksProjection
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
HEADING = "## Links"
|
|
28
|
+
EMPTY_COMMENT = "<!-- No sources or chain; this intent has no graph edges to project. -->"
|
|
29
|
+
|
|
30
|
+
# Raised when a sources/chain ref resolves to no intent. Carries the offending
|
|
31
|
+
# ref so the IO shell can report it per-intent and skip the write.
|
|
32
|
+
class UnresolvedRef < StandardError
|
|
33
|
+
attr_reader :ref
|
|
34
|
+
|
|
35
|
+
def initialize(ref)
|
|
36
|
+
@ref = ref
|
|
37
|
+
super("unresolved sources/chain ref: #{ref.inspect}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# PURE. Build the canonical `## Links` section text for one intent.
|
|
42
|
+
#
|
|
43
|
+
# sources — array of id / `store:id` ref strings, in frontmatter order
|
|
44
|
+
# chain — array of id / `store:id` ref strings, in frontmatter order
|
|
45
|
+
# resolve — a callable (`->(ref) { ... }`) mapping ONE ref to a Hash like
|
|
46
|
+
# { target: "<id>--<slug>", label: "<full intent: text>" } or
|
|
47
|
+
# { target: "<store>:<id>--<slug>", label: "<full intent: text>" }.
|
|
48
|
+
# Returning nil (or a Hash lacking :target) signals no target and
|
|
49
|
+
# raises UnresolvedRef. Keeping resolution injected keeps this module
|
|
50
|
+
# pure and hermetically testable with in-memory maps.
|
|
51
|
+
#
|
|
52
|
+
# Returns the full section text: the `## Links` heading line, one entry line per
|
|
53
|
+
# ref (sources first, then chain), and a single trailing newline. The empty case
|
|
54
|
+
# returns the heading + the empty-state comment + a single trailing newline.
|
|
55
|
+
def section(sources:, chain:, resolve:)
|
|
56
|
+
src = Array(sources).map(&:to_s)
|
|
57
|
+
chn = Array(chain).map(&:to_s)
|
|
58
|
+
|
|
59
|
+
# Resolve EVERY ref to its { target:, label: } first, then dedup by the RESOLVED
|
|
60
|
+
# target (not the raw ref string). This is load-bearing: the same intent may be
|
|
61
|
+
# referenced as a bare id in one group and as `store:id` in another (or via a
|
|
62
|
+
# relocation), which dedups identically only AFTER resolution. Sources win
|
|
63
|
+
# (formative edge), and frontmatter order is preserved within each group.
|
|
64
|
+
seen = {}
|
|
65
|
+
rendered = []
|
|
66
|
+
src.each { |ref| add_entry(ref, resolve, seen, rendered) }
|
|
67
|
+
chn.each { |ref| add_entry(ref, resolve, seen, rendered) }
|
|
68
|
+
|
|
69
|
+
return empty_section if rendered.empty?
|
|
70
|
+
|
|
71
|
+
(["#{HEADING}\n"] + rendered.map { |line| "#{line}\n" }).join
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# PURE. The canonical empty-state section: heading + the single comment line.
|
|
75
|
+
def empty_section
|
|
76
|
+
"#{HEADING}\n#{EMPTY_COMMENT}\n"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Resolve `ref`, render its entry, and append it to `rendered` UNLESS its resolved
|
|
80
|
+
# target was already emitted (dedup by resolved target, first-seen wins so sources
|
|
81
|
+
# precede chain). Mutates `seen` and `rendered`. Raises UnresolvedRef on a miss.
|
|
82
|
+
def add_entry(ref, resolve, seen, rendered)
|
|
83
|
+
target, label = resolve_entry(ref, resolve)
|
|
84
|
+
return if seen.key?(target)
|
|
85
|
+
|
|
86
|
+
seen[target] = true
|
|
87
|
+
rendered << "- [[#{target}|#{label}]]"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# PURE. Resolve one ref to [target, label]. Raises UnresolvedRef when the
|
|
91
|
+
# resolver returns nothing usable.
|
|
92
|
+
def resolve_entry(ref, resolve)
|
|
93
|
+
resolved = resolve.call(ref)
|
|
94
|
+
target = resolved.is_a?(Hash) ? resolved[:target] || resolved["target"] : nil
|
|
95
|
+
raise UnresolvedRef, ref if target.nil? || target.to_s.strip.empty?
|
|
96
|
+
|
|
97
|
+
label = (resolved[:label] || resolved["label"]).to_s.strip
|
|
98
|
+
[target.to_s, label]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# PURE. Render one entry line `- [[<target>|<label>]]` from a single ref. Kept for
|
|
102
|
+
# callers/tests that render one entry; #section uses add_entry for dedup.
|
|
103
|
+
def entry(ref, resolve)
|
|
104
|
+
target, label = resolve_entry(ref, resolve)
|
|
105
|
+
"- [[#{target}|#{label}]]"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# PURE. Resolve ONE sources/chain ref to its `{ target:, label: }` projection,
|
|
109
|
+
# given the in-memory cross-store maps. This is the single resolver definition
|
|
110
|
+
# shared by the IO shell (scripts/project-links) and the doctor check, so the two
|
|
111
|
+
# can never diverge.
|
|
112
|
+
#
|
|
113
|
+
# ref — "40" (same-store bare id) or "knowdb:1" (cross-store)
|
|
114
|
+
# referer_store — store_key of the intent carrying the ref ("global" / "project:<slug>")
|
|
115
|
+
# relocation_map — from GraphRebuild.build_relocation_map (spans all stores)
|
|
116
|
+
# store_index — { store_key => [bare ids present] } (spans all stores)
|
|
117
|
+
# node_index — { store_key => { id => { basename:, label: } } } (spans all stores)
|
|
118
|
+
#
|
|
119
|
+
# Returns { target:, label: } (target is `<id>--<slug>` for a same-store id, or
|
|
120
|
+
# `<slug>:<id>--<slug>` for a cross-store one), or nil when the ref resolves to no
|
|
121
|
+
# live intent (which makes #section / #entry raise UnresolvedRef).
|
|
122
|
+
#
|
|
123
|
+
# Uses GraphRebuild.resolve_ref so a relocation always wins over a coincidentally
|
|
124
|
+
# reused id (the `global:24` impostor hazard), exactly as the frontmatter rebuild
|
|
125
|
+
# and the cross-store doctor check do.
|
|
126
|
+
def resolve_ref_projection(ref, referer_store:, relocation_map:, store_index:, node_index:)
|
|
127
|
+
require_relative "graph_rebuild"
|
|
128
|
+
|
|
129
|
+
res = GraphRebuild.resolve_ref(ref, referer_store: referer_store,
|
|
130
|
+
relocation_map: relocation_map,
|
|
131
|
+
store_index: store_index)
|
|
132
|
+
case res[:status]
|
|
133
|
+
when :same_store
|
|
134
|
+
node = (node_index[referer_store] || {})[res[:id]]
|
|
135
|
+
return nil if node.nil?
|
|
136
|
+
|
|
137
|
+
{ target: node[:basename], label: node[:label] }
|
|
138
|
+
when :cross_store
|
|
139
|
+
slug, bare = res[:ref].split(":", 2)
|
|
140
|
+
target_key = canonical_store_key(slug, store_index)
|
|
141
|
+
node = (node_index[target_key] || {})[bare]
|
|
142
|
+
return nil if node.nil?
|
|
143
|
+
|
|
144
|
+
{ target: "#{slug}:#{node[:basename]}", label: node[:label] }
|
|
145
|
+
else # :dead
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Map a ref store slug ("global", "knowdb", "plastic") to a node_index/store_index
|
|
151
|
+
# key ("global", "project:knowdb", "project:plastic"). Mirrors
|
|
152
|
+
# GraphRebuild.canonical_store_key's intent for the node_index keyspace.
|
|
153
|
+
def canonical_store_key(slug, store_index)
|
|
154
|
+
return "global" if slug == "global"
|
|
155
|
+
return slug if (store_index || {}).key?(slug)
|
|
156
|
+
|
|
157
|
+
projected = "project:#{slug}"
|
|
158
|
+
(store_index || {}).key?(projected) ? projected : slug
|
|
159
|
+
end
|
|
160
|
+
end
|