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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +696 -133
  3. data/README.md +250 -334
  4. data/lib/okf/bundle/folder.rb +24 -5
  5. data/lib/okf/bundle/linter.rb +1 -1
  6. data/lib/okf/bundle/search/index.rb +13 -3
  7. data/lib/okf/bundle/search.rb +91 -11
  8. data/lib/okf/bundle.rb +26 -2
  9. data/lib/okf/cli/catalog.rb +66 -0
  10. data/lib/okf/cli/command.rb +657 -0
  11. data/lib/okf/cli/dirs.rb +118 -0
  12. data/lib/okf/cli/files.rb +68 -0
  13. data/lib/okf/cli/graph.rb +82 -0
  14. data/lib/okf/cli/index.rb +169 -0
  15. data/lib/okf/cli/lint.rb +139 -0
  16. data/lib/okf/cli/loose.rb +78 -0
  17. data/lib/okf/cli/registry.rb +229 -0
  18. data/lib/okf/cli/render.rb +66 -0
  19. data/lib/okf/cli/search.rb +285 -0
  20. data/lib/okf/cli/server.rb +186 -0
  21. data/lib/okf/cli/skill.rb +57 -0
  22. data/lib/okf/cli/stats.rb +113 -0
  23. data/lib/okf/cli/tags.rb +144 -0
  24. data/lib/okf/cli/types.rb +37 -0
  25. data/lib/okf/cli/validate.rb +66 -0
  26. data/lib/okf/cli.rb +425 -1706
  27. data/lib/okf/render/graph/template.html.erb +1285 -129
  28. data/lib/okf/render/graph.rb +46 -2
  29. data/lib/okf/server/app.rb +71 -4
  30. data/lib/okf/server/hub/not_found.rb +663 -0
  31. data/lib/okf/server/hub.rb +512 -38
  32. data/lib/okf/skill/SKILL.md +26 -19
  33. data/lib/okf/skill/playbooks/consume.md +3 -3
  34. data/lib/okf/skill/playbooks/curate.md +3 -1
  35. data/lib/okf/skill/playbooks/maintain.md +7 -5
  36. data/lib/okf/skill/playbooks/menu.md +5 -0
  37. data/lib/okf/skill/playbooks/refine.md +93 -0
  38. data/lib/okf/skill/playbooks/search.md +7 -7
  39. data/lib/okf/skill/reference/cli.md +122 -22
  40. data/lib/okf/version.rb +1 -1
  41. data/lib/okf.rb +9 -0
  42. metadata +38 -8
@@ -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
- # Human-readable "parent/dir" namethe default HTML title.
64
- # The bundle's display label, "parent/dir" path arithmetic, no disk. It
65
- # is a class method so a caller that only wants the label (the registry
66
- # naming an entry) can have it without a Reader.read of every file.
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
- "#{pathname.parent.basename}/#{pathname.basename}"
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
@@ -324,7 +324,7 @@ module OKF
324
324
  end
325
325
 
326
326
  def concepts_in(dir)
327
- @concepts.select { |concept| File.dirname("#{concept.id}.md") == dir }
327
+ @concepts.select { |concept| OKF.dir_of(concept.id) == dir }
328
328
  end
329
329
 
330
330
  def index_path_for(dir)
@@ -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 is built per call see .okf/capabilities/search.md for why that
16
- # ceiling stands and what lifts it.
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
- def call(documents, terms, fields:, fuzzy: false, **_options)
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
@@ -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: File.dirname("#{id}.md"),
122
- area: id.include?("/") ? id.split("/").first : "(root)",
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