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
@@ -10,8 +10,8 @@ module OKF
10
10
  # index.md/log.md is kept as raw text (its structure is validated as text), and
11
11
  # a file the reader cannot use — frontmatter that does not parse, or a file it
12
12
  # cannot open at all — is retained as an unparseable entry (carrying the
13
- # ParseError message or the errno, so §9.1 can report it) rather than dropped
14
- # or raised. That tolerance is the whole §9 best-effort promise: one bad file
13
+ # ParseError message or the errno, so §11 condition 1 can report it) rather than dropped
14
+ # or raised. That tolerance is the whole §11 best-effort promise: one bad file
15
15
  # never breaks the rest, and this is the read every verb shares.
16
16
  #
17
17
  # Containment is enforced twice, because the two ways out of the root are
@@ -22,7 +22,7 @@ module OKF
22
22
  # location checked against the real root before a byte is read. An escaping
23
23
  # file joins the unparseable bucket rather than raising: a planted symlink is
24
24
  # one bad file, and letting it take down the whole bundle read would hand any
25
- # writer of a served directory a denial of service. §9.1 then names it.
25
+ # writer of a served directory a denial of service. §11 condition 1 then names it.
26
26
  class Reader
27
27
  def self.read(dir)
28
28
  new(dir).read
@@ -72,7 +72,7 @@ module OKF
72
72
  # share — and in the worst way: a backtrace under an exit code that
73
73
  # claims non-conformance, or a served bundle taken down by one planted
74
74
  # symlink. So it joins the same bucket a bad frontmatter block does,
75
- # and §9.1 reports it naming the file and the reason.
75
+ # and §11 condition 1 reports it naming the file and the reason.
76
76
  #
77
77
  # Its content is "" rather than nil: unknown, but every analyzer reads
78
78
  # it as text, and empty is the honest shape of a file we never read —
@@ -87,6 +87,12 @@ module OKF
87
87
 
88
88
  private
89
89
 
90
+ # Dir.glob's default (no FNM_DOTMATCH) excludes hidden files and every
91
+ # path under a hidden directory — kept deliberately, as the Unix
92
+ # convention it is: a bundle read from a project root must not pull in
93
+ # an installed skill under .claude/ or templates under .github/ as
94
+ # concepts. §3's taxonomy is read as covering the visible tree; the
95
+ # exclusion is documented in authoring.md and pinned by a reader test.
90
96
  def markdown_paths
91
97
  return [] unless Dir.exist?(@root)
