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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +195 -0
- data/README.md +25 -6
- data/lib/okf/bundle/folder.rb +27 -1
- data/lib/okf/bundle/graph.rb +12 -3
- data/lib/okf/bundle/linter.rb +470 -47
- data/lib/okf/bundle/reader.rb +10 -4
- 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 +105 -4
- data/lib/okf/cli/catalog.rb +1 -1
- data/lib/okf/cli/command.rb +27 -8
- data/lib/okf/cli/files.rb +1 -1
- data/lib/okf/cli/index.rb +1 -1
- data/lib/okf/cli/lint.rb +70 -12
- data/lib/okf/cli/references.rb +97 -0
- data/lib/okf/cli/search.rb +2 -1
- 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.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/render/graph/template.html.erb +173 -41
- data/lib/okf/render/graph.rb +11 -3
- 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 +155 -42
- 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 +22 -2
- metadata +5 -1
data/lib/okf/bundle/reader.rb
CHANGED
|
@@ -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 §
|
|
14
|
-
# or raised. That tolerance is the whole §
|
|
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. §
|
|
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 §
|
|
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
|
data/lib/okf/bundle/search.rb
CHANGED
|
@@ -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
|
-
|
|
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 §
|
|
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
|
-
|
|
26
|
-
|
|
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)
|
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
|