okf 1.9.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 +493 -133
- data/README.md +101 -10
- data/lib/okf/bundle/folder.rb +4 -0
- data/lib/okf/bundle.rb +25 -1
- 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 -1703
- data/lib/okf/render/graph/template.html.erb +1020 -61
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +10 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +504 -38
- data/lib/okf/skill/SKILL.md +14 -10
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +3 -2
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +92 -0
- data/lib/okf/skill/reference/cli.md +22 -4
- data/lib/okf/version.rb +1 -1
- metadata +19 -1
data/lib/okf/cli/tags.rb
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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|area] [--type T] [--area A] [--json]"
|
|
27
|
+
json_flags(o, options, "emit the tag index as JSON")
|
|
28
|
+
o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
|
|
29
|
+
filter_flags(o, options, :type, :area)
|
|
30
|
+
help_flag(o)
|
|
31
|
+
end
|
|
32
|
+
dir = positional_dir(parser, argv) or return 2
|
|
33
|
+
|
|
34
|
+
return grouped_tags(dir, options) if options[:by]
|
|
35
|
+
|
|
36
|
+
print_inverted_index(dir, "Tags", :tag, "tags", options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# `tags --by type|area`: the tag index re-cut per concept type or top-level
|
|
42
|
+
# area, with within-group counts — the curation view. A tag confined to one
|
|
43
|
+
# group at count 1 is scattered; one recurring across groups is connective.
|
|
44
|
+
# The --type/--area filters narrow the concepts first, then the grouping cuts.
|
|
45
|
+
def grouped_tags(dir, options)
|
|
46
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
47
|
+
report_skipped(folder)
|
|
48
|
+
graph = folder.graph(minimal: true)
|
|
49
|
+
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
50
|
+
groups = tag_groups(graph.tag_index, folder, options)
|
|
51
|
+
options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
|
|
52
|
+
0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# [ [ group, rows ], … ] — groups sorted by name, rows shaped like index_rows'
|
|
56
|
+
# plus each tag's total across the narrowed set. A tag carried in several
|
|
57
|
+
# groups appears in each, counted per group; count/total per row is what
|
|
58
|
+
# makes a tag's spread — local to one group, or cutting across several —
|
|
59
|
+
# readable without cross-referencing the groups by hand.
|
|
60
|
+
def tag_groups(tag_index, folder, options)
|
|
61
|
+
by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
|
|
62
|
+
groups = {}
|
|
63
|
+
totals = Hash.new(0)
|
|
64
|
+
tag_index.each do |tag, ids|
|
|
65
|
+
ids.each do |id|
|
|
66
|
+
entry = by_id[id]
|
|
67
|
+
next if entry.nil?
|
|
68
|
+
|
|
69
|
+
key = options[:by] == :type ? entry_type(entry) : entry[:area]
|
|
70
|
+
((groups[key] ||= {})[tag] ||= []) << id
|
|
71
|
+
totals[tag] += 1
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
groups.map do |key, tags|
|
|
75
|
+
rows = tags.map { |tag, ids| { tag: tag, count: ids.length, total: totals[tag], concepts: ids } }
|
|
76
|
+
.sort_by { |row| [ -row[:count], row[:tag] ] }
|
|
77
|
+
[ key, rows ]
|
|
78
|
+
end.sort_by(&:first)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A catalog entry's type for display — "Untyped" when blank, matching the graph.
|
|
82
|
+
def entry_type(entry)
|
|
83
|
+
OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def print_grouped_tags(dir, dim, groups, titles)
|
|
87
|
+
@out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
|
|
88
|
+
groups.each do |key, rows|
|
|
89
|
+
label = dim == :area && key != "(root)" ? "#{key}/" : key
|
|
90
|
+
@out.puts
|
|
91
|
+
@out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
|
|
92
|
+
width = rows.map { |row| row[:tag].length }.max || 0
|
|
93
|
+
cwidth = [ 3, *rows.map { |row| count_cell(row).length } ].max
|
|
94
|
+
rows.each do |row|
|
|
95
|
+
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
96
|
+
@out.puts " #{row[:tag].ljust(width)} #{count_cell(row).rjust(cwidth)} #{truncate(names, 76)}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# "2/3" when the tag spreads beyond this group, the plain count when it is
|
|
102
|
+
# local — so equality (locality 1.0) reads by absence.
|
|
103
|
+
def count_cell(row)
|
|
104
|
+
row[:count] == row[:total] ? row[:count].to_s : "#{row[:count]}/#{row[:total]}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def print_grouped_tags_json(dir, dim, groups)
|
|
108
|
+
groups_json = groups.map do |key, rows|
|
|
109
|
+
rows_json = rows.map { |row| { "tag" => row[:tag], "count" => row[:count], "total" => row[:total], "concepts" => row[:concepts] } }
|
|
110
|
+
{ dim.to_s => key, "count" => rows.size, "tags" => rows_json }
|
|
111
|
+
end
|
|
112
|
+
emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def distinct_tags(groups)
|
|
116
|
+
groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.size
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
register(Tags)
|
|
121
|
+
end
|
|
122
|
+
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> [--area A] [--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
|