okf 1.11.0 → 1.13.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.
@@ -190,22 +190,52 @@ module OKF
190
190
  parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
191
191
  end
192
192
 
193
- def filter_entries(entries, options)
193
+ # The argument is resolved once per view, not once per entry: the `root`
194
+ # alias below has to consult the bundle's own directories, and that is a
195
+ # question about the whole set rather than about the row in hand.
196
+ #
197
+ # +dirs+ is that set, and every in-tree caller passes it (see #dir_scope)
198
+ # rather than deriving it from +entries+ — the catalog knows only
199
+ # directories that hold *concepts*, so a `root/` carrying an index.md and
200
+ # nothing else was invisible here while `dirs` and `index` (which read
201
+ # Bundle#directories) saw it. Two answers to one question about one
202
+ # bundle is how the alias survived its own fix.
203
+ #
204
+ # The default is a compatibility promise, not a shortcut: this base is
205
+ # what every plugin verb inherits, and 1.12.0 shipped the two-argument
206
+ # shape. An out-of-tree caller that never learned the third argument gets
207
+ # exactly the resolution it was written against — the alias folds with no
208
+ # directory set consulted — no better and no worse.
209
+ def filter_entries(entries, options, dirs = nil)
210
+ area = options[:area] && fold_area(options[:area], dirs)
211
+ base = options[:dir] && fold_dir(options[:dir], dirs)
194
212
  entries.select do |entry|
