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
@@ -2,21 +2,36 @@
2
2
 
3
3
  module OKF
4
4
  class Bundle
5
- # Checks an OKF::Bundle against the OKF v0.1 conformance rules (§9), which has
5
+ # Checks an OKF::Bundle against the OKF conformance rules (§11), which has
6
6
  # three conditions — all hard errors:
7
7
  #
8
- # §9.1 every non-reserved file has a parseable YAML frontmatter block;
9
- # §9.2 every such block has a non-empty `type`;
10
- # §9.3 every index.md/log.md present follows the §67 structure — a nested
11
- # index.md has no frontmatter, a root index.md carries only okf_version,
12
- # and log.md date headings are ISO `YYYY-MM-DD`.
8
+ # §11 c1 every non-reserved file has a parseable YAML frontmatter block;
9
+ # §11 c2 every such block has a non-empty `type`;
10
+ # §11 c3 every index.md/log.md present follows the §89 structure — a nested
11
+ # index.md has no frontmatter, a root index.md carries only okf_version,
12
+ # and log.md date headings are ISO `YYYY-MM-DD`.
13
13
  #
14
14
  # Everything the spec marks as soft guidance is a warning and never makes a
15
15
  # bundle non-conformant: missing recommended fields, non-list tags, an
16
- # unparseable timestamp, and broken cross-links (§5.3), which consumers MUST
17
- # tolerate. Pure — it reads nothing from disk; it works entirely on the
18
- # in-memory bundle.
16
+ # unparseable timestamp, broken cross-links (§6.1), and every v0.2 family
17
+ # shape (§5, §10). Pure — it reads nothing from disk; it works entirely on
18
+ # the in-memory bundle.
19
+ #
20
+ # Warnings are machine-readable: each carries a `check:` id and a `source:`
21
+ # naming who states the rule — `:spec` for the SPEC's own words,
22
+ # `:convention` for a shape this gem asks for beyond them. Errors keep their
23
+ # exact two-key shape; consumers already read it.
19
24
  class Validator
25
+ # The warnings that state a gem convention rather than a SPEC rule. §5.1
26
+ # gives `usage_count` no type and §10.2 marks only `runtime` REQUIRED;
27
+ # `verified[].by` is REQUIRED-within by analogy with `generated.by`, not
28
+ # by the SPEC's own words. A consumer that wants only the spec-normative
29
+ # warnings filters on `source:` instead of string-matching messages.
30
+ CONVENTION_CHECKS = %i[
31
+ verified_entry_by source_usage_count source_usage_window_shape
32
+ parameter_name executor_resource attester_resource
33
+ ].freeze
34
+
20
35
  def self.call(bundle)
21
36
  new(bundle).call
22
37
  end
@@ -36,20 +51,203 @@ module OKF
36
51
 
37
52
  private
38
53
 
39
- # §9.2 (non-empty type) is the only hard error here; the missing recommended
40
- # fields, non-list tags, and bad timestamp are soft warnings (never
41
- # non-conformant). A parsed concept is valid UTF-8 by construction.
54
+ # Every warning goes through here so the check id and its source cannot
55
+ # drift apart: the id is the API, the source is derived from one constant.
56
+ #
57
+ # Not `warn`: that name shadows Kernel#warn for every method in the class,
58
+ # so a later `warn "…"` meant for stderr would raise ArgumentError at
59
+ # runtime — a collision neither RuboCop nor the call site can show you.
60
+ def record_warning(path, check, message)
61
+ @result.add_warning(path, message,
62
+ check: check,
63
+ source: CONVENTION_CHECKS.include?(check) ? :convention : :spec)
64
+ end
65
+
66
+ # §11.2 (non-empty type) is the only hard error here; the missing
67
+ # recommended fields, non-list tags, and bad timestamp are soft warnings
68
+ # (never non-conformant). A parsed concept is valid UTF-8 by construction.
42
69
  def validate_concept(concept)
43
70
  @result.count(:concepts)
44
71
  @result.add_error(concept.path, "frontmatter must include a non-empty type") if OKF.blank?(concept.type)
