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.
- checksums.yaml +7 -0
- data/.okf/capabilities/agent-skill.md +46 -0
- data/.okf/capabilities/graph-server.md +60 -0
- data/.okf/capabilities/index.md +20 -0
- data/.okf/capabilities/library-api.md +67 -0
- data/.okf/capabilities/linter.md +49 -0
- data/.okf/capabilities/read-views.md +84 -0
- data/.okf/capabilities/validator.md +40 -0
- data/.okf/cli.md +52 -0
- data/.okf/design/core-shell-split.md +58 -0
- data/.okf/design/index.md +10 -0
- data/.okf/design/ruby-floor.md +45 -0
- data/.okf/design/runtime-dependencies.md +44 -0
- data/.okf/design/server-trust-boundary.md +35 -0
- data/.okf/format/citations.md +33 -0
- data/.okf/format/cross-links.md +52 -0
- data/.okf/format/frontmatter.md +38 -0
- data/.okf/format/index.md +9 -0
- data/.okf/format/okf-format.md +43 -0
- data/.okf/index.md +18 -0
- data/.okf/log.md +9 -0
- data/.okf/model/bundle.md +38 -0
- data/.okf/model/concept.md +44 -0
- data/.okf/model/graph.md +44 -0
- data/.okf/model/index.md +8 -0
- data/.okf/overview.md +66 -0
- data/CHANGELOG.md +54 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +276 -0
- data/exe/okf +6 -0
- data/lib/okf/bundle/folder.rb +94 -0
- data/lib/okf/bundle/graph.rb +118 -0
- data/lib/okf/bundle/linter/report.rb +56 -0
- data/lib/okf/bundle/linter.rb +416 -0
- data/lib/okf/bundle/reader.rb +60 -0
- data/lib/okf/bundle/validator/result.rb +35 -0
- data/lib/okf/bundle/validator.rb +131 -0
- data/lib/okf/bundle/writer.rb +137 -0
- data/lib/okf/bundle.rb +216 -0
- data/lib/okf/cli.rb +910 -0
- data/lib/okf/concept/file.rb +63 -0
- data/lib/okf/concept.rb +101 -0
- data/lib/okf/markdown/citations.rb +49 -0
- data/lib/okf/markdown/frontmatter.rb +55 -0
- data/lib/okf/markdown/links.rb +98 -0
- data/lib/okf/path.rb +34 -0
- data/lib/okf/server/app.rb +120 -0
- data/lib/okf/server/graph.rb +112 -0
- data/lib/okf/server/runner.rb +78 -0
- data/lib/okf/server/templates/graph.html.erb +803 -0
- data/lib/okf/skill/SKILL.md +133 -0
- data/lib/okf/skill/reference/APACHE-2.0.txt +202 -0
- data/lib/okf/skill/reference/SPEC.md +460 -0
- data/lib/okf/skill/reference/authoring.md +218 -0
- data/lib/okf/skill/reference/cli.md +196 -0
- data/lib/okf/skill/templates/concept.md +24 -0
- data/lib/okf/skill/templates/index.md +8 -0
- data/lib/okf/skill/templates/log.md +6 -0
- data/lib/okf/skill/templates/root-index.md +12 -0
- data/lib/okf/skill.rb +82 -0
- data/lib/okf/version.rb +5 -0
- data/lib/okf.rb +55 -0
- 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
|