AGENTS.md@gems/okf · diff

git:20260820.3ce9341 to git:20260820.246ce59

201 added, 66 removed. Audit A to A.

# AGENTS.md
- okf — the baseline gem, `okf` on RubyGems. It reads, validates, lints, searches
- and serves Open Knowledge Format (OKF) v0.2 bundles, and ships the companion
- skill that teaches an agent to write them. The three siblings under `gems/` all
- depend on it.
+ Maintainer guide for okf — `okf` on RubyGems. The baseline gem: it reads,
+ validates, lints, searches and serves Open Knowledge Format (OKF) v0.2 bundles —
+ directories of Markdown + YAML frontmatter that humans and agents both read — and
+ ships the companion skill that teaches an agent to write them. This file
+ documents how to change the code without breaking its contracts.
- **This file is context, routing and reference.** [`../../AGENTS.md`](../../AGENTS.md)
- binds every change in the repo; what is below is okf's own, and every argument
- for it is in `.okf/` rather than here.
+ This gem is the baseline of the okf-gem monorepo, one directory per gem under
+ `gems/`, and the three siblings all depend on it.
+ [`../../AGENTS.md`](../../AGENTS.md) is the repo-level guide and owns everything
+ above a single gem — the layout, the distribution surfaces, the PR shape, the
+ release-title convention, the Git attribution rule. What is here is okf's own:
+ its floor, its dependency limits, its contracts. Where the two overlap, the root
+ is the general rule and this is the instance.
- ## Where to read
+ ## Read the bundle first
+ **`.okf/` is this gem's knowledge, and this file no longer restates it.** What
+ the code is, what every verb already answers, why each rule is a rule, and how a
+ change is proven all live there — once, in the concept that owns them:
+
| you want | read |
| --- | --- |
| the gem at a glance | [`.okf/overview.md`](.okf/overview.md) |
- | what a file under `lib/` does | [`.okf/structure/`](.okf/structure/) — one concept per layer, naming all fifty files |
- | whether a verb already answers this | [`.okf/cli.md`](.okf/cli.md), [`.okf/capabilities/`](.okf/capabilities/) |
- | what the format's nouns mean here | [`.okf/model/`](.okf/model/) |
+ | what a file under `lib/` does | [`.okf/structure/`](.okf/structure/) — one concept per layer, naming every one of the fifty files |
+ | what a verb answers | [`.okf/cli.md`](.okf/cli.md) and [`.okf/capabilities/`](.okf/capabilities/) — before you add a verb that already exists |
+ | what the format's own nouns mean here | [`.okf/model/`](.okf/model/) |
| why a rule is a rule | [`.okf/design/`](.okf/design/) |
- | how to add a verb, and what the suite is made of | [`.okf/testing/`](.okf/testing/) |
+ | how a change is proven | [`.okf/testing/`](.okf/testing/) |
- `okf server .okf` reads it as a graph; `okf search @okf <term>` from anywhere in
- the checkout, `okf search @all <term>` across every bundle.
+ `okf server .okf` from this directory reads it as a graph; `okf search @okf
+ <term>` searches it from anywhere in the checkout, and `okf search @all <term>`
+ reaches every bundle in the repository at once.
+ `structure/` is the half a test holds to the tree:
+ `test/unit/bundle_catalog_test.rb` fails when a file under `lib/` is named by no
+ concept, when a concept names a file that is gone, or when `cli.md`'s group
+ table disagrees with `OKF::CLI.builtins`. That table was code-derived and
+ unchecked for its whole life.
+
## The contract
- Nine rules. Each line is the whole of what you must hold; the link is why.
+ Nine rules. Where a concept carries the argument, this is the short form a
+ reviewer checks against and the link is the rest; two of them have no concept
+ and are stated here in full.
- 1. **Ruby >= 2.4**, and it binds `test/` too. RuboCop catches syntax at 2.4 but
- **not APIs** — [`.okf/design/ruby-floor.md`](.okf/design/ruby-floor.md) has
- the forbidden list by version and the container that proves it.
- 2. **Runtime dependencies are exactly `rack`, `webrick`, `minifts`.** No
- ActiveSupport. A fourth is a design decision —
+ 1. **Ruby >= 2.4** — rack's own floor, so the gem runs on the Ruby an OS already
+ ships. RuboCop parses at 2.4 and catches syntax, but **not APIs**: the
+ forbidden list, broken out by the version that introduced each name, and the
+ container command that actually proves the floor, are
+ [`.okf/design/ruby-floor.md`](.okf/design/ruby-floor.md). It binds `test/` too.
+ 2. **Runtime dependencies are exactly `rack`, `webrick` and `minifts`.** No
+ ActiveSupport — `OKF.blank?` and `Markdown::Frontmatter.stringify_keys` exist
+ precisely so it is not needed. A fourth is a design decision, not a
+ convenience; the bar the third one cleared, and the honest note that its case
+ has since weakened, are
[`.okf/design/runtime-dependencies.md`](.okf/design/runtime-dependencies.md).
- 3. **YAML only through `Markdown::Frontmatter`.** Never call
- `YAML.safe_load`/`YAML.load` anywhere else —
+ 3. **YAML only through `Markdown::Frontmatter`** — `safe_load`, `Date`/`Time`
+ permitted, no aliases. The Psych <3.1 positional-argument shim lives there;
+ do not call `YAML.safe_load`/`YAML.load` anywhere else.
[`.okf/structure/format-layer.md`](.okf/structure/format-layer.md).
- 4. **`validate` and `lint` stay separate**, and exit codes are 0 ok / 1 failing
- bundle / 2 usage error. A new check goes to one side or the other —
- [`.okf/capabilities/validator.md`](.okf/capabilities/validator.md),
- [`.okf/capabilities/linter.md`](.okf/capabilities/linter.md).
- 5. **The server page stays self-contained, and both XSS defenses stay.** A new
- render path that skips `DOMPurify.sanitize` reopens the hole —
+ 4. **`validate` and `lint` stay separate.** The spec forbids the validator from
+ rejecting broken cross-links or missing optional fields (warnings only); lint
+ owns curation findings and never emits conformance errors. New checks go to
+ the right side, and exit codes keep the contract: 0 ok, 1 failing bundle, 2
+ usage error. [`.okf/capabilities/validator.md`](.okf/capabilities/validator.md)
+ and [`.okf/capabilities/linter.md`](.okf/capabilities/linter.md).
+ 5. **The server page stays self-contained** — one ERB template, inline CSS/JS,
+ no bundler and no build step. Two XSS defenses hold the line: inlined data
+ goes through `json_for_script`, and every fetched body through
+ `DOMPurify.sanitize(marked.parse(...))` before it reaches `innerHTML`. Keep
+ both — a new render path that skips the sanitizer reopens the hole. The
+ boundary in full, including the realpath containment on every read, is
[`.okf/design/server-trust-boundary.md`](.okf/design/server-trust-boundary.md).
- 6. **The skill ships only from `lib/okf/skill/**`.** Edit there and nowhere else,
- then `rake skill:sync`; `plugin/skills/okf` and `skills/okf` are generated —
- [`.okf/capabilities/agent-skill.md`](.okf/capabilities/agent-skill.md).
- 7. **Tests subclass `OKF::TestCase`**, nothing else —
- [`.okf/testing/the-harness.md`](.okf/testing/the-harness.md).
- 8. **Integration first**: `test/integration/cli/` is the critical layer, and it
- wins when it competes with a unit test for effort —
- [`.okf/design/integration-first.md`](.okf/design/integration-first.md).
- 9. **A new top-level file here ships unless the gemspec rejects it, and nothing
- in `spec.files` may be a symlink.** Check `gem build` output —
- [`.okf/design/packaging.md`](.okf/design/packaging.md).
+ 6. **The skill ships only from `lib/okf/skill/**`** — that tree is the single
+ canonical copy, so edit it there and nowhere else. `plugin/skills/okf` and
+ `skills/okf` at the repo root are *generated*: run `bundle exec rake
+ skill:sync` after touching the skill or bumping the version. Two guards fail
+ on drift, and `build` depends on one of them.
+ [`.okf/capabilities/agent-skill.md`](.okf/capabilities/agent-skill.md); why
+ there are two destinations is the ecosystem bundle's `@okf-eco skills/okf-skill`.
+ 7. **Tests use `OKF::TestCase`** (`test/test_helper.rb`): plain Minitest plus
+ `test "..."` / block `setup`/`teardown` sugar. Nothing else is the base
+ class, and the suite runs on 2.4, so rule 1 applies to `test/` unchanged.
+ 8. **Integration first — `test/integration/cli/` is the critical layer.** It is
+ the only place the gem is exercised the way it is actually used: real argv,
+ real streams, real exit codes, real files. When a unit test and an
+ integration test compete for effort, integration wins. See
+ [Testing](#testing) below for what that obliges, and
+ [`.okf/design/integration-first.md`](.okf/design/integration-first.md) for
+ how the layer is organised.
+ 9. **`.dockerignore` implies the gemspec's reject list, one way only.** Anything
+ `.dockerignore` drops from under this directory must also be rejected by the
+ gemspec (or be gitignored): `git ls-files` reads the *index*, so a path
+ excluded from the Docker build context is still in `spec.files` and
+ `gem build` then fails on a file that is not there. **The converse does not
+ hold** and must not be "restored" for symmetry — `bin/`, `Gemfile` and
+ `Rakefile` are rejected from the gem and stay in the build context on purpose.
+ Paths outside this directory need no pairing at all; the gemspec runs with
+ `chdir:` here and never sees them.
- **The graph page is not done until `rake test:browser` is green.** Nothing
- enforces it — it does not run in CI, deliberately. Run it and say what it said.
- [`.okf/design/browser-tests.md`](.okf/design/browser-tests.md).
+ The same section's other rule: **nothing in `spec.files` may be a symlink.**
+ `gem build` does not resolve one — it writes a symlink into the package,
+ warns, and succeeds. RubyGems >= 3.2 then refuses to extract a link pointing
+ outside the gem, and **RubyGems < 3.2 has no guard at all**, so on Ruby 2.7
+ (RubyGems 3.1.6, inside the supported range) `gem install` exits 0 and
+ installs a *dangling* file. The old half of the matrix is the dangerous one:
+ the gem installs cleanly and carries no licence. So `LICENSE.txt` and
+ `NOTICE` are real duplicates of the root's, and `test/unit/packaging_test.rb`
+ pins that they are not symlinks, are byte-identical to the root's, and are
+ actually in `spec.files`.
+ ## Testing
+
+ `test/integration/cli/` is the critical layer. How it is organised — one file
+ per command and subcommand, three folders for the three ways a user names a
+ bundle, what `across_bundles/` obliges even for the verbs that take one, and why
+ fixtures are the cheap part — is
+ [`.okf/design/integration-first.md`](.okf/design/integration-first.md). The
+ step-by-step walk a new verb owes is
+ [`.okf/testing/adding-a-verb.md`](.okf/testing/adding-a-verb.md).
+
+ Four obligations stay in this file, because a reviewer checks them:
+
+ - **Test first, and at this level.** A change starts with a failing integration
+ test, not with the fix. Run it and read the failure: it must fail for the
+ reason you predicted, not because a fixture is missing or a regex has a typo.
+ Then write the code and re-run; the same test passes, **unedited**. A test
+ written after the fix certifies only the code it was read off, and editing
+ test and code together in one pass is how a bug and its test come to agree
+ with each other and stay wrong together. A bug report earns a red test before
+ a patch.
+ - **Pure refactors are the exception, not a licence.** They change no behavior,
+ so the existing suite is the test and a green run is the proof. If a change is
+ too small to fail visibly first, say so — never skip the step quietly.
+ - **Assertions must be able to fail for a real reason.** Run the CLI, read what
+ it actually prints, then assert *that*. Never assert what you assume the code
+ does — that is how a green suite certifies a bug.
+ - **Do not skimp on fixtures.** When a path is unreachable from the existing
+ ones, add the fixture; never bend a test toward what the fixtures happen to
+ make easy. A branch no fixture can reach is a branch nobody has ever proven.
+
+ ### The graph page
+
+ `lib/okf/render/graph/template.html.erb` is ~1,300 lines of inline JS and CSS,
+ and its regressions are the kind a string assertion cannot see.
+ `test/integration/render/` proves the page is *emitted* correctly;
+ `test/browser/` — Playwright driving real Chromium, every spec run twice — is
+ what proves it *works*.
+
+ It does not run in CI, deliberately and on the evidence, so the obligation is
+ unhedged: **a change to the template is not done until `rake test:browser` is
+ green**, and a bug in the page earns a red spec there before it earns a patch.
+ Nothing enforces it. Run it and say what it said. The argument, the two modes
+ and the measurement that ended the CI job are
+ [`.okf/design/browser-tests.md`](.okf/design/browser-tests.md); the three seams
+ that couple the file's sections are
+ [`.okf/structure/the-server.md`](.okf/structure/the-server.md).
+
## Commands
+ From this directory — everything about the gem, and what CI actually runs:
+
```bash
bin/setup # install dependencies
bundle exec rake # test + rubocop — the default task, what CI runs
bundle exec rake test # just the suite (SimpleCov report in coverage/)
- bundle exec rake test:integration # the critical layer alone
- bundle exec rake test:browser # the graph page in real Chromium (needs browser:setup)
- bundle exec rake browser:ui # the same suite, interactive
- bundle exec rake serve # the browser fixture bundle, served by hand
- bundle exec rake skill:sync # regenerate the generated skill copies + version stamp
+ bundle exec rake test:integration # the critical layer alone + coverage/integration/
+ bundle exec rake test:browser # the graph page in a real Chromium (needs browser:setup)
+ bundle exec rake browser:ui # the same suite, interactive — pick specs and watch
+ bundle exec rake serve # the browser fixture bundle, served for poking by hand
ruby -Ilib exe/okf <cmd> <dir> # the CLI from the checkout, no install
+ bundle exec rake skill:sync # regenerate every generated skill copy + version stamp
```
- The 2.4 floor, from the repo root because it copies the whole tree:
+ The 2.4 floor is proven in a container, run from the repo root because it copies
+ the whole tree and steps in here:
```bash
docker run --rm -v "$PWD":/src:ro ruby:2.4 bash -c \
"cp -a /src /build && cd /build/gems/okf && rm -f Gemfile.lock && bundle install --quiet && bundle exec rake test"
```
+ CI (`../../.github/workflows/main.yml`) runs the default task on every supported
+ Ruby, 2.4 through the current stable, as its own job with `working-directory:
+ gems/okf`. A change is not done until it is green.
+
## Releasing
- From this directory — `cd gems/okf` first; the root `rake release` refuses.
+ A release is cut **from this directory** — `cd gems/okf` first. Bundler reads
+ the gemspec in its working directory and derives the tag from it, so the root
+ `rake release` refuses rather than doing something plausible. The PR that
+ carries a version bump is a release PR and takes the repo-level shape — the
+ `release` label, the title `Release X.Y.Z — <summary>`, the body skeleton — all
+ three owned by [`../../AGENTS.md`](../../AGENTS.md).
- 1. Bump `lib/okf/version.rb`, run `bundle exec rake skill:sync` (the plugin
- manifest versions with the gem), move `CHANGELOG.md`'s `Unreleased` notes
- under the new version.
- 2. `bundle exec rake release` — tags `vX.Y.Z`, pushes commits + tag, pushes to
- RubyGems (MFA). `build` aborts on a drifted skill copy, so a forgotten sync
- stops the release rather than shipping.
+ 1. Bump `lib/okf/version.rb`, then `bundle exec rake skill:sync` — the plugin
+ versions with the gem, so `plugin/.claude-plugin/plugin.json` must follow
+ every bump, and both generated skill copies with it. Move the `Unreleased`
+ notes in `CHANGELOG.md` under the new version.
+ 2. `bundle exec rake release` — tags `vX.Y.Z`, pushes commits + tag, pushes the
+ gem to RubyGems (MFA required). `release` runs `build`, and `build` aborts if
+ a generated skill copy has drifted or the plugin manifest lags the gem
+ version (`rake skill:verify`), so a forgotten sync stops the release instead
+ of shipping.
- `release:guard_clean` is repo-wide, so an edited file in a sibling gem blocks
- this release; `release:preflight` runs first and names the paths. The bare `v*`
- series is this gem's — `@okf-eco decisions/release-and-tags`. The PR shape is
- `@okf-eco design/pull-requests`.
+ **The bare `v*` tag series belongs to this gem** and keeps doing so; a sibling
+ tags `<gem>/vX.Y.Z`. The asymmetry is deliberate and the argument is the
+ ecosystem bundle's `@okf-eco decisions/release-and-tags`.
+ `release:guard_clean` is **repo-wide** — Bundler runs `git diff` with no
+ pathspec, so a half-finished sibling gem or an edited file two levels up blocks
+ a release of this one, and all Bundler says is "There are files that need to be
+ committed first." `release:preflight` runs ahead of it and names the paths,
+ separating this gem's from the rest; it aborts, because guard_clean was going to
+ anyway and the only question is which message you get. Verified by running, not
+ pinned by a test: it is release tooling, and nothing in the suite drives rake
+ tasks.
+
+ Gem packaging detail: `spec.files` comes from `git ls-files` run with `chdir:`
+ here, minus `test/`, `bin/`, the Gemfile, the Rakefile, `.gitignore`,
+ `.rubocop.yml`, `AGENTS.md`, `CLAUDE.md` and the gemspec itself. Everything at
+ the repo root is invisible to it, so a new *root* file needs no reject — but a
+ new top-level file **here** ships unless the gemspec rejects it, so check
+ `gem build` output when adding one. Rule 9 above is the other half of this.
+
## Its own bundle
- `.okf/` ships inside the gem, deliberately — an installed okf carries a real
- bundle to open with the tool just installed. Maintain it in the same commit as
- the code. A file under `lib/` with no concept naming it is a red suite, and so is
- a verb in `CLI.builtins` with no row in `cli.md`.
+ `.okf/` ships inside the gem — the reject list does not name it, deliberately —
+ so an installed okf carries a real bundle, its own, for a reader to open with
+ the very tool they just installed. `test/unit/packaging_test.rb` pins that it
+ ships; `rake okf` at the repo root keeps it validated and lint-clean.
- A concept cannot link out of its bundle, so a reference across the line names the
- other in prose: `` `@okf-eco format/frontmatter` ``. Which bundle a fact belongs
- to is `@okf-eco design/where-knowledge-lives`.
+ Maintain it in the same commit as the code it documents. A new file under `lib/`
+ without a line in the concept that owns its layer is a red suite, not a stale
+ document — and so is a verb added to `CLI.builtins` without its row in `cli.md`.
+
+ Which bundle a fact belongs in — this one or the repository's `@okf-eco` — is
+ the ecosystem bundle's `@okf-eco design/where-knowledge-lives`. The test is
+ whether the fact survives deleting this gem; if it does, it is not this bundle's.
+ A concept cannot link out of its own bundle, so a reference across the line
+ names the other bundle in prose: `` `@okf-eco format/frontmatter` ``.