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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +94 -466
  4. data/lib/okf/bundle/folder.rb +48 -3
  5. data/lib/okf/bundle/graph.rb +12 -3
  6. data/lib/okf/bundle/linter.rb +470 -47
  7. data/lib/okf/bundle/reader.rb +47 -18
  8. data/lib/okf/bundle/references.rb +111 -0
  9. data/lib/okf/bundle/row_filter.rb +53 -0
  10. data/lib/okf/bundle/search.rb +20 -2
  11. data/lib/okf/bundle/validator/result.rb +6 -3
  12. data/lib/okf/bundle/validator.rb +267 -26
  13. data/lib/okf/bundle/writer.rb +1 -1
  14. data/lib/okf/bundle.rb +124 -8
  15. data/lib/okf/cli/catalog.rb +2 -2
  16. data/lib/okf/cli/command.rb +93 -19
  17. data/lib/okf/cli/dirs.rb +1 -1
  18. data/lib/okf/cli/files.rb +2 -2
  19. data/lib/okf/cli/index.rb +3 -3
  20. data/lib/okf/cli/lint.rb +70 -12
  21. data/lib/okf/cli/references.rb +97 -0
  22. data/lib/okf/cli/search.rb +23 -8
  23. data/lib/okf/cli/stats.rb +3 -39
  24. data/lib/okf/cli/tags.rb +6 -43
  25. data/lib/okf/cli/types.rb +1 -1
  26. data/lib/okf/cli/validate.rb +3 -3
  27. data/lib/okf/cli.rb +4 -1
  28. data/lib/okf/concept/file.rb +17 -2
  29. data/lib/okf/concept.rb +362 -10
  30. data/lib/okf/markdown/citations.rb +41 -4
  31. data/lib/okf/markdown/frontmatter.rb +1 -1
  32. data/lib/okf/markdown/links.rb +67 -7
  33. data/lib/okf/path.rb +17 -3
  34. data/lib/okf/render/graph/template.html.erb +173 -41
  35. data/lib/okf/render/graph.rb +11 -3
  36. data/lib/okf/safe_read.rb +50 -0
  37. data/lib/okf/server/app.rb +47 -15
  38. data/lib/okf/server/hub.rb +1 -1
  39. data/lib/okf/skill/SKILL.md +14 -12
  40. data/lib/okf/skill/playbooks/curate.md +8 -3
  41. data/lib/okf/skill/playbooks/doctor.md +3 -1
  42. data/lib/okf/skill/playbooks/maintain.md +7 -6
  43. data/lib/okf/skill/playbooks/menu.md +5 -4
  44. data/lib/okf/skill/playbooks/migrate.md +31 -8
  45. data/lib/okf/skill/playbooks/produce.md +16 -9
  46. data/lib/okf/skill/playbooks/search.md +2 -2
  47. data/lib/okf/skill/reference/SPEC.md +739 -187
  48. data/lib/okf/skill/reference/authoring.md +154 -35
  49. data/lib/okf/skill/reference/cli.md +160 -44
  50. data/lib/okf/skill/templates/attested-computation.md +41 -0
  51. data/lib/okf/skill/templates/concept.md +13 -6
  52. data/lib/okf/skill/templates/root-index.md +1 -1
  53. data/lib/okf/version.rb +1 -1
  54. data/lib/okf.rb +23 -2
  55. metadata +7 -3
  56. data/CODE_OF_CONDUCT.md +0 -10
