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/cli.rb
CHANGED
|
@@ -3,85 +3,16 @@
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
|
|
5
5
|
module OKF
|
|
6
|
-
# Command-line front end: `okf
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# Command-line front end: `okf <command> [options]`.
|
|
7
|
+
#
|
|
8
|
+
# This file is the dispatcher and the registry; the verbs themselves live one
|
|
9
|
+
# per file under `okf/cli/`, each a Command subclass that registers itself at
|
|
10
|
+
# load. It is still the only layer that parses argv, prints, writes files and
|
|
11
|
+
# decides exit codes — the lib classes below it just return data. Streams are
|
|
12
|
+
# injectable for testing.
|
|
10
13
|
#
|
|
11
14
|
# Exit codes: 0 success, 1 non-conformant / failing bundle, 2 usage error.
|
|
12
15
|
class CLI
|
|
13
|
-
# The `registry` umbrella's subcommands — the dispatch, and the words a
|
|
14
|
-
# flag-first invocation is checked against.
|
|
15
|
-
SUBCOMMANDS = %w[set del list default rename].freeze
|
|
16
|
-
|
|
17
|
-
# Lint findings grouped for display, in category order.
|
|
18
|
-
LINT_CATEGORIES = {
|
|
19
|
-
"Reachability" => %i[orphan not_in_index disconnected_component unlinked],
|
|
20
|
-
"Backlog" => %i[missing_concept broken_index_entry],
|
|
21
|
-
"Completeness" => %i[stub missing_title missing_description missing_timestamp],
|
|
22
|
-
"Freshness" => %i[stale],
|
|
23
|
-
"Provenance" => %i[uncited_external broken_citation],
|
|
24
|
-
"Hygiene" => %i[duplicate_title unused_reference_def undefined_reference self_link]
|
|
25
|
-
}.freeze
|
|
26
|
-
|
|
27
|
-
# Runs a Rack app under WEBrick until interrupted. Injected into the CLI so
|
|
28
|
-
# tests can drive `server` without opening a socket; the runner loads here
|
|
29
|
-
# (not at require time) so `require "okf"` and a Rails mount of the server stay
|
|
30
|
-
# light.
|
|
31
|
-
WEBRICK = lambda do |app, host, port|
|
|
32
|
-
require "okf/server/runner"
|
|
33
|
-
OKF::Server::Runner.run(app, host: host, port: port)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def self.start(argv, out: $stdout, err: $stderr)
|
|
37
|
-
new(out: out, err: err).run(argv)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def initialize(out: $stdout, err: $stderr, runner: WEBRICK)
|
|
41
|
-
@out = out
|
|
42
|
-
@err = err
|
|
43
|
-
@runner = runner
|
|
44
|
-
@pretty = false
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def run(argv)
|
|
48
|
-
argv = argv.dup
|
|
49
|
-
# Per-run state, reset so a reused instance never inherits the last run's
|
|
50
|
-
# answer: the ref→slug memo, and the --pretty a previous argv turned on.
|
|
51
|
-
@ref_slugs = {}
|
|
52
|
-
@pretty = false
|
|
53
|
-
# -h/--help is answered wherever a parser sees it — deep inside
|
|
54
|
-
# positional_dir, where returning would only mean "usage error, exit 2".
|
|
55
|
-
# Thrown here instead, so help keeps the contract every other path keeps:
|
|
56
|
-
# a status this method returns. See #help_flag.
|
|
57
|
-
catch(:help) do
|
|
58
|
-
case (command = argv.shift)
|
|
59
|
-
when "graph" then graph(argv)
|
|
60
|
-
when "validate" then validate(argv)
|
|
61
|
-
when "lint" then lint(argv)
|
|
62
|
-
when "loose" then loose(argv)
|
|
63
|
-
when "search" then search(argv)
|
|
64
|
-
when "index" then index(argv)
|
|
65
|
-
when "catalog" then catalog(argv)
|
|
66
|
-
when "files" then files(argv)
|
|
67
|
-
when "tags" then tags(argv)
|
|
68
|
-
when "types" then types(argv)
|
|
69
|
-
when "stats" then stats(argv)
|
|
70
|
-
when "server" then server(argv)
|
|
71
|
-
when "render" then render(argv)
|
|
72
|
-
when "registry" then registry(argv)
|
|
73
|
-
when "skill" then skill(argv)
|
|
74
|
-
when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
|
|
75
|
-
when "help", "--help", "-h" then usage(@out); 0
|
|
76
|
-
when nil then usage(@err); 2
|
|
77
|
-
else
|
|
78
|
-
@err.puts "okf: unknown command '#{command}'"
|
|
79
|
-
usage(@err)
|
|
80
|
-
2
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
16
|
# "every registered bundle" as a ref, in its canonical spelling — what the
|
|
86
17
|
# messages say, and (normalized) what #all_ref? recognizes. Only `search`
|
|
87
18
|
# expands it: it is the one verb that merges across bundles, so it is the one
|
|
@@ -114,1611 +45,465 @@ module OKF
|
|
|
114
45
|
"bundles" => %w[slug title dir mount default missing]
|
|
115
46
|
}.freeze
|
|
116
47
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
help_flag(o)
|
|
125
|
-
end
|
|
126
|
-
dir = positional_dir(parser, argv) or return 2
|
|
127
|
-
|
|
128
|
-
result = OKF::Bundle::Folder.load(dir).validate
|
|
129
|
-
options[:json] ? print_validation_json(dir, result) : print_validation(dir, result)
|
|
130
|
-
result.valid? ? 0 : 1
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def lint(argv)
|
|
134
|
-
options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
|
|
135
|
-
parser = OptionParser.new do |o|
|
|
136
|
-
o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
|
|
137
|
-
json_flags(o, options, "emit a JSON report")
|
|
138
|
-
o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
|
|
139
|
-
o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
|
|
140
|
-
o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
|
|
141
|
-
o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
|
|
142
|
-
o.on("--fail-on LEVEL", %w[never warn], "exit 1 when a finding at LEVEL exists (never | warn)") { |v| options[:fail_on] = v.to_sym }
|
|
143
|
-
help_flag(o)
|
|
144
|
-
end
|
|
145
|
-
dir = positional_dir(parser, argv) or return 2
|
|
146
|
-
|
|
147
|
-
unknown = ((options[:only] || []) + (options[:except] || [])) - OKF::Bundle::Linter::CHECKS
|
|
148
|
-
unless unknown.empty?
|
|
149
|
-
@err.puts "error: unknown check(s): #{unknown.uniq.join(", ")}"
|
|
150
|
-
return 2
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
stale_before = parse_stale_after(options[:stale_after])
|
|
154
|
-
if stale_before == :invalid
|
|
155
|
-
@err.puts "error: invalid --stale-after `#{options[:stale_after]}` (use 90d, 12w, or an ISO date like 2026-01-01)"
|
|
156
|
-
return 2
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
160
|
-
report = folder.lint(min_body: options[:min_body], stale_before: stale_before, only: options[:only], except: options[:except])
|
|
161
|
-
note_skipped(report.stats[:skipped])
|
|
162
|
-
options[:json] ? print_lint_json(dir, report) : print_lint(dir, report)
|
|
163
|
-
options[:fail_on] == :warn && report.warnings.any? ? 1 : 0
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
# List the "loose" files — concepts with graph degree 0 (no cross-links in or
|
|
167
|
-
# out), grouped by folder. A folder-grouped view over lint's `unlinked` check,
|
|
168
|
-
# for the common "which files float in the graph?" question. Advisory (exit 0):
|
|
169
|
-
# a terminal leaf can be loose by design. `--json` for a machine substrate.
|
|
170
|
-
def loose(argv)
|
|
171
|
-
options = { json: false }
|
|
172
|
-
parser = OptionParser.new do |o|
|
|
173
|
-
o.banner = "Usage: okf loose <dir|@slug> [--json]"
|
|
174
|
-
json_flags(o, options, "emit the loose files as JSON")
|
|
175
|
-
help_flag(o)
|
|
176
|
-
end
|
|
177
|
-
dir = positional_dir(parser, argv) or return 2
|
|
178
|
-
|
|
179
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
180
|
-
report_skipped(folder)
|
|
181
|
-
files = loose_files(folder.graph(minimal: true))
|
|
182
|
-
options[:json] ? print_loose_json(dir, files) : print_loose(dir, files)
|
|
183
|
-
0
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Deterministic text retrieval — the browser page's search brought to the CLI
|
|
187
|
-
# and extended to bodies. Terms after the directory are ANDed case-insensitive
|
|
188
|
-
# substrings (Ruby regexps with --regexp); rows rank by where they hit (title >
|
|
189
|
-
# id > tags > type/description > body) and carry one bounded context snippet,
|
|
190
|
-
# so "which concept covers X?" costs a few rows, not a body read. Advisory
|
|
191
|
-
# read: exit 0 even with no matches. Deliberately not fuzzy — the consuming
|
|
192
|
-
# agent is the fuzzy layer.
|
|
193
|
-
def search(argv)
|
|
194
|
-
options = { json: false, regexp: false }
|
|
195
|
-
parser = OptionParser.new do |o|
|
|
196
|
-
o.banner = "Usage: okf search <dir|@slug…|@all> <term> [term ...] [--regexp] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
|
|
197
|
-
json_flags(o, options, "emit the matches as JSON")
|
|
198
|
-
projection_flags(o, options)
|
|
199
|
-
o.on("-e", "--regexp", "treat each term as a Ruby regular expression (case-insensitive)") { options[:regexp] = true }
|
|
200
|
-
o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
|
|
201
|
-
filter_flags(o, options, :type, :area, :tag)
|
|
202
|
-
help_flag(o)
|
|
203
|
-
end
|
|
204
|
-
begin
|
|
205
|
-
parser.parse!(argv)
|
|
206
|
-
rescue OptionParser::ParseError => e
|
|
207
|
-
@err.puts e.message
|
|
208
|
-
return 2
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# Registry mode — leading @refs, @all among them — searches several bundles
|
|
212
|
-
# and labels every match; a plain dir keeps the classic single-bundle output.
|
|
213
|
-
if argv.first&.start_with?("@")
|
|
214
|
-
pairs = ref_targets(argv) or return 2
|
|
215
|
-
dir = nil
|
|
216
|
-
else
|
|
217
|
-
dir = argv.shift
|
|
218
|
-
if dir.nil?
|
|
219
|
-
@err.puts parser.banner
|
|
220
|
-
return 2
|
|
221
|
-
end
|
|
222
|
-
dir = resolve_ref(dir) or return 2
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
terms = argv
|
|
226
|
-
if terms.empty?
|
|
227
|
-
@err.puts parser.banner
|
|
228
|
-
return 2
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
# A non-leading @arg is a literal term by the grammar — say so, since the
|
|
232
|
-
# user may have meant a ref (refs must lead) and would otherwise see only
|
|
233
|
-
# a silent zero-match.
|
|
234
|
-
stray = terms.find { |term| term.start_with?("@") }
|
|
235
|
-
@err.puts "note: '#{stray}' searches as a literal term — an @slug or @all must lead" if stray
|
|
236
|
-
|
|
237
|
-
unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
|
|
238
|
-
return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
|
|
239
|
-
|
|
240
|
-
return multi_search(pairs, terms, options) if pairs
|
|
241
|
-
|
|
242
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
243
|
-
report_skipped(folder)
|
|
244
|
-
rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
|
|
245
|
-
keep = filter_ids(folder, options)
|
|
246
|
-
rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
|
|
247
|
-
return print_search_json(dir, terms, rows, options) if options[:json]
|
|
248
|
-
|
|
249
|
-
print_search(dir, terms, rows, folder.bundle.concepts.size)
|
|
250
|
-
0
|
|
251
|
-
rescue RegexpError => e
|
|
252
|
-
usage_error("invalid pattern: #{e.message}")
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Every registered bundle, as [slug, dir] pairs — what @all expands to.
|
|
256
|
-
# Asking for everything tolerates gaps: a registered directory that has since
|
|
257
|
-
# vanished is skipped with a note, the same forgiveness the hub shows a stale
|
|
258
|
-
# entry. Naming one bundle demands it, so a plain @slug still fails hard.
|
|
259
|
-
def all_targets
|
|
260
|
-
registry = load_registry
|
|
261
|
-
return nil unless registry
|
|
262
|
-
|
|
263
|
-
if registry.empty?
|
|
264
|
-
@err.puts "error: no bundles registered (okf registry set <dir>)"
|
|
265
|
-
return nil
|
|
266
|
-
end
|
|
267
|
-
pairs = []
|
|
268
|
-
registry.each do |entry|
|
|
269
|
-
if File.directory?(entry.path)
|
|
270
|
-
pairs << [ entry.slug, entry.path ]
|
|
271
|
-
else
|
|
272
|
-
skip_registered(entry)
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
if pairs.empty?
|
|
276
|
-
@err.puts "error: every registered bundle is missing on disk (okf registry list)"
|
|
277
|
-
return nil
|
|
278
|
-
end
|
|
279
|
-
pairs
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
# Dedupe by resolved path, not ref spelling — `@ @one` is one bundle when
|
|
283
|
-
# "one" is the default, and must be searched once. `@all @one` is the same
|
|
284
|
-
# story with a wider first ref: all ⊇ one, so the result is right and the
|
|
285
|
-
# duplicate simply drops. No error branch, because there is no wrong answer
|
|
286
|
-
# to warn about.
|
|
287
|
-
def ref_targets(argv)
|
|
288
|
-
refs = []
|
|
289
|
-
refs << argv.shift while argv.first&.start_with?("@")
|
|
290
|
-
pairs = []
|
|
291
|
-
refs.each do |ref|
|
|
292
|
-
found = all_ref?(ref) ? all_targets : ref_pair(ref)
|
|
293
|
-
return nil unless found
|
|
294
|
-
|
|
295
|
-
found.each { |slug, path| pairs << [ slug, path ] unless pairs.any? { |_, seen| seen == path } }
|
|
296
|
-
end
|
|
297
|
-
pairs
|
|
48
|
+
# Runs a Rack app under WEBrick until interrupted. Injected into the CLI so
|
|
49
|
+
# tests can drive `server` without opening a socket; the runner loads here
|
|
50
|
+
# (not at require time) so `require "okf"` and a Rails mount of the server stay
|
|
51
|
+
# light.
|
|
52
|
+
WEBRICK = lambda do |app, host, port|
|
|
53
|
+
require "okf/server/runner"
|
|
54
|
+
OKF::Server::Runner.run(app, host: host, port: port)
|
|
298
55
|
end
|
|
299
56
|
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
#
|
|
57
|
+
# The file a gem ships to add verbs to `okf`. Everything about the seam is in
|
|
58
|
+
# this one constant: a gem that wants to extend the CLI puts `okf/plugin.rb`
|
|
59
|
+
# on its load path and registers from it.
|
|
303
60
|
#
|
|
304
|
-
#
|
|
305
|
-
#
|
|
306
|
-
#
|
|
307
|
-
#
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
#
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
#
|
|
328
|
-
#
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
#
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
usage_error(e.message)
|
|
423
|
-
end
|
|
424
|
-
|
|
425
|
-
# Build the single-bundle Rack app and hand it to the runner (WEBrick by
|
|
426
|
-
# default, injected so tests drive this without a socket).
|
|
427
|
-
def run_server(folder, options)
|
|
428
|
-
app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
|
|
429
|
-
# minimal: the banner wants a count, not bodies — and Folder#graph is not
|
|
430
|
-
# memoized, so a full build here parses every concept a second time (the
|
|
431
|
-
# App builds its own) purely to print one number.
|
|
432
|
-
count = folder.graph(minimal: true).nodes.size
|
|
433
|
-
@out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
434
|
-
serve(app, options)
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
# Build the multi-bundle hub and hand it to the runner. With dirs it serves
|
|
438
|
-
# those ephemerally; with none it serves the persistent registry. Either way
|
|
439
|
-
# the first bundle is the one `/` opens — for the registry that is its own
|
|
440
|
-
# order, and a first entry whose directory has vanished drops out here, so
|
|
441
|
-
# `/` lands on the next one that is actually there.
|
|
442
|
-
def run_hub(dirs, options)
|
|
443
|
-
require "okf/server/hub"
|
|
444
|
-
require "okf/registry"
|
|
445
|
-
if dirs.empty?
|
|
446
|
-
# A malformed registry raises OKF::Error, which `server` rescues into a
|
|
447
|
-
# usage error — no guarded load needed on this path.
|
|
448
|
-
reg = OKF::Registry.load
|
|
449
|
-
bundles = reg.map { |entry| load_registered(entry) }.compact
|
|
450
|
-
else
|
|
451
|
-
bundles = ephemeral_bundles(dirs)
|
|
452
|
-
end
|
|
453
|
-
hub = OKF::Server::Hub.new(bundles, layout: options[:layout])
|
|
454
|
-
concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
|
|
455
|
-
@out.puts "serving #{bundles.size} #{pluralize(bundles.size,
|
|
456
|
-
"bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
457
|
-
print_mounts(hub)
|
|
458
|
-
serve(hub, options)
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
# The one boot seam every served app passes through, so a hub gzips exactly
|
|
462
|
-
# like a single bundle — the wrap belongs to booting a server, not to either
|
|
463
|
-
# mode, and a mode added later gets it for free. Deliberately not inside the
|
|
464
|
-
# runner: an embedding app mounting OKF::Server::App brings its own middleware.
|
|
465
|
-
def serve(app, options)
|
|
466
|
-
# gzip responses when the client accepts it — transparent, no new dependency
|
|
467
|
-
@runner.call(Rack::Deflater.new(app), options[:bind], options[:port])
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
# The mount table — which dir landed on which /b/<slug>/ and where `/` goes.
|
|
471
|
-
# Mirrors the Hub's own default resolution (explicit slug, else first).
|
|
472
|
-
# Ask the hub which bundle it chose rather than re-deriving the
|
|
473
|
-
# explicit-else-first rule, and mount at its own prefix: two copies of a
|
|
474
|
-
# rule is two answers waiting to disagree.
|
|
475
|
-
def print_mounts(hub)
|
|
476
|
-
hub.bundles.each do |bundle|
|
|
477
|
-
marker = bundle.equal?(hub.default) ? "*" : " "
|
|
478
|
-
@out.puts " #{marker} #{OKF::Server::Hub::MOUNT}/#{bundle.slug}/ #{bundle.title}"
|
|
479
|
-
end
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
# Load the given directories as unregistered bundles, slugged by basename and
|
|
483
|
-
# deduped within the run. The same directory listed twice mounts once — two
|
|
484
|
-
# windows on one bundle would just burn a slug on a URL that vanishes next run.
|
|
485
|
-
def ephemeral_bundles(dirs)
|
|
486
|
-
roots = []
|
|
487
|
-
dirs.each do |dir|
|
|
488
|
-
root = File.expand_path(dir)
|
|
489
|
-
roots << root unless roots.include?(root)
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
# A registered slug owns its mount outright: reserve every ref's slug
|
|
493
|
-
# before any basename is deduped. Otherwise argv order decides, and
|
|
494
|
-
# `server ./two @two` mounts the *unregistered* ./two at /b/two/ while
|
|
495
|
-
# pushing the ref — the bundle whose slug that is — to /b/two-2/, so a
|
|
496
|
-
# bookmark from a bundle-less run silently opens the wrong graph.
|
|
497
|
-
taken = roots.map { |root| ref_slugs[root] }.compact
|
|
498
|
-
roots.each_with_object([]) do |root, bundles|
|
|
499
|
-
folder = OKF::Bundle::Folder.load(root)
|
|
500
|
-
report_skipped(folder)
|
|
501
|
-
slug = ref_slugs[root]
|
|
502
|
-
unless slug
|
|
503
|
-
slug = OKF::Registry.dedupe(File.basename(root), taken)
|
|
504
|
-
taken << slug
|
|
61
|
+
# A convention rather than a list the base gem keeps, because the alternative
|
|
62
|
+
# is this gem naming its own addons — and the moment it does, adding an addon
|
|
63
|
+
# means editing okf. `--engine` already set the precedent on the search side:
|
|
64
|
+
# an addon shows up in help *without the CLI knowing it exists*.
|
|
65
|
+
PLUGIN_FILE = "okf/plugin.rb"
|
|
66
|
+
|
|
67
|
+
# Only gems named `okf-*` are loaded — the namespacing convention Jekyll
|
|
68
|
+
# (`jekyll-*`) and Vagrant (`vagrant-*`) use, which is the reason the rule is
|
|
69
|
+
# here: it makes what counts as an okf extension explicit and stops an
|
|
70
|
+
# unrelated gem claiming the `okf/plugin.rb` path. It guards a little too,
|
|
71
|
+
# since `require` runs what it loads, but that window is nearly empty and
|
|
72
|
+
# overselling it would be worse than having no rule. The argument in full is
|
|
73
|
+
# at #plugin_paths.
|
|
74
|
+
PLUGIN_GEM_PREFIX = "okf-"
|
|
75
|
+
|
|
76
|
+
# What #plugin_gem_name answers when it cannot work out a path's owning gem
|
|
77
|
+
# at all — deliberately distinct from nil, which means "belongs to no gem"
|
|
78
|
+
# and is trusted. See #plugin_gem_name for why the two must not merge.
|
|
79
|
+
UNKNOWN_GEM = :unknown
|
|
80
|
+
|
|
81
|
+
# The map's shape: the order the groups print in, and the heading each one
|
|
82
|
+
# carries. Only extensions get a heading — the built-in groups are separated
|
|
83
|
+
# by a blank line and their verbs speak for themselves, which is how this map
|
|
84
|
+
# has always read. A plugin's verbs are labelled because "where did this come
|
|
85
|
+
# from?" is a question only an installed extension raises.
|
|
86
|
+
GROUPS = [
|
|
87
|
+
[ :act, nil ],
|
|
88
|
+
[ :registry, nil ],
|
|
89
|
+
[ :judge, nil ],
|
|
90
|
+
[ :read, nil ],
|
|
91
|
+
[ :graph, nil ],
|
|
92
|
+
[ :extension, " installed extensions:" ]
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
# Everything the map's grammar column cannot say for itself. A test finds the
|
|
96
|
+
# `@slug names` paragraph by its opening words, so the wording is load-bearing.
|
|
97
|
+
NOTE = <<~NOTE
|
|
98
|
+
@slug names a registered bundle instead of a path — the slug from
|
|
99
|
+
`okf registry set`, or bare @ for the registry default. Anywhere a <dir>
|
|
100
|
+
goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`.
|
|
101
|
+
The registry lives under $OKF_HOME (default ~/.okf); set it to point
|
|
102
|
+
every verb at another one.
|
|
103
|
+
search spans bundles: several leading @slugs, or @all for every registered one
|
|
104
|
+
(@all skips a bundle whose directory is gone; a named @slug insists on it).
|
|
105
|
+
|
|
106
|
+
[filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
|
|
107
|
+
(each view takes the ones orthogonal to it; matching is case-insensitive).
|
|
108
|
+
tags --by DIM regroups the tags per concept dimension — type or area — with
|
|
109
|
+
within-group counts, the view for curating a tag vocabulary.
|
|
110
|
+
--json emits compact JSON (the machine substrate); add --pretty to indent it.
|
|
111
|
+
--fields / --except project the JSON to the properties you want (search/index/catalog/files).
|
|
112
|
+
|
|
113
|
+
okf --version
|
|
114
|
+
NOTE
|
|
115
|
+
|
|
116
|
+
class << self
|
|
117
|
+
# Append-only and idempotent by id: a second registration of an id already
|
|
118
|
+
# present is a no-op, so a double `require` cannot double the registry and
|
|
119
|
+
# **an addon cannot quietly displace a built-in**. Deliberately the same
|
|
120
|
+
# shape as Search.register — three extension points, one idiom.
|
|
121
|
+
#
|
|
122
|
+
# The duck type is checked here rather than at dispatch, so a malformed
|
|
123
|
+
# command fails where it is installed instead of the first time somebody
|
|
124
|
+
# types its verb.
|
|
125
|
+
def register(command)
|
|
126
|
+
missing = Command::DUCK_TYPE.reject { |message| command.respond_to?(message) }
|
|
127
|
+
raise ArgumentError, "#{command} cannot be a command: it does not answer #{missing.join(", ")}" unless missing.empty?
|
|
128
|
+
|
|
129
|
+
@commands ||= []
|
|
130
|
+
existing = @commands.find { |registered| registered.id == command.id }
|
|
131
|
+
return register_declined(command, existing) if existing
|
|
132
|
+
|
|
133
|
+
@commands << command
|
|
134
|
+
command
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A frozen snapshot in registration order — which, for the built-ins, is
|
|
138
|
+
# the order this file requires them in at the bottom, and therefore the
|
|
139
|
+
# order `okf help` lists them in.
|
|
140
|
+
def commands
|
|
141
|
+
(@commands ||= []).dup.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def lookup(name)
|
|
145
|
+
return nil if OKF.blank?(name)
|
|
146
|
+
|
|
147
|
+
commands.find { |command| command.id.to_s == name.to_s }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Registrations refused because the id was taken. Kept so the refusal can
|
|
151
|
+
# be *reported* — Search can no-op in silence because an engine nobody
|
|
152
|
+
# selected is invisible either way, but a verb that silently does nothing
|
|
153
|
+
# is a bug report waiting to happen.
|
|
154
|
+
def declined
|
|
155
|
+
(@declined ||= []).dup.freeze
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Load every installed extension, once. Returns the failures as
|
|
159
|
+
# [ path, error ] pairs rather than printing them: this is a class method
|
|
160
|
+
# with no streams, and the CLI's whole contract is that nothing writes
|
|
161
|
+
# anywhere but the streams it was handed.
|
|
162
|
+
#
|
|
163
|
+
# A plugin that raises is *skipped and reported*, never fatal — the same
|
|
164
|
+
# best-effort posture the reader takes with an unparseable file. One broken
|
|
165
|
+
# addon must not cost a user their `okf lint`.
|
|
166
|
+
def load_plugins
|
|
167
|
+
return @plugin_failures if @plugins_loaded
|
|
168
|
+
|
|
169
|
+
@plugins_loaded = true
|
|
170
|
+
@plugin_failures = []
|
|
171
|
+
@loaded_plugins = []
|
|
172
|
+
plugin_paths.each do |path|
|
|
173
|
+
begin
|
|
174
|
+
require path
|
|
175
|
+
@loaded_plugins << path
|
|
176
|
+
rescue ::LoadError, ::StandardError => e
|
|
177
|
+
@plugin_failures << [ path, e ]
|
|
178
|
+
end
|
|
505
179
|
end
|
|
506
|
-
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
# A bad -o path (a missing directory, a permission denial) is a bad
|
|
552
|
-
# *argument*: exit 2 with the reason, never a backtrace and an exit code
|
|
553
|
-
# that means "failing bundle".
|
|
554
|
-
begin
|
|
555
|
-
File.write(options[:output], html)
|
|
556
|
-
rescue SystemCallError => e
|
|
557
|
-
return usage_error("cannot write #{options[:output]}: #{e.message}")
|
|
180
|
+
@plugin_failures
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Latest-version-only where RubyGems offers it, so two installed versions
|
|
184
|
+
# of the same addon cannot both register. The fallback keeps the floor:
|
|
185
|
+
# find_latest_files has been there since RubyGems 1.8, but the guard costs
|
|
186
|
+
# nothing and says which method the behaviour depends on.
|
|
187
|
+
#
|
|
188
|
+
# Narrowed to gems named `okf-*` — the convention Jekyll (`jekyll-*`) and
|
|
189
|
+
# Vagrant (`vagrant-*`) use for the same job, and the reason the rule is
|
|
190
|
+
# here: it makes what counts as an okf extension explicit, and stops an
|
|
191
|
+
# unrelated gem claiming the `okf/plugin.rb` path by accident.
|
|
192
|
+
#
|
|
193
|
+
# It is a mild guard as well, since `require` runs whatever it loads, but
|
|
194
|
+
# the window it closes is nearly empty and calling it a **defence** would
|
|
195
|
+
# invite the false confidence that is worse than having no rule at all. A
|
|
196
|
+
# transitive dependency is required by its parent in normal use, so
|
|
197
|
+
# `require "foo"` already runs foo's; under Bundler, discovery is
|
|
198
|
+
# bundle-scoped, so the Gemfile is an allowlist already. What is left is a
|
|
199
|
+
# pure-Ruby gem installed globally and then used by nothing — and nothing
|
|
200
|
+
# here saves anyone from a package deliberately installed under an `okf-`
|
|
201
|
+
# name, because `gem install` has already run on it.
|
|
202
|
+
#
|
|
203
|
+
# The rule underneath this one *is* load-bearing: naming a gem must never
|
|
204
|
+
# load it. See `plugin_gem_name` below, and
|
|
205
|
+
# .okf/design/extension-points.md for the argument in full.
|
|
206
|
+
def plugin_paths
|
|
207
|
+
# Cleared first, so the rescue below cannot return "found nothing" while
|
|
208
|
+
# leaving an earlier call's refusals standing to be reported again.
|
|
209
|
+
@untrusted_plugins = []
|
|
210
|
+
@plugin_gem_error = nil
|
|
211
|
+
@plugin_discovery_error = nil
|
|
212
|
+
@gem_index = nil
|
|
213
|
+
found = if Gem.respond_to?(:find_latest_files)
|
|
214
|
+
Gem.find_latest_files(PLUGIN_FILE)
|
|
215
|
+
else
|
|
216
|
+
Gem.find_files(PLUGIN_FILE)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
found.select do |path|
|
|
220
|
+
name = plugin_gem_name(path)
|
|
221
|
+
next true if trusted_gem?(name)
|
|
222
|
+
|
|
223
|
+
@untrusted_plugins << [ path, name ]
|
|
224
|
+
false
|
|
558
225
|
end
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
when
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
when
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
#
|
|
618
|
-
#
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
#
|
|
622
|
-
#
|
|
623
|
-
#
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
help_flag(o)
|
|
638
|
-
end
|
|
639
|
-
slug = positional(parser, argv) or return 2
|
|
640
|
-
no_extras?(argv) or return 2
|
|
641
|
-
|
|
642
|
-
reg = OKF::Registry.load
|
|
643
|
-
slug = registry_slug(slug, reg) or return 2
|
|
644
|
-
removed = reg.remove(slug)
|
|
645
|
-
return usage_error("no such bundle: #{slug}") unless removed
|
|
646
|
-
|
|
647
|
-
@out.puts "removed #{removed.slug}"
|
|
648
|
-
0
|
|
649
|
-
rescue OKF::Error => e
|
|
650
|
-
usage_error(e.message)
|
|
651
|
-
end
|
|
652
|
-
|
|
653
|
-
def registry_list(argv)
|
|
654
|
-
options = { json: false }
|
|
655
|
-
parser = OptionParser.new do |o|
|
|
656
|
-
o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
|
|
657
|
-
"okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
|
|
658
|
-
json_flags(o, options, "emit the registry as JSON")
|
|
659
|
-
help_flag(o)
|
|
660
|
-
end
|
|
661
|
-
begin
|
|
662
|
-
parser.parse!(argv)
|
|
663
|
-
rescue OptionParser::ParseError => e
|
|
664
|
-
@err.puts e.message
|
|
665
|
-
return 2
|
|
666
|
-
end
|
|
667
|
-
no_extras?(argv) or return 2
|
|
668
|
-
|
|
669
|
-
reg = OKF::Registry.load
|
|
670
|
-
return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
|
|
671
|
-
|
|
672
|
-
print_registry(reg)
|
|
673
|
-
0
|
|
674
|
-
rescue OKF::Error => e
|
|
675
|
-
usage_error(e.message)
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
# Choose which registered bundle a bare `okf server` opens at `/`, by moving
|
|
679
|
-
# it to the front of the registry. The listing is ordered and the JSON is
|
|
680
|
-
# meant to be hand-editable, so the move is stated rather than left to be
|
|
681
|
-
# discovered from a reordered file.
|
|
682
|
-
def registry_default(argv)
|
|
683
|
-
parser = OptionParser.new do |o|
|
|
684
|
-
o.banner = "Usage: okf registry default <@slug>\n " \
|
|
685
|
-
"moves it to the front — the first registered bundle is the default until you do"
|
|
686
|
-
help_flag(o)
|
|
687
|
-
end
|
|
688
|
-
slug = positional(parser, argv) or return 2
|
|
689
|
-
no_extras?(argv) or return 2
|
|
690
|
-
|
|
691
|
-
reg = OKF::Registry.load
|
|
692
|
-
slug = registry_slug(slug, reg) or return 2
|
|
693
|
-
reg.default = slug
|
|
694
|
-
@out.puts "default bundle → #{reg.default.slug} (now first)"
|
|
695
|
-
0
|
|
696
|
-
rescue OKF::Error => e
|
|
697
|
-
usage_error(e.message)
|
|
698
|
-
end
|
|
699
|
-
|
|
700
|
-
# The @ref grammar for a verb that takes a *slug*, read by name. These three
|
|
701
|
-
# must reach an entry whose directory is gone — that is the one worth
|
|
702
|
-
# deleting or renaming — so they cannot go through resolve_ref, which
|
|
703
|
-
# insists the directory exist. Without this the refs only appeared to work:
|
|
704
|
-
# `normalize` strips the `@` off `@slug`, so `default @slug` resolved by
|
|
705
|
-
# accident while a bare `@` normalized to "" and failed. Returns the slug,
|
|
706
|
-
# or nil after reporting.
|
|
707
|
-
def registry_slug(arg, registry)
|
|
708
|
-
return arg unless arg.start_with?("@")
|
|
709
|
-
|
|
710
|
-
asked = arg[1..-1]
|
|
711
|
-
return asked unless asked.empty?
|
|
712
|
-
|
|
713
|
-
default = registry.default
|
|
714
|
-
return default.slug if default
|
|
715
|
-
|
|
716
|
-
@err.puts "error: no bundle is registered, so `@` names nothing (okf registry set <dir>)"
|
|
717
|
-
nil
|
|
718
|
-
end
|
|
719
|
-
|
|
720
|
-
# Rename a registered bundle's slug — its mount path and switcher name.
|
|
721
|
-
def registry_rename(argv)
|
|
722
|
-
parser = OptionParser.new do |o|
|
|
723
|
-
o.banner = "Usage: okf registry rename <@slug> <new>"
|
|
724
|
-
help_flag(o)
|
|
725
|
-
end
|
|
726
|
-
parser.parse!(argv)
|
|
727
|
-
old_slug, new_slug = argv.shift(2)
|
|
728
|
-
if old_slug.nil? || new_slug.nil?
|
|
729
|
-
@err.puts parser.banner
|
|
730
|
-
return 2
|
|
731
|
-
end
|
|
732
|
-
no_extras?(argv) or return 2
|
|
733
|
-
|
|
734
|
-
reg = OKF::Registry.load
|
|
735
|
-
# The old name may be a ref; the new one is a name being minted, never one.
|
|
736
|
-
old_slug = registry_slug(old_slug, reg) or return 2
|
|
737
|
-
entry = reg.rename(old_slug, new_slug)
|
|
738
|
-
# The slug it *found*, not the argv that found it: rename normalizes to look
|
|
739
|
-
# the entry up, so echoing the raw ask names a bundle that never existed.
|
|
740
|
-
@out.puts "renamed #{OKF::Registry.normalize(old_slug)} → #{entry.slug}"
|
|
741
|
-
0
|
|
742
|
-
rescue OptionParser::ParseError => e
|
|
743
|
-
@err.puts e.message
|
|
744
|
-
2
|
|
745
|
-
rescue OKF::Error => e
|
|
746
|
-
usage_error(e.message)
|
|
747
|
-
end
|
|
748
|
-
|
|
749
|
-
# The registry verbs take an exact number of positionals — a leftover argument
|
|
750
|
-
# is a typo'd invocation, not something to drop silently.
|
|
751
|
-
def no_extras?(argv)
|
|
752
|
-
return true if argv.empty?
|
|
753
|
-
|
|
754
|
-
@err.puts "error: unexpected argument '#{argv.first}'"
|
|
755
|
-
false
|
|
756
|
-
end
|
|
757
|
-
|
|
758
|
-
def print_registry(reg)
|
|
759
|
-
return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
|
|
760
|
-
|
|
761
|
-
rows = reg.listing
|
|
762
|
-
width = rows.map { |row| row[:slug].length }.max
|
|
763
|
-
rows.each do |row|
|
|
764
|
-
marker = row[:default] ? "*" : " "
|
|
765
|
-
missing = row[:missing] ? " (missing)" : ""
|
|
766
|
-
@out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
|
|
767
|
-
end
|
|
768
|
-
end
|
|
769
|
-
|
|
770
|
-
def graph(argv)
|
|
771
|
-
options = { json: false, minimal: false, body: true }
|
|
772
|
-
parser = OptionParser.new do |o|
|
|
773
|
-
o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body]"
|
|
774
|
-
json_flags(o, options, "emit nodes and edges as JSON")
|
|
775
|
-
o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
|
|
776
|
-
o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
|
|
777
|
-
help_flag(o)
|
|
778
|
-
end
|
|
779
|
-
dir = positional_dir(parser, argv) or return 2
|
|
780
|
-
|
|
781
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
782
|
-
graph = folder.graph(minimal: options[:minimal], body: options[:body])
|
|
783
|
-
report_skipped(folder)
|
|
784
|
-
if options[:json]
|
|
785
|
-
# The head every view carries: a payload of nodes and edges that never
|
|
786
|
-
# says which bundle they came from is exactly what an agent holding
|
|
787
|
-
# several bundles has to guess at.
|
|
788
|
-
payload = bundle_head(dir).merge(graph.to_h)
|
|
789
|
-
payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
|
|
790
|
-
emit_json(payload)
|
|
791
|
-
else
|
|
792
|
-
@out.puts "Graph — #{bundle_label(dir)} (#{graph.nodes.size} #{pluralize(graph.nodes.size, "concept")}, " \
|
|
793
|
-
"#{graph.edges.size} #{pluralize(graph.edges.size, "link")})"
|
|
794
|
-
end
|
|
795
|
-
0
|
|
796
|
-
end
|
|
797
|
-
|
|
798
|
-
# The progressive-disclosure map (spec §6): every directory that holds concepts
|
|
799
|
-
# or carries an index.md, with its authored index body, a type/tag rollup, its
|
|
800
|
-
# child directories, and — for a directory with no index.md — the listing
|
|
801
|
-
# synthesized from the concepts there. The "orient before you read" view. `--area`
|
|
802
|
-
# is repeatable (one or many directories; `root` is the bundle root); `--no-body`
|
|
803
|
-
# drops the prose to a skeleton; advisory, exit 0.
|
|
804
|
-
def index(argv)
|
|
805
|
-
options = { json: false, body: true, areas: nil }
|
|
806
|
-
parser = OptionParser.new do |o|
|
|
807
|
-
o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
|
|
808
|
-
json_flags(o, options, "emit the index map as JSON")
|
|
809
|
-
projection_flags(o, options)
|
|
810
|
-
o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
|
|
811
|
-
o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
|
|
812
|
-
help_flag(o)
|
|
813
|
-
end
|
|
814
|
-
dir = positional_dir(parser, argv) or return 2
|
|
815
|
-
|
|
816
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
817
|
-
report_skipped(folder)
|
|
818
|
-
entries = folder.directory_index
|
|
819
|
-
selected = select_directories(entries, options[:areas])
|
|
820
|
-
if options[:json]
|
|
821
|
-
# --no-body is shorthand for --except body, so asking for the body by
|
|
822
|
-
# name in the same breath is a contradiction. Letting --fields quietly
|
|
823
|
-
# win would hand back the very thing the other flag was there to drop.
|
|
824
|
-
if !options[:body] && Array(options[:fields]).map(&:downcase).include?("body")
|
|
825
|
-
return usage_error("--no-body and --fields body contradict each other: drop one")
|
|
226
|
+
rescue ::StandardError => e
|
|
227
|
+
# The *search* failing is its own report, and the reason is the one
|
|
228
|
+
# `plugin_gem_name` already answers to one frame down: an empty list and
|
|
229
|
+
# no message is indistinguishable from a machine with nothing installed.
|
|
230
|
+
# There is no path left to hang a refusal on here, so the failure has to
|
|
231
|
+
# carry itself or it is not reported at all.
|
|
232
|
+
@plugin_discovery_error = e
|
|
233
|
+
[]
|
|
234
|
+
ensure
|
|
235
|
+
# Only a snapshot for the length of one discovery — a long-lived copy of
|
|
236
|
+
# every installed spec's path would outlive its usefulness and go stale.
|
|
237
|
+
@gem_index = nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Three answers, and the third has to stay distinct from the second: a gem
|
|
241
|
+
# name; nil when the path belongs to no gem at all — a bare $LOAD_PATH
|
|
242
|
+
# entry, which is how a checkout, `ruby -I`, a Gemfile `path:` and the
|
|
243
|
+
# suite's own fixtures appear, and which stays trusted because someone put
|
|
244
|
+
# it there; and UNKNOWN_GEM when the lookup itself failed.
|
|
245
|
+
#
|
|
246
|
+
# Answering nil for that last case is fail-open, and worth spelling out
|
|
247
|
+
# because it reads as harmless: enumerating the installed specs is what
|
|
248
|
+
# raises when one gemspec anywhere on the machine is corrupt — and every
|
|
249
|
+
# discovered path would come back "belongs to no gem" and load. A rule that
|
|
250
|
+
# quietly switches itself off under failure is the false confidence this
|
|
251
|
+
# one is deliberately modest to avoid, so a name that cannot be read is
|
|
252
|
+
# refused — and the cause is kept, because refusing every extension on the
|
|
253
|
+
# machine while naming no reason leaves the user nothing to act on.
|
|
254
|
+
#
|
|
255
|
+
# Resolved from the spec's full_gem_path rather than by loading anything:
|
|
256
|
+
# naming an extension must never mean running it.
|
|
257
|
+
def plugin_gem_name(path)
|
|
258
|
+
index = gem_index
|
|
259
|
+
return UNKNOWN_GEM if index.equal?(UNKNOWN_GEM)
|
|
260
|
+
|
|
261
|
+
found = index.find { |prefix, _name| path.start_with?(prefix) }
|
|
262
|
+
found&.last
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Whether a name clears the prefix rule. Three answers in, two out, and
|
|
266
|
+
# the middle one is the whole point: nil belongs to no gem — a checkout,
|
|
267
|
+
# `ruby -I`, a Gemfile `path:` — and stays trusted because someone put it
|
|
268
|
+
# there deliberately, while UNKNOWN_GEM is the lookup itself failing and is
|
|
269
|
+
# refused, because a rule that switches itself off when it cannot get an
|
|
270
|
+
# answer is the false confidence this one is deliberately modest to avoid.
|
|
271
|
+
def trusted_gem?(name)
|
|
272
|
+
return true if name.nil?
|
|
273
|
+
return false if name.equal?(UNKNOWN_GEM)
|
|
274
|
+
|
|
275
|
+
name.start_with?(PLUGIN_GEM_PREFIX)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Every installed gem's path with the name that owns it, or UNKNOWN_GEM if
|
|
279
|
+
# the specs could not be walked. Not a saving — it replaces a `find` that
|
|
280
|
+
# short-circuited on the first match with a `map` over all of them, so on
|
|
281
|
+
# the ordinary one-path discovery it is strictly more work (1.0ms for 282
|
|
282
|
+
# specs). It buys a shape instead: **one pass has one outcome**, so every
|
|
283
|
+
# path in a discovery gets the same answer. Per-path enumeration made that
|
|
284
|
+
# a lottery — a failure that cleared between paths, a gemspec rewritten by
|
|
285
|
+
# a concurrent `gem install`, would refuse one path and trust the next in
|
|
286
|
+
# the same run.
|
|
287
|
+
#
|
|
288
|
+
# Which is why the failure memoizes too. `||=` over a raising expression
|
|
289
|
+
# caches nothing, so the second path would try again and could get a
|
|
290
|
+
# different answer — the lottery back, in the branch the whole thing was
|
|
291
|
+
# written for.
|
|
292
|
+
#
|
|
293
|
+
# Built lazily, so the ordinary run — nothing discovered — never walks the
|
|
294
|
+
# specs at all.
|
|
295
|
+
def gem_index
|
|
296
|
+
@gem_index ||= begin
|
|
297
|
+
Gem::Specification.map do |spec|
|
|
298
|
+
full = spec.full_gem_path
|
|
299
|
+
[ full.end_with?(File::SEPARATOR) ? full : "#{full}#{File::SEPARATOR}", spec.name ]
|
|
300
|
+
end
|
|
301
|
+
rescue ::StandardError => e
|
|
302
|
+
@plugin_gem_error = e
|
|
303
|
+
UNKNOWN_GEM
|
|
826
304
|
end
|
|
827
|
-
|
|
828
|
-
options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
|
|
829
|
-
return print_index_map_json(dir, selected, options)
|
|
830
305
|
end
|
|
831
|
-
print_index_map(dir, selected, options[:body])
|
|
832
|
-
0
|
|
833
|
-
end
|
|
834
306
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
return entries if areas.nil? || areas.empty?
|
|
307
|
+
# Why a name could not be read, when one could not. Reported with the
|
|
308
|
+
# refusal it caused: "could not be determined" on its own names no gem to
|
|
309
|
+
# fix and no reason to look.
|
|
310
|
+
attr_reader :plugin_gem_error
|
|
840
311
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
312
|
+
# Why the search for extensions could not run at all, when it could not.
|
|
313
|
+
# Distinct from the above: that one refuses paths it found, this one found
|
|
314
|
+
# none — so there is nothing to refuse and the error is the only witness.
|
|
315
|
+
attr_reader :plugin_discovery_error
|
|
844
316
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
@
|
|
850
|
-
@out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
|
|
851
|
-
subdirs = entry[:subdirs]
|
|
852
|
-
@out.puts " → #{subdirs.map { |sub| "#{File.basename(sub)}/" }.join(" ")}" unless subdirs.empty?
|
|
853
|
-
if entry[:present]
|
|
854
|
-
print_index_body(entry[:body]) if body
|
|
855
|
-
else
|
|
856
|
-
print_synthesized_listing(entry[:listing])
|
|
857
|
-
end
|
|
317
|
+
# Paths discovered but refused for their gem's name, as [ path, gem ]
|
|
318
|
+
# pairs. Kept so the refusal can be *reported*: an extension that is
|
|
319
|
+
# present and deliberately not run is exactly the thing a user needs told.
|
|
320
|
+
def untrusted_plugins
|
|
321
|
+
(@untrusted_plugins ||= []).dup.freeze
|
|
858
322
|
end
|
|
859
|
-
end
|
|
860
|
-
|
|
861
|
-
def index_dir_label(entry)
|
|
862
|
-
base = entry[:dir] == "." ? "(root)" : "#{entry[:dir]}/"
|
|
863
|
-
entry[:present] ? base : "#{base} (no index.md)"
|
|
864
|
-
end
|
|
865
|
-
|
|
866
|
-
def index_dir_meta(entry)
|
|
867
|
-
count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
|
|
868
|
-
types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
|
|
869
|
-
types.empty? ? " · #{count}" : " · #{count} · #{types}"
|
|
870
|
-
end
|
|
871
|
-
|
|
872
|
-
def print_index_body(body)
|
|
873
|
-
text = body.to_s.strip
|
|
874
|
-
return if text.empty?
|
|
875
323
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
@out.puts " • #{item[:title]}#{suffix}"
|
|
324
|
+
# Called once at the bottom of this file, after the built-ins have
|
|
325
|
+
# registered. Everything registered after it is an extension — which makes
|
|
326
|
+
# "built-in" a fact the CLI knows rather than a group a command claims,
|
|
327
|
+
# and gives a test somewhere to roll back to.
|
|
328
|
+
def seal_builtins!
|
|
329
|
+
@builtins = commands
|
|
883
330
|
end
|
|
884
|
-
end
|
|
885
|
-
|
|
886
|
-
def print_index_map_json(dir, entries, options)
|
|
887
|
-
emit_list_json(dir, "directories", entries.map { |entry| index_map_entry_json(entry) }, options)
|
|
888
|
-
end
|
|
889
|
-
|
|
890
|
-
def index_map_entry_json(entry)
|
|
891
|
-
{
|
|
892
|
-
"dir" => entry[:dir], "index_path" => entry[:index_path],
|
|
893
|
-
"present" => entry[:present], "synthesized" => entry[:synthesized],
|
|
894
|
-
"count" => entry[:count], "types" => entry[:types], "tags" => entry[:tags],
|
|
895
|
-
"subdirs" => entry[:subdirs], "body" => entry[:body],
|
|
896
|
-
"listing" => entry[:listing].map { |item| stringify(item) }
|
|
897
|
-
}
|
|
898
|
-
end
|
|
899
331
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
# all are advisory reads (exit 0). They share OKF::Bundle#catalog for their data,
|
|
904
|
-
# and (with `types`) narrow through the same --type/--area/--tag filters the
|
|
905
|
-
# server UI offers, so browser and CLI can answer the same questions.
|
|
906
|
-
|
|
907
|
-
def catalog(argv)
|
|
908
|
-
options = { json: false }
|
|
909
|
-
parser = OptionParser.new do |o|
|
|
910
|
-
o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
911
|
-
json_flags(o, options, "emit the catalog as JSON")
|
|
912
|
-
projection_flags(o, options)
|
|
913
|
-
filter_flags(o, options, :type, :area, :tag)
|
|
914
|
-
help_flag(o)
|
|
332
|
+
# The verbs this gem ships, frozen at seal time.
|
|
333
|
+
def builtins
|
|
334
|
+
(@builtins ||= []).dup.freeze
|
|
915
335
|
end
|
|
916
|
-
dir = positional_dir(parser, argv) or return 2
|
|
917
|
-
|
|
918
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
919
|
-
report_skipped(folder)
|
|
920
|
-
entries = folder.catalog
|
|
921
|
-
selected = filter_entries(entries, options)
|
|
922
|
-
return print_catalog_json(dir, selected, options) if options[:json]
|
|
923
|
-
|
|
924
|
-
print_catalog(dir, selected, entries.size)
|
|
925
|
-
0
|
|
926
|
-
end
|
|
927
336
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
parser = OptionParser.new do |o|
|
|
931
|
-
o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
932
|
-
json_flags(o, options, "emit the file tree as JSON")
|
|
933
|
-
projection_flags(o, options)
|
|
934
|
-
filter_flags(o, options, :type, :area, :tag)
|
|
935
|
-
help_flag(o)
|
|
337
|
+
def extension?(command)
|
|
338
|
+
!builtins.include?(command)
|
|
936
339
|
end
|
|
937
|
-
dir = positional_dir(parser, argv) or return 2
|
|
938
|
-
|
|
939
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
940
|
-
report_skipped(folder)
|
|
941
|
-
entries = folder.catalog
|
|
942
|
-
selected = filter_entries(entries, options)
|
|
943
|
-
return print_files_json(dir, selected, options) if options[:json]
|
|
944
340
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
341
|
+
# Test seam: put the registry back to what shipped and forget the load
|
|
342
|
+
# latch. Registration happens at require time, so without this a test that
|
|
343
|
+
# installs a fake plugin would leak it into every test that runs after it.
|
|
344
|
+
#
|
|
345
|
+
# Dropping the files from $LOADED_FEATURES is not optional, and the reason
|
|
346
|
+
# is worth stating: `require` is idempotent, so clearing the registry
|
|
347
|
+
# alone leaves a plugin *unregistered and unloadable* — the next
|
|
348
|
+
# load_plugins would find the file, require it, get `false`, and register
|
|
349
|
+
# nothing. That only stays hidden while each test writes its plugin to a
|
|
350
|
+
# fresh tmpdir; the moment one points at a real gem's lib/, the verb
|
|
351
|
+
# vanishes after the first reset.
|
|
352
|
+
def reset_plugins!
|
|
353
|
+
Array(@loaded_plugins).each { |path| $LOADED_FEATURES.delete(path) }
|
|
354
|
+
@loaded_plugins = []
|
|
355
|
+
# Unconditional, because there is always a seal to roll back to:
|
|
356
|
+
# `seal_builtins!` runs at the bottom of this file, so a caller that can
|
|
357
|
+
# name this method has already loaded it. An earlier version guarded on
|
|
358
|
+
# `@builtins.nil?` to cover a pre-seal call — a state that cannot occur,
|
|
359
|
+
# and a test that could not have detected it either way, since `builtins`
|
|
360
|
+
# memoizes `@builtins ||= []` and so stops being nil on its first read.
|
|
361
|
+
@commands = builtins.dup
|
|
362
|
+
@plugins_loaded = false
|
|
363
|
+
@plugin_failures = []
|
|
364
|
+
@plugin_gem_error = nil
|
|
365
|
+
@plugin_discovery_error = nil
|
|
366
|
+
@untrusted_plugins = []
|
|
367
|
+
@declined = []
|
|
972
368
|
end
|
|
973
|
-
dir = positional_dir(parser, argv) or return 2
|
|
974
369
|
|
|
975
|
-
|
|
976
|
-
end
|
|
370
|
+
private
|
|
977
371
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
report_skipped(folder)
|
|
982
|
-
graph = folder.graph(minimal: true)
|
|
983
|
-
index = key == :tag ? graph.tag_index : graph.type_index
|
|
984
|
-
rows = index_rows(index, key, folder, options)
|
|
985
|
-
if options[:json]
|
|
986
|
-
print_index_json(dir, plural, key, rows)
|
|
987
|
-
else
|
|
988
|
-
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
989
|
-
print_index(dir, label, key, rows, titles)
|
|
372
|
+
def register_declined(command, existing)
|
|
373
|
+
(@declined ||= []) << [ command, existing ] unless existing.equal?(command)
|
|
374
|
+
existing
|
|
990
375
|
end
|
|
991
|
-
0
|
|
992
|
-
end
|
|
993
|
-
|
|
994
|
-
# `tags --by type|area`: the tag index re-cut per concept type or top-level
|
|
995
|
-
# area, with within-group counts — the curation view. A tag confined to one
|
|
996
|
-
# group at count 1 is scattered; one recurring across groups is connective.
|
|
997
|
-
# The --type/--area filters narrow the concepts first, then the grouping cuts.
|
|
998
|
-
def grouped_tags(dir, options)
|
|
999
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
1000
|
-
report_skipped(folder)
|
|
1001
|
-
graph = folder.graph(minimal: true)
|
|
1002
|
-
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
1003
|
-
groups = tag_groups(graph.tag_index, folder, options)
|
|
1004
|
-
options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
|
|
1005
|
-
0
|
|
1006
376
|
end
|
|
1007
377
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
def tag_groups(tag_index, folder, options)
|
|
1011
|
-
by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
|
|
1012
|
-
groups = {}
|
|
1013
|
-
tag_index.each do |tag, ids|
|
|
1014
|
-
ids.each do |id|
|
|
1015
|
-
entry = by_id[id]
|
|
1016
|
-
next if entry.nil?
|
|
1017
|
-
|
|
1018
|
-
key = options[:by] == :type ? entry_type(entry) : entry[:area]
|
|
1019
|
-
((groups[key] ||= {})[tag] ||= []) << id
|
|
1020
|
-
end
|
|
1021
|
-
end
|
|
1022
|
-
groups.map do |key, tags|
|
|
1023
|
-
rows = tags.map { |tag, ids| { tag: tag, count: ids.length, concepts: ids } }
|
|
1024
|
-
.sort_by { |row| [ -row[:count], row[:tag] ] }
|
|
1025
|
-
[ key, rows ]
|
|
1026
|
-
end.sort_by(&:first)
|
|
378
|
+
def self.start(argv, out: $stdout, err: $stderr, input: $stdin)
|
|
379
|
+
new(out: out, err: err, input: input).run(argv)
|
|
1027
380
|
end
|
|
1028
381
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
382
|
+
def initialize(out: $stdout, err: $stderr, runner: WEBRICK, input: $stdin)
|
|
383
|
+
@out = out
|
|
384
|
+
@err = err
|
|
385
|
+
@runner = runner
|
|
386
|
+
@input = input
|
|
387
|
+
@plugin_notes_reported = false
|
|
1032
388
|
end
|
|
1033
389
|
|
|
1034
|
-
def
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
390
|
+
def run(argv)
|
|
391
|
+
argv = argv.dup
|
|
392
|
+
# -h/--help is answered wherever a parser sees it — deep inside
|
|
393
|
+
# positional_dir, where returning would only mean "usage error, exit 2".
|
|
394
|
+
# Thrown here instead, so help keeps the contract every other path keeps:
|
|
395
|
+
# a status this method returns. See Command#help_flag.
|
|
396
|
+
catch(:help) do
|
|
397
|
+
case (name = argv.shift)
|
|
398
|
+
when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
|
|
399
|
+
when "help", "--help", "-h" then usage(@out); 0
|
|
400
|
+
when nil then usage(@err); 2
|
|
401
|
+
else dispatch(name, argv)
|
|
1044
402
|
end
|
|
1045
403
|
end
|
|
1046
404
|
end
|
|
1047
405
|
|
|
1048
|
-
|
|
1049
|
-
groups_json = groups.map do |key, rows|
|
|
1050
|
-
{ dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
|
|
1051
|
-
end
|
|
1052
|
-
emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
|
|
1053
|
-
end
|
|
1054
|
-
|
|
1055
|
-
def distinct_tags(groups)
|
|
1056
|
-
groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.size
|
|
1057
|
-
end
|
|
1058
|
-
|
|
1059
|
-
def stats(argv)
|
|
1060
|
-
options = { json: false }
|
|
1061
|
-
parser = OptionParser.new do |o|
|
|
1062
|
-
o.banner = "Usage: okf stats <dir|@slug> [--json]"
|
|
1063
|
-
json_flags(o, options, "emit the stats as JSON")
|
|
1064
|
-
help_flag(o)
|
|
1065
|
-
end
|
|
1066
|
-
dir = positional_dir(parser, argv) or return 2
|
|
1067
|
-
|
|
1068
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
1069
|
-
report_skipped(folder)
|
|
1070
|
-
stats = bundle_stats(folder)
|
|
1071
|
-
options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
|
|
1072
|
-
0
|
|
1073
|
-
end
|
|
1074
|
-
|
|
1075
|
-
# Bundle-level rollups derived from the catalog and the graph indexes.
|
|
1076
|
-
def bundle_stats(folder)
|
|
1077
|
-
graph = folder.graph(minimal: true)
|
|
1078
|
-
entries = folder.catalog
|
|
1079
|
-
by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
1080
|
-
by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
1081
|
-
{
|
|
1082
|
-
concepts: entries.size,
|
|
1083
|
-
areas: by_area.size,
|
|
1084
|
-
types: by_type.size,
|
|
1085
|
-
cross_links: graph.edges.size,
|
|
1086
|
-
tags: graph.tag_index.size,
|
|
1087
|
-
by_type: by_type,
|
|
1088
|
-
by_area: by_area
|
|
1089
|
-
}
|
|
1090
|
-
end
|
|
1091
|
-
|
|
1092
|
-
# ── the read views' shared --type/--area/--tag narrowing ──
|
|
1093
|
-
# Each view takes the filters orthogonal to it (tags can't filter by tag).
|
|
1094
|
-
# Matching is case-insensitive and exact; a concept at the bundle root lives in
|
|
1095
|
-
# the "(root)" area, which --area also accepts as plain `root` (no shell quoting).
|
|
1096
|
-
|
|
1097
|
-
# The --json / --pretty pair every emitting verb shares. --json is the compact
|
|
1098
|
-
# machine substrate (the default JSON form, aligned with the server); --pretty
|
|
1099
|
-
# indents it for a human and implies --json. Both route through emit_json.
|
|
1100
|
-
def json_flags(parser, options, desc)
|
|
1101
|
-
parser.on("--json", desc) { options[:json] = true }
|
|
1102
|
-
parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
|
|
1103
|
-
end
|
|
1104
|
-
|
|
1105
|
-
# Every parser answers its own -h/--help, so no parser inherits
|
|
1106
|
-
# OptionParser's officious one: that prints to the process's $stdout rather
|
|
1107
|
-
# than @out (an embedding app that injects streams never sees it) and ends
|
|
1108
|
-
# the process with `exit` rather than returning a status (a test that asks a
|
|
1109
|
-
# command for help takes the whole runner down with it). Thrown, not
|
|
1110
|
-
# returned — #run catches it — because a parser is parsed inside
|
|
1111
|
-
# positional_dir, where every other early exit means "exit 2".
|
|
1112
|
-
# on_tail, so help sorts last in the list it is printing.
|
|
1113
|
-
def help_flag(parser)
|
|
1114
|
-
parser.on_tail("-h", "--help", "print this message") do
|
|
1115
|
-
@out.puts parser.help
|
|
1116
|
-
throw :help, 0
|
|
1117
|
-
end
|
|
1118
|
-
end
|
|
1119
|
-
|
|
1120
|
-
# --fields/--except project the JSON down to the properties an agent wants, so it
|
|
1121
|
-
# never pays tokens for fields it will not read. --fields is an allowlist,
|
|
1122
|
-
# --except a denylist (mutually exclusive); both imply --json and apply per item
|
|
1123
|
-
# in a list view (catalog, files, index). Names are the JSON keys, matched
|
|
1124
|
-
# case-insensitively.
|
|
1125
|
-
def projection_flags(parser, options)
|
|
1126
|
-
parser.on("--fields LIST", Array, "emit only these JSON properties (comma-separated)") { |v| options[:json] = true; options[:fields] = v }
|
|
1127
|
-
parser.on("--except LIST", Array, "emit every JSON property but these") { |v| options[:json] = true; options[:except] = v }
|
|
1128
|
-
end
|
|
1129
|
-
|
|
1130
|
-
def filter_flags(parser, options, *keys)
|
|
1131
|
-
parser.on("--type TYPE", "only concepts of this type") { |v| options[:type] = v } if keys.include?(:type)
|
|
1132
|
-
parser.on("--area AREA", "only concepts in this top-level area") { |v| options[:area] = v } if keys.include?(:area)
|
|
1133
|
-
parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
|
|
1134
|
-
end
|
|
406
|
+
private
|
|
1135
407
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
408
|
+
# Built-ins answer without a plugin ever being loaded — the scan only
|
|
409
|
+
# happens once a name misses, which is every run of `okf lint` and no run
|
|
410
|
+
# of `okf tui`. Discovery is cheap (about 11ms on the 2.4 floor) but not
|
|
411
|
+
# free, and a one-shot CLI that already refuses to build a search index for
|
|
412
|
+
# a single query should not pay it to answer a verb it shipped with.
|
|
413
|
+
def dispatch(name, argv)
|
|
414
|
+
command = self.class.lookup(name) || begin
|
|
415
|
+
report_plugin_failures(self.class.load_plugins)
|
|
416
|
+
self.class.lookup(name)
|
|
1141
417
|
end
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
def fold(value)
|
|
1145
|
-
value.to_s.downcase
|
|
1146
|
-
end
|
|
1147
|
-
|
|
1148
|
-
def fold_area(value)
|
|
1149
|
-
folded = fold(value)
|
|
1150
|
-
folded == "root" ? "(root)" : folded
|
|
1151
|
-
end
|
|
418
|
+
return unknown(name) if command.nil?
|
|
1152
419
|
|
|
1153
|
-
|
|
1154
|
-
# count, narrowed to the concepts the active filters select; rows the narrowing
|
|
1155
|
-
# empties drop. With no filters the index passes through whole.
|
|
1156
|
-
def index_rows(index, key, folder, options)
|
|
1157
|
-
keep = filter_ids(folder, options)
|
|
1158
|
-
index.each_with_object([]) do |(value, ids), rows|
|
|
1159
|
-
ids = ids.select { |id| keep.include?(id) } unless keep.nil?
|
|
1160
|
-
rows << { key => value, count: ids.length, concepts: ids } unless ids.empty?
|
|
1161
|
-
end.sort_by { |row| [ -row[:count], row[key] ] }
|
|
420
|
+
command.new(out: @out, err: @err, runner: @runner, input: @input).call(argv)
|
|
1162
421
|
end
|
|
1163
422
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
return nil if options[:type].nil? && options[:area].nil? && options[:tag].nil?
|
|
1168
|
-
|
|
1169
|
-
filter_entries(folder.catalog, options).map { |entry| entry[:id] }
|
|
1170
|
-
end
|
|
1171
|
-
|
|
1172
|
-
# Install this gem's companion agent skill into a destination directory. The
|
|
1173
|
-
# destination is required (no magic default) so the user always decides where
|
|
1174
|
-
# their agent picks the skill up. By default the skill lands in a skills/okf/
|
|
1175
|
-
# folder under it — point at a project or skills dir (.claude, .agents/skills)
|
|
1176
|
-
# and it settles in its own folder, never loose among the others — so the
|
|
1177
|
-
# resolved path is echoed back. --here installs straight into <dest-dir>.
|
|
1178
|
-
def skill(argv)
|
|
1179
|
-
options = { force: false, nest: true }
|
|
1180
|
-
parser = OptionParser.new do |o|
|
|
1181
|
-
o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
|
|
1182
|
-
o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
|
|
1183
|
-
o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
|
|
1184
|
-
help_flag(o)
|
|
1185
|
-
end
|
|
1186
|
-
parser.parse!(argv)
|
|
1187
|
-
dest = argv.shift
|
|
1188
|
-
if dest.nil?
|
|
1189
|
-
@err.puts parser.banner
|
|
1190
|
-
return 2
|
|
1191
|
-
end
|
|
1192
|
-
|
|
1193
|
-
skill = OKF::Skill.new(dest, force: options[:force], nest: options[:nest])
|
|
1194
|
-
files = skill.install
|
|
1195
|
-
@out.puts "installed the okf skill (#{files.size} files) -> #{skill.dest}"
|
|
1196
|
-
files.each { |f| @out.puts " #{f}" }
|
|
1197
|
-
@out.puts "your agent picks it up from #{skill.dest} (needs the `okf` CLI, which you already have)."
|
|
1198
|
-
0
|
|
1199
|
-
rescue OptionParser::ParseError => e
|
|
1200
|
-
@err.puts e.message
|
|
423
|
+
def unknown(name)
|
|
424
|
+
@err.puts "okf: unknown command '#{name}'"
|
|
425
|
+
usage(@err)
|
|
1201
426
|
2
|
|
1202
|
-
rescue OKF::Skill::Error => e
|
|
1203
|
-
@err.puts "error: #{e.message}"
|
|
1204
|
-
2
|
|
1205
|
-
end
|
|
1206
|
-
|
|
1207
|
-
# §9 best-effort: the graph is built from concepts that parse. Surface any that
|
|
1208
|
-
# the reader could not parse (to stderr, so JSON on stdout stays clean) rather
|
|
1209
|
-
# than dropping them silently.
|
|
1210
|
-
def report_skipped(folder)
|
|
1211
|
-
note_skipped(folder.bundle.unparseable.size)
|
|
1212
|
-
end
|
|
1213
|
-
|
|
1214
|
-
# The bucket holds two kinds now — frontmatter that would not parse, and a
|
|
1215
|
-
# file that would not open — so the note names neither and points at the verb
|
|
1216
|
-
# that names both. "invalid frontmatter" was a guess the summary had no need
|
|
1217
|
-
# to make: `validate` prints the file and the reason for every one of them.
|
|
1218
|
-
def note_skipped(count)
|
|
1219
|
-
return if count.nil? || count <= 0
|
|
1220
|
-
|
|
1221
|
-
@err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
|
|
1222
427
|
end
|
|
1223
428
|
|
|
1224
|
-
#
|
|
1225
|
-
#
|
|
1226
|
-
# bad value.
|
|
1227
|
-
def parse_stale_after(value)
|
|
1228
|
-
return nil if value.nil?
|
|
1229
|
-
|
|
1230
|
-
if (match = value.match(/\A(\d+)([dw])\z/))
|
|
1231
|
-
days = match[1].to_i * (match[2] == "w" ? 7 : 1)
|
|
1232
|
-
Time.now - (days * 86_400)
|
|
1233
|
-
else
|
|
1234
|
-
Date.iso8601(value).to_time
|
|
1235
|
-
end
|
|
1236
|
-
rescue ArgumentError
|
|
1237
|
-
:invalid
|
|
1238
|
-
end
|
|
1239
|
-
|
|
1240
|
-
# Parse options, then require a single bundle positional — a directory, or an
|
|
1241
|
-
# @ref into the registry. Returns the bundle's directory, or nil (after
|
|
1242
|
-
# reporting) so the caller returns 2.
|
|
1243
|
-
def positional_dir(parser, argv)
|
|
1244
|
-
parser.parse!(argv)
|
|
1245
|
-
dir = argv.shift
|
|
1246
|
-
if dir.nil?
|
|
1247
|
-
@err.puts parser.banner
|
|
1248
|
-
return nil
|
|
1249
|
-
end
|
|
1250
|
-
# A second bundle is a question this verb cannot answer: only `search`
|
|
1251
|
-
# merges across bundles and only `server` mounts several. Reading the
|
|
1252
|
-
# first and dropping the rest would answer confidently about a bundle the
|
|
1253
|
-
# user never asked about — the silent-wrong-answer shape, so: exit 2.
|
|
1254
|
-
return nil unless no_extras?(argv)
|
|
1255
|
-
|
|
1256
|
-
resolve_ref(dir)
|
|
1257
|
-
rescue OptionParser::ParseError => e
|
|
1258
|
-
@err.puts e.message
|
|
1259
|
-
nil
|
|
1260
|
-
end
|
|
1261
|
-
|
|
1262
|
-
# Parse options, then take zero or more bundle positionals (the multi-bundle
|
|
1263
|
-
# server) — directories or @refs. Returns the resolved array (possibly
|
|
1264
|
-
# empty), or nil (after reporting) so the caller returns 2.
|
|
1265
|
-
def positional_dirs(parser, argv)
|
|
1266
|
-
parser.parse!(argv)
|
|
1267
|
-
dirs = argv.map { |dir| resolve_ref(dir) }
|
|
1268
|
-
dirs.include?(nil) ? nil : dirs
|
|
1269
|
-
rescue OptionParser::ParseError => e
|
|
1270
|
-
@err.puts e.message
|
|
1271
|
-
nil
|
|
1272
|
-
end
|
|
1273
|
-
|
|
1274
|
-
# "@slug" — or bare "@", the registry's default — names a registered bundle
|
|
1275
|
-
# wherever a <dir> goes; anything else must be a directory on disk. A
|
|
1276
|
-
# leading @ always means the registry (a directory literally named that way
|
|
1277
|
-
# stays reachable as ./@name), and the registry loads only when a ref
|
|
1278
|
-
# appears, so plain-dir invocations never pay for it. Returns the bundle's
|
|
1279
|
-
# directory, or nil after reporting.
|
|
1280
|
-
def resolve_ref(arg)
|
|
1281
|
-
return resolve_registered(arg) if arg.start_with?("@")
|
|
1282
|
-
|
|
1283
|
-
unless File.directory?(arg)
|
|
1284
|
-
@err.puts "error: #{arg} is not a directory"
|
|
1285
|
-
return nil
|
|
1286
|
-
end
|
|
1287
|
-
arg
|
|
1288
|
-
end
|
|
1289
|
-
|
|
1290
|
-
# Load the registry, turning a malformed file into a reported usage error
|
|
1291
|
-
# instead of an OKF::Error escaping through whatever verb took an @ref —
|
|
1292
|
-
# only `server` and the `registry` verbs rescue one. Returns nil after
|
|
1293
|
-
# reporting, so every caller returns 2.
|
|
1294
|
-
def load_registry
|
|
1295
|
-
require "okf/registry"
|
|
1296
|
-
OKF::Registry.load
|
|
1297
|
-
rescue OKF::Error => e
|
|
1298
|
-
@err.puts "error: #{e.message}"
|
|
1299
|
-
nil
|
|
1300
|
-
end
|
|
1301
|
-
|
|
1302
|
-
# Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
|
|
1303
|
-
# The slug part is normalized
|
|
1304
|
-
# exactly as registration normalized it, so @One finds the bundle
|
|
1305
|
-
# registered from dir One — but never through #slugify's mint-a-name
|
|
1306
|
-
# placeholder, so "@***" is a bad ref rather than whatever is slugged
|
|
1307
|
-
# "bundle". An explicit ask fails hard: an unknown slug or a
|
|
1308
|
-
# registered-but-gone directory is a usage error naming the registry file
|
|
1309
|
-
# and the next move, never a silent skip.
|
|
429
|
+
# On stderr, so a `--json` run's stdout stays a clean machine substrate even
|
|
430
|
+
# when an addon is broken — or when one was deliberately not run.
|
|
1310
431
|
#
|
|
1311
|
-
#
|
|
1312
|
-
#
|
|
1313
|
-
#
|
|
1314
|
-
#
|
|
1315
|
-
#
|
|
1316
|
-
#
|
|
1317
|
-
#
|
|
1318
|
-
def
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
hint = registry.empty? ? "okf registry set <dir>" : "okf registry list"
|
|
1339
|
-
@err.puts "error: not a registered bundle: #{ref} in #{registry.path} (#{hint})"
|
|
1340
|
-
return nil
|
|
1341
|
-
end
|
|
1342
|
-
unless File.directory?(entry.path)
|
|
1343
|
-
@ref_failure = :missing
|
|
1344
|
-
@err.puts "error: #{ref} points to #{entry.path}, which is not a directory (okf registry del #{entry.slug}, or restore it)"
|
|
1345
|
-
return nil
|
|
1346
|
-
end
|
|
1347
|
-
ref_slugs[entry.path] = entry.slug
|
|
1348
|
-
entry.path
|
|
1349
|
-
end
|
|
1350
|
-
|
|
1351
|
-
# Which slug each @ref resolved to, by absolute path — so a hub built from
|
|
1352
|
-
# refs mounts each bundle under its registered slug, not its dir basename.
|
|
1353
|
-
# Reset by every run; never memoized here, or a stale run would seed it.
|
|
1354
|
-
attr_reader :ref_slugs
|
|
1355
|
-
|
|
1356
|
-
# Every bundle-scoped output names its bundle in the identity the caller
|
|
1357
|
-
# used: `@handbook (/path)` when they named a registered bundle, the plain
|
|
1358
|
-
# path otherwise. A dir named by path stays a path — inventing a slug for it
|
|
1359
|
-
# would imply a registration that does not exist, and looking one up would
|
|
1360
|
-
# cost a registry read on every plain-dir run.
|
|
1361
|
-
def bundle_label(dir)
|
|
1362
|
-
slug = ref_slugs[dir]
|
|
1363
|
-
slug ? "@#{slug} (#{dir})" : dir.to_s
|
|
1364
|
-
end
|
|
1365
|
-
|
|
1366
|
-
# The JSON head for one bundle. `bundle` is always its directory and `slug`
|
|
1367
|
-
# always a registry slug — never the same key meaning two things — so a
|
|
1368
|
-
# consumer resolves a row to a file without a second lookup.
|
|
1369
|
-
def bundle_head(dir)
|
|
1370
|
-
head = { "bundle" => dir }
|
|
1371
|
-
slug = ref_slugs[dir]
|
|
1372
|
-
head["slug"] = slug if slug
|
|
1373
|
-
head
|
|
1374
|
-
end
|
|
1375
|
-
|
|
1376
|
-
# Parse options, then require a single non-directory positional (e.g. a slug).
|
|
1377
|
-
# Returns it, or nil (after reporting the banner) so the caller returns 2.
|
|
1378
|
-
def positional(parser, argv)
|
|
1379
|
-
parser.parse!(argv)
|
|
1380
|
-
value = argv.shift
|
|
1381
|
-
if value.nil?
|
|
1382
|
-
@err.puts parser.banner
|
|
1383
|
-
return nil
|
|
1384
|
-
end
|
|
1385
|
-
value
|
|
1386
|
-
rescue OptionParser::ParseError => e
|
|
1387
|
-
@err.puts e.message
|
|
1388
|
-
nil
|
|
1389
|
-
end
|
|
1390
|
-
|
|
1391
|
-
def print_validation(dir, result)
|
|
1392
|
-
counts = result.counts
|
|
1393
|
-
@out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
|
|
1394
|
-
@out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
|
|
1395
|
-
result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
|
|
1396
|
-
result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
|
|
1397
|
-
if result.valid? && result.warnings.empty?
|
|
1398
|
-
@out.puts " #{paint("✓ conformant — no issues", 32)}"
|
|
1399
|
-
elsif result.valid?
|
|
1400
|
-
@out.puts " #{paint("✓ conformant", 32)} (#{result.warnings.size} warning(s))"
|
|
1401
|
-
else
|
|
1402
|
-
@out.puts " #{paint("✗ non-conformant", 31)} (#{result.errors.size} error(s))"
|
|
1403
|
-
end
|
|
1404
|
-
end
|
|
1405
|
-
|
|
1406
|
-
def print_validation_json(dir, result)
|
|
1407
|
-
emit_json(bundle_head(dir).merge(
|
|
1408
|
-
"conformant" => result.valid?,
|
|
1409
|
-
"counts" => result.counts,
|
|
1410
|
-
"errors" => result.errors,
|
|
1411
|
-
"warnings" => result.warnings
|
|
1412
|
-
))
|
|
1413
|
-
end
|
|
1414
|
-
|
|
1415
|
-
def print_lint(dir, report)
|
|
1416
|
-
stats = report.stats
|
|
1417
|
-
@out.puts "OKF lint — #{bundle_label(dir)}"
|
|
1418
|
-
@out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
|
|
1419
|
-
summary = lint_summary(stats)
|
|
1420
|
-
@out.puts " #{summary}" unless summary.empty?
|
|
1421
|
-
|
|
1422
|
-
LINT_CATEGORIES.each do |name, checks|
|
|
1423
|
-
findings = report.findings.select { |finding| checks.include?(finding[:check]) }
|
|
1424
|
-
next if findings.empty?
|
|
1425
|
-
|
|
1426
|
-
@out.puts
|
|
1427
|
-
@out.puts " #{name}"
|
|
1428
|
-
findings.each do |finding|
|
|
1429
|
-
@out.puts " #{lint_glyph(finding)} #{[ finding[:path], finding[:message] ].compact.join(": ")}"
|
|
1430
|
-
end
|
|
1431
|
-
end
|
|
1432
|
-
|
|
1433
|
-
@out.puts
|
|
1434
|
-
@out.puts " #{lint_verdict(report)}"
|
|
1435
|
-
end
|
|
1436
|
-
|
|
1437
|
-
def print_lint_json(dir, report)
|
|
1438
|
-
emit_json(bundle_head(dir).merge(
|
|
1439
|
-
"healthy" => report.healthy?,
|
|
1440
|
-
"stats" => report.stats,
|
|
1441
|
-
"findings" => report.findings
|
|
1442
|
-
))
|
|
1443
|
-
end
|
|
1444
|
-
|
|
1445
|
-
# Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
|
|
1446
|
-
# `unlinked` check reports, resolved to titles/folders for display.
|
|
1447
|
-
def loose_files(graph)
|
|
1448
|
-
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
1449
|
-
graph.unlinked_ids
|
|
1450
|
-
.map { |id| { id: id, title: titles[id], dir: File.dirname("#{id}.md") } }
|
|
1451
|
-
.sort_by { |file| file[:id] }
|
|
1452
|
-
end
|
|
1453
|
-
|
|
1454
|
-
def print_loose(dir, files)
|
|
1455
|
-
@out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
|
|
1456
|
-
if files.empty?
|
|
1457
|
-
@out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
|
|
1458
|
-
return
|
|
1459
|
-
end
|
|
1460
|
-
|
|
1461
|
-
files.group_by { |file| file[:dir] }.sort_by(&:first).each do |folder, group|
|
|
1462
|
-
width = group.map { |file| File.basename("#{file[:id]}.md").length }.max
|
|
1463
|
-
@out.puts
|
|
1464
|
-
@out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
|
|
1465
|
-
group.each do |file|
|
|
1466
|
-
@out.puts " #{File.basename("#{file[:id]}.md").ljust(width)} #{file[:title]}"
|
|
1467
|
-
end
|
|
1468
|
-
end
|
|
1469
|
-
end
|
|
1470
|
-
|
|
1471
|
-
def print_loose_json(dir, files)
|
|
1472
|
-
emit_json(bundle_head(dir).merge(
|
|
1473
|
-
"count" => files.size,
|
|
1474
|
-
"loose" => files.map { |file| stringify(file) }
|
|
1475
|
-
))
|
|
1476
|
-
end
|
|
1477
|
-
|
|
1478
|
-
def print_catalog(dir, entries, total)
|
|
1479
|
-
@out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
|
|
1480
|
-
entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
|
|
1481
|
-
@out.puts
|
|
1482
|
-
@out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
|
|
1483
|
-
group.each do |entry|
|
|
1484
|
-
links = entry[:links_out] + entry[:links_in]
|
|
1485
|
-
meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
|
|
1486
|
-
@out.puts " #{entry[:title]} · #{meta}"
|
|
1487
|
-
@out.puts " #{truncate(entry[:description], 92)}" unless entry[:description].empty?
|
|
1488
|
-
end
|
|
1489
|
-
end
|
|
1490
|
-
end
|
|
1491
|
-
|
|
1492
|
-
def print_catalog_json(dir, entries, options)
|
|
1493
|
-
emit_list_json(dir, "concepts", entries.map { |entry| stringify(entry) }, options)
|
|
1494
|
-
end
|
|
1495
|
-
|
|
1496
|
-
def print_files(dir, entries, total)
|
|
1497
|
-
@out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
|
|
1498
|
-
entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
|
|
1499
|
-
width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
|
|
1500
|
-
@out.puts
|
|
1501
|
-
@out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
|
|
1502
|
-
group.each do |entry|
|
|
1503
|
-
@out.puts " #{File.basename("#{entry[:id]}.md").ljust(width)} #{entry[:title]}"
|
|
432
|
+
# A refused extension is *louder* than a broken one on purpose. A gem that
|
|
433
|
+
# ships okf/plugin.rb under a name outside the okf- prefix is either an
|
|
434
|
+
# honest mistake somebody needs told about, or something that wanted to run
|
|
435
|
+
# code on a machine where nobody asked it to. Both want saying out loud.
|
|
436
|
+
# Once per run, not once per caller. An unknown verb reaches this twice —
|
|
437
|
+
# dispatch looks, misses, and then prints the map, which looks again — and a
|
|
438
|
+
# warning repeated is a warning that reads like two problems.
|
|
439
|
+
def report_plugin_failures(failures)
|
|
440
|
+
return if @plugin_notes_reported
|
|
441
|
+
|
|
442
|
+
@plugin_notes_reported = true
|
|
443
|
+
if (error = self.class.plugin_discovery_error)
|
|
444
|
+
@err.puts "okf: could not look for installed extensions (#{error.class}: #{error.message})"
|
|
445
|
+
@err.puts " none were loaded, so a verb an extension provides will read as unknown"
|
|
446
|
+
end
|
|
447
|
+
Array(failures).each do |path, error|
|
|
448
|
+
@err.puts "okf: extension at #{path} failed to load (#{error.class}: #{error.message})"
|
|
449
|
+
end
|
|
450
|
+
self.class.untrusted_plugins.each do |path, gem_name|
|
|
451
|
+
if gem_name == UNKNOWN_GEM
|
|
452
|
+
cause = self.class.plugin_gem_error
|
|
453
|
+
@err.puts "okf: ignoring the extension at #{path} — its owning gem could not be determined" \
|
|
454
|
+
"#{cause && " (#{cause.class}: #{cause.message})"}"
|
|
455
|
+
@err.puts " extensions are loaded only from gems named #{PLUGIN_GEM_PREFIX}*, and a name that cannot be read cannot be checked"
|
|
456
|
+
else
|
|
457
|
+
@err.puts "okf: ignoring an extension shipped by `#{gem_name}` (#{path})"
|
|
458
|
+
@err.puts " extensions are loaded only from gems named #{PLUGIN_GEM_PREFIX}*, since loading one runs its code"
|
|
1504
459
|
end
|
|
1505
460
|
end
|
|
1506
461
|
end
|
|
1507
462
|
|
|
1508
|
-
def print_files_json(dir, entries, options)
|
|
1509
|
-
files = entries.map do |entry|
|
|
1510
|
-
{ "path" => "#{entry[:id]}.md", "id" => entry[:id], "dir" => entry[:dir], "type" => entry[:type], "title" => entry[:title],
|
|
1511
|
-
"description" => entry[:description] }
|
|
1512
|
-
end
|
|
1513
|
-
emit_list_json(dir, "files", files, options)
|
|
1514
|
-
end
|
|
1515
|
-
|
|
1516
|
-
def print_index(dir, label, key, rows, titles)
|
|
1517
|
-
@out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
|
|
1518
|
-
@out.puts
|
|
1519
|
-
width = rows.map { |row| row[key].length }.max || 0
|
|
1520
|
-
rows.each do |row|
|
|
1521
|
-
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
1522
|
-
@out.puts " #{row[key].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 78)}"
|
|
1523
|
-
end
|
|
1524
|
-
end
|
|
1525
|
-
|
|
1526
|
-
def print_index_json(dir, plural, key, rows)
|
|
1527
|
-
emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
|
|
1528
|
-
end
|
|
1529
|
-
|
|
1530
|
-
def index_rows_json(key, rows)
|
|
1531
|
-
rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
|
|
1532
|
-
end
|
|
1533
|
-
|
|
1534
|
-
# "3 concepts", "1 concept", "1 of 7 concepts" — the noun agrees with the
|
|
1535
|
-
# number it follows: the size when that is all we show, the total otherwise.
|
|
1536
|
-
def counted(size, total, noun)
|
|
1537
|
-
return "#{size} #{pluralize(size, noun)}" if size == total
|
|
1538
|
-
|
|
1539
|
-
"#{size} of #{total} #{pluralize(total, noun)}"
|
|
1540
|
-
end
|
|
1541
|
-
|
|
1542
|
-
# The gem's whole vocabulary is regular, so a naive +s is not a shortcut —
|
|
1543
|
-
# it is the rule. Callers pass the singular.
|
|
1544
|
-
def pluralize(count, noun)
|
|
1545
|
-
count == 1 ? noun : "#{noun}s"
|
|
1546
|
-
end
|
|
1547
|
-
|
|
1548
|
-
def print_stats(dir, stats)
|
|
1549
|
-
@out.puts "Stats — #{bundle_label(dir)}"
|
|
1550
|
-
@out.puts
|
|
1551
|
-
@out.puts " concepts #{stats[:concepts]}"
|
|
1552
|
-
@out.puts " areas #{stats[:areas]}"
|
|
1553
|
-
@out.puts " concept types #{stats[:types]}"
|
|
1554
|
-
@out.puts " cross-links #{stats[:cross_links]}"
|
|
1555
|
-
@out.puts " distinct tags #{stats[:tags]}"
|
|
1556
|
-
print_stat_breakdown("By type", stats[:by_type])
|
|
1557
|
-
print_stat_breakdown("By area", stats[:by_area])
|
|
1558
|
-
end
|
|
1559
|
-
|
|
1560
|
-
def print_stat_breakdown(title, counts)
|
|
1561
|
-
return if counts.empty?
|
|
1562
|
-
|
|
1563
|
-
width = counts.keys.map(&:length).max
|
|
1564
|
-
@out.puts
|
|
1565
|
-
@out.puts " #{title}"
|
|
1566
|
-
counts.each { |label, count| @out.puts " #{label.ljust(width)} #{count}" }
|
|
1567
|
-
end
|
|
1568
|
-
|
|
1569
|
-
def print_stats_json(dir, stats)
|
|
1570
|
-
emit_json(bundle_head(dir).merge(
|
|
1571
|
-
"concepts" => stats[:concepts], "areas" => stats[:areas],
|
|
1572
|
-
"concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
|
|
1573
|
-
"by_type" => stats[:by_type], "by_area" => stats[:by_area]
|
|
1574
|
-
))
|
|
1575
|
-
end
|
|
1576
|
-
|
|
1577
|
-
# The single JSON writer. Compact by default — the token-efficient substrate an
|
|
1578
|
-
# agent consumes; --pretty indents it for a human. JSON semantics are identical
|
|
1579
|
-
# either way, so a parser never cares which was emitted.
|
|
1580
|
-
def emit_json(payload)
|
|
1581
|
-
@out.puts(@pretty ? JSON.pretty_generate(payload) : JSON.generate(payload))
|
|
1582
|
-
end
|
|
1583
|
-
|
|
1584
|
-
# Emit a list view's JSON envelope with --fields/--except projection applied to
|
|
1585
|
-
# each item. Returns the verb's exit code (0, or 2 on a bad projection request —
|
|
1586
|
-
# both flags at once, or a field name no item carries).
|
|
1587
|
-
# +dir+ is the bundle's directory — or a ready-made head Hash when the
|
|
1588
|
-
# payload spans bundles (multi-bundle search's "bundles" key).
|
|
1589
|
-
# +key+ names the JSON property the rows land under; +shape+ names the row
|
|
1590
|
-
# shape to check --fields/--except against. They are the same for every view
|
|
1591
|
-
# but search, whose two modes emit the same property from different rows.
|
|
1592
|
-
def emit_list_json(dir, key, items, options, extra = {}, shape = key)
|
|
1593
|
-
return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
|
|
1594
|
-
|
|
1595
|
-
unknown = unknown_fields(items, options, shape)
|
|
1596
|
-
return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items, shape).join(", ")})") unless unknown.empty?
|
|
1597
|
-
|
|
1598
|
-
payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
|
|
1599
|
-
payload["count"] = items.size
|
|
1600
|
-
payload[key] = project(items, options)
|
|
1601
|
-
emit_json(payload)
|
|
1602
|
-
0
|
|
1603
|
-
end
|
|
1604
|
-
|
|
1605
|
-
# Keep only --fields (allowlist) or drop --except (denylist) from each item's
|
|
1606
|
-
# top-level properties; unset flags pass the items through whole.
|
|
1607
|
-
def project(items, options)
|
|
1608
|
-
return items if options[:fields].nil? && options[:except].nil?
|
|
1609
|
-
|
|
1610
|
-
fields = options[:fields]&.map(&:downcase)
|
|
1611
|
-
except = options[:except]&.map(&:downcase)
|
|
1612
|
-
items.map do |item|
|
|
1613
|
-
fields ? item.select { |k, _| fields.include?(k.to_s.downcase) } : item.reject { |k, _| except.include?(k.to_s.downcase) }
|
|
1614
|
-
end
|
|
1615
|
-
end
|
|
1616
|
-
|
|
1617
|
-
# The declared shape wins over the data's, so the same typo gets the same
|
|
1618
|
-
# answer whether or not the result happened to have rows; a view with no
|
|
1619
|
-
# declared shape falls back to what it actually emitted.
|
|
1620
|
-
def available_fields(items, key = nil)
|
|
1621
|
-
ROW_FIELDS[key] || (items.first ? items.first.keys.map(&:to_s) : [])
|
|
1622
|
-
end
|
|
1623
|
-
|
|
1624
|
-
# Requested field names that no item actually carries — a typo guard (exit 2),
|
|
1625
|
-
# matching how lint rejects unknown check names.
|
|
1626
|
-
def unknown_fields(items, options, key = nil)
|
|
1627
|
-
requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
|
|
1628
|
-
return [] if requested.empty?
|
|
1629
|
-
|
|
1630
|
-
known = available_fields(items, key).map(&:downcase)
|
|
1631
|
-
return [] if known.empty? # an unknown view: no shape to check against, so accept
|
|
1632
|
-
|
|
1633
|
-
requested.reject { |field| known.include?(field) }.uniq
|
|
1634
|
-
end
|
|
1635
|
-
|
|
1636
|
-
def usage_error(message)
|
|
1637
|
-
@err.puts "error: #{message}"
|
|
1638
|
-
2
|
|
1639
|
-
end
|
|
1640
|
-
|
|
1641
|
-
def stringify(hash)
|
|
1642
|
-
hash.map { |key, value| [ key.to_s, value ] }.to_h
|
|
1643
|
-
end
|
|
1644
|
-
|
|
1645
|
-
def truncate(str, max)
|
|
1646
|
-
str.length > max ? "#{str[0, max - 1]}…" : str
|
|
1647
|
-
end
|
|
1648
|
-
|
|
1649
|
-
def lint_summary(stats)
|
|
1650
|
-
parts = []
|
|
1651
|
-
hubs = stats[:hubs].map { |hub| "#{hub[:id]} (×#{hub[:in_degree]})" }.join(", ")
|
|
1652
|
-
types = stats[:types].map { |type, count| "#{type} #{count}" }.join(", ")
|
|
1653
|
-
parts << "hubs: #{hubs}" unless hubs.empty?
|
|
1654
|
-
parts << "types: #{types}" unless types.empty?
|
|
1655
|
-
parts.join(" ")
|
|
1656
|
-
end
|
|
1657
|
-
|
|
1658
|
-
def lint_glyph(finding)
|
|
1659
|
-
finding[:severity] == :warn ? paint("! warn", 33) : "· info"
|
|
1660
|
-
end
|
|
1661
|
-
|
|
1662
|
-
def lint_verdict(report)
|
|
1663
|
-
warnings = report.warnings.size
|
|
1664
|
-
infos = report.info.size
|
|
1665
|
-
return paint("✓ healthy — no issues", 32) if warnings.zero? && infos.zero?
|
|
1666
|
-
|
|
1667
|
-
marker = warnings.zero? ? paint("✓", 32) : paint("⚠", 33)
|
|
1668
|
-
"#{marker} #{warnings} warn, #{infos} info"
|
|
1669
|
-
end
|
|
1670
|
-
|
|
1671
|
-
def paint(text, code)
|
|
1672
|
-
return text unless @out.respond_to?(:tty?) && @out.tty?
|
|
1673
|
-
|
|
1674
|
-
"\e[#{code}m#{text}\e[0m"
|
|
1675
|
-
end
|
|
1676
|
-
|
|
1677
463
|
def usage(io)
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
registry set <dir|@slug> [--as SLUG] [--default] add or update a bundle (a bare `server` serves them)
|
|
1687
|
-
registry del <dir|@slug> remove a bundle from the registry
|
|
1688
|
-
registry default <@slug> move a bundle to the front (the default)
|
|
1689
|
-
registry rename <@slug> <new> rename a registered bundle (<new> is a new name, not a ref)
|
|
1690
|
-
|
|
1691
|
-
lint <dir|@slug> [--json] [--fail-on warn] [...] report curation-quality issues
|
|
1692
|
-
loose <dir|@slug> [--json] list files with no graph links, by folder
|
|
1693
|
-
validate <dir|@slug> [--json] check OKF v0.1 conformance
|
|
1694
|
-
|
|
1695
|
-
search <dir|@slug…|@all> <term…> [-e] [...] find concepts by text or regexp, ranked (@all: every bundle)
|
|
1696
|
-
index <dir|@slug> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
|
|
1697
|
-
stats <dir|@slug> [--json] bundle rollups (concepts, types, areas, links, tags)
|
|
1698
|
-
types <dir|@slug> [--json] [filters] list types with their concepts, by count
|
|
1699
|
-
tags <dir|@slug> [--json] [--by DIM] [filters] list tags with their concepts, by count
|
|
1700
|
-
files <dir|@slug> [--json] [filters] list files with titles, by folder
|
|
1701
|
-
catalog <dir|@slug> [--json] [filters] list concepts with metadata, by area
|
|
1702
|
-
|
|
1703
|
-
graph <dir|@slug> [--json] [--minimal] [--no-body] print the knowledge graph
|
|
1704
|
-
|
|
1705
|
-
@slug names a registered bundle instead of a path — the slug from
|
|
1706
|
-
`okf registry set`, or bare @ for the registry default. Anywhere a <dir>
|
|
1707
|
-
goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`.
|
|
1708
|
-
The registry lives under $OKF_HOME (default ~/.okf); set it to point
|
|
1709
|
-
every verb at another one.
|
|
1710
|
-
search spans bundles: several leading @slugs, or @all for every registered one
|
|
1711
|
-
(@all skips a bundle whose directory is gone; a named @slug insists on it).
|
|
464
|
+
# Help is the one place that must know about every verb, so it is the one
|
|
465
|
+
# place besides an unknown name that pays for discovery.
|
|
466
|
+
report_plugin_failures(self.class.load_plugins)
|
|
467
|
+
io.puts "okf <command> [options]"
|
|
468
|
+
io.puts
|
|
469
|
+
GROUPS.each { |group, heading| print_group(io, group, heading) }
|
|
470
|
+
io.puts NOTE
|
|
471
|
+
end
|
|
1712
472
|
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
within-group counts, the view for curating a tag vocabulary.
|
|
1717
|
-
--json emits compact JSON (the machine substrate); add --pretty to indent it.
|
|
1718
|
-
--fields / --except project the JSON to the properties you want (search/index/catalog/files).
|
|
473
|
+
def print_group(io, group, heading)
|
|
474
|
+
rows = self.class.commands.reject(&:hidden?).select { |command| command.group == group }.flat_map(&:help_rows)
|
|
475
|
+
return if rows.empty?
|
|
1719
476
|
|
|
1720
|
-
|
|
1721
|
-
|
|
477
|
+
io.puts heading if heading
|
|
478
|
+
rows.each { |left, desc| io.puts " #{left.to_s.ljust(56)}#{desc}" }
|
|
479
|
+
io.puts
|
|
1722
480
|
end
|
|
1723
481
|
end
|
|
1724
482
|
end
|
|
483
|
+
|
|
484
|
+
require "okf/cli/command"
|
|
485
|
+
|
|
486
|
+
# ── These requires ARE the order `okf help` lists the verbs in ──
|
|
487
|
+
# Registration happens at load, `CLI.commands` is registration order, and the
|
|
488
|
+
# map walks the groups in GROUPS order and the verbs within a group in this
|
|
489
|
+
# one. Reordering these reorders the map. A test pins the result so the
|
|
490
|
+
# coupling cannot drift unnoticed, but the coupling is here, not there.
|
|
491
|
+
require "okf/cli/skill"
|
|
492
|
+
require "okf/cli/server"
|
|
493
|
+
require "okf/cli/render"
|
|
494
|
+
require "okf/cli/registry"
|
|
495
|
+
require "okf/cli/lint"
|
|
496
|
+
require "okf/cli/loose"
|
|
497
|
+
require "okf/cli/validate"
|
|
498
|
+
require "okf/cli/search"
|
|
499
|
+
require "okf/cli/index"
|
|
500
|
+
require "okf/cli/stats"
|
|
501
|
+
require "okf/cli/types"
|
|
502
|
+
require "okf/cli/tags"
|
|
503
|
+
require "okf/cli/files"
|
|
504
|
+
require "okf/cli/catalog"
|
|
505
|
+
require "okf/cli/graph"
|
|
506
|
+
|
|
507
|
+
# The line between what ships and what is installed. Everything above is a
|
|
508
|
+
# built-in; everything registered after this point came from a plugin.
|
|
509
|
+
OKF::CLI.seal_builtins!
|