okf 1.13.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +195 -0
  3. data/README.md +25 -6
  4. data/lib/okf/bundle/folder.rb +27 -1
  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 +10 -4
  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 +105 -4
  15. data/lib/okf/cli/catalog.rb +1 -1
  16. data/lib/okf/cli/command.rb +27 -8
  17. data/lib/okf/cli/files.rb +1 -1
  18. data/lib/okf/cli/index.rb +1 -1
  19. data/lib/okf/cli/lint.rb +70 -12
  20. data/lib/okf/cli/references.rb +97 -0
  21. data/lib/okf/cli/search.rb +2 -1
  22. data/lib/okf/cli/stats.rb +3 -39
  23. data/lib/okf/cli/tags.rb +6 -43
  24. data/lib/okf/cli/types.rb +1 -1
  25. data/lib/okf/cli/validate.rb +3 -3
  26. data/lib/okf/cli.rb +4 -1
  27. data/lib/okf/concept.rb +362 -10
  28. data/lib/okf/markdown/citations.rb +41 -4
  29. data/lib/okf/markdown/frontmatter.rb +1 -1
  30. data/lib/okf/markdown/links.rb +67 -7
  31. data/lib/okf/render/graph/template.html.erb +173 -41
  32. data/lib/okf/render/graph.rb +11 -3
  33. data/lib/okf/server/app.rb +47 -15
  34. data/lib/okf/server/hub.rb +1 -1
  35. data/lib/okf/skill/SKILL.md +14 -12
  36. data/lib/okf/skill/playbooks/curate.md +8 -3
  37. data/lib/okf/skill/playbooks/doctor.md +3 -1
  38. data/lib/okf/skill/playbooks/maintain.md +7 -6
  39. data/lib/okf/skill/playbooks/menu.md +5 -4
  40. data/lib/okf/skill/playbooks/migrate.md +31 -8
  41. data/lib/okf/skill/playbooks/produce.md +16 -9
  42. data/lib/okf/skill/playbooks/search.md +2 -2
  43. data/lib/okf/skill/reference/SPEC.md +739 -187
  44. data/lib/okf/skill/reference/authoring.md +154 -35
  45. data/lib/okf/skill/reference/cli.md +155 -42
  46. data/lib/okf/skill/templates/attested-computation.md +41 -0
  47. data/lib/okf/skill/templates/concept.md +13 -6
  48. data/lib/okf/skill/templates/root-index.md +1 -1
  49. data/lib/okf/version.rb +1 -1
  50. data/lib/okf.rb +22 -2
  51. metadata +5 -1
data/lib/okf/bundle.rb CHANGED
@@ -66,6 +66,28 @@ module OKF
66
66
  entry ? entry.content.to_s : ""
67
67
  end
68
68
 
69
+ # The spec version the root index.md declares (§12), as the producer wrote
70
+ # it, or nil when it declares none — which §12 explicitly permits, so nil is
71
+ # an answer rather than a fault, and a consumer that must name a version
72
+ # should say "conformant" rather than guess one.
73
+ #
74
+ # Stringified and stripped for the reason the validator compares that way:
75
+ # an unquoted `okf_version: 0.2` is a Psych Float, and a consumer switching
76
+ # on it must not be handed 0.2 the number. Public because a version stated
77
+ # on screen is otherwise a literal — which is how a reader gets told "v0.1"
78
+ # about a bundle that declares 0.2. Unparseable frontmatter is the
79
+ # validator's error to report, not this reader's to raise.
80
+ def okf_version
81
+ content = reserved_content("index.md")
82
+ return nil unless content.match?(/\A---[ \t]*\n/)
83
+
84
+ frontmatter, = Markdown::Frontmatter.parse(content)
85
+ declared = frontmatter["okf_version"]
86
+ OKF.blank?(declared) ? nil : declared.to_s.strip
87
+ rescue Markdown::Frontmatter::ParseError
88
+ nil
89
+ end
90
+
69
91
  # ── id ↔ path (the single source of "which concept an id names") ──
70
92
  # A concept's id may be a frontmatter `id`, so it is not derivable from the path
71
93
  # alone. These maps let the shell resolve an id back to its file (the server's
@@ -119,8 +141,13 @@ module OKF
119
141
  type: concept.type.to_s,
120
142
  description: concept.description.to_s,
121
143
  tags: Array(concept.tags).map(&:to_s),
