okf 2.1.0 → 2.2.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.okf/capabilities/agent-skill.md +112 -0
  3. data/.okf/capabilities/bundles-manager.md +144 -0
  4. data/.okf/capabilities/graph-server.md +678 -0
  5. data/.okf/capabilities/index.md +26 -0
  6. data/.okf/capabilities/library-api.md +82 -0
  7. data/.okf/capabilities/linter.md +83 -0
  8. data/.okf/capabilities/read-views.md +228 -0
  9. data/.okf/capabilities/render.md +66 -0
  10. data/.okf/capabilities/search.md +297 -0
  11. data/.okf/capabilities/validator.md +60 -0
  12. data/.okf/cli.md +214 -0
  13. data/.okf/design/browser-tests.md +211 -0
  14. data/.okf/design/core-shell-split.md +73 -0
  15. data/.okf/design/index.md +17 -0
  16. data/.okf/design/integration-first.md +140 -0
  17. data/.okf/design/packaging.md +65 -0
  18. data/.okf/design/ruby-floor.md +53 -0
  19. data/.okf/design/runtime-dependencies.md +82 -0
  20. data/.okf/design/search-engines.md +154 -0
  21. data/.okf/design/server-trust-boundary.md +139 -0
  22. data/.okf/index.md +40 -0
  23. data/.okf/log.md +724 -0
  24. data/.okf/model/bundle.md +47 -0
  25. data/.okf/model/concept.md +75 -0
  26. data/.okf/model/graph.md +59 -0
  27. data/.okf/model/index.md +9 -0
  28. data/.okf/model/skeleton.md +76 -0
  29. data/.okf/overview.md +87 -0
  30. data/.okf/registry.md +432 -0
  31. data/.okf/structure/format-layer.md +59 -0
  32. data/.okf/structure/index.md +22 -0
  33. data/.okf/structure/search.md +53 -0
  34. data/.okf/structure/the-analysers.md +60 -0
  35. data/.okf/structure/the-cli.md +99 -0
  36. data/.okf/structure/the-disk-shell.md +76 -0
  37. data/.okf/structure/the-model.md +81 -0
  38. data/.okf/structure/the-server.md +74 -0
  39. data/.okf/structure/the-skill.md +52 -0
  40. data/.okf/testing/adding-a-verb.md +76 -0
  41. data/.okf/testing/index.md +12 -0
  42. data/.okf/testing/the-harness.md +45 -0
  43. data/CHANGELOG.md +161 -16
  44. data/README.md +226 -17
  45. data/lib/okf/cli/command.rb +8 -3
  46. data/lib/okf/cli/registry.rb +306 -47
  47. data/lib/okf/cli.rb +1 -1
  48. data/lib/okf/registry.rb +448 -22
  49. data/lib/okf/render/graph/template.html.erb +6 -2
  50. data/lib/okf/server/hub.rb +6 -2
  51. data/lib/okf/skill/reference/cli/registry.md +49 -6
  52. data/lib/okf/skill/reference/cli.md +1 -1
  53. data/lib/okf/version.rb +1 -1
  54. metadata +46 -5
data/lib/okf/registry.rb CHANGED
@@ -6,7 +6,7 @@ require "pathname"
6
6
  module OKF
7
7
  # A persistent, ordered registry of bundle references — the kernel behind the
8
8
  # multi-bundle server. It is a plain JSON file (no database): the global one
9
- # under $OKF_HOME (default ~/.okf), or a project-local .okf-registry.json
9
+ # under $OKF_HOME (default ~/.okf), or a project-local .okf.json
10
10
  # discovered by walking up from cwd (see .load / .discover), which replaces the
11
11
  # global one while you stand in its tree. Either way `okf registry set`/`del`
12
12
  # and a later bare `okf server` share one on-disk list. Part of the shell — it
@@ -32,8 +32,11 @@ module OKF
32
32
  # original shape) still reads.
33
33
  class Registry
34
34
  # One registered bundle: a unique +slug+, the absolute +path+ on disk, and a
35
- # human-readable +title+ ("parent/dir").
36
- Entry = Struct.new(:slug, :path, :title)
35
+ # human-readable +title+ ("parent/dir"). +link+ names the Link an entry
36
+ # arrived through (nil for one this registry owns), and +origin+ the slug it
37
+ # carries in that linked file — the two differ only when the bare name was
38
+ # already taken here.
39
+ Entry = Struct.new(:slug, :path, :title, :link, :origin)
37
40
 
38
41
  # A named set of bundle sources: a unique +slug+ and an ordered list of
39
42
  # +members+ (bundle *or* group slugs), stored normalized. A group has no path
@@ -55,6 +58,14 @@ module OKF
55
58
  end
56
59
  end
57
60
 
