Class: OKF::Bundle

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

#conceptsObject (readonly)

Returns the value of attribute concepts.



38
39
40
# File 'lib/okf/bundle.rb', line 38

def concepts
  @concepts
end

#reservedObject (readonly)

Returns the value of attribute reserved.



38
39
40
# File 'lib/okf/bundle.rb', line 38

def reserved
  @reserved
end

#rootObject (readonly)

Returns the value of attribute root.



38
39
40
# File 'lib/okf/bundle.rb', line 38

def root
  @root
end

#unparseableObject (readonly)

Returns the value of attribute unparseable.



38
39
40
# File 'lib/okf/bundle.rb', line 38

def unparseable
  @unparseable
end

Instance Method Details

#catalogObject

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.tags).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

#directoriesObject

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_indexObject



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.tags).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.tags).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

#hubsObject

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_filesObject



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(**options)
  Linter.call(self, **options)
end

#log_filesObject



57
58
59
# File 'lib/okf/bundle.rb', line 57

def log_files
  reserved_paths("log.md")
end

#okf_versionObject

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

#pathsObject

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_idObject

{ 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

#skeletonObject



120
121
122
# File 'lib/okf/bundle.rb', line 120

def skeleton
  Skeleton.build(self)
end

#statsObject

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, tags|
    rows = tags.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

#validateObject

── analysis (pure; forwards to the core analyzers) ──



108
109
110
# File 'lib/okf/bundle.rb', line 108

def validate
  Validator.call(self)
end