195
213
  (options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
196
- (options[:area].nil? || fold(entry[:area]) == fold_area(options[:area])) &&
197
- (options[:dir].nil? || under_dir?(entry[:dir], options[:dir])) &&
214
+ (area.nil? || fold(entry[:top_dir]) == area) &&
215
+ (base.nil? || under_dir?(entry[:dir], base)) &&
198
216
  (options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
199
217
  end
200
218
  end
201
219
 
220
+ # The directory set only when a flag is going to consult it. Deriving it
221
+ # walks every path up to the root, and #filter_entries reads it solely to
222
+ # resolve the `root`/`--area` aliases — a plain listing, or a type/tag
223
+ # narrowing, never needs the walk.
224
+ def dir_scope(folder, options)
225
+ options[:dir] || options[:area] ? folder.directories : nil
226
+ end
227
+
202
228
  # The one rule --dir is built on: a dir names itself and everything beneath
203
229
  # it. `--dir foo` reaches foo/bar, `--dir foo/bar` narrows, and `--dir .`
204
230
  # needs no special case at all — nothing starts with "./", so the root
205
231
  # selects only what lives directly in it.
206
- def under_dir?(entry_dir, wanted)
232
+ #
233
+ # +path+ arrives already folded, through #fold_dir for a user's argument
234
+ # and #fold for a stored one. A stored dir is never an alias — that is the
235
+ # distinction the old signature could not make, and it is what had a row
236
+ # named `root` counting the bundle root's subtree instead of its own.
237
+ def under_dir?(entry_dir, path)
207
238
  entry = fold(entry_dir)
208
- path = fold_dir(wanted)
209
239
  entry == path || entry.start_with?("#{path}/")
210
240
  end
211
241
 
@@ -213,16 +243,34 @@ module OKF
213
243
  value.to_s.downcase
214
244
  end
215
245
 
216
- def fold_area(value)
246
+ def fold_area(value, known = nil)
217
247
  folded = trim_slash(fold(value))
218
- folded == "root" ? "(root)" : folded
248
+ folded == "root" && !names_dir?(known, "root") ? "(root)" : folded
219
249
  end
220
250
 
221
251
  # `.` is the stored spelling of the root everywhere; `root` is the one a
222
252
  # shell needs no quoting for, and the only reason the two exist.
223
- def fold_dir(value)
253
+ #
254
+ # A bundle that really has a `root/` directory owns the word, and +known+
255
+ # — the directories that bundle actually holds — is what decides. The
256
+ # alias is a convenience; being able to name a directory at all is not, so
257
+ # the convenience yields. Without this, `--dir root` answered for the
258
+ # bundle root in such a bundle: the wrong concepts, exit 0, nothing said.
259
+ def fold_dir(value, known = nil)
224
260
  folded = trim_slash(fold(value))
225
- folded.empty? || folded == "root" ? "." : folded
261
+ return "." if folded.empty?
262
+
263
+ folded == "root" && !names_dir?(known, "root") ? "." : folded
264
+ end
265
+
266
+ # Does the bundle hold a directory by this name? An ancestor counts: a
267
+ # bundle whose only concept sits in `root/deep` still has a `root`, and
268
+ # `--dir root` has to reach it by the prefix rule above.
269
+ def names_dir?(known, name)
270
+ Array(known).any? do |dir|
271
+ folded = fold(dir)
272
+ folded == name || folded.start_with?("#{name}/")
273
+ end
226
274
  end
227
275
 
228
276
  # The human views print a directory with the slash that says it is one —
@@ -307,7 +355,7 @@ module OKF
307
355
 
308
356
  stored = known.each_with_object({}) { |dir, out| out[fold(dir)] = dir }
309
357
  Array(options[:dirs]).each_with_object([]) do |path, out|
310
- base = fold_dir(path)
358
+ base = fold_dir(path, known)
311
359
  next unless stored.key?(base)
312
360
 
313
361
  current = dir_parent(base)
@@ -331,7 +379,7 @@ module OKF
331
379
  # they do not define one. The ancestor chain is unioned on top by the
332
380
  # caller, which is also what tells a row apart from context.
333
381
  def select_dirs(dirs, options)
334
- bases = Array(options[:dirs]).map { |path| fold_dir(path) }
382
+ bases = Array(options[:dirs]).map { |path| fold_dir(path, dirs) }
335
383
  depth = options[:depth]&.to_i
336
384
  return dirs if bases.empty? && depth.nil?
337
385
  # No --dir means the whole bundle is the starting point, which is *not*
@@ -373,10 +421,17 @@ module OKF
373
421
 
374
422
  # The ids the filters select, resolved through the catalog metadata — or nil
375
423
  # when no filter is active, meaning keep everything.
376
- def filter_ids(folder, options)
424
+ #
425
+ # +dirs+ overrides the folder's own directory set, which is what a
426
+ # multi-bundle run passes: the `root` alias is a fact about a bundle, so
427
+ # resolving it per folder inside a loop let one `--dir root` mean the
428
+ # `root/` subtree in one bundle and the bundle root in the next, merged
429
+ # into a single ranking with nothing saying so. One invocation, one
430
+ # meaning — see Search#multi_search.
431
+ def filter_ids(folder, options, dirs = nil)
377
432
  return nil if options[:type].nil? && options[:area].nil? && options[:dir].nil? && options[:tag].nil?
378
433
 
379
- filter_entries(folder.catalog, options).map { |entry| entry[:id] }
434
+ filter_entries(folder.catalog, options, dirs || dir_scope(folder, options)).map { |entry| entry[:id] }
380
435
  end
381
436
 
382
437
  # §9 best-effort: the graph is built from concepts that parse. Surface any that
@@ -421,15 +476,58 @@ module OKF
421
476
  # Parse options, then take zero or more bundle positionals (the multi-bundle
422
477
  # server) — directories or @refs. Returns the resolved array (possibly
423
478
  # empty), or nil (after reporting) so the caller returns 2.
424
- def positional_dirs(parser, argv)
479
+ def positional_dirs(parser, argv, expand_groups: false)
425
480
  parser.parse!(argv)
426
- dirs = argv.map { |dir| resolve_ref(dir) }
481
+ dirs = if expand_groups
482
+ argv.flat_map { |arg| resolve_ref_expanding(arg) }
483
+ else
484
+ argv.map { |dir| resolve_ref(dir) }
485
+ end
427
486
  dirs.include?(nil) ? nil : dirs
428
487
  rescue OptionParser::ParseError => e
429
488
  @err.puts e.message
430
489
  nil
431
490
  end
432
491
 
492
+ # Like #resolve_ref, but a group @ref fans out to its member directories —
493
+ # the multi-bundle expansion only `server` wants (single-bundle verbs reject
494
+ # a group in #resolve_registered). Always returns an array of dirs so the
495
+ # caller can flat_map, or nil (reported) to fail the run.
496
+ def resolve_ref_expanding(arg)
497
+ return [ resolve_ref(arg) ] unless arg.start_with?("@")
498
+
499
+ registry = load_registry
500
+ return [ nil ] unless registry
501
+
502
+ slug = OKF::Registry.normalize(arg[1..-1])
503
+ return [ resolve_ref(arg) ] if slug.empty? || registry.group?(slug).nil?
504
+
505
+ group_member_dirs(registry, slug)
506
+ end
507
+
508
+ # A group's member directories, in order, skipping ones whose directory has
509
+ # vanished with the same note `@all` gives — and populating +ref_slugs+ so the
510
+ # hub mounts each under its registered slug. nil (reported) when nothing
511
+ # readable is left, or on a hand-edited cycle.
512
+ def group_member_dirs(registry, slug)
513
+ dirs = []
514
+ registry.expand(slug).each do |entry|
515
+ if File.directory?(entry.path)
516
+ ref_slugs[entry.path] = entry.slug
517
+ dirs << entry.path
518
+ else
519
+ skip_registered(entry)
520
+ end
521
+ end
522
+ return dirs unless dirs.empty?
523
+
524
+ @err.puts "error: @#{slug} resolves to no readable bundle (okf registry list)"
525
+ nil
526
+ rescue OKF::Error => e
527
+ @err.puts "error: #{e.message}"
528
+ nil
529
+ end
530
+
433
531
  # "@slug" — or bare "@", the registry's default — names a registered bundle
434
532
  # wherever a <dir> goes; anything else must be a directory on disk. A
435
533
  # leading @ always means the registry (a directory literally named that way
@@ -452,15 +550,24 @@ module OKF
452
550
  # only `server` and the `registry` verbs rescue one. Returns nil after
453
551
  # reporting, so every caller returns 2.
454
552
  def load_registry
455
- require "okf/registry"
456
- OKF::Registry.load
553
+ open_registry
457
554
  rescue OKF::Error => e
458
555
  @err.puts "error: #{e.message}"
459
556
  nil
460
557
  end
461
558
 
462
- # Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
463
- # The slug part is normalized
559
+ # The registry a verb resolves against — the single seam that opts the CLI
560
+ # into discovery. `cwd: Dir.pwd` is what makes OKF::Registry.load walk up for
561
+ # a project-local .okf-registry.json; a library caller passing no cwd stays
562
+ # global-only. The registry subcommands and `server` open through here too,
563
+ # so a bare `okf server` inside a repo serves that repo's bundles.
564
+ def open_registry
565
+ require "okf/registry"
566
+ OKF::Registry.load(cwd: Dir.pwd)
567
+ end
568
+
569
+ # Resolve one @ref through the active registry — a discovered project-local
570
+ # one, else the global $OKF_HOME (default ~/.okf). The slug part is normalized
464
571
  # exactly as registration normalized it, so @One finds the bundle
465
572
  # registered from dir One — but never through #slugify's mint-a-name
466
573
  # placeholder, so "@***" is a bad ref rather than whatever is slugged
@@ -486,6 +593,19 @@ module OKF
486
593
 
487
594
  asked = ref[1..-1]
488
595
  slug = OKF::Registry.normalize(asked)
596
+
597
+ # A group is a set, and this verb takes one bundle. Reading its first member
598
+ # would answer confidently about a bundle the user never singled out — the
599
+ # silent-wrong-answer shape the second-bundle rule already forbids — so it is
600
+ # exit 2, with the two verbs that *can* take a group named.
601
+ group = slug.empty? ? nil : registry.group?(slug)
602
+ if group
603
+ count = group.members.size
604
+ @err.puts "error: @#{slug} names a group of #{count} #{count == 1 ? "member" : "members"}; " \
605
+ "only `okf search` and `okf server` take a group"
606
+ return nil
607
+ end
608
+
489
609
  entry = if asked.empty?
490
610
  registry.default # bare "@"
491
611
  elsif slug.empty?
data/lib/okf/cli/dirs.rb CHANGED
@@ -79,7 +79,7 @@ module OKF
79
79
  def subtree_counts(entries)
80
80
  entries.each_with_object({}) do |entry, out|
81
81
  out[entry[:dir]] = entries.reduce(0) do |sum, other|
82
- under_dir?(other[:dir], entry[:dir]) ? sum + other[:count] : sum
82
+ under_dir?(other[:dir], fold(entry[:dir])) ? sum + other[:count] : sum
83
83
  end
84
84
  end
85
85
  end
data/lib/okf/cli/files.rb CHANGED
@@ -33,7 +33,7 @@ module OKF
33
33
  folder = OKF::Bundle::Folder.load(dir)
34
34
  report_skipped(folder)
35
35
  entries = folder.catalog
36
- selected = filter_entries(entries, options)
36
+ selected = filter_entries(entries, options, dir_scope(folder, options))
37
37
  return print_files_json(dir, selected, options) if options[:json]
38
38
 
39
39
  print_files(dir, selected, entries.size)
data/lib/okf/cli/graph.rb CHANGED
@@ -15,22 +15,26 @@ module OKF
15
15
 
16
16
  def self.help_rows
17
17
  [
18
- [ "graph <dir|@slug> [--json] [--minimal] [--hubs]", "print the knowledge graph" ]
18
+ [ "graph <dir|@slug> [--json] [--minimal] [--hubs]", "print the knowledge graph" ],
19
+ [ "graph <dir|@slug> --traffic [--cut N]", "directories and the link traffic between them" ]
19
20
  ]
20
21
  end
21
22
 
22
23
  def call(argv)
23
- options = { json: false, minimal: false, body: true, hubs: false }
24
+ options = { json: false, minimal: false, body: true, hubs: false, traffic: false, cut: nil }
24
25
  parser = OptionParser.new do |o|
25
- o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body] [--hubs]"
26
+ o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body] [--hubs] [--traffic]"
26
27
  json_flags(o, options, "emit nodes and edges as JSON")
