okf 1.12.0 → 2.0.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +94 -466
  4. data/lib/okf/bundle/folder.rb +48 -3
  5. data/lib/okf/bundle/graph.rb +12 -3
  6. data/lib/okf/bundle/linter.rb +470 -47
  7. data/lib/okf/bundle/reader.rb +47 -18
  8. data/lib/okf/bundle/references.rb +111 -0
  9. data/lib/okf/bundle/row_filter.rb +53 -0
  10. data/lib/okf/bundle/search.rb +20 -2
  11. data/lib/okf/bundle/validator/result.rb +6 -3
  12. data/lib/okf/bundle/validator.rb +267 -26
  13. data/lib/okf/bundle/writer.rb +1 -1
  14. data/lib/okf/bundle.rb +124 -8
  15. data/lib/okf/cli/catalog.rb +2 -2
  16. data/lib/okf/cli/command.rb +93 -19
  17. data/lib/okf/cli/dirs.rb +1 -1
  18. data/lib/okf/cli/files.rb +2 -2
  19. data/lib/okf/cli/index.rb +3 -3
  20. data/lib/okf/cli/lint.rb +70 -12
  21. data/lib/okf/cli/references.rb +97 -0
  22. data/lib/okf/cli/search.rb +23 -8
  23. data/lib/okf/cli/stats.rb +3 -39
  24. data/lib/okf/cli/tags.rb +6 -43
  25. data/lib/okf/cli/types.rb +1 -1
  26. data/lib/okf/cli/validate.rb +3 -3
  27. data/lib/okf/cli.rb +4 -1
  28. data/lib/okf/concept/file.rb +17 -2
  29. data/lib/okf/concept.rb +362 -10
  30. data/lib/okf/markdown/citations.rb +41 -4
  31. data/lib/okf/markdown/frontmatter.rb +1 -1
  32. data/lib/okf/markdown/links.rb +67 -7
  33. data/lib/okf/path.rb +17 -3
  34. data/lib/okf/render/graph/template.html.erb +173 -41
  35. data/lib/okf/render/graph.rb +11 -3
  36. data/lib/okf/safe_read.rb +50 -0
  37. data/lib/okf/server/app.rb +47 -15
  38. data/lib/okf/server/hub.rb +1 -1
  39. data/lib/okf/skill/SKILL.md +14 -12
  40. data/lib/okf/skill/playbooks/curate.md +8 -3
  41. data/lib/okf/skill/playbooks/doctor.md +3 -1
  42. data/lib/okf/skill/playbooks/maintain.md +7 -6
  43. data/lib/okf/skill/playbooks/menu.md +5 -4
  44. data/lib/okf/skill/playbooks/migrate.md +31 -8
  45. data/lib/okf/skill/playbooks/produce.md +16 -9
  46. data/lib/okf/skill/playbooks/search.md +2 -2
  47. data/lib/okf/skill/reference/SPEC.md +739 -187
  48. data/lib/okf/skill/reference/authoring.md +154 -35
  49. data/lib/okf/skill/reference/cli.md +160 -44
  50. data/lib/okf/skill/templates/attested-computation.md +41 -0
  51. data/lib/okf/skill/templates/concept.md +13 -6
  52. data/lib/okf/skill/templates/root-index.md +1 -1
  53. data/lib/okf/version.rb +1 -1
  54. data/lib/okf.rb +23 -2
  55. metadata +7 -3
  56. data/CODE_OF_CONDUCT.md +0 -10
@@ -23,6 +23,11 @@ module OKF
23
23
  # it is installed instead of the first time a user types its verb.
24
24
  DUCK_TYPE = %i[id group help_rows hidden? new].freeze
25
25
 
26
+ # Every key a filter flag can set. One list, read by both the narrowing
27
+ # itself and the "is anything narrowing?" guard, so adding a filter cannot
28
+ # leave one of them behind.
29
+ FILTER_KEYS = %i[type area dir tag status trust].freeze
30
+
26
31
  class << self
