okf 1.7.0 → 1.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64b163bdb5b0e234c879f6d5424279b8b329a797292b6322af0379741e06565c
4
- data.tar.gz: 816d6456345e3cd6e507be229b757f8143987ba2c98ffb1bcc26d45c19bdfba2
3
+ metadata.gz: 6c6e09a9ce22d65f6ffe880b9e8ad3925bb648e4c985e2249ad13a83c17cc620
4
+ data.tar.gz: 177086b26883d9004852181dbc5f935f5112ad2b5fde3277fb29a5db766b650a
5
5
  SHA512:
6
- metadata.gz: 9ee06f232154bf609ce24882447de92f26e8a33ebddd593ff462bb83ad993d4f79c797a0c88f7647cbea9bba05b190a0b1da127e01e53d99a9b5e99d952eb76b
7
- data.tar.gz: 69a26c944a1a393f7bcb58cdfd554ed1991c0617fe3fe9d7e138af7dd9847763df947d393a9cea1ff99182430b5f79b488bde78a79f0915740beab088501c4bd
6
+ metadata.gz: d39fa2b520b3c33e76dd3c03111da96a7668809101c035e494ad47df31f993bf661ed3bb5aa0d3b3e9cba81b5087ef95a4398082954033949a20a91ff152feb5
7
+ data.tar.gz: 6109c082b1d89144bd6f60ddedf93a70af41ca23914d5cf9cc243acad3b418211317c988cd2e28fa23632187c79ab629b1b6473a8d6f0d711700db4736c3898f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,156 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [1.8.0] - 2026-07-17
6
+
7
+ - A persistent bundle registry and a multi-bundle hub. `okf registry`
8
+ (list / set / del / default / rename) keeps a per-user list in a plain JSON
9
+ file at `$OKF_HOME/registry.json` (default `~/.okf`), and `okf server` reads
10
+ its mode from its arguments: one dir is the classic single bundle at `/`,
11
+ several mount ephemerally behind a hub at `/b/<slug>/`, none serves the whole
12
+ registry with its default at `/`. Behind a hub the page gains a bundle
13
+ switcher (⌘/Ctrl-K, or the rail button), `/b/` is a browsable index, and an
14
+ unknown slug 404s as a page with a way home. The hub reads its bundles at
15
+ boot — restart after registry changes.
16
+ - The registry is **ordered, and the first entry still on disk is the default** —
17
+ the bundle a bare `okf server` opens at `/`. `okf registry default @slug` moves
18
+ that entry to the front, and `okf registry set --default` registers straight to
19
+ it; until you do either, the first bundle you registered is the default. Nothing
20
+ else has to be maintained: a rename keeps its position, a `del` promotes
21
+ whatever is next, and the file cannot name a default that is not there. A
22
+ vanished directory is stepped over rather than starred — `registry list`'s `*`
23
+ always names the bundle `/` opens — and `registry default @slug` refuses one,
24
+ just as `registry set` refuses to register a directory that is not there.
25
+ - `$OKF_HOME` is the single lever on which registry a command reads: set it and
26
+ every verb follows, from `okf registry list` to an `@slug` on `okf lint`. It
27
+ names exactly one registry, with no fallback to `~/.okf` behind it, and an
28
+ empty value counts as unset rather than planting `registry.json` in the
29
+ current directory.
30
+ - `@slug`: wherever a command takes a `<dir>`, `@slug` names a registered
31
+ bundle and bare `@` the registry default — `okf lint @handbook`,
32
+ `okf render @ -o graph.html`. A slug is normalized like registration was
33
+ (`@One` finds the bundle from dir `One`) but never to a placeholder, so
34
+ `@***` is a bad ref rather than a silent hit. An unknown slug, a
35
+ registered-but-gone directory, or a malformed registry file is a usage error
36
+ naming the registry file and the next move. A hub built from `@slug`s
37
+ (`okf server @a @b`) mounts each bundle under its registered slug, the first
38
+ at `/`, and a registered slug reserves its mount ahead of any plain
39
+ directory that shares the name.
40
+ - `@slug` is spelled where it is used, not just where it is explained, and it is
41
+ the one token — `okf help`'s map (`lint <dir|@slug>`) and each command's own
42
+ banner show it the same way, with a note under the map defining it: the slug
43
+ from `okf registry set`, or bare `@` for the default. It was documented once,
44
+ in prose at the foot of `okf help`, past where a reader who already knows the
45
+ verb ever looks, so seventeen surfaces took a registered bundle while
46
+ advertising a bare `<dir>`. The registry-editing verbs — `del`, `default`,
47
+ `rename` — take the slug bare (no `@`) or as an `@slug`; the read verbs need
48
+ the `@`, since a bare word there is a path.
49
+ - `okf <command> -h` prints that command's own banner and flags. Help now answers
50
+ on stdout with an exit code like every other command: it was OptionParser's
51
+ officious handler, which printed past the caller's injected streams and ended
52
+ the process with `exit` instead of returning a status.
53
+ - `okf search` spans bundles: several leading `@slug`s, or `@all` for every
54
+ registered one. Rankings merge across bundles with every row labeled by its
55
+ bundle's slug (a `bundles` list and a per-match `slug` key in the JSON).
56
+ Asking for everything tolerates gaps — `@all` skips a bundle whose directory
57
+ has vanished, with a note — while naming one insists on it, and `@all @docs`
58
+ simply dedupes. `all` is reserved *in the registry*, on all three ways in: a
59
+ directory named `all/` registers as `all-2`, `--as all` is refused, and a row
60
+ already claiming the name in the registry file — hand-typed, or written before
61
+ the name was reserved — is read as `all-2`, so the reservation never strands a
62
+ registry it inherited. An ephemeral `okf server ./all` still mounts at
63
+ `/b/all/` — no registry, no refs, nothing to reserve.
64
+ - The registry validates its file's shape, not just its JSON syntax: a
65
+ hand-edited entry missing `path` is a usage error naming the file instead of
66
+ a `TypeError`, and `okf registry --json set <dir>` — a subcommand behind a
67
+ flag — is a usage error rather than silently listing and exiting 0.
68
+ - The graph page's ⌘/Ctrl-K palette opens in **every** mode and reaches a view as
69
+ well as a bundle. It was wired only behind a hub, so a standalone
70
+ `okf server ./docs` and every `okf render` page — the two modes most people meet
71
+ first — had no palette at all. Bundles still lead it and own the empty box,
72
+ because switching bundles is what it is for; views wait until what you type
73
+ reaches one, arrive underneath, and stay muted until the cursor does. Each view
74
+ carries the rail's own icon and label, read from the rail so the two cannot
75
+ drift. Where there is no hub there is no bundle to switch to, and views become
76
+ the whole list.
77
+ - The inspector's type and tags are filter handles: clicking one focuses the
78
+ graph on that facet — the same jump the stats bars make — and clicking it again
79
+ clears it. The chip lights while its facet is the only filter in play, which is
80
+ exactly when a second click is an undo, so what you see and what the next click
81
+ does are the same question. With another filter set it re-focuses instead,
82
+ rather than throwing away more than the click put there.
83
+ - The inspector's *Links to* / *Linked from* rows read as concepts rather than a
84
+ wall of accent-coloured text. Each carries its type's dot — the colour that node
85
+ already wears in the graph beside it — with the type named and the section
86
+ counted, so the column answers what kind of neighbourhood a concept has before a
87
+ title is read. The rows share one panel with hairline dividers and a hover fill:
88
+ the container carries the click affordance, which leaves colour free to mean type
89
+ and nothing else.
90
+ - The inspector's widen chevron splits the screen instead of taking 70% of it. The
91
+ panel drag-resizes, so the chevron is a preset rather than a maximum, and burying
92
+ the graph to read one concept was the wrong thing to default to.
93
+ - The graph page answers `?` with a sheet of every keyboard shortcut, reachable
94
+ from a rail button too — a shortcut list you can only open with a shortcut helps
95
+ whoever needs it least. `/` focuses the current view's search where it has one,
96
+ skipping the view that only reads; the sheet is written against the key handler
97
+ it documents, since a shortcut list that has drifted is worse than none.
98
+ - Fixed: a file the reader could not **open** (permissions) threw its errno out
99
+ of the read, so a single locked file took the whole bundle down through every
100
+ verb that reads one — `lint`, `validate`, `catalog`, `server`, `registry set`
101
+ — as a backtrace, under an exit code claiming the bundle was non-conformant.
102
+ §9's best-effort promise covers it now, the same as frontmatter that will not
103
+ parse: the file is skipped, noted on stderr, and reported by `validate` under
104
+ §9.1 naming the file and the errno. One bad file never breaks the rest.
105
+ The stderr note reads `skipped N unusable file(s)` — it counts two kinds now,
106
+ so it names neither and points at `validate`, which names both.
107
+
108
+ - Fixed: `okf registry del <path>` could delete the wrong bundle. A path that
109
+ matched no registered directory fell through to a normalized *slug* lookup, so
110
+ `del ./notes` — naming a local directory — removed whichever entry happened to
111
+ be slugged `notes`, wherever it pointed, and reported `removed notes` with exit
112
+ 0. An argument with a `/` in it now names a location and only a location.
113
+ - Fixed: the registry read trusted stored slugs verbatim while both write paths
114
+ normalized, so a hand-typed `"slug": "My Docs"` listed fine but could not be
115
+ named by `@my-docs`, `registry rename`, or `registry default` — the verbs that
116
+ could repair it were the ones that could not see it. The read normalizes now,
117
+ leaving an already-usable slug untouched. This also removes the only way a
118
+ quote could reach a slug, and with it a DOM XSS in the server's bundle
119
+ switcher, whose JS escape covered `& < >` but not quotes; the escape now covers
120
+ quotes too, so the page does not depend on a guarantee three layers away.
121
+ - Fixed: `okf lint` bucketed a whitespace-only `type` under its own literal
122
+ heading while `types`/`graph`/`stats` bucketed it as `Untyped`, so two verbs
123
+ reported type inventories for the same bundle that would not reconcile. §9.2
124
+ makes a blank type as non-conformant as a missing one; both sides say so now.
125
+ - Fixed: `okf search <dir> --fields slug` passed the field guard and returned one
126
+ empty object per match with exit 0. Only registry mode labels rows with a slug,
127
+ so the two modes now declare the shape each actually emits and a path-named
128
+ search names the fields it does have.
129
+ - Fixed: `okf registry set` reported `(0 concepts)` for a bundle whose files it
130
+ could not read; it notes the skipped files like every other reading verb.
131
+ `okf registry rename DOCS handbook` echoed the argv rather than the slug it
132
+ renamed, naming a bundle that never existed. An unwritable `$OKF_HOME` raised
133
+ a bare `Errno::EACCES` at exit 1 instead of a usage error at exit 2.
134
+ - Fixed: a long path in the Files view's Indexes tab pushed its `map`/`log` badge
135
+ past the right edge of the list. The badge was already pinned and unshrinkable;
136
+ the filename beside it was the problem — a bare text node, and an anonymous flex
137
+ item's automatic minimum size is the full width of its text, so it refused to
138
+ give way and drove the badge out instead. It truncates now, with the full path on
139
+ hover. Only a wide enough path in a narrow enough pane reached it, which is why
140
+ it never showed on mobile, where the list runs full width.
141
+ - Fixed: the bundle switcher scrolled its own first row out of view as it opened.
142
+ It rendered, and scrolled the active row into view, while the dialog was still
143
+ hidden — and a list that is not being displayed measures zero, so the scroll
144
+ landed arbitrarily.
145
+ - Fixed: the `3` (Files) shortcut did nothing once the Indexes tab was open, and
146
+ the palette's Index row would have blanked the page. "Index" is not a view —
147
+ there is no `#view-index`, only the Files view showing its Indexes tab — so
148
+ `setView('files')` from that tab early-returned, and `setView('index')` named a
149
+ view that does not exist. The keyboard and the palette each re-implemented what
150
+ the rail button already did right; both now click the rail item, so the one
151
+ correct path is the only one, and the palette's `current` badge reads the same
152
+ active-tab answer the rail's own highlight does.
153
+
3
154
  ## [1.7.0] - 2026-07-16