61
+ # A pointer from the global registry to another registry file: a unique
62
+ # +slug+ and the absolute +registry+ path. Its bundles resolve through it at
63
+ # read time under their own slugs, so nothing is copied and nothing goes
64
+ # stale — the registry's "references, never content" rule one level up. Only
65
+ # the global registry follows links (see .load), which is why a linked file's
66
+ # own links are never read and there is no graph to cycle-guard.
67
+ Link = Struct.new(:slug, :registry)
68
+
58
69
  HOME_ENV = "OKF_HOME"
59
70
  DEFAULT_HOME = "~/.okf"
60
71
 
@@ -62,7 +73,21 @@ module OKF
62
73
  # working directory rather than read from $OKF_HOME. Its presence is the whole
63
74
  # state — no stored "local mode" flag — so a bare `okf server` inside a repo
64
75
  # serves that repo's bundles with no global setup.
65
- LOCAL_FILE = ".okf-registry.json"
76
+ LOCAL_FILE = ".okf.json"
77
+
78
+ # What that file used to be called. It is still discovered, because the file
79
+ # is *committed* — retiring the name outright would break every repository
80
+ # carrying one, to save eight characters. So the short name became canonical
81
+ # and this one keeps resolving; `okf registry` says so once, where a reader
82
+ # can act on it, and nothing else says anything.
83
+ LEGACY_LOCAL_FILE = ".okf-registry.json"
84
+
85
+ # Both names, in the order a single directory prefers them. Discovery reads
86
+ # this list *per directory* rather than sweeping the whole path for one name
87
+ # and then the other — otherwise a legacy file at the repo root would beat a
88
+ # `.okf.json` two levels down, and "the nearest one wins" would quietly mean
89
+ # something else.
90
+ LOCAL_FILES = [ LOCAL_FILE, LEGACY_LOCAL_FILE ].freeze
66
91
 
67
92
  # The lever that forces the global registry even when a local one is on the
68
93
  # path up from cwd. Set it (inline) and discovery is skipped — the escape hatch
@@ -100,8 +125,9 @@ module OKF
100
125
  end
101
126
 
102
127
  # The registry a run resolves to. Precedence, highest first: OKF_NO_DISCOVERY
103
- # forces the global one; else a `.okf-registry.json` discovered on the path
104
- # up from +cwd+ wins; else the global $OKF_HOME registry, exactly as before.
128
+ # forces the global one; else a `.okf.json` (or the legacy
129
+ # `.okf-registry.json`) discovered on the path up from +cwd+ wins; else the
130
+ # global $OKF_HOME registry, exactly as before.
105
131
  # +cwd+ nil ⇒ no discovery, so an embedding app that calls `load` with no
106
132
  # arguments keeps the global-only behavior — only the CLI opts in by passing
107
133
  # `cwd: Dir.pwd`. $OKF_HOME names *where the global registry lives*; it does
@@ -112,7 +138,10 @@ module OKF
112
138
  local = looking ? discover(cwd) : nil
113
139
  # A local registry anchors its relative paths on its own directory; the
114
140
  # global one has no common anchor, so it stays absolute (relative_base nil).
115
- new(local || path(home: home), relative_base: local && File.dirname(local))
141
+ # Links are the *global* registry's alone: a discovered local one parses
142
+ # and preserves them but does not resolve them, so depth is one by
143
+ # construction rather than by a limit anyone has to enforce.
144
+ new(local || path(home: home), relative_base: local && File.dirname(local), follow_links: local.nil?)
116
145
  end
117
146
 
118
147
  # Walk up from +start+ looking for a local registry; return its absolute path
@@ -120,8 +149,8 @@ module OKF
120
149
  def discover(start)
121
150
  dir = expand(start.to_s)
122
151
  loop do
123
- candidate = File.join(dir, LOCAL_FILE)
124
- return candidate if File.file?(candidate)
152
+ found = LOCAL_FILES.map { |name| File.join(dir, name) }.find { |candidate| File.file?(candidate) }
153
+ return found if found
125
154
 
126
155
  parent = File.dirname(dir)
127
156
  break if parent == dir
@@ -182,11 +211,16 @@ module OKF
182
211
  # (see .load). nil means an absolute-path registry — the global $OKF_HOME one,
183
212
  # and every library caller — so its behavior is exactly what it was before
184
213
  # relative storage existed.
185
- def initialize(path, relative_base: nil)
214
+ def initialize(path, relative_base: nil, follow_links: true)
186
215
  @path = path
187
216
  @relative_base = relative_base
217
+ @follow_links = follow_links
188
218
  @entries = []
189
219
  @groups = []
220
+ @links = []
221
+ @link_groups = []
222
+ @link_state = {}
223
+ @link_of = {}
190
224
  read
191
225
  end
192
226
 
@@ -212,7 +246,7 @@ module OKF
212
246
 
213
247
  # The group registered under +slug+ (already normalized, like #get), or nil.
214
248
  def group?(slug)
215
- @groups.find { |group| group.slug == slug }
249
+ @groups.find { |group| group.slug == slug } || @link_groups.find { |group| group.slug == slug }
216
250
  end
217
251
 