27
32
  # The verb this answers to, as a Symbol. The registry is keyed on it.
28
33
  def id
@@ -188,41 +193,103 @@ module OKF
188
193
  end
189
194
  end
190
195
  parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
196
+ # The read-side payoff of v0.2, and the two questions no v0.1 tool could
197
+ # answer: which concepts nobody has verified, and which are on their way
198
+ # out. Unconditional — every filtering verb narrows the same catalog
199
+ # rows, and on a v0.1 bundle the answers still read (`stable` for every
200
+ # concept, `unverified` for every concept — §13.1, not an error).
201
+ parser.on("--status STATUS", "only concepts at this lifecycle status (draft | stable | deprecated)") do |v|
202
+ options[:status] = v
203
+ end
204
+ parser.on("--trust TIER", "only concepts at this trust tier (unverified | machine-confirmed | human-reviewed)") do |v|
205
+ options[:trust] = v
206
+ end
191
207
  end
192
208
 
193
- def filter_entries(entries, options)
209
+ # The argument is resolved once per view, not once per entry: the `root`
210
+ # alias below has to consult the bundle's own directories, and that is a
211
+ # question about the whole set rather than about the row in hand.
212
+ #
213
+ # +dirs+ is that set, and every in-tree caller passes it (see #dir_scope)
214
+ # rather than deriving it from +entries+ — the catalog knows only
215
+ # directories that hold *concepts*, so a `root/` carrying an index.md and
216
+ # nothing else was invisible here while `dirs` and `index` (which read
217
+ # Bundle#directories) saw it. Two answers to one question about one
218
+ # bundle is how the alias survived its own fix.
219
+ #
220
+ # The default is a compatibility promise, not a shortcut: this base is
221
+ # what every plugin verb inherits, and 1.12.0 shipped the two-argument
222
+ # shape. An out-of-tree caller that never learned the third argument gets
223
+ # exactly the resolution it was written against — the alias folds with no
224
+ # directory set consulted — no better and no worse.
225
+ # The row rules live in Bundle::RowFilter, shared with the MCP shell;
226
+ # this layer keeps only what is the CLI's own — the deprecated --area
227
+ # (a top_dir compare no other surface offers) and the argument spelling
228
+ # (the `root`/`.` aliases resolved against the bundle's real dirs).
229
+ def filter_entries(entries, options, dirs = nil)
230
+ area = options[:area] && fold_area(options[:area], dirs)
231
+ base = options[:dir] && fold_dir(options[:dir], dirs)
194
232
  entries.select do |entry|
