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
data/NOTICE ADDED
@@ -0,0 +1,10 @@
1
+ OKF gem
2
+ Copyright (c) 2026 Rodrigo Serradura
3
+
4
+ Licensed under the Apache License, Version 2.0 (see LICENSE.txt).
5
+
6
+ This product bundles the Open Knowledge Format (OKF) v0.1 specification
7
+ (lib/okf/skill/reference/SPEC.md), authored by Google Cloud Platform and
8
+ licensed under the Apache License, Version 2.0, Copyright (c) Google LLC.
9
+ Source: https://github.com/GoogleCloudPlatform/knowledge-catalog
10
+ Full license text: lib/okf/skill/reference/APACHE-2.0.txt
data/README.md ADDED
@@ -0,0 +1,276 @@
1
+ <p align="center">
2
+ <h1 align="center">
3
+ <img src=".github/logo.svg" alt="" width="128"><br/>
4
+ <i>okf-gem</i>
5
+ </h1>
6
+ </p>
7
+
8
+ > A rough project, cut and polished into a jewel. And like any jewel, what it is
9
+ > worth comes down to what it does with knowledge: reading it, validating it,
10
+ > curating it, and putting it on display.
11
+
12
+ **okf-gem** reads, validates, lints, and serves **Open Knowledge Format (OKF)**
13
+ v0.1 bundles. OKF is portable knowledge: a directory of Markdown files with YAML
14
+ frontmatter that both humans and agents read. Each file is a _concept_; a
15
+ directory of them is a _bundle_. Over such a bundle the gem gives you five
16
+ things: a library API, a conformance validator, a curation linter, an
17
+ interactive graph server, and a companion agent skill. All but the library API
18
+ are reachable through one `okf` command-line tool.
19
+
20
+ It is deliberately light so it runs on the Ruby your OS already ships:
21
+
22
+ - works on every Ruby since 2.4, the same floor as [rack](https://github.com/rack/rack),
23
+ its core dependency;
24
+ - only two runtime dependencies: `rack` (the server is a mountable Rack app)
25
+ and `webrick` (unbundled from Ruby in 3.0);
26
+ - no ActiveSupport, no build step, no JavaScript toolchain.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ gem install okf
32
+ # or, in a project
33
+ bundle add okf
34
+ ```
35
+
36
+ From a checkout, this builds the gem and installs it into your Ruby environment,
37
+ putting the `okf` command on your `PATH`:
38
+
39
+ ```bash
40
+ bundle exec rake install
41
+ ```
42
+
43
+ ## Command line
44
+
45
+ ```bash
46
+ okf validate <dir> [--json] # check OKF v0.1 conformance (§9)
47
+ okf lint <dir> [--json] [--fail-on warn] [...] # report curation-quality issues
48
+ okf loose <dir> [--json] # list files with no graph links, by folder
49
+ okf server <dir> [-p PORT] [--bind ADDR] [...] # serve the interactive graph over HTTP
50
+ okf graph <dir> [--json] [--minimal] [--no-body] # print the knowledge graph
51
+ okf catalog | files | tags | stats <dir> [--json] # the browser views, on the CLI
52
+ okf skill <dest> [--here] [--force] # install the companion agent skill
53
+ okf --version
54
+ ```
55
+
56
+ Exit codes: `0` success, `1` non-conformant bundle (or a `lint --fail-on`
57
+ threshold crossed), `2` usage error.
58
+
59
+ > [!NOTE]
60
+ > Section numbers like §5, §8, and §9 refer to the OKF v0.1 spec, bundled with
61
+ > the skill at [`lib/okf/skill/reference/SPEC.md`](lib/okf/skill/reference/SPEC.md).
62
+
63
+ ```bash
64
+ $ okf validate docs
65
+ OKF v0.1 conformance — docs
66
+ concepts: 37 index.md: 10 log.md: 1
67
+ ! warn features/link-suggestions.md: cross-link target not found: `/graph-view.md` (tolerated under §5.3)
68
+
69
+ ✓ conformant (33 warning(s))
70
+
71
+ $ okf server docs
72
+ serving 37 concepts at http://127.0.0.1:8808 (Ctrl-C to stop)
73
+ ```
74
+
75
+ <picture>
76
+ <source media="(prefers-color-scheme: dark)" srcset=".github/server-dark.png">
77
+ <img src=".github/server-dark.png" alt="The okf graph server: a force-directed knowledge graph with a concept selected, its neighbors highlighted, and the inspector panel showing the concept's type, tags, cross-links, and rendered Markdown body.">
78
+ </picture>
79
+
80
+ _The graph server on this repo's own [`.okf`](.okf) bundle, with the
81
+ `capabilities/graph-server` concept selected._
82
+
83
+ `graph` and `server` are best-effort (§9): a file with invalid frontmatter is
84
+ skipped (and noted on stderr), not fatal, so one bad file never breaks the rest.
85
+ Before serving a bundle you did not author, read the
86
+ [server trust boundary](#server-trust-boundary).
87
+
88
+ `lint` reports curation quality (reachability, backlog, completeness, freshness,
89
+ provenance, and hygiene) separately from `validate`. It is advisory: it exits
90
+ `0` even with findings unless you opt into gating with `--fail-on warn`.
91
+
92
+ ```bash
93
+ $ okf lint docs
94
+ OKF lint — docs
95
+ concepts: 37 edges: 87 index.md: 10 log.md: 1
96
+ hubs: features/chat/sources/source-ingestion-pipeline (×12), …
97
+
98
+ Backlog
99
+ · info graph-view.md: referenced by 3 link(s) across 2 concept(s) but does not exist
100
+ ! warn features/index.md: index links to missing concept `../../CHANGELOG.md`
101
+ Completeness
102
+ · info features/bundles/entry-editor.md: missing recommended field: description
103
+ Hygiene
104
+ ! warn link-suggestions.md: reference-style link `[:approved_ids]` has no matching definition (an invisible broken link)
105
+
106
+ ⚠ 3 warn, 31 info
107
+ ```
108
+
109
+ `loose` lists the files that float in the graph: concepts with no cross-links
110
+ in or out (graph degree 0), grouped by folder. It is a curation lens over
111
+ `lint`'s `unlinked` check, distinct from `orphan`. An `index.md` listing makes a
112
+ file _reachable_ (not an orphan) but is not a graph edge, so a listed file
113
+ can still be loose. A loose file may be fine: a terminal leaf like a backlog
114
+ item is loose by design. `loose` surfaces the set for you to judge and
115
+ always exits `0`.
116
+
117
+ ## Agent skill
118
+
119
+ The gem carries the companion OKF agent skill: a `SKILL.md` plus reference
120
+ and template files that teach a coding agent to author, maintain, and consume OKF
121
+ bundles and to drive the commands above. Because the skill ships inside the gem,
122
+ installing the gem already puts the skill on your machine, and the skill's
123
+ CLI reference can never drift from the executable it was released with.
124
+
125
+ Point it at your agent's config directory (or its skills directory) and the tree
126
+ settles in its own `skills/okf/` folder, so a shared skills directory never gets
127
+ the files loose:
128
+
129
+ ```bash
130
+ okf skill .claude # Claude Code -> .claude/skills/okf
131
+ okf skill .agents # agent-agnostic -> .agents/skills/okf
132
+ ```
133
+
134
+ The destination is required (no default). The skill lands in `<dest>/skills/okf`,
135
+ unless `<dest>` already ends in `skills` (→ `<dest>/okf`) or `okf` (used as-is).
136
+ Pass `--here` to paste the tree straight into `<dest>`, wherever it is. The
137
+ resolved directory must be empty unless you pass `--force`, so a customized skill
138
+ is never clobbered.
139
+
140
+ ## Library
141
+
142
+ The gem is two layers: pure in-memory data (`OKF::Concept`, `OKF::Bundle`)
143
+ you build, interrogate, and analyze with no disk involved, and on-disk
144
+ handles (`OKF::Concept::File`, `OKF::Bundle::Folder`) that add
145
+ load/save/reload/delete, an "ActiveRecord for the filesystem".
146
+
147
+ ### Pure, in-memory (no disk)
148
+
149
+ Build knowledge straight from data, with no markdown round-trip, and run every
150
+ feature against it. This is the surface an embedding app (e.g. a Rails store)
151
+ uses to reuse the gem over knowledge it already holds as records:
152
+
153
+ ```ruby
154
+ require "okf"
155
+
156
+ concept = OKF::Concept.new(
157
+ path: "tables/orders.md",
158
+ frontmatter: { "type" => "BigQuery Table", "title" => "Orders" },
159
+ body: "Joined with [customers](/tables/customers.md).\n"
160
+ )
161
+
162
+ concept.id # => "tables/orders"
163
+ concept.links # => ["/tables/customers.md"] (spec §5 cross-links)
164
+ concept.citations # => [...] (spec §8 # Citations)
165
+ concept.external_links # => [...] (URLs / mailto:)
166
+ concept.to_markdown # => String (inverse of OKF::Markdown::Frontmatter.parse)
167
+ concept.lint # => OKF::Bundle::Linter::Report (the concept-scoped checks)
168
+
169
+ bundle = OKF::Bundle.new(concepts: [ concept ]) # also: reserved:, unparseable:
170
+ bundle.validate # => OKF::Bundle::Validator::Result (spec §9 conformance)
171
+ bundle.lint # => OKF::Bundle::Linter::Report (curation quality)
172
+ bundle.graph # => OKF::Bundle::Graph (#nodes, #edges, #to_h)
173
+ ```
174
+
175
+ ### On disk
176
+
177
+ `OKF::Bundle::Folder` reads a directory into a pure bundle and materializes one
178
+ back; `OKF::Concept::File` is a single-file handle:
179
+
180
+ ```ruby
181
+ folder = OKF::Bundle::Folder.load("docs")
182
+ folder.bundle # => OKF::Bundle (the pure bundle it read)
183
+ folder.concepts # => [OKF::Concept] (reserved files excluded)
184
+ folder.validate; folder.lint; folder.graph # delegate to the pure core
185
+ folder.concept("tables/orders") # => OKF::Concept::File
186
+ OKF::Server::App.new(folder) # => a Rack app: the interactive graph server
187
+
188
+ # build in memory, then write it out (validates §9 before publishing):
189
+ OKF::Bundle::Folder.new(bundle: bundle, root: "out/dir").save
190
+
191
+ file = OKF::Concept::File.read(root: "docs", path: "tables/orders.md")
192
+ file.concept # => OKF::Concept (pure)
193
+ file.save; file.delete; file.reload
194
+ ```
195
+
196
+ The lower-level pieces are usable on their own too: `OKF::Bundle::Validator.call(bundle)`,
197
+ `OKF::Bundle::Linter.call(bundle, min_body: 50)`, `OKF::Bundle::Graph.build(bundle)`,
198
+ `OKF::Markdown::Frontmatter.parse(markdown)`.
199
+
200
+ ### Conformance model
201
+
202
+ `validate` implements the spec's [§9 conformance definition](lib/okf/skill/reference/SPEC.md#9-conformance)
203
+ exactly. There are three hard conditions, all errors:
204
+
205
+ - **§9.1** every non-reserved file has a parseable YAML frontmatter block;
206
+ - **§9.2** every such block has a non-empty `type`;
207
+ - **§9.3** every `index.md`/`log.md` present follows §6/§7: a nested `index.md`
208
+ has no frontmatter, a root `index.md` carries only `okf_version`, and `log.md`
209
+ date headings are ISO `YYYY-MM-DD`.
210
+
211
+ Everything the spec marks as soft guidance is a warning and never makes a
212
+ bundle non-conformant: missing recommended fields, non-list tags, an unparseable
213
+ timestamp, and broken cross-links (§5.3), which consumers MUST tolerate.
214
+ `OKF::Bundle::Folder#save` validates before publishing, so it never writes a
215
+ bundle that fails §9.
216
+
217
+ ### Curation model (`lint`)
218
+
219
+ `validate` asks _"is this §9-conformant?"_ and is forbidden by §9 to reject for
220
+ broken links or missing optional fields. `lint` asks the complementary question,
221
+ _"is this well-curated, navigable, trustworthy?"_, over exactly those tolerated
222
+ things. The two stay separate: `lint` has its own `OKF::Bundle::Linter` and
223
+ report, never emits conformance errors, and is advisory unless you pass
224
+ `--fail-on warn`.
225
+
226
+ Checks span six categories: **reachability** (orphans, not-in-index, disconnected
227
+ islands, unlinked), **backlog** (demand-ranked missing concepts, broken index entries),
228
+ **completeness** (stubs, missing title/description/timestamp), **freshness**
229
+ (`--stale-after`), **provenance** (uncited external claims, broken citations,
230
+ spec §8), and **hygiene** (duplicate titles, unused/undefined reference links,
231
+ self-links). Select with `--only`/`--except`; `--json` emits the full report as JSON.
232
+
233
+ Two loop concerns from the format's own guidance, _contradictions_ and _semantic_
234
+ staleness, need to understand meaning and are not computed here; `lint --json`
235
+ is the structured input an agent consumes to reason about those.
236
+
237
+ ## Server trust boundary
238
+
239
+ > [!WARNING]
240
+ > The served page loads its JavaScript (Cytoscape, marked, mermaid, and layout
241
+ > plugins) from a CDN and renders each concept's Markdown body **without
242
+ > sanitization**, so only serve bundles you trust. Inlined graph data still
243
+ > cannot break out of its `<script>` (`<` is escaped), but the fetched Markdown
244
+ > is rendered unsanitized.
245
+
246
+ ## Development
247
+
248
+ ```bash
249
+ bin/setup # install dependencies
250
+ bundle exec rake # tests + RuboCop (what CI runs)
251
+ bundle exec rake test # just the test suite
252
+ ruby -Ilib exe/okf validate <dir> # run the CLI from a checkout
253
+ ```
254
+
255
+ The suite runs on every supported Ruby; to check the 2.4 floor locally:
256
+
257
+ ```bash
258
+ docker run --rm -v "$PWD":/app -w /app ruby:2.4 \
259
+ bash -c "bundle install && bundle exec rake test"
260
+ ```
261
+
262
+ ## Contributing
263
+
264
+ Bug reports and pull requests are welcome on GitHub at
265
+ <https://github.com/serradura/okf-gem>. This project is intended to be a safe,
266
+ welcoming space for collaboration, and contributors are expected to adhere to
267
+ the [code of conduct](CODE_OF_CONDUCT.md).
268
+
269
+ ## License
270
+
271
+ The gem is available as open source under the terms of the
272
+ [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) (see
273
+ `LICENSE.txt`). The Open Knowledge Format specification bundled with the skill
274
+ is authored by Google Cloud Platform and included under its own Apache-2.0
275
+ license, Copyright (c) Google LLC. See `NOTICE` and
276
+ `lib/okf/skill/reference/APACHE-2.0.txt`.
data/exe/okf ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "okf"
5
+
6
+ exit OKF::CLI.start(ARGV)
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ # A bundle on disk — the directory-level handle. Reads a directory into a pure
6
+ # OKF::Bundle once, exposes the analyzers over it,
7
+ # and can materialize an in-memory bundle back to disk. Part of the shell.
8
+ #
9
+ # folder = OKF::Bundle::Folder.load("docs")
10
+ # folder.bundle # => OKF::Bundle (pure)
11
+ # folder.validate; folder.lint; folder.graph
12
+ # folder.concept("tables/orders") # => OKF::Concept::File (or nil)
13
+ #
14
+ # # build in memory (the Rails / snapshot-publisher path) and write it out:
15
+ # OKF::Bundle::Folder.new(bundle: pure_bundle, root: "out/dir").save
16
+ class Folder
17
+ attr_reader :root, :bundle
18
+
19
+ def self.load(dir)
20
+ root = File.expand_path(dir.to_s)
21
+ new(bundle: Reader.read(root), root: root)
22
+ end
23
+
24
+ def initialize(bundle:, root:)
25
+ @bundle = bundle
26
+ @root = File.expand_path(root.to_s)
27
+ end
28
+
29
+ def concepts
30
+ @bundle.concepts
31
+ end
32
+
33
+ def validate
34
+ @bundle.validate
35
+ end
36
+
37
+ def lint(**options)
38
+ @bundle.lint(**options)
39
+ end
40
+
41
+ def graph(minimal: false, body: true)
42
+ @bundle.graph(minimal: minimal, body: body)
43
+ end
44
+
45
+ def catalog
46
+ @bundle.catalog
47
+ end
48
+
49
+ def directory_index
50
+ @bundle.directory_index
51
+ end
52
+
53
+ # Human-readable "parent/dir" name — the default HTML title.
54
+ def name
55
+ pathname = Pathname.new(@root)
56
+ "#{pathname.parent.basename}/#{pathname.basename}"
57
+ end
58
+
59
+ # A single-file handle for one concept id (read live from disk), or nil when no
60
+ # concept in the loaded bundle has that id. The id may be a frontmatter `id`, so
61
+ # it is resolved to a path through the bundle rather than assumed to be "id.md".
62
+ def concept(id)
63
+ path = @bundle.paths_by_id[id] or return nil
64
+ Concept::File.read(root: @root, path: path)
65
+ end
66
+
67
+ # Materialize the in-memory bundle to disk (Writer validates §9 before
68
+ # publishing, so a malformed bundle is never written).
69
+ def save(overwrite: false)
70
+ Writer.call(
71
+ bundle_path: @root,
72
+ concepts: @bundle.concepts,
73
+ index_files: reserved_hash("index.md"),
74
+ log_files: reserved_hash("log.md"),
75
+ overwrite: overwrite
76
+ )
77
+ self
78
+ end
79
+
80
+ def reload
81
+ @bundle = Reader.read(@root)
82
+ self
83
+ end
84
+
85
+ private
86
+
87
+ def reserved_hash(basename)
88
+ @bundle.reserved
89
+ .select { |entry| File.basename(entry.path) == basename }
90
+ .each_with_object({}) { |entry, hash| hash[entry.path] = entry.content }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ # An in-memory knowledge graph of a bundle: concepts become nodes and
6
+ # bundle-relative markdown links become directed edges. Pure — built from an
7
+ # OKF::Bundle (already in memory), does no I/O, and carries no presentation
8
+ # concerns (sizing/colour belong to a renderer).
9
+ #
10
+ # Node fidelity is a build option, so the graph can ship only what a consumer
11
+ # needs and let a server serve the rest on demand:
12
+ # * default (minimal: false, body: true) — full: id, type, title, description,
13
+ # tags, body.
14
+ # * body: false — everything but the body.
15
+ # * minimal: true — just id and title (the leanest payload to draw the graph).
16
+ # Regardless of node fidelity, #type_index and #tag_index expose compact inverted
17
+ # indexes ({ value => [id, …] }) computed from every concept, so a minimal client
18
+ # can still colour by type and filter by tag.
19
+ class Graph
20
+ attr_reader :nodes, :edges, :type_index, :tag_index
21
+
22
+ def self.build(bundle, minimal: false, body: true)
23
+ # Best-effort (§9): a malformed concept never reaches here — the reader keeps
24
+ # it in bundle.unparseable — so the rest of the bundle still renders. Inspect
25
+ # bundle.unparseable to detect skips.
26
+ concepts = bundle.concepts
27
+ id_by_path = concepts.map { |concept| [ concept.path, concept.id ] }.to_h
28
+ new(
29
+ nodes: concepts.map { |concept| node_for(concept, minimal: minimal, body: body) },
30
+ edges: edges_for(concepts, id_by_path, bundle.root),
31
+ type_index: type_index_for(concepts),
32
+ tag_index: tag_index_for(concepts)
33
+ )
34
+ end
35
+
36
+ def self.node_for(concept, minimal: false, body: true)
37
+ title = default(concept.title, File.basename(concept.id))
38
+ return { id: concept.id, title: title } if minimal
39
+
40
+ node = {
41
+ id: concept.id,
42
+ type: default(concept.type, "Untyped"),
43
+ title: title,
44
+ description: concept.description.to_s,
45
+ tags: tags_of(concept)
46
+ }
47
+ node[:body] = concept.body.to_s.strip if body
48
+ node
49
+ end
50
+
51
+ # Edges resolve by *path* — a markdown link is a file path — then map that path
52
+ # to the concept living there and use its id, so a frontmatter `id` that differs
53
+ # from the path still lands the edge on the right node.
54
+ def self.edges_for(concepts, id_by_path, root)
55
+ seen = Set.new
56
+ concepts.each_with_object([]) do |concept, edges|
57
+ Markdown::Links.extract(concept.body).each do |raw|
58
+ resolved = Markdown::Links.resolve(raw, from: concept.path, bundle: root)
59
+ next if resolved.nil?
60
+
61
+ target_id = id_by_path[resolved]
62
+ next if target_id.nil?
63
+ next if target_id == concept.id
64
+ next unless seen.add?([ concept.id, target_id ])
65
+
66
+ edges << { source: concept.id, target: target_id }
67
+ end
68
+ end
69
+ end
70
+
71
+ # { type => [id, …] } over every concept (one type each, "Untyped" if blank).
72
+ def self.type_index_for(concepts)
73
+ concepts.each_with_object({}) do |concept, index|
74
+ (index[default(concept.type, "Untyped")] ||= []) << concept.id
75
+ end
76
+ end
77
+
78
+ # { tag => [id, …] } over every concept (a concept contributes 0..n tags).
79
+ def self.tag_index_for(concepts)
80
+ concepts.each_with_object({}) do |concept, index|
81
+ tags_of(concept).each { |tag| (index[tag.to_s] ||= []) << concept.id }
82
+ end
83
+ end
84
+
85
+ def self.tags_of(concept)
86
+ concept.tags.is_a?(Array) ? concept.tags : []
87
+ end
88
+
89
+ def self.default(value, fallback)
90
+ value.nil? ? fallback : value.to_s
91
+ end
92
+
93
+ def initialize(nodes:, edges:, type_index: {}, tag_index: {})
94
+ @nodes = nodes
95
+ @edges = edges
96
+ @type_index = type_index
97
+ @tag_index = tag_index
98
+ end
99
+
100
+ def to_h
101
+ { nodes: nodes, edges: edges }
102
+ end
103
+
104
+ # Ids of nodes with graph degree 0 — no cross-links in *or* out. These are the
105
+ # "loose" files: they float in a rendered graph, reachable (if at all) only via
106
+ # an index.md listing, which is not an edge. Distinct from an orphan, a
107
+ # *reachability* notion that an index listing satisfies — a concept can be
108
+ # indexed (not an orphan) yet still be unlinked here. Preserves node order.
109
+ def unlinked_ids
110
+ @unlinked_ids ||= begin
111
+ linked = Set.new
112
+ edges.each { |edge| linked << edge[:source] << edge[:target] }
113
+ nodes.map { |node| node[:id] }.reject { |id| linked.include?(id) }
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ class Linter
6
+ # The result of linting a bundle for curation quality (see OKF::Bundle::Linter). Mirrors
7
+ # OKF::Bundle::Validator::Result, but lint has no errors — only `:warn` and `:info`
8
+ # findings — and carries free-form `stats` in place of conformance counts. A
9
+ # bundle is "healthy" when it has no `:warn` findings; `:info` never makes it
10
+ # unhealthy. Every finding is a self-describing Hash so #to_h is a stable machine
11
+ # substrate an agent can act on.
12
+ class Report
13
+ attr_reader :findings, :stats
14
+
15
+ def initialize
16
+ @findings = []
17
+ @stats = {}
18
+ end
19
+
20
+ def add_warning(check, path, message, metric: nil)
21
+ @findings << finding(:warn, check, path, message, metric)
22
+ end
23
+
24
+ def add_info(check, path, message, metric: nil)
25
+ @findings << finding(:info, check, path, message, metric)
26
+ end
27
+
28
+ def warnings
29
+ findings.select { |f| f[:severity] == :warn }
30
+ end
31
+
32
+ def info
33
+ findings.select { |f| f[:severity] == :info }
34
+ end
35
+
36
+ def healthy?
37
+ warnings.empty?
38
+ end
39
+
40
+ def stat(key, value)
41
+ @stats[key] = value
42
+ end
43
+
44
+ def to_h
45
+ { healthy: healthy?, stats: stats, findings: findings }
46
+ end
47
+
48
+ private
49
+
50
+ def finding(severity, check, path, message, metric)
51
+ { check: check, severity: severity, path: path, message: message, metric: metric }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end