4
155
 
5
156
  - `okf server`: responses are gzipped when the client accepts it
data/README.md CHANGED
@@ -35,7 +35,7 @@ The package, end to end:
35
35
  <p align="center">
36
36
  <picture>
37
37
  <source media="(prefers-color-scheme: dark)" srcset=".github/overview-dark.png">
38
- <img src=".github/overview-light.png" width="760" alt="The package: the Agent Skill (your coding agent authors and curates, you stay the editor) writes and maintains the bundle, a folder of Markdown + YAML in your repo where one concept is one file and links between files are the knowledge graph. The bundle is read by the CLI/Lib (validate: legal OKF per section 9; lint: well-curated and fresh; search: ranked retrieval; require okf for Ruby objects) and by the Graph, in three modes: okf server (a live local server), okf render (the same page exported as one static, self-contained HTML file you can host anywhere), and OKF::Server::App (the Rack app mounted in a Rails route). One gem, 100% local, Ruby 2.4 or newer, only rack and webrick as dependencies.">
38
+ <img src=".github/overview-light.png" width="760" alt="The package: the Agent Skill (your coding agent authors and curates, you stay the editor) writes and maintains the bundle, a folder of Markdown + YAML in your repo where one concept is one file and links between files are the knowledge graph. The bundle is read by the CLI/Lib (validate: legal OKF per section 9; lint: well-curated and fresh; search: ranked retrieval; require okf for Ruby objects) and by the Graph, in four modes: okf server (a live local server), okf render (the same page exported as one static, self-contained HTML file you can host anywhere), okf registry (every registered bundle behind one hub), and OKF::Server::App (the Rack app mounted in a Rails route). One gem, 100% local, Ruby 2.4 or newer, only rack and webrick as dependencies.">
39
39
  </picture>