195
- (options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
196
- (options[:area].nil? || fold(entry[:top_dir]) == fold_area(options[:area])) &&
197
- (options[:dir].nil? || under_dir?(entry[:dir], options[:dir])) &&
198
- (options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
233
+ (area.nil? || fold(entry[:top_dir]) == area) &&
234
+ Bundle::RowFilter.matches?(entry, type: options[:type], dir: base, tag: options[:tag],
235
+ status: options[:status], trust: options[:trust])
199
236
  end
200
237
  end
201
238
 
239
+ # The directory set only when a flag is going to consult it. Deriving it
240
+ # walks every path up to the root, and #filter_entries reads it solely to
241
+ # resolve the `root`/`--area` aliases — a plain listing, or a type/tag
242
+ # narrowing, never needs the walk.
243
+ def dir_scope(folder, options)
244
+ options[:dir] || options[:area] ? folder.directories : nil
245
+ end
246
+
202
247
  # The one rule --dir is built on: a dir names itself and everything beneath
203
248
  # it. `--dir foo` reaches foo/bar, `--dir foo/bar` narrows, and `--dir .`
204
249
  # needs no special case at all — nothing starts with "./", so the root
205
250
  # selects only what lives directly in it.
206
- def under_dir?(entry_dir, wanted)
207
- entry = fold(entry_dir)
208
- path = fold_dir(wanted)
209
- entry == path || entry.start_with?("#{path}/")
251
+ #
252
+ # +path+ arrives already folded, through #fold_dir for a user's argument
253
+ # and #fold for a stored one. A stored dir is never an alias — that is the
254
+ # distinction the old signature could not make, and it is what had a row
255
+ # named `root` counting the bundle root's subtree instead of its own.
256
+ # The comparison itself is Bundle::RowFilter's (fold is idempotent).
257
+ def under_dir?(entry_dir, path)
258
+ Bundle::RowFilter.under_dir?(entry_dir, path)
210
259
  end
211
260
 
212
261
  def fold(value)
213
262
  value.to_s.downcase
214
263
  end
215
264
 
216
- def fold_area(value)
265
+ def fold_area(value, known = nil)
217
266
  folded = trim_slash(fold(value))
218
- folded == "root" ? "(root)" : folded
267
+ folded == "root" && !names_dir?(known, "root") ? "(root)" : folded
219
268
  end
220
269
 
221
270
  # `.` is the stored spelling of the root everywhere; `root` is the one a
222
271
  # shell needs no quoting for, and the only reason the two exist.
223
- def fold_dir(value)
272
+ #
273
+ # A bundle that really has a `root/` directory owns the word, and +known+
274
+ # — the directories that bundle actually holds — is what decides. The
275
+ # alias is a convenience; being able to name a directory at all is not, so
276
+ # the convenience yields. Without this, `--dir root` answered for the
277
+ # bundle root in such a bundle: the wrong concepts, exit 0, nothing said.
278
+ def fold_dir(value, known = nil)
224
279
  folded = trim_slash(fold(value))
225
- folded.empty? || folded == "root" ? "." : folded
280
+ return "." if folded.empty?
281
+
282
+ folded == "root" && !names_dir?(known, "root") ? "." : folded
283
+ end
284
+
285
+ # Does the bundle hold a directory by this name? An ancestor counts: a
286
+ # bundle whose only concept sits in `root/deep` still has a `root`, and
287
+ # `--dir root` has to reach it by the prefix rule above.
288
+ def names_dir?(known, name)
289
+ Array(known).any? do |dir|
290
+ folded = fold(dir)
291
+ folded == name || folded.start_with?("#{name}/")
292
+ end
226
293
  end
227
294
 
228
295
  # The human views print a directory with the slash that says it is one —
@@ -307,7 +374,7 @@ module OKF
307
374
 
308
375
  stored = known.each_with_object({}) { |dir, out| out[fold(dir)] = dir }
309
376
  Array(options[:dirs]).each_with_object([]) do |path, out|
310
- base = fold_dir(path)
377
+ base = fold_dir(path, known)
311
378
  next unless stored.key?(base)
312
379
 
313
380
  current = dir_parent(base)
@@ -331,7 +398,7 @@ module OKF
331
398
  # they do not define one. The ancestor chain is unioned on top by the
332
399
  # caller, which is also what tells a row apart from context.
333
400
  def select_dirs(dirs, options)
334
- bases = Array(options[:dirs]).map { |path| fold_dir(path) }
401
+ bases = Array(options[:dirs]).map { |path| fold_dir(path, dirs) }
335
402
  depth = options[:depth]&.to_i
336
403
  return dirs if bases.empty? && depth.nil?
337
404
  # No --dir means the whole bundle is the starting point, which is *not*
@@ -373,13 +440,20 @@ module OKF
373
440
 
374
441
  # The ids the filters select, resolved through the catalog metadata — or nil
375
442
  # when no filter is active, meaning keep everything.
376
- def filter_ids(folder, options)
377
- return nil if options[:type].nil? && options[:area].nil? && options[:dir].nil? && options[:tag].nil?
443
+ #
444
+ # +dirs+ overrides the folder's own directory set, which is what a
445
+ # multi-bundle run passes: the `root` alias is a fact about a bundle, so
446
+ # resolving it per folder inside a loop let one `--dir root` mean the
447
+ # `root/` subtree in one bundle and the bundle root in the next, merged
448
+ # into a single ranking with nothing saying so. One invocation, one
449
+ # meaning — see Search#multi_search.
450
+ def filter_ids(folder, options, dirs = nil)
451
+ return nil if FILTER_KEYS.none? { |key| options[key] }
378
452
 
379
- filter_entries(folder.catalog, options).map { |entry| entry[:id] }
453
+ filter_entries(folder.catalog, options, dirs || dir_scope(folder, options)).map { |entry| entry[:id] }
380
454
  end
381
455
 
382
- # §9 best-effort: the graph is built from concepts that parse. Surface any that
456
+ # §11 best-effort: the graph is built from concepts that parse. Surface any that
383
457
  # the reader could not parse (to stderr, so JSON on stdout stays clean) rather
384
458
  # than dropping them silently.
385
459
  def report_skipped(folder)
data/lib/okf/cli/dirs.rb CHANGED
@@ -79,7 +79,7 @@ module OKF
79
79
  def subtree_counts(entries)
80
80
  entries.each_with_object({}) do |entry, out|
81
81
  out[entry[:dir]] = entries.reduce(0) do |sum, other|
82
- under_dir?(other[:dir], entry[:dir]) ? sum + other[:count] : sum
82
+ under_dir?(other[:dir], fold(entry[:dir])) ? sum + other[:count] : sum
83
83
  end
84
84
  end
85
85
  end
data/lib/okf/cli/files.rb CHANGED
@@ -22,7 +22,7 @@ module OKF
22
22
  def call(argv)
23
23
  options = { json: false }
24
24
  parser = OptionParser.new do |o|
25
- o.banner = "Usage: okf files <dir|@slug> [--type T] [--dir D] [--tag T] [--json]"
25
+ o.banner = "Usage: okf files <dir|@slug> [--type T] [--dir D] [--tag T] [--status S] [--trust T] [--json]"
26
26
  json_flags(o, options, "emit the file tree as JSON")
27
27
  projection_flags(o, options)
28
28
  filter_flags(o, options, :type, :area, :tag)
@@ -33,7 +33,7 @@ module OKF
33
33
  folder = OKF::Bundle::Folder.load(dir)
34
34
  report_skipped(folder)
35
35
  entries = folder.catalog
36
- selected = filter_entries(entries, options)
36
+ selected = filter_entries(entries, options, dir_scope(folder, options))
37
37
  return print_files_json(dir, selected, options) if options[:json]
38
38
 
39
39
  print_files(dir, selected, entries.size)
data/lib/okf/cli/index.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module OKF
4
4
  class CLI
5
- # The progressive-disclosure map (spec §6): every directory that holds concepts
5
+ # The progressive-disclosure map (spec §8): every directory that holds concepts
6
6
  # or carries an index.md, with its authored index body, a type/tag rollup, its
7
7
  # child directories, and — for a directory with no index.md — the listing
8
8
  # synthesized from the concepts there. The "orient before you read" view. `--dir`
@@ -91,11 +91,11 @@ module OKF
91
91
  # deprecated flag that quietly widens is worse than one that is merely old.
92
92
  # Nothing passed keeps the whole map.
93
93
  def select_directories(entries, options)
94
- areas = Array(options[:areas]).map { |area| fold_dir(area) }
94
+ all_dirs = entries.map { |entry| entry[:dir] }
95
+ areas = Array(options[:areas]).map { |area| fold_dir(area, all_dirs) }
95
96
  scoped = !options[:dirs].nil? || !options[:depth].nil?
96
97
  return [ entries, [] ] if areas.empty? && !scoped
97
98
 
98
- all_dirs = entries.map { |entry| entry[:dir] }
99
99
  wanted = scoped ? select_dirs(all_dirs, options) : []
100
100
  chain = ancestor_dirs(options, all_dirs) - wanted
101
101
  selected = entries.select do |entry|
data/lib/okf/cli/lint.rb CHANGED
@@ -10,12 +10,19 @@ module OKF
10
10
  LINT_CATEGORIES = {
11
11
  "Reachability" => %i[orphan not_in_index disconnected_component unlinked],
12
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]
13
+ "Completeness" => %i[stub missing_title missing_description missing_generated],
14
+ "Freshness" => %i[expired stale],
15
+ "Provenance" => %i[uncited_external broken_source unattributed_claim unused_source unprefixed_actor],
16
+ "Attestation" => %i[incomplete_computation broken_attestation_ref],
17
+ "Migration" => %i[legacy_timestamp legacy_citations],
18
+ "Hygiene" => %i[duplicate_title unused_reference_def undefined_reference self_link log_order]
17
19
  }.freeze
18
20
 
21
+ # `--today` reason for existing, in one line: a CI consumer wanting a
22
+ # reproducible `expired` report needs a fixed clock. Semver-stable API,
23
+ # documented in the skill's cli.md; absent, the CLI reads the wall clock —
24
+ # the library default (`Linter.call` with no `today:`) reads none at all.
25
+
19
26
  def self.id
20
27
  :lint
21
28
  end
@@ -26,20 +33,24 @@ module OKF
26
33
 
27
34
  def self.help_rows
28
35
  [
29
- [ "lint <dir|@slug> [--json] [--fail-on warn] [...]", "report curation-quality issues" ]
36
+ [ "lint <dir|@slug> [--json] [--fail-on LEVEL] [...]", "report curation-quality issues" ]
30
37
  ]
31
38
  end
32
39
 
33
40
  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 }
