@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
package/PLASTIC.md
CHANGED
|
@@ -50,6 +50,13 @@ tags: [plastic, architecture]
|
|
|
50
50
|
- Context contract: load `sources` strongly (they are what the intent was built from);
|
|
51
51
|
traverse `chain` lightly for discovery. See
|
|
52
52
|
docs/concepts/how-plastic-sources-and-chains-intents.md for the full model.
|
|
53
|
+
- `## Links` (I5) is the human-readable projection of the graph. It mirrors the
|
|
54
|
+
frontmatter exactly: every entry is `- [[id--slug|<target's full intent: text>]]`, a
|
|
55
|
+
clickable `id--slug` wikilink target with the target intent's full `intent:` text as the
|
|
56
|
+
label (cross-store targets render `- [[store:id--slug|<target's full intent: text>]]`).
|
|
57
|
+
Ordering is mandatory: all `sources` first (top), then all `chain`, frontmatter order
|
|
58
|
+
preserved within each group. Sources never appear at the end. No source/chain tags, no
|
|
59
|
+
sub-grouping. An intent with empty `sources` and `chain` carries the empty-state comment.
|
|
53
60
|
- IDs use Luhmann's alternating convention: `1` → `1a` → `1a1` → `1a1a`
|
|
54
61
|
- Multiple branches increment: `1a`, `1b`, `1c`
|
|
55
62
|
|
|
@@ -164,8 +171,8 @@ Format: `ID--three-to-five-words` (all stores).
|
|
|
164
171
|
- **Root (`15`, `16`)**: an independent thought, even if inspired by another intent.
|
|
165
172
|
Reserve `sources` for true created-from provenance (intents this was built out of). An
|
|
166
173
|
independent intent merely related to or inspired by another carries NO `sources`; record
|
|
167
|
-
the relation on the PREDECESSOR's `chain` (and mirror it as a
|
|
168
|
-
`## Links`).
|
|
174
|
+
the relation on the PREDECESSOR's `chain` (and mirror it as a
|
|
175
|
+
`[[id--slug|<target's full intent: text>]]` wikilink in `## Links`).
|
|
169
176
|
- **Rule of thumb:** if the intent could exist without its parent, it's a root.
|
|
170
177
|
|
|
171
178
|
## INDEX.md
|
package/package.json
CHANGED
package/scripts/doctor.rb
CHANGED
|
@@ -17,6 +17,9 @@ require "digest"
|
|
|
17
17
|
|
|
18
18
|
require_relative "lib/qmd_sync"
|
|
19
19
|
require_relative "lib/intent_validator"
|
|
20
|
+
require_relative "lib/graph_rebuild"
|
|
21
|
+
require_relative "lib/links_projection"
|
|
22
|
+
require_relative "lib/links_section"
|
|
20
23
|
|
|
21
24
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
22
25
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -480,9 +483,178 @@ class Doctor
|
|
|
480
483
|
# flagged: validate_graph does not compute it.
|
|
481
484
|
checks.concat(graph_invariant_checks(intent_dirs))
|
|
482
485
|
|
|
486
|
+
# cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
|
|
487
|
+
# `store:id` ref against the FULL store family via the relocation map
|
|
488
|
+
# (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
|
|
489
|
+
# Resolution always spans all stores even under `--store` scoping; only the
|
|
490
|
+
# REPORTED findings are filtered to refs originating in the scoped store(s).
|
|
491
|
+
checks << cross_store_resolution_check(scopes: scopes)
|
|
492
|
+
|
|
493
|
+
# graph_links_projection — the `## Links` section of every intent must EQUAL its
|
|
494
|
+
# canonical I5 frontmatter projection (intent 72), in BOTH set membership AND
|
|
495
|
+
# ordering (sources first, then chain). Recomputes the projection from each
|
|
496
|
+
# intent's sources/chain + the on-disk basenames using the SAME resolver the
|
|
497
|
+
# scripts/project-links tool uses, so the two can never diverge. Resolution
|
|
498
|
+
# spans all stores; only the REPORTED findings are filtered to the scoped store.
|
|
499
|
+
checks << links_projection_check(scopes: scopes)
|
|
500
|
+
|
|
483
501
|
checks
|
|
484
502
|
end
|
|
485
503
|
|
|
504
|
+
# Build the cross-store node maps (basename + label per store) + relocation map
|
|
505
|
+
# from ALL stores, then for every intent compute its canonical `## Links`
|
|
506
|
+
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
507
|
+
# ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
|
|
508
|
+
# run) filters only the REPORTED findings by origin scope.
|
|
509
|
+
def links_projection_check(scopes: nil)
|
|
510
|
+
all_dirs = all_intent_dirs
|
|
511
|
+
|
|
512
|
+
store_index = Hash.new { |h, k| h[k] = [] }
|
|
513
|
+
node_index = Hash.new { |h, k| h[k] = {} }
|
|
514
|
+
intents = [] # { scope:, id:, sources:, chain:, path: }
|
|
515
|
+
|
|
516
|
+
all_dirs.each do |d|
|
|
517
|
+
md = File.join(d[:path], "#{d[:name]}.md")
|
|
518
|
+
next unless File.exist?(md)
|
|
519
|
+
|
|
520
|
+
fm = parse_frontmatter(md)
|
|
521
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
522
|
+
|
|
523
|
+
id = fm["id"].to_s
|
|
524
|
+
store_index[d[:scope]] << id
|
|
525
|
+
node_index[d[:scope]][id] = { basename: d[:name], label: fm["intent"].to_s.strip }
|
|
526
|
+
intents << {
|
|
527
|
+
scope: d[:scope], id: id, path: md,
|
|
528
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
529
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
530
|
+
}
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
534
|
+
|
|
535
|
+
findings = []
|
|
536
|
+
intents.each do |node|
|
|
537
|
+
next if scopes && !scopes.include?(node[:scope])
|
|
538
|
+
|
|
539
|
+
resolve = ->(ref) do
|
|
540
|
+
LinksProjection.resolve_ref_projection(
|
|
541
|
+
ref, referer_store: node[:scope],
|
|
542
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index
|
|
543
|
+
)
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
begin
|
|
547
|
+
expected = LinksProjection.section(sources: node[:sources], chain: node[:chain], resolve: resolve)
|
|
548
|
+
actual = actual_links_section(node[:path])
|
|
549
|
+
rescue LinksProjection::UnresolvedRef => e
|
|
550
|
+
findings << "#{node[:id]} ## Links projection failed: #{e.message}"
|
|
551
|
+
next
|
|
552
|
+
rescue LinksSection::AmbiguousLinks => e
|
|
553
|
+
findings << "#{node[:id]} ## Links ambiguous: #{e.message}"
|
|
554
|
+
next
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
next if actual == expected
|
|
558
|
+
|
|
559
|
+
findings << "#{node[:id]} ## Links does not match its frontmatter projection (membership/ordering drift)"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
graph_finding_check(
|
|
563
|
+
"graph_links_projection", findings,
|
|
564
|
+
"Every intent's ## Links equals its frontmatter projection (membership and ordering)",
|
|
565
|
+
"Run scripts/project-links to regenerate the canonical ## Links sections"
|
|
566
|
+
)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Extract a file's ACTUAL REAL `## Links` section text (FENCE-AWARE), normalized
|
|
570
|
+
# to the canonical block shape the projection emits. Delegates to the shared
|
|
571
|
+
# LinksSection.extract_section so the doctor check and the project-links tool
|
|
572
|
+
# agree on the section location and never match a `## Links` heading inside an
|
|
573
|
+
# example code fence. Returns "" when the section is absent (which differs from
|
|
574
|
+
# any real projection, so a missing section is a finding).
|
|
575
|
+
def actual_links_section(path)
|
|
576
|
+
LinksSection.extract_section(IntentValidator.body_of(File.read(path)))
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Build the relocation map + cross-store store_index from ALL stores, then for
|
|
580
|
+
# every intent's cross-store `sources`/`chain` ref resolve it and flag:
|
|
581
|
+
# - DEAD: the target resolves nowhere
|
|
582
|
+
# - RELOCATED-STALE: the ref points at an old location the relocation log has
|
|
583
|
+
# moved (the resolved location differs from the literal ref), e.g. the
|
|
584
|
+
# `global:24` id-reuse hazard that direct resolution would silently accept.
|
|
585
|
+
# `scopes` (nil = full run) filters only the REPORTED findings by origin scope.
|
|
586
|
+
def cross_store_resolution_check(scopes: nil)
|
|
587
|
+
all_dirs = all_intent_dirs
|
|
588
|
+
|
|
589
|
+
# Per-scope node maps + store_index over the WHOLE family.
|
|
590
|
+
nodes_by_scope = Hash.new { |h, k| h[k] = {} }
|
|
591
|
+
store_index = Hash.new { |h, k| h[k] = [] }
|
|
592
|
+
all_dirs.each do |d|
|
|
593
|
+
md = File.join(d[:path], "#{d[:name]}.md")
|
|
594
|
+
next unless File.exist?(md)
|
|
595
|
+
|
|
596
|
+
fm = parse_frontmatter(md)
|
|
597
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
598
|
+
|
|
599
|
+
id = fm["id"].to_s
|
|
600
|
+
store_index[d[:scope]] << id
|
|
601
|
+
nodes_by_scope[d[:scope]][id] = {
|
|
602
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
603
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
604
|
+
}
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
608
|
+
|
|
609
|
+
findings = []
|
|
610
|
+
nodes_by_scope.each do |scope, nodes|
|
|
611
|
+
next if scopes && !scopes.include?(scope)
|
|
612
|
+
|
|
613
|
+
nodes.each do |id, edges|
|
|
614
|
+
%i[sources chain].each do |field|
|
|
615
|
+
edges[field].each do |ref|
|
|
616
|
+
next unless ref.include?(":") # only cross-store refs are resolved here
|
|
617
|
+
|
|
618
|
+
res = GraphRebuild.resolve_ref(ref, referer_store: scope,
|
|
619
|
+
relocation_map: relocation_map,
|
|
620
|
+
store_index: store_index)
|
|
621
|
+
case res[:status]
|
|
622
|
+
when :dead
|
|
623
|
+
findings << "#{id}.#{field} cross-store ref #{ref} resolves to no intent (dead)"
|
|
624
|
+
when :same_store
|
|
625
|
+
findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now same-store #{res[:id]})"
|
|
626
|
+
when :cross_store
|
|
627
|
+
findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now #{res[:ref]})" if res[:ref] != ref
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
graph_finding_check(
|
|
635
|
+
"graph_cross_store_resolution", findings,
|
|
636
|
+
"Every cross-store sources/chain ref resolves to a live, current intent",
|
|
637
|
+
"Run scripts/rebuild-graph to repoint/collapse/drop stale cross-store refs"
|
|
638
|
+
)
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# { store_key => INDEX.md text } for every store (global + all projects), for the
|
|
642
|
+
# relocation-map builder. Reads INDEX.md one level above each store dir.
|
|
643
|
+
def cross_store_index_texts
|
|
644
|
+
texts = {}
|
|
645
|
+
global_index = File.join(plastic_home, "INDEX.md")
|
|
646
|
+
texts["global"] = File.read(global_index) if File.exist?(global_index)
|
|
647
|
+
|
|
648
|
+
projects_root = File.join(plastic_home, "projects")
|
|
649
|
+
if File.directory?(projects_root)
|
|
650
|
+
Dir.children(projects_root).each do |project|
|
|
651
|
+
idx = File.join(projects_root, project, "INDEX.md")
|
|
652
|
+
texts["project:#{project}"] = File.read(idx) if File.exist?(idx)
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
texts
|
|
656
|
+
end
|
|
657
|
+
|
|
486
658
|
# Build a per-scope `nodes` map and surface IntentValidator.validate_graph
|
|
487
659
|
# findings as warn-level checks. Scope-aware (the caller already filtered
|
|
488
660
|
# `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# FrontmatterWriter — pure, minimal, style-preserving rewrite of the `sources:`
|
|
5
|
+
# and `chain:` arrays in an intent file's content string (intent 49).
|
|
6
|
+
#
|
|
7
|
+
# It rewrites ONLY those two arrays and leaves every other frontmatter line and
|
|
8
|
+
# the entire body byte-identical. It preserves each array's existing serialization
|
|
9
|
+
# style independently:
|
|
10
|
+
# - flow style: `sources: ["40", "1a"]` (or `[]`)
|
|
11
|
+
# - block style: a `sources:` line followed by ` - '1a'` item lines
|
|
12
|
+
# When the desired array equals the file's current value (same ids, same order),
|
|
13
|
+
# the content is returned UNCHANGED so a re-run produces no diff (idempotency).
|
|
14
|
+
#
|
|
15
|
+
# Pure: no file IO, no eval, no global/ENV state. The IO shell reads/writes files.
|
|
16
|
+
module FrontmatterWriter
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Rewrite `sources:`/`chain:` in `content`. `sources`/`chain` are the desired
|
|
20
|
+
# final arrays of id strings. Returns the new content (or the original when
|
|
21
|
+
# nothing changed). Only operates within the leading `---`...`---` frontmatter
|
|
22
|
+
# block; never touches the body.
|
|
23
|
+
def rewrite_arrays(content, sources:, chain:)
|
|
24
|
+
return content unless content.is_a?(String) && content.start_with?("---")
|
|
25
|
+
|
|
26
|
+
parts = content.split("---", 3)
|
|
27
|
+
return content if parts.length < 3
|
|
28
|
+
|
|
29
|
+
fm = parts[1]
|
|
30
|
+
body = parts[2]
|
|
31
|
+
|
|
32
|
+
fm = rewrite_one(fm, "sources", sources)
|
|
33
|
+
fm = rewrite_one(fm, "chain", chain)
|
|
34
|
+
|
|
35
|
+
"---#{fm}---#{body}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Rewrite a single `key:` array within the frontmatter text `fm`, preserving the
|
|
39
|
+
# key's existing flow-vs-block style. No-op when the key is absent or unchanged.
|
|
40
|
+
def rewrite_one(fm, key, desired)
|
|
41
|
+
lines = fm.lines
|
|
42
|
+
idx = lines.index { |l| l.match?(/\A#{Regexp.escape(key)}:\s/) || l.match?(/\A#{Regexp.escape(key)}:\s*\z/) }
|
|
43
|
+
return fm if idx.nil?
|
|
44
|
+
|
|
45
|
+
header = lines[idx]
|
|
46
|
+
if block_style?(lines, idx)
|
|
47
|
+
rewrite_block(lines, idx, key, desired)
|
|
48
|
+
else
|
|
49
|
+
rewrite_flow(lines, idx, header, key, desired)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The key is block style when its own line carries no inline value and the next
|
|
54
|
+
# non-blank line is a `-` list item.
|
|
55
|
+
def block_style?(lines, idx)
|
|
56
|
+
header = lines[idx]
|
|
57
|
+
inline = header.sub(/\A[^:]+:/, "").strip
|
|
58
|
+
return false unless inline.empty?
|
|
59
|
+
|
|
60
|
+
nxt = lines[idx + 1]
|
|
61
|
+
!nxt.nil? && nxt.match?(/\A\s*-\s/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Flow style: replace the inline array on the header line, preserving indentation
|
|
65
|
+
# and any trailing newline. No-op when the current ids already match `desired`.
|
|
66
|
+
def rewrite_flow(lines, idx, header, key, desired)
|
|
67
|
+
current = parse_flow(header)
|
|
68
|
+
return lines.join if current == desired
|
|
69
|
+
|
|
70
|
+
newline = header.end_with?("\n") ? "\n" : ""
|
|
71
|
+
lines[idx] = "#{key}: #{render_flow(desired)}#{newline}"
|
|
72
|
+
lines.join
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Parse the inline flow array from a `key: [ ... ]` header line.
|
|
76
|
+
def parse_flow(header)
|
|
77
|
+
inline = header.sub(/\A[^:]+:/, "").strip
|
|
78
|
+
return [] if inline.empty? || inline == "[]"
|
|
79
|
+
|
|
80
|
+
inline = inline.sub(/\A\[/, "").sub(/\]\z/, "")
|
|
81
|
+
inline.split(",").map { |t| t.strip.gsub(/\A['"]|['"]\z/, "") }.reject(&:empty?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_flow(ids)
|
|
85
|
+
return "[]" if ids.empty?
|
|
86
|
+
|
|
87
|
+
"[#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Block style: replace the contiguous `-` item lines following the header. No-op
|
|
91
|
+
# when the current ids already match `desired`. Preserves the item indentation
|
|
92
|
+
# and quoting style sampled from the existing first item.
|
|
93
|
+
def rewrite_block(lines, idx, key, desired)
|
|
94
|
+
last = idx
|
|
95
|
+
item_lines = []
|
|
96
|
+
(idx + 1).upto(lines.length - 1) do |i|
|
|
97
|
+
break unless lines[i].match?(/\A\s*-\s/)
|
|
98
|
+
|
|
99
|
+
item_lines << lines[i]
|
|
100
|
+
last = i
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
current = item_lines.map { |l| l.sub(/\A\s*-\s*/, "").strip.gsub(/\A['"]|['"]\z/, "") }
|
|
104
|
+
return lines.join if current == desired
|
|
105
|
+
|
|
106
|
+
indent, quote = block_item_shape(item_lines.first)
|
|
107
|
+
rendered = desired.map { |id| "#{indent}- #{quote}#{id}#{quote}\n" }
|
|
108
|
+
|
|
109
|
+
# When desired is empty, collapse the block to an inline `key: []` to keep YAML
|
|
110
|
+
# valid (a bare `key:` with no items parses as nil, not an empty array).
|
|
111
|
+
rendered = ["#{key}: []\n"] if desired.empty? && rendered.empty?
|
|
112
|
+
|
|
113
|
+
if desired.empty?
|
|
114
|
+
new_lines = lines[0...idx] + rendered + lines[(last + 1)..]
|
|
115
|
+
else
|
|
116
|
+
new_lines = lines[0...idx] + [lines[idx]] + rendered + lines[(last + 1)..]
|
|
117
|
+
end
|
|
118
|
+
new_lines.join
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Sample indentation and quote char from an existing block item line.
|
|
122
|
+
def block_item_shape(sample)
|
|
123
|
+
return ["", "'"] if sample.nil?
|
|
124
|
+
|
|
125
|
+
indent = sample[/\A\s*/].to_s
|
|
126
|
+
value = sample.sub(/\A\s*-\s*/, "").strip
|
|
127
|
+
quote = value.start_with?('"') ? '"' : "'"
|
|
128
|
+
[indent, quote]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -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
|