218
252
  # The default bundle a bare `okf server` selects: the first entry still on
@@ -244,6 +278,8 @@ module OKF
244
278
 
245
279
  entry = get(normalized)
246
280
  raise OKF::Error, "no such bundle: #{slug}" unless entry
281
+
282
+ refuse_linked(entry, "default to")
247
283
  unless File.directory?(entry.path)
248
284
  raise OKF::Error, "cannot default to #{entry.slug}: #{entry.path} is not a directory " \
249
285
  "(okf registry del #{entry.slug}, or restore it)"
@@ -263,6 +299,7 @@ module OKF
263
299
  entry = get(old) || group?(old)
264
300
  raise OKF::Error, "no such bundle or group: #{old_slug}" unless entry
265
301
 
302
+ refuse_linked(entry, "rename")
266
303
  slug = explicit_slug(new_slug, entry)
267
304
  entry.slug = slug
268
305
  # A member list stores slugs, so a rename that stopped at the entry would
@@ -281,7 +318,8 @@ module OKF
281
318
  chosen = default
282
319
  @entries.map do |entry|
283
320
  { slug: entry.slug, title: entry.title, dir: entry.path, mount: "/b/#{entry.slug}/",
284
- default: entry.equal?(chosen), missing: !File.directory?(entry.path) }
321
+ default: entry.equal?(chosen), missing: !File.directory?(entry.path),
322
+ link: entry.link, origin: entry.origin }
285
323
  end
286
324
  end
287
325
 
@@ -298,6 +336,7 @@ module OKF
298
336
  # file in the bundle to hand back its own basename.
299
337
  title = Bundle::Folder.label(root)
300
338
  entry = @entries.find { |candidate| candidate.path == root }
339
+ refuse_linked(entry, "register") if entry
301
340
  if entry
302
341
  entry.title = title
303
342
  entry.slug = explicit_slug(as, entry) if as
@@ -324,6 +363,7 @@ module OKF
324
363
  target = get(slug) ||
325
364
  @entries.find { |entry| entry.path == self.class.expand(slug.to_s) } ||
326
365
  (self.class.path_shaped?(slug) ? nil : get(self.class.normalize(slug)))
366
+ refuse_linked(target, "remove") if target
327
367
  if target
328
368
  @entries.delete(target)
329
369
  cascade_remove(target.slug)
@@ -337,6 +377,7 @@ module OKF
337
377
  group = self.class.path_shaped?(slug) ? nil : (group?(slug) || group?(self.class.normalize(slug)))
338
378
  return nil unless group
339
379
 
380
+ refuse_linked(group, "remove")
340
381
  @groups.delete(group)
341
382
  cascade_remove(group.slug)
342
383
  write
@@ -354,9 +395,13 @@ module OKF
354
395
  raise OKF::Error, "a group needs at least one member (okf registry group #{name} <@bundle…>)" if members.empty?
355
396
 
356
397
  members.each do |member|
357
- next if get(member) || group?(member)
398
+ found = get(member) || group?(member)
399
+ raise OKF::Error, "no such bundle or group: @#{member} (okf registry list)" unless found
358
400
 
359
- raise OKF::Error, "no such bundle or group: @#{member} (okf registry list)"
401
+ # A group stores slugs, and a linked slug lives only while its link
402
+ # resolves — holding one would dangle the group the moment the link goes,
403
+ # the same foreign key the default rule refused.
404
+ refuse_linked(found, "group")
360
405
  end
361
406
 
362
407
  group = group?(name)
@@ -383,6 +428,7 @@ module OKF
383
428
  group = group?(name)
384
429
  raise OKF::Error, "no such group: #{slug} (okf registry list)" unless group
385
430
 
431
+ refuse_linked(group, "ungroup")
386
432
  asks = normalize_members(member_asks)
387
433
  removed = group.members & asks
388
434
  group.members -= asks
@@ -405,6 +451,93 @@ module OKF
405
451
  entries
406
452
  end
407
453
 