41
+ options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil,
42
+ today: nil, only: nil, except: nil, fail_on: :never }
35
43
  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]"
44
+ o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--today DATE] " \
45
+ "[--only a,b] [--except a,b] [--fail-on never|info|warn]"
37
46
  json_flags(o, options, "emit a JSON report")
38
47
  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 }
48
+ o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01,",
49
+ "or a full 2026-01-01T09:00:00Z timestamp)") { |v| options[:stale_after] = v }
50
+ o.on("--today DATE", "the day `expired` compares stale_after against (default: today)") { |v| options[:today] = v }
40
51
  o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
41
52
  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 }
53
+ o.on("--fail-on LEVEL", %w[never info warn], "exit 1 when a finding at LEVEL exists (never | info | warn)") { |v| options[:fail_on] = v.to_sym }
43
54
  help_flag(o)
44
55
  end
45
56
  dir = positional_dir(parser, argv) or return 2
@@ -56,15 +67,42 @@ module OKF
56
67
  return 2
57
68
  end
58
69
 
70
+ today = parse_today(options[:today])
71
+ if today == :invalid
72
+ @err.puts "error: invalid --today `#{options[:today]}` (use YYYY-MM-DD)"
73
+ return 2
74
+ end
75
+
59
76
  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])
77
+ report = folder.lint(min_body: options[:min_body], stale_before: stale_before, today: today,
78
+ only: options[:only], except: options[:except])
61
79
  note_skipped(report.stats[:skipped])
