okf 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +696 -133
- data/README.md +250 -334
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +91 -11
- data/lib/okf/bundle.rb +26 -2
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +657 -0
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +169 -0
- data/lib/okf/cli/lint.rb +139 -0
- data/lib/okf/cli/loose.rb +78 -0
- data/lib/okf/cli/registry.rb +229 -0
- data/lib/okf/cli/render.rb +66 -0
- data/lib/okf/cli/search.rb +285 -0
- data/lib/okf/cli/server.rb +186 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +113 -0
- data/lib/okf/cli/tags.rb +144 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +425 -1706
- data/lib/okf/render/graph/template.html.erb +1285 -129
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +71 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +512 -38
- data/lib/okf/skill/SKILL.md +26 -19
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -5
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +93 -0
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +122 -22
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +9 -0
- metadata +38 -8
data/lib/okf/cli/dirs.rb
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The bundle's directories — its clusters — and how many concepts live
|
|
6
|
+
# directly in each. The shape view: `index` reads a directory's contents,
|
|
7
|
+
# `dirs` reads the layout they hang off, which is the question `--dir` is
|
|
8
|
+
# answered against.
|
|
9
|
+
#
|
|
10
|
+
# `count` is *direct*, never cumulative: a dir's number is what lives in it,
|
|
11
|
+
# so the column sums to the bundle's concept count and an empty intermediate
|
|
12
|
+
# dir reads as the zero it is. `subtree` is the other half of that honesty —
|
|
13
|
+
# what `--dir <that row>` would return — because a direct count alone cannot
|
|
14
|
+
# say where the mass is once `--depth` truncates the listing: on a deep
|
|
15
|
+
# bundle the top-level rows are then all zeroes.
|
|
16
|
+
#
|
|
17
|
+
# Presentation only — every number comes off Bundle#directory_index, the same
|
|
18
|
+
# source `okf index` and the server's Index panel read. Advisory: exit 0.
|
|
19
|
+
class Dirs < Command
|
|
20
|
+
def self.id
|
|
21
|
+
:dirs
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.group
|
|
25
|
+
:read
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.help_rows
|
|
29
|
+
[
|
|
30
|
+
[ "dirs <dir|@slug> [--json] [--dir D] [--depth N]", "list the bundle's dirs (clusters) and their concept counts" ]
|
|
31
|
+
]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(argv)
|
|
35
|
+
options = { json: false, dirs: nil, depth: nil, ancestors: true }
|
|
36
|
+
parser = OptionParser.new do |o|
|
|
37
|
+
o.banner = "Usage: okf dirs <dir|@slug> [--dir PATH] [--depth N] [--json]"
|
|
38
|
+
json_flags(o, options, "emit the dirs as JSON")
|
|
39
|
+
projection_flags(o, options)
|
|
40
|
+
o.on("--dir PATH", "only this directory and the ones below it",
|
|
41
|
+
"(repeatable; `root` for the bundle root)") { |v| (options[:dirs] ||= []) << v }
|
|
42
|
+
depth_flag(o, options)
|
|
43
|
+
ancestors_flag(o, options)
|
|
44
|
+
help_flag(o)
|
|
45
|
+
end
|
|
46
|
+
dir = positional_dir(parser, argv) or return 2
|
|
47
|
+
bad_depth = depth_error(options)
|
|
48
|
+
return bad_depth if bad_depth
|
|
49
|
+
|
|
50
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
51
|
+
report_skipped(folder)
|
|
52
|
+
rows = select_rows(folder.directory_index, options)
|
|
53
|
+
return emit_list_json(dir, "dirs", rows, options, "total" => total(rows)) if options[:json]
|
|
54
|
+
|
|
55
|
+
print_dirs(dir, rows)
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# The subtree counts come off the *whole* map, before any narrowing — a
|
|
62
|
+
# truncated view still has to report the real weight hanging below a row,
|
|
63
|
+
# which is the only reason the column exists.
|
|
64
|
+
def select_rows(entries, options)
|
|
65
|
+
subtree = subtree_counts(entries)
|
|
66
|
+
all_dirs = entries.map { |entry| entry[:dir] }
|
|
67
|
+
wanted = select_dirs(all_dirs, options)
|
|
68
|
+
chain = ancestor_dirs(options, all_dirs) - wanted
|
|
69
|
+
entries.select { |entry| wanted.include?(entry[:dir]) || chain.include?(entry[:dir]) }.map do |entry|
|
|
70
|
+
{ "dir" => entry[:dir], "ancestor" => chain.include?(entry[:dir]), "count" => entry[:count],
|
|
71
|
+
"subtree" => subtree[entry[:dir]], "subdirs" => entry[:subdirs] }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Per dir, the concepts at or below it — defined as exactly what `--dir` on
|
|
76
|
+
# that row selects, so the number on the row and the flag can never
|
|
77
|
+
# disagree. Which is also why the root's subtree is its own direct count:
|
|
78
|
+
# `.` is a prefix of nothing, the same rule `--dir .` is built on.
|
|
79
|
+
def subtree_counts(entries)
|
|
80
|
+
entries.each_with_object({}) do |entry, out|
|
|
81
|
+
out[entry[:dir]] = entries.reduce(0) do |sum, other|
|
|
82
|
+
under_dir?(other[:dir], entry[:dir]) ? sum + other[:count] : sum
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The chain is context, not the answer, so it stays out of the total —
|
|
88
|
+
# which is what keeps a row's `subtree` equal to the total `--dir` on that
|
|
89
|
+
# row returns. `count` in the envelope is rows printed, chain included,
|
|
90
|
+
# because that is what it has always meant: how many rows came back.
|
|
91
|
+
def total(rows)
|
|
92
|
+
rows.reject { |row| row["ancestor"] }.map { |row| row["count"] }.reduce(0, :+)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def print_dirs(dir, rows)
|
|
96
|
+
@out.puts "Dirs — #{bundle_label(dir)}"
|
|
97
|
+
@out.puts
|
|
98
|
+
labels = rows.map { |row| "#{"↑ " if row["ancestor"]}#{dir_label(row["dir"])}" }
|
|
99
|
+
# The second column earns its place only where a dir actually nests. On a
|
|
100
|
+
# flat bundle it would repeat the first one down the page.
|
|
101
|
+
nested = rows.any? { |row| row["subtree"] != row["count"] }
|
|
102
|
+
unless rows.empty?
|
|
103
|
+
width = [ 3, *labels.map(&:length) ].max
|
|
104
|
+
@out.puts " #{"Dir".ljust(width)} Concepts#{" Subtree" if nested}"
|
|
105
|
+
rows.each_with_index do |row, i|
|
|
106
|
+
line = " #{labels[i].ljust(width)} #{row["count"].to_s.rjust(8)}"
|
|
107
|
+
line += " #{row["subtree"].to_s.rjust(7)}" if nested
|
|
108
|
+
@out.puts line
|
|
109
|
+
end
|
|
110
|
+
@out.puts
|
|
111
|
+
end
|
|
112
|
+
@out.puts " #{rows.size} #{pluralize(rows.size, "dir")} · #{total(rows)} #{pluralize(total(rows), "concept")}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
register(Dirs)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# Every file with its title, grouped by folder — the view for "what is on disk"
|
|
6
|
+
# rather than "what is modelled".
|
|
7
|
+
class Files < Command
|
|
8
|
+
def self.id
|
|
9
|
+
:files
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.group
|
|
13
|
+
:read
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.help_rows
|
|
17
|
+
[
|
|
18
|
+
[ "files <dir|@slug> [--json] [filters]", "list files with titles, by folder" ]
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(argv)
|
|
23
|
+
options = { json: false }
|
|
24
|
+
parser = OptionParser.new do |o|
|
|
25
|
+
o.banner = "Usage: okf files <dir|@slug> [--type T] [--dir D] [--tag T] [--json]"
|
|
26
|
+
json_flags(o, options, "emit the file tree as JSON")
|
|
27
|
+
projection_flags(o, options)
|
|
28
|
+
filter_flags(o, options, :type, :area, :tag)
|
|
29
|
+
help_flag(o)
|
|
30
|
+
end
|
|
31
|
+
dir = positional_dir(parser, argv) or return 2
|
|
32
|
+
|
|
33
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
34
|
+
report_skipped(folder)
|
|
35
|
+
entries = folder.catalog
|
|
36
|
+
selected = filter_entries(entries, options)
|
|
37
|
+
return print_files_json(dir, selected, options) if options[:json]
|
|
38
|
+
|
|
39
|
+
print_files(dir, selected, entries.size)
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def print_files(dir, entries, total)
|
|
46
|
+
@out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
|
|
47
|
+
entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
|
|
48
|
+
width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
|
|
49
|
+
@out.puts
|
|
50
|
+
@out.puts " #{dir_label(folder, slash: true)}"
|
|
51
|
+
group.each do |entry|
|
|
52
|
+
@out.puts " #{File.basename("#{entry[:id]}.md").ljust(width)} #{entry[:title]}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def print_files_json(dir, entries, options)
|
|
58
|
+
files = entries.map do |entry|
|
|
59
|
+
{ "path" => "#{entry[:id]}.md", "id" => entry[:id], "dir" => entry[:dir], "type" => entry[:type], "title" => entry[:title],
|
|
60
|
+
"description" => entry[:description] }
|
|
61
|
+
end
|
|
62
|
+
emit_list_json(dir, "files", files, options)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
register(Files)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The knowledge graph as text — nodes, edges and the rollups the browser page
|
|
6
|
+
# draws, for a reader that has no browser.
|
|
7
|
+
class Graph < Command
|
|
8
|
+
def self.id
|
|
9
|
+
:graph
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.group
|
|
13
|
+
:graph
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.help_rows
|
|
17
|
+
[
|
|
18
|
+
[ "graph <dir|@slug> [--json] [--minimal] [--hubs]", "print the knowledge graph" ]
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(argv)
|
|
23
|
+
options = { json: false, minimal: false, body: true, hubs: false }
|
|
24
|
+
parser = OptionParser.new do |o|
|
|
25
|
+
o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body] [--hubs]"
|
|
26
|
+
json_flags(o, options, "emit nodes and edges as JSON")
|
|
27
|
+
o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
|
|
28
|
+
o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
|
|
29
|
+
o.on("--hubs", "rank concepts by inbound links, with the source areas") { options[:hubs] = true }
|
|
30
|
+
help_flag(o)
|
|
31
|
+
end
|
|
32
|
+
dir = positional_dir(parser, argv) or return 2
|
|
33
|
+
|
|
34
|
+
return print_hubs(dir, options) if options[:hubs]
|
|
35
|
+
|
|
36
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
37
|
+
graph = folder.graph(minimal: options[:minimal], body: options[:body])
|
|
38
|
+
report_skipped(folder)
|
|
39
|
+
if options[:json]
|
|
40
|
+
# The head every view carries: a payload of nodes and edges that never
|
|
41
|
+
# says which bundle they came from is exactly what an agent holding
|
|
42
|
+
# several bundles has to guess at.
|
|
43
|
+
payload = bundle_head(dir).merge(graph.to_h)
|
|
44
|
+
payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
|
|
45
|
+
emit_json(payload)
|
|
46
|
+
else
|
|
47
|
+
@out.puts "Graph — #{bundle_label(dir)} (#{graph.nodes.size} #{pluralize(graph.nodes.size, "concept")}, " \
|
|
48
|
+
"#{graph.edges.size} #{pluralize(graph.edges.size, "link")})"
|
|
49
|
+
end
|
|
50
|
+
0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# `graph --hubs`: the inbound ranking with each hub's links grouped by
|
|
56
|
+
# source area — the "is this hub well-homed?" evidence. A hub whose
|
|
57
|
+
# inbound majority comes from outside its own area is a move candidate;
|
|
58
|
+
# --minimal/--no-body shape node payloads and change nothing here.
|
|
59
|
+
def print_hubs(dir, options)
|
|
60
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
61
|
+
hubs = folder.hubs
|
|
62
|
+
report_skipped(folder)
|
|
63
|
+
if options[:json]
|
|
64
|
+
rows = hubs.map { |row| { "id" => row[:id], "area" => row[:area], "inbound" => row[:inbound], "by_area" => row[:by_area] } }
|
|
65
|
+
emit_json(bundle_head(dir).merge("count" => hubs.size, "hubs" => rows))
|
|
66
|
+
else
|
|
67
|
+
@out.puts "Hubs — #{bundle_label(dir)} (#{counted(hubs.size, folder.concepts.size, "concept")} with inbound links)"
|
|
68
|
+
@out.puts
|
|
69
|
+
width = hubs.map { |row| row[:id].length }.max || 0
|
|
70
|
+
dwidth = hubs.map { |row| row[:inbound].to_s.length }.max || 0
|
|
71
|
+
hubs.each do |row|
|
|
72
|
+
sources = row[:by_area].map { |area, count| "#{area} #{count}" }.join(", ")
|
|
73
|
+
@out.puts " #{row[:id].ljust(width)} ×#{row[:inbound].to_s.rjust(dwidth)} #{sources}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
0
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
register(Graph)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The progressive-disclosure map (spec §6): every directory that holds concepts
|
|
6
|
+
# or carries an index.md, with its authored index body, a type/tag rollup, its
|
|
7
|
+
# child directories, and — for a directory with no index.md — the listing
|
|
8
|
+
# synthesized from the concepts there. The "orient before you read" view. `--dir`
|
|
9
|
+
# is repeatable and selects a directory *and its subtree* (`root` is the bundle
|
|
10
|
+
# root), `--depth N` bounds how far below the starting point that goes, and
|
|
11
|
+
# `--no-body` drops the prose to a skeleton; advisory, exit 0.
|
|
12
|
+
#
|
|
13
|
+
# The two narrowings are what make the map usable on a deep bundle: every
|
|
14
|
+
# directory is a section, so a few hundred concepts is a map nobody reads at
|
|
15
|
+
# once. `--depth 1` is the top of the tree, `--dir X --depth 1` is one branch
|
|
16
|
+
# of it, and the pair walks down a level at a time.
|
|
17
|
+
class Index < Command
|
|
18
|
+
def self.id
|
|
19
|
+
:index
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.group
|
|
23
|
+
:read
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.help_rows
|
|
27
|
+
[
|
|
28
|
+
[ "index <dir|@slug> [--dir D] [--depth N] [--no-body]", "the index map: dirs, their listings and rollups" ]
|
|
29
|
+
]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(argv)
|
|
33
|
+
options = { json: false, body: true, dirs: nil, areas: nil, depth: nil, ancestors: true }
|
|
34
|
+
parser = OptionParser.new do |o|
|
|
35
|
+
o.banner = "Usage: okf index <dir|@slug> [--dir PATH] [--depth N] [--no-body] [--json]"
|
|
36
|
+
json_flags(o, options, "emit the index map as JSON")
|
|
37
|
+
projection_flags(o, options)
|
|
38
|
+
o.on("--dir PATH", "only this directory and the ones below it",
|
|
39
|
+
"(repeatable; `root` for the bundle root)") { |v| (options[:dirs] ||= []) << v }
|
|
40
|
+
depth_flag(o, options)
|
|
41
|
+
ancestors_flag(o, options)
|
|
42
|
+
o.on("--area AREA", "deprecated: use --dir (this directory exactly)") do |v|
|
|
43
|
+
(options[:areas] ||= []) << v
|
|
44
|
+
deprecated("--area", "--dir")
|
|
45
|
+
end
|
|
46
|
+
o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
|
|
47
|
+
help_flag(o)
|
|
48
|
+
end
|
|
49
|
+
dir = positional_dir(parser, argv) or return 2
|
|
50
|
+
bad_depth = depth_error(options)
|
|
51
|
+
return bad_depth if bad_depth
|
|
52
|
+
# --area is exact and names no starting point, so --depth has nothing to
|
|
53
|
+
# be relative *to*: the pair used to union the area with every directory
|
|
54
|
+
# at that depth from the root. Refusing beats answering with more.
|
|
55
|
+
#
|
|
56
|
+
# --dir is refused for the same reason and not a weaker one: one flag is
|
|
57
|
+
# exact and the other a prefix, so the pair came back with the area *and*
|
|
58
|
+
# the subtree — an answer to neither question, from a combination only
|
|
59
|
+
# someone mid-migration would type. A deprecated flag that quietly widens
|
|
60
|
+
# is worse than one that is merely old.
|
|
61
|
+
if options[:areas] && (options[:depth] || options[:dirs])
|
|
62
|
+
return usage_error("--area and #{options[:dirs] ? "--dir" : "--depth"} do not combine: use --dir")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
66
|
+
report_skipped(folder)
|
|
67
|
+
entries = folder.directory_index
|
|
68
|
+
selected, chain = select_directories(entries, options)
|
|
69
|
+
if options[:json]
|
|
70
|
+
# --no-body is shorthand for --except body, so asking for the body by
|
|
71
|
+
# name in the same breath is a contradiction. Letting --fields quietly
|
|
72
|
+
# win would hand back the very thing the other flag was there to drop.
|
|
73
|
+
if !options[:body] && Array(options[:fields]).map(&:downcase).include?("body")
|
|
74
|
+
return usage_error("--no-body and --fields body contradict each other: drop one")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
|
|
78
|
+
return print_index_map_json(dir, selected, chain, options)
|
|
79
|
+
end
|
|
80
|
+
print_index_map(dir, selected, chain, options[:body])
|
|
81
|
+
0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Narrow the map — case-insensitive, `root` matching the bundle root (".").
|
|
87
|
+
# --dir takes the named directory *and its subtree* and --depth bounds how
|
|
88
|
+
# far below the starting point that reaches, both through the shared
|
|
89
|
+
# select_dirs so the whole CLI answers "which directories?" one way. The
|
|
90
|
+
# deprecated --area keeps its old exact match beside them, because a
|
|
91
|
+
# deprecated flag that quietly widens is worse than one that is merely old.
|
|
92
|
+
# Nothing passed keeps the whole map.
|
|
93
|
+
def select_directories(entries, options)
|
|
94
|
+
areas = Array(options[:areas]).map { |area| fold_dir(area) }
|
|
95
|
+
scoped = !options[:dirs].nil? || !options[:depth].nil?
|
|
96
|
+
return [ entries, [] ] if areas.empty? && !scoped
|
|
97
|
+
|
|
98
|
+
all_dirs = entries.map { |entry| entry[:dir] }
|
|
99
|
+
wanted = scoped ? select_dirs(all_dirs, options) : []
|
|
100
|
+
chain = ancestor_dirs(options, all_dirs) - wanted
|
|
101
|
+
selected = entries.select do |entry|
|
|
102
|
+
areas.include?(fold(entry[:dir])) || wanted.include?(entry[:dir]) || chain.include?(entry[:dir])
|
|
103
|
+
end
|
|
104
|
+
[ selected, chain ]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def print_index_map(dir, entries, chain, body)
|
|
108
|
+
noun = entries.size == 1 ? "directory" : "directories"
|
|
109
|
+
@out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
|
|
110
|
+
entries.each do |entry|
|
|
111
|
+
@out.puts
|
|
112
|
+
# ↑ marks a row the reader did not ask for: it is here to place the
|
|
113
|
+
# branch, not to answer about it.
|
|
114
|
+
up = chain.include?(entry[:dir]) ? "↑ " : ""
|
|
115
|
+
@out.puts " #{up}#{index_dir_label(entry)}#{index_dir_meta(entry)}"
|
|
116
|
+
subdirs = entry[:subdirs]
|
|
117
|
+
@out.puts " → #{subdirs.map { |sub| "#{File.basename(sub)}/" }.join(" ")}" unless subdirs.empty?
|
|
118
|
+
if entry[:present]
|
|
119
|
+
print_index_body(entry[:body]) if body
|
|
120
|
+
else
|
|
121
|
+
print_synthesized_listing(entry[:listing])
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def index_dir_label(entry)
|
|
127
|
+
base = dir_label(entry[:dir], slash: true)
|
|
128
|
+
entry[:present] ? base : "#{base} (no index.md)"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def index_dir_meta(entry)
|
|
132
|
+
count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
|
|
133
|
+
types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
|
|
134
|
+
types.empty? ? " · #{count}" : " · #{count} · #{types}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def print_index_body(body)
|
|
138
|
+
text = body.to_s.strip
|
|
139
|
+
return if text.empty?
|
|
140
|
+
|
|
141
|
+
text.each_line { |line| @out.puts " #{line.chomp}" }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def print_synthesized_listing(listing)
|
|
145
|
+
listing.each do |item|
|
|
146
|
+
suffix = item[:description].empty? ? "" : " — #{truncate(item[:description], 72)}"
|
|
147
|
+
@out.puts " • #{item[:title]}#{suffix}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def print_index_map_json(dir, entries, chain, options)
|
|
152
|
+
rows = entries.map { |entry| index_map_entry_json(entry, chain.include?(entry[:dir])) }
|
|
153
|
+
emit_list_json(dir, "directories", rows, options)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def index_map_entry_json(entry, ancestor)
|
|
157
|
+
{
|
|
158
|
+
"dir" => entry[:dir], "ancestor" => ancestor, "index_path" => entry[:index_path],
|
|
159
|
+
"present" => entry[:present], "synthesized" => entry[:synthesized],
|
|
160
|
+
"count" => entry[:count], "types" => entry[:types], "tags" => entry[:tags],
|
|
161
|
+
"subdirs" => entry[:subdirs], "body" => entry[:body],
|
|
162
|
+
"listing" => entry[:listing].map { |item| stringify(item) }
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
register(Index)
|
|
168
|
+
end
|
|
169
|
+
end
|
data/lib/okf/cli/lint.rb
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The curation judge: is this bundle *good*? Advisory by design — findings
|
|
6
|
+
# never change the exit code unless --fail-on says so, because a stub or a
|
|
7
|
+
# loose leaf can be deliberate. Freshness is off unless --stale-after asks.
|
|
8
|
+
class Lint < Command
|
|
9
|
+
# Lint findings grouped for display, in category order.
|
|
10
|
+
LINT_CATEGORIES = {
|
|
11
|
+
"Reachability" => %i[orphan not_in_index disconnected_component unlinked],
|
|
12
|
+
"Backlog" => %i[missing_concept broken_index_entry],
|
|
13
|
+
"Completeness" => %i[stub missing_title missing_description missing_timestamp],
|
|
14
|
+
"Freshness" => %i[stale],
|
|
15
|
+
"Provenance" => %i[uncited_external broken_citation],
|
|
16
|
+
"Hygiene" => %i[duplicate_title unused_reference_def undefined_reference self_link]
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def self.id
|
|
20
|
+
:lint
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.group
|
|
24
|
+
:judge
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.help_rows
|
|
28
|
+
[
|
|
29
|
+
[ "lint <dir|@slug> [--json] [--fail-on warn] [...]", "report curation-quality issues" ]
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(argv)
|
|
34
|
+
options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
|
|
35
|
+
parser = OptionParser.new do |o|
|
|
36
|
+
o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
|
|
37
|
+
json_flags(o, options, "emit a JSON report")
|
|
38
|
+
o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
|
|
39
|
+
o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
|
|
40
|
+
o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
|
|
41
|
+
o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
|
|
42
|
+
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 }
|
|
43
|
+
help_flag(o)
|
|
44
|
+
end
|
|
45
|
+
dir = positional_dir(parser, argv) or return 2
|
|
46
|
+
|
|
47
|
+
unknown = ((options[:only] || []) + (options[:except] || [])) - OKF::Bundle::Linter::CHECKS
|
|
48
|
+
unless unknown.empty?
|
|
49
|
+
@err.puts "error: unknown check(s): #{unknown.uniq.join(", ")}"
|
|
50
|
+
return 2
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
stale_before = parse_stale_after(options[:stale_after])
|
|
54
|
+
if stale_before == :invalid
|
|
55
|
+
@err.puts "error: invalid --stale-after `#{options[:stale_after]}` (use 90d, 12w, or an ISO date like 2026-01-01)"
|
|
56
|
+
return 2
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
60
|
+
report = folder.lint(min_body: options[:min_body], stale_before: stale_before, only: options[:only], except: options[:except])
|
|
61
|
+
note_skipped(report.stats[:skipped])
|
|
62
|
+
options[:json] ? print_lint_json(dir, report) : print_lint(dir, report)
|
|
63
|
+
options[:fail_on] == :warn && report.warnings.any? ? 1 : 0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
|
|
69
|
+
# Time so the pure Linter never reads the clock. nil when unset, :invalid on a
|
|
70
|
+
# bad value.
|
|
71
|
+
def parse_stale_after(value)
|
|
72
|
+
return nil if value.nil?
|
|
73
|
+
|
|
74
|
+
if (match = value.match(/\A(\d+)([dw])\z/))
|
|
75
|
+
days = match[1].to_i * (match[2] == "w" ? 7 : 1)
|
|
76
|
+
Time.now - (days * 86_400)
|
|
77
|
+
else
|
|
78
|
+
Date.iso8601(value).to_time
|
|
79
|
+
end
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
:invalid
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def print_lint(dir, report)
|
|
85
|
+
stats = report.stats
|
|
86
|
+
@out.puts "OKF lint — #{bundle_label(dir)}"
|
|
87
|
+
@out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
|
|
88
|
+
summary = lint_summary(stats)
|
|
89
|
+
@out.puts " #{summary}" unless summary.empty?
|
|
90
|
+
|
|
91
|
+
LINT_CATEGORIES.each do |name, checks|
|
|
92
|
+
findings = report.findings.select { |finding| checks.include?(finding[:check]) }
|
|
93
|
+
next if findings.empty?
|
|
94
|
+
|
|
95
|
+
@out.puts
|
|
96
|
+
@out.puts " #{name}"
|
|
97
|
+
findings.each do |finding|
|
|
98
|
+
@out.puts " #{lint_glyph(finding)} #{[ finding[:path], finding[:message] ].compact.join(": ")}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@out.puts
|
|
103
|
+
@out.puts " #{lint_verdict(report)}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def print_lint_json(dir, report)
|
|
107
|
+
emit_json(bundle_head(dir).merge(
|
|
108
|
+
"healthy" => report.healthy?,
|
|
109
|
+
"stats" => report.stats,
|
|
110
|
+
"findings" => report.findings
|
|
111
|
+
))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def lint_summary(stats)
|
|
115
|
+
parts = []
|
|
116
|
+
hubs = stats[:hubs].map { |hub| "#{hub[:id]} (×#{hub[:in_degree]})" }.join(", ")
|
|
117
|
+
types = stats[:types].map { |type, count| "#{type} #{count}" }.join(", ")
|
|
118
|
+
parts << "hubs: #{hubs}" unless hubs.empty?
|
|
119
|
+
parts << "types: #{types}" unless types.empty?
|
|
120
|
+
parts.join(" ")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def lint_glyph(finding)
|
|
124
|
+
finding[:severity] == :warn ? paint("! warn", 33) : "· info"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def lint_verdict(report)
|
|
128
|
+
warnings = report.warnings.size
|
|
129
|
+
infos = report.info.size
|
|
130
|
+
return paint("✓ healthy — no issues", 32) if warnings.zero? && infos.zero?
|
|
131
|
+
|
|
132
|
+
marker = warnings.zero? ? paint("✓", 32) : paint("⚠", 33)
|
|
133
|
+
"#{marker} #{warnings} warn, #{infos} info"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
register(Lint)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# List the "loose" files — concepts with graph degree 0 (no cross-links in or
|
|
6
|
+
# out), grouped by folder. A folder-grouped view over lint's `unlinked` check,
|
|
7
|
+
# for the common "which files float in the graph?" question. Advisory (exit 0):
|
|
8
|
+
# a terminal leaf can be loose by design. `--json` for a machine substrate.
|
|
9
|
+
class Loose < Command
|
|
10
|
+
def self.id
|
|
11
|
+
:loose
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.group
|
|
15
|
+
:judge
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.help_rows
|
|
19
|
+
[
|
|
20
|
+
[ "loose <dir|@slug> [--json]", "list files with no graph links, by folder" ]
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(argv)
|
|
25
|
+
options = { json: false }
|
|
26
|
+
parser = OptionParser.new do |o|
|
|
27
|
+
o.banner = "Usage: okf loose <dir|@slug> [--json]"
|
|
28
|
+
json_flags(o, options, "emit the loose files as JSON")
|
|
29
|
+
help_flag(o)
|
|
30
|
+
end
|
|
31
|
+
dir = positional_dir(parser, argv) or return 2
|
|
32
|
+
|
|
33
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
34
|
+
report_skipped(folder)
|
|
35
|
+
files = loose_files(folder.graph(minimal: true))
|
|
36
|
+
options[:json] ? print_loose_json(dir, files) : print_loose(dir, files)
|
|
37
|
+
0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
|
|
43
|
+
# `unlinked` check reports, resolved to titles/folders for display.
|
|
44
|
+
def loose_files(graph)
|
|
45
|
+
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
46
|
+
graph.unlinked_ids
|
|
47
|
+
.map { |id| { id: id, title: titles[id], dir: OKF.dir_of(id) } }
|
|
48
|
+
.sort_by { |file| file[:id] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def print_loose(dir, files)
|
|
52
|
+
@out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
|
|
53
|
+
if files.empty?
|
|
54
|
+
@out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
files.group_by { |file| file[:dir] }.sort_by(&:first).each do |folder, group|
|
|
59
|
+
width = group.map { |file| File.basename("#{file[:id]}.md").length }.max
|
|
60
|
+
@out.puts
|
|
61
|
+
@out.puts " #{dir_label(folder, slash: true)}"
|
|
62
|
+
group.each do |file|
|
|
63
|
+
@out.puts " #{File.basename("#{file[:id]}.md").ljust(width)} #{file[:title]}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def print_loose_json(dir, files)
|
|
69
|
+
emit_json(bundle_head(dir).merge(
|
|
70
|
+
"count" => files.size,
|
|
71
|
+
"loose" => files.map { |file| stringify(file) }
|
|
72
|
+
))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
register(Loose)
|
|
77
|
+
end
|
|
78
|
+
end
|