454
+ # Point this registry at another registry file under +slug+: its bundles
455
+ # resolve through the pointer from now on, under their own slugs unless one
456
+ # is already taken here. Re-linking a slug re-points it. Persists, returns
457
+ # the Link.
458
+ #
459
+ # The target must exist *now* — a link is an explicit ask, and one typed at a
460
+ # path that is not a registry file is a typo worth catching at the keyboard
461
+ # rather than a silent empty section later. (A target that vanishes
462
+ # afterwards is a different case, and is tolerated: see #links_listing.)
463
+ def link(slug, target)
464
+ name = explicit_link_slug(slug)
465
+ registry = self.class.expand(target.to_s)
466
+ raise OKF::Error, "not a registry file: #{target}" unless File.file?(registry)
467
+ raise OKF::Error, "a registry cannot link itself: #{registry}" if registry == self.class.expand(@path)
468
+
469
+ existing = @links.find { |candidate| candidate.slug == name }
470
+ existing ? existing.registry = registry : @links << Link.new(name, registry)
471
+ write
472
+ refresh_links
473
+ @links.find { |candidate| candidate.slug == name }
474
+ end
475
+
476
+ # Drop the link +slug+ and every bundle that arrived through it. Returns the
477
+ # removed Link, or nil when nothing matched.
478
+ def unlink(slug)
479
+ name = self.class.normalize(slug)
480
+ link = @links.find { |candidate| candidate.slug == name }
481
+ return nil unless link
482
+
483
+ @links.delete(link)
484
+ write
485
+ refresh_links
486
+ link
487
+ end
488
+
489
+ # Copy bundles and groups out of another registry file into this one, under
490
+ # the slugs they carry there. +asks+ names bundles or groups in the source
491
+ # (bare, or as `@ref`); a group brings everything it reaches and is recreated
492
+ # here. +as+ renames the single thing asked for. Persists once, and returns
493
+ # { bundles: [ Entry… ], groups: [ Group… ] }.
494
+ #
495
+ # Import is the opposite trade from #link, and the pair is the point: a link
496
+ # holds a live pointer, so the other file keeps owning what it lends and can
497
+ # take it back; an import copies the reference and owns it from then on — the
498
+ # source can be deleted, moved or rewritten and nothing here notices. That is
499
+ # why the two disagree on a collision. A linked name was never chosen here, so
500
+ # #link_slug invents around it; an imported name lands in *this* file under
501
+ # this registry's own rules, so a collision is refused exactly as #rename's
502
+ # is. The gem may invent a name; it may not substitute one you chose, and
503
+ # naming a slug on the command line is choosing it.
504
+ #
505
+ # Nothing is applied until everything has been checked. Half an import is a
506
+ # registry the user has to unpick by hand, reported as a success — so every
507
+ # ask is resolved and refused against the current state first, and one #write
508
+ # publishes the lot. Imported rows append, so the default stays where it was.
509
+ def import(asks, from:, as: nil)
510
+ source = open_source(from)
511
+ # #normalize_members drops what normalizes to nothing, which is right for a
512
+ # group's members and wrong here: dropping one ask and importing the rest is
513
+ # the silent partial all-or-nothing exists to rule out, and the report would
514
+ # count the ones that landed as a success.
515
+ unusable = asks.find { |ask| self.class.normalize(ask).empty? }
516
+ raise OKF::Error, "not a usable slug: #{unusable} (letters and digits, please)" if unusable
517
+
518
+ names = normalize_members(asks)
519
+ raise OKF::Error, "nothing to import (name a bundle or a group from #{source.path})" if names.empty?
520
+
521
+ plan = []
522
+ names.each { |name| plan_import(source, name, nil, plan, []) }
523
+ rename_import(plan, names.first, as) if as
524
+ plan.each { |step| refuse_import(step, source.path) }
525
+ apply_import(plan)
526
+ end
527
+
528
+ # One row per link for `registry list`: the file it points at, how many
529
+ # bundles it contributed, and why it contributed none when it did. A target
530
+ # that is gone or unreadable is *reported*, never raised — one bad pointer
531
+ # must not take down the registry that holds it, the same tolerance a
532
+ # vanished bundle directory gets.
533
+ def links_listing
534
+ @links.map do |link|
535
+ state = @link_state[link.slug] || { bundles: 0, missing: false, unreadable: false }
536
+ { slug: link.slug, registry: link.registry, bundles: state[:bundles],
537
+ missing: state[:missing], unreadable: state[:unreadable] }
538
+ end
539
+ end
540
+
408
541
  # Persist the current state to disk. The mutating verbs write as a side effect
409
542
  # of the change; `save` is the public seam for the one caller that creates a
410
543
  # registry with nothing to change yet — `okf registry init`, materializing an
@@ -422,19 +555,27 @@ module OKF
422
555
  # write would flatten a newly-added in-tree bundle to an absolute path,
423
556
  # silently undoing the portability the base exists for.
424
557
  def reopen
425
- self.class.new(@path, relative_base: @relative_base)
558
+ self.class.new(@path, relative_base: @relative_base, follow_links: @follow_links)
426
559
  end
427
560
 
428
- # One row per group for `registry list`: its members and how many bundles it
429
- # resolves to (+resolved+ is nil when a hand-edited cycle makes it unanswerable).
561
+ # One row per group this registry's own first, then the ones that arrived
562
+ # through a link, each tagged with the +link+ it came from (nil for a group
563
+ # this registry owns). Members, and how many bundles it resolves to
564
+ # (+resolved+ is nil when a hand-edited cycle makes it unanswerable).
565
+ #
566
+ # One list, not two. #group? resolves a linked group, so the method that
567
+ # *enumerates* groups has to name it: a second listing for the linked half is
568
+ # how a caller comes to answer about a smaller set than the same object can
569
+ # resolve — the drift a sibling reading this method would inherit silently.
570
+ # A caller that wants only the editable ones filters on +link+.
430
571
  def groups_listing