62
80
  options[:json] ? print_lint_json(dir, report) : print_lint(dir, report)
63
- options[:fail_on] == :warn && report.warnings.any? ? 1 : 0
81
+ lint_exit(options[:fail_on], report)
64
82
  end
65
83
 
66
84
  private
67
85
 
86
+ def lint_exit(fail_on, report)
87
+ case fail_on
88
+ when :warn then report.warnings.any? ? 1 : 0
89
+ when :info then report.findings.any? ? 1 : 0
90
+ else 0
91
+ end
92
+ end
93
+
94
+ # The CLI always supplies a clock — Date.today unless --today pins one —
95
+ # so `okf lint` reports `expired` out of the box while the pure library
96
+ # default runs no clock check at all (and confesses via skipped_checks).
97
+ def parse_today(value)
98
+ return Date.today if value.nil?
99
+ return :invalid unless value.match?(Concept::ISO_DATE)
100
+
101
+ Date.iso8601(value)
102
+ rescue ArgumentError
103
+ :invalid
104
+ end
105
+
68
106
  # Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
69
107
  # Time so the pure Linter never reads the clock. nil when unset, :invalid on a
70
108
  # bad value.
@@ -74,8 +112,14 @@ module OKF
74
112
  if (match = value.match(/\A(\d+)([dw])\z/))
