okf 1.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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/capabilities/agent-skill.md +46 -0
  3. data/.okf/capabilities/graph-server.md +60 -0
  4. data/.okf/capabilities/index.md +20 -0
  5. data/.okf/capabilities/library-api.md +67 -0
  6. data/.okf/capabilities/linter.md +49 -0
  7. data/.okf/capabilities/read-views.md +84 -0
  8. data/.okf/capabilities/validator.md +40 -0
  9. data/.okf/cli.md +52 -0
  10. data/.okf/design/core-shell-split.md +58 -0
  11. data/.okf/design/index.md +10 -0
  12. data/.okf/design/ruby-floor.md +45 -0
  13. data/.okf/design/runtime-dependencies.md +44 -0
  14. data/.okf/design/server-trust-boundary.md +35 -0
  15. data/.okf/format/citations.md +33 -0
  16. data/.okf/format/cross-links.md +52 -0
  17. data/.okf/format/frontmatter.md +38 -0
  18. data/.okf/format/index.md +9 -0
  19. data/.okf/format/okf-format.md +43 -0
  20. data/.okf/index.md +18 -0
  21. data/.okf/log.md +9 -0
  22. data/.okf/model/bundle.md +38 -0
  23. data/.okf/model/concept.md +44 -0
  24. data/.okf/model/graph.md +44 -0
  25. data/.okf/model/index.md +8 -0
  26. data/.okf/overview.md +66 -0
  27. data/CHANGELOG.md +54 -0
  28. data/CODE_OF_CONDUCT.md +10 -0
  29. data/LICENSE.txt +201 -0
  30. data/NOTICE +10 -0
  31. data/README.md +276 -0
  32. data/exe/okf +6 -0
  33. data/lib/okf/bundle/folder.rb +94 -0
  34. data/lib/okf/bundle/graph.rb +118 -0
  35. data/lib/okf/bundle/linter/report.rb +56 -0
  36. data/lib/okf/bundle/linter.rb +416 -0
  37. data/lib/okf/bundle/reader.rb +60 -0
  38. data/lib/okf/bundle/validator/result.rb +35 -0
  39. data/lib/okf/bundle/validator.rb +131 -0
  40. data/lib/okf/bundle/writer.rb +137 -0
  41. data/lib/okf/bundle.rb +216 -0
  42. data/lib/okf/cli.rb +910 -0
  43. data/lib/okf/concept/file.rb +63 -0
  44. data/lib/okf/concept.rb +101 -0
  45. data/lib/okf/markdown/citations.rb +49 -0
  46. data/lib/okf/markdown/frontmatter.rb +55 -0
  47. data/lib/okf/markdown/links.rb +98 -0
  48. data/lib/okf/path.rb +34 -0
  49. data/lib/okf/server/app.rb +120 -0
  50. data/lib/okf/server/graph.rb +112 -0
  51. data/lib/okf/server/runner.rb +78 -0
  52. data/lib/okf/server/templates/graph.html.erb +803 -0
  53. data/lib/okf/skill/SKILL.md +133 -0
  54. data/lib/okf/skill/reference/APACHE-2.0.txt +202 -0
  55. data/lib/okf/skill/reference/SPEC.md +460 -0
  56. data/lib/okf/skill/reference/authoring.md +218 -0
  57. data/lib/okf/skill/reference/cli.md +196 -0
  58. data/lib/okf/skill/templates/concept.md +24 -0
  59. data/lib/okf/skill/templates/index.md +8 -0
  60. data/lib/okf/skill/templates/log.md +6 -0
  61. data/lib/okf/skill/templates/root-index.md +12 -0
  62. data/lib/okf/skill.rb +82 -0
  63. data/lib/okf/version.rb +5 -0
  64. data/lib/okf.rb +55 -0
  65. metadata +142 -0
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ # Lints a bundle for curation quality — the deterministic subset of the
6
+ # ingest → query → lint loop (overview.md): reachability, backlog, completeness,
7
+ # freshness, provenance, and hygiene. Pure — it reads nothing from disk and works
8
+ # entirely on the in-memory OKF::Bundle, mirroring OKF::Bundle::Validator.
9
+ #
10
+ # Unlike OKF::Bundle::Validator (the §9 conformance gate, which MUST NOT reject for broken
11
+ # links or missing optional fields), lint never rejects a bundle: it reports
12
+ # `:warn` and `:info` findings the spec marks as tolerable, and emits them as
13
+ # structured data (OKF::Bundle::Linter::Report) for a human or agent to act on. Contradictions
14
+ # and semantic staleness are NOT detected here — they need meaning, not structure;
15
+ # the JSON report is the substrate an agent consumes for those passes.
16
+ class Linter
17
+ # All checks, in display/registry order. `--only`/`--except` select from these.
18
+ CHECKS = %i[
19
+ orphan not_in_index disconnected_component unlinked
20
+ missing_concept broken_index_entry
21
+ stub missing_title missing_description missing_timestamp
22
+ stale
23
+ uncited_external broken_citation
24
+ duplicate_title unused_reference_def undefined_reference self_link
25
+ ].freeze
26
+
27
+ DEFAULT_MIN_BODY = 50
28
+ HUB_LIMIT = 5
29
+
30
+ def self.call(bundle, **options)
31
+ new(bundle, **options).call
32
+ end
33
+
34
+ def initialize(bundle, min_body: DEFAULT_MIN_BODY, stale_before: nil, only: nil, except: nil)
35
+ @bundle = bundle
36
+ @min_body = min_body
37
+ @stale_before = stale_before
38
+ @only = only
39
+ @except = except
40
+ @report = Report.new
41
+ end
42
+
43
+ def call
44
+ prepare
45
+ selected_checks.each { |check| send("check_#{check}") }
46
+ fill_stats
47
+ @report
48
+ end
49
+
50
+ private
51
+
52
+ # Shared derived data, computed once and reused by every check.
53
+ def prepare
54
+ @concepts = @bundle.concepts
55
+ @graph = Graph.build(@bundle)
56
+ @ids = @concepts.to_set(&:id)
57
+ @existing = @bundle.paths.to_set
58
+ @inbound = Hash.new(0)
59
+ @graph.edges.each { |edge| @inbound[edge[:target]] += 1 }
60
+ @indexed_ids = indexed_by_dir.values.reduce(Set.new, :|)
61
+ end
62
+
63
+ def selected_checks
64
+ checks = CHECKS
65
+ checks &= Array(@only).map(&:to_sym) if @only
66
+ checks -= Array(@except).map(&:to_sym) if @except
67
+ checks
68
+ end
69
+
70
+ # ── Reachability ─────────────────────────────────────────────────────────
71
+
72
+ def check_orphan
73
+ @concepts.each do |concept|
74
+ next if @inbound[concept.id].positive? || @indexed_ids.include?(concept.id)
75
+
76
+ @report.add_warning(:orphan, "#{concept.id}.md",
77
+ "unreachable: no inbound links and not listed in any index.md")
78
+ end
79
+ end
80
+
81
+ def check_not_in_index
82
+ indexed_by_dir.each do |dir, listed|
83
+ concepts_in(dir).each do |concept|
84
+ next if listed.include?(concept.id)
85
+
86
+ @report.add_warning(:not_in_index, "#{concept.id}.md",
87
+ "not listed in its directory index (#{index_path_for(dir)})",
88
+ metric: { index: index_path_for(dir) })
89
+ end
90
+ end
91
+ end
92
+
93
+ # Reports genuine multi-concept islands only. A size-1 component is either an
94
+ # orphan (already flagged by check_orphan) or a lone indexed leaf, so reporting
95
+ # every unlinked node here would just be noise.
96
+ def check_disconnected_component
97
+ groups = components
98
+ return if groups.size <= 1
99
+
100
+ main = groups.max_by(&:size)
101
+ groups.each do |members|
102
+ next if members.equal?(main) || members.size < 2
103
+
104
+ @report.add_info(:disconnected_component, nil,
105
+ "#{members.size} concepts form an island disconnected from the main graph",
106
+ metric: { size: members.size, members: members.sort })
107
+ end
108
+ end
109
+
110
+ # A concept with graph degree 0 — no cross-links in or out — floats in a
111
+ # rendered graph, reachable only via its index listing (if any). Advisory
112
+ # (info): a legitimately terminal leaf (a backlog item, a spec reference) is
113
+ # fine; this just surfaces the set so a human/agent can judge intent. Unlike
114
+ # :orphan, an index.md listing does NOT silence it — being *listed* is not
115
+ # being *linked*, and it is the missing links this catches.
116
+ def check_unlinked
117
+ loose = @graph.unlinked_ids.to_set
118
+ @concepts.each do |concept|
119
+ next unless loose.include?(concept.id)
120
+
121
+ @report.add_info(:unlinked, "#{concept.id}.md",
122
+ "no cross-links (in or out); it floats in the graph")
123
+ end
124
+ end
125
+
126
+ # ── Backlog ──────────────────────────────────────────────────────────────
127
+
128
+ # NOTE: this must NOT reuse @graph.edges — the graph drops targets that do not
129
+ # exist and dedups pairs, which would erase exactly this backlog. Count raw
130
+ # link occurrences from Markdown::Links.extract instead.
131
+ def check_missing_concept
132
+ demand = Hash.new { |hash, key| hash[key] = { references: 0, sources: [] } }
133
+ @concepts.each do |concept|
134
+ Markdown::Links.extract(concept.body).each do |raw|
135
+ target = Markdown::Links.resolve(raw, from: concept.path, bundle: @bundle.root)
136
+ next if target.nil? || @existing.include?(target)
137
+
138
+ entry = demand[target]
139
+ entry[:references] += 1
140
+ entry[:sources] << concept.id unless entry[:sources].include?(concept.id)
141
+ end
142
+ end
143
+
144
+ demand.sort_by { |target, entry| [ -entry[:references], target ] }.each do |target, entry|
145
+ @report.add_info(:missing_concept, target,
146
+ "referenced by #{entry[:references]} link(s) across #{entry[:sources].size} concept(s) but does not exist",
147
+ metric: { references: entry[:references], sources: entry[:sources] })
148
+ end
149
+ end
150
+
151
+ def check_broken_index_entry
152
+ @bundle.index_files.each do |path|
153
+ Markdown::Links.extract(content_of(path)).each do |raw|
154
+ target = Markdown::Links.resolve(raw, from: path, bundle: @bundle.root)
155
+ next if target.nil? || @existing.include?(target)
156
+
157
+ @report.add_warning(:broken_index_entry, path,
158
+ "index links to missing concept `#{raw}`", metric: { target: target })
159
+ end
160
+ end
161
+ end
162
+
163
+ # ── Completeness ───────────────────────────────────────────────────────────
164
+
165
+ def check_stub
166
+ @concepts.each do |concept|
167
+ length = concept.body.to_s.strip.length
168
+ next if length >= @min_body
169
+
170
+ @report.add_info(:stub, "#{concept.id}.md",
171
+ "body is #{length} character(s) (under min-body #{@min_body})",
172
+ metric: { chars: length, min: @min_body })
173
+ end
174
+ end
175
+
176
+ def check_missing_title
177
+ each_missing(:title, :missing_title, "title")
178
+ end
179
+
180
+ def check_missing_description
181
+ each_missing(:description, :missing_description, "description")
182
+ end
183
+
184
+ def check_missing_timestamp
185
+ @concepts.each do |concept|
186
+ next unless concept.timestamp.nil?
187
+
188
+ @report.add_info(:missing_timestamp, "#{concept.id}.md", "missing recommended field: timestamp")
189
+ end
190
+ end
191
+
192
+ # ── Freshness (opt-in) ───────────────────────────────────────────────────────
193
+
194
+ def check_stale
195
+ return if @stale_before.nil?
196
+
197
+ @concepts.each do |concept|
198
+ at = parse_time(concept.timestamp)
199
+ next if at.nil? || at >= @stale_before
200
+
201
+ @report.add_warning(:stale, "#{concept.id}.md",
202
+ "last updated #{concept.timestamp}; older than cutoff #{@stale_before}",
203
+ metric: { timestamp: concept.timestamp.to_s, cutoff: @stale_before.to_s })
204
+ end
205
+ end
206
+
207
+ # ── Provenance (§8) ──────────────────────────────────────────────────────────
208
+
209
+ def check_uncited_external
210
+ @concepts.each do |concept|
211
+ externals = Markdown::Links.extract(concept.body).count { |raw| external?(raw) }
212
+ next if externals.zero? || Markdown::Citations.section(concept.body)
213
+
214
+ @report.add_info(:uncited_external, "#{concept.id}.md",
215
+ "body has external link(s) but no # Citations section",
216
+ metric: { external_count: externals })
217
+ end
218
+ end
219
+
220
+ # Verifies .md citation targets only; §8 also permits non-.md references/ assets,
221
+ # which the Bundle does not index and so cannot be checked here.
222
+ def check_broken_citation
223
+ @concepts.each do |concept|
224
+ Markdown::Citations.targets(concept.body).each do |raw|
225
+ target = Markdown::Links.resolve(raw, from: concept.path, bundle: @bundle.root)
226
+ next if target.nil? || @existing.include?(target)
227
+
228
+ @report.add_warning(:broken_citation, "#{concept.id}.md",
229
+ "citation target `#{raw}` does not exist in the bundle", metric: { target: target })
230
+ end
231
+ end
232
+ end
233
+
234
+ # ── Hygiene ────────────────────────────────────────────────────────────────
235
+
236
+ def check_duplicate_title
237
+ @concepts.group_by { |concept| concept.title.to_s.strip.downcase }.each do |key, members|
238
+ next if key.empty? || members.size < 2
239
+
240
+ @report.add_info(:duplicate_title, nil,
241
+ "title #{members.first.title.inspect} used by #{members.size} concepts",
242
+ metric: { title: members.first.title, concepts: members.map(&:id).sort })
243
+ end
244
+ end
245
+
246
+ def check_unused_reference_def
247
+ @concepts.each do |concept|
248
+ defined = Markdown::Links.reference_definitions(concept.body).keys
249
+ (defined - reference_uses(concept.body)).each do |label|
250
+ @report.add_info(:unused_reference_def, "#{concept.id}.md",
251
+ "reference definition `[#{label}]` is defined but never used", metric: { label: label })
252
+ end
253
+ end
254
+ end
255
+
256
+ def check_undefined_reference
257
+ @concepts.each do |concept|
258
+ defined = Markdown::Links.reference_definitions(concept.body).keys
259
+ (reference_uses(concept.body) - defined).each do |label|
260
+ @report.add_warning(:undefined_reference, "#{concept.id}.md",
261
+ "reference-style link `[#{label}]` has no matching definition (an invisible broken link)",
262
+ metric: { label: label })
263
+ end
264
+ end
265
+ end
266
+
267
+ def check_self_link
268
+ @concepts.each do |concept|
269
+ count = Markdown::Links.extract(concept.body).count do |raw|
270
+ target = Markdown::Links.resolve(raw, from: concept.path, bundle: @bundle.root)
271
+ target && target.sub(/\.md\z/, "") == concept.id
272
+ end
273
+ next if count.zero?
274
+
275
+ @report.add_info(:self_link, "#{concept.id}.md", "concept links to itself", metric: { count: count })
276
+ end
277
+ end
278
+
279
+ # ── stats ────────────────────────────────────────────────────────────────────
280
+
281
+ def fill_stats
282
+ @report.stat(:concepts, @concepts.size)
283
+ @report.stat(:edges, @graph.edges.size)
284
+ @report.stat(:indexes, @bundle.index_files.size)
285
+ @report.stat(:logs, @bundle.log_files.size)
286
+ @report.stat(:skipped, @bundle.unparseable.size)
287
+ @report.stat(:orphans, count_findings(:orphan))
288
+ @report.stat(:loose, count_findings(:unlinked))
289
+ @report.stat(:stubs, count_findings(:stub))
290
+ @report.stat(:backlog, count_findings(:missing_concept))
291
+ @report.stat(:components, components.size)
292
+ @report.stat(:hubs, hubs)
293
+ @report.stat(:types, frequency(@concepts.map { |c| c.type || "Untyped" }))
294
+ @report.stat(:tags, frequency(@concepts.flat_map { |c| c.tags.is_a?(Array) ? c.tags : [] }))
295
+ end
296
+
297
+ # ── helpers ────────────────────────────────────────────────────────────────
298
+
299
+ def each_missing(field, check, label)
300
+ @concepts.each do |concept|
301
+ next unless OKF.blank?(concept.public_send(field))
302
+
303
+ @report.add_info(check, "#{concept.id}.md", "missing recommended field: #{label}")
304
+ end
305
+ end
306
+
307
+ # dir (File.dirname of the index path; root index → ".") => Set of listed ids.
308
+ def indexed_by_dir
309
+ @indexed_by_dir ||= @bundle.index_files.each_with_object({}) do |path, map|
310
+ map[File.dirname(path)] = resolved_ids(path)
311
+ end
312
+ end
313
+
314
+ def resolved_ids(index_path)
315
+ Markdown::Links.extract(content_of(index_path)).map do |raw|
316
+ target = Markdown::Links.resolve(raw, from: index_path, bundle: @bundle.root)
317
+ target&.sub(/\.md\z/, "")
318
+ end.compact.to_set
319
+ end
320
+
321
+ def concepts_in(dir)
322
+ @concepts.select { |concept| File.dirname("#{concept.id}.md") == dir }
323
+ end
324
+
325
+ def index_path_for(dir)
326
+ dir == "." ? "index.md" : "#{dir}/index.md"
327
+ end
328
+
329
+ # Connected components of the concept graph, treating edges as undirected. Every
330
+ # concept appears in exactly one component (isolated concepts are singletons).
331
+ def components
332
+ @components ||= begin
333
+ adjacency = Hash.new { |hash, key| hash[key] = [] }
334
+ @graph.edges.each do |edge|
335
+ adjacency[edge[:source]] << edge[:target]
336
+ adjacency[edge[:target]] << edge[:source]
337
+ end
338
+ seen = Set.new
339
+ @ids.sort.each_with_object([]) do |id, groups|
340
+ next if seen.include?(id)
341
+
342
+ groups << reachable_from(id, adjacency, seen)
343
+ end
344
+ end
345
+ end
346
+
347
+ def reachable_from(start, adjacency, seen)
348
+ queue = [ start ]
349
+ seen << start
350
+ members = []
351
+ until queue.empty?
352
+ node = queue.shift
353
+ members << node
354
+ adjacency[node].each do |neighbor|
355
+ next if seen.include?(neighbor)
356
+
357
+ seen << neighbor
358
+ queue << neighbor
359
+ end
360
+ end
361
+ members
362
+ end
363
+
364
+ def hubs
365
+ @inbound.select { |_, degree| degree.positive? }
366
+ .sort_by { |id, degree| [ -degree, id ] }
367
+ .first(HUB_LIMIT)
368
+ .map { |id, degree| { id: id, in_degree: degree } }
369
+ end
370
+
371
+ def frequency(values)
372
+ counts = values.each_with_object(Hash.new(0)) { |value, hash| hash[value] += 1 }
373
+ counts.sort_by { |value, count| [ -count, value.to_s ] }.to_h
374
+ end
375
+
376
+ def reference_uses(body)
377
+ uses = []
378
+ Markdown::Links.each_prose_line(body.to_s) do |line|
379
+ line.scan(Markdown::Links::REFERENCE_LINK).each do |label, explicit|
380
+ uses << (explicit.empty? ? label : explicit).strip.downcase
381
+ end
382
+ end
383
+ uses.uniq
384
+ end
385
+
386
+ def external?(raw)
387
+ raw.match?(Markdown::Links::SCHEME) || raw.start_with?("mailto:")
388
+ end
389
+
390
+ def count_findings(check)
391
+ @report.findings.count { |finding| finding[:check] == check }
392
+ end
393
+
394
+ def content_of(path)
395
+ @bundle.reserved_content(path)
396
+ end
397
+
398
+ def parse_time(value)
399
+ return value.to_time if value.is_a?(Date)
400
+ return value if value.is_a?(Time)
401
+ return nil if value.nil?
402
+
403
+ string = value.to_s
404
+ begin
405
+ Time.iso8601(string)
406
+ rescue ArgumentError
407
+ begin
408
+ Date.iso8601(string).to_time
409
+ rescue ArgumentError
410
+ nil
411
+ end
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ # Reads an OKF bundle directory into an in-memory OKF::Bundle. Together with
6
+ # Bundle::Writer this is the only component that touches the filesystem — the
7
+ # core (Bundle, Concept, Graph, Validator, Linter) then works purely in memory.
8
+ #
9
+ # It parses eagerly: each concept file becomes an OKF::Concept, each
10
+ # index.md/log.md is kept as raw text (its structure is validated as text), and
11
+ # a concept file whose frontmatter does not parse is retained as an unparseable
12
+ # entry (carrying the ParseError message, so §9.1 can report it) rather than
13
+ # dropped or raised. Every read goes through Path.join_under! so a
14
+ # symlinked or crafted path cannot escape the bundle root.
15
+ class Reader
16
+ def self.read(dir)
17
+ new(dir).read
18
+ end
19
+
20
+ attr_reader :root
21
+
22
+ def initialize(dir)
23
+ @root = File.expand_path(dir.to_s)
24
+ end
25
+
26
+ def read
27
+ concepts = []
28
+ reserved = []
29
+ unparseable = []
30
+
31
+ markdown_paths.each do |path|
32
+ begin
33
+ content = File.read(Path.join_under!(@root, path), encoding: "UTF-8")
34
+ if Concept.reserved?(path)
35
+ reserved << Entry.new(path: path, content: content)
36
+ else
37
+ frontmatter, body = Markdown::Frontmatter.parse(content)
38
+ concepts << Concept.new(path: path, frontmatter: frontmatter, body: body)
39
+ end
40
+ rescue Markdown::Frontmatter::ParseError => e
41
+ unparseable << Entry.new(path: path, content: content, error: e.message)
42
+ end
43
+ end
44
+
45
+ Bundle.new(concepts: concepts, reserved: reserved, unparseable: unparseable, root: @root)
46
+ end
47
+
48
+ private
49
+
50
+ def markdown_paths
51
+ return [] unless Dir.exist?(@root)
52
+
53
+ Dir.glob(File.join(@root, "**", "*.md"))
54
+ .select { |path| File.file?(path) }
55
+ .map { |path| Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s }
56
+ .sort
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ class Validator
6
+ # The outcome of a §9 conformance check (see OKF::Bundle::Validator): hard `errors`,
7
+ # soft `warnings`, and file `counts`. Conformant iff there are no errors.
8
+ class Result
9
+ attr_reader :errors, :warnings, :counts
10
+
11
+ def initialize
12
+ @errors = []
13
+ @warnings = []
14
+ @counts = { concepts: 0, indexes: 0, logs: 0 }
15
+ end
16
+
17
+ def valid?
18
+ errors.empty?
19
+ end
20
+
21
+ def add_error(path, message)
22
+ errors << { path: path, message: message }
23
+ end
24
+
25
+ def add_warning(path, message)
26
+ warnings << { path: path, message: message }
27
+ end
28
+
29
+ def count(kind)
30
+ @counts[kind] += 1 if @counts.key?(kind)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ # Checks an OKF::Bundle against the OKF v0.1 conformance rules (§9), which has
6
+ # three conditions — all hard errors:
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 §6/§7 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
+ #
14
+ # Everything the spec marks as soft guidance is a warning and never makes a
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.
19
+ class Validator
20
+ def self.call(bundle)
21
+ new(bundle).call
22
+ end
23
+
24
+ def initialize(bundle)
25
+ @bundle = bundle
26
+ @result = Result.new
27
+ end
28
+
29
+ def call
30
+ @existing = @bundle.paths.to_set
31
+ @bundle.concepts.each { |concept| validate_concept(concept) }
32
+ @bundle.reserved.each { |entry| validate_reserved(entry) }
33
+ @bundle.unparseable.each { |entry| validate_unparseable(entry) }
34
+ @result
35
+ end
36
+
37
+ private
38
+
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.
42
+ def validate_concept(concept)
43
+ @result.count(:concepts)
44
+ @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")
49
+ check_links(concept.path, concept.body)
50
+ end
51
+
52
+ # §9.1: a concept-position file whose frontmatter did not parse. The message is
53
+ # the ParseError captured at read time.
54
+ def validate_unparseable(entry)
55
+ unless entry.content.valid_encoding?
56
+ @result.add_error(entry.path, "file content is not valid UTF-8")
57
+ return
58
+ end
59
+
60
+ @result.count(:concepts)
61
+ @result.add_error(entry.path, entry.error)
62
+ check_links(entry.path, entry.content)
63
+ end
64
+
65
+ def validate_reserved(entry)
66
+ unless entry.content.valid_encoding?
67
+ @result.add_error(entry.path, "file content is not valid UTF-8")
68
+ return
69
+ end
70
+
71
+ @result.count(File.basename(entry.path) == "index.md" ? :indexes : :logs)
72
+ validate_index(entry.path, entry.content) if File.basename(entry.path) == "index.md"
73
+ validate_log(entry.path, entry.content) if File.basename(entry.path) == "log.md"
74
+ check_links(entry.path, entry.content)
75
+ end
76
+
77
+ def validate_index(path, content)
78
+ return unless content.match?(/\A---[ \t]*\n/)
79
+
80
+ if path != "index.md"
81
+ @result.add_error(path, "nested index.md must not include frontmatter")
82
+ return
83
+ end
84
+
85
+ frontmatter, = Markdown::Frontmatter.parse(content)
86
+ extra_keys = frontmatter.keys - [ "okf_version" ]
87
+ @result.add_error(path, "root index.md frontmatter may only include okf_version") if extra_keys.any?
88
+ rescue Markdown::Frontmatter::ParseError => e
89
+ @result.add_error(path, e.message)
90
+ end
91
+
92
+ def validate_log(path, content)
93
+ content.each_line do |line|
94
+ next unless line.start_with?("## ")
95
+
96
+ heading = line.sub(/\A## /, "").strip
97
+ next if heading.match?(/\A\d{4}-\d{2}-\d{2}\z/)
98
+
99
+ @result.add_error(path, "log.md date headings must use YYYY-MM-DD")
100
+ end
101
+ end
102
+
103
+ # Broken bundle-internal links are warnings only (§5.3): the spec requires
104
+ # consumers to tolerate them, so they never make a bundle non-conformant.
105
+ def check_links(path, content)
106
+ Markdown::Links.extract(content).each do |raw|
107
+ resolved = Markdown::Links.resolve(raw, from: path, bundle: @bundle.root)
108
+ next if resolved.nil? || @existing.include?(resolved)
109
+
110
+ @result.add_warning(path, "cross-link target not found: `#{raw}` (tolerated under §5.3)")
111
+ end
112
+ end
113
+
114
+ # A YAML-parsed Date/Time is temporal by construction (YAML already validated
115
+ # the shape); only a String needs checking, and it may be a full ISO 8601
116
+ # 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)
119
+
120
+ value = timestamp.to_s
121
+ begin
122
+ Time.iso8601(value)
123
+ rescue ArgumentError
124
+ Date.iso8601(value)
125
+ end
126
+ rescue ArgumentError
127
+ @result.add_warning(path, "timestamp should be ISO 8601 parseable")
128
+ end
129
+ end
130
+ end
131
+ end