431
- @groups.map do |group|
572
+ (@groups + @link_groups).map do |group|
432
573
  resolved = begin
433
574
  expand(group.slug).size
434
575
  rescue OKF::Error
435
576
  nil
436
577
  end
437
- { slug: group.slug, members: group.members.dup, resolved: resolved }
578
+ { slug: group.slug, members: group.members.dup, resolved: resolved, link: @link_of[group.slug] }
438
579
  end
439
580
  end
440
581
 
@@ -454,7 +595,9 @@ module OKF
454
595
  # a collision check has to see both lists.
455
596
  def taken_slugs(skip)
456
597
  @entries.reject { |entry| entry.equal?(skip) }.map(&:slug) +
457
- @groups.reject { |group| group.equal?(skip) }.map(&:slug)
598
+ @groups.reject { |group| group.equal?(skip) }.map(&:slug) +
599
+ @link_groups.reject { |group| group.equal?(skip) }.map(&:slug) +
600
+ @links.map(&:slug)
458
601
  end
459
602
 
460
603
  # An explicitly requested slug (--as, rename): normalized, and a collision
@@ -495,6 +638,13 @@ module OKF
495
638
  raise OKF::Error, "slug already taken: #{slug} names a bundle (rename or remove that entry first)"
496
639
  end
497
640
 
641
+ # A group a link brought in is the update path everywhere else — and here
642
+ # that is the trap: #write persists @groups alone, so mutating one reported
643
+ # success and dropped the change on the next read. A refusal, like every
644
+ # other write a link owns.
645
+ linked = @link_groups.find { |group| group.slug == slug }
646
+ refuse_linked(linked, "group") if linked
647
+
498
648
  slug
499
649
  end
500
650
 
@@ -577,7 +727,12 @@ module OKF
577
727
  @entries = rows.map { |row| entry_from(row) }
578
728
  group_rows = data.is_a?(Hash) ? Array(data["groups"]) : [] # a groups-less file has none
579
729
  @groups = group_rows.map { |row| group_from(row) }.reject { |group| group.members.empty? }
730
+ link_rows = data.is_a?(Hash) ? Array(data["links"]) : []
731
+ @links = link_rows.map { |row| link_from(row) }
580
732
  normalize_slugs
733
+ # After normalize_slugs, so a linked name is minted around the repaired
734
+ # local ones rather than around names nothing could reach.
735
+ resolve_links if @follow_links
581
736
  rescue JSON::ParserError => e
582
737
  malformed("#{e.message} (fix or delete the file)")
583
738
  rescue SystemCallError => e
@@ -634,6 +789,17 @@ module OKF
634
789
  Group.new(row["slug"], members)
635
790
  end
636
791
 
792
+ # One row to a Link, shape-checked like #entry_from and #group_from — the file
793
+ # is hand-editable, so a row missing its target must fail here rather than
794
+ # three frames away in a File.file? on nil.
795
+ def link_from(row)
796
+ unless row.is_a?(Hash) && row["slug"].is_a?(String) &&
797
+ row["registry"].is_a?(String) && !row["registry"].empty?
798
+ malformed('every link needs a "slug" and a "registry" path (fix or delete the file)')
799
+ end
800
+ Link.new(self.class.normalize(row["slug"]), resolve_stored(row["registry"]))
801
+ end
802
+
637
803
  # Slugs enter this list three ways — minted from a basename, asked for with
638
804
  # --as, and read from this file — and the first two normalize. The third did
639
805
  # not, and that asymmetry is the whole bug: the file could hold a name the
@@ -668,6 +834,261 @@ module OKF
668
834
  !slug.empty? && slug == self.class.normalize(slug) && !RESERVED_SLUGS.include?(slug)
669
835
  end
670
836
 
