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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +296 -0
- data/README.md +94 -466
- data/lib/okf/bundle/folder.rb +48 -3
- data/lib/okf/bundle/graph.rb +12 -3
- data/lib/okf/bundle/linter.rb +470 -47
- data/lib/okf/bundle/reader.rb +47 -18
- data/lib/okf/bundle/references.rb +111 -0
- data/lib/okf/bundle/row_filter.rb +53 -0
- data/lib/okf/bundle/search.rb +20 -2
- data/lib/okf/bundle/validator/result.rb +6 -3
- data/lib/okf/bundle/validator.rb +267 -26
- data/lib/okf/bundle/writer.rb +1 -1
- data/lib/okf/bundle.rb +124 -8
- data/lib/okf/cli/catalog.rb +2 -2
- data/lib/okf/cli/command.rb +93 -19
- data/lib/okf/cli/dirs.rb +1 -1
- data/lib/okf/cli/files.rb +2 -2
- data/lib/okf/cli/index.rb +3 -3
- data/lib/okf/cli/lint.rb +70 -12
- data/lib/okf/cli/references.rb +97 -0
- data/lib/okf/cli/search.rb +23 -8
- data/lib/okf/cli/stats.rb +3 -39
- data/lib/okf/cli/tags.rb +6 -43
- data/lib/okf/cli/types.rb +1 -1
- data/lib/okf/cli/validate.rb +3 -3
- data/lib/okf/cli.rb +4 -1
- data/lib/okf/concept/file.rb +17 -2
- data/lib/okf/concept.rb +362 -10
- data/lib/okf/markdown/citations.rb +41 -4
- data/lib/okf/markdown/frontmatter.rb +1 -1
- data/lib/okf/markdown/links.rb +67 -7
- data/lib/okf/path.rb +17 -3
- data/lib/okf/render/graph/template.html.erb +173 -41
- data/lib/okf/render/graph.rb +11 -3
- data/lib/okf/safe_read.rb +50 -0
- data/lib/okf/server/app.rb +47 -15
- data/lib/okf/server/hub.rb +1 -1
- data/lib/okf/skill/SKILL.md +14 -12
- data/lib/okf/skill/playbooks/curate.md +8 -3
- data/lib/okf/skill/playbooks/doctor.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -6
- data/lib/okf/skill/playbooks/menu.md +5 -4
- data/lib/okf/skill/playbooks/migrate.md +31 -8
- data/lib/okf/skill/playbooks/produce.md +16 -9
- data/lib/okf/skill/playbooks/search.md +2 -2
- data/lib/okf/skill/reference/SPEC.md +739 -187
- data/lib/okf/skill/reference/authoring.md +154 -35
- data/lib/okf/skill/reference/cli.md +160 -44
- data/lib/okf/skill/templates/attested-computation.md +41 -0
- data/lib/okf/skill/templates/concept.md +13 -6
- data/lib/okf/skill/templates/root-index.md +1 -1
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +23 -2
- metadata +7 -3
- data/CODE_OF_CONDUCT.md +0 -10
data/lib/okf/bundle/validator.rb
CHANGED
|
@@ -2,21 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
module OKF
|
|
4
4
|
class Bundle
|
|
5
|
-
# Checks an OKF::Bundle against the OKF
|
|
5
|
+
# Checks an OKF::Bundle against the OKF conformance rules (§11), which has
|
|
6
6
|
# three conditions — all hard errors:
|
|
7
7
|
#
|
|
8
|
-
# §
|
|
9
|
-
# §
|
|
10
|
-
# §
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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 §8/§9 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,
|
|
17
|
-
#
|
|
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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
348
|
+
string = value.to_s
|
|
121
349
|
begin
|
|
122
|
-
Time.iso8601(
|
|
350
|
+
Time.iso8601(string)
|
|
123
351
|
rescue ArgumentError
|
|
124
|
-
Date.iso8601(
|
|
352
|
+
Date.iso8601(string)
|
|
125
353
|
end
|
|
126
354
|
rescue ArgumentError
|
|
127
|
-
|
|
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
|
data/lib/okf/bundle/writer.rb
CHANGED
|
@@ -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 §
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
#
|
|
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 (§
|
|
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
|
|
204
|
-
# their ancestors up to the root, so the subdir tree stays
|
|
205
|
-
# an intermediate directory holds nothing directly.
|
|
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 }
|
data/lib/okf/cli/catalog.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 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)
|