27
28
  o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
28
29
  o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
29
- o.on("--hubs", "rank concepts by inbound links, with the source areas") { options[:hubs] = true }
30
+ o.on("--hubs", "rank concepts by inbound links, with the source top-level dirs") { options[:hubs] = true }
31
+ o.on("--traffic", "collapse concepts into their dirs; count the links between them") { options[:traffic] = true }
32
+ o.on("--cut N", Integer, "least arc weight to draw (default: fitted to the bundle)") { |v| options[:cut] = v }
30
33
  help_flag(o)
31
34
  end
32
35
  dir = positional_dir(parser, argv) or return 2
33
36
 
37
+ return print_traffic(dir, options) if options[:traffic]
34
38
  return print_hubs(dir, options) if options[:hubs]
35
39
 
36
40
  folder = OKF::Bundle::Folder.load(dir)
@@ -52,16 +56,118 @@ module OKF
52
56
 
53
57
  private
54
58
 
59
+ # `graph --traffic`: concepts collapse into the directory they live in, and
60
+ # the links between two directories collapse into one weighted arc. It is
61
+ # the reduction that pays, and the measurement says why: on a typical
62
+ # bundle three quarters of all links cross a directory boundary, and those
63
+ # aggregate roughly ten-to-one — 227 links became 14 arcs on the bundle
64
+ # this was built for.
65
+ #
66
+ # The other half of the view is the cohesion column, which is the reason
67
+ # `refine` wants this. `--hubs` measures concepts; refine's step-3
68
+ # judgements ("does this directory prune?", "concern or container?") are
69
+ # about *directories*, and nothing measured those. Cohesion is a
70
+ # directory's internal traffic over its total, so it reads directly:
71
+ # near-zero with heavy inbound is a shared primitive, heavy outbound with
72
+ # nothing coming back is an index behaving like a container, and high is a
73
+ # directory that genuinely holds together.
74
+ def print_traffic(dir, options)
75
+ folder = OKF::Bundle::Folder.load(dir)
76
+ skeleton = folder.skeleton
77
+ cut = options[:cut] || skeleton.suggested_cut
78
+ return usage_error("--cut must be 1 or more, got #{cut}") if cut < 1
79
+
80
+ report_skipped(folder)
81
+ arcs = OKF::Bundle::Skeleton.arcs_above(skeleton.arcs, cut)
82
+ rows = dir_traffic(skeleton)
83
+ if options[:json]
84
+ emit_json(bundle_head(dir).merge(
85
+ "cut" => cut, "fitted" => options[:cut].nil?, "dirs" => stringify_rows(rows),
86
+ "arcs" => stringify_rows(arcs), "total_arcs" => skeleton.arcs.size
87
+ ))
88
+ else
89
+ @out.puts "Traffic — #{bundle_label(dir)} (#{skeleton.dirs.size} #{pluralize(skeleton.dirs.size, "dir")}, " \
90
+ "#{counted(arcs.size, skeleton.arcs.size, "arc")} at weight #{cut} or more)"
91
+ print_dir_rows(rows)
92
+ print_arc_rows(arcs)
93
+ end
94
+ 0
95
+ end
96
+
97
+ # Each directory's link traffic, split three ways. Counted over *every*
98
+ # arc, never the cut ones: the cut decides what is drawn, and a measurement
99
+ # that moved when the picture was tidied would be worthless as evidence.
100
+ def dir_traffic(skeleton)
101
+ out = Hash.new(0)
102
+ into = Hash.new(0)
103
+ skeleton.arcs.each do |arc|
104
+ out[arc[:source]] += arc[:weight]
105
+ into[arc[:target]] += arc[:weight]
106
+ end
107
+
108
+ skeleton.dirs.map do |row|
109
+ total = row[:internal] + out[row[:dir]] + into[row[:dir]]
110
+ row.merge(out: out[row[:dir]], in: into[row[:dir]],
111
+ cohesion: total.zero? ? nil : (100.0 * row[:internal] / total).round)
112
+ end
113
+ end
114
+
115
+ # An empty bundle gets no table at all — a column header over nothing is a
116
+ # heading that promises rows. The arc list is the opposite case and prints
117
+ # regardless (see print_arc_rows); the difference is that an empty arc list
118
+ # is a fact about the *cut*, and an empty dir table is a fact about the
119
+ # bundle the count line has already stated.
120
+ #
121
+ # Sorted by cohesion ascending, so the directories with a case to answer
122
+ # come first — a table that leads with the healthy ones buries its finding
123
+ # under the rows nobody needed to read. A directory with no traffic at all
124
+ # has no ratio to report and prints `—` rather than a 0% it did not earn.
125
+ def print_dir_rows(rows)
126
+ return if rows.empty?
127
+
128
+ ordered = rows.sort_by { |row| [ row[:cohesion] || 999, row[:dir] ] }
129
+ @out.puts
130
+ labels = ordered.map { |row| dir_label(row[:dir]) }
131
+ width = [ 3, *labels.map(&:length) ].max
132
+ @out.puts " #{"Dir".ljust(width)} Concepts Internal Out In Cohesion"
133
+ ordered.each_with_index do |row, i|
134
+ @out.puts " #{labels[i].ljust(width)} #{row[:count].to_s.rjust(8)} #{row[:internal].to_s.rjust(8)} " \
135
+ "#{row[:out].to_s.rjust(4)} #{row[:in].to_s.rjust(4)} #{(row[:cohesion] ? "#{row[:cohesion]}%" : "—").rjust(8)}"
136
+ end
137
+ end
138
+
139
+ # The arc list is the answer this mode exists for, so it prints even when
140
+ # the cut emptied it — a heading over nothing says "the cut was too tight",
141
+ # where silence reads as "the bundle has no cross-links".
142
+ def print_arc_rows(arcs)
143
+ @out.puts
144
+ @out.puts " Arcs"
145
+ return @out.puts " (none at this cut)" if arcs.empty?
146
+
147
+ width = arcs.map { |arc| dir_label(arc[:source]).length }.max
148
+ twidth = arcs.map { |arc| dir_label(arc[:target]).length }.max
149
+ arcs.each do |arc|
150
+ @out.puts " #{dir_label(arc[:source]).ljust(width)} → #{dir_label(arc[:target]).ljust(twidth)} ×#{arc[:weight]}"
151
+ end
152
+ end
153
+
154
+ # The skeleton is symbol-keyed (it is a pure model, not a payload), and every
155
+ # other --json view in this CLI answers in strings. Converted at the edge, so
156
+ # the model stays the model.
157
+ def stringify_rows(rows)
158
+ rows.map { |row| stringify(row) }
159
+ end
160
+
55
161
  # `graph --hubs`: the inbound ranking with each hub's links grouped by