837
+ # Refuse a write aimed at something a link owns, naming the file that does own
838
+ # it. The refusal lives here rather than in the CLI because the graph server's
839
+ # bundles panel posts straight into these same methods — a guard one layer up
840
+ # would leave the browser doing what the terminal refuses.
841
+ def refuse_linked(target, action)
842
+ link = target.respond_to?(:link) ? target.link : @link_of[target.slug]
843
+ return unless link
844
+
845
+ owner = @links.find { |candidate| candidate.slug == link }
846
+ raise OKF::Error, "cannot #{action} @#{target.slug}: it comes from the linked registry at " \
847
+ "#{owner&.registry} (edit that file, or okf registry unlink #{link})"
848
+ end
849
+
850
+ # A link name: usable, not reserved, and not already a bundle's or a group's.
851
+ # Re-pointing an existing link keeps its name, so that is the one collision
852
+ # that is the update path rather than an error.
853
+ def explicit_link_slug(base)
854
+ slug = self.class.normalize(base)
855
+ raise OKF::Error, "not a usable slug: #{base} (letters and digits, please)" if slug.empty?
856
+
857
+ if RESERVED_SLUGS.include?(slug)
858
+ raise OKF::Error, "not a usable slug: #{slug} is reserved (@#{slug} names every registered bundle)"
859
+ end
860
+ return slug if @links.any? { |link| link.slug == slug }
861
+
862
+ if get(slug) || group?(slug)
863
+ raise OKF::Error, "slug already taken: #{slug} (rename or remove that entry first)"
864
+ end
865
+
866
+ slug
867
+ end
868
+
869
+ # Drop everything the links contributed and resolve them again — what a
870
+ # mutating link verb owes the in-memory instance, so the object it returns
871
+ # from agrees with the file it just wrote.
872
+ def refresh_links
873
+ @entries.reject!(&:link)
874
+ @link_groups = []
875
+ @link_state = {}
876
+ @link_of = {}
877
+ resolve_links
878
+ end
879
+
880
+ # Read each linked registry and fold its bundles (then its groups) into this
881
+ # one. Nothing is copied to disk: the entries carry their link, and #write
882
+ # leaves them out.
883
+ #
884
+ # The target is opened with `follow_links: false`, which is the whole of the
885
+ # depth rule — a linked file's own links are never read, so no chain forms and
886
+ # no cycle is possible. It is also anchored on its own directory, so a repo's
887
+ # committed `.okf.json` resolves its relative paths exactly as it
888
+ # would from inside that repo.
889
+ def resolve_links
890
+ @links.each do |link|
891
+ state = { bundles: 0, missing: false, unreadable: false }
892
+ @link_state[link.slug] = state
893
+ source = open_linked(link, state) or next
894
+
895
+ map = fold_linked_bundles(source, link)
896
+ state[:bundles] = map.size
897
+ @link_groups << Group.new(link.slug, map.values) unless map.empty?
898
+ @link_of[link.slug] = link.slug
899
+ fold_linked_groups(source, link, map)
900
+ end
901
+ end
902
+
903
+ # The linked registry, or nil after recording why there is none. A target that
904
+ # is gone or malformed is a reported state, not an exception: the registry that
905
+ # holds the link is still perfectly usable without it.
906
+ def open_linked(link, state)
907
+ unless File.file?(link.registry)
908
+ state[:missing] = true
909
+ return nil
910
+ end
911
+
912
+ self.class.new(link.registry, relative_base: File.dirname(link.registry), follow_links: false)
913
+ rescue OKF::Error
914
+ state[:unreadable] = true
915
+ nil
916
+ end
917
+
918
+ # The linked file's bundles, as entries of this registry. Returns the map from
919
+ # each bundle's slug *there* to the slug it answers to *here* — what the groups
920
+ # below are remapped through.
921
+ def fold_linked_bundles(source, link)
922
+ source.each_with_object({}) do |entry, map|
923
+ slug = link_slug(entry.slug, link.slug)
924
+ map[entry.slug] = slug
925
+ @entries << Entry.new(slug, entry.path, entry.title, link.slug, entry.slug)
926
+ end
927
+ end
928
+
929
+ # The linked file's own groups, so a link carries a file's curation rather than
930
+ # just its rows. Names are minted like the bundles above; members are remapped
931
+ # through +map+ (and through the group names, so a nested group survives), and
932
+ # a group left with nothing that resolved here is dropped.
933
+ def fold_linked_groups(source, link, map)
934
+ rows = source.groups_listing
935
+ names = rows.each_with_object({}) { |row, acc| acc[row[:slug]] = link_slug(row[:slug], link.slug) }
936
+ resolvable = map.merge(names)
937
+ rows.each do |row|
938
+ members = row[:members].map { |member| resolvable[member] }.compact
939
+ next if members.empty?
940
+
941
+ @link_groups << Group.new(names[row[:slug]], members)
942
+ @link_of[names[row[:slug]]] = link.slug
943
+ end
944
+ end
945
+
946
+ # The slug a linked name answers to here: its own when free, otherwise
947
+ # prefixed with the link it came from (and suffixed beyond that, through the
948
+ # same #dedupe a basename goes through). A name arriving from a file this
949
+ # registry does not own was never chosen here, so inventing around a collision
950
+ # is the forgiving half of the rule #explicit_slug keeps the strict half of —
951
+ # and refusing instead would let one foreign row take down the whole link.
952
+ def link_slug(slug, link_name)
953
+ base = self.class.slugify(slug)
954
+ taken = taken_slugs(nil) + RESERVED_SLUGS
955
+ return base unless taken.include?(base)
956
+
957
+ self.class.dedupe("#{link_name}-#{base}", taken)
958
+ end
959
+
960
+ # The registry an import reads from, opened once. It must exist *now* — an
961
+ # import is an explicit ask, and a path that is not a registry file is a typo
962
+ # worth catching at the keyboard.
963
+ #
964
+ # +relative_base+ is the source's own directory, unconditionally, and that is
965
+ # deliberately not .load's rule. The base does two jobs, and .load's nil for
966
+ # the global registry is the *write*-side one: #store_form must leave those
967
+ # paths absolute. A reader that never writes to the source has no stake in
968
+ # that half. On the read side the base answers only "what does a relative row
969
+ # in this file mean?", and for a file being read from another directory the
970
+ # one available answer is "beside that file". For a global-shaped source it is
971
+ # inert — every row #store_form wrote there is absolute and #resolve_stored
972
+ # short-circuits — so it bites only a hand-typed relative row, where anchoring
973
+ # beats resolving against whatever cwd happens to be.
974
+ #
975
+ # +follow_links+ is true, and has to be: a bundle that reached the source
976
+ # through a link lives in its @entries only after #resolve_links. It is also
977
+ # right. An import copies the path and holds nothing live, so the depth rule
978
+ # that makes #open_linked pass false — which bounds *live* resolution — has
979
+ # nothing to bound here, and depth is two at most, so a source that links back
980
+ # here still terminates.
981
+ def open_source(from)
982
+ registry = self.class.expand(from.to_s)
983
+ raise OKF::Error, "not a registry file: #{from}" unless File.file?(registry)
984
+
985
+ if registry == self.class.expand(@path)
986
+ raise OKF::Error, "the source and the target are the same registry: #{registry} (--from names another file)"
987
+ end
988
+
989
+ self.class.new(registry, relative_base: File.dirname(registry), follow_links: true)
990
+ end
991
+
992
+ # Walk one ask into +plan+: the bundle it names, or the group and everything
993
+ # that group reaches. A member that is itself a group comes too, asked for or
994
+ # not — recreating a group here without the group inside it would leave a name
995
+ # resolving to a smaller set than the same name resolves to there, which is
996
+ # the quiet substitution the slug rule exists to stop, and nothing on screen
997
+ # would say so. Members are planned before the set that holds them, so a
998
+ # refusal names the leaf that caused it rather than the group it hid in.
999
+ def plan_import(source, name, via, plan, chain)
1000
+ if chain.include?(name)
1001
+ raise OKF::Error, "group cycle in #{source.path}: " \
1002
+ "#{(chain + [ name ]).map { |slug| "@#{slug}" }.join(" → ")} (fix that file first)"
1003
+ end
1004
+ # After the cycle guard, never before: memoizing a second visit would
1005
+ # swallow the cycle it is there to catch.
1006
+ return if plan.any? { |step| step[:slug] == name }
1007
+
1008
+ entry = source.get(name)
1009
+ # The title comes across as it stands rather than re-derived from the path
1010
+ # the way #add derives it: it is what that file says this bundle is called.
1011
+ return plan << { slug: name, path: entry.path, title: entry.title, members: nil, via: via } if entry
1012
+
1013
+ group = source.group?(name)
1014
+ raise OKF::Error, "no such bundle or group in #{source.path}: @#{name}" unless group
1015
+
1016
+ # A member that dangles in the source names nothing there and would name
1017
+ # nothing here; importing it would only move the damage into a second file.
1018
+ members = group.members.select { |member| source.get(member) || source.group?(member) }
1019
+ raise OKF::Error, "@#{name} names nothing in #{source.path}: its members are gone" if members.empty?
1020
+
1021
+ members.each { |member| plan_import(source, member, name, plan, chain + [ name ]) }
1022
+ # Members are stored verbatim, and that is the payoff of preserving slugs: a
1023
+ # name means the same thing on both sides, so there is nothing to remap —
1024
+ # the work #fold_linked_groups must do precisely because a link mints names.
1025
+ plan << { slug: name, path: nil, title: nil, members: members, via: via }
1026
+ end
1027
+
1028
+ # +as+ renames the thing that was asked for, not the members it dragged in:
1029
+ # you named one bundle or one group, so one name changes. Applied before the
1030
+ # refusals, so the collision that matters is the one the new name would cause.
1031
+ def rename_import(plan, asked, as)
1032
+ root = plan.find { |step| step[:slug] == asked && step[:via].nil? }
1033
+ root[:slug] = explicit_slug(as, nil)
1034
+ end
1035
+
1036
+ # Every reason a step could be refused, checked while nothing has been
1037
+ # applied. What the row *is* comes before what it wants to be called: when
1038
+ # both collide, "that bundle is already here" is the answer and "the name is
1039
+ # taken" is only the symptom.
1040
+ def refuse_import(step, registry)
1041
+ # Which ask dragged this name in. Without it a group import refuses on a
1042
+ # slug the user never typed and nothing says where it came from.
1043
+ via = step[:via] ? ", a member of @#{step[:via]}" : ""
1044
+ unless step[:members]
1045
+ unless File.directory?(step[:path])
1046
+ raise OKF::Error, "cannot import @#{step[:slug]}#{via}: #{step[:path]} is not a directory " \
1047
+ "(restore it, or drop that entry from #{registry})"
1048
+ end
1049
+
1050
+ # #add would key on the path and rename that entry in place. Import must
1051
+ # not: the bundle is already yours under a name you chose, so taking the
1052
+ # source's name for it is the substitution the rule forbids — and skipping
1053
+ # it silently would report an import that did not happen.
1054
+ existing = @entries.find { |candidate| candidate.path == step[:path] }
1055
+ if existing
1056
+ raise OKF::Error, "cannot import @#{step[:slug]}#{via}: #{step[:path]} is already registered here " \
1057
+ "as #{existing.slug} (remove that entry first, or leave this one out)"
1058
+ end
1059
+ end
1060
+
1061
+ return unless taken_slugs(nil).include?(step[:slug])
1062
+
1063
+ raise OKF::Error, "slug already taken: #{step[:slug]}#{via} " \
1064
+ "(rename or remove that entry first, or import it under another name with --as)"
1065
+ end
1066
+
1067
+ # The plan, applied and persisted in one write. #add and #set_group are out of
1068
+ # reach for the one reason they share: both write, so N asks would be N files
1069
+ # on disk and a refusal in the middle would leave the earlier half applied.
1070
+ # What is left of either once the write is taken out is a constructor and a
1071
+ # push, which is what this is — the rest of #add answers questions import has
1072
+ # already answered, and differently.
1073
+ def apply_import(plan)
1074
+ imported = { bundles: [], groups: [] }
1075
+ plan.each do |step|
1076
+ if step[:members]
1077
+ group = Group.new(step[:slug], step[:members].dup)
1078
+ @groups << group
1079
+ imported[:groups] << group
1080
+ else
1081
+ # No link and no origin: an imported row is this registry's own from
1082
+ # here on, which is the whole difference between importing and linking.
1083
+ entry = Entry.new(step[:slug], step[:path], step[:title])
1084
+ @entries << entry
1085
+ imported[:bundles] << entry
1086
+ end
1087
+ end
1088
+ write
1089
+ imported
1090
+ end
1091
+
671
1092
  def malformed(detail)
