okf 1.8.0 → 1.10.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 +615 -38
- data/README.md +109 -15
- data/lib/okf/bundle/folder.rb +20 -0
- data/lib/okf/bundle/search/index.rb +65 -0
- data/lib/okf/bundle/search/scan.rb +89 -0
- data/lib/okf/bundle/search.rb +262 -66
- data/lib/okf/bundle.rb +27 -3
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +495 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +127 -0
- data/lib/okf/cli/lint.rb +139 -0
- data/lib/okf/cli/loose.rb +78 -0
- data/lib/okf/cli/registry.rb +229 -0
- data/lib/okf/cli/render.rb +66 -0
- data/lib/okf/cli/search.rb +285 -0
- data/lib/okf/cli/server.rb +179 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +88 -0
- data/lib/okf/cli/tags.rb +122 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +418 -1633
- data/lib/okf/{server → render}/graph/template.html.erb +1553 -175
- data/lib/okf/{server → render}/graph.rb +85 -9
- data/lib/okf/server/app.rb +17 -48
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +504 -38
- data/lib/okf/skill/SKILL.md +41 -26
- data/lib/okf/skill/playbooks/consume.md +5 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +4 -3
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +92 -0
- data/lib/okf/skill/playbooks/search.md +47 -7
- data/lib/okf/skill/reference/authoring.md +3 -2
- data/lib/okf/skill/reference/cli.md +98 -21
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +8 -0
- metadata +37 -3
data/lib/okf/bundle/search.rb
CHANGED
|
@@ -2,24 +2,97 @@
|
|
|
2
2
|
|
|
3
3
|
module OKF
|
|
4
4
|
class Bundle
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# with `regexp: true`. Matches rank by where they hit (a title hit outranks a
|
|
10
|
-
# body hit) and carry one bounded context snippet, so answering "which concept
|
|
11
|
-
# covers X?" costs a row, not a body read.
|
|
5
|
+
# Ranked text retrieval over one or more in-memory bundles. Terms are ANDed:
|
|
6
|
+
# every term must hit at least one searched field, though not necessarily the
|
|
7
|
+
# same one. Rows carry the fields each term hit, so a result stays explainable
|
|
8
|
+
# rather than being a bare relevance number.
|
|
12
9
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
10
|
+
# This class is a *facade*. It owns everything that defines what a result is —
|
|
11
|
+
# the documents, the row and its key order, the snippet window, the final sort
|
|
12
|
+
# — and delegates only "which documents match, how well, and where" to an
|
|
13
|
+
# engine (Search::Index by default, Search::Scan for regexp). An engine that
|
|
14
|
+
# built its own rows could disagree about what a match is; this split makes
|
|
15
|
+
# that unrepresentable.
|
|
15
16
|
#
|
|
16
|
-
# Pure — no disk, no stdio. The CLI's `okf search` and any embedding app
|
|
17
|
-
# it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
|
|
17
|
+
# Pure — no disk, no stdio. The CLI's `okf search` and any embedding app
|
|
18
|
+
# share it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
|
|
18
19
|
class Search
|
|
20
|
+
# Raised when the query needs something the engine cannot do — either the
|
|
21
|
+
# one that was named, or any that is available. Carries structured data
|
|
22
|
+
# rather than a finished sentence, because the shell says "--regexp" where
|
|
23
|
+
# the core says ":regexp"; the CLI formats it and exits 2.
|
|
24
|
+
class UnsupportedQuery < OKF::Error
|
|
25
|
+
attr_reader :missing, :engine
|
|
26
|
+
|
|
27
|
+
def initialize(missing, engine: nil)
|
|
28
|
+
@missing = missing
|
|
29
|
+
@engine = engine
|
|
30
|
+
super(build_message(missing, engine))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_message(missing, engine)
|
|
36
|
+
return "no search engine is available" if missing.empty?
|
|
37
|
+
|
|
38
|
+
offered = missing.map { |name| ":#{name}" }.join(", ")
|
|
39
|
+
engine.nil? ? "no available search engine offers #{offered}" : "engine #{engine} does not offer #{offered}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised when `--engine` names something that is not on offer. An engine
|
|
44
|
+
# registered but reporting `available? == false` is absent from the list for
|
|
45
|
+
# the same reason it is absent from routing: it cannot answer. A future
|
|
46
|
+
# addon whose native build failed will want a kinder message than this one.
|
|
47
|
+
class UnknownEngine < OKF::Error
|
|
48
|
+
attr_reader :name, :available
|
|
49
|
+
|
|
50
|
+
def initialize(name, available)
|
|
51
|
+
@name = name
|
|
52
|
+
@available = available
|
|
53
|
+
super("unknown search engine: #{name} (available: #{available.join(", ")})")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The **declarable** vocabulary: what an engine may claim about itself.
|
|
58
|
+
# Frozen so an engine declaring `:regex` is refused at registration rather
|
|
59
|
+
# than silently never selected — a typo in an addon would otherwise present
|
|
60
|
+
# as "my engine is ignored".
|
|
61
|
+
#
|
|
62
|
+
# `:prefix` lives here and *not* in ROUTABLE on purpose. Nothing asks for
|
|
63
|
+
# its absence, so it selects nothing; what it does is document that this
|
|
64
|
+
# engine grows a term to the tokens it prefixes, which an FTS5 engine may
|
|
65
|
+
# not do by default. Declarative, and honest about being declarative.
|
|
66
|
+
CAPABILITIES = %i[regexp fuzzy prefix].freeze
|
|
67
|
+
|
|
68
|
+
# The **routable** subset: the capabilities a query can actually require,
|
|
69
|
+
# and therefore the only ones that pick an engine. Kept distinct from
|
|
70
|
+
# CAPABILITIES because a capability nothing selects on, filed among the ones
|
|
71
|
+
# that do, is documentation posing as code.
|
|
72
|
+
#
|
|
73
|
+
# Each entry is also the option name the facade hands an engine that
|
|
74
|
+
# declares it — see #engine_options, which is what keeps a meaningful
|
|
75
|
+
# option from reaching an engine that would quietly drop it.
|
|
76
|
+
ROUTABLE = %i[regexp fuzzy].freeze
|
|
77
|
+
|
|
78
|
+
# Chosen when the query requires nothing in particular, which is the
|
|
79
|
+
# overwhelming majority of searches.
|
|
80
|
+
#
|
|
81
|
+
# The scan, not the index, because a one-shot CLI builds an index, asks one
|
|
82
|
+
# question and exits — a build with a single query to amortize it over.
|
|
83
|
+
# Measured end to end: 3.00s vs 0.24s at 1,000 concepts, 0.83s vs 0.18s at
|
|
84
|
+
# 250, and the gap widens with the bundle. Raw-text matching also carries no
|
|
85
|
+
# tokenizer, so the terms that are glued to symbols and therefore
|
|
86
|
+
# unreachable by token (`minifts`, $OKF_HOME) stay findable by default.
|
|
87
|
+
#
|
|
88
|
+
# What it gives up is BM25+ ranking, reachable with `--engine index` — and
|
|
89
|
+
# that is also the engine the browser page runs, so the two rank alike only
|
|
90
|
+
# when the index is named. See .okf/design/search-engines.md.
|
|
91
|
+
DEFAULT_ENGINE = :scan
|
|
92
|
+
|
|
19
93
|
# The searchable fields with their rank weight, strongest signal first.
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# mirroring the server page's haystack.
|
|
94
|
+
# In the index engine these ride as MiniFTS per-field `boost`; the scan
|
|
95
|
+
# sums the weights of the fields that matched instead.
|
|
23
96
|
WEIGHTS = {
|
|
24
97
|
"title" => 5,
|
|
25
98
|
"id" => 4,
|
|
@@ -38,53 +111,165 @@ module OKF
|
|
|
38
111
|
# Characters of context kept on each side of the first matched term.
|
|
39
112
|
SNIPPET_RADIUS = 44
|
|
40
113
|
|
|
41
|
-
|
|
42
|
-
|
|
114
|
+
# Edit distance as a fraction of term length, under `fuzzy: true` — the
|
|
115
|
+
# same 0.2 the browser page passes, so both forgive the same typos.
|
|
116
|
+
FUZZY_DISTANCE = 0.2
|
|
117
|
+
|
|
118
|
+
# The unique document key is "<slug>\0<id>": ids are only unique *within* a
|
|
119
|
+
# bundle, and a merge that collided two bundles' same-named concepts would
|
|
120
|
+
# silently drop one.
|
|
121
|
+
KEY_SEPARATOR = "\0"
|
|
122
|
+
|
|
123
|
+
# Append-only and idempotent by id: a second registration of an id already
|
|
124
|
+
# present is a no-op, so a double `require` cannot double the registry and
|
|
125
|
+
# an addon cannot quietly displace a built-in. Deliberately the same shape
|
|
126
|
+
# as the Linter's planned register hook — two extension points, one idiom.
|
|
127
|
+
def self.register(engine)
|
|
128
|
+
rogue = engine.capabilities - CAPABILITIES
|
|
129
|
+
raise ArgumentError, "unknown search capability: #{rogue.join(", ")}" unless rogue.empty?
|
|
130
|
+
|
|
131
|
+
@engines ||= []
|
|
132
|
+
@engines << engine unless @engines.any? { |registered| registered.id == engine.id }
|
|
133
|
+
engine
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A frozen snapshot in registration order. Frozen because the registry is
|
|
137
|
+
# only meant to grow through .register, where the vocabulary is checked.
|
|
138
|
+
def self.engines
|
|
139
|
+
(@engines ||= []).dup.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The router. Naming an engine is an override, not a hint: it is how a
|
|
143
|
+
# caller reaches semantics no capability flag asks for — `--engine scan`
|
|
144
|
+
# means "match raw text", which the flags cannot express because there is
|
|
145
|
+
# nothing to *require*. A named engine that cannot do what was also asked
|
|
146
|
+
# is an error rather than a silent fallback, since falling back would answer
|
|
147
|
+
# a different question than the one that was posed.
|
|
148
|
+
#
|
|
149
|
+
# Unnamed, the default engine leads, then registration order; the first
|
|
150
|
+
# available engine offering *every* required capability answers. Partition
|
|
151
|
+
# rather than sort_by, because sort_by is not stable and registration order
|
|
152
|
+
# is the tie-break.
|
|
153
|
+
def self.engine_for(required, engines: self.engines, name: nil)
|
|
154
|
+
available = engines.select(&:available?)
|
|
155
|
+
return named_engine(name, required, available) unless OKF.blank?(name)
|
|
156
|
+
|
|
157
|
+
default, rest = available.partition { |engine| engine.id == DEFAULT_ENGINE }
|
|
158
|
+
found = (default + rest).find { |engine| (required - engine.capabilities).empty? }
|
|
159
|
+
return found if found
|
|
160
|
+
|
|
161
|
+
raise UnsupportedQuery, required
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.named_engine(name, required, available)
|
|
165
|
+
wanted = name.to_s.downcase
|
|
166
|
+
found = available.find { |engine| engine.id.to_s == wanted }
|
|
167
|
+
raise UnknownEngine.new(name, available.map(&:id)) if found.nil?
|
|
168
|
+
|
|
169
|
+
missing = required - found.capabilities
|
|
170
|
+
raise UnsupportedQuery.new(missing, engine: found.id) unless missing.empty?
|
|
171
|
+
|
|
172
|
+
found
|
|
173
|
+
end
|
|
174
|
+
private_class_method :named_engine
|
|
175
|
+
|
|
176
|
+
def self.call(bundle, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
|
|
177
|
+
new([ [ nil, bundle ] ], terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Several bundles as [ slug, bundle ] pairs, ranked into one list with every
|
|
181
|
+
# row labeled by its slug. They share **one** index on purpose: BM25 weighs a
|
|
182
|
+
# term by how rare it is in the corpus, so per-bundle indexes would score the
|
|
183
|
+
# same match differently depending on which bundle it came from. One index
|
|
184
|
+
# makes one corpus, and the merged ranking is comparable by construction.
|
|
185
|
+
def self.across(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
|
|
186
|
+
new(bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
|
|
43
187
|
end
|
|
44
188
|
|
|
45
|
-
# Raises RegexpError on an invalid pattern with `regexp: true
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@
|
|
189
|
+
# Raises RegexpError on an invalid pattern with `regexp: true`, and
|
|
190
|
+
# UnsupportedQuery when no engine can answer — the caller owns turning
|
|
191
|
+
# either into a usage error. `engines:` overrides the registry, which is how
|
|
192
|
+
# the "nothing qualifies" path stays reachable without an addon installed.
|
|
193
|
+
def initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
|
|
194
|
+
@bundles = bundles
|
|
195
|
+
@terms = Array(terms).reject { |term| OKF.blank?(term) }.map(&:to_s)
|
|
51
196
|
@fields = fields.nil? || fields.empty? ? FIELDS : fields
|
|
197
|
+
@regexp = regexp
|
|
198
|
+
@fuzzy = fuzzy
|
|
199
|
+
@engine = engine
|
|
200
|
+
@engines = engines
|
|
201
|
+
@sources = {}
|
|
52
202
|
end
|
|
53
203
|
|
|
54
204
|
# Ranked match rows, catalog-style identity plus where the terms hit:
|
|
55
|
-
# [{ id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
|
|
56
|
-
# ordered by score descending, then id.
|
|
205
|
+
# [{ slug:, id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
|
|
206
|
+
# ordered by score descending, then slug, then id. `slug` is present only
|
|
207
|
+
# when searching across bundles. No terms means no matches.
|
|
57
208
|
def results
|
|
58
|
-
return [] if @
|
|
209
|
+
return [] if @terms.empty?
|
|
59
210
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
211
|
+
chosen = engine
|
|
212
|
+
rows = chosen.call(documents, @terms, **engine_options(chosen)).map do |hit|
|
|
213
|
+
slug, concept = @sources[hit[:key]]
|
|
214
|
+
row(slug, concept, hit[:matched], hit[:score], hit[:terms])
|
|
215
|
+
end
|
|
216
|
+
rows.sort_by { |row| [ -row[:score], row[:slug].to_s, row[:id] ] }
|
|
64
217
|
end
|
|
65
218
|
|
|
66
219
|
private
|
|
67
220
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
221
|
+
# The engine is chosen by what the query needs, not by a flag naming one.
|
|
222
|
+
# `-e` unambiguously means "regexp semantics", so it routes on its own and
|
|
223
|
+
# says nothing about it — there is no --engine flag to reconcile with.
|
|
224
|
+
def engine
|
|
225
|
+
Search.engine_for(required_capabilities, engines: @engines || Search.engines, name: @engine)
|
|
226
|
+
end
|
|
72
227
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
228
|
+
# What the query requires, in the routable vocabulary. `:prefix` never
|
|
229
|
+
# appears: it is declarable, not routable — nothing asks for its absence.
|
|
230
|
+
def required_capabilities
|
|
231
|
+
ROUTABLE.select { |capability| requested[capability] }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# `fields:` always, plus exactly the routable options the chosen engine
|
|
235
|
+
# declared it understands.
|
|
236
|
+
#
|
|
237
|
+
# The facade used to hand every engine every option and trust it to ignore
|
|
238
|
+
# what it could not use. Routing makes that harmless in practice — a fuzzy
|
|
239
|
+
# query only ever reaches a :fuzzy engine — but "harmless because something
|
|
240
|
+
# else prevents it" is precisely how an option comes to be dropped in
|
|
241
|
+
# silence the day that something else changes. An engine now receives only
|
|
242
|
+
# what it can act on, so there is nothing left for it to ignore.
|
|
243
|
+
def engine_options(chosen)
|
|
244
|
+
options = { fields: @fields }
|
|
245
|
+
ROUTABLE.each do |capability|
|
|
246
|
+
options[capability] = requested[capability] if chosen.capabilities.include?(capability)
|
|
247
|
+
end
|
|
248
|
+
options
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def requested
|
|
252
|
+
@requested ||= { regexp: @regexp, fuzzy: @fuzzy }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Every concept as an indexable document, keyed uniquely across bundles.
|
|
256
|
+
# @sources keeps the way back, so the index stores no fields of its own.
|
|
257
|
+
def documents
|
|
258
|
+
docs = []
|
|
259
|
+
@bundles.each do |slug, bundle|
|
|
260
|
+
bundle.concepts.each do |concept|
|
|
261
|
+
key = "#{slug}#{KEY_SEPARATOR}#{concept.id}"
|
|
262
|
+
@sources[key] = [ slug, concept ]
|
|
263
|
+
docs << field_texts(concept).merge("key" => key)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
docs
|
|
83
267
|
end
|
|
84
268
|
|
|
85
|
-
# { field => original-case text } for
|
|
86
|
-
|
|
87
|
-
|
|
269
|
+
# { field => original-case text } for every searchable field. The index reads
|
|
270
|
+
# all of them; `fields:` narrows the search, not the document.
|
|
271
|
+
def field_texts(concept)
|
|
272
|
+
{
|
|
88
273
|
"id" => concept.id,
|
|
89
274
|
"title" => concept.title.to_s,
|
|
90
275
|
"type" => concept.type.to_s,
|
|
@@ -92,35 +277,46 @@ module OKF
|
|
|
92
277
|
"tags" => Array(concept.tags).join(" "),
|
|
93
278
|
"body" => concept.body
|
|
94
279
|
}
|
|
95
|
-
texts.each_with_object({}) do |(field, text), acc|
|
|
96
|
-
acc[field] = text if @fields.include?(field)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# The union of fields any term hit, in WEIGHTS order — or nil when some term
|
|
101
|
-
# hit nothing (terms are ANDed).
|
|
102
|
-
def matched_fields(texts)
|
|
103
|
-
hits = @matchers.map do |matcher|
|
|
104
|
-
fields = texts.keys.select { |field| hit?(matcher, texts[field]) }
|
|
105
|
-
return nil if fields.empty?
|
|
106
|
-
|
|
107
|
-
fields
|
|
108
|
-
end
|
|
109
|
-
FIELDS.select { |field| hits.flatten.include?(field) }
|
|
110
280
|
end
|
|
111
281
|
|
|
112
|
-
|
|
113
|
-
|
|
282
|
+
# `slug` leads the row so a merged result reads bundle-first, and drops
|
|
283
|
+
# entirely for a single bundle, which has no slug to carry.
|
|
284
|
+
def row(slug, concept, matched, score, terms)
|
|
285
|
+
texts = field_texts(concept)
|
|
286
|
+
built = {
|
|
287
|
+
slug: slug,
|
|
288
|
+
id: concept.id,
|
|
289
|
+
title: (concept.title || concept.id).to_s,
|
|
290
|
+
type: concept.type.to_s,
|
|
291
|
+
area: area_of(concept.id),
|
|
292
|
+
tags: Array(concept.tags).map(&:to_s),
|
|
293
|
+
matched: matched,
|
|
294
|
+
score: score.round(4),
|
|
295
|
+
snippet: snippet(texts, matched, terms)
|
|
296
|
+
}
|
|
297
|
+
built.delete(:slug) if slug.nil?
|
|
298
|
+
built
|
|
114
299
|
end
|
|
115
300
|
|
|
116
301
|
# One bounded context window around the first term that hit the strongest
|
|
117
302
|
# snippet-worthy field; "" when the match needs no context (id/title/type/tags).
|
|
118
|
-
def snippet(texts, matched)
|
|
303
|
+
def snippet(texts, matched, terms)
|
|
119
304
|
field = SNIPPET_FIELDS.find { |candidate| matched.include?(candidate) }
|
|
120
305
|
return "" if field.nil?
|
|
121
306
|
|
|
122
|
-
matcher =
|
|
123
|
-
context(texts[field], matcher)
|
|
307
|
+
matcher = snippet_matcher(texts[field], terms)
|
|
308
|
+
matcher.nil? ? "" : context(texts[field], matcher)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# What to point the window at: the first of the engine's reported matchers
|
|
312
|
+
# that this text actually contains. Engine-agnostic on purpose — the scan
|
|
313
|
+
# reports compiled patterns, the index reports lowercased document terms,
|
|
314
|
+
# and both are things `locate` can find again in the flattened text.
|
|
315
|
+
def snippet_matcher(text, terms)
|
|
316
|
+
down = text.downcase
|
|
317
|
+
Array(terms).find do |term|
|
|
318
|
+
term.is_a?(Regexp) ? term.match?(text) : down.include?(term)
|
|
319
|
+
end
|
|
124
320
|
end
|
|
125
321
|
|
|
126
322
|
def context(text, matcher)
|
data/lib/okf/bundle.rb
CHANGED
|
@@ -111,7 +111,7 @@ module OKF
|
|
|
111
111
|
id = concept.id
|
|
112
112
|
{
|
|
113
113
|
id: id,
|
|
114
|
-
title: (concept.title
|
|
114
|
+
title: OKF.blank?(concept.title) ? File.basename(id) : concept.title.to_s,
|
|
115
115
|
type: concept.type.to_s,
|
|
116
116
|
description: concept.description.to_s,
|
|
117
117
|
tags: Array(concept.tags).map(&:to_s),
|
|
@@ -119,13 +119,31 @@ module OKF
|
|
|
119
119
|
status: concept.frontmatter["status"]&.to_s,
|
|
120
120
|
backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
|
|
121
121
|
dir: File.dirname("#{id}.md"),
|
|
122
|
-
area:
|
|
122
|
+
area: area_of(id),
|
|
123
123
|
links_out: out_degree[id],
|
|
124
124
|
links_in: in_degree[id]
|
|
125
125
|
}
|
|
126
126
|
end.sort_by { |entry| entry[:id] }
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
+
# Concepts ranked by inbound link degree, each with the areas its inbound
|
|
130
|
+
# links come from — the evidence for "is this hub well-homed?": a hub whose
|
|
131
|
+
# inbound majority is foreign to its own area is a move candidate, one with
|
|
132
|
+
# a single dominant foreign area already names its better home. Only
|
|
133
|
+
# concepts with at least one inbound link appear. Pure: derived from the
|
|
134
|
+
# graph edges. Shared by the `okf graph --hubs` view.
|
|
135
|
+
def hubs
|
|
136
|
+
inbound = {}
|
|
137
|
+
graph(minimal: true).edges.each do |edge|
|
|
138
|
+
(inbound[edge[:target]] ||= Hash.new(0))[area_of(edge[:source])] += 1
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
inbound.map do |id, sources|
|
|
142
|
+
by_area = sources.sort_by { |area, count| [ -count, area ] }.to_h
|
|
143
|
+
{ id: id, area: area_of(id), inbound: by_area.values.reduce(0, :+), by_area: by_area }
|
|
144
|
+
end.sort_by { |row| [ -row[:inbound], row[:id] ] }
|
|
145
|
+
end
|
|
146
|
+
|
|
129
147
|
# The progressive-disclosure map (spec §6): one entry per directory that holds
|
|
130
148
|
# concepts or carries an index.md, sorted with the root (".") first. Each entry
|
|
131
149
|
# gives the authored index body (frontmatter stripped) when an index.md is
|
|
@@ -159,7 +177,7 @@ module OKF
|
|
|
159
177
|
listing: here.map do |concept|
|
|
160
178
|
{
|
|
161
179
|
id: concept.id,
|
|
162
|
-
title: (concept.title
|
|
180
|
+
title: OKF.blank?(concept.title) ? File.basename(concept.id) : concept.title.to_s,
|
|
163
181
|
description: concept.description.to_s,
|
|
164
182
|
type: concept.type.to_s,
|
|
165
183
|
tags: Array(concept.tags).map(&:to_s)
|
|
@@ -171,6 +189,12 @@ module OKF
|
|
|
171
189
|
|
|
172
190
|
private
|
|
173
191
|
|
|
192
|
+
# A concept's top-level area, derived from its id — the same derivation the
|
|
193
|
+
# catalog exposes, so every grouped view labels the bundle root "(root)".
|
|
194
|
+
def area_of(id)
|
|
195
|
+
id.include?("/") ? id.split("/").first : "(root)"
|
|
196
|
+
end
|
|
197
|
+
|
|
174
198
|
# Every directory to show: those holding concepts or an index.md, plus each of
|
|
175
199
|
# their ancestors up to the root, so the subdir tree stays connected even when
|
|
176
200
|
# an intermediate directory holds nothing directly. Sorted with "." first.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# Every concept with its metadata, grouped by area. The widest of the read
|
|
6
|
+
# views, and the one the others narrow down from.
|
|
7
|
+
class Catalog < Command
|
|
8
|
+
def self.id
|
|
9
|
+
:catalog
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.group
|
|
13
|
+
:read
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.help_rows
|
|
17
|
+
[
|
|
18
|
+
[ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by area" ]
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(argv)
|
|
23
|
+
options = { json: false }
|
|
24
|
+
parser = OptionParser.new do |o|
|
|
25
|
+
o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
26
|
+
json_flags(o, options, "emit the catalog as JSON")
|
|
27
|
+
projection_flags(o, options)
|
|
28
|
+
filter_flags(o, options, :type, :area, :tag)
|
|
29
|
+
help_flag(o)
|
|
30
|
+
end
|
|
31
|
+
dir = positional_dir(parser, argv) or return 2
|
|
32
|
+
|
|
33
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
34
|
+
report_skipped(folder)
|
|
35
|
+
entries = folder.catalog
|
|
36
|
+
selected = filter_entries(entries, options)
|
|
37
|
+
return print_catalog_json(dir, selected, options) if options[:json]
|
|
38
|
+
|
|
39
|
+
print_catalog(dir, selected, entries.size)
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def print_catalog(dir, entries, total)
|
|
46
|
+
@out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
|
|
47
|
+
entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
|
|
48
|
+
@out.puts
|
|
49
|
+
@out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
|
|
50
|
+
group.each do |entry|
|
|
51
|
+
links = entry[:links_out] + entry[:links_in]
|
|
52
|
+
meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
|
|
53
|
+
@out.puts " #{entry[:title]} · #{meta}"
|
|
54
|
+
@out.puts " #{truncate(entry[:description], 92)}" unless entry[:description].empty?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def print_catalog_json(dir, entries, options)
|
|
60
|
+
emit_list_json(dir, "concepts", entries.map { |entry| stringify(entry) }, options)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
register(Catalog)
|
|
65
|
+
end
|
|
66
|
+
end
|