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/bundle/reader.rb
CHANGED
|
@@ -12,10 +12,17 @@ module OKF
|
|
|
12
12
|
# cannot open at all — is retained as an unparseable entry (carrying the
|
|
13
13
|
# ParseError message or the errno, so §9.1 can report it) rather than dropped
|
|
14
14
|
# or raised. That tolerance is the whole §9 best-effort promise: one bad file
|
|
15
|
-
# never breaks the rest, and this is the read every verb shares.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
15
|
+
# never breaks the rest, and this is the read every verb shares.
|
|
16
|
+
#
|
|
17
|
+
# Containment is enforced twice, because the two ways out of the root are
|
|
18
|
+
# different. A crafted *path* (`..`, an absolute string) is caught lexically
|
|
19
|
+
# by Path.join_under!. A *symlink* whose name sits inside the root but whose
|
|
20
|
+
# target does not cannot be seen lexically — File.expand_path does not
|
|
21
|
+
# resolve links — so each file is also realpath-resolved and its real
|
|
22
|
+
# location checked against the real root before a byte is read. An escaping
|
|
23
|
+
# file joins the unparseable bucket rather than raising: a planted symlink is
|
|
24
|
+
# one bad file, and letting it take down the whole bundle read would hand any
|
|
25
|
+
# writer of a served directory a denial of service. §9.1 then names it.
|
|
19
26
|
class Reader
|
|
20
27
|
def self.read(dir)
|
|
21
28
|
new(dir).read
|
|
@@ -32,9 +39,22 @@ module OKF
|
|
|
32
39
|
reserved = []
|
|
33
40
|
unparseable = []
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
paths = markdown_paths
|
|
43
|
+
# Resolved once for the whole loop, but never at the cost of the
|
|
44
|
+
# best-effort promise: if the root itself has become unreadable since
|
|
45
|
+
# the glob, this stays nil and each file's own SafeRead call raises
|
|
46
|
+
# inside the per-file rescue below — one bad bundle degrades to
|
|
47
|
+
# unparseable entries, it does not crash the read every verb shares.
|
|
48
|
+
real_root = begin
|
|
49
|
+
File.realpath(@root) unless paths.empty?
|
|
50
|
+
rescue SystemCallError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
paths.each do |path|
|
|
36
55
|
begin
|
|
37
|
-
|
|
56
|
+
absolute = Path.join_under!(@root, path)
|
|
57
|
+
content = SafeRead.read!(@root, absolute, real_root: real_root)
|
|
38
58
|
if Concept.reserved?(path)
|
|
39
59
|
reserved << Entry.new(path: path, content: content)
|
|
40
60
|
else
|
|
@@ -43,18 +63,21 @@ module OKF
|
|
|
43
63
|
end
|
|
44
64
|
rescue Markdown::Frontmatter::ParseError => e
|
|
45
65
|
unparseable << Entry.new(path: path, content: content, error: e.message)
|
|
46
|
-
rescue SystemCallError => e
|
|
47
|
-
# A file
|
|
48
|
-
# bundle
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
66
|
+
rescue Path::Error, SystemCallError => e
|
|
67
|
+
# A file we cannot safely read is one unusable file, not a broken
|
|
68
|
+
# bundle: an errno on open, or a path that leaves the root — lexically
|
|
69
|
+
# (`..`, an absolute string) or through a symlink whose target escapes
|
|
70
|
+
# it. Letting either out of here breaks "one bad file never breaks the
|
|
71
|
+
# rest" for every verb at once — the read is the one path they all
|
|
72
|
+
# share — and in the worst way: a backtrace under an exit code that
|
|
73
|
+
# claims non-conformance, or a served bundle taken down by one planted
|
|
74
|
+
# symlink. So it joins the same bucket a bad frontmatter block does,
|
|
75
|
+
# and §9.1 reports it naming the file and the reason.
|
|
54
76
|
#
|
|
55
77
|
# Its content is "" rather than nil: unknown, but every analyzer reads
|
|
56
|
-
# it as text, and empty is the honest shape of a file we never
|
|
57
|
-
# no links to resolve,
|
|
78
|
+
# it as text, and empty is the honest shape of a file we never read —
|
|
79
|
+
# no links to resolve, nothing claimed, and for a symlink escape, none
|
|
80
|
+
# of the target's bytes.
|
|
58
81
|
unparseable << Entry.new(path: path, content: "", error: e.message)
|
|
59
82
|
end
|
|
60
83
|
end
|
data/lib/okf/bundle/search.rb
CHANGED
|
@@ -279,7 +279,7 @@ module OKF
|
|
|
279
279
|
end
|
|
280
280
|
|
|
281
281
|
# Ranked match rows, catalog-style identity plus where the terms hit:
|
|
282
|
-
# [{ slug:, id:, title:, type:, dir:,
|
|
282
|
+
# [{ slug:, id:, title:, type:, dir:, top_dir:, tags:, matched: [field, …], score:, snippet: }, …]
|
|
283
283
|
# ordered by score descending, then slug, then id. `slug` is present only
|
|
284
284
|
# when searching across bundles. No terms means no matches.
|
|
285
285
|
def results
|
|
@@ -367,7 +367,7 @@ module OKF
|
|
|
367
367
|
title: (concept.title || concept.id).to_s,
|
|
368
368
|
type: concept.type.to_s,
|
|
369
369
|
dir: OKF.dir_of(concept.id),
|
|
370
|
-
|
|
370
|
+
top_dir: top_dir_of(concept.id),
|
|
371
371
|
tags: Array(concept.tags).map(&:to_s),
|
|
372
372
|
matched: matched,
|
|
373
373
|
score: score.round(4),
|
|
@@ -421,9 +421,9 @@ module OKF
|
|
|
421
421
|
end
|
|
422
422
|
end
|
|
423
423
|
|
|
424
|
-
# A concept's top-level
|
|
425
|
-
#
|
|
426
|
-
def
|
|
424
|
+
# A concept's top-level dir, mirroring the catalog's definition — the first
|
|
425
|
+
# path segment. OKF.dir_of keeps the levels this one rolls up.
|
|
426
|
+
def top_dir_of(id)
|
|
427
427
|
id.include?("/") ? id.split("/").first : "(root)"
|
|
428
428
|
end
|
|
429
429
|
end
|
|
@@ -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
|
|
|
@@ -156,6 +160,18 @@ module OKF
|
|
|
156
160
|
# `id` must not move a concept out of the directory it lives in. Pure: derived
|
|
157
161
|
# from the concepts and the reserved index text, no disk. Shared by the
|
|
158
162
|
# `okf index` view and the server's Index panel (/index).
|
|
163
|
+
# Every directory this bundle has — the same set #directory_index enumerates
|
|
164
|
+
# (concepts, an index.md or a log.md, plus every ancestor), without building
|
|
165
|
+
# the map. It is the answer to "does this bundle have a directory named X?",
|
|
166
|
+
# and the CLI needs exactly that to decide whether `--dir root` names a real
|
|
167
|
+
# directory or the bundle root. Reading it off #catalog instead is the same
|
|
168
|
+
# question asked of a smaller set, which is how the two views came to
|
|
169
|
+
# disagree about one bundle. Memoized: the model is immutable once read,
|
|
170
|
+
# and the resolvers above ask per invocation, not per bundle load.
|
|
171
|
+
def directories
|
|
172
|
+
@directories ||= directory_set(concepts.map { |concept| File.dirname(concept.path) }.uniq)
|
|
173
|
+
end
|
|
174
|
+
|
|
159
175
|
def directory_index
|
|
160
176
|
by_dir = concepts.group_by { |concept| File.dirname(concept.path) }
|
|
161
177
|
dirs = directory_set(by_dir.keys)
|
|
@@ -189,17 +205,21 @@ module OKF
|
|
|
189
205
|
|
|
190
206
|
private
|
|
191
207
|
|
|
192
|
-
# A concept's top-level
|
|
193
|
-
# catalog exposes, so every grouped view labels the bundle
|
|
194
|
-
|
|
208
|
+
# A concept's top-level dir, derived from its id — the first path segment, the
|
|
209
|
+
# same derivation the catalog exposes, so every grouped view labels the bundle
|
|
210
|
+
# root "(root)". OKF.dir_of keeps the levels this one rolls up.
|
|
211
|
+
def top_dir_of(id)
|
|
195
212
|
id.include?("/") ? id.split("/").first : "(root)"
|
|
196
213
|
end
|
|
197
214
|
|
|
198
|
-
# Every directory to show: those holding concepts
|
|
199
|
-
# their ancestors up to the root, so the subdir tree stays
|
|
200
|
-
# an intermediate directory holds nothing directly.
|
|
215
|
+
# Every directory to show: those holding concepts, an index.md or a log.md,
|
|
216
|
+
# plus each of their ancestors up to the root, so the subdir tree stays
|
|
217
|
+
# connected even when an intermediate directory holds nothing directly. A
|
|
218
|
+
# scoped log counts because `okf log` reads it — a directory whose only file
|
|
219
|
+
# is its history still exists, and leaving it out is how the `root` alias
|
|
220
|
+
# beat a real `root/` for the second file kind in a row. Sorted "." first.
|
|
201
221
|
def directory_set(concept_dirs)
|
|
202
|
-
seed = concept_dirs + index_files.map { |path| File.dirname(path) }
|
|
222
|
+
seed = concept_dirs + (index_files + log_files).map { |path| File.dirname(path) }
|
|
203
223
|
dirs = {}
|
|
204
224
|
seed.each do |dir|
|
|
205
225
|
current = dir
|
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
|
|
|
@@ -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_catalog_json(dir, selected, options) if options[:json]
|
|
38
38
|
|
|
39
39
|
print_catalog(dir, selected, entries.size)
|
|
@@ -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(" · ")
|