672
1093
  raise OKF::Error, "malformed registry at #{@path}: #{detail}"
673
1094
  end
@@ -677,9 +1098,14 @@ module OKF
677
1098
  # Two racing writers stay last-writer-wins; the registry is a per-user file.
678
1099
  def write
679
1100
  FileUtils.mkdir_p(File.dirname(@path))
680
- rows = @entries.map { |entry| { "slug" => entry.slug, "path" => store_form(entry.path), "title" => entry.title } }
1101
+ # Only what this registry owns: a linked entry is a view onto another file,
1102
+ # and writing it here would be the copy the whole feature exists to avoid.
1103
+ rows = @entries.reject(&:link).map do |entry|
1104
+ { "slug" => entry.slug, "path" => store_form(entry.path), "title" => entry.title }
1105
+ end
681
1106
  groups = @groups.map { |group| { "slug" => group.slug, "members" => group.members } }
682
- payload = { "bundles" => rows, "groups" => groups }
1107
+ links = @links.map { |link| { "slug" => link.slug, "registry" => store_form(link.registry) } }
1108
+ payload = { "bundles" => rows, "groups" => groups, "links" => links }
683
1109
  tmp = "#{@path}.tmp-#{Process.pid}"
684
1110
  begin
685
1111
  File.write(tmp, JSON.pretty_generate(payload) + "\n")