92
98
 
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module OKF
6
+ class Bundle
7
+ # The §6.3 inventory, pure: every file under `references/` — handed in by
8
+ # the shell as +files+, because the reader models concepts and this model
9
+ # performs no disk access — with which concepts cite each one through the
10
+ # §6.2 path-valued fields (resource, sources[].resource, computation,
11
+ # executor.resource, attester.resource), plus the pointers into
12
+ # `references/` that resolve to nothing.
13
+ #
14
+ # The bare-path trap is what the dangling list exists to surface: §6.2
15
+ # resolves a bare path relative to the concept, so `references/x` works
16
+ # from the bundle root and silently misses one directory down. When the
17
+ # missed spelling would have hit with a leading slash, the entry says so.
18
+ #
19
+ # Scope is the folder, by design. §6.3 is "a naming convention, not a
20
+ # requirement", and a computation stored beside its concept is legal —
21
+ # and deliberately not this inventory's to list.
22
+ class References
23
+ def self.build(bundle, files:)
24
+ new(bundle, files: files)
25
+ end
26
+
27
+ # Files under references/, sorted by path, each
28
+ # { path:, dir:, kind:, referenced_by: [ { id:, field: } ] } — kind is
29
+ # "concept" when the file is a parsed concept (§6.3 allows both), "file"
30
+ # otherwise. referenced_by is ordered by citing concept id.
31
+ attr_reader :entries
32
+
33
+ # Pointers into references/ that resolve to no file, ordered by concept
34
+ # id then field position, each { id:, field:, raw:, resolved:, hint: } —
35
+ # hint names the leading-slash fix when the bare spelling exists at the
36
+ # root, nil otherwise.
37
+ attr_reader :dangling
38
+
39
+ def initialize(bundle, files:)
40
+ @bundle = bundle
41
+ @files = files.sort
42
+ @existing = @files.to_set
43
+ build!
44
+ end
45
+
46
+ private
47
+
48
+ def build!
49
+ concept_paths = @bundle.concepts.to_set(&:path)
50
+ cited = {}
51
+ @dangling = []
52
+
53
+ @bundle.concepts.sort_by(&:id).each do |concept|
54
+ path_fields(concept).each do |field, raw|
55
+ target = Markdown::Links.resolve_path(raw, from: concept.path, bundle: @bundle.root)
56
+ next if target.nil? # external, empty, or a directory — not a file pointer
57
+
58
+ if @existing.include?(target)
59
+ (cited[target] ||= []) << { id: concept.id, field: field }
60
+ elsif under_references?(target) || bare_references?(raw)
61
+ @dangling << dangling_row(concept, field, raw, target)
62
+ end
63
+ end
64
+ end
65
+
66
+ @entries = @files.map do |path|
67
+ { path: path, dir: File.dirname(path),
68
+ kind: concept_paths.include?(path) ? "concept" : "file",
69
+ referenced_by: cited[path] || [] }
70
+ end
71
+ end
72
+
73
+ # Every §6.2 path-valued field this concept carries, as
74
+ # [ field-label, raw ] pairs. sources includes §13.1's lifted Citations —
75
+ # when the fallback is a concept's provenance, its pointers are too.
76
+ def path_fields(concept)
77
+ fields = [
78
+ [ "resource", concept.frontmatter["resource"] ],
79
+ [ "computation", concept.computation ],
80
+ [ "executor.resource", concept.executor && concept.executor["resource"] ],
81
+ [ "attester.resource", concept.attester && concept.attester["resource"] ]
82
+ ]
83
+ concept.sources.each_with_index do |source, index|
84
+ fields << [ "sources[#{index}].resource", source["resource"] ]
85
+ end
86
+ fields.reject { |_field, raw| OKF.blank?(raw) }
87
+ end
88
+
89
+ def under_references?(path)
90
+ path.to_s.start_with?("references/")
91
+ end
92
+
93
+ # The bare spelling of the trap: a raw that *names* references/ without
94
+ # the leading slash, which §6.2 resolves relative to the concept. From a
95
+ # subdirectory the resolved path leaves references/, so the target check
96
+ # above no longer sees it — the raw is what still does.
97
+ def bare_references?(raw)
98
+ raw.to_s.split("#", 2).first.to_s.start_with?("references/")
99
+ end
100
+
101
+ def dangling_row(concept, field, raw, target)
102
+ bare = raw.to_s.split("#", 2).first.to_s
103
+ hint = nil
104
+ if !bare.start_with?("/") && bare != target && @existing.include?(bare)
105
+ hint = "/#{bare} exists — missing leading slash?"
106
+ end
107
+ { id: concept.id, field: field, raw: raw.to_s, resolved: target, hint: hint }
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ # The one catalog-row predicate every narrowing surface shares — the CLI's
6
+ # filter flags, the MCP shell's catalog filters, and its search narrowing
7
+ # select rows by these rules and no other copy of them. The seam earned the
8
+ # module: the predicate was hand-spelled in three shells with only the leaf
9
+ # folds shared, and the copies diverged three recorded times (a raw status
10
+ # compare answering the opposite of `--status stable`, "" meaning no-filter
11
+ # on one tool and match-nothing on the next, the root's spellings).
12
+ #
13
+ # Callers hand it *normalized* wants — nil means "no filter", and `dir`
14
+ # arrives as a folded base path ("." for the bundle root) — because
15
+ # argument spelling is each shell's own: the CLI folds the `root` alias
16
+ # against the bundle's real directories, the MCP shell maps ""/"/" onto
17
+ # ".", and neither belongs here. Pure, like everything beside it.
18
+ module RowFilter
19
+ module_function
20
+
21
+ def matches?(row, type: nil, dir: nil, tag: nil, status: nil, trust: nil)
22
+ (type.nil? || fold(row[:type]) == fold(type)) &&
23
+ (dir.nil? || under_dir?(row[:dir], dir)) &&
24
+ (tag.nil? || Array(row[:tags]).any? { |value| fold(value) == fold(tag) }) &&
25
+ (status.nil? || Concept.effective_status(row[:status]) == Concept.fold_status(status)) &&
26
+ (trust.nil? || fold(row[:trust]) == Concept.fold_tier(trust))
27
+ end
28
+
29
+ # The one rule `--dir` is built on: a dir names itself and everything
30
+ # beneath it; "." is a prefix of nothing, so the root selects only what
31
+ # lives directly in it.
32
+ def under_dir?(entry_dir, base)
33
+ entry = fold(entry_dir)
34
+ path = fold(base)
35
+ entry == path || entry.start_with?("#{path}/")
36
+ end
37
+
38
+ def fold(value)
39
+ value.to_s.downcase
40
+ end
41
+
42
+ # The row-shaped spelling of Concept.shows_trust? — "is this row's tier one
43
+ # a surface should claim?". It lives beside #matches? because narrowing is
44
+ # one of its three consumers: the trust facet's gate, its counts and its
45
+ # narrowing all read this, so the facet describes exactly the rows wearing a
46
+ # tier. `generated` is the catalog's raw declared-key boolean, which is what
47
+ # tells a concept that opted into §5 from one that said nothing at all.
48
+ def shows_trust?(row)
49
+ Concept.shows_trust?(row[:trust], row[:generated])
50
+ end
51
+ end
52
+ end
53
+ end
@@ -99,14 +99,29 @@ module OKF
99
99
  "tags" => 3,