@@ -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 §9.1 can report it) rather than dropped
14
- # or raised. That tolerance is the whole §9 best-effort promise: one bad file
15
- # never breaks the rest, and this is the read every verb shares. Every read
16
- # goes through Path.join_under! so a symlinked or crafted path cannot escape
17
- # the bundle root that guard still raises, because a path leaving the root
18
- # is not a bad file, it is a bundle lying about its shape.
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
- markdown_paths.each do |path|
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
- content = File.read(Path.join_under!(@root, path), encoding: "UTF-8")
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 that cannot be opened is one unusable file, not a broken
48
- # bundle. Letting the errno out of here breaks "one bad file never
49
- # breaks the rest" for every verb at once the read is the one path
50
- # they all share and it breaks it in the worst way: a backtrace,
51
- # under an exit code that claims the bundle is non-conformant. So it
52
- # joins the same bucket a bad frontmatter block does, and §9.1 reports
53
- # it naming the file and the errno.
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 saw
57
- # no links to resolve, no encoding to be invalid, nothing claimed.
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
@@ -99,14 +99,29 @@ module OKF
99
99
  "tags" => 3,
100
100
  "type" => 2,
101
101
  "description" => 2,
102
+ # A regression fix rather than a feature: in v0.1 a citation's text
103
+ # lived in the body and was searchable at weight 1. After a bundle
104
+ # migrates it lives in frontmatter, so without this a migrated bundle
105
+ # silently loses the hit entirely. What this restores is *recall* at
106
+ # the same weight — not an identical total. A v0.1 concept keeps
107
+ # matching `body` too, because the text really is body prose there, so
108
+ # it scores one higher than its migrated twin; that divergence is
109
+ # deliberate and pinned by cli_twins_test.rb ("migrating moves a
110
+ # source-only hit's snippet from body text to source text"). Suppressing
111
+ # it would mean lying about `--in body` on a bundle whose body does
112
+ # contain the words.
113
+ "sources" => 1,
102
114
  "body" => 1
103
115
  }.freeze
104
116
 
105
117
  FIELDS = WEIGHTS.keys.freeze
106
118
 
107
119
  # Fields whose match is only meaningful with surrounding context. The other
108
- # fields already appear whole on the result row.
109
- SNIPPET_FIELDS = %w[description body].freeze
120
+ # fields already appear whole on the result row. `sources` is here because
121
+ # indexed-but-un-snippeted would degrade a consumer's evidence line to a
122
+ # bare id list: after migration the snippet moves from body text to source
123
+ # text, it does not vanish.
124
+ SNIPPET_FIELDS = %w[description body sources].freeze
110
125
 
111
126
  # Characters of context kept on each side of the first matched term.
112
127
  SNIPPET_RADIUS = 44
@@ -195,6 +210,9 @@ module OKF
195
210
  "type" => concept.type.to_s,
196
211
  "description" => concept.description.to_s,
197
212
  "tags" => Array(concept.tags).join(" "),
213
+ # Titles and resources together: a source is findable by what it is
214
+ # called and by where it lives, which is how the body list read.
215
+ "sources" => concept.sources.flat_map { |source| [ source["title"], source["resource"] ] }.compact.join(" "),
198
216
  "body" => concept.body
199
217
  }
200
218
  end
@@ -3,7 +3,7 @@
3
3
  module OKF
4
4
  class Bundle
5
5
  class Validator
6
- # The outcome of a §9 conformance check (see OKF::Bundle::Validator): hard `errors`,
6
+ # The outcome of a §11 conformance check (see OKF::Bundle::Validator): hard `errors`,
7
7
  # soft `warnings`, and file `counts`. Conformant iff there are no errors.
8
8
  class Result
9
9
  attr_reader :errors, :warnings, :counts
@@ -22,8 +22,11 @@ module OKF
22
22
  errors << { path: path, message: message }
23
23
  end
24
24
 
25
- def add_warning(path, message)
26
- warnings << { path: path, message: message }
25
+ # `check:` (a stable id) and `source:` (:spec | :convention) make a
26
+ # warning machine-readable; errors keep their exact two-key shape —
27
+ # consumers already read it, so new keys land on warnings only.
28
+ def add_warning(path, message, check: nil, source: nil)
29
+ warnings << { path: path, message: message, check: check, source: source }
27
30
  end
28
31
 
29
32
  def count(kind)