40
40
  </p>
41
41
 
@@ -44,13 +44,20 @@ command-line tool (the library API is also usable in-process). Each capability
44
44
  below links to the concept that documents it: this gem's own knowledge is an OKF
45
45
  bundle, so you can read its design in the format it defends.
46
46
 
47
- | Capability | What it answers | Verb |
48
- | --------------------------------------------------------- | --------------------------------- | ------------------- |
49
- | [Companion agent skill](.okf/capabilities/agent-skill.md) | Can an agent author it? | `skill` |
50
- | [Conformance validator](.okf/capabilities/validator.md) | Is this a legal OKF bundle? (§9) | `validate` |
51
- | [Curation linter](.okf/capabilities/linter.md) | Is it navigable, complete, fresh? | `lint` / `loose` |
52
- | [Interactive graph](.okf/capabilities/graph-server.md) | Explore it live or static? | `server` / `render` |
53
- | [Library API](.okf/capabilities/library-api.md) | Can my Ruby program use it? | in-process |
47
+ | Capability | What it answers | Verb |
48
+ | ------------------------------------------------------------- | --------------------------------- | ---------------- |
49
+ | [Companion agent skill](.okf/capabilities/agent-skill.md) | Can an agent author it? | `skill` |
50
+ | [Conformance validator](.okf/capabilities/validator.md) | Is this a legal OKF bundle? (§9) | `validate` |
51
+ | [Curation linter](.okf/capabilities/linter.md) | Is it navigable, complete, fresh? | `lint` / `loose` |
52
+ | [Ranked text search](.okf/capabilities/search.md) | Which concept covers X? | `search` |
53
+ | [Interactive graph server](.okf/capabilities/graph-server.md) | Can I explore it visually? | `server` |
54
+ | [Static render](.okf/capabilities/render.md) | Can I ship a serverless snapshot? | `render` |
55
+ | [Library API](.okf/capabilities/library-api.md) | Can my Ruby program use it? | in-process |
56
+
57
+ And because knowledge rarely lives in one bundle, a per-user
58
+ [registry](.okf/registry.md) gives each bundle a name: `okf registry set ./docs`
59
+ once, then `@docs` works anywhere a `<dir>` does — from any directory — and a
60
+ bare `okf server` hosts every registered bundle behind one hub.
54
61
 