75
113
  days = match[1].to_i * (match[2] == "w" ? 7 : 1)
76
114
  Time.now - (days * 86_400)
77
- else
115
+ elsif value.match?(Concept::ISO_CUTOFF)
116
+ # Not --today's grammar: that one is a calendar day, this one a
117
+ # moment, so a full `generated.at` timestamp is accepted and reduced
118
+ # to its date. What both refuse is the same — the basic and week
119
+ # spellings Date.iso8601 would silently reinterpret.
78
120
  Date.iso8601(value).to_time
121
+ else
122
+ :invalid
79
123
  end
80
124
  rescue ArgumentError
81
125
  :invalid
@@ -87,6 +131,8 @@ module OKF
87
131
  @out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
88
132
  summary = lint_summary(stats)
89
133
  @out.puts " #{summary}" unless summary.empty?
134
+ posture = lint_posture(stats)
135
+ @out.puts " #{posture}" unless posture.empty?
90
136
 
91
137
  LINT_CATEGORIES.each do |name, checks|
92
138
  findings = report.findings.select { |finding| checks.include?(finding[:check]) }
@@ -99,6 +145,8 @@ module OKF
99
145
  end
100
146
  end
101
147
 
148
+ skipped = Array(report.stats[:skipped_checks])
149
+ @out.puts " skipped: #{skipped.join(", ")} (no clock or cutoff supplied)" unless skipped.empty?
102
150
  @out.puts
103
151
  @out.puts " #{lint_verdict(report)}"
104
152
  end
@@ -120,6 +168,16 @@ module OKF
120
168
  parts.join(" ")
121
169
  end
122
170
 
171
+ # The bundle's trust/status posture, printed only when there is a bundle
172
+ # to have one (an empty bundle has no distribution worth a line).
173
+ def lint_posture(stats)
174
+ return "" if stats[:concepts].zero?
175
+
176
+ trust = stats[:trust].map { |tier, count| "#{tier} #{count}" }.join(", ")
177
+ status = stats[:status].map { |value, count| "#{value} #{count}" }.join(", ")
178
+ "trust: #{trust} status: #{status}"
179
+ end
180
+
123
181
  def lint_glyph(finding)
124
182
  finding[:severity] == :warn ? paint("! warn", 33) : "· info"