@@ -3148,7 +3148,8 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
3148
3148
 
3149
3149
  function row(b){
3150
3150
  const cur=b.mount!==null&&b.mount===SELF_SLUG;
3151
- const pills=(b['default']?'<span class="ws-pill def">default</span>':'')+(cur?'<span class="ws-pill cur">current</span>':'');
3151
+ const pills=(b['default']?'<span class="ws-pill def">default</span>':'')+(cur?'<span class="ws-pill cur">current</span>':'')+
3152
+ (b.link?'<span class="ws-pill">via @'+esc(b.link)+'</span>':'');
3152
3153
  /* A row the hub could not load links nowhere, because there is nowhere to
3153
3154
  link — but it is still a row: "where did my bundle go?" is the question it
3154
3155
  exists to answer. */
@@ -3165,7 +3166,10 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
3165
3166
  '<div class="ws-meta">'+(where?'<span class="ws-where">'+esc(where)+'</span>':'')+count+
3166
3167
  '<span class="ws-health '+esc(b.health)+'">'+esc(b.word)+'</span></div>'+
3167
3168
  (editing===b.slug?edit(b):'')+(confirming===b.slug?confirm(b):'')+'</div>';
3168
- return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()?menuBtn(b):'')+'</div>';}
3169
+ /* No menu on a linked row: the registry that owns it is another file, so
3170
+ rename/remove/default are refused there and offering them here would be a
3171
+ menu of three errors. It is listed, opened and searched like any other. */
3172
+ return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()&&!b.link?menuBtn(b):'')+'</div>';}
3169
3173
 
3170
3174
  function menuBtn(b){
3171
3175
  return '<button class="ws-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" '+