100
100
  "type" => 2,
101
101
  "description" => 2,
102
+ # A regression fix rather than a feature: in v0.1 a citation's text
103
+ # lived in the body and was searchable at weight 1. After a bundle
104
+ # migrates it lives in frontmatter, so without this a migrated bundle
105
+ # silently loses the hit entirely. What this restores is *recall* at
106
+ # the same weight — not an identical total. A v0.1 concept keeps
107
+ # matching `body` too, because the text really is body prose there, so
108
+ # it scores one higher than its migrated twin; that divergence is
109
+ # deliberate and pinned by cli_twins_test.rb ("migrating moves a
110
+ # source-only hit's snippet from body text to source text"). Suppressing
111
+ # it would mean lying about `--in body` on a bundle whose body does
112
+ # contain the words.
113
+ "sources" => 1,
102
114
  "body" => 1
103
115
  }.freeze
104
116
 
105
117
  FIELDS = WEIGHTS.keys.freeze
106
118
 
107
119
  # Fields whose match is only meaningful with surrounding context. The other
108
- # fields already appear whole on the result row.
109
- SNIPPET_FIELDS = %w[description body].freeze
120
+ # fields already appear whole on the result row. `sources` is here because
121
+ # indexed-but-un-snippeted would degrade a consumer's evidence line to a
122
+ # bare id list: after migration the snippet moves from body text to source
123
+ # text, it does not vanish.
124
+ SNIPPET_FIELDS = %w[description body sources].freeze
110
125
 
111
126
  # Characters of context kept on each side of the first matched term.
112
127
  SNIPPET_RADIUS = 44
@@ -195,6 +210,9 @@ module OKF
195
210
  "type" => concept.type.to_s,
196
211
  "description" => concept.description.to_s,
197
212
  "tags" => Array(concept.tags).join(" "),
213
+ # Titles and resources together: a source is findable by what it is
214
+ # called and by where it lives, which is how the body list read.
215
+ "sources" => concept.sources.flat_map { |source| [ source["title"], source["resource"] ] }.compact.join(" "),
198
216
  "body" => concept.body
199
217
  }
200
218
  end
@@ -3,7 +3,7 @@
3
3
  module OKF
4
4
  class Bundle
5
5
  class Validator
6
- # The outcome of a §9 conformance check (see OKF::Bundle::Validator): hard `errors`,
6
+ # The outcome of a §11 conformance check (see OKF::Bundle::Validator): hard `errors`,
7
7
  # soft `warnings`, and file `counts`. Conformant iff there are no errors.
8
8
  class Result
9
9
  attr_reader :errors, :warnings, :counts
@@ -22,8 +22,11 @@ module OKF
22
22
  errors << { path: path, message: message }
23
23
  end
24
24
 
25
- def add_warning(path, message)
26
- warnings << { path: path, message: message }
25
+ # `check:` (a stable id) and `source:` (:spec | :convention) make a
26
+ # warning machine-readable; errors keep their exact two-key shape —
27
+ # consumers already read it, so new keys land on warnings only.
28
+ def add_warning(path, message, check: nil, source: nil)
29
+ warnings << { path: path, message: message, check: check, source: source }
27
30
  end
28
31
 
29
32
  def count(kind)
@@ -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