Class: OKF::Bundle
- Inherits:
-
Object
- Object
- OKF::Bundle
- Defined in:
- lib/okf/bundle.rb,
lib/okf/bundle/graph.rb,
lib/okf/bundle/folder.rb,
lib/okf/bundle/linter.rb,
lib/okf/bundle/reader.rb,
lib/okf/bundle/search.rb,
lib/okf/bundle/writer.rb,
lib/okf/bundle/skeleton.rb,
lib/okf/bundle/validator.rb,
lib/okf/bundle/references.rb,
lib/okf/bundle/row_filter.rb,
lib/okf/bundle/search/scan.rb,
lib/okf/bundle/search/index.rb,
lib/okf/bundle/linter/report.rb,
lib/okf/bundle/validator/result.rb
Overview
A knowledge bundle held in memory (spec §2), Concept-first: parsed concepts plus the raw text of reserved index/log files and any files whose frontmatter failed to parse. Pure — it performs no disk access.
Build one straight from data (the Rails path — no markdown round-trip):
OKF::Bundle.new(concepts: [OKF::Concept.new(...)])
or let OKF::Bundle::Reader parse a directory into one. OKF::Bundle::Validator,
OKF::Bundle::Linter, and OKF::Bundle::Graph consume it; the convenience methods below forward
to them, so bundle.validate / bundle.lint / bundle.graph work on any
in-memory bundle.
root is the bundle path kept purely as data — it seeds bundle-relative link
resolution (§5.1) and report messages, never I/O. A bundle built in memory
without a real directory gets VIRTUAL_ROOT so relative-link math still works.
Defined Under Namespace
Modules: RowFilter Classes: Entry, Folder, Graph, Linter, Reader, References, Search, Skeleton, Validator, Writer
Constant Summary collapse
- VIRTUAL_ROOT =
Stand-in absolute root for bundles built in memory (no directory). Only ever the base for pure path arithmetic in Links.resolve; the paths it yields are bundle-relative regardless of its value.
"/okf"
Instance Attribute Summary collapse
-
#concepts ⇒ Object
readonly
Returns the value of attribute concepts.
-
#reserved ⇒ Object
readonly
Returns the value of attribute reserved.
-
#root ⇒ Object
readonly
Returns the value of attribute root.
-
#unparseable ⇒ Object
readonly
Returns the value of attribute unparseable.
Instance Method Summary collapse
-
#catalog ⇒ Object
Rich per-concept metadata the catalog / files / stats consumers want but the lean graph omits — the descriptive frontmatter fields plus in/out link degree taken from the graph edges.
-
#concept_by_id(id) ⇒ Object
The Concept with this id, or nil.
-
#directories ⇒ Object
The progressive-disclosure map (spec §8): one entry per directory that holds concepts or carries an index.md, sorted with the root (".") first.
- #directory_index ⇒ Object
- #graph(minimal: false, body: true) ⇒ Object
-
#hubs ⇒ Object
Concepts ranked by inbound link degree, each with the top-level dirs its inbound links come from — the evidence for "is this hub well-homed?": a hub whose inbound majority is foreign to its own top-level dir is a move candidate, one with a single dominant foreign dir already names its better home.
- #index_files ⇒ Object
-
#initialize(concepts: [], reserved: [], unparseable: [], root: nil) ⇒ Bundle
constructor
A new instance of Bundle.
- #lint(**options) ⇒ Object
- #log_files ⇒ Object
-
#okf_version ⇒ Object
The spec version the root index.md declares (§12), as the producer wrote it, or nil when it declares none — which §12 explicitly permits, so nil is an answer rather than a fault, and a consumer that must name a version should say "conformant" rather than guess one.
-
#paths ⇒ Object
Bundle-relative paths of every markdown file — concepts, reserved, and unparseable — sorted.
-
#paths_by_id ⇒ Object
{ id => bundle-relative path }.
-
#reserved_content(path) ⇒ Object
Raw content of a reserved file (index.md/log.md) by bundle-relative path, or "" when absent.
- #skeleton ⇒ Object
-
#stats ⇒ Object
Bundle-level rollups — concepts, dirs, types, links, tags, with the by_type/by_dir/by_top_dir distributions.
-
#tag_groups(by:, entries: nil) ⇒ Object
The tag index re-cut per concept dimension — the vocabulary-curation view: [ [ group-key, rows ], … ] with each row carrying
count(within the group) besidetotal(across the set), so a tag local to one group and one cutting across several read differently without cross-referencing by hand. -
#validate ⇒ Object
── analysis (pure; forwards to the core analyzers) ──.
Constructor Details
#initialize(concepts: [], reserved: [], unparseable: [], root: nil) ⇒ Bundle
Returns a new instance of Bundle.
40 41 42 43 44 45 |
# File 'lib/okf/bundle.rb', line 40 def initialize(concepts: [], reserved: [], unparseable: [], root: nil) @concepts = concepts @reserved = reserved @unparseable = unparseable @root = root || VIRTUAL_ROOT end |
Instance Attribute Details
#concepts ⇒ Object (readonly)
Returns the value of attribute concepts.
38 39 40 |
# File 'lib/okf/bundle.rb', line 38 def concepts @concepts end |
#reserved ⇒ Object (readonly)
Returns the value of attribute reserved.
38 39 40 |
# File 'lib/okf/bundle.rb', line 38 def reserved @reserved end |
#root ⇒ Object (readonly)
Returns the value of attribute root.
38 39 40 |
# File 'lib/okf/bundle.rb', line 38 def root @root end |
#unparseable ⇒ Object (readonly)
Returns the value of attribute unparseable.
38 39 40 |
# File 'lib/okf/bundle.rb', line 38 def unparseable @unparseable end |
Instance Method Details
#catalog ⇒ Object
Rich per-concept metadata the catalog / files / stats consumers want but the lean graph omits — the descriptive frontmatter fields plus in/out link degree taken from the graph edges. Pure: derived from the concepts and their links, sorted by id. Shared by the CLI views and the server's /catalog endpoint.
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/okf/bundle.rb', line 128 def catalog out_degree = Hash.new(0) in_degree = Hash.new(0) graph(minimal: true).edges.each do |edge| out_degree[edge[:source]] += 1 in_degree[edge[:target]] += 1 end concepts.map do |concept| id = concept.id { id: id, title: OKF.blank?(concept.title) ? File.basename(id) : concept.title.to_s, type: concept.type.to_s, description: concept.description.to_s, tags: Array(concept.).map(&:to_s), generated_at: iso8601(concept.generated_at), generated_by: concept.generated_by&.to_s, generated: concept.declared_generated?, trust: concept.trust, status: concept.declared_status&.to_s, stale_after: iso8601(concept.stale_after), sources: concept.sources.length, backlog_ref: concept.frontmatter["backlog_ref"]&.to_s, dir: OKF.dir_of(id), top_dir: top_dir_of(id), links_out: out_degree[id], links_in: in_degree[id] } end.sort_by { |entry| entry[:id] } end |
#concept_by_id(id) ⇒ Object
The Concept with this id, or nil. Last wins on a (rare) duplicate id.
97 98 99 |
# File 'lib/okf/bundle.rb', line 97 def concept_by_id(id) concepts_by_id[id] end |
#directories ⇒ Object
The progressive-disclosure map (spec §8): one entry per directory that holds
concepts or carries an index.md, sorted with the root (".") first. Each entry
gives the authored index body (frontmatter stripped) when an index.md is
present, a type/tag rollup over the concepts that live directly in the
directory, its immediate child directories, and the concept listing an
index.md there would enumerate. A directory with concepts but no index.md has
present: false and still carries the listing, so a consumer can synthesize
the map on the fly (§8 permits exactly that). Grouped by the concept's file
path — index files are physical directory listings, so a custom frontmatter
id must not move a concept out of the directory it lives in. Pure: derived
from the concepts and the reserved index text, no disk. Shared by the
okf index view and the server's Index panel (/index).
Every directory this bundle has — the same set #directory_index enumerates
(concepts, an index.md or a log.md, plus every ancestor), without building
the map. It is the answer to "does this bundle have a directory named X?",
and the CLI needs exactly that to decide whether --dir root names a real
directory or the bundle root. Reading it off #catalog instead is the same
question asked of a smaller set, which is how the two views came to
disagree about one bundle. Memoized: the model is immutable once read,
and the resolvers above ask per invocation, not per bundle load.
255 256 257 |
# File 'lib/okf/bundle.rb', line 255 def directories @directories ||= directory_set(concepts.map { |concept| File.dirname(concept.path) }.uniq) end |
#directory_index ⇒ Object
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 |
# File 'lib/okf/bundle.rb', line 259 def directory_index by_dir = concepts.group_by { |concept| File.dirname(concept.path) } dirs = directory_set(by_dir.keys) dirs.map do |dir| here = (by_dir[dir] || []).sort_by(&:id) index_path = dir == "." ? "index.md" : "#{dir}/index.md" present = index_files.include?(index_path) { dir: dir, index_path: index_path, present: present, synthesized: !present, body: present ? strip_frontmatter(reserved_content(index_path)) : nil, count: here.size, types: tally(here.map { |concept| concept.type.to_s }), tags: tally(here.flat_map { |concept| Array(concept.).map(&:to_s) }), subdirs: dirs.select { |other| other != dir && File.dirname(other) == dir }, listing: here.map do |concept| { id: concept.id, title: OKF.blank?(concept.title) ? File.basename(concept.id) : concept.title.to_s, description: concept.description.to_s, type: concept.type.to_s, tags: Array(concept.).map(&:to_s) } end } end end |
#graph(minimal: false, body: true) ⇒ Object
116 117 118 |
# File 'lib/okf/bundle.rb', line 116 def graph(minimal: false, body: true) Graph.build(self, minimal: minimal, body: body) end |
#hubs ⇒ Object
Concepts ranked by inbound link degree, each with the top-level dirs its
inbound links come from — the evidence for "is this hub well-homed?": a hub
whose inbound majority is foreign to its own top-level dir is a move
candidate, one with a single dominant foreign dir already names its better
home. Only concepts with at least one inbound link appear. Pure: derived
from the graph edges. Shared by the okf graph --hubs view.
166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/okf/bundle.rb', line 166 def hubs inbound = {} graph(minimal: true).edges.each do |edge| (inbound[edge[:target]] ||= Hash.new(0))[top_dir_of(edge[:source])] += 1 end inbound.map do |id, sources| by_top_dir = sources.sort_by { |top_dir, count| [ -count, top_dir ] }.to_h { id: id, top_dir: top_dir_of(id), inbound: by_top_dir.values.reduce(0, :+), by_top_dir: by_top_dir } end.sort_by { |row| [ -row[:inbound], row[:id] ] } end |
#index_files ⇒ Object
53 54 55 |
# File 'lib/okf/bundle.rb', line 53 def index_files reserved_paths("index.md") end |
#lint(**options) ⇒ Object
112 113 114 |
# File 'lib/okf/bundle.rb', line 112 def lint(**) Linter.call(self, **) end |
#log_files ⇒ Object
57 58 59 |
# File 'lib/okf/bundle.rb', line 57 def log_files reserved_paths("log.md") end |
#okf_version ⇒ Object
The spec version the root index.md declares (§12), as the producer wrote it, or nil when it declares none — which §12 explicitly permits, so nil is an answer rather than a fault, and a consumer that must name a version should say "conformant" rather than guess one.
Stringified and stripped for the reason the validator compares that way:
an unquoted okf_version: 0.2 is a Psych Float, and a consumer switching
on it must not be handed 0.2 the number. Public because a version stated
on screen is otherwise a literal — which is how a reader gets told "v0.1"
about a bundle that declares 0.2. Unparseable frontmatter is the
validator's error to report, not this reader's to raise.
80 81 82 83 84 85 86 87 88 89 |
# File 'lib/okf/bundle.rb', line 80 def okf_version content = reserved_content("index.md") return nil unless content.match?(/\A---[ \t]*\n/) frontmatter, = Markdown::Frontmatter.parse(content) declared = frontmatter["okf_version"] OKF.blank?(declared) ? nil : declared.to_s.strip rescue Markdown::Frontmatter::ParseError nil end |
#paths ⇒ Object
Bundle-relative paths of every markdown file — concepts, reserved, and unparseable — sorted.
49 50 51 |
# File 'lib/okf/bundle.rb', line 49 def paths (@concepts.map(&:path) + @reserved.map(&:path) + @unparseable.map(&:path)).sort end |
#paths_by_id ⇒ Object
{ id => bundle-relative path }.
102 103 104 |
# File 'lib/okf/bundle.rb', line 102 def paths_by_id @paths_by_id ||= @concepts.map { |concept| [ concept.id, concept.path ] }.to_h end |
#reserved_content(path) ⇒ Object
Raw content of a reserved file (index.md/log.md) by bundle-relative path, or "" when absent. Reserved structure is validated as text, and index links are extracted from it, so its raw form is retained.
64 65 66 67 |
# File 'lib/okf/bundle.rb', line 64 def reserved_content(path) entry = @reserved.find { |candidate| candidate.path == path } entry ? entry.content.to_s : "" end |
#skeleton ⇒ Object
120 121 122 |
# File 'lib/okf/bundle.rb', line 120 def skeleton Skeleton.build(self) end |
#stats ⇒ Object
Bundle-level rollups — concepts, dirs, types, links, tags, with the
by_type/by_dir/by_top_dir distributions. One home, shared by okf stats
and the MCP stats tool, because the by_dir subtlety already diverged once
when hand-copied: it reads Bundle#directory_index (the map --dir is
answered against), so a directory holding nothing directly appears at 0
rather than disappearing, and dirs equals by_dir.size.
Note the recorded split: by_dir is the disk, by_top_dir rolls up the id.
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/okf/bundle.rb', line 185 def stats minimal = graph(minimal: true) entries = catalog by_type = minimal.type_index.transform_values(&:size).sort_by { |_, size| -size }.to_h by_top_dir = entries.group_by { |entry| entry[:top_dir] }.transform_values(&:size).sort_by { |_, size| -size }.to_h by_dir = directory_index.map { |entry| [ entry[:dir], entry[:count] ] } .sort_by { |dir, count| [ -count, dir ] }.to_h { concepts: entries.size, dirs: by_dir.size, top_dirs: by_top_dir.size, types: by_type.size, cross_links: minimal.edges.size, tags: minimal.tag_index.size, by_type: by_type, by_dir: by_dir, by_top_dir: by_top_dir } end |
#tag_groups(by:, entries: nil) ⇒ Object
The tag index re-cut per concept dimension — the vocabulary-curation
view: [ [ group-key, rows ], … ] with each row carrying count (within
the group) beside total (across the set), so a tag local to one group
and one cutting across several read differently without cross-referencing
by hand. by: is :type (blank folds to "Untyped", matching the graph),
:dir (the stored spelling — . for the root), or anything else for the
deprecated first-segment cut. entries: narrows the concepts counted —
the CLI passes its filtered catalog; default is everything.
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 |
# File 'lib/okf/bundle.rb', line 213 def tag_groups(by:, entries: nil) entries ||= catalog by_id = entries.map { |entry| [ entry[:id], entry ] }.to_h groups = {} totals = Hash.new(0) graph(minimal: true).tag_index.each do |tag, ids| ids.each do |id| entry = by_id[id] next if entry.nil? key = tag_group_key(entry, by) ((groups[key] ||= {})[tag] ||= []) << id totals[tag] += 1 end end groups.map do |key, | rows = .map { |tag, ids| { tag: tag, count: ids.length, total: totals[tag], concepts: ids } } .sort_by { |row| [ -row[:count], row[:tag] ] } [ key, rows ] end.sort_by(&:first) end |