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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/capabilities/agent-skill.md +46 -0
  3. data/.okf/capabilities/graph-server.md +60 -0
  4. data/.okf/capabilities/index.md +20 -0
  5. data/.okf/capabilities/library-api.md +67 -0
  6. data/.okf/capabilities/linter.md +49 -0
  7. data/.okf/capabilities/read-views.md +84 -0
  8. data/.okf/capabilities/validator.md +40 -0
  9. data/.okf/cli.md +52 -0
  10. data/.okf/design/core-shell-split.md +58 -0
  11. data/.okf/design/index.md +10 -0
  12. data/.okf/design/ruby-floor.md +45 -0
  13. data/.okf/design/runtime-dependencies.md +44 -0
  14. data/.okf/design/server-trust-boundary.md +35 -0
  15. data/.okf/format/citations.md +33 -0
  16. data/.okf/format/cross-links.md +52 -0
  17. data/.okf/format/frontmatter.md +38 -0
  18. data/.okf/format/index.md +9 -0
  19. data/.okf/format/okf-format.md +43 -0
  20. data/.okf/index.md +18 -0
  21. data/.okf/log.md +9 -0
  22. data/.okf/model/bundle.md +38 -0
  23. data/.okf/model/concept.md +44 -0
  24. data/.okf/model/graph.md +44 -0
  25. data/.okf/model/index.md +8 -0
  26. data/.okf/overview.md +66 -0
  27. data/CHANGELOG.md +54 -0
  28. data/CODE_OF_CONDUCT.md +10 -0
  29. data/LICENSE.txt +201 -0
  30. data/NOTICE +10 -0
  31. data/README.md +276 -0
  32. data/exe/okf +6 -0
  33. data/lib/okf/bundle/folder.rb +94 -0
  34. data/lib/okf/bundle/graph.rb +118 -0
  35. data/lib/okf/bundle/linter/report.rb +56 -0
  36. data/lib/okf/bundle/linter.rb +416 -0
  37. data/lib/okf/bundle/reader.rb +60 -0
  38. data/lib/okf/bundle/validator/result.rb +35 -0
  39. data/lib/okf/bundle/validator.rb +131 -0
  40. data/lib/okf/bundle/writer.rb +137 -0
  41. data/lib/okf/bundle.rb +216 -0
  42. data/lib/okf/cli.rb +910 -0
  43. data/lib/okf/concept/file.rb +63 -0
  44. data/lib/okf/concept.rb +101 -0
  45. data/lib/okf/markdown/citations.rb +49 -0
  46. data/lib/okf/markdown/frontmatter.rb +55 -0
  47. data/lib/okf/markdown/links.rb +98 -0
  48. data/lib/okf/path.rb +34 -0
  49. data/lib/okf/server/app.rb +120 -0
  50. data/lib/okf/server/graph.rb +112 -0
  51. data/lib/okf/server/runner.rb +78 -0
  52. data/lib/okf/server/templates/graph.html.erb +803 -0
  53. data/lib/okf/skill/SKILL.md +133 -0
  54. data/lib/okf/skill/reference/APACHE-2.0.txt +202 -0
  55. data/lib/okf/skill/reference/SPEC.md +460 -0
  56. data/lib/okf/skill/reference/authoring.md +218 -0
  57. data/lib/okf/skill/reference/cli.md +196 -0
  58. data/lib/okf/skill/templates/concept.md +24 -0
  59. data/lib/okf/skill/templates/index.md +8 -0
  60. data/lib/okf/skill/templates/log.md +6 -0
  61. data/lib/okf/skill/templates/root-index.md +12 -0
  62. data/lib/okf/skill.rb +82 -0
  63. data/lib/okf/version.rb +5 -0
  64. data/lib/okf.rb +55 -0
  65. metadata +142 -0
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ # Atomically writes a bundle to disk: renders concepts to a temp directory,
6
+ # validates it for §9 conformance (so a malformed bundle is never published),
7
+ # then promotes it into place under a lock. The disk-writing counterpart to
8
+ # Reader.
9
+ class Writer
10
+ class AlreadyExistsError < Error
11
+ end
12
+
13
+ def self.call(**kwargs)
14
+ new(**kwargs).call
15
+ end
16
+
17
+ def initialize(bundle_path:, concepts:, index_files: {}, log_files: {}, overwrite: false)
18
+ @bundle_path = File.expand_path(bundle_path.to_s)
19
+ @concepts = concepts
20
+ @index_files = index_files
21
+ @log_files = log_files
22
+ @overwrite = overwrite
23
+ end
24
+
25
+ def call
26
+ FileUtils.mkdir_p(parent_path)
27
+ with_lock do
28
+ raise AlreadyExistsError, "bundle already exists: #{@bundle_path}" if target_exists? && !@overwrite
29
+
30
+ temp_path = build_temp_path
31
+ FileUtils.mkdir_p(temp_path)
32
+ begin
33
+ write_bundle(temp_path)
34
+ result = Validator.call(Reader.read(temp_path))
35
+ raise ValidationErrorFromResult.new(result), "materialized bundle is invalid" unless result.valid?
36
+
37
+ promote(temp_path)
38
+ ensure
39
+ FileUtils.rm_rf(temp_path)
40
+ end
41
+ end
42
+
43
+ true
44
+ end
45
+
46
+ private
47
+
48
+ def with_lock
49
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
50
+ begin
51
+ file.flock(File::LOCK_EX)
52
+ yield
53
+ ensure
54
+ file.flock(File::LOCK_UN)
55
+ end
56
+ end
57
+ end
58
+
59
+ def write_bundle(root)
60
+ @concepts.each { |concept| write_concept(root, concept) }
61
+ @index_files.each { |path, content| write_plain_file(root, path, content) }
62
+ @log_files.each { |path, content| write_plain_file(root, path, content) }
63
+ end
64
+
65
+ # Accepts an OKF::Concept (the currency Bundle::Reader produces) or a plain
66
+ # {path:, frontmatter:, body:} hash, so a bundle can be round-tripped through
67
+ # Reader -> Writer without repacking into hashes.
68
+ def write_concept(root, concept)
69
+ path = safe_markdown_path!(concept_attr(concept, :path))
70
+ frontmatter = concept_attr(concept, :frontmatter)
71
+ body = concept_attr(concept, :body)
72
+ write_plain_file(root, path, Markdown::Frontmatter.dump(frontmatter, body))
73
+ end
74
+
75
+ def concept_attr(concept, name)
76
+ concept.is_a?(Concept) ? concept.public_send(name) : concept.fetch(name)
77
+ end
78
+
79
+ def write_plain_file(root, path, content)
80
+ safe_path = safe_markdown_path!(path)
81
+ target = Path.join_under!(root, safe_path)
82
+ FileUtils.mkdir_p(File.dirname(target))
83
+ File.write(target, content.to_s, encoding: "UTF-8")
84
+ end
85
+
86
+ def safe_markdown_path!(path)
87
+ safe_path = Path.normalize_relative!(path)
88
+
89
+ raise Path::Error, "path must end in .md" unless safe_path.end_with?(".md")
90
+
91
+ safe_path
92
+ end
93
+
94
+ def promote(temp_path)
95
+ if @overwrite && File.exist?(@bundle_path)
96
+ backup_path = "#{@bundle_path}.replacing-#{SecureRandom.hex(8)}"
97
+ File.rename(@bundle_path, backup_path)
98
+ begin
99
+ File.rename(temp_path, @bundle_path)
100
+ FileUtils.rm_rf(backup_path)
101
+ rescue StandardError
102
+ File.rename(backup_path, @bundle_path) unless File.exist?(@bundle_path)
103
+ raise
104
+ end
105
+ else
106
+ Dir.rmdir(@bundle_path) if Dir.exist?(@bundle_path) && Dir.entries(@bundle_path).reject { |entry| [ ".", ".." ].include?(entry) }.empty?
107
+ File.rename(temp_path, @bundle_path)
108
+ end
109
+ end
110
+
111
+ def target_exists?
112
+ File.exist?(@bundle_path) && (!Dir.exist?(@bundle_path) || Dir.entries(@bundle_path).reject { |entry| [ ".", ".." ].include?(entry) }.any?)
113
+ end
114
+
115
+ def parent_path
116
+ File.dirname(@bundle_path)
117
+ end
118
+
119
+ def lock_path
120
+ File.join(parent_path, ".#{File.basename(@bundle_path)}.okf.lock")
121
+ end
122
+
123
+ def build_temp_path
124
+ File.join(parent_path, ".#{File.basename(@bundle_path)}.tmp-#{Process.pid}-#{Thread.current.object_id}-#{SecureRandom.hex(8)}")
125
+ end
126
+
127
+ class ValidationErrorFromResult < Error
128
+ attr_reader :result
129
+
130
+ def initialize(result)
131
+ @result = result
132
+ super()
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
data/lib/okf/bundle.rb ADDED
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ # A knowledge bundle held in memory (spec §2), Concept-first: parsed concepts
5
+ # plus the raw text of reserved index/log files and any files whose frontmatter
6
+ # failed to parse. Pure — it performs no disk access.
7
+ #
8
+ # Build one straight from data (the Rails path — no markdown round-trip):
9
+ #
10
+ # OKF::Bundle.new(concepts: [OKF::Concept.new(...)])
11
+ #
12
+ # or let OKF::Bundle::Reader parse a directory into one. OKF::Bundle::Validator,
13
+ # OKF::Bundle::Linter, and OKF::Bundle::Graph consume it; the convenience methods below forward
14
+ # to them, so `bundle.validate` / `bundle.lint` / `bundle.graph` work on any
15
+ # in-memory bundle.
16
+ #
17
+ # `root` is the bundle path kept purely as data — it seeds bundle-relative link
18
+ # resolution (§5.1) and report messages, never I/O. A bundle built in memory
19
+ # without a real directory gets VIRTUAL_ROOT so relative-link math still works.
20
+ class Bundle
21
+ # Raw text of one markdown file. `error` is set only for unparseable entries
22
+ # (the ParseError message), nil for reserved files.
23
+ class Entry
24
+ attr_reader :path, :content, :error
25
+
26
+ def initialize(path:, content:, error: nil)
27
+ @path = path
28
+ @content = content
29
+ @error = error
30
+ end
31
+ end
32
+
33
+ # Stand-in absolute root for bundles built in memory (no directory). Only ever
34
+ # the base for pure path arithmetic in Links.resolve; the paths it yields are
35
+ # bundle-relative regardless of its value.
36
+ VIRTUAL_ROOT = "/okf"
37
+
38
+ attr_reader :concepts, :reserved, :unparseable, :root
39
+
40
+ def initialize(concepts: [], reserved: [], unparseable: [], root: nil)
41
+ @concepts = concepts
42
+ @reserved = reserved
43
+ @unparseable = unparseable
44
+ @root = root || VIRTUAL_ROOT
45
+ end
46
+
47
+ # Bundle-relative paths of every markdown file — concepts, reserved, and
48
+ # unparseable — sorted.
49
+ def paths
50
+ (@concepts.map(&:path) + @reserved.map(&:path) + @unparseable.map(&:path)).sort
51
+ end
52
+
53
+ def index_files
54
+ reserved_paths("index.md")
55
+ end
56
+
57
+ def log_files
58
+ reserved_paths("log.md")
59
+ end
60
+
61
+ # Raw content of a reserved file (index.md/log.md) by bundle-relative path, or
62
+ # "" when absent. Reserved structure is validated as text, and index links are
63
+ # extracted from it, so its raw form is retained.
64
+ def reserved_content(path)
65
+ entry = @reserved.find { |candidate| candidate.path == path }
66
+ entry ? entry.content.to_s : ""
67
+ end
68
+
69
+ # ── id ↔ path (the single source of "which concept an id names") ──
70
+ # A concept's id may be a frontmatter `id`, so it is not derivable from the path
71
+ # alone. These maps let the shell resolve an id back to its file (the server's
72
+ # per-node lookup) and the graph map a resolved link path to the target's id.
73
+
74
+ # The Concept with this id, or nil. Last wins on a (rare) duplicate id.
75
+ def concept_by_id(id)
76
+ concepts_by_id[id]
77
+ end
78
+
79
+ # { id => bundle-relative path }.
80
+ def paths_by_id
81
+ @paths_by_id ||= @concepts.map { |concept| [ concept.id, concept.path ] }.to_h
82
+ end
83
+
84
+ # ── analysis (pure; forwards to the core analyzers) ──
85
+
86
+ def validate
87
+ Validator.call(self)
88
+ end
89
+
90
+ def lint(**options)
91
+ Linter.call(self, **options)
92
+ end
93
+
94
+ def graph(minimal: false, body: true)
95
+ Graph.build(self, minimal: minimal, body: body)
96
+ end
97
+
98
+ # Rich per-concept metadata the catalog / files / stats consumers want but the
99
+ # lean graph omits — the descriptive frontmatter fields plus in/out link degree
100
+ # taken from the graph edges. Pure: derived from the concepts and their links,
101
+ # sorted by id. Shared by the CLI views and the server's /catalog endpoint.
102
+ def catalog
103
+ out_degree = Hash.new(0)
104
+ in_degree = Hash.new(0)
105
+ graph(minimal: true).edges.each do |edge|
106
+ out_degree[edge[:source]] += 1
107
+ in_degree[edge[:target]] += 1
108
+ end
109
+
110
+ concepts.map do |concept|
111
+ id = concept.id
112
+ {
113
+ id: id,
114
+ title: (concept.title || id).to_s,
115
+ type: concept.type.to_s,
116
+ description: concept.description.to_s,
117
+ tags: Array(concept.tags).map(&:to_s),
118
+ timestamp: concept.timestamp&.to_s,
119
+ status: concept.frontmatter["status"]&.to_s,
120
+ backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
121
+ dir: File.dirname("#{id}.md"),
122
+ area: id.include?("/") ? id.split("/").first : "(root)",
123
+ links_out: out_degree[id],
124
+ links_in: in_degree[id]
125
+ }
126
+ end.sort_by { |entry| entry[:id] }
127
+ end
128
+
129
+ # The progressive-disclosure map (spec §6): one entry per directory that holds
130
+ # concepts or carries an index.md, sorted with the root (".") first. Each entry
131
+ # gives the authored index body (frontmatter stripped) when an index.md is
132
+ # present, a type/tag rollup over the concepts that live *directly* in the
133
+ # directory, its immediate child directories, and the concept listing an
134
+ # index.md there would enumerate. A directory with concepts but no index.md has
135
+ # `present: false` and still carries the listing, so a consumer can synthesize
136
+ # the map on the fly (§6 permits exactly that). Grouped by the concept's file
137
+ # path — index files are physical directory listings, so a custom frontmatter
138
+ # `id` must not move a concept out of the directory it lives in. Pure: derived
139
+ # from the concepts and the reserved index text, no disk. Shared by the `okf
140
+ # index` view and the server's /index endpoint.
141
+ def directory_index
142
+ by_dir = concepts.group_by { |concept| File.dirname(concept.path) }
143
+ dirs = directory_set(by_dir.keys)
144
+
145
+ dirs.map do |dir|
146
+ here = (by_dir[dir] || []).sort_by(&:id)
147
+ index_path = dir == "." ? "index.md" : "#{dir}/index.md"
148
+ present = index_files.include?(index_path)
149
+ {
150
+ dir: dir,
151
+ index_path: index_path,
152
+ present: present,
153
+ synthesized: !present,
154
+ body: present ? strip_frontmatter(reserved_content(index_path)) : nil,
155
+ count: here.size,
156
+ types: tally(here.map { |concept| concept.type.to_s }),
157
+ tags: tally(here.flat_map { |concept| Array(concept.tags).map(&:to_s) }),
158
+ subdirs: dirs.select { |other| other != dir && File.dirname(other) == dir },
159
+ listing: here.map do |concept|
160
+ {
161
+ id: concept.id,
162
+ title: (concept.title || concept.id).to_s,
163
+ description: concept.description.to_s,
164
+ type: concept.type.to_s,
165
+ tags: Array(concept.tags).map(&:to_s)
166
+ }
167
+ end
168
+ }
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ # Every directory to show: those holding concepts or an index.md, plus each of
175
+ # their ancestors up to the root, so the subdir tree stays connected even when
176
+ # an intermediate directory holds nothing directly. Sorted with "." first.
177
+ def directory_set(concept_dirs)
178
+ seed = concept_dirs + index_files.map { |path| File.dirname(path) }
179
+ dirs = {}
180
+ seed.each do |dir|
181
+ current = dir
182
+ loop do
183
+ dirs[current] = true
184
+ break if current == "."
185
+
186
+ current = File.dirname(current)
187
+ end
188
+ end
189
+ dirs.keys.sort_by { |dir| dir == "." ? "" : dir }
190
+ end
191
+
192
+ # { value => count }, ordered by count descending then value.
193
+ def tally(values)
194
+ counts = values.each_with_object(Hash.new(0)) { |value, acc| acc[value] += 1 }
195
+ counts.sort_by { |value, count| [ -count, value ] }.to_h
196
+ end
197
+
198
+ # The index body with a leading frontmatter block removed (the bundle-root
199
+ # index carries okf_version; nested indexes carry none). Returns the content
200
+ # unchanged when it has no parseable frontmatter — the common case for a nested
201
+ # index — rather than raising.
202
+ def strip_frontmatter(content)
203
+ Markdown::Frontmatter.parse(content).last
204
+ rescue Markdown::Frontmatter::ParseError
205
+ content
206
+ end
207
+
208
+ def concepts_by_id
209
+ @concepts_by_id ||= @concepts.map { |concept| [ concept.id, concept ] }.to_h
210
+ end
211
+
212
+ def reserved_paths(basename)
213
+ @reserved.map(&:path).select { |path| File.basename(path) == basename }
214
+ end
215
+ end
216
+ end