56
- # source area — the "is this hub well-homed?" evidence. A hub whose
57
- # inbound majority comes from outside its own area is a move candidate;
58
- # --minimal/--no-body shape node payloads and change nothing here.
162
+ # source top-level dir — the "is this hub well-homed?" evidence. A hub whose
163
+ # inbound majority comes from outside its own top-level dir is a move
164
+ # candidate; --minimal/--no-body shape node payloads and change nothing here.
59
165
  def print_hubs(dir, options)
60
166
  folder = OKF::Bundle::Folder.load(dir)
61
167
  hubs = folder.hubs
62
168
  report_skipped(folder)
63
169
  if options[:json]
64
- rows = hubs.map { |row| { "id" => row[:id], "area" => row[:area], "inbound" => row[:inbound], "by_area" => row[:by_area] } }
170
+ rows = hubs.map { |row| { "id" => row[:id], "top_dir" => row[:top_dir], "inbound" => row[:inbound], "by_top_dir" => row[:by_top_dir] } }
65
171
  emit_json(bundle_head(dir).merge("count" => hubs.size, "hubs" => rows))
66
172
  else
67
173
  @out.puts "Hubs — #{bundle_label(dir)} (#{counted(hubs.size, folder.concepts.size, "concept")} with inbound links)"
@@ -69,7 +175,7 @@ module OKF
69
175
  width = hubs.map { |row| row[:id].length }.max || 0