125
183
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The §6.3 inventory: every file under `references/`, which concepts cite
6
+ # it through the §6.2 path-valued fields, and the pointers into
7
+ # `references/` that resolve to nothing — with the leading-slash fix named
8
+ # when a bare path written from a subdirectory is what missed. An advisory
9
+ # read (exit 0): what a curator acts on lives in the output, never the
10
+ # status.
11
+ class References < Command
12
+ def self.id
13
+ :references
14
+ end
15
+
16
+ def self.group
17
+ :read
18
+ end
19
+
20
+ def self.help_rows
21
+ [
22
+ [ "references <dir|@slug> [--json]", "inventory references/ files and the concepts citing them" ]
23
+ ]
24
+ end
25
+
26
+ def call(argv)
27
+ options = { json: false }
28
+ parser = OptionParser.new do |o|
29
+ o.banner = "Usage: okf references <dir|@slug> [--json]"
30
+ json_flags(o, options, "emit the inventory as JSON")
31
+ projection_flags(o, options)
32
+ help_flag(o)
33
+ end
34
+ dir = positional_dir(parser, argv) or return 2
35
+
36
+ folder = OKF::Bundle::Folder.load(dir)
37
+ report_skipped(folder)
38
+ references = folder.references
39
+ return print_references_json(dir, references, options) if options[:json]
40
+
41
+ print_references(dir, references)
42
+ 0
43
+ end
44
+
45
+ private
46
+
47
+ def print_references(dir, references)
48
+ entries = references.entries
49
+ @out.puts "References — #{bundle_label(dir)} (#{entries.size} #{pluralize(entries.size, "file")})"
50
+ @out.puts " (none)" if entries.empty?
51
+ entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
52
+ width = group.map { |entry| File.basename(entry[:path]).length }.max
53
+ @out.puts
54
+ @out.puts " #{folder}/"
55
+ group.each do |entry|
56
+ @out.puts " #{File.basename(entry[:path]).ljust(width)} #{entry_note(entry)}"
57
+ end
58
+ end
59
+ print_dangling(references.dangling)
60
+ end
61
+
62
+ def entry_note(entry)
63
+ marker = entry[:kind] == "concept" ? "[concept] " : ""
64
+ cited = entry[:referenced_by]
65
+ return "#{marker}(unreferenced)" if cited.empty?
66
+
67
+ "#{marker}← #{cited.map { |ref| "#{ref[:id]} (#{ref[:field]})" }.join(", ")}"
68
+ end
69
+
70
+ def print_dangling(rows)
71
+ return if rows.empty?
72
+
73
+ @out.puts
74
+ @out.puts " dangling pointers:"
75
+ rows.each do |row|
76
+ @out.puts " #{row[:id]} — #{row[:field]}: #{row[:raw]}"
77
+ line = " resolves to #{row[:resolved]}, which does not exist"
78
+ # hint is only ever the leading-slash fix, so the human line can name
79
+ # the spelling that would have hit without restating the sentence.
80
+ line += " — /#{row[:raw]} does (missing leading slash?)" if row[:hint]
81
+ @out.puts line
82
+ end
83
+ end
84
+
85
+ def print_references_json(dir, references, options)
86
+ rows = references.entries.map do |entry|
87
+ { "path" => entry[:path], "dir" => entry[:dir], "kind" => entry[:kind],
88
+ "referenced_by" => entry[:referenced_by].map { |ref| stringify(ref) } }
89
+ end
90
+ dangling = references.dangling.map { |row| stringify(row) }
91
+ emit_list_json(dir, "references", rows, options, "dangling" => dangling)
92
+ end
93
+ end
94
+
95
+ register(References)
96
+ end
97
+ end
@@ -34,7 +34,8 @@ module OKF
34
34
  def call(argv)
35
35
  options = { json: false, regexp: false, fuzzy: false, engine: nil }
36
36
  parser = OptionParser.new do |o|
37
- o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--dir D] [--tag T] [--json]"
37
+ o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] " \
38
+ "[--in FIELDS] [--type T] [--dir D] [--tag T] [--status S] [--trust T] [--json]"
38
39
  search_engine_note(o)
39
40
  json_flags(o, options, "emit the matches as JSON")
40
41
  projection_flags(o, options)
@@ -209,16 +210,30 @@ module OKF
209
210
  #
210
211
  # Filters stay per-bundle — they are per-folder questions — so they apply to
211
212
  # the merged rows by (slug, id) afterwards.
213
+ #
214
+ # The one thing that is *not* a per-folder question is what `--dir root`
215
+ # means. The alias yields to a directory that really carries the name, so
216
+ # resolving it inside this loop made one flag mean two things in one
217
+ # ranking: the `root/` subtree where a bundle has one, the bundle root
218
+ # where it does not, merged with nothing in the output saying so. The
219
+ # served set answers it once, and a bundle without the directory then
220
+ # matches nothing — which is what `--dir` already does everywhere for a
221
+ # directory a bundle lacks.
212
222
  def multi_search(pairs, terms, options)
213
- bundles = []
214
- keeps = {}
215
- total = 0
216
- pairs.each do |slug, dir|
223
+ folders = pairs.map do |slug, dir|
217
224
  folder = OKF::Bundle::Folder.load(dir)
218
225
  report_skipped(folder)
