okf 1.9.0 → 1.11.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 +696 -133
- data/README.md +250 -334
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +91 -11
- data/lib/okf/bundle.rb +26 -2
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +657 -0
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +169 -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 +186 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +113 -0
- data/lib/okf/cli/tags.rb +144 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +425 -1706
- data/lib/okf/render/graph/template.html.erb +1285 -129
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +71 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +512 -38
- data/lib/okf/skill/SKILL.md +26 -19
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -5
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +93 -0
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +122 -22
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +9 -0
- metadata +38 -8
data/lib/okf/bundle/folder.rb
CHANGED
|
@@ -46,6 +46,10 @@ module OKF
|
|
|
46
46
|
@bundle.catalog
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
def hubs
|
|
50
|
+
@bundle.hubs
|
|
51
|
+
end
|
|
52
|
+
|
|
49
53
|
def directory_index
|
|
50
54
|
@bundle.directory_index
|
|
51
55
|
end
|
|
@@ -60,13 +64,28 @@ module OKF
|
|
|
60
64
|
end
|
|
61
65
|
end
|
|
62
66
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
+
# The bundle's display label — path arithmetic, no disk. It is a class
|
|
68
|
+
# method so a caller that only wants the label (the registry naming an
|
|
69
|
+
# entry) can have it without a Reader.read of every file.
|
|
70
|
+
#
|
|
71
|
+
# "parent/dir", because a bundle directory's own name is rarely unique
|
|
72
|
+
# enough to name it by — except when that name is `.okf`, the conventional
|
|
73
|
+
# container, and then the parent carries the whole answer on its own. A
|
|
74
|
+
# registry of eight projects is eight rows reading `…/.okf`, which is the
|
|
75
|
+
# one word that tells none of them apart; `repo/.okf` is read as "repo" by
|
|
76
|
+
# anyone looking at it anyway.
|
|
77
|
+
#
|
|
78
|
+
# A directory with no parent to borrow (`/.okf`) keeps its own name: the
|
|
79
|
+
# parent is `/`, which names nothing. That case used to compose into
|
|
80
|
+
# `//.okf`.
|
|
67
81
|
def self.label(root)
|
|
68
82
|
pathname = Pathname.new(root)
|
|
69
|
-
|
|
83
|
+
parent = pathname.parent.basename.to_s
|
|
84
|
+
base = pathname.basename.to_s
|
|
85
|
+
return base if [ "/", "." ].include?(parent)
|
|
86
|
+
return parent if base == ".okf"
|
|
87
|
+
|
|
88
|
+
"#{parent}/#{base}"
|
|
70
89
|
end
|
|
71
90
|
|
|
72
91
|
def name
|
data/lib/okf/bundle/linter.rb
CHANGED
|
@@ -12,8 +12,9 @@ module OKF
|
|
|
12
12
|
#
|
|
13
13
|
# Matching is by *token*: a term matches a whole word or a word it prefixes
|
|
14
14
|
# ("dedup" reaches "deduplication"), and `fuzzy:` opts into typo tolerance.
|
|
15
|
-
# The index
|
|
16
|
-
#
|
|
15
|
+
# The index builds per call unless the caller holds a Search::Corpus, which
|
|
16
|
+
# builds it once through .prepare and reuses it — the server does, the
|
|
17
|
+
# one-shot CLI cannot. See .okf/capabilities/search.md.
|
|
17
18
|
module Index
|
|
18
19
|
CAPABILITIES = %i[fuzzy prefix].freeze
|
|
19
20
|
|
|
@@ -37,9 +38,18 @@ module OKF
|
|
|
37
38
|
# can neither match nor be credited. The hit's `terms` are MiniFTS's
|
|
38
39
|
# matched *document* terms — already lowercased, and present in the text
|
|
39
40
|
# verbatim even when the query only prefixed them.
|
|
40
|
-
|
|
41
|
+
# The expensive half, separated so a long-lived caller can hold it. A
|
|
42
|
+
# Corpus calls this once; a one-shot call still goes through #call and
|
|
43
|
+
# builds inline, which is why this is an addition and not a new
|
|
44
|
+
# requirement on the engine contract.
|
|
45
|
+
def prepare(documents)
|
|
41
46
|
index = MiniFTS.new(fields: FIELDS, id_field: "key")
|
|
42
47
|
index.add_all(documents)
|
|
48
|
+
index
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(documents, terms, fields:, fuzzy: false, prepared: nil, **_options)
|
|
52
|
+
index = prepared || prepare(documents)
|
|
43
53
|
|
|
44
54
|
options = { combine_with: "AND", prefix: true, boost: WEIGHTS, fields: fields }
|
|
45
55
|
options[:fuzzy] = FUZZY_DISTANCE if fuzzy
|
data/lib/okf/bundle/search.rb
CHANGED
|
@@ -186,12 +186,89 @@ module OKF
|
|
|
186
186
|
new(bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# The searchable text of one concept, by field. Here rather than on an
|
|
190
|
+
# instance because a Corpus builds documents with no query in hand.
|
|
191
|
+
def self.field_texts(concept)
|
|
192
|
+
{
|
|
193
|
+
"id" => concept.id,
|
|
194
|
+
"title" => concept.title.to_s,
|
|
195
|
+
"type" => concept.type.to_s,
|
|
196
|
+
"description" => concept.description.to_s,
|
|
197
|
+
"tags" => Array(concept.tags).join(" "),
|
|
198
|
+
"body" => concept.body
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A corpus prepared once and queried many times: the documents, the key →
|
|
203
|
+
# concept map, and each engine's built index.
|
|
204
|
+
#
|
|
205
|
+
# This is the asymmetry the engine choice was always argued from. A CLI
|
|
206
|
+
# process loads a bundle, asks one question and exits, so an index build has
|
|
207
|
+
# exactly one query to amortize over and the scan wins. A server is the
|
|
208
|
+
# other case — the build is ~95% of the index path's cost, and paying it per
|
|
209
|
+
# request made every search re-read the whole corpus. Held once, it is paid
|
|
210
|
+
# once.
|
|
211
|
+
#
|
|
212
|
+
# Pure: it holds concepts, never disk. Which is also the cost — the corpus
|
|
213
|
+
# is a snapshot, so a body edited after it was built is searchable only
|
|
214
|
+
# after the holder drops it. That matches the graph, which is memoized the
|
|
215
|
+
# same way and for the same reason.
|
|
216
|
+
class Corpus
|
|
217
|
+
attr_reader :bundles, :documents, :sources
|
|
218
|
+
|
|
219
|
+
def initialize(bundles)
|
|
220
|
+
@bundles = bundles
|
|
221
|
+
@documents = []
|
|
222
|
+
@sources = {}
|
|
223
|
+
@indexes = {}
|
|
224
|
+
bundles.each do |slug, bundle|
|
|
225
|
+
bundle.concepts.each do |concept|
|
|
226
|
+
key = "#{slug}#{KEY_SEPARATOR}#{concept.id}"
|
|
227
|
+
@sources[key] = [ slug, concept ]
|
|
228
|
+
@documents << Search.field_texts(concept).merge("key" => key)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# nil for an engine with nothing to prebuild — the scan reads raw text and
|
|
234
|
+
# has no index to hold — so the option only ever reaches one that declared
|
|
235
|
+
# it can. Memoized per engine id: two engines over one corpus is legal.
|
|
236
|
+
def index_for(engine)
|
|
237
|
+
return nil unless engine.respond_to?(:prepare)
|
|
238
|
+
|
|
239
|
+
@indexes[engine.id] ||= engine.prepare(@documents)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Prepare a corpus for a long-lived caller. Hand the result back to .with
|
|
244
|
+
# for every query.
|
|
245
|
+
#
|
|
246
|
+
# `engine:` builds that engine's index *now* rather than on the first query.
|
|
247
|
+
# Without it the corpus holds only the documents, and the expensive half —
|
|
248
|
+
# the index — is still built lazily, which puts the whole cost on whoever
|
|
249
|
+
# searches first. A server knows its engine at boot, so it can pay there.
|
|
250
|
+
def self.prepare(bundles, engine: nil, engines: nil)
|
|
251
|
+
corpus = Corpus.new(bundles)
|
|
252
|
+
return corpus if OKF.blank?(engine)
|
|
253
|
+
|
|
254
|
+
corpus.index_for(engine_for([], engines: engines || self.engines, name: engine))
|
|
255
|
+
corpus
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Query a prepared corpus. Same rows as .across, without rebuilding what the
|
|
259
|
+
# corpus already holds.
|
|
260
|
+
def self.with(corpus, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
|
|
261
|
+
new(corpus.bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy,
|
|
262
|
+
engine: engine, engines: engines, corpus: corpus).results
|
|
263
|
+
end
|
|
264
|
+
|
|
189
265
|
# Raises RegexpError on an invalid pattern with `regexp: true`, and
|
|
190
266
|
# UnsupportedQuery when no engine can answer — the caller owns turning
|
|
191
267
|
# either into a usage error. `engines:` overrides the registry, which is how
|
|
192
268
|
# 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)
|
|
269
|
+
def initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil, corpus: nil)
|
|
194
270
|
@bundles = bundles
|
|
271
|
+
@corpus = corpus
|
|
195
272
|
@terms = Array(terms).reject { |term| OKF.blank?(term) }.map(&:to_s)
|
|
196
273
|
@fields = fields.nil? || fields.empty? ? FIELDS : fields
|
|
197
274
|
@regexp = regexp
|
|
@@ -202,7 +279,7 @@ module OKF
|
|
|
202
279
|
end
|
|
203
280
|
|
|
204
281
|
# Ranked match rows, catalog-style identity plus where the terms hit:
|
|
205
|
-
# [{ slug:, id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
|
|
282
|
+
# [{ slug:, id:, title:, type:, dir:, area:, tags:, matched: [field, …], score:, snippet: }, …]
|
|
206
283
|
# ordered by score descending, then slug, then id. `slug` is present only
|
|
207
284
|
# when searching across bundles. No terms means no matches.
|
|
208
285
|
def results
|
|
@@ -242,6 +319,7 @@ module OKF
|
|
|
242
319
|
# what it can act on, so there is nothing left for it to ignore.
|
|
243
320
|
def engine_options(chosen)
|
|
244
321
|
options = { fields: @fields }
|
|
322
|
+
options[:prepared] = @corpus.index_for(chosen) if @corpus
|
|
245
323
|
ROUTABLE.each do |capability|
|
|
246
324
|
options[capability] = requested[capability] if chosen.capabilities.include?(capability)
|
|
247
325
|
end
|
|
@@ -255,6 +333,13 @@ module OKF
|
|
|
255
333
|
# Every concept as an indexable document, keyed uniquely across bundles.
|
|
256
334
|
# @sources keeps the way back, so the index stores no fields of its own.
|
|
257
335
|
def documents
|
|
336
|
+
# The corpus already walked every concept and kept the map that turns a
|
|
337
|
+
# hit back into a row; taking its sources is what makes that reuse whole.
|
|
338
|
+
if @corpus
|
|
339
|
+
@sources = @corpus.sources
|
|
340
|
+
return @corpus.documents
|
|
341
|
+
end
|
|
342
|
+
|
|
258
343
|
docs = []
|
|
259
344
|
@bundles.each do |slug, bundle|
|
|
260
345
|
bundle.concepts.each do |concept|
|
|
@@ -269,14 +354,7 @@ module OKF
|
|
|
269
354
|
# { field => original-case text } for every searchable field. The index reads
|
|
270
355
|
# all of them; `fields:` narrows the search, not the document.
|
|
271
356
|
def field_texts(concept)
|
|
272
|
-
|
|
273
|
-
"id" => concept.id,
|
|
274
|
-
"title" => concept.title.to_s,
|
|
275
|
-
"type" => concept.type.to_s,
|
|
276
|
-
"description" => concept.description.to_s,
|
|
277
|
-
"tags" => Array(concept.tags).join(" "),
|
|
278
|
-
"body" => concept.body
|
|
279
|
-
}
|
|
357
|
+
Search.field_texts(concept)
|
|
280
358
|
end
|
|
281
359
|
|
|
282
360
|
# `slug` leads the row so a merged result reads bundle-first, and drops
|
|
@@ -288,6 +366,7 @@ module OKF
|
|
|
288
366
|
id: concept.id,
|
|
289
367
|
title: (concept.title || concept.id).to_s,
|
|
290
368
|
type: concept.type.to_s,
|
|
369
|
+
dir: OKF.dir_of(concept.id),
|
|
291
370
|
area: area_of(concept.id),
|
|
292
371
|
tags: Array(concept.tags).map(&:to_s),
|
|
293
372
|
matched: matched,
|
|
@@ -342,7 +421,8 @@ module OKF
|
|
|
342
421
|
end
|
|
343
422
|
end
|
|
344
423
|
|
|
345
|
-
# A concept's top-level area, mirroring the catalog's definition.
|
|
424
|
+
# A concept's top-level area, mirroring the catalog's definition. Deprecated
|
|
425
|
+
# in favour of OKF.dir_of, which keeps the levels this one throws away.
|
|
346
426
|
def area_of(id)
|
|
347
427
|
id.include?("/") ? id.split("/").first : "(root)"
|
|
348
428
|
end
|
data/lib/okf/bundle.rb
CHANGED
|
@@ -118,14 +118,32 @@ module OKF
|
|
|
118
118
|
timestamp: concept.timestamp&.to_s,
|
|
119
119
|
status: concept.frontmatter["status"]&.to_s,
|
|
120
120
|
backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
|
|
121
|
-
dir:
|
|
122
|
-
area:
|
|
121
|
+
dir: OKF.dir_of(id),
|
|
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
|
|
@@ -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] [--dir D] [--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
|