@zalom/plastic 1.3.0 → 1.4.0
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-reference.md +8 -6
- package/PLASTIC.md +24 -3
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +164 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/lib/bridge.rb +29 -1
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +2 -1
- package/scripts/lib/installer_core.rb +30 -7
- package/scripts/lib/intent_validator.rb +38 -10
- package/scripts/lib/links_gate.rb +140 -0
- package/scripts/lib/links_projection.rb +71 -12
- package/scripts/lib/power_tools.rb +57 -14
- package/scripts/lib/project_validator.rb +113 -0
- package/scripts/lib/qmd_hook.rb +12 -8
- package/scripts/lib/restore_intent_v1.rb +154 -0
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +38 -10
- package/scripts/lib/store_discovery.rb +77 -0
- package/scripts/lib/store_provisioning.rb +21 -12
- package/scripts/new-intent +10 -12
- package/scripts/project-links +132 -35
- package/scripts/provision-project-store +18 -5
- package/scripts/read-config +1 -0
- package/scripts/rebuild-graph +42 -17
- package/scripts/restore-intent-v1 +288 -0
- package/scripts/roadmap-next +9 -2
- package/scripts/roadmap-savepoint +9 -1
- package/scripts/update.rb +50 -1
- package/scripts/validate-intent +3 -1
- package/scripts/validate-project +53 -0
- package/scripts/write-config +105 -0
- package/skills/auto/SKILL.md +16 -10
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/install/SKILL.md +4 -4
- package/skills/intent-creating/SKILL.md +5 -0
- package/skills/intent-ending/SKILL.md +49 -36
- package/skills/project-creating/SKILL.md +29 -1
- package/skills/releasing/SKILL.md +37 -19
- package/skills/roadmap/SKILL.md +9 -7
- package/skills/roadmap/references/file-format.md +14 -10
- package/skills/roadmap/references/operations.md +22 -18
- package/skills/roadmap-continuing/SKILL.md +5 -5
- package/skills/roadmap-continuing/evals/evals.json +3 -3
- package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
- package/skills/update/SKILL.md +30 -17
- package/templates/roadmap.md +8 -8
package/scripts/new-intent
CHANGED
|
@@ -29,6 +29,7 @@ require_relative "lib/intent_validator"
|
|
|
29
29
|
require_relative "lib/graph_rebuild"
|
|
30
30
|
require_relative "lib/links_projection"
|
|
31
31
|
require_relative "lib/links_section"
|
|
32
|
+
require_relative "lib/store_discovery"
|
|
32
33
|
|
|
33
34
|
# --- Explicit flag parsing (no eval, no global injection) ------------------
|
|
34
35
|
|
|
@@ -162,17 +163,12 @@ def store_context(store)
|
|
|
162
163
|
end
|
|
163
164
|
end
|
|
164
165
|
|
|
165
|
-
# The in-scope stores under `plastic_home`, each { key:, store: }.
|
|
166
|
-
#
|
|
166
|
+
# The in-scope stores under `plastic_home`, each { key:, store: }. Delegates to
|
|
167
|
+
# StoreDiscovery, the single source of truth also used by project-links, rebuild-graph,
|
|
168
|
+
# and doctor.rb (intent 189), so cross-store resolution spans EVERY real store, not a
|
|
169
|
+
# hardcoded few.
|
|
167
170
|
def family_stores(plastic_home)
|
|
168
|
-
|
|
169
|
-
global_store = File.join(plastic_home, "store")
|
|
170
|
-
list << { key: "global", store: global_store } if File.directory?(global_store)
|
|
171
|
-
%w[plastic knowdb].each do |slug|
|
|
172
|
-
store = File.join(plastic_home, "projects", slug, "store")
|
|
173
|
-
list << { key: "project:#{slug}", store: store } if File.directory?(store)
|
|
174
|
-
end
|
|
175
|
-
list
|
|
171
|
+
StoreDiscovery.discover(plastic_home)[:stores].map { |s| { key: s[:key], store: s[:store] } }
|
|
176
172
|
end
|
|
177
173
|
|
|
178
174
|
# Build the cross-store maps the LinksProjection resolver needs:
|
|
@@ -353,8 +349,10 @@ def main(argv)
|
|
|
353
349
|
# fire adds nothing.
|
|
354
350
|
Bridge.append_savepoint(intent_dir, intent_file)
|
|
355
351
|
|
|
356
|
-
# 7. Self-validate (frontmatter + sanctioned sections)
|
|
357
|
-
|
|
352
|
+
# 7. Self-validate (frontmatter + sanctioned sections), including that every
|
|
353
|
+
# sources/chain cross-store token names a real store (intent 189 D3).
|
|
354
|
+
known_stores = StoreDiscovery.known_slugs(plastic_home)
|
|
355
|
+
result = IntentValidator.validate(intent_dir, known_stores: known_stores)
|
|
358
356
|
unless result[:ok]
|
|
359
357
|
warn "new-intent: scaffolded intent is NOT born complete:"
|
|
360
358
|
result[:missing].each { |f| warn " missing field: #{f}" }
|
package/scripts/project-links
CHANGED
|
@@ -3,13 +3,26 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# project-links — project each intent's corrected sources/chain graph into its
|
|
6
|
-
# canonical I5 `## Links` section, store-wide across
|
|
7
|
-
#
|
|
8
|
-
# projected store changes zero files. Touches ONLY the
|
|
9
|
-
# intent file; frontmatter and every other section
|
|
6
|
+
# canonical I5 `## Links` section, store-wide across every discovered store
|
|
7
|
+
# (intent 72; intent 189 made discovery store-wide). Deterministic: a second
|
|
8
|
+
# run over a projected store changes zero files (idempotent). Touches ONLY the
|
|
9
|
+
# `## Links` section of each intent file; frontmatter and every other section
|
|
10
|
+
# stay byte-identical.
|
|
11
|
+
#
|
|
12
|
+
# PLASTIC.md's `## Links` contract has two halves: never hand-write a line, and
|
|
13
|
+
# NEVER AUTO-DELETE one. Before intent 192 this tool violated the second half:
|
|
14
|
+
# it rebuilt `## Links` purely from frontmatter, silently discarding any
|
|
15
|
+
# existing line with no sources/chain edge behind it, real relationship or
|
|
16
|
+
# not. Now: a line unbacked by frontmatter that still RESOLVES to a real,
|
|
17
|
+
# currently-discovered intent is an ORPHAN CANDIDATE, preserved by default and
|
|
18
|
+
# reported in the audit, never silently deleted. Pass --drop-unbacked-links to
|
|
19
|
+
# delete orphan candidates deliberately (reported separately from a silent
|
|
20
|
+
# drop). A line that resolves to nothing (a broken or mistyped wikilink) is
|
|
21
|
+
# still dropped silently, exactly as before: it names no real relationship to
|
|
22
|
+
# lose.
|
|
10
23
|
#
|
|
11
24
|
# Usage:
|
|
12
|
-
# project-links [--plastic-home PATH] [--dry-run] [--audit-path PATH]
|
|
25
|
+
# project-links [--plastic-home PATH] [--dry-run] [--audit-path PATH] [--drop-unbacked-links]
|
|
13
26
|
#
|
|
14
27
|
# Pure-Ruby (no bash). The pure logic lives in lib/links_projection.rb and
|
|
15
28
|
# lib/links_section.rb; this shell does only discovery, IO, and reporting. It reads
|
|
@@ -24,6 +37,7 @@ require_relative "lib/intent_validator"
|
|
|
24
37
|
require_relative "lib/graph_rebuild"
|
|
25
38
|
require_relative "lib/links_projection"
|
|
26
39
|
require_relative "lib/links_section"
|
|
40
|
+
require_relative "lib/store_discovery"
|
|
27
41
|
|
|
28
42
|
class ProjectLinks
|
|
29
43
|
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
@@ -32,9 +46,11 @@ class ProjectLinks
|
|
|
32
46
|
DEFAULT_AUDIT_REL =
|
|
33
47
|
"projects/plastic/store/72--links-graph-projection/resources/audit--links-projection.md"
|
|
34
48
|
|
|
35
|
-
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil
|
|
49
|
+
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil,
|
|
50
|
+
drop_unbacked_links: false)
|
|
36
51
|
@plastic_home = plastic_home
|
|
37
52
|
@dry_run = dry_run
|
|
53
|
+
@drop_unbacked_links = drop_unbacked_links
|
|
38
54
|
|
|
39
55
|
# A dry run must NOT stomp the canonical audit (humans run --dry-run to review
|
|
40
56
|
# the plan). With no explicit --audit-path, a dry run writes a `.dry-run.md`
|
|
@@ -52,26 +68,23 @@ class ProjectLinks
|
|
|
52
68
|
end
|
|
53
69
|
end
|
|
54
70
|
|
|
55
|
-
attr_reader :plastic_home, :dry_run, :audit_path
|
|
71
|
+
attr_reader :plastic_home, :dry_run, :audit_path, :drop_unbacked_links
|
|
56
72
|
|
|
57
|
-
#
|
|
73
|
+
# Every store in scope: global plus every projects/<slug>/store directory that exists
|
|
74
|
+
# (intent 189). Shares its definition with rebuild-graph, doctor.rb, and new-intent via
|
|
75
|
+
# StoreDiscovery, so this tool can never again disagree with doctor about what exists.
|
|
58
76
|
def stores
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
77
|
+
discovery[:stores]
|
|
78
|
+
end
|
|
65
79
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
# Registered projects.yml slugs with no store directory on disk (intent 189 D5).
|
|
81
|
+
# Reported in the audit rather than silently treated as an empty store.
|
|
82
|
+
def missing_stores
|
|
83
|
+
discovery[:missing]
|
|
84
|
+
end
|
|
70
85
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
end
|
|
74
|
-
list
|
|
86
|
+
def discovery
|
|
87
|
+
@discovery ||= StoreDiscovery.discover(plastic_home)
|
|
75
88
|
end
|
|
76
89
|
|
|
77
90
|
# { id => { sources:, chain:, basename:, label:, path: } } for one store.
|
|
@@ -131,7 +144,8 @@ class ProjectLinks
|
|
|
131
144
|
end
|
|
132
145
|
|
|
133
146
|
# Project every intent in ONE store. Returns
|
|
134
|
-
# { entries: [ {id:, status:, before:, after:, error
|
|
147
|
+
# { entries: [ {id:, status:, before:, after:, error:, orphans_preserved:,
|
|
148
|
+
# orphans_dropped_optin:} ], counts: {...} }.
|
|
135
149
|
def project_store(referer_store, nodes, relocation_map:, store_index:, node_index:)
|
|
136
150
|
entries = []
|
|
137
151
|
nodes.each do |id, node|
|
|
@@ -143,38 +157,89 @@ class ProjectLinks
|
|
|
143
157
|
end
|
|
144
158
|
|
|
145
159
|
content = File.read(node[:path])
|
|
160
|
+
old_text = extract_links(content) # never raises; ambiguous -> a placeholder string
|
|
146
161
|
|
|
147
162
|
begin
|
|
148
163
|
had_links = LinksSection.links_heading?(IntentValidator.body_of(content))
|
|
149
|
-
|
|
164
|
+
canonical_text = LinksProjection.section(
|
|
150
165
|
sources: node[:sources], chain: node[:chain], resolve: resolve
|
|
151
166
|
)
|
|
167
|
+
kept, dropped_optin = orphan_split(old_text, canonical_text, referer_store,
|
|
168
|
+
store_index, node_index)
|
|
169
|
+
section_text = LinksProjection.render_entries(
|
|
170
|
+
LinksProjection.parse_entries(canonical_text) + kept
|
|
171
|
+
)
|
|
152
172
|
updated = LinksSection.rewrite(content, section_text)
|
|
153
173
|
rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
|
|
154
|
-
entries << { id: id, status: :failed, error: e.message
|
|
174
|
+
entries << { id: id, status: :failed, error: e.message,
|
|
175
|
+
orphans_preserved: [], orphans_dropped_optin: [] }
|
|
155
176
|
next
|
|
156
177
|
end
|
|
157
178
|
|
|
158
179
|
if updated == content
|
|
159
|
-
entries << { id: id, status: :unchanged
|
|
180
|
+
entries << { id: id, status: :unchanged,
|
|
181
|
+
orphans_preserved: kept, orphans_dropped_optin: dropped_optin }
|
|
160
182
|
next
|
|
161
183
|
end
|
|
162
184
|
|
|
163
185
|
status = had_links ? :regenerated : :added
|
|
164
186
|
File.write(node[:path], updated) unless dry_run
|
|
165
|
-
entries << { id: id, status: status,
|
|
166
|
-
|
|
187
|
+
entries << { id: id, status: status, before: old_text, after: section_text,
|
|
188
|
+
orphans_preserved: kept, orphans_dropped_optin: dropped_optin }
|
|
167
189
|
end
|
|
168
190
|
|
|
169
191
|
counts = entries.each_with_object(Hash.new(0)) { |e, h| h[e[:status]] += 1 }
|
|
170
192
|
{ entries: entries, counts: counts }
|
|
171
193
|
end
|
|
172
194
|
|
|
195
|
+
# Split the OLD (pre-mutation) `## Links` entries into orphans to KEEP
|
|
196
|
+
# (unbacked by frontmatter but resolve to a real, currently-discovered
|
|
197
|
+
# intent) vs DROP under the explicit --drop-unbacked-links opt-in. An entry
|
|
198
|
+
# that resolves nowhere is neither: it is dropped silently, exactly as before
|
|
199
|
+
# intent 192 (this is dealintell 3b's three broken single-dash lines:
|
|
200
|
+
# garbage, not data). Returns [kept_entries, dropped_optin_entries], both
|
|
201
|
+
# [{target:, label:}].
|
|
202
|
+
def orphan_split(old_text, canonical_text, referer_store, store_index, node_index)
|
|
203
|
+
canonical_targets = LinksProjection.parse_entries(canonical_text).map { |e| e[:target] }
|
|
204
|
+
kept = []
|
|
205
|
+
dropped_optin = []
|
|
206
|
+
LinksProjection.parse_entries(old_text).each do |oe|
|
|
207
|
+
next if canonical_targets.include?(oe[:target]) # already backed; not an orphan
|
|
208
|
+
|
|
209
|
+
label = resolve_orphan_label(oe[:target], referer_store, store_index, node_index)
|
|
210
|
+
next if label.nil? # dead: not backed AND does not resolve; silent drop, as before
|
|
211
|
+
|
|
212
|
+
if drop_unbacked_links
|
|
213
|
+
dropped_optin << oe
|
|
214
|
+
else
|
|
215
|
+
kept << { target: oe[:target], label: label }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
[kept, dropped_optin]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Does `target` (already in projected `id--slug` or `store:id--slug` form)
|
|
222
|
+
# name a real, currently-discovered intent? Returns its CURRENT canonical
|
|
223
|
+
# label when so (self-healing a stale hand-typed label), else nil (genuinely
|
|
224
|
+
# dead/broken, e.g. 3b's single-dash mismatches that no real basename
|
|
225
|
+
# matches).
|
|
226
|
+
def resolve_orphan_label(target, referer_store, store_index, node_index)
|
|
227
|
+
if target.include?(":")
|
|
228
|
+
slug, basename = target.split(":", 2)
|
|
229
|
+
key = LinksProjection.canonical_store_key(slug, store_index)
|
|
230
|
+
else
|
|
231
|
+
key = referer_store
|
|
232
|
+
basename = target
|
|
233
|
+
end
|
|
234
|
+
node = (node_index[key] || {}).values.find { |v| v[:basename] == basename }
|
|
235
|
+
node && node[:label]
|
|
236
|
+
end
|
|
237
|
+
|
|
173
238
|
# Pull the current REAL `## Links` section text (fence-aware) from a file's
|
|
174
|
-
# content, for the audit sample
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
# code fence).
|
|
239
|
+
# content, for the audit sample and for orphan-candidate detection. Returns
|
|
240
|
+
# "" when absent. Delegates to the shared LinksSection.extract_section so the
|
|
241
|
+
# audit, the rewriter, and the doctor check all agree on the section
|
|
242
|
+
# location (and never match a heading inside an example code fence).
|
|
178
243
|
def extract_links(content)
|
|
179
244
|
LinksSection.extract_section(IntentValidator.body_of(content))
|
|
180
245
|
rescue LinksSection::AmbiguousLinks
|
|
@@ -207,11 +272,22 @@ class ProjectLinks
|
|
|
207
272
|
total = STATUS_ORDER.to_h do |st|
|
|
208
273
|
[st, store_list.sum { |s| results[s[:key]][:counts][st] }]
|
|
209
274
|
end
|
|
275
|
+
total_preserved = store_list.sum { |s| results[s[:key]][:entries].sum { |e| Array(e[:orphans_preserved]).size } }
|
|
276
|
+
total_dropped_optin = store_list.sum { |s| results[s[:key]][:entries].sum { |e| Array(e[:orphans_dropped_optin]).size } }
|
|
210
277
|
lines << "Totals across all stores: " \
|
|
211
278
|
"regenerated #{total[:regenerated]}, added #{total[:added]}, " \
|
|
212
|
-
"unchanged #{total[:unchanged]}, failed #{total[:failed]}."
|
|
279
|
+
"unchanged #{total[:unchanged]}, failed #{total[:failed]}. " \
|
|
280
|
+
"Unbacked Links lines preserved as orphan candidates: #{total_preserved}" \
|
|
281
|
+
"#{drop_unbacked_links ? ", dropped via --drop-unbacked-links: #{total_dropped_optin}" : ""}."
|
|
213
282
|
lines << ""
|
|
214
283
|
|
|
284
|
+
unless missing_stores.empty?
|
|
285
|
+
lines << "## Registered projects with no store on disk"
|
|
286
|
+
lines << ""
|
|
287
|
+
missing_stores.each { |m| lines << "- #{m[:slug]} (#{m[:project_dir]})" }
|
|
288
|
+
lines << ""
|
|
289
|
+
end
|
|
290
|
+
|
|
215
291
|
store_list.each do |s|
|
|
216
292
|
key = s[:key]
|
|
217
293
|
res = results[key]
|
|
@@ -229,6 +305,22 @@ class ProjectLinks
|
|
|
229
305
|
lines << ""
|
|
230
306
|
end
|
|
231
307
|
|
|
308
|
+
preserved = res[:entries].flat_map { |e| Array(e[:orphans_preserved]).map { |o| [e[:id], o] } }
|
|
309
|
+
unless preserved.empty?
|
|
310
|
+
lines << "### Orphan candidates preserved (#{preserved.size})"
|
|
311
|
+
lines << "Unbacked by sources/chain but resolve to a real intent; add the frontmatter " \
|
|
312
|
+
"edge if the relationship is real, or re-run with --drop-unbacked-links if not."
|
|
313
|
+
preserved.each { |id, o| lines << "- #{id}: -> #{o[:target]}" }
|
|
314
|
+
lines << ""
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
dropped_optin = res[:entries].flat_map { |e| Array(e[:orphans_dropped_optin]).map { |o| [e[:id], o] } }
|
|
318
|
+
unless dropped_optin.empty?
|
|
319
|
+
lines << "### Orphan candidates DROPPED (--drop-unbacked-links) (#{dropped_optin.size})"
|
|
320
|
+
dropped_optin.each { |id, o| lines << "- #{id}: -> #{o[:target]}" }
|
|
321
|
+
lines << ""
|
|
322
|
+
end
|
|
323
|
+
|
|
232
324
|
sample = res[:entries].select { |e| %i[regenerated added].include?(e[:status]) }.first(5)
|
|
233
325
|
next if sample.empty?
|
|
234
326
|
|
|
@@ -263,17 +355,22 @@ if $PROGRAM_NAME == __FILE__
|
|
|
263
355
|
home = ProjectLinks::DEFAULT_HOME
|
|
264
356
|
dry = false
|
|
265
357
|
audit = nil
|
|
358
|
+
drop = false
|
|
266
359
|
i = 0
|
|
267
360
|
while i < ARGV.length
|
|
268
361
|
case ARGV[i]
|
|
269
362
|
when "--plastic-home" then home = ARGV[i + 1]; i += 2
|
|
270
363
|
when "--dry-run" then dry = true; i += 1
|
|
271
364
|
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
272
|
-
|
|
365
|
+
when "--drop-unbacked-links" then drop = true; i += 1
|
|
366
|
+
else
|
|
367
|
+
abort "project-links: unknown argument #{ARGV[i].inspect} " \
|
|
368
|
+
"(usage: --plastic-home PATH | --dry-run | --audit-path PATH | --drop-unbacked-links)"
|
|
273
369
|
end
|
|
274
370
|
end
|
|
275
371
|
|
|
276
|
-
tool = ProjectLinks.new(plastic_home: home, dry_run: dry, audit_path: audit
|
|
372
|
+
tool = ProjectLinks.new(plastic_home: home, dry_run: dry, audit_path: audit,
|
|
373
|
+
drop_unbacked_links: drop)
|
|
277
374
|
results = tool.run
|
|
278
375
|
totals = ProjectLinks::STATUS_ORDER.to_h do |st|
|
|
279
376
|
[st, results.values.sum { |r| r[:counts][st] }]
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
# nothing.
|
|
13
13
|
#
|
|
14
14
|
# Usage:
|
|
15
|
-
# provision-project-store <slug> [--home <path>]
|
|
15
|
+
# provision-project-store <slug> [--home <path>] [--package-root <path>]
|
|
16
16
|
#
|
|
17
17
|
# Exit codes: 0 (store provisioned), 1 (unregistered slug / error on stderr),
|
|
18
18
|
# 2 (usage). --home <path> overrides the Plastic home (default: ~/.plastic).
|
|
19
|
+
# --package-root <path> overrides where templates/ resolves from (default:
|
|
20
|
+
# StoreProvisioning::PACKAGE_ROOT, the repo root); mainly for hermetic tests.
|
|
19
21
|
|
|
20
22
|
require_relative "lib/store_provisioning"
|
|
21
23
|
|
|
@@ -27,22 +29,33 @@ def plastic_home(args)
|
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
|
|
32
|
+
def package_root(args)
|
|
33
|
+
if (i = args.index("--package-root")) && args[i + 1]
|
|
34
|
+
File.expand_path(args[i + 1])
|
|
35
|
+
else
|
|
36
|
+
StoreProvisioning::PACKAGE_ROOT
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
home = plastic_home(ARGV)
|
|
41
|
+
root = package_root(ARGV)
|
|
31
42
|
|
|
32
|
-
# First positional arg, skipping any flag and the value that follows --home
|
|
43
|
+
# First positional arg, skipping any flag and the value that follows --home
|
|
44
|
+
# or --package-root.
|
|
33
45
|
home_value_index = (i = ARGV.index("--home")) ? i + 1 : nil
|
|
46
|
+
package_root_value_index = (i = ARGV.index("--package-root")) ? i + 1 : nil
|
|
34
47
|
slug_index = ARGV.each_index.find do |idx|
|
|
35
48
|
arg = ARGV[idx]
|
|
36
|
-
!arg.start_with?("--") && idx != home_value_index
|
|
49
|
+
!arg.start_with?("--") && idx != home_value_index && idx != package_root_value_index
|
|
37
50
|
end
|
|
38
51
|
slug = slug_index && ARGV[slug_index]
|
|
39
52
|
|
|
40
53
|
if slug.nil?
|
|
41
|
-
warn "usage: provision-project-store <slug> [--home <path>]"
|
|
54
|
+
warn "usage: provision-project-store <slug> [--home <path>] [--package-root <path>]"
|
|
42
55
|
exit 2
|
|
43
56
|
end
|
|
44
57
|
|
|
45
|
-
result = StoreProvisioning.provision(slug, plastic_home: home)
|
|
58
|
+
result = StoreProvisioning.provision(slug, plastic_home: home, package_root: root)
|
|
46
59
|
|
|
47
60
|
if result[:ok]
|
|
48
61
|
puts "OK: #{result[:store_dir]}"
|
package/scripts/read-config
CHANGED
package/scripts/rebuild-graph
CHANGED
|
@@ -24,6 +24,7 @@ require "fileutils"
|
|
|
24
24
|
require_relative "lib/graph_rebuild"
|
|
25
25
|
require_relative "lib/frontmatter_writer"
|
|
26
26
|
require_relative "lib/intent_validator"
|
|
27
|
+
require_relative "lib/store_discovery"
|
|
27
28
|
|
|
28
29
|
class RebuildGraph
|
|
29
30
|
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
@@ -65,23 +66,24 @@ class RebuildGraph
|
|
|
65
66
|
|
|
66
67
|
attr_reader :plastic_home, :dry_run, :audit_path
|
|
67
68
|
|
|
68
|
-
#
|
|
69
|
-
#
|
|
69
|
+
# Every store in scope: global plus every projects/<slug>/store directory that exists
|
|
70
|
+
# (intent 189). A superset of reality, not a hardcoded list: missing a real store here
|
|
71
|
+
# means a live cross-store ref into it gets classified :dead and DELETED (the exact bug
|
|
72
|
+
# this discovery fixes); including one nobody registered is harmless. Single source of
|
|
73
|
+
# truth is StoreDiscovery, shared with project-links, doctor.rb, and new-intent.
|
|
70
74
|
def stores
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
end
|
|
84
|
-
list
|
|
75
|
+
discovery[:stores]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Registered projects.yml slugs with no store directory on disk (legal;
|
|
79
|
+
# plastic-store-provisioning exists for exactly this state). Reported in the audit
|
|
80
|
+
# (intent 189 D5) so this never looks like a silent empty store.
|
|
81
|
+
def missing_stores
|
|
82
|
+
discovery[:missing]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def discovery
|
|
86
|
+
@discovery ||= StoreDiscovery.discover(plastic_home)
|
|
85
87
|
end
|
|
86
88
|
|
|
87
89
|
# { id => { sources:, chain:, path: } } for one store.
|
|
@@ -169,14 +171,33 @@ class RebuildGraph
|
|
|
169
171
|
# PURE-ish formatter (string from results). Per-store, grouped by kind.
|
|
170
172
|
def render_audit(store_list, results)
|
|
171
173
|
total = store_list.sum { |s| results[s[:key]][:changes].size }
|
|
174
|
+
preserved_unknown = store_list.flat_map { |s| results[s[:key]][:preserved] }
|
|
172
175
|
lines = []
|
|
173
176
|
lines << "# Audit: store-wide sources/chain graph rebuild (intent 49)"
|
|
174
177
|
lines << ""
|
|
175
178
|
lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
|
|
176
179
|
lines << ""
|
|
177
180
|
lines << "Total changes across all stores: #{total}"
|
|
181
|
+
lines << "Unknown-store refs preserved (NOT dropped): #{preserved_unknown.size}"
|
|
178
182
|
lines << ""
|
|
179
183
|
|
|
184
|
+
unless preserved_unknown.empty?
|
|
185
|
+
lines << "## Unknown-store refs preserved (store not recognized this run; check " \
|
|
186
|
+
"projects.yml and store discovery before trusting a zero-change result)"
|
|
187
|
+
lines << ""
|
|
188
|
+
preserved_unknown.each do |p|
|
|
189
|
+
lines << "- #{p[:intent]}.#{p[:field]}: #{p[:ref]} (store #{p[:store].inspect} not recognized)"
|
|
190
|
+
end
|
|
191
|
+
lines << ""
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
unless missing_stores.empty?
|
|
195
|
+
lines << "## Registered projects with no store on disk"
|
|
196
|
+
lines << ""
|
|
197
|
+
missing_stores.each { |m| lines << "- #{m[:slug]} (#{m[:project_dir]})" }
|
|
198
|
+
lines << ""
|
|
199
|
+
end
|
|
200
|
+
|
|
180
201
|
store_list.each do |s|
|
|
181
202
|
key = s[:key]
|
|
182
203
|
changes = results[key][:changes]
|
|
@@ -232,13 +253,17 @@ if $PROGRAM_NAME == __FILE__
|
|
|
232
253
|
when "--plastic-home" then home = ARGV[i + 1]; i += 2
|
|
233
254
|
when "--dry-run" then dry = true; i += 1
|
|
234
255
|
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
235
|
-
else
|
|
256
|
+
else
|
|
257
|
+
abort "rebuild-graph: unknown argument #{ARGV[i].inspect} " \
|
|
258
|
+
"(usage: --plastic-home PATH | --dry-run | --audit-path PATH)"
|
|
236
259
|
end
|
|
237
260
|
end
|
|
238
261
|
|
|
239
262
|
tool = RebuildGraph.new(plastic_home: home, dry_run: dry, audit_path: audit)
|
|
240
263
|
results = tool.run
|
|
241
264
|
total = results.values.sum { |r| r[:changes].size }
|
|
265
|
+
preserved = results.values.sum { |r| r[:preserved].size }
|
|
242
266
|
puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
|
|
267
|
+
puts "Unknown-store refs preserved (not dropped): #{preserved}" if preserved.positive?
|
|
243
268
|
puts "Audit: #{tool.audit_path}"
|
|
244
269
|
end
|