122
- timestamp: concept.timestamp&.to_s,
123
- status: concept.frontmatter["status"]&.to_s,
144
+ generated_at: iso8601(concept.generated_at),
145
+ generated_by: concept.generated_by&.to_s,
146
+ generated: concept.declared_generated?,
147
+ trust: concept.trust,
148
+ status: concept.declared_status&.to_s,
149
+ stale_after: iso8601(concept.stale_after),
150
+ sources: concept.sources.length,
124
151
  backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
125
152
  dir: OKF.dir_of(id),
126
153
  top_dir: top_dir_of(id),
@@ -148,14 +175,71 @@ module OKF
148
175
  end.sort_by { |row| [ -row[:inbound], row[:id] ] }
149
176
  end
150
177
 
151
- # The progressive-disclosure map (spec §6): one entry per directory that holds
178
+ # Bundle-level rollups concepts, dirs, types, links, tags, with the
179
+ # by_type/by_dir/by_top_dir distributions. One home, shared by `okf stats`
180
+ # and the MCP stats tool, because the by_dir subtlety already diverged once
181
+ # when hand-copied: it reads Bundle#directory_index (the map `--dir` is
182
+ # answered against), so a directory holding nothing directly appears at 0
183
+ # rather than disappearing, and `dirs` equals `by_dir.size`.
184
+ # Note the recorded split: by_dir is the disk, by_top_dir rolls up the id.
185
+ def stats
186
+ minimal = graph(minimal: true)
187
+ entries = catalog
188
+ by_type = minimal.type_index.transform_values(&:size).sort_by { |_, size| -size }.to_h
189
+ by_top_dir = entries.group_by { |entry| entry[:top_dir] }.transform_values(&:size).sort_by { |_, size| -size }.to_h
190
+ by_dir = directory_index.map { |entry| [ entry[:dir], entry[:count] ] }
191
+ .sort_by { |dir, count| [ -count, dir ] }.to_h
192
+ {
193
+ concepts: entries.size,
194
+ dirs: by_dir.size,
195
+ top_dirs: by_top_dir.size,
196
+ types: by_type.size,
197
+ cross_links: minimal.edges.size,
198
+ tags: minimal.tag_index.size,
199
+ by_type: by_type,
200
+ by_dir: by_dir,
201
+ by_top_dir: by_top_dir
202
+ }
203
+ end
204
+
205
+ # The tag index re-cut per concept dimension — the vocabulary-curation
206
+ # view: [ [ group-key, rows ], … ] with each row carrying `count` (within
207
+ # the group) beside `total` (across the set), so a tag local to one group
208
+ # and one cutting across several read differently without cross-referencing
209
+ # by hand. `by:` is :type (blank folds to "Untyped", matching the graph),
210
+ # :dir (the stored spelling — `.` for the root), or anything else for the
211
+ # deprecated first-segment cut. `entries:` narrows the concepts counted —
212
+ # the CLI passes its filtered catalog; default is everything.
213
+ def tag_groups(by:, entries: nil)
214
+ entries ||= catalog
215
+ by_id = entries.map { |entry| [ entry[:id], entry ] }.to_h
216
+ groups = {}
217
+ totals = Hash.new(0)
218
+ graph(minimal: true).tag_index.each do |tag, ids|
219
+ ids.each do |id|
220
+ entry = by_id[id]
221
+ next if entry.nil?
222
+
223
+ key = tag_group_key(entry, by)
224
+ ((groups[key] ||= {})[tag] ||= []) << id
225
+ totals[tag] += 1
226
+ end
227
+ end
228
+ groups.map do |key, tags|
229
+ rows = tags.map { |tag, ids| { tag: tag, count: ids.length, total: totals[tag], concepts: ids } }
230
+ .sort_by { |row| [ -row[:count], row[:tag] ] }
231
+ [ key, rows ]
232
+ end.sort_by(&:first)
233
+ end
234
+
235
+ # The progressive-disclosure map (spec §8): one entry per directory that holds
152
236
  # concepts or carries an index.md, sorted with the root (".") first. Each entry
153
237
  # gives the authored index body (frontmatter stripped) when an index.md is
154
238
  # present, a type/tag rollup over the concepts that live *directly* in the
155
239
  # directory, its immediate child directories, and the concept listing an
156
240
  # index.md there would enumerate. A directory with concepts but no index.md has
157
241
  # `present: false` and still carries the listing, so a consumer can synthesize
