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,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
|