55
62
  > [!TIP]
56
63
  > **Browse the gem as knowledge, not just docs.** This README is the front door;
@@ -218,7 +225,7 @@ docker run --rm -v "$PWD:/data" -p 8808:8808 ghcr.io/serradura/okf server . --bi
218
225
  Then open <http://127.0.0.1:8808>. Images are published for `linux/amd64` and
219
226
  `linux/arm64` on
220
227
  [ghcr.io](https://github.com/serradura/okf-gem/pkgs/container/okf): `:latest`
221
- tracks the newest release, or pin a version like `:1.5.0`.
228
+ tracks the newest release, or pin a version like `:1.8.0`.
222
229
 
223
230
  Tired of the long line? Install a Docker-backed [`okf` command](https://docker.okfgem.com),
224
231
  so every verb drops the `docker run` prefix and reads exactly like the native CLI
@@ -237,15 +244,30 @@ instead: `irm https://docker.okfgem.com/install.ps1 | iex`.
237
244
  ## Command line
238
245
 
239
246
  ```bash
240
- okf validate <dir> [--json] # check OKF v0.1 conformance (§9)
241
- okf lint <dir> [--json] [--fail-on warn] [...] # report curation-quality issues
242
- okf loose <dir> [--json] # list files with no graph links, by folder
243
- okf search <dir> <term…> [-e] [--in a,b] [...] # ranked text retrieval across metadata + bodies
244
- okf index <dir> [--json] [--area A] [--no-body] # progressive-disclosure map (§6): bodies, rollups, listings
245
- okf server <dir> [-p PORT] [--bind ADDR] [...] # serve the interactive graph over HTTP
246
- okf render <dir> [-o FILE] [--layout NAME] [...] # export the graph as one static, self-contained HTML file
247
- okf graph <dir> [--json] [--minimal] [--no-body] # print the knowledge graph
248
- okf catalog | files | tags | types | stats <dir> [--json] # the browser views, on the CLI
247
+ okf validate <dir|@slug> [--json] # check OKF v0.1 conformance (§9)
248
+ okf lint <dir|@slug> [--json] [--fail-on warn] [...] # report curation-quality issues
249
+ okf loose <dir|@slug> [--json] # list files with no graph links, by folder
250
+ okf search <dir|@slug…|@all> <term…> [-e] [...] # ranked retrieval; @slugs or @all span bundles
251
+ okf index <dir|@slug> [--json] [--area A] [--no-body] # progressive-disclosure map (§6): bodies, rollups, listings
252
+ okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [...] # serve one bundle, or many behind a hub (⌘K to switch)
253
+ okf render <dir|@slug> [-o FILE] [--layout NAME] [...] # export the graph as one static, self-contained HTML file
254
+ okf registry list [--json] # list registered bundles (* marks the default)
255
+ okf registry set <dir|@slug> [--as SLUG] [--default] # add or update a bundle (a bare `server` serves it)
256
+ okf registry del <dir|@slug> # remove a bundle from the registry
257
+ okf registry default <@slug> | rename <@slug> <new> # move a bundle to the front (the default) / rename it
258
+
259
+ # The registry is a plain JSON file at $OKF_HOME/registry.json (default ~/.okf).
260
+ # It is ordered, and the first entry is the default: `registry default @slug`
261
+ # moves that entry to the front. The subcommand leads and flags follow it:
262
+ # `registry set <dir> --as X`, never `registry --json set <dir>`.
263
+ # A running server reads it at boot — restart after changes.
264
+ # Behind a multi-bundle server, /b/ lists every bundle and ⌘/Ctrl-K switches.
265
+ # @slug names a registered bundle instead of a path — the slug from `registry
266
+ # set`, or bare @ for the default. Anywhere a <dir> goes, an @slug goes:
267
+ # okf lint @handbook works from anywhere. Set $OKF_HOME to point every verb
268
+ # at another registry.
269
+ okf graph <dir|@slug> [--json] [--minimal] [--no-body] # print the knowledge graph
270
+ okf catalog | files | tags | types | stats <dir|@slug> [--json] # the browser views, on the CLI
249
271
  okf skill <dest> [--here] [--force] # install the companion agent skill
250
272
  okf --version
251
273
  ```
@@ -276,10 +298,39 @@ _The graph server on this repo's own [`.okf`](.okf) bundle, with the
276
298
  `capabilities/graph-server` concept selected. Try it live at
277
299
  **[demo.okfgem.com](https://demo.okfgem.com)**._
278
300
 
301
+ The page is one template from a phone to a desktop: on small screens the
302
+ navigation rail becomes a drawer, the toolbar folds into a `⚙` sheet, and the
303
+ panels go full-bleed — rotate a tablet and the layout re-evaluates. It is
304
+ keyboard-first too: `⌘/Ctrl-K` opens a command palette in every mode (views
305
+ always; bundles too when a [hub](#one-registry-many-bundles) is serving), `/`
306
+ jumps to the current view's search, and `?` answers with a sheet of every
307
+ shortcut.
308
+
279
309
  To skip the server entirely, **`okf render <dir>`** writes that same page as one
280
310
  self-contained HTML file, the whole bundle baked in, so you can publish the
281
311
  graph on GitHub Pages or any static host.
282
312
 
313
+ ### One registry, many bundles
314
+
315
+ The [registry](.okf/registry.md) is a per-user, ordered list of bundles in one
316
+ plain JSON file (`$OKF_HOME/registry.json`, default `~/.okf`) — hand-editable,
317
+ greppable, no database. It stores references, never content: the bundles stay in
318
+ the repos that own them.
319
+
320
+ ```bash
321
+ okf registry set ./docs --as handbook # give the bundle a name
322
+ okf lint @handbook # @slug works wherever a <dir> does, from anywhere
323
+ okf search @all rate limit # ranked retrieval across every registered bundle
324
+ okf server # no args: the whole registry behind one hub
325
+ ```
326
+
327
+ The first entry still on disk is the **default** — the bundle a bare `okf
328
+ server` opens at `/` and a bare `@` names; `okf registry default @slug` moves an
329
+ entry to the front. Behind the hub each bundle mounts at `/b/<slug>/`, `/b/` is
330
+ a browsable index, and the `⌘/Ctrl-K` palette switches bundles without leaving
331
+ the page. The hub reads the registry at boot, so restart it after registry
332
+ changes; set `$OKF_HOME` to point every verb at a different registry.
333
+
283
334
  `graph` and `server` are best-effort (§9): a file with invalid frontmatter is
284
335
  skipped (and noted on stderr), not fatal, so one bad file never breaks the rest.
285
336
  The [graph server](.okf/capabilities/graph-server.md) concept walks the request
@@ -519,8 +570,8 @@ ruby -Ilib exe/okf validate <dir> # run the CLI from a checkout
519
570
  The suite runs on every supported Ruby; to check the 2.4 floor locally:
520
571
 
521
572
  ```bash
522
- docker run --rm -v "$PWD":/app -w /app ruby:2.4 \
523
- bash -c "bundle install && bundle exec rake test"
573
+ docker run --rm -v "$PWD":/src:ro ruby:2.4 bash -c \
574
+ "cp -a /src /build && cd /build && rm -f Gemfile.lock && bundle install --quiet && bundle exec rake test"
524
575
  ```
525
576
 
526
577
  ## Contributing
@@ -51,11 +51,18 @@ module OKF
51
51
  end
52
52
 
53
53
  # Human-readable "parent/dir" name — the default HTML title.
54
- def name
55
- pathname = Pathname.new(@root)
54
+ # The bundle's display label, "parent/dir" — path arithmetic, no disk. It
55
+ # is a class method so a caller that only wants the label (the registry
56
+ # naming an entry) can have it without a Reader.read of every file.
57
+ def self.label(root)
58
+ pathname = Pathname.new(root)
56
59
  "#{pathname.parent.basename}/#{pathname.basename}"
57
60
  end
58
61
 
62
+ def name
63
+ self.class.label(@root)
64
+ end
65
+
59
66
  # A single-file handle for one concept id (read live from disk), or nil when no
60
67
  # concept in the loaded bundle has that id. The id may be a frontmatter `id`, so
61
68
  # it is resolved to a path through the bundle rather than assumed to be "id.md".
@@ -86,8 +86,12 @@ module OKF
86
86
  concept.tags.is_a?(Array) ? concept.tags : []
87
87
  end
88
88
 
89
+ # Blank, not just nil: §9.2 makes a whitespace-only `type` as non-conformant
90
+ # as a missing one (the validator says so with the same OKF.blank?), so the
91
+ # index must not sort them into different buckets. Otherwise `type: " "`
92
+ # earns its own row, labelled with spaces, next to Untyped.
89
93
  def self.default(value, fallback)
90
- value.nil? ? fallback : value.to_s
94
+ OKF.blank?(value) ? fallback : value.to_s
91
95
  end
92
96
 
93
97
  def initialize(nodes:, edges:, type_index: {}, tag_index: {})
@@ -290,7 +290,12 @@ module OKF
290
290
  @report.stat(:backlog, count_findings(:missing_concept))
291
291
  @report.stat(:components, components.size)
292
292
  @report.stat(:hubs, hubs)
293
- @report.stat(:types, frequency(@concepts.map { |c| c.type || "Untyped" }))
293
+ # Through Graph.default, not a second `|| "Untyped"`: §9.2 makes a
294
+ # whitespace-only type as non-conformant as a missing one, so the two must
295
+ # land in one bucket. Spelling the rule twice is how lint came to report a
296
+ # `" "` bucket that `types` and `graph` had never heard of — the same
297
+ # concepts counted by both verbs, into inventories that will not reconcile.
298
+ @report.stat(:types, frequency(@concepts.map { |c| Graph.default(c.type, "Untyped") }))
294
299
  @report.stat(:tags, frequency(@concepts.flat_map { |c| c.tags.is_a?(Array) ? c.tags : [] }))
295
300
  end
296
301
 
@@ -8,10 +8,14 @@ module OKF
8
8
  #
9
9
  # It parses eagerly: each concept file becomes an OKF::Concept, each
10
10
  # index.md/log.md is kept as raw text (its structure is validated as text), and
11
- # a concept file whose frontmatter does not parse is retained as an unparseable
12
- # entry (carrying the ParseError message, so §9.1 can report it) rather than
13
- # dropped or raised. Every read goes through Path.join_under! so a
14
- # symlinked or crafted path cannot escape the bundle root.
11
+ # a file the reader cannot use — frontmatter that does not parse, or a file it
12
+ # cannot open at all is retained as an unparseable entry (carrying the
13
+ # ParseError message or the errno, so §9.1 can report it) rather than dropped
14
+ # or raised. That tolerance is the whole §9 best-effort promise: one bad file
15
+ # never breaks the rest, and this is the read every verb shares. Every read
16
+ # goes through Path.join_under! so a symlinked or crafted path cannot escape
17
+ # the bundle root — that guard still raises, because a path leaving the root
18
+ # is not a bad file, it is a bundle lying about its shape.
15
19
  class Reader
16
20
  def self.read(dir)
17
21
  new(dir).read
@@ -39,6 +43,19 @@ module OKF
39
43
  end
40
44
  rescue Markdown::Frontmatter::ParseError => e
41
45
  unparseable << Entry.new(path: path, content: content, error: e.message)
46
+ rescue SystemCallError => e
47
+ # A file that cannot be opened is one unusable file, not a broken
48
+ # bundle. Letting the errno out of here breaks "one bad file never
49
+ # breaks the rest" for every verb at once — the read is the one path
50
+ # they all share — and it breaks it in the worst way: a backtrace,
51
+ # under an exit code that claims the bundle is non-conformant. So it
52
+ # joins the same bucket a bad frontmatter block does, and §9.1 reports
53
+ # it naming the file and the errno.
54
+ #
55
+ # Its content is "" rather than nil: unknown, but every analyzer reads
56
+ # it as text, and empty is the honest shape of a file we never saw —
57
+ # no links to resolve, no encoding to be invalid, nothing claimed.
58
+ unparseable << Entry.new(path: path, content: "", error: e.message)
42
59
  end
43
60
  end
44
61