okf 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +696 -133
  3. data/README.md +250 -334
  4. data/lib/okf/bundle/folder.rb +24 -5
  5. data/lib/okf/bundle/linter.rb +1 -1
  6. data/lib/okf/bundle/search/index.rb +13 -3
  7. data/lib/okf/bundle/search.rb +91 -11
  8. data/lib/okf/bundle.rb +26 -2
  9. data/lib/okf/cli/catalog.rb +66 -0
  10. data/lib/okf/cli/command.rb +657 -0
  11. data/lib/okf/cli/dirs.rb +118 -0
  12. data/lib/okf/cli/files.rb +68 -0
  13. data/lib/okf/cli/graph.rb +82 -0
  14. data/lib/okf/cli/index.rb +169 -0
  15. data/lib/okf/cli/lint.rb +139 -0
  16. data/lib/okf/cli/loose.rb +78 -0
  17. data/lib/okf/cli/registry.rb +229 -0
  18. data/lib/okf/cli/render.rb +66 -0
  19. data/lib/okf/cli/search.rb +285 -0
  20. data/lib/okf/cli/server.rb +186 -0
  21. data/lib/okf/cli/skill.rb +57 -0
  22. data/lib/okf/cli/stats.rb +113 -0
  23. data/lib/okf/cli/tags.rb +144 -0
  24. data/lib/okf/cli/types.rb +37 -0
  25. data/lib/okf/cli/validate.rb +66 -0
  26. data/lib/okf/cli.rb +425 -1706
  27. data/lib/okf/render/graph/template.html.erb +1285 -129
  28. data/lib/okf/render/graph.rb +46 -2
  29. data/lib/okf/server/app.rb +71 -4
  30. data/lib/okf/server/hub/not_found.rb +663 -0
  31. data/lib/okf/server/hub.rb +512 -38
  32. data/lib/okf/skill/SKILL.md +26 -19
  33. data/lib/okf/skill/playbooks/consume.md +3 -3
  34. data/lib/okf/skill/playbooks/curate.md +3 -1
  35. data/lib/okf/skill/playbooks/maintain.md +7 -5
  36. data/lib/okf/skill/playbooks/menu.md +5 -0
  37. data/lib/okf/skill/playbooks/refine.md +93 -0
  38. data/lib/okf/skill/playbooks/search.md +7 -7
  39. data/lib/okf/skill/reference/cli.md +122 -22
  40. data/lib/okf/version.rb +1 -1
  41. data/lib/okf.rb +9 -0
  42. metadata +38 -8
@@ -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
@@ -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