okf 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +696 -133
- data/README.md +250 -334
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +91 -11
- data/lib/okf/bundle.rb +26 -2
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +657 -0
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +169 -0
- data/lib/okf/cli/lint.rb +139 -0
- data/lib/okf/cli/loose.rb +78 -0
- data/lib/okf/cli/registry.rb +229 -0
- data/lib/okf/cli/render.rb +66 -0
- data/lib/okf/cli/search.rb +285 -0
- data/lib/okf/cli/server.rb +186 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +113 -0
- data/lib/okf/cli/tags.rb +144 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +425 -1706
- data/lib/okf/render/graph/template.html.erb +1285 -129
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +71 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +512 -38
- data/lib/okf/skill/SKILL.md +26 -19
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -5
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +93 -0
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +122 -22
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +9 -0
- metadata +38 -8
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# Boot the graph server. One verb covers three intentions and the argument
|
|
6
|
+
# count is the whole interface: one dir serves it at /, several serve them
|
|
7
|
+
# behind a hub, none serves the registry. Passing dirs never registers them.
|
|
8
|
+
class Server < Command
|
|
9
|
+
def self.id
|
|
10
|
+
:server
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.group
|
|
14
|
+
:act
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.help_rows
|
|
18
|
+
[
|
|
19
|
+
[ "server [DIR|@slug…] [-p PORT] [--bind ADDR] [...]", "serve one bundle, or many behind a hub" ]
|
|
20
|
+
]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(argv)
|
|
24
|
+
require "okf/server/app"
|
|
25
|
+
require "rack/deflater"
|
|
26
|
+
|
|
27
|
+
options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose", read_only: false }
|
|
28
|
+
parser = OptionParser.new do |o|
|
|
29
|
+
o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
|
|
30
|
+
o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
|
|
31
|
+
o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
|
|
32
|
+
o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
33
|
+
o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
|
|
34
|
+
o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
35
|
+
o.on("--read-only", "serve the bundles list without its registry controls") { options[:read_only] = true }
|
|
36
|
+
help_flag(o)
|
|
37
|
+
end
|
|
38
|
+
dirs = positional_dirs(parser, argv) or return 2
|
|
39
|
+
|
|
40
|
+
# A flag that will have no effect in this mode gets a note, not silence.
|
|
41
|
+
@err.puts "note: --title/--link apply to a single-bundle server; ignored" if dirs.size != 1 && (options[:title] || options[:link])
|
|
42
|
+
|
|
43
|
+
# One dir keeps the historical single-bundle server at `/`; zero (the
|
|
44
|
+
# persistent registry) or many (ephemeral) fan out behind a hub.
|
|
45
|
+
if dirs.size == 1
|
|
46
|
+
folder = OKF::Bundle::Folder.load(dirs.first)
|
|
47
|
+
report_skipped(folder)
|
|
48
|
+
run_server(folder, options)
|
|
49
|
+
else
|
|
50
|
+
run_hub(dirs, options)
|
|
51
|
+
end
|
|
52
|
+
0
|
|
53
|
+
rescue OKF::Error => e
|
|
54
|
+
usage_error(e.message)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Build the single-bundle Rack app and hand it to the runner (WEBrick by
|
|
60
|
+
# default, injected so tests drive this without a socket).
|
|
61
|
+
def run_server(folder, options)
|
|
62
|
+
# search_endpoint is named here rather than defaulted in App: the page
|
|
63
|
+
# resolves it against the URL the reader is on, and this is the layer that
|
|
64
|
+
# knows the app is mounted at the root. An embedding host mounting App
|
|
65
|
+
# elsewhere passes its own.
|
|
66
|
+
app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link],
|
|
67
|
+
layout: options[:layout], search_endpoint: "search")
|
|
68
|
+
# minimal: the banner wants a count, not bodies — and Folder#graph is not
|
|
69
|
+
# memoized, so a full build here parses every concept a second time (the
|
|
70
|
+
# App builds its own) purely to print one number.
|
|
71
|
+
app.warm_search
|
|
72
|
+
count = folder.graph(minimal: true).nodes.size
|
|
73
|
+
@out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
74
|
+
serve(app, options)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Build the multi-bundle hub and hand it to the runner. With dirs it serves
|
|
78
|
+
# those ephemerally; with none it serves the persistent registry. Either way
|
|
79
|
+
# the first bundle is the one `/` opens — for the registry that is its own
|
|
80
|
+
# order, and a first entry whose directory has vanished drops out here, so
|
|
81
|
+
# `/` lands on the next one that is actually there.
|
|
82
|
+
def run_hub(dirs, options)
|
|
83
|
+
require "okf/server/hub"
|
|
84
|
+
require "okf/registry"
|
|
85
|
+
reg = nil
|
|
86
|
+
if dirs.empty?
|
|
87
|
+
# A malformed registry raises OKF::Error, which `server` rescues into a
|
|
88
|
+
# usage error — no guarded load needed on this path.
|
|
89
|
+
reg = OKF::Registry.load
|
|
90
|
+
# The hub's own loader, so the set it rebuilds after a browser-side
|
|
91
|
+
# write is built exactly the way this one was.
|
|
92
|
+
bundles = OKF::Server::Hub.bundles_for(reg) { |entry| skip_registered(entry) }
|
|
93
|
+
bundles.each { |bundle| report_skipped(bundle.folder) }
|
|
94
|
+
else
|
|
95
|
+
bundles = ephemeral_bundles(dirs)
|
|
96
|
+
end
|
|
97
|
+
# The hub keeps the registry so its /b/ manager can report on entries it
|
|
98
|
+
# could not host — a folder deleted out from under one is the question
|
|
99
|
+
# "where did my bundle go?", and only the registry can answer it.
|
|
100
|
+
hub = OKF::Server::Hub.new(bundles, layout: options[:layout], registry: reg, writable: writable?(options))
|
|
101
|
+
hub.warm_search
|
|
102
|
+
concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
|
|
103
|
+
@out.puts "serving #{bundles.size} #{pluralize(bundles.size,
|
|
104
|
+
"bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
105
|
+
print_mounts(hub)
|
|
106
|
+
serve(hub, options)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The one boot seam every served app passes through, so a hub gzips exactly
|
|
110
|
+
# like a single bundle — the wrap belongs to booting a server, not to either
|
|
111
|
+
# mode, and a mode added later gets it for free. Deliberately not inside the
|
|
112
|
+
# runner: an embedding app mounting OKF::Server::App brings its own middleware.
|
|
113
|
+
def serve(app, options)
|
|
114
|
+
# gzip responses when the client accepts it — transparent, no new dependency
|
|
115
|
+
@runner.call(Rack::Deflater.new(app), options[:bind], options[:port])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The mount table — which dir landed on which /b/<slug>/ and where `/` goes.
|
|
119
|
+
# Mirrors the Hub's own default resolution (explicit slug, else first).
|
|
120
|
+
# Ask the hub which bundle it chose rather than re-deriving the
|
|
121
|
+
# explicit-else-first rule, and mount at its own prefix: two copies of a
|
|
122
|
+
# rule is two answers waiting to disagree.
|
|
123
|
+
def print_mounts(hub)
|
|
124
|
+
hub.bundles.each do |bundle|
|
|
125
|
+
marker = bundle.equal?(hub.default) ? "*" : " "
|
|
126
|
+
@out.puts " #{marker} #{OKF::Server::Hub::MOUNT}/#{bundle.slug}/ #{bundle.title}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Load the given directories as unregistered bundles, slugged by basename and
|
|
131
|
+
# deduped within the run. The same directory listed twice mounts once — two
|
|
132
|
+
# windows on one bundle would just burn a slug on a URL that vanishes next run.
|
|
133
|
+
def ephemeral_bundles(dirs)
|
|
134
|
+
roots = []
|
|
135
|
+
dirs.each do |dir|
|
|
136
|
+
root = File.expand_path(dir)
|
|
137
|
+
roots << root unless roots.include?(root)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A registered slug owns its mount outright: reserve every ref's slug
|
|
141
|
+
# before any basename is deduped. Otherwise argv order decides, and
|
|
142
|
+
# `server ./two @two` mounts the *unregistered* ./two at /b/two/ while
|
|
143
|
+
# pushing the ref — the bundle whose slug that is — to /b/two-2/, so a
|
|
144
|
+
# bookmark from a bundle-less run silently opens the wrong graph.
|
|
145
|
+
taken = roots.map { |root| ref_slugs[root] }.compact
|
|
146
|
+
roots.each_with_object([]) do |root, bundles|
|
|
147
|
+
folder = OKF::Bundle::Folder.load(root)
|
|
148
|
+
report_skipped(folder)
|
|
149
|
+
slug = ref_slugs[root]
|
|
150
|
+
unless slug
|
|
151
|
+
slug = OKF::Registry.dedupe(File.basename(root), taken)
|
|
152
|
+
taken << slug
|
|
153
|
+
end
|
|
154
|
+
bundles << OKF::Server::Hub::Bundle.new(slug, folder, folder.name)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# May the browser change the registry? On a loopback bind, yes: the server
|
|
159
|
+
# is reachable only from this machine, and the audience this was built for
|
|
160
|
+
# should not need a flag to use the page they were pointed at. Anywhere
|
|
161
|
+
# else, no — and there is no flag that says otherwise, because the registry
|
|
162
|
+
# is a per-user file and the machine that owns it is the machine that
|
|
163
|
+
# manages it. `--bind 0.0.0.0` is how a personal tool becomes a public one,
|
|
164
|
+
# and 0.0.0.0 is *not* loopback: it is every interface, which is the exact
|
|
165
|
+
# case this guards.
|
|
166
|
+
#
|
|
167
|
+
# So the only flag is the way *out*. It was `--allow-manage`, an opt-in
|
|
168
|
+
# that read as the on switch and was not one — the loopback default had
|
|
169
|
+
# already turned management on, and the flag only widened it to a bind
|
|
170
|
+
# nobody should widen it to. Naming the exception instead means the flag
|
|
171
|
+
# cannot be misread as permission: `--read-only` is the word the hub's own
|
|
172
|
+
# refusal already uses, and this server never writes a reader's markdown
|
|
173
|
+
# anyway, so in context it can only mean the registry.
|
|
174
|
+
def writable?(options)
|
|
175
|
+
!options[:read_only] && loopback?(options[:bind])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def loopback?(bind)
|
|
179
|
+
address = bind.to_s
|
|
180
|
+
address == "localhost" || address == "::1" || address.start_with?("127.")
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
register(Server)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# Install this gem's companion agent skill into a destination directory. The
|
|
6
|
+
# destination is required (no magic default) so the user always decides where
|
|
7
|
+
# their agent picks the skill up. By default the skill lands in a skills/okf/
|
|
8
|
+
# folder under it — point at a project or skills dir (.claude, .agents/skills)
|
|
9
|
+
# and it settles in its own folder, never loose among the others — so the
|
|
10
|
+
# resolved path is echoed back. --here installs straight into <dest-dir>.
|
|
11
|
+
class Skill < Command
|
|
12
|
+
def self.id
|
|
13
|
+
:skill
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.group
|
|
17
|
+
:act
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.help_rows
|
|
21
|
+
[
|
|
22
|
+
[ "skill <dest> [--here] [--force]", "install the companion agent skill" ]
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(argv)
|
|
27
|
+
options = { force: false, nest: true }
|
|
28
|
+
parser = OptionParser.new do |o|
|
|
29
|
+
o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
|
|
30
|
+
o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
|
|
31
|
+
o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
|
|
32
|
+
help_flag(o)
|
|
33
|
+
end
|
|
34
|
+
# Through the shared pair like every other verb that takes one positional:
|
|
35
|
+
# `positional` for the value, `no_extras?` for what must not follow it.
|
|
36
|
+
# Hand-rolling the shift is how this one came to accept a second
|
|
37
|
+
# destination, install into the first and exit 0 — the silent-wrong-answer
|
|
38
|
+
# shape the <dir> verbs are guarded against, on the one verb whose
|
|
39
|
+
# positional is not a <dir>.
|
|
40
|
+
dest = positional(parser, argv) or return 2
|
|
41
|
+
no_extras?(argv) or return 2
|
|
42
|
+
|
|
43
|
+
skill = OKF::Skill.new(dest, force: options[:force], nest: options[:nest])
|
|
44
|
+
files = skill.install
|
|
45
|
+
@out.puts "installed the okf skill (#{files.size} files) -> #{skill.dest}"
|
|
46
|
+
files.each { |f| @out.puts " #{f}" }
|
|
47
|
+
@out.puts "your agent picks it up from #{skill.dest} (needs the `okf` CLI, which you already have)."
|
|
48
|
+
0
|
|
49
|
+
rescue OKF::Skill::Error => e
|
|
50
|
+
@err.puts "error: #{e.message}"
|
|
51
|
+
2
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
register(Skill)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# Bundle rollups — concepts, dirs, types, links, tags — in one screen.
|
|
6
|
+
class Stats < Command
|
|
7
|
+
def self.id
|
|
8
|
+
:stats
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.group
|
|
12
|
+
:read
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.help_rows
|
|
16
|
+
[
|
|
17
|
+
[ "stats <dir|@slug> [--json]", "bundle rollups (concepts, dirs, types, links, tags)" ]
|
|
18
|
+
]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(argv)
|
|
22
|
+
options = { json: false }
|
|
23
|
+
parser = OptionParser.new do |o|
|
|
24
|
+
o.banner = "Usage: okf stats <dir|@slug> [--json]"
|
|
25
|
+
json_flags(o, options, "emit the stats as JSON")
|
|
26
|
+
help_flag(o)
|
|
27
|
+
end
|
|
28
|
+
dir = positional_dir(parser, argv) or return 2
|
|
29
|
+
|
|
30
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
31
|
+
report_skipped(folder)
|
|
32
|
+
stats = bundle_stats(folder)
|
|
33
|
+
options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
|
|
34
|
+
0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Bundle-level rollups derived from the catalog and the graph indexes.
|
|
40
|
+
def bundle_stats(folder)
|
|
41
|
+
graph = folder.graph(minimal: true)
|
|
42
|
+
entries = folder.catalog
|
|
43
|
+
by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
44
|
+
by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
45
|
+
by_dir = directory_counts(folder)
|
|
46
|
+
{
|
|
47
|
+
concepts: entries.size,
|
|
48
|
+
dirs: by_dir.size,
|
|
49
|
+
areas: by_area.size,
|
|
50
|
+
types: by_type.size,
|
|
51
|
+
cross_links: graph.edges.size,
|
|
52
|
+
tags: graph.tag_index.size,
|
|
53
|
+
by_type: by_type,
|
|
54
|
+
by_dir: by_dir,
|
|
55
|
+
by_area: by_area
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Every directory the bundle has, with the concepts that live *directly* in
|
|
60
|
+
# it. Read off Bundle#directory_index — the same map `okf dirs` lists and
|
|
61
|
+
# `--dir` is answered against — rather than off the catalog, which knows
|
|
62
|
+
# only the directories that happen to hold a concept. Grouping the catalog
|
|
63
|
+
# made `stats` and `dirs` report different totals for one bundle, and left
|
|
64
|
+
# an addressable directory out of by_dir entirely: `--dir deeply` answers,
|
|
65
|
+
# but nothing in `stats` said `deeply` was there to ask about.
|
|
66
|
+
#
|
|
67
|
+
# A directory holding nothing directly therefore appears at 0. That is the
|
|
68
|
+
# honest reading — it is the same zero `okf dirs` prints in its Concepts
|
|
69
|
+
# column — and it keeps `dirs` equal to `by_dir.size`. Ties break by path so
|
|
70
|
+
# the order is total, not whatever the sort happened to leave.
|
|
71
|
+
def directory_counts(folder)
|
|
72
|
+
folder.directory_index
|
|
73
|
+
.map { |entry| [ entry[:dir], entry[:count] ] }
|
|
74
|
+
.sort_by { |dir, count| [ -count, dir ] }.to_h
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def print_stats(dir, stats)
|
|
78
|
+
@out.puts "Stats — #{bundle_label(dir)}"
|
|
79
|
+
@out.puts
|
|
80
|
+
@out.puts " concepts #{stats[:concepts]}"
|
|
81
|
+
@out.puts " dirs #{stats[:dirs]}"
|
|
82
|
+
@out.puts " concept types #{stats[:types]}"
|
|
83
|
+
@out.puts " cross-links #{stats[:cross_links]}"
|
|
84
|
+
@out.puts " distinct tags #{stats[:tags]}"
|
|
85
|
+
print_stat_breakdown("By type", stats[:by_type])
|
|
86
|
+
# One grouping word in the human view: `by_area` stays in --json for the
|
|
87
|
+
# deprecation window, but a screen that printed both would be teaching the
|
|
88
|
+
# vocabulary the rest of this change is retiring.
|
|
89
|
+
print_stat_breakdown("By dir", stats[:by_dir]) { |label| dir_label(label) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def print_stat_breakdown(title, counts)
|
|
93
|
+
return if counts.empty?
|
|
94
|
+
|
|
95
|
+
labels = counts.keys.map { |key| block_given? ? yield(key) : key }
|
|
96
|
+
width = labels.map(&:length).max
|
|
97
|
+
@out.puts
|
|
98
|
+
@out.puts " #{title}"
|
|
99
|
+
counts.each_with_index { |(_, count), i| @out.puts " #{labels[i].ljust(width)} #{count}" }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def print_stats_json(dir, stats)
|
|
103
|
+
emit_json(bundle_head(dir).merge(
|
|
104
|
+
"concepts" => stats[:concepts], "dirs" => stats[:dirs], "areas" => stats[:areas],
|
|
105
|
+
"concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
|
|
106
|
+
"by_type" => stats[:by_type], "by_dir" => stats[:by_dir], "by_area" => stats[:by_area]
|
|
107
|
+
))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
register(Stats)
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/okf/cli/tags.rb
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The tag index: which tags exist, how often, and on what. --by regroups them
|
|
6
|
+
# per concept dimension, which is the view for curating a vocabulary rather
|
|
7
|
+
# than reading one.
|
|
8
|
+
class Tags < Command
|
|
9
|
+
def self.id
|
|
10
|
+
:tags
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.group
|
|
14
|
+
:read
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.help_rows
|
|
18
|
+
[
|
|
19
|
+
[ "tags <dir|@slug> [--json] [--by DIM] [filters]", "list tags with their concepts, by count" ]
|
|
20
|
+
]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(argv)
|
|
24
|
+
options = { json: false, by: nil }
|
|
25
|
+
parser = OptionParser.new do |o|
|
|
26
|
+
o.banner = "Usage: okf tags <dir|@slug> [--by type|dir] [--type T] [--dir D] [--json]"
|
|
27
|
+
json_flags(o, options, "emit the tag index as JSON")
|
|
28
|
+
o.on("--by DIM", %w[type dir area], "group the tags by a concept dimension (type | dir)") do |v|
|
|
29
|
+
options[:by] = v.to_sym
|
|
30
|
+
deprecated("--by area", "--by dir") if options[:by] == :area
|
|
31
|
+
end
|
|
32
|
+
filter_flags(o, options, :type, :area)
|
|
33
|
+
help_flag(o)
|
|
34
|
+
end
|
|
35
|
+
dir = positional_dir(parser, argv) or return 2
|
|
36
|
+
|
|
37
|
+
return grouped_tags(dir, options) if options[:by]
|
|
38
|
+
|
|
39
|
+
print_inverted_index(dir, "Tags", :tag, "tags", options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# `tags --by type|dir`: the tag index re-cut per concept type or directory,
|
|
45
|
+
# with within-group counts — the curation view. A tag confined to one
|
|
46
|
+
# group at count 1 is scattered; one recurring across groups is connective.
|
|
47
|
+
# The --type/--dir filters narrow the concepts first, then the grouping cuts.
|
|
48
|
+
def grouped_tags(dir, options)
|
|
49
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
50
|
+
report_skipped(folder)
|
|
51
|
+
graph = folder.graph(minimal: true)
|
|
52
|
+
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
53
|
+
groups = tag_groups(graph.tag_index, folder, options)
|
|
54
|
+
options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# [ [ group, rows ], … ] — groups sorted by name, rows shaped like index_rows'
|
|
59
|
+
# plus each tag's total across the narrowed set. A tag carried in several
|
|
60
|
+
# groups appears in each, counted per group; count/total per row is what
|
|
61
|
+
# makes a tag's spread — local to one group, or cutting across several —
|
|
62
|
+
# readable without cross-referencing the groups by hand.
|
|
63
|
+
def tag_groups(tag_index, folder, options)
|
|
64
|
+
by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
|
|
65
|
+
groups = {}
|
|
66
|
+
totals = Hash.new(0)
|
|
67
|
+
tag_index.each do |tag, ids|
|
|
68
|
+
ids.each do |id|
|
|
69
|
+
entry = by_id[id]
|
|
70
|
+
next if entry.nil?
|
|
71
|
+
|
|
72
|
+
key = group_key(entry, options[:by])
|
|
73
|
+
((groups[key] ||= {})[tag] ||= []) << id
|
|
74
|
+
totals[tag] += 1
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
groups.map do |key, tags|
|
|
78
|
+
rows = tags.map { |tag, ids| { tag: tag, count: ids.length, total: totals[tag], concepts: ids } }
|
|
79
|
+
.sort_by { |row| [ -row[:count], row[:tag] ] }
|
|
80
|
+
[ key, rows ]
|
|
81
|
+
end.sort_by(&:first)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A catalog entry's type for display — "Untyped" when blank, matching the graph.
|
|
85
|
+
def entry_type(entry)
|
|
86
|
+
OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The group a concept falls in, in its *stored* spelling — `.` for the root
|
|
90
|
+
# under --by dir, never "(root)". The human label is applied at print time,
|
|
91
|
+
# so the JSON and the table cannot disagree about which one is the data.
|
|
92
|
+
def group_key(entry, dim)
|
|
93
|
+
case dim
|
|
94
|
+
when :type then entry_type(entry)
|
|
95
|
+
when :dir then entry[:dir]
|
|
96
|
+
else entry[:area]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# `.` prints "(root)" bare; every other dir carries the trailing slash that
|
|
101
|
+
# says it is one. The deprecated --by area already stores "(root)" itself.
|
|
102
|
+
def group_label(key, dim)
|
|
103
|
+
return key if dim == :type
|
|
104
|
+
|
|
105
|
+
dir_label(key, slash: true)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def print_grouped_tags(dir, dim, groups, titles)
|
|
109
|
+
@out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
|
|
110
|
+
groups.each do |key, rows|
|
|
111
|
+
label = group_label(key, dim)
|
|
112
|
+
@out.puts
|
|
113
|
+
@out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
|
|
114
|
+
width = rows.map { |row| row[:tag].length }.max || 0
|
|
115
|
+
cwidth = [ 3, *rows.map { |row| count_cell(row).length } ].max
|
|
116
|
+
rows.each do |row|
|
|
117
|
+
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
118
|
+
@out.puts " #{row[:tag].ljust(width)} #{count_cell(row).rjust(cwidth)} #{truncate(names, 76)}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# "2/3" when the tag spreads beyond this group, the plain count when it is
|
|
124
|
+
# local — so equality (locality 1.0) reads by absence.
|
|
125
|
+
def count_cell(row)
|
|
126
|
+
row[:count] == row[:total] ? row[:count].to_s : "#{row[:count]}/#{row[:total]}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def print_grouped_tags_json(dir, dim, groups)
|
|
130
|
+
groups_json = groups.map do |key, rows|
|
|
131
|
+
rows_json = rows.map { |row| { "tag" => row[:tag], "count" => row[:count], "total" => row[:total], "concepts" => row[:concepts] } }
|
|
132
|
+
{ dim.to_s => key, "count" => rows.size, "tags" => rows_json }
|
|
133
|
+
end
|
|
134
|
+
emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def distinct_tags(groups)
|
|
138
|
+
groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.size
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
register(Tags)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The type index: which types exist, how often, and on what.
|
|
6
|
+
class Types < Command
|
|
7
|
+
def self.id
|
|
8
|
+
:types
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.group
|
|
12
|
+
:read
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.help_rows
|
|
16
|
+
[
|
|
17
|
+
[ "types <dir|@slug> [--json] [filters]", "list types with their concepts, by count" ]
|
|
18
|
+
]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(argv)
|
|
22
|
+
options = { json: false }
|
|
23
|
+
parser = OptionParser.new do |o|
|
|
24
|
+
o.banner = "Usage: okf types <dir|@slug> [--dir D] [--tag T] [--json]"
|
|
25
|
+
json_flags(o, options, "emit the type index as JSON")
|
|
26
|
+
filter_flags(o, options, :area, :tag)
|
|
27
|
+
help_flag(o)
|
|
28
|
+
end
|
|
29
|
+
dir = positional_dir(parser, argv) or return 2
|
|
30
|
+
|
|
31
|
+
print_inverted_index(dir, "Types", :type, "types", options)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
register(Types)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The §9 conformance judge: is this legal OKF? Binary and tolerant — it is
|
|
6
|
+
# forbidden from failing a bundle over a broken link or a missing optional
|
|
7
|
+
# field, which is lint's job. Exit 1 when non-conformant.
|
|
8
|
+
class Validate < Command
|
|
9
|
+
def self.id
|
|
10
|
+
:validate
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.group
|
|
14
|
+
:judge
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.help_rows
|
|
18
|
+
[
|
|
19
|
+
[ "validate <dir|@slug> [--json]", "check OKF v0.1 conformance" ]
|
|
20
|
+
]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(argv)
|
|
24
|
+
options = { json: false }
|
|
25
|
+
parser = OptionParser.new do |o|
|
|
26
|
+
o.banner = "Usage: okf validate <dir|@slug> [--json]"
|
|
27
|
+
json_flags(o, options, "emit a JSON report")
|
|
28
|
+
help_flag(o)
|
|
29
|
+
end
|
|
30
|
+
dir = positional_dir(parser, argv) or return 2
|
|
31
|
+
|
|
32
|
+
result = OKF::Bundle::Folder.load(dir).validate
|
|
33
|
+
options[:json] ? print_validation_json(dir, result) : print_validation(dir, result)
|
|
34
|
+
result.valid? ? 0 : 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def print_validation(dir, result)
|
|
40
|
+
counts = result.counts
|
|
41
|
+
@out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
|
|
42
|
+
@out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
|
|
43
|
+
result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
|
|
44
|
+
result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
|
|
45
|
+
if result.valid? && result.warnings.empty?
|
|
46
|
+
@out.puts " #{paint("✓ conformant — no issues", 32)}"
|
|
47
|
+
elsif result.valid?
|
|
48
|
+
@out.puts " #{paint("✓ conformant", 32)} (#{result.warnings.size} warning(s))"
|
|
49
|
+
else
|
|
50
|
+
@out.puts " #{paint("✗ non-conformant", 31)} (#{result.errors.size} error(s))"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def print_validation_json(dir, result)
|
|
55
|
+
emit_json(bundle_head(dir).merge(
|
|
56
|
+
"conformant" => result.valid?,
|
|
57
|
+
"counts" => result.counts,
|
|
58
|
+
"errors" => result.errors,
|
|
59
|
+
"warnings" => result.warnings
|
|
60
|
+
))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
register(Validate)
|
|
65
|
+
end
|
|
66
|
+
end
|