70
176
  dwidth = hubs.map { |row| row[:inbound].to_s.length }.max || 0
71
177
  hubs.each do |row|
72
- sources = row[:by_area].map { |area, count| "#{area} #{count}" }.join(", ")
178
+ sources = row[:by_top_dir].map { |top_dir, count| "#{top_dir} #{count}" }.join(", ")
73
179
  @out.puts " #{row[:id].ljust(width)} ×#{row[:inbound].to_s.rjust(dwidth)} #{sources}"
74
180
  end
75
181
  end
data/lib/okf/cli/index.rb CHANGED
@@ -91,11 +91,11 @@ module OKF
91
91
  # deprecated flag that quietly widens is worse than one that is merely old.
92
92
  # Nothing passed keeps the whole map.
93
93
  def select_directories(entries, options)
94
- areas = Array(options[:areas]).map { |area| fold_dir(area) }
94
+ all_dirs = entries.map { |entry| entry[:dir] }
95
+ areas = Array(options[:areas]).map { |area| fold_dir(area, all_dirs) }
95
96
  scoped = !options[:dirs].nil? || !options[:depth].nil?
96
97
  return [ entries, [] ] if areas.empty? && !scoped
97
98
 
98
- all_dirs = entries.map { |entry| entry[:dir] }
99
99
  wanted = scoped ? select_dirs(all_dirs, options) : []
100
100
  chain = ancestor_dirs(options, all_dirs) - wanted
101
101
  selected = entries.select do |entry|