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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -1
- data/README.md +77 -428
- data/lib/okf/bundle/folder.rb +25 -2
- data/lib/okf/bundle/reader.rb +39 -16
- data/lib/okf/bundle/search.rb +5 -5
- data/lib/okf/bundle/skeleton.rb +241 -0
- data/lib/okf/bundle.rb +37 -17
- data/lib/okf/cli/catalog.rb +6 -6
- data/lib/okf/cli/command.rb +139 -19
- data/lib/okf/cli/dirs.rb +1 -1
- data/lib/okf/cli/files.rb +1 -1
- data/lib/okf/cli/graph.rb +115 -9
- data/lib/okf/cli/index.rb +2 -2
- data/lib/okf/cli/registry.rb +152 -15
- data/lib/okf/cli/render.rb +3 -2
- data/lib/okf/cli/search.rb +53 -8
- data/lib/okf/cli/server.rb +9 -5
- data/lib/okf/cli/stats.rb +8 -8
- data/lib/okf/cli/tags.rb +2 -2
- data/lib/okf/cli.rb +3 -3
- data/lib/okf/concept/file.rb +17 -2
- data/lib/okf/path.rb +17 -3
- data/lib/okf/registry.rb +351 -20
- data/lib/okf/render/graph/template.html.erb +211 -7
- data/lib/okf/render/graph.rb +27 -3
- data/lib/okf/safe_read.rb +50 -0
- data/lib/okf/server/app.rb +13 -3
- data/lib/okf/server/hub.rb +5 -3
- data/lib/okf/skill/SKILL.md +5 -1
- data/lib/okf/skill/playbooks/menu.md +2 -1
- data/lib/okf/skill/playbooks/refine.md +26 -0
- data/lib/okf/skill/reference/cli.md +81 -21
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +2 -0
- metadata +4 -3
- data/CODE_OF_CONDUCT.md +0 -10
data/lib/okf/cli/command.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
(
|
|
197
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
463
|
-
#
|
|
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
|
|
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
|
|
57
|
-
# inbound majority comes from outside its own
|
|
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], "
|
|
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[:
|
|
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
|
-
|
|
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|
|