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/reader.rb
CHANGED
|
@@ -10,12 +10,19 @@ 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 §
|
|
15
|
-
# never breaks the rest, and this is the read every verb shares.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
+
# never breaks the rest, and this is the read every verb shares.
|
|
16
|
+
#
|
|
17
|
+
# Containment is enforced twice, because the two ways out of the root are
|
|
18
|
+
# different. A crafted *path* (`..`, an absolute string) is caught lexically
|
|
19
|
+
# by Path.join_under!. A *symlink* whose name sits inside the root but whose
|
|
20
|
+
# target does not cannot be seen lexically — File.expand_path does not
|
|
21
|
+
# resolve links — so each file is also realpath-resolved and its real
|
|
22
|
+
# location checked against the real root before a byte is read. An escaping
|
|
23
|
+
# file joins the unparseable bucket rather than raising: a planted symlink is
|
|
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. §11 condition 1 then names it.
|
|
19
26
|
class Reader
|
|
20
27
|
def self.read(dir)
|
|
21
28
|
new(dir).read
|
|
@@ -32,9 +39,22 @@ module OKF
|
|
|
32
39
|
reserved = []
|
|
33
40
|
unparseable = []
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
paths = markdown_paths
|
|
43
|
+
# Resolved once for the whole loop, but never at the cost of the
|
|
44
|
+
# best-effort promise: if the root itself has become unreadable since
|
|
45
|
+
# the glob, this stays nil and each file's own SafeRead call raises
|
|
46
|
+
# inside the per-file rescue below — one bad bundle degrades to
|
|
47
|
+
# unparseable entries, it does not crash the read every verb shares.
|
|
48
|
+
real_root = begin
|
|
49
|
+
File.realpath(@root) unless paths.empty?
|
|
50
|
+
rescue SystemCallError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
paths.each do |path|
|
|
36
55
|
begin
|
|
37
|
-
|
|
56
|
+
absolute = Path.join_under!(@root, path)
|
|
57
|
+
content = SafeRead.read!(@root, absolute, real_root: real_root)
|
|
38
58
|
if Concept.reserved?(path)
|
|
39
59
|
reserved << Entry.new(path: path, content: content)
|
|
40
60
|
else
|
|
@@ -43,18 +63,21 @@ module OKF
|
|
|
43
63
|
end
|
|
44
64
|
rescue Markdown::Frontmatter::ParseError => e
|
|
45
65
|
unparseable << Entry.new(path: path, content: content, error: e.message)
|
|
46
|
-
rescue SystemCallError => e
|
|
47
|
-
# A file
|
|
48
|
-
# bundle
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
66
|
+
rescue Path::Error, SystemCallError => e
|
|
67
|
+
# A file we cannot safely read is one unusable file, not a broken
|
|
68
|
+
# bundle: an errno on open, or a path that leaves the root — lexically
|
|
69
|
+
# (`..`, an absolute string) or through a symlink whose target escapes
|
|
70
|
+
# it. Letting either out of here breaks "one bad file never breaks the
|
|
71
|
+
# rest" for every verb at once — the read is the one path they all
|
|
72
|
+
# share — and in the worst way: a backtrace under an exit code that
|
|
73
|
+
# claims non-conformance, or a served bundle taken down by one planted
|
|
74
|
+
# symlink. So it joins the same bucket a bad frontmatter block does,
|
|
75
|
+
# and §11 condition 1 reports it naming the file and the reason.
|
|
54
76
|
#
|
|
55
77
|
# Its content is "" rather than nil: unknown, but every analyzer reads
|
|
56
|
-
# it as text, and empty is the honest shape of a file we never
|
|
57
|
-
# no links to resolve,
|
|
78
|
+
# it as text, and empty is the honest shape of a file we never read —
|
|
79
|
+
# no links to resolve, nothing claimed, and for a symlink escape, none
|
|
80
|
+
# of the target's bytes.
|
|
58
81
|
unparseable << Entry.new(path: path, content: "", error: e.message)
|
|
59
82
|
end
|
|
60
83
|
end
|
|
@@ -64,6 +87,12 @@ module OKF
|
|
|
64
87
|
|
|
65
88
|
private
|
|
66
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.
|
|
67
96
|
def markdown_paths
|
|
68
97
|
return [] unless Dir.exist?(@root)
|
|
69
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)
|