158
- # the map on the fly (§6 permits exactly that). Grouped by the concept's file
242
+ # the map on the fly (§8 permits exactly that). Grouped by the concept's file
159
243
  # path — index files are physical directory listings, so a custom frontmatter
160
244
  # `id` must not move a concept out of the directory it lives in. Pure: derived
161
245
  # from the concepts and the reserved index text, no disk. Shared by the
@@ -205,6 +289,12 @@ module OKF
205
289
 
206
290
  private
207
291
 
292
+ # The one temporal-serialization rule, shared with /node/meta — see
293
+ # OKF.iso8601.
294
+ def iso8601(value)
295
+ OKF.iso8601(value)
296
+ end
297
+
208
298
  # A concept's top-level dir, derived from its id — the first path segment, the
209
299
  # same derivation the catalog exposes, so every grouped view labels the bundle
210
300
  # root "(root)". OKF.dir_of keeps the levels this one rolls up.
@@ -233,6 +323,17 @@ module OKF
233
323
  dirs.keys.sort_by { |dir| dir == "." ? "" : dir }
234
324
  end
235
325
 
326
+ # The group a concept falls in, in its *stored* spelling — `.` for the
327
+ # root under :dir, never "(root)": the human label is applied at print
328
+ # time, so the JSON and a table cannot disagree about which is the data.
329
+ def tag_group_key(entry, dim)
330
+ case dim
331
+ when :type then OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
332
+ when :dir then entry[:dir]
333
+ else entry[:top_dir]
334
+ end
335
+ end
336
+
236
337
  # { value => count }, ordered by count descending then value.
237
338
  def tally(values)
238
339
  counts = values.each_with_object(Hash.new(0)) { |value, acc| acc[value] += 1 }
@@ -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 catalog <dir|@slug> [--type T] [--dir D] [--tag T] [--json]"
25
+ o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--dir D] [--tag T] [--status S] [--trust T] [--json]"
26
26
  json_flags(o, options, "emit the catalog as JSON")
27
27
  projection_flags(o, options)
28
28
  filter_flags(o, options, :type, :area, :tag)
@@ -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,6 +193,17 @@ 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
209
  # The argument is resolved once per view, not once per entry: the `root`
@@ -206,14 +222,17 @@ module OKF
206
222
  # shape. An out-of-tree caller that never learned the third argument gets
207
223
  # exactly the resolution it was written against — the alias folds with no
208
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).
209
229
  def filter_entries(entries, options, dirs = nil)
210
230
  area = options[:area] && fold_area(options[:area], dirs)
211
231
  base = options[:dir] && fold_dir(options[:dir], dirs)
212
232
  entries.select do |entry|
213
- (options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
214
- (area.nil? || fold(entry[:top_dir]) == area) &&
215
- (base.nil? || under_dir?(entry[:dir], base)) &&
216
- (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])
217
236
  end
218
237
  end
219
238
 
@@ -234,9 +253,9 @@ module OKF
234
253
  # and #fold for a stored one. A stored dir is never an alias — that is the
235
254
  # distinction the old signature could not make, and it is what had a row
236
255
  # named `root` counting the bundle root's subtree instead of its own.
256
+ # The comparison itself is Bundle::RowFilter's (fold is idempotent).
237
257
  def under_dir?(entry_dir, path)
238
- entry = fold(entry_dir)
239
- entry == path || entry.start_with?("#{path}/")
258
+ Bundle::RowFilter.under_dir?(entry_dir, path)
240
259
  end
241
260
 
242
261
  def fold(value)
@@ -429,12 +448,12 @@ module OKF
429
448
  # into a single ranking with nothing saying so. One invocation, one
430
449
  # meaning — see Search#multi_search.
431
450
  def filter_ids(folder, options, dirs = nil)
432
- return nil if options[:type].nil? && options[:area].nil? && options[:dir].nil? && options[:tag].nil?
451
+ return nil if FILTER_KEYS.none? { |key| options[key] }
433
452
 
434
453
  filter_entries(folder.catalog, options, dirs || dir_scope(folder, options)).map { |entry| entry[:id] }
435
454
  end
436
455
 
437
- # §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
438
457
  # the reader could not parse (to stderr, so JSON on stdout stays clean) rather
439
458
  # than dropping them silently.
440
459
  def report_skipped(folder)
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)
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`
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)
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, dir_scope(folder, 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)