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