okf 1.11.0 → 1.12.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 +92 -1
- data/README.md +136 -96
- data/lib/okf/bundle/folder.rb +4 -0
- data/lib/okf/bundle/search.rb +5 -5
- data/lib/okf/bundle/skeleton.rb +241 -0
- data/lib/okf/bundle.rb +18 -13
- data/lib/okf/cli/catalog.rb +5 -5
- data/lib/okf/cli/command.rb +72 -7
- data/lib/okf/cli/graph.rb +115 -9
- data/lib/okf/cli/registry.rb +152 -15
- data/lib/okf/cli/render.rb +3 -2
- data/lib/okf/cli/search.rb +32 -1
- data/lib/okf/cli/server.rb +9 -5
- data/lib/okf/cli/stats.rb +8 -8
- data/lib/okf/cli/tags.rb +1 -1
- data/lib/okf/cli.rb +3 -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/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 +76 -19
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +1 -0
- metadata +2 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class Bundle
|
|
5
|
+
# The graph reduced to what a reader can hold in their head. Pure — built from
|
|
6
|
+
# an OKF::Bundle::Graph's nodes and edges, does no I/O, and decides nothing
|
|
7
|
+
# about how any of it is drawn.
|
|
8
|
+
#
|
|
9
|
+
# A dense bundle is not dense in the way a hub-and-spoke picture is. Measured
|
|
10
|
+
# on a 47-concept bundle with 227 links: the top hub takes 13 inbound and the
|
|
11
|
+
# median takes 4, so there is no 80/20 to exploit — dropping two thirds of the
|
|
12
|
+
# concepts still leaves 53 edges. The density lives *between directories*:
|
|
13
|
+
# 173 of those 227 links (76%) cross a directory boundary, and they collapse
|
|
14
|
+
# into 50 directory-to-directory arcs of which the top ten carry half the mass.
|
|
15
|
+
# That is the reduction worth drawing, and it is why #arcs exists at all.
|
|
16
|
+
#
|
|
17
|
+
# ── the two things it produces, and who reads them ──
|
|
18
|
+
#
|
|
19
|
+
# dirs + arcs the reduction, as counts: one row per directory, one
|
|
20
|
+
# weighted arc per ordered pair. Printed by
|
|
21
|
+
# `okf graph --traffic`, with cohesion derived from them.
|
|
22
|
+
# edges every link with the cut it survives (`keep_at`). Nothing
|
|
23
|
+
# prints these — they are what lets the graph page lay a
|
|
24
|
+
# large bundle out on its strongest links first.
|
|
25
|
+
#
|
|
26
|
+
# Both are emitted *unthresholded*, and #suggested_cut names where to cut
|
|
27
|
+
# rather than cutting, so a caller narrows the picture without this class
|
|
28
|
+
# having to know what a picture is.
|
|
29
|
+
#
|
|
30
|
+
# Directories come off the concept *id* (OKF.dir_of), not the file path, so
|
|
31
|
+
# this agrees with #catalog, #hubs and the `--dir` filter. Bundle#directory_index
|
|
32
|
+
# groups by path instead — deliberately, since an index.md is a physical
|
|
33
|
+
# listing — so the two disagree for a concept whose frontmatter `id` moves it.
|
|
34
|
+
# This side follows the id because the edges do.
|
|
35
|
+
class Skeleton
|
|
36
|
+
attr_reader :dirs, :arcs, :edges
|
|
37
|
+
|
|
38
|
+
def self.build(bundle)
|
|
39
|
+
graph = bundle.graph(minimal: true)
|
|
40
|
+
ids = graph.nodes.map { |node| node[:id] }
|
|
41
|
+
pairs = graph.edges.map { |edge| [ edge[:source], edge[:target] ] }
|
|
42
|
+
dir_by_id = ids.map { |id| [ id, OKF.dir_of(id) ] }.to_h
|
|
43
|
+
|
|
44
|
+
new(
|
|
45
|
+
dirs: dirs_for(dir_by_id, pairs),
|
|
46
|
+
arcs: arcs_for(dir_by_id, pairs),
|
|
47
|
+
edges: edges_for(pairs, neighbours_for(ids, pairs))
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ── the cuts (see above) ──
|
|
52
|
+
|
|
53
|
+
def self.arcs_above(arcs, weight)
|
|
54
|
+
arcs.select { |arc| arc[:weight] >= weight }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.edges_within(edges, keep_at)
|
|
58
|
+
edges.select { |edge| edge[:keep_at] <= keep_at }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ── the directories ──
|
|
62
|
+
|
|
63
|
+
# One row per directory that holds a concept, plus every ancestor up to the
|
|
64
|
+
# root, so the tree stays connected through a directory that holds nothing
|
|
65
|
+
# directly. `count` is direct and `subtree` is at-or-below — the same pair
|
|
66
|
+
# `okf dirs` prints, and for the same reason: a direct count alone cannot
|
|
67
|
+
# say where the mass is once the listing is cut off at a depth.
|
|
68
|
+
def self.dirs_for(dir_by_id, pairs)
|
|
69
|
+
direct = Hash.new(0)
|
|
70
|
+
dir_by_id.each_value { |dir| direct[dir] += 1 }
|
|
71
|
+
internal = Hash.new(0)
|
|
72
|
+
pairs.each do |source, target|
|
|
73
|
+
from = dir_by_id[source]
|
|
74
|
+
internal[from] += 1 if from == dir_by_id[target]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
every_dir(direct.keys).map do |dir|
|
|
78
|
+
{ dir: dir, parent: parent_of(dir), count: direct[dir],
|
|
79
|
+
subtree: direct.reduce(0) { |sum, (other, n)| under?(other, dir) ? sum + n : sum },
|
|
80
|
+
internal: internal[dir] }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The cross-directory link mass, aggregated. Directed and never a self-arc:
|
|
85
|
+
# a directory's internal links are carried on its own row (`internal`),
|
|
86
|
+
# because an arc from a box to itself is a loop the eye has to untangle to
|
|
87
|
+
# learn a number the box could simply have carried.
|
|
88
|
+
def self.arcs_for(dir_by_id, pairs)
|
|
89
|
+
weights = Hash.new(0)
|
|
90
|
+
pairs.each do |source, target|
|
|
91
|
+
from = dir_by_id[source]
|
|
92
|
+
to = dir_by_id[target]
|
|
93
|
+
weights[[ from, to ]] += 1 unless from == to
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
weights.map { |(from, to), weight| { source: from, target: to, weight: weight } }
|
|
97
|
+
.sort_by { |arc| [ -arc[:weight], arc[:source], arc[:target] ] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ── where to cut ──
|
|
101
|
+
|
|
102
|
+
# The arc cut that leaves a readable picture, chosen from the *shape* of
|
|
103
|
+
# the bundle rather than fixed. A fixed cut cannot work: measured at
|
|
104
|
+
# weight 3 across ten bundles it left 2 arcs on one and 136 on another —
|
|
105
|
+
# too tight to be a picture at one end, no reduction at all at the other.
|
|
106
|
+
#
|
|
107
|
+
# What stays constant when a bundle grows is not the arc count but the
|
|
108
|
+
# arcs *per box*: a node-link diagram reads at roughly one to two edges
|
|
109
|
+
# per node regardless of size. So the target is 1.5 arcs per directory,
|
|
110
|
+
# and the cut is whatever weight delivers it — 22 arcs over 13 directories
|
|
111
|
+
# on one bundle, 191 over 95 on another, both about the same density.
|
|
112
|
+
#
|
|
113
|
+
# The floor of 8 is for the small end, where 1.5-per-box would cut a
|
|
114
|
+
# ten-arc bundle down to something that no longer shows how it is joined
|
|
115
|
+
# up. Ties are kept rather than broken, so the result is *at least* the
|
|
116
|
+
# target — the alternative is dropping one of two arcs that weigh the
|
|
117
|
+
# same, which is an arbitrary choice presented as a threshold.
|
|
118
|
+
def self.suggested_cut(arcs, dir_count)
|
|
119
|
+
target = [ (dir_count * 1.5).ceil, 8 ].max
|
|
120
|
+
return 1 if arcs.empty? || arcs.size <= target
|
|
121
|
+
|
|
122
|
+
arcs[target - 1][:weight]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ── the edges, each with the cut it survives ──
|
|
126
|
+
|
|
127
|
+
# The local-degree sparsifier (Lindner et al.): every concept keeps its own
|
|
128
|
+
# most-connected neighbours, and an edge survives if *either* end kept it.
|
|
129
|
+
# The union is the point — it is what stops a sparsifier from stranding the
|
|
130
|
+
# quiet half of the bundle, which a global "drop the weakest edges" rule
|
|
131
|
+
# does immediately.
|
|
132
|
+
#
|
|
133
|
+
# Not the disparity filter, the usual name in this territory: that one reads
|
|
134
|
+
# an edge's weight against its endpoint's total, and every link here weighs
|
|
135
|
+
# exactly 1, which makes every proportion identical and the filter a coin
|
|
136
|
+
# toss. Weighted-graph tools do not transfer to an unweighted graph just
|
|
137
|
+
# because both are graphs.
|
|
138
|
+
#
|
|
139
|
+
# `keep_at` is the smallest cut (0–100) at which the edge appears, so the
|
|
140
|
+
# consumer's whole job is `keep_at <= n`. An edge to a node's single most
|
|
141
|
+
# connected neighbour keeps at 0 and is therefore never cut away: the
|
|
142
|
+
# skeleton always spans every linked concept.
|
|
143
|
+
def self.edges_for(pairs, neighbours)
|
|
144
|
+
order = ordered_neighbours(neighbours)
|
|
145
|
+
pairs.map do |source, target|
|
|
146
|
+
{ source: source, target: target,
|
|
147
|
+
keep_at: [ keep_at(order, neighbours, source, target),
|
|
148
|
+
keep_at(order, neighbours, target, source) ].min }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Where `other` sits in `id`'s neighbours (1 = most connected), turned into
|
|
153
|
+
# the smallest cut that reaches it. Position 1 needs no budget at all; past
|
|
154
|
+
# that, a cut of n keeps ceil(degree ** n/100) neighbours, so the answer is
|
|
155
|
+
# the least n satisfying that — solved directly rather than searched, and
|
|
156
|
+
# pinned against the definition it came from in the unit test.
|
|
157
|
+
def self.keep_at(order, neighbours, id, other)
|
|
158
|
+
position = order[id].index(other) + 1
|
|
159
|
+
return 0 if position == 1
|
|
160
|
+
|
|
161
|
+
degree = neighbours[id].size
|
|
162
|
+
(100 * Math.log(position - 1) / Math.log(degree)).floor + 1
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.ordered_neighbours(neighbours)
|
|
166
|
+
neighbours.each_with_object({}) do |(id, set), out|
|
|
167
|
+
out[id] = set.sort_by { |other| [ -neighbours[other].size, other ] }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Undirected neighbour sets. A link is structure regardless of which end
|
|
172
|
+
# authored it, and a concept every page cites but that cites nothing back
|
|
173
|
+
# is as central as one that does the citing.
|
|
174
|
+
def self.neighbours_for(ids, pairs)
|
|
175
|
+
sets = ids.map { |id| [ id, Set.new ] }.to_h
|
|
176
|
+
pairs.each do |source, target|
|
|
177
|
+
sets[source] << target if sets.key?(source) && sets.key?(target)
|
|
178
|
+
sets[target] << source if sets.key?(source) && sets.key?(target)
|
|
179
|
+
end
|
|
180
|
+
sets
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ── path arithmetic (pure; the bundle root is ".") ──
|
|
184
|
+
|
|
185
|
+
def self.every_dir(dirs)
|
|
186
|
+
seen = {}
|
|
187
|
+
dirs.each do |dir|
|
|
188
|
+
current = dir
|
|
189
|
+
loop do
|
|
190
|
+
seen[current] = true
|
|
191
|
+
break if current == "."
|
|
192
|
+
|
|
193
|
+
current = parent_of(current)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
seen.keys.sort_by { |dir| dir == "." ? "" : dir }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.parent_of(dir)
|
|
200
|
+
return nil if dir == "."
|
|
201
|
+
|
|
202
|
+
File.dirname(dir)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# At or below — the same rule `--dir` is answered against, so a row's
|
|
206
|
+
# `subtree` and what `--dir <that row>` returns can never disagree.
|
|
207
|
+
def self.under?(dir, ancestor)
|
|
208
|
+
return true if ancestor == "."
|
|
209
|
+
|
|
210
|
+
dir == ancestor || dir.start_with?("#{ancestor}/")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def initialize(dirs:, arcs:, edges:)
|
|
214
|
+
@dirs = dirs
|
|
215
|
+
@arcs = arcs
|
|
216
|
+
@edges = edges
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def suggested_cut
|
|
220
|
+
@suggested_cut ||= self.class.suggested_cut(arcs, dirs.size)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The `keep_at` of each edge in +edges+, in *its* order — so a caller
|
|
224
|
+
# holding an OKF::Bundle::Graph can line the two up. Matched on the pair
|
|
225
|
+
# rather than by index: both lists derive from the same graph and so are
|
|
226
|
+
# already parallel today, and an index-aligned read would go wrong in
|
|
227
|
+
# silence on the day one of them stops being built that way.
|
|
228
|
+
def cuts_for(graph_edges)
|
|
229
|
+
by_pair = edges.each_with_object({}) { |edge, out| out[[ edge[:source], edge[:target] ]] = edge[:keep_at] }
|
|
230
|
+
graph_edges.map { |edge| by_pair[[ edge[:source], edge[:target] ]] || 0 }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# What the drawn views read. `edges` stays out: it is per-link data whose
|
|
234
|
+
# only consumer needs it at boot, before any fetch could answer — so it
|
|
235
|
+
# rides inline with the graph instead (see OKF::Render::Graph#edge_cuts_json).
|
|
236
|
+
def to_h
|
|
237
|
+
{ dirs: dirs, arcs: arcs, suggested_cut: suggested_cut }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
data/lib/okf/bundle.rb
CHANGED
|
@@ -95,6 +95,10 @@ module OKF
|
|
|
95
95
|
Graph.build(self, minimal: minimal, body: body)
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
def skeleton
|
|
99
|
+
Skeleton.build(self)
|
|
100
|
+
end
|
|
101
|
+
|
|
98
102
|
# Rich per-concept metadata the catalog / files / stats consumers want but the
|
|
99
103
|
# lean graph omits — the descriptive frontmatter fields plus in/out link degree
|
|
100
104
|
# taken from the graph edges. Pure: derived from the concepts and their links,
|
|
@@ -119,28 +123,28 @@ module OKF
|
|
|
119
123
|
status: concept.frontmatter["status"]&.to_s,
|
|
120
124
|
backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
|
|
121
125
|
dir: OKF.dir_of(id),
|
|
122
|
-
|
|
126
|
+
top_dir: top_dir_of(id),
|
|
123
127
|
links_out: out_degree[id],
|
|
124
128
|
links_in: in_degree[id]
|
|
125
129
|
}
|
|
126
130
|
end.sort_by { |entry| entry[:id] }
|
|
127
131
|
end
|
|
128
132
|
|
|
129
|
-
# Concepts ranked by inbound link degree, each with the
|
|
130
|
-
# links come from — the evidence for "is this hub well-homed?": a hub
|
|
131
|
-
# inbound majority is foreign to its own
|
|
132
|
-
# a single dominant foreign
|
|
133
|
-
# concepts with at least one inbound link appear. Pure: derived
|
|
134
|
-
# graph edges. Shared by the `okf graph --hubs` view.
|
|
133
|
+
# Concepts ranked by inbound link degree, each with the top-level dirs its
|
|
134
|
+
# inbound links come from — the evidence for "is this hub well-homed?": a hub
|
|
135
|
+
# whose inbound majority is foreign to its own top-level dir is a move
|
|
136
|
+
# candidate, one with a single dominant foreign dir already names its better
|
|
137
|
+
# home. Only concepts with at least one inbound link appear. Pure: derived
|
|
138
|
+
# from the graph edges. Shared by the `okf graph --hubs` view.
|
|
135
139
|
def hubs
|
|
136
140
|
inbound = {}
|
|
137
141
|
graph(minimal: true).edges.each do |edge|
|
|
138
|
-
(inbound[edge[:target]] ||= Hash.new(0))[
|
|
142
|
+
(inbound[edge[:target]] ||= Hash.new(0))[top_dir_of(edge[:source])] += 1
|
|
139
143
|
end
|
|
140
144
|
|
|
141
145
|
inbound.map do |id, sources|
|
|
142
|
-
|
|
143
|
-
{ id: id,
|
|
146
|
+
by_top_dir = sources.sort_by { |top_dir, count| [ -count, top_dir ] }.to_h
|
|
147
|
+
{ id: id, top_dir: top_dir_of(id), inbound: by_top_dir.values.reduce(0, :+), by_top_dir: by_top_dir }
|
|
144
148
|
end.sort_by { |row| [ -row[:inbound], row[:id] ] }
|
|
145
149
|
end
|
|
146
150
|
|
|
@@ -189,9 +193,10 @@ module OKF
|
|
|
189
193
|
|
|
190
194
|
private
|
|
191
195
|
|
|
192
|
-
# A concept's top-level
|
|
193
|
-
# catalog exposes, so every grouped view labels the bundle
|
|
194
|
-
|
|
196
|
+
# A concept's top-level dir, derived from its id — the first path segment, the
|
|
197
|
+
# same derivation the catalog exposes, so every grouped view labels the bundle
|
|
198
|
+
# root "(root)". OKF.dir_of keeps the levels this one rolls up.
|
|
199
|
+
def top_dir_of(id)
|
|
195
200
|
id.include?("/") ? id.split("/").first : "(root)"
|
|
196
201
|
end
|
|
197
202
|
|
data/lib/okf/cli/catalog.rb
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module OKF
|
|
4
4
|
class CLI
|
|
5
|
-
# Every concept with its metadata, grouped by
|
|
6
|
-
# views, and the one the others narrow down from.
|
|
5
|
+
# Every concept with its metadata, grouped by top-level dir. The widest of the
|
|
6
|
+
# read views, and the one the others narrow down from.
|
|
7
7
|
class Catalog < Command
|
|
8
8
|
def self.id
|
|
9
9
|
:catalog
|
|
@@ -15,7 +15,7 @@ module OKF
|
|
|
15
15
|
|
|
16
16
|
def self.help_rows
|
|
17
17
|
[
|
|
18
|
-
[ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by
|
|
18
|
+
[ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by top-level dir" ]
|
|
19
19
|
]
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -44,9 +44,9 @@ module OKF
|
|
|
44
44
|
|
|
45
45
|
def print_catalog(dir, entries, total)
|
|
46
46
|
@out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
|
|
47
|
-
entries.group_by { |entry| entry[:
|
|
47
|
+
entries.group_by { |entry| entry[:top_dir] }.sort_by(&:first).each do |top_dir, group|
|
|
48
48
|
@out.puts
|
|
49
|
-
@out.puts " #{
|
|
49
|
+
@out.puts " #{top_dir == "(root)" ? "(root)" : "#{top_dir}/"} (#{group.size})"
|
|
50
50
|
group.each do |entry|
|
|
51
51
|
links = entry[:links_out] + entry[:links_in]
|
|
52
52
|
meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
|
data/lib/okf/cli/command.rb
CHANGED
|
@@ -193,7 +193,7 @@ module OKF
|
|
|
193
193
|
def filter_entries(entries, options)
|
|
194
194
|
entries.select do |entry|
|
|
195
195
|
(options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
|
|
196
|
-
(options[:area].nil? || fold(entry[:
|
|
196
|
+
(options[:area].nil? || fold(entry[:top_dir]) == fold_area(options[:area])) &&
|
|
197
197
|
(options[:dir].nil? || under_dir?(entry[:dir], options[:dir])) &&
|
|
198
198
|
(options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
|
|
199
199
|
end
|
|
@@ -421,15 +421,58 @@ module OKF
|
|
|
421
421
|
# Parse options, then take zero or more bundle positionals (the multi-bundle
|
|
422
422
|
# server) — directories or @refs. Returns the resolved array (possibly
|
|
423
423
|
# empty), or nil (after reporting) so the caller returns 2.
|
|
424
|
-
def positional_dirs(parser, argv)
|
|
424
|
+
def positional_dirs(parser, argv, expand_groups: false)
|
|
425
425
|
parser.parse!(argv)
|
|
426
|
-
dirs =
|
|
426
|
+
dirs = if expand_groups
|
|
427
|
+
argv.flat_map { |arg| resolve_ref_expanding(arg) }
|
|
428
|
+
else
|
|
429
|
+
argv.map { |dir| resolve_ref(dir) }
|
|
430
|
+
end
|
|
427
431
|
dirs.include?(nil) ? nil : dirs
|
|
428
432
|
rescue OptionParser::ParseError => e
|
|
429
433
|
@err.puts e.message
|
|
430
434
|
nil
|
|
431
435
|
end
|
|
432
436
|
|
|
437
|
+
# Like #resolve_ref, but a group @ref fans out to its member directories —
|
|
438
|
+
# the multi-bundle expansion only `server` wants (single-bundle verbs reject
|
|
439
|
+
# a group in #resolve_registered). Always returns an array of dirs so the
|
|
440
|
+
# caller can flat_map, or nil (reported) to fail the run.
|
|
441
|
+
def resolve_ref_expanding(arg)
|
|
442
|
+
return [ resolve_ref(arg) ] unless arg.start_with?("@")
|
|
443
|
+
|
|
444
|
+
registry = load_registry
|
|
445
|
+
return [ nil ] unless registry
|
|
446
|
+
|
|
447
|
+
slug = OKF::Registry.normalize(arg[1..-1])
|
|
448
|
+
return [ resolve_ref(arg) ] if slug.empty? || registry.group?(slug).nil?
|
|
449
|
+
|
|
450
|
+
group_member_dirs(registry, slug)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# A group's member directories, in order, skipping ones whose directory has
|
|
454
|
+
# vanished with the same note `@all` gives — and populating +ref_slugs+ so the
|
|
455
|
+
# hub mounts each under its registered slug. nil (reported) when nothing
|
|
456
|
+
# readable is left, or on a hand-edited cycle.
|
|
457
|
+
def group_member_dirs(registry, slug)
|
|
458
|
+
dirs = []
|
|
459
|
+
registry.expand(slug).each do |entry|
|
|
460
|
+
if File.directory?(entry.path)
|
|
461
|
+
ref_slugs[entry.path] = entry.slug
|
|
462
|
+
dirs << entry.path
|
|
463
|
+
else
|
|
464
|
+
skip_registered(entry)
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
return dirs unless dirs.empty?
|
|
468
|
+
|
|
469
|
+
@err.puts "error: @#{slug} resolves to no readable bundle (okf registry list)"
|
|
470
|
+
nil
|
|
471
|
+
rescue OKF::Error => e
|
|
472
|
+
@err.puts "error: #{e.message}"
|
|
473
|
+
nil
|
|
474
|
+
end
|
|
475
|
+
|
|
433
476
|
# "@slug" — or bare "@", the registry's default — names a registered bundle
|
|
434
477
|
# wherever a <dir> goes; anything else must be a directory on disk. A
|
|
435
478
|
# leading @ always means the registry (a directory literally named that way
|
|
@@ -452,15 +495,24 @@ module OKF
|
|
|
452
495
|
# only `server` and the `registry` verbs rescue one. Returns nil after
|
|
453
496
|
# reporting, so every caller returns 2.
|
|
454
497
|
def load_registry
|
|
455
|
-
|
|
456
|
-
OKF::Registry.load
|
|
498
|
+
open_registry
|
|
457
499
|
rescue OKF::Error => e
|
|
458
500
|
@err.puts "error: #{e.message}"
|
|
459
501
|
nil
|
|
460
502
|
end
|
|
461
503
|
|
|
462
|
-
#
|
|
463
|
-
#
|
|
504
|
+
# The registry a verb resolves against — the single seam that opts the CLI
|
|
505
|
+
# into discovery. `cwd: Dir.pwd` is what makes OKF::Registry.load walk up for
|
|
506
|
+
# a project-local .okf-registry.json; a library caller passing no cwd stays
|
|
507
|
+
# global-only. The registry subcommands and `server` open through here too,
|
|
508
|
+
# so a bare `okf server` inside a repo serves that repo's bundles.
|
|
509
|
+
def open_registry
|
|
510
|
+
require "okf/registry"
|
|
511
|
+
OKF::Registry.load(cwd: Dir.pwd)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Resolve one @ref through the active registry — a discovered project-local
|
|
515
|
+
# one, else the global $OKF_HOME (default ~/.okf). The slug part is normalized
|
|
464
516
|
# exactly as registration normalized it, so @One finds the bundle
|
|
465
517
|
# registered from dir One — but never through #slugify's mint-a-name
|
|
466
518
|
# placeholder, so "@***" is a bad ref rather than whatever is slugged
|
|
@@ -486,6 +538,19 @@ module OKF
|
|
|
486
538
|
|
|
487
539
|
asked = ref[1..-1]
|
|
488
540
|
slug = OKF::Registry.normalize(asked)
|
|
541
|
+
|
|
542
|
+
# A group is a set, and this verb takes one bundle. Reading its first member
|
|
543
|
+
# would answer confidently about a bundle the user never singled out — the
|
|
544
|
+
# silent-wrong-answer shape the second-bundle rule already forbids — so it is
|
|
545
|
+
# exit 2, with the two verbs that *can* take a group named.
|
|
546
|
+
group = slug.empty? ? nil : registry.group?(slug)
|
|
547
|
+
if group
|
|
548
|
+
count = group.members.size
|
|
549
|
+
@err.puts "error: @#{slug} names a group of #{count} #{count == 1 ? "member" : "members"}; " \
|
|
550
|
+
"only `okf search` and `okf server` take a group"
|
|
551
|
+
return nil
|
|
552
|
+
end
|
|
553
|
+
|
|
489
554
|
entry = if asked.empty?
|
|
490
555
|
registry.default # bare "@"
|
|
491
556
|
elsif slug.empty?
|
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
|