219
- total += folder.bundle.concepts.size
220
- bundles << [ slug, folder.bundle ]
221
- keep = filter_ids(folder, options)
226
+ [ slug, folder ]
227
+ end
228
+ total = folders.reduce(0) { |sum, (_, folder)| sum + folder.bundle.concepts.size }
229
+ bundles = folders.map { |slug, folder| [ slug, folder.bundle ] }
230
+ # The served set's directories, only when a flag will consult them —
231
+ # the alias is resolved once across the whole run (see filter_ids),
232
+ # and an unfiltered search never pays the walk.
233
+ dirs = options[:dir] || options[:area] ? folders.flat_map { |_, folder| folder.directories }.uniq : nil
234
+ keeps = {}
235
+ folders.each do |slug, folder|
236
+ keep = filter_ids(folder, options, dirs)
222
237
  keeps[slug] = keep unless keep.nil?
223
238
  end
224
239
  rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
data/lib/okf/cli/stats.rb CHANGED
@@ -29,51 +29,15 @@ module OKF
29
29
 
30
30
  folder = OKF::Bundle::Folder.load(dir)
31
31
  report_skipped(folder)
32
- stats = bundle_stats(folder)
32
+ stats = folder.stats
33
33
  options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
34
34
  0
35
35
  end
36
36
 
37
37
  private
38
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_top_dir = entries.group_by { |entry| entry[:top_dir] }.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
- top_dirs: by_top_dir.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_top_dir: by_top_dir
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
-
39
+ # The rollup itself is Bundle#stats one home, shared with the MCP
40
+ # shell, after the by_dir subtlety diverged once when hand-copied.
77
41
  def print_stats(dir, stats)
78
42
  @out.puts "Stats — #{bundle_label(dir)}"
79
43
  @out.puts
data/lib/okf/cli/tags.rb CHANGED
@@ -23,7 +23,7 @@ module OKF
23
23
  def call(argv)
24
24
  options = { json: false, by: nil }
25
25
  parser = OptionParser.new do |o|
26
- o.banner = "Usage: okf tags <dir|@slug> [--by type|dir] [--type T] [--dir D] [--json]"
26
+ o.banner = "Usage: okf tags <dir|@slug> [--by type|dir] [--type T] [--dir D] [--status S] [--trust T] [--json]"
27
27
  json_flags(o, options, "emit the tag index as JSON")
28
28
  o.on("--by DIM", %w[type dir area], "group the tags by a concept dimension (type | dir)") do |v|
29
29
  options[:by] = v.to_sym
@@ -50,52 +50,15 @@ module OKF
50
50
  report_skipped(folder)
51
51
  graph = folder.graph(minimal: true)
52
52
  titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
53
- groups = tag_groups(graph.tag_index, folder, options)
53
+ groups = folder.tag_groups(by: options[:by],
54
+ entries: filter_entries(folder.catalog, options, dir_scope(folder, options)))
54
55
  options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
55
56
  0
56
57
  end
57
58
 
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[:top_dir]
97
- end
98
- end
59
+ # The grouping itself is Bundle#tag_groupsone home, shared with the
60
+ # MCP shell; this layer keeps only what is the CLI's own, the filter
61
+ # narrowing passed through `entries:`.
99
62
 
100
63
  # `.` prints "(root)" bare; every other dir carries the trailing slash that
101
64
  # says it is one. The deprecated --by area already stores "(root)" itself.
data/lib/okf/cli/types.rb CHANGED
@@ -21,7 +21,7 @@ module OKF
21
21
  def call(argv)
22
22
  options = { json: false }
23
23
  parser = OptionParser.new do |o|
24
- o.banner = "Usage: okf types <dir|@slug> [--dir D] [--tag T] [--json]"
24
+ o.banner = "Usage: okf types <dir|@slug> [--dir D] [--tag T] [--status S] [--trust T] [--json]"
25
25
  json_flags(o, options, "emit the type index as JSON")
26
26
  filter_flags(o, options, :area, :tag)
27
27
  help_flag(o)