45
- @result.add_warning(concept.path, "frontmatter should include title") if OKF.blank?(concept.title)
46
- @result.add_warning(concept.path, "frontmatter should include description") if OKF.blank?(concept.description)
47
- @result.add_warning(concept.path, "tags should be a list") if concept.frontmatter.key?("tags") && !concept.tags.is_a?(Array)
48
- validate_timestamp(concept.path, concept.timestamp) if concept.frontmatter.key?("timestamp")
72
+ record_warning(concept.path, :recommended_title, "frontmatter should include title") if OKF.blank?(concept.title)
73
+ record_warning(concept.path, :recommended_description, "frontmatter should include description") if OKF.blank?(concept.description)
74
+ record_warning(concept.path, :tags_shape, "tags should be a list") if concept.frontmatter.key?("tags") && !concept.tags.is_a?(Array)
75
+ validate_iso8601(concept.path, :timestamp_format, "timestamp", concept.timestamp) if concept.frontmatter.key?("timestamp")
76
+ validate_families(concept)
49
77
  check_links(concept.path, concept.body)
50
78
  end
51
79
 
52
- # §9.1: a concept-position file whose frontmatter did not parse. The message is
80
+ # ── the v0.2 families (§5, §10) ──────────────────────────────────────────
81
+ #
82
+ # Shape, and only shape: is it a mapping, does the date parse, is the enum
83
+ # value one the spec names. Whether a field is *missing*, *stale* or
84
+ # *unattributed* is curation, and the linter owns it — without that line the
85
+ # two would double-report every family.
86
+ #
87
+ # Every check reads the raw frontmatter key, never the fallback-carrying
88
+ # accessor — otherwise every v0.1 concept would warn about a mapping the
89
+ # §13.1 fallback synthesized, and "a pure v0.1 bundle validates silently"
90
+ # would stop being true. And every check is guarded by `frontmatter.key?`,
91
+ # so absence is never a fault: each family is optional (§5), and a v0.1
92
+ # concept that adopts none is a valid v0.2 concept. All warnings — §11's
93
+ # conformance conditions are only three.
94
+ def validate_families(concept)
95
+ validate_generated(concept)
96
+ validate_verified(concept)
97
+ validate_sources(concept)
98
+ validate_usage_window(concept)
99
+ validate_lifecycle(concept)
100
+ validate_computation(concept)
101
+ end
102
+
103
+ def validate_generated(concept)
104
+ return unless concept.frontmatter.key?("generated")
105
+
106
+ value = concept.frontmatter["generated"]
107
+ unless value.is_a?(Hash)
108
+ record_warning(concept.path, :generated_shape, "generated should be a mapping")
109
+ return
110
+ end
111
+
112
+ generated = Markdown::Frontmatter.stringify_keys(value)
113
+ record_warning(concept.path, :generated_by, "generated should include by") if OKF.blank?(generated["by"])
114
+ validate_iso8601(concept.path, :generated_at_format, "generated.at", generated["at"]) if generated.key?("at")
115
+ end
116
+
117
+ # §5.2 permits a list or a single bare mapping; anything else records no
118
+ # verification at all, which is what the warning is about.
119
+ def validate_verified(concept)
120
+ return unless concept.frontmatter.key?("verified")
121
+
122
+ value = concept.frontmatter["verified"]
123
+ return validate_verified_entries(concept, [ value ]) if value.is_a?(Hash)
124
+ return validate_verified_entries(concept, value) if value.is_a?(Array)
125
+
126
+ record_warning(concept.path, :verified_shape, "verified should be a mapping or a list of mappings")
127
+ end
128
+
129
+ def validate_verified_entries(concept, entries)
130
+ entries.each_with_index do |entry, index|
131
+ unless entry.is_a?(Hash)
132
+ record_warning(concept.path, :verified_entry_shape, "verified[#{index}] should be a mapping")
133
+ next
134
+ end
135
+
136
+ event = Markdown::Frontmatter.stringify_keys(entry)
137
+ record_warning(concept.path, :verified_entry_by, "verified[#{index}] should include by") if OKF.blank?(event["by"])
138
+ validate_iso8601(concept.path, :verified_entry_at_format, "verified[#{index}].at", event["at"]) if event.key?("at")
139
+ end
140
+ end
141
+
142
+ def validate_sources(concept)
143
+ return unless concept.frontmatter.key?("sources")
144
+
145
+ value = concept.frontmatter["sources"]
146
+ unless value.is_a?(Array)
147
+ record_warning(concept.path, :sources_shape, "sources should be a list")
148
+ return
149
+ end
150
+
151
+ value.each_with_index { |entry, index| validate_source(concept, entry, index) }
152
+ end
153
+
154
+ # `resource` is what makes a source addressable, so its absence is the one
155
+ # that matters most; `last_modified` and `usage_count` are the credibility
156
+ # signals with a shape to get wrong, and a per-entry `usage_window`
157
+ # override must be the same mapping the sibling is (§5.1).
158
+ def validate_source(concept, entry, index)
159
+ unless entry.is_a?(Hash)
160
+ record_warning(concept.path, :source_entry_shape, "sources[#{index}] should be a mapping")
161
+ return
162
+ end
163
+
164
+ source = Markdown::Frontmatter.stringify_keys(entry)
165
+ record_warning(concept.path, :source_resource, "sources[#{index}] should include resource") if OKF.blank?(source["resource"])
166
+ validate_date(concept.path, :source_last_modified, "sources[#{index}].last_modified", source["last_modified"]) if source.key?("last_modified")
167
+ if source.key?("usage_count") && !source["usage_count"].is_a?(Integer)
168
+ record_warning(concept.path, :source_usage_count, "sources[#{index}].usage_count should be an integer")
169
+ end
170
+ return unless source.key?("usage_window") && !source["usage_window"].is_a?(Hash)
171
+
172
+ record_warning(concept.path, :source_usage_window_shape, "sources[#{index}].usage_window should be a mapping")
173
+ end
174
+
175
+ def validate_usage_window(concept)
176
+ return unless concept.frontmatter.key?("usage_window")
177
+
178
+ value = concept.frontmatter["usage_window"]
179
+ unless value.is_a?(Hash)
180
+ record_warning(concept.path, :usage_window_shape, "usage_window should be a mapping")
181
+ return
182
+ end
183
+
184
+ window = Markdown::Frontmatter.stringify_keys(value)
185
+ %w[from to].each { |key| validate_date(concept.path, :usage_window_date, "usage_window.#{key}", window[key]) if window.key?(key) }
186
+ end
187
+
188
+ # §5.4 lets a producer use a status outside the three and requires consumers
189
+ # to tolerate it (§4.1), so this is a warning about a vocabulary a consumer
190
+ # keyed to the spec will not understand — never a rejection.
191
+ def validate_lifecycle(concept)
192
+ # fold_status, not effective_status: the §5.4 default belongs to a
193
+ # concept that never declared the key, and applying it here read a
194
+ # blank `status: ""` as `stable` — so the one value §5.4 names nowhere
195
+ # was the one value that never warned.
196
+ if concept.frontmatter.key?("status") && !Concept::STATUSES.include?(Concept.fold_status(concept.declared_status))
197
+ record_warning(concept.path, :status_vocabulary, "status should be one of #{Concept::STATUSES.join(", ")}")
198
+ end
199
+
200
+ validate_date(concept.path, :stale_after_format, "stale_after", concept.stale_after) if concept.frontmatter.key?("stale_after")
201
+ end
202
+
203
+ # §10.2 makes `runtime` REQUIRED for an Attested Computation — but §11's
204
+ # conformance conditions are only three, so its absence is a warning here
205
+ # and the linter owns the rest of the contract (one home per finding:
206
+ # shape and REQUIRED-within are the validator's side).
207
+ def validate_computation(concept)
208
+ if concept.attested_computation? && OKF.blank?(concept.frontmatter["runtime"])
209
+ record_warning(concept.path, :runtime_required, "runtime is required for an Attested Computation")
210
+ end
211
+
212
+ validate_parameters(concept)
213
+ validate_contract_mapping(concept, "executor", :executor_shape, :executor_resource)
214
+ validate_contract_mapping(concept, "attester", :attester_shape, :attester_resource)
215
+ end
216
+
217
+ def validate_parameters(concept)
218
+ return unless concept.frontmatter.key?("parameters")
219
+
220
+ value = concept.frontmatter["parameters"]
221
+ unless value.is_a?(Array)
222
+ record_warning(concept.path, :parameters_shape, "parameters should be a list")
223
+ return
224
+ end
225
+
226
+ value.each_with_index do |entry, index|
227
+ unless entry.is_a?(Hash)
228
+ record_warning(concept.path, :parameter_entry_shape, "parameters[#{index}] should be a mapping")
229
+ next
230
+ end
231
+
232
+ parameter = Markdown::Frontmatter.stringify_keys(entry)
233
+ record_warning(concept.path, :parameter_name, "parameters[#{index}] should include name") if OKF.blank?(parameter["name"])
234
+ end
235
+ end
236
+
237
+ def validate_contract_mapping(concept, key, shape_check, resource_check)
238
+ return unless concept.frontmatter.key?(key)
239
+
240
+ value = concept.frontmatter[key]
241
+ unless value.is_a?(Hash)
242
+ record_warning(concept.path, shape_check, "#{key} should be a mapping")
243
+ return
244
+ end
245
+
246
+ contract = Markdown::Frontmatter.stringify_keys(value)
247
+ record_warning(concept.path, resource_check, "#{key} should include resource") if OKF.blank?(contract["resource"])
248
+ end
249
+
250
+ # §11.1: a concept-position file whose frontmatter did not parse. The message is
53
251
  # the ParseError captured at read time.
54
252
  def validate_unparseable(entry)
55
253
  unless entry.content.valid_encoding?
@@ -85,46 +283,89 @@ module OKF
85
283
  frontmatter, = Markdown::Frontmatter.parse(content)
86
284
  extra_keys = frontmatter.keys - [ "okf_version" ]
87
285
  @result.add_error(path, "root index.md frontmatter may only include okf_version") if extra_keys.any?
286
+ validate_okf_version(path, frontmatter["okf_version"]) if frontmatter.key?("okf_version")
88
287
  rescue Markdown::Frontmatter::ParseError => e
89
288
  @result.add_error(path, e.message)
90
289
  end
91
290
 
291
+ # §12 asks a consumer to attempt best-effort consumption rather than
292
+ # refuse, so an unknown version is a warning and the bundle is read
293
+ # anyway; an absent one is the sanctioned MAY-not-declare case and never
294
+ # warns. Compared after `to_s.strip`: an unquoted `okf_version: 0.2` is a
295
+ # Psych Float, and warning on a correctly-declared bundle is the bug.
296
+ def validate_okf_version(path, declared)
297
+ return if OKF.blank?(declared) || Concept::KNOWN_SPEC_VERSIONS.include?(declared.to_s.strip)
298
+
299
+ record_warning(path, :okf_version_unknown,
300
+ "okf_version `#{declared}` is not a version this gem knows (read best-effort under §12)")
301
+ end
302
+
92
303
  def validate_log(path, content)
93
304
  content.each_line do |line|
94
305
  next unless line.start_with?("## ")
95
306
 
96
307
  heading = line.sub(/\A## /, "").strip
97
- next if heading.match?(/\A\d{4}-\d{2}-\d{2}\z/)
308
+ next if date_heading?(heading)
98
309
 
99
310
  @result.add_error(path, "log.md date headings must use YYYY-MM-DD")
100
311
  end
101
312
  end
102
313
 
103
- # Broken bundle-internal links are warnings only (§5.3): the spec requires
314
+ # §9's MUST is ISO 8601, and 2026-02-30 is not a date the digit shape
315
+ # alone accepted it, so a bundle could be conformant around a day that
316
+ # never existed. Same message either way: "must use YYYY-MM-DD" is as
317
+ # true of a February 30th as of a prose heading.
318
+ def date_heading?(heading)
319
+ return false unless heading.match?(/\A\d{4}-\d{2}-\d{2}\z/)
320
+
321
+ Date.iso8601(heading)
322
+ true
323
+ rescue ArgumentError
324
+ false
325
+ end
326
+
327
+ # Broken bundle-internal links are warnings only (§6.1): the spec requires
104
328
  # consumers to tolerate them, so they never make a bundle non-conformant.
105
329
  def check_links(path, content)
106
330
  Markdown::Links.extract(content).each do |raw|
107
331
  resolved = Markdown::Links.resolve(raw, from: path, bundle: @bundle.root)
108
332
  next if resolved.nil? || @existing.include?(resolved)
109
333
 
110
- @result.add_warning(path, "cross-link target not found: `#{raw}` (tolerated under §5.3)")
334
+ record_warning(path, :broken_link, "cross-link target not found: `#{raw}` (tolerated under §6.1)")
111
335
  end
112
336
  end
113
337
 
114
338
  # A YAML-parsed Date/Time is temporal by construction (YAML already validated
115
339
  # the shape); only a String needs checking, and it may be a full ISO 8601
116
340
  # datetime (2026-05-28T14:30:00Z) or a date-only value (2026-05-28).
117
- def validate_timestamp(path, timestamp)
118
- return if timestamp.is_a?(Date) || timestamp.is_a?(Time)
341
+ #
342
+ # `field` names what is being checked: one rule about what a moment looks
343
+ # like serves the legacy `timestamp`, `generated.at` and every
344
+ # `verified[].at`, reported against whichever field carried it.
345
+ def validate_iso8601(path, check, field, value)
346
+ return if value.is_a?(Date) || value.is_a?(Time)
119
347
 
120
- value = timestamp.to_s
348
+ string = value.to_s
121
349
  begin
122
- Time.iso8601(value)
350
+ Time.iso8601(string)
123
351
  rescue ArgumentError
124
- Date.iso8601(value)
352
+ Date.iso8601(string)
125
353
  end
126
354
  rescue ArgumentError
127
- @result.add_warning(path, "timestamp should be ISO 8601 parseable")
355
+ record_warning(path, check, "#{field} should be ISO 8601 parseable")
356
+ end
357
+
358
+ # §5.1/§5.5 want a calendar day, not a moment: `last_modified`,
359
+ # `usage_window.from`/`to` and `stale_after` are all YYYY-MM-DD. A YAML Date
360
+ # is one by construction; a Time is not — the extra precision means the
361
+ # producer wrote something else.
362
+ def validate_date(path, check, field, value)
363
+ return if value.is_a?(Date) && !value.is_a?(DateTime)
364
+
365
+ Date.iso8601(value.to_s)
366
+ record_warning(path, check, "#{field} should be a YYYY-MM-DD date") unless value.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
367
+ rescue ArgumentError
368
+ record_warning(path, check, "#{field} should be a YYYY-MM-DD date")
128
369
  end
129
370
  end
130
371
  end
@@ -3,7 +3,7 @@
3
3
  module OKF
4
4
  class Bundle
5
5
  # Atomically writes a bundle to disk: renders concepts to a temp directory,
6
- # validates it for §9 conformance (so a malformed bundle is never published),
6
+ # validates it for §11 conformance (so a malformed bundle is never published),
7
7
  # then promotes it into place under a lock. The disk-writing counterpart to
8
8
  # Reader.
9
9
  class Writer
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,18 +175,87 @@ 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
162
246
  # `okf index` view and the server's Index panel (/index).
247
+ # Every directory this bundle has — the same set #directory_index enumerates
248
+ # (concepts, an index.md or a log.md, plus every ancestor), without building
249
+ # the map. It is the answer to "does this bundle have a directory named X?",
250
+ # and the CLI needs exactly that to decide whether `--dir root` names a real
251
+ # directory or the bundle root. Reading it off #catalog instead is the same
252
+ # question asked of a smaller set, which is how the two views came to
253
+ # disagree about one bundle. Memoized: the model is immutable once read,
254
+ # and the resolvers above ask per invocation, not per bundle load.
255
+ def directories
256
+ @directories ||= directory_set(concepts.map { |concept| File.dirname(concept.path) }.uniq)
257
+ end
258
+
163
259
  def directory_index
164
260
  by_dir = concepts.group_by { |concept| File.dirname(concept.path) }
165
261
  dirs = directory_set(by_dir.keys)
@@ -193,6 +289,12 @@ module OKF
193
289
 
194
290
  private
195
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
+
196
298
  # A concept's top-level dir, derived from its id — the first path segment, the
197
299
  # same derivation the catalog exposes, so every grouped view labels the bundle
198
300
  # root "(root)". OKF.dir_of keeps the levels this one rolls up.
@@ -200,11 +302,14 @@ module OKF
200
302
  id.include?("/") ? id.split("/").first : "(root)"
201
303
  end
202
304
 
203
- # Every directory to show: those holding concepts or an index.md, plus each of
204
- # their ancestors up to the root, so the subdir tree stays connected even when
205
- # an intermediate directory holds nothing directly. Sorted with "." first.
305
+ # Every directory to show: those holding concepts, an index.md or a log.md,
306
+ # plus each of their ancestors up to the root, so the subdir tree stays
307
+ # connected even when an intermediate directory holds nothing directly. A
308
+ # scoped log counts because `okf log` reads it — a directory whose only file
309
+ # is its history still exists, and leaving it out is how the `root` alias
310
+ # beat a real `root/` for the second file kind in a row. Sorted "." first.
206
311
  def directory_set(concept_dirs)
207
- seed = concept_dirs + index_files.map { |path| File.dirname(path) }
312
+ seed = concept_dirs + (index_files + log_files).map { |path| File.dirname(path) }
208
313
  dirs = {}
209
314
  seed.each do |dir|
210
315
  current = dir
@@ -218,6 +323,17 @@ module OKF
218
323
  dirs.keys.sort_by { |dir| dir == "." ? "" : dir }
219
324
  end
220
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
+
221
337
  # { value => count }, ordered by count descending then value.
222
338
  def tally(values)
223
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)
@@ -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_catalog_json(dir, selected, options) if options[:json]
38
38
 
39
39
  print_catalog(dir, selected, entries.size)