From 0e9aafdd64a7b092b03718db8111d5607d652b5f Mon Sep 17 00:00:00 2001 From: pierg Date: Mon, 19 Oct 2026 23:17:33 +0000 Subject: [PATCH 1/2] Rail: keep evidence genres last and apart (rail.evidence) A library can name genres that are audit records rather than reading, such as a lab's protocols and results. The rail's By genre section then lists the reading genres in reading order, and the evidence genres follow in one closed Evidence group that opens on the page being read. - Charter: rail.evidence (list of genres); an empty list keeps the old rail. - Packs: PACK.md may name its own genres under evidence; the lab pack names protocol and result, used when the charter sets none. - Gate: new check rail-genres names an unknown or switched-off genre. - Site data carries rail.evidence; folio config get/set rail.evidence. - Reading order places the lab genres. - Specs, configure skill, lab pack doc, changelog; version 0.2.3. --- CHANGELOG.md | 5 ++ docs/spec/checks.md | 1 + docs/spec/commands.md | 2 +- docs/spec/model.md | 6 +- docs/spec/pack-format.md | 3 + packs/lab/PACK.md | 2 + pyproject.toml | 2 +- shell/folio.css | 6 ++ shell/folio.js | 35 ++++++--- skills/configure/SKILL.md | 1 + src/folio/__init__.py | 2 +- src/folio/charter.py | 23 +++++- src/folio/checks/gate.py | 15 ++++ src/folio/checks/registry.py | 1 + src/folio/commands/charter_cmds.py | 3 +- src/folio/library.py | 15 ++++ src/folio/packs.py | 10 ++- src/folio/site/site.py | 3 +- tests/test_rail.py | 109 +++++++++++++++++++++++++++++ 19 files changed, 228 insertions(+), 16 deletions(-) create mode 100644 tests/test_rail.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3580fa4..241c1a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.3 (2026-10-19) + +- A library can keep its evidence apart in the rail. The charter's `rail: { evidence: [protocol, result] }` names genres that are audit records rather than reading: the rail's "By genre" section then lists the other genres in reading order, and the evidence genres follow in one closed group headed Evidence, which counts them while closed and opens, as any group does, on the page being read. Without the option the packs decide: a pack's `PACK.md` may name its own genres under `evidence`, and the lab pack names `protocol` and `result`, so a lab library gets this without a change to its charter. `rail: { evidence: [] }` keeps the old rail, every genre listed by name. `folio config get|set rail.evidence` reads and sets it, and the new gate check `rail-genres` (error) names a genre in it that does not exist or is switched off. +- The rail's reading order places the lab genres: guide, entry, report, concept, claim, question, note, survey, reading, source, map, protocol, result, then any other genre by name. A long map without sections lists its genres in this order. + ## 0.2.2 (2026-10-19) - The README's closing section describes folio, giro and lab-kit by what each does: folio keeps the documents, giro takes a spec to proof on a branch, and lab-kit runs research on top of folio. No change to the engine. diff --git a/docs/spec/checks.md b/docs/spec/checks.md index d515149..39e8561 100644 --- a/docs/spec/checks.md +++ b/docs/spec/checks.md @@ -24,6 +24,7 @@ These run on every document. No card needs to name them. | `placeholder` | No `{{...}}` placeholder is left in a document. | error | | `orphan` | The no-orphans rule: a document is on a map, or linked from a map or from a document on a map, as its card's `on_map` asks. A field of type `id` counts as a link both ways, so a document named by a reachable document's field, or naming one, is reachable. Links from journal entries do not count. | error | | `home-maps` | Every map the charter lists under `home.maps` exists and is not a `draft`. | error | +| `rail-genres` | Every genre the charter lists under `rail.evidence` exists and is switched on: its pack is on and it is not disabled. | error | | `unique-id` | Every id is unique across the library, whatever the genre, compared without regard to case: slugs, record ids, and journal entry ids (their file stems). A record id is in its genre's prefix and is never reused, even after its document is retired. | error | | `index-current` | The generated indices in `.folio/` match what `folio index` would write. | error | | `stale-quote` | An open question still quotes text that is on its document. | error | diff --git a/docs/spec/commands.md b/docs/spec/commands.md index b44c8a3..cb9cc00 100644 --- a/docs/spec/commands.md +++ b/docs/spec/commands.md @@ -35,7 +35,7 @@ The `--json` output of `folio cite`, `folio maps`, `folio cards`, `folio genres` | `folio annotations resolve [--doc ..] [--label ..] --state --body ".." [--author ]` | Moves every thread the filters select to one state, with one reply each. | | `folio cite [--json]` | Prints a document's path, title, description, status (derived for a permanent document) and the citation markup for HTML, Markdown and LaTeX, always in the same form for each. `folio cite :` prints the link into another library the charter names (HTML and Markdown only: a paper cites only its own library). | | `folio search [--all]` | Searches this library, or with `--all` also every library the charter lists under `search` and every library under `libraries` that is at its path. It reindexes in memory first, so a document just created is found. Titles, descriptions, ids, tags and text are searched. A word matches a whole word or the start of one, and words under two characters are ignored; with several words, a document must match all of them. Each hit shows its status, and retired documents come last. | -| `folio config get []` / `folio config set ` | Reads or changes the charter. Keys are dotted (`home.maps`, `genres.concept.max_words`, `checks.uncited-number`, `journal.kinds`, `site_url`, `comments.identity_header`, `save.gate`, `save.review`, `save.url`); a list is given as comma-separated values. `set` keeps the charter's key order. Only set-up and configure use `set`. | +| `folio config get []` / `folio config set ` | Reads or changes the charter. Keys are dotted (`home.maps`, `genres.concept.max_words`, `checks.uncited-number`, `journal.kinds`, `site_url`, `comments.identity_header`, `save.gate`, `save.review`, `save.url`, `rail.evidence`); a list is given as comma-separated values. `set` keeps the charter's key order. Only set-up and configure use `set`. | | `folio pack list` | The packs available: shipped, in the library's `packs/`, or pinned. Outside a library, the packs folio ships. | | `folio pack on \| --git --ref ` | Switches a pack on in the charter; a git pack is fetched and pinned. | | `folio pack off ` | Switches a pack off. | diff --git a/docs/spec/model.md b/docs/spec/model.md index a3e1e0e..368a974 100644 --- a/docs/spec/model.md +++ b/docs/spec/model.md @@ -214,6 +214,8 @@ packs: ref: home: maps: [proof-repair, solvers] # the top-level maps, in order +rail: + evidence: [protocol, result] # genres the rail lists last and apart, as evidence (§14) genres: # optional per-genre settings journal: { max_words: 120 } survey: { enabled: false } @@ -245,6 +247,8 @@ Defaults: no packs, the folio theme, `assets/`, `root` is the folder holding `fo Under `libraries:`, each name is a lowercase slug, used as the scheme of a link (§4), and may not be a scheme the web already uses (`http`, `https`, `mailto` and the like). `path` is required, relative to the folder holding `folio.yaml`; `url` is optional and must be an http(s) address. `folio search --all` searches these libraries too, where their path holds one. +Under `rail:`, `evidence` lists genres the library rail shows last, apart from the reading genres (§14). Without it, the `evidence` of every pack switched on applies (the lab pack's is `protocol` and `result`); an empty list keeps every genre with the reading genres. Each genre it names exists and is switched on (check `rail-genres`). + Under `genres:`, a genre takes `enabled` and any setting of its card's checks. Under `checks:`, a check takes `error`, `warning` or `off`. Check names are listed in `checks.md`. @@ -313,7 +317,7 @@ Every HTML document uses the shared shell: one stylesheet, light and dark themes A page that is one line of argument may opt into the reading column with ``; a Markdown record is always rendered in it. The shell offers colour registers, a few shared components, syntax colours for code listings (highlight.js, by each listing's `language-` class or a guess) and KaTeX math (`$…$`, `\[…\]`, on a page carrying ``), all described in `shell/COMPONENTS.md`. How to choose a page's form is in `craft/layout.md`. A figure, control or animation earns its place by showing what prose cannot; a page uses one where it carries the idea, never to decorate. The shell shows an inline SVG near the size it was drawn for, so its labels do not grow with the window, and lets the reader open any figure larger in a dialog. -The library rail is on the left of every page, the home page, the journal and the Review page included. It links home, the journal and, when served, the Review page. Then it lists the charter's home maps in order, each a group of the documents it lists, and then every document other than a journal entry, grouped by genre. Inside a map's group the documents keep the map's own sections: each list of rows sits under the heading nearest above it on the map page, and consecutive lists under one heading are one section. A map with two or more such sections shows them as sub-groups, each counting its genres while closed; a long map (more than twelve rows) opens only its first section and the one being read. A map with fewer sections lists its documents by genre in reading order (guide, entry, concept, note, survey, reading, source) when it has more than six, and as one list otherwise. Each document in a map's group carries its genre's mark, a letter or two with the genre's name on hover, and a key at the foot of the rail names the marks shown. The map's first document is marked Start. It is drawn from the indices alone (`catalog.json`, `nav.json`) and the site data, so no page lists the library by hand. The current page is marked and scrolled into view, and a guide being read shows its chapters. A group the reader opens or closes stays so in that browser. On a wide screen the rail sits beside the page and the page panel; the `[` key or the top bar's library button hides or shows it, and that choice is remembered. On a narrow screen it is a drawer that the same button or key opens and Escape, a click outside or a link closes. +The library rail is on the left of every page, the home page, the journal and the Review page included. It links home, the journal and, when served, the Review page. Then it lists the charter's home maps in order, each a group of the documents it lists, and then every document other than a journal entry, grouped by genre. Inside a map's group the documents keep the map's own sections: each list of rows sits under the heading nearest above it on the map page, and consecutive lists under one heading are one section. A map with two or more such sections shows them as sub-groups, each counting its genres while closed; a long map (more than twelve rows) opens only its first section and the one being read. A map with fewer sections lists its documents by genre in reading order (guide, entry, report, concept, claim, question, note, survey, reading, source, map, protocol, result, then any other genre by name) when it has more than six, and as one list otherwise. Each document in a map's group carries its genre's mark, a letter or two with the genre's name on hover, and a key at the foot of the rail names the marks shown. The map's first document is marked Start. When the library names evidence genres (the charter's `rail.evidence`, or its packs', §10), the genre groups list the reading genres in reading order, and the evidence genres follow in one closed group of their own, headed Evidence, that counts them while closed; it opens, as any group does, when it holds the page being read. Without evidence genres, the genre groups are listed by name. It is drawn from the indices alone (`catalog.json`, `nav.json`) and the site data, so no page lists the library by hand. The current page is marked and scrolled into view, and a guide being read shows its chapters. A group the reader opens or closes stays so in that browser. On a wide screen the rail sits beside the page and the page panel; the `[` key or the top bar's library button hides or shows it, and that choice is remembered. On a narrow screen it is a drawer that the same button or key opens and Escape, a click outside or a link closes. **Topic focus.** A reader can focus the library on one or more topics, chosen from the Focus control in the top bar. A document is in focus when it falls under a chosen topic (§5). While a focus is on, the rail (its maps, genre groups and counts), search, the journal view, the Review page, the home page's inbox line, its map cards and its recent entries show only what is in focus, and a chip in the top bar names the focus and clears it in one click. Search can still widen to the whole library for one search. Maps, genres and tags still filter inside the focus. A page outside the focus still opens by its address and says, under its header, that it is outside the focus. While it is read, the rail keeps it in its genre group, marked. The focus is remembered in that browser. It is computed from the catalog alone, so it works the same on an exported site. diff --git a/docs/spec/pack-format.md b/docs/spec/pack-format.md index b6ca67c..ea85adb 100644 --- a/docs/spec/pack-format.md +++ b/docs/spec/pack-format.md @@ -13,9 +13,12 @@ genres: [question, protocol, result, claim, report] workflows: [record-a-result, write-a-report] rules: rules.md journal_kinds: [lock, run, kill, instrument, pivot] # kinds the journal accepts while the pack is on +evidence: [protocol, result] # its genres the library rail lists last and apart, unless the charter sets rail.evidence --- ``` +`evidence` names only genres the pack lists under `genres`. A library's charter overrides it with `rail.evidence` (model §10). + The body says, in plain sentences: - `# `: what kind of work this pack is for, in one sentence. diff --git a/packs/lab/PACK.md b/packs/lab/PACK.md index a9dca55..5acd0d4 100644 --- a/packs/lab/PACK.md +++ b/packs/lab/PACK.md @@ -6,6 +6,7 @@ genres: [question, protocol, result, claim, report] workflows: [record-a-result, write-a-report] rules: rules.md journal_kinds: [lock, run, kill, instrument, pivot] +evidence: [protocol, result] --- # Lab @@ -24,6 +25,7 @@ This pack is for work that tests ideas against evidence: it gives questions, pla - **Every number cites a result** (rule, check `uncited-number`). A number that reports a measurement links the result that holds it. - **A citation names a current result** (rule, check `cites-superseded`). A revised document that cites a superseded or retracted result is flagged. - **Journal kinds** `lock` (a protocol locked), `run` (a run launched, checkpointed, failed or ended), `kill` (a kill rule fired), `instrument` (a change to how the lab measures) and `pivot` (a change of direction). They join the core's four (`decision`, `lesson`, `correction`, `retraction`) while the pack is on. +- **Evidence in the rail.** Protocols and results are audit records, so the library rail lists them last, in a closed Evidence group, after the reading genres. The charter's `rail.evidence` changes this; `rail: { evidence: [] }` lists them with the rest. A result's evidence and its re-derive command usually sit outside the library, in the project around it. They resolve from the charter's `root`, so set `root` to the project root when the library is a folder inside it. diff --git a/pyproject.toml b/pyproject.toml index 76db6ce..7e7f2df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] dynamic = ["readme"] name = "folio-kb" -version = "0.2.2" +version = "0.2.3" description = "A knowledge library a coding agent builds and keeps, checked by a gate." license = "MIT" authors = [{ name = "Piergiuseppe Mallozzi" }] diff --git a/shell/folio.css b/shell/folio.css index e86df13..614a903 100644 --- a/shell/folio.css +++ b/shell/folio.css @@ -278,6 +278,12 @@ img, svg, video { max-width: 100%; height: auto; } .f-rgroup[data-open] > .f-rhead > .f-rsum { display: none; } .f-rail .f-rsec .f-rlist { margin: 0 0 .3rem .62rem; padding-left: .3rem; } .f-rail .f-rsec .f-rlist a { padding-left: .35rem; } +/* The evidence genres, last and apart: one closed group headed like a section, which counts + its genres on a line of its own while closed. */ +.f-revidence > .f-rgroup > .f-rhead { flex-wrap: wrap; } +.f-revidence > .f-rgroup > .f-rhead > .f-rname-btn { font: 650 .66rem/1.3 var(--sans); letter-spacing: .12em; + text-transform: uppercase; color: var(--ink-3); } +.f-revidence > .f-rgroup > .f-rhead:hover > .f-rname-btn { color: var(--ink-2); } /* A genre's mark, and the map's way in. */ .f-gmark { flex: none; align-self: center; display: inline-flex; align-items: center; justify-content: center; min-width: 1.15rem; height: 1.15rem; padding: 0 .2rem; border: 1px solid var(--rule); border-radius: 4px; diff --git a/shell/folio.js b/shell/folio.js index 6c90cfc..ac442a3 100644 --- a/shell/folio.js +++ b/shell/folio.js @@ -317,8 +317,9 @@ /* --------------------------------------------------------- library rail */ /* The library on the left of every page: the charter's home maps, each with the documents - it lists, then every document by genre. All of it comes from the catalog, the guides' - nav and the site data. A group the reader opens or closes is remembered in this browser. */ + it lists, then every document by genre, and the evidence genres last and apart when the + library names some. All of it comes from the catalog, the guides' nav and the site data. + A group the reader opens or closes is remembered in this browser. */ var NARROW = "(max-width: 77.99rem)"; function narrow() { return !!(window.matchMedia && matchMedia(NARROW).matches); } function plural(word) { @@ -334,7 +335,8 @@ reading: "R", source: "S", survey: "Sv", question: "Q", protocol: "Pr", result: "Rs", claim: "Cl", report: "Rp" }; function markOf(genre) { return MARKS[genre] || human(genre).slice(0, 2); } /* The order a reader meets genres in: the way in first, then ideas, then the evidence. */ - var READING = ["guide", "entry", "concept", "note", "survey", "reading", "source", "map"]; + var READING = ["guide", "entry", "report", "concept", "claim", "question", "note", "survey", "reading", "source", "map", + "protocol", "result"]; function genreRank(genre) { var i = READING.indexOf(genre); return i < 0 ? READING.length : i; } function summary(docs) { var n = {}; @@ -483,13 +485,30 @@ if (!inFocus(d) && d !== hereDoc) return; // the page being read stays in the rail, marked (genres[d.genre] = genres[d.genre] || []).push(d); }); - var byGenre = Object.keys(genres).sort().map(function (g) { - var docs = genres[g].slice().sort(function (a, b) { + function genreDocs(g) { + return genres[g].slice().sort(function (a, b) { return isRecordId(a.id) && isRecordId(b.id) ? a.id.localeCompare(b.id, undefined, { numeric: true }) : a.title.localeCompare(b.title); }); - return group("genre:" + g, el("button", { class: "f-rname-btn", type: "button", text: plural(g) }), docs, false); - }); - section("By genre", byGenre); + } + function genreGroup(g, cls) { + return group("genre:" + g, el("button", { class: cls || "f-rname-btn", type: "button", text: plural(g) }), genreDocs(g), false); + } + /* The charter's (or its packs') evidence genres, such as a lab's protocols and results, are + audit records: they go last, in one closed group of their own, and the reading genres + above them follow reading order. Without any, the genres are listed as they always were. */ + var evidence = (((D.site.rail || {}).evidence) || []).filter(function (g) { return genres[g]; }); + var reading = Object.keys(genres).filter(function (g) { return evidence.indexOf(g) < 0; }); + reading.sort(evidence.length ? function (a, b) { return genreRank(a) - genreRank(b) || a.localeCompare(b); } : undefined); + section("By genre", reading.map(function (g) { return genreGroup(g); })); + if (evidence.length) { + var proof = []; + evidence.forEach(function (g) { proof = proof.concat(genres[g]); }); + var head = el("button", { class: "f-rname-btn", type: "button", text: "Evidence", + title: evidence.map(function (g) { return plural(g); }).join(", ") }); + box.appendChild(el("div", { class: "f-rsection f-revidence" }, [group("evidence", head, proof, false, function (ul) { + evidence.forEach(function (g) { ul.appendChild(el("li", { class: "f-rsec" }, [genreGroup(g, "f-rname-btn f-rsec-btn")])); }); + }, el("span", { class: "f-rsum", text: summary(proof) }))])); + } var used = Object.keys(marked).sort(function (a, b) { return genreRank(a) - genreRank(b) || a.localeCompare(b); }); if (used.length) box.appendChild(el("p", { class: "f-rlegend" }, used.map(function (g) { diff --git a/skills/configure/SKILL.md b/skills/configure/SKILL.md index 9582528..c3efbb7 100644 --- a/skills/configure/SKILL.md +++ b/skills/configure/SKILL.md @@ -42,6 +42,7 @@ Change the charter, or the genres, workflows and packs the library holds, and re - **Other libraries this one links to** (`libraries:`, each with its `path` and the `url` where it is served; model §4, §10): edit `folio.yaml` as a diff, then run `folio check`, which warns when a library is not at its path. - **A journal kind:** `folio config set journal.kinds ", "`, the full list of the library's own kinds. The core's and the packs' kinds need no entry. - **Home maps:** `folio config set home.maps ", "`, in reading order. Each must be an existing map that is not a `draft`; run `folio maps` to check. If one is a draft, warn the user and set it live through the write skill first. + - **Evidence genres in the rail:** `folio config set rail.evidence ", "`, the genres the rail lists last, in a closed Evidence group, after the reading genres (model §14). Without it the packs decide: the lab pack keeps protocols and results apart. An empty value (`""`) lists every genre together. Each must be a genre switched on; `folio check` names any that is not (`rail-genres`). - **The library's name:** `folio config set name ""`. The home page takes its title from it, so nothing else changes. - **Check severity:** `folio config set checks. error` or `warning`. - **A genre setting the charter holds** (switched off, a word limit): `folio config set genres.. `. diff --git a/src/folio/__init__.py b/src/folio/__init__.py index b0d6fa1..381b010 100644 --- a/src/folio/__init__.py +++ b/src/folio/__init__.py @@ -1,3 +1,3 @@ """folio: a knowledge library a coding agent builds and keeps.""" -__version__ = "0.2.2" +__version__ = "0.2.3" diff --git a/src/folio/charter.py b/src/folio/charter.py index b656edf..59b6195 100644 --- a/src/folio/charter.py +++ b/src/folio/charter.py @@ -19,11 +19,13 @@ KEYS = ( "folio", "name", "purpose", "reader", "voice", "packs", "home", "genres", "checks", "assets", "root", "theme", "journal", "search", "site_url", "comments", "libraries", "save", + "rail", ) _HOME_KEYS = ("maps",) _JOURNAL_KEYS = ("kinds",) _COMMENTS_KEYS = ("identity_header",) _SAVE_KEYS = ("gate", "review", "url") +_RAIL_KEYS = ("evidence",) REVIEWS = ("drafts",) _PACK_KEYS = ("name", "git", "ref") _LIBRARY_KEYS = ("path", "url") @@ -32,7 +34,7 @@ "ws", "wss", "about", "blob", "chrome", "git", "ssh", "urn", "news", "irc", "vbscript"} SEVERITIES = ("error", "warning", "off") # Keys whose value is a list, so `config set` splits a comma-separated value. -LIST_KEYS = ("packs", "home.maps", "journal.kinds", "search") +LIST_KEYS = ("packs", "home.maps", "journal.kinds", "search", "rail.evidence") DEFAULT_READER = "One reader, the owner." DEFAULT_VOICE = "Plain and precise." @@ -83,6 +85,8 @@ class Charter: identity_header: str = "" # the request header a sign-in proxy names the reader in; empty: none libraries: dict[str, LibraryRef] = field(default_factory=dict) save: "SaveSettings" = field(default_factory=lambda: SaveSettings()) + # Genres the rail keeps apart, last, as evidence; None: the charter does not say, the packs do. + rail_evidence: list[str] | None = None @property def library(self) -> Path: @@ -112,6 +116,7 @@ def effective(self) -> dict[str, Any]: "libraries": {n: {"path": r.path, **({"url": r.url} if r.url else {})} for n, r in self.libraries.items()}, "save": {"gate": self.save.gate, "review": self.save.review, "url": self.save.url}, + "rail": {"evidence": None if self.rail_evidence is None else list(self.rail_evidence)}, } @@ -211,9 +216,25 @@ def parse(raw: dict[str, Any], path: Path) -> Charter: identity_header=_identity_header(raw, where), libraries=_libraries(raw, where), save=_save(raw, where), + rail_evidence=_rail_evidence(raw, where), ) +def _rail_evidence(raw: dict[str, Any], where: str) -> list[str] | None: + """`rail: {evidence: [...]}`: the genres the rail lists last, apart, as evidence (model §14). + + None when the charter does not set it, so the packs switched on decide; an empty + list keeps every genre with the reading genres. + """ + rail = _only(raw.get("rail"), _RAIL_KEYS, "rail", where) + if "evidence" not in rail: + return None + names = _str_list(rail["evidence"], "rail.evidence", where) + if len(set(names)) != len(names): + raise FolioError(f"{where}: a genre is listed twice in `rail.evidence`") + return names + + def _libraries(raw: dict[str, Any], where: str) -> dict[str, LibraryRef]: """The other libraries this one links to: `name: {path, url}` (model §4, §10).""" table = raw.get("libraries") diff --git a/src/folio/checks/gate.py b/src/folio/checks/gate.py index b81fc21..38e0df4 100644 --- a/src/folio/checks/gate.py +++ b/src/folio/checks/gate.py @@ -268,6 +268,20 @@ def home_maps(lib: Library) -> Found: return out +def rail_genres(lib: Library) -> Found: + """Every genre the charter lists under `rail.evidence` is a genre this library has on.""" + out = [] + for name in lib.charter.rail_evidence or []: + genre = lib.registry.genres.get(name) + if genre is None: + out.append((CHARTER, f"`rail.evidence` names `{name}`, which is no genre; " + "`folio genres` lists them")) + elif not genre.active: + why = f"its pack `{genre.pack}` is off" if genre.pack and genre.enabled else "it is switched off" + out.append((CHARTER, f"`rail.evidence` names genre `{name}`, but {why}")) + return out + + def unique_id(lib: Library) -> Found: """Every id names one document across the whole library; a record id has its form and is never reused.""" out = [] @@ -310,6 +324,7 @@ def index_current(lib: Library) -> Found: "placeholder": placeholder, "orphan": orphan, "home-maps": home_maps, + "rail-genres": rail_genres, "unique-id": unique_id, "index-current": index_current, } diff --git a/src/folio/checks/registry.py b/src/folio/checks/registry.py index b6002f3..42f0ce1 100644 --- a/src/folio/checks/registry.py +++ b/src/folio/checks/registry.py @@ -22,6 +22,7 @@ class CheckSpec: CheckSpec("placeholder", "gate", "error"), CheckSpec("orphan", "gate", "error"), CheckSpec("home-maps", "gate", "error"), + CheckSpec("rail-genres", "gate", "error"), CheckSpec("unique-id", "gate", "error"), CheckSpec("index-current", "gate", "error"), CheckSpec("stale-quote", "gate", "error"), diff --git a/src/folio/commands/charter_cmds.py b/src/folio/commands/charter_cmds.py index 5d2c76e..39d686c 100644 --- a/src/folio/commands/charter_cmds.py +++ b/src/folio/commands/charter_cmds.py @@ -22,6 +22,7 @@ def _show(value: Any) -> str: def get(lib: Library, key: str | None) -> str: effective = lib.charter.effective() + effective["rail"]["evidence"] = lib.rail_evidence() # the packs' default when the charter sets none if key is None: return yaml.safe_dump(effective, sort_keys=False, allow_unicode=True, width=1000).rstrip() value = charter_mod.get_key(effective, key) @@ -62,7 +63,7 @@ def pack_list(lib: Library) -> list[dict[str, Any]]: return [ {"name": p.name, "on": p.on, "source": p.source, "version": p.version, "purpose": p.purpose, "genres": p.genres, "workflows": p.workflows, - "journal_kinds": p.journal_kinds} + "journal_kinds": p.journal_kinds, "evidence": p.evidence} for p in sorted(lib.registry.packs.values(), key=lambda p: p.name) ] diff --git a/src/folio/library.py b/src/folio/library.py index 3f1a4c1..74e8bc6 100644 --- a/src/folio/library.py +++ b/src/folio/library.py @@ -71,6 +71,21 @@ def journal_kinds(self) -> list[str]: kinds.extend(self.charter.journal_kinds) return list(dict.fromkeys(kinds)) + def rail_evidence(self) -> list[str]: + """The genres the rail lists last, apart, as evidence (model §14). + + The charter's `rail.evidence` when it sets one; else the `evidence` of every + pack switched on, keeping only the genres that are active. + """ + if self.charter.rail_evidence is not None: + return list(self.charter.rail_evidence) + out: list[str] = [] + for pack in self.registry.packs.values(): + if pack.on: + out.extend(g for g in pack.evidence + if g in self.registry.genres and self.registry.genres[g].active) + return list(dict.fromkeys(out)) + def find(self, ident: str, genre: str | None = None) -> list[Document]: docs = self.by_id.get(ident, []) if genre is not None: diff --git a/src/folio/packs.py b/src/folio/packs.py index 192eb2b..a67d927 100644 --- a/src/folio/packs.py +++ b/src/folio/packs.py @@ -10,7 +10,7 @@ from .errors import FolioError from .frontmatter import split -_PACK_KEYS = ("name", "version", "requires", "genres", "workflows", "rules", "journal_kinds") +_PACK_KEYS = ("name", "version", "requires", "genres", "workflows", "rules", "journal_kinds", "evidence") @dataclass @@ -24,6 +24,7 @@ class Pack: rules: str | None journal_kinds: list[str] purpose: str + evidence: list[str] = field(default_factory=list) # its genres the rail keeps apart, last on: bool = False extra: dict = field(default_factory=dict) @@ -63,6 +64,12 @@ def load_pack(folder: Path, source: str) -> Pack: for wf in workflows: if not (folder / "workflows" / f"{wf}.md").is_file(): raise FolioError(f"{card}: lists workflow `{wf}` but workflows/{wf}.md is missing") + evidence = front.get("evidence") or [] + if not isinstance(evidence, list) or not all(isinstance(g, str) for g in evidence): + raise FolioError(f"{card}: `evidence` must be a list of the pack's genres") + for genre in evidence: + if genre not in genres: + raise FolioError(f"{card}: `evidence` names `{genre}`, which is not one of the pack's genres") return Pack( name=name, folder=folder, @@ -73,6 +80,7 @@ def load_pack(folder: Path, source: str) -> Pack: rules=front.get("rules"), journal_kinds=[str(k) for k in front.get("journal_kinds") or []], purpose=_purpose(body), + evidence=list(evidence), ) diff --git a/src/folio/site/site.py b/src/folio/site/site.py index 62719d0..fe75261 100644 --- a/src/folio/site/site.py +++ b/src/folio/site/site.py @@ -115,13 +115,14 @@ def builds(self) -> dict[str, Path]: return dict(self.paper_builds().values()) def site_data(self) -> dict: - """What the shell needs beyond `.folio/`: the charter's home maps, kinds and dates.""" + """What the shell needs beyond `.folio/`: the charter's home maps, rail, kinds and dates.""" lib = self.lib known = {f.path for f in lib.files} return { "name": lib.charter.name, "purpose": lib.charter.purpose, "home": {"maps": list(lib.charter.home_maps)}, + "rail": {"evidence": lib.rail_evidence()}, "journal": {"kinds": lib.journal_kinds(), "url": url_of(JOURNAL_VIEW)}, "dates": {p: d for p, d in sorted(self.dates().items()) if p in known}, "frozen": self.frozen(), diff --git a/tests/test_rail.py b/tests/test_rail.py new file mode 100644 index 0000000..83ac323 --- /dev/null +++ b/tests/test_rail.py @@ -0,0 +1,109 @@ +"""The rail's evidence genres: `rail.evidence` in the charter, the packs' default, the gate and the shell.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from folio import charter, library +from folio.errors import FolioError +from folio.packs import load_pack +from folio.site.site import Site + +from conftest import edit, folio, found + +ROOT = Path(__file__).resolve().parents[1] +SHELL = (ROOT / "shell/folio.js").read_text(encoding="utf-8") +CSS = (ROOT / "shell/folio.css").read_text(encoding="utf-8") + + +def test_the_lab_pack_keeps_protocols_and_results_apart_by_default(sample: Path) -> None: + lib = library.load_at(sample) + assert lib.charter.rail_evidence is None + assert lib.registry.packs["lab"].evidence == ["protocol", "result"] + assert lib.rail_evidence() == ["protocol", "result"] + assert Site.load(sample).site_data()["rail"] == {"evidence": ["protocol", "result"]} + + +def test_without_the_lab_pack_there_is_no_evidence(sample: Path) -> None: + edit(sample / "folio.yaml", "packs: [lab, knowledge-base]", "packs: [knowledge-base]") + assert library.load_at(sample).rail_evidence() == [] + + +def test_a_genre_switched_off_leaves_the_packs_evidence(sample: Path) -> None: + edit(sample / "folio.yaml", "checks:", "genres:\n protocol: { enabled: false }\nchecks:") + assert library.load_at(sample).rail_evidence() == ["result"] + + +def test_the_charter_sets_the_evidence_and_wins_over_the_packs(sample: Path) -> None: + edit(sample / "folio.yaml", "checks:", "rail:\n evidence: [result, source]\nchecks:") + lib = library.load_at(sample) + assert lib.rail_evidence() == ["result", "source"] + assert Site.load(sample).site_data()["rail"]["evidence"] == ["result", "source"] + assert not found(sample, "rail-genres") + + +def test_an_empty_list_keeps_every_genre_with_the_reading_genres(sample: Path) -> None: + edit(sample / "folio.yaml", "checks:", "rail:\n evidence: []\nchecks:") + assert library.load_at(sample).rail_evidence() == [] + assert Site.load(sample).site_data()["rail"] == {"evidence": []} + + +@pytest.mark.parametrize("bad, message", [ + ({"rail": {"evidence": "protocol"}}, "rail.evidence"), + ({"rail": {"evidence": ["result", "result"]}}, "listed twice"), + ({"rail": {"order": ["report"]}}, "rail.order"), + ({"rail": ["protocol"]}, "`rail` must be a mapping"), +]) +def test_the_rail_block_is_checked(sample: Path, bad: dict, message: str) -> None: + with pytest.raises(FolioError, match=message): + charter.parse(bad, sample / "folio.yaml") + + +def test_the_gate_names_an_unknown_evidence_genre(sample: Path) -> None: + edit(sample / "folio.yaml", "checks:", "rail:\n evidence: [protocol, experiment]\nchecks:") + problems = found(sample, "rail-genres") + assert [p.path for p in problems] == ["folio.yaml"] + assert "`experiment`" in problems[0].message and "no genre" in problems[0].message + assert problems[0].severity == "error" + + +def test_the_gate_names_an_evidence_genre_whose_pack_is_off(sample: Path) -> None: + edit(sample / "folio.yaml", "checks:", "rail:\n evidence: [result, source]\nchecks:") + edit(sample / "folio.yaml", "packs: [lab, knowledge-base]", "packs: [lab]") + problems = found(sample, "rail-genres") + assert len(problems) == 1 and "`source`" in problems[0].message and "knowledge-base" in problems[0].message + + +def test_a_pack_lists_only_its_own_genres_as_evidence(tmp_path: Path) -> None: + pack = tmp_path / "mine" + (pack / "genres" / "trial").mkdir(parents=True) + (pack / "genres" / "trial" / "GENRE.md").write_text("---\nname: trial\n---\n") + (pack / "PACK.md").write_text("---\nname: mine\ngenres: [trial]\nevidence: [trial, result]\n---\n# Mine\n") + with pytest.raises(FolioError, match="`result`"): + load_pack(pack, "library") + (pack / "PACK.md").write_text("---\nname: mine\ngenres: [trial]\nevidence: [trial]\n---\n# Mine\n") + assert load_pack(pack, "library").evidence == ["trial"] + + +def test_config_reads_and_sets_the_evidence(sample: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None: + assert folio(sample, "config", "get", "rail.evidence", monkeypatch=monkeypatch) == 0 + assert yaml.safe_load(capsys.readouterr().out) == ["protocol", "result"] + assert folio(sample, "config", "set", "rail.evidence", "result", monkeypatch=monkeypatch) == 0 + assert yaml.safe_load((sample / "folio.yaml").read_text())["rail"] == {"evidence": ["result"]} + assert library.load_at(sample).rail_evidence() == ["result"] + + +def test_the_shell_lists_evidence_last_and_apart() -> None: + # The rail reads the site data's evidence genres, orders the reading genres by reading + # order only when there are some, and gives the evidence one closed group of its own. + assert "(D.site.rail || {}).evidence" in SHELL + assert 'section("By genre", reading.map(' in SHELL + assert 'reading.sort(evidence.length ? function (a, b) { return genreRank(a) - genreRank(b)' in SHELL + assert 'group("evidence", head, proof, false,' in SHELL + assert '"f-rsection f-revidence"' in SHELL and ".f-revidence" in CSS + reading = SHELL.split("var READING = [", 1)[1].split("];", 1)[0] + order = [g.strip().strip('"') for g in reading.split(",")] + assert order.index("report") < order.index("concept") < order.index("protocol") < order.index("result") From 30551c05016eae86ecddf497abc57bf7df7e11e2 Mon Sep 17 00:00:00 2001 From: pierg Date: Mon, 19 Oct 2026 23:25:02 +0000 Subject: [PATCH 2/2] Rail evidence: fail loud on an empty evidence line, check a pack's evidence strictly - An empty rail.evidence (null) is an error asking for []; rail: and rail: {} leave the packs to decide. - A pack's evidence is checked when present: a list of strings, each once, from its genres. - config set shows the old value with the packs' default applied, as config get does. - The closed Evidence group's count line follows the evidence order. - Say exactly what evidence: [] restores; tests for each case. --- CHANGELOG.md | 2 +- docs/spec/model.md | 2 +- docs/spec/pack-format.md | 2 +- packs/lab/PACK.md | 2 +- shell/folio.js | 4 ++- skills/configure/SKILL.md | 2 +- src/folio/charter.py | 3 ++ src/folio/commands/charter_cmds.py | 10 ++++-- src/folio/packs.py | 10 ++++-- tests/test_rail.py | 57 ++++++++++++++++++++++++++++++ 10 files changed, 83 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 241c1a3..ccef782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.2.3 (2026-10-19) -- A library can keep its evidence apart in the rail. The charter's `rail: { evidence: [protocol, result] }` names genres that are audit records rather than reading: the rail's "By genre" section then lists the other genres in reading order, and the evidence genres follow in one closed group headed Evidence, which counts them while closed and opens, as any group does, on the page being read. Without the option the packs decide: a pack's `PACK.md` may name its own genres under `evidence`, and the lab pack names `protocol` and `result`, so a lab library gets this without a change to its charter. `rail: { evidence: [] }` keeps the old rail, every genre listed by name. `folio config get|set rail.evidence` reads and sets it, and the new gate check `rail-genres` (error) names a genre in it that does not exist or is switched off. +- A library can keep its evidence apart in the rail. The charter's `rail: { evidence: [protocol, result] }` names genres that are audit records rather than reading: the rail's "By genre" section then lists the other genres in reading order, and the evidence genres follow in one closed group headed Evidence, which counts them while closed and opens, as any group does, on the page being read. Without the option the packs decide: a pack's `PACK.md` may name its own genres under `evidence`, and the lab pack names `protocol` and `result`, so a lab library gets this without a change to its charter. `rail: { evidence: [] }` lists every genre together in "By genre", by name, as before; an empty `evidence:` is an error asking for `[]`. `folio config get|set rail.evidence` reads and sets it, and the new gate check `rail-genres` (error) names a genre in it that does not exist or is switched off. - The rail's reading order places the lab genres: guide, entry, report, concept, claim, question, note, survey, reading, source, map, protocol, result, then any other genre by name. A long map without sections lists its genres in this order. ## 0.2.2 (2026-10-19) diff --git a/docs/spec/model.md b/docs/spec/model.md index 368a974..4f732c1 100644 --- a/docs/spec/model.md +++ b/docs/spec/model.md @@ -247,7 +247,7 @@ Defaults: no packs, the folio theme, `assets/`, `root` is the folder holding `fo Under `libraries:`, each name is a lowercase slug, used as the scheme of a link (§4), and may not be a scheme the web already uses (`http`, `https`, `mailto` and the like). `path` is required, relative to the folder holding `folio.yaml`; `url` is optional and must be an http(s) address. `folio search --all` searches these libraries too, where their path holds one. -Under `rail:`, `evidence` lists genres the library rail shows last, apart from the reading genres (§14). Without it, the `evidence` of every pack switched on applies (the lab pack's is `protocol` and `result`); an empty list keeps every genre with the reading genres. Each genre it names exists and is switched on (check `rail-genres`). +Under `rail:`, `evidence` lists genres the library rail shows last, apart from the reading genres (§14). Without it, the `evidence` of every pack switched on applies (the lab pack's is `protocol` and `result`); an empty list keeps every genre with the reading genres, listed by name. An empty `evidence:` (null) is an error: write `[]`. Each genre it names exists and is switched on (check `rail-genres`). Under `genres:`, a genre takes `enabled` and any setting of its card's checks. Under `checks:`, a check takes `error`, `warning` or `off`. Check names are listed in `checks.md`. diff --git a/docs/spec/pack-format.md b/docs/spec/pack-format.md index ea85adb..01a7bd2 100644 --- a/docs/spec/pack-format.md +++ b/docs/spec/pack-format.md @@ -17,7 +17,7 @@ evidence: [protocol, result] # its genres the library rail lists last and apart --- ``` -`evidence` names only genres the pack lists under `genres`. A library's charter overrides it with `rail.evidence` (model §10). +`evidence`, when present, lists genres from the pack's `genres`, each once. A library's charter overrides it with `rail.evidence` (model §10). The body says, in plain sentences: diff --git a/packs/lab/PACK.md b/packs/lab/PACK.md index 5acd0d4..18867e8 100644 --- a/packs/lab/PACK.md +++ b/packs/lab/PACK.md @@ -25,7 +25,7 @@ This pack is for work that tests ideas against evidence: it gives questions, pla - **Every number cites a result** (rule, check `uncited-number`). A number that reports a measurement links the result that holds it. - **A citation names a current result** (rule, check `cites-superseded`). A revised document that cites a superseded or retracted result is flagged. - **Journal kinds** `lock` (a protocol locked), `run` (a run launched, checkpointed, failed or ended), `kill` (a kill rule fired), `instrument` (a change to how the lab measures) and `pivot` (a change of direction). They join the core's four (`decision`, `lesson`, `correction`, `retraction`) while the pack is on. -- **Evidence in the rail.** Protocols and results are audit records, so the library rail lists them last, in a closed Evidence group, after the reading genres. The charter's `rail.evidence` changes this; `rail: { evidence: [] }` lists them with the rest. +- **Evidence in the rail.** Protocols and results are audit records, so the library rail lists them last, in a closed Evidence group, after the reading genres. The charter's `rail.evidence` changes this. `rail: { evidence: [] }` puts them back among the other genres in the "By genre" section, every genre listed by name as before. The reading order that sorts a long map's documents by genre still places protocols and results last. A result's evidence and its re-derive command usually sit outside the library, in the project around it. They resolve from the charter's `root`, so set `root` to the project root when the library is a folder inside it. diff --git a/shell/folio.js b/shell/folio.js index ac442a3..5186e80 100644 --- a/shell/folio.js +++ b/shell/folio.js @@ -507,7 +507,9 @@ title: evidence.map(function (g) { return plural(g); }).join(", ") }); box.appendChild(el("div", { class: "f-rsection f-revidence" }, [group("evidence", head, proof, false, function (ul) { evidence.forEach(function (g) { ul.appendChild(el("li", { class: "f-rsec" }, [genreGroup(g, "f-rname-btn f-rsec-btn")])); }); - }, el("span", { class: "f-rsum", text: summary(proof) }))])); + }, el("span", { class: "f-rsum", text: evidence.map(function (g) { // in the evidence order, as the groups inside + return genres[g].length + " " + (genres[g].length === 1 ? g : plural(g).toLowerCase()); + }).join(", ") }))])); } var used = Object.keys(marked).sort(function (a, b) { return genreRank(a) - genreRank(b) || a.localeCompare(b); }); diff --git a/skills/configure/SKILL.md b/skills/configure/SKILL.md index c3efbb7..0074684 100644 --- a/skills/configure/SKILL.md +++ b/skills/configure/SKILL.md @@ -42,7 +42,7 @@ Change the charter, or the genres, workflows and packs the library holds, and re - **Other libraries this one links to** (`libraries:`, each with its `path` and the `url` where it is served; model §4, §10): edit `folio.yaml` as a diff, then run `folio check`, which warns when a library is not at its path. - **A journal kind:** `folio config set journal.kinds ", "`, the full list of the library's own kinds. The core's and the packs' kinds need no entry. - **Home maps:** `folio config set home.maps ", "`, in reading order. Each must be an existing map that is not a `draft`; run `folio maps` to check. If one is a draft, warn the user and set it live through the write skill first. - - **Evidence genres in the rail:** `folio config set rail.evidence ", "`, the genres the rail lists last, in a closed Evidence group, after the reading genres (model §14). Without it the packs decide: the lab pack keeps protocols and results apart. An empty value (`""`) lists every genre together. Each must be a genre switched on; `folio check` names any that is not (`rail-genres`). + - **Evidence genres in the rail:** `folio config set rail.evidence ", "`, the genres the rail lists last, in a closed Evidence group, after the reading genres (model §14). Without it the packs decide: the lab pack keeps protocols and results apart. An empty value (`""`, written `evidence: []`) lists every genre together in the "By genre" section, by name, as before; a long map still sorts its genres in reading order, lab genres included. Each must be a genre switched on; `folio check` names any that is not (`rail-genres`). - **The library's name:** `folio config set name ""`. The home page takes its title from it, so nothing else changes. - **Check severity:** `folio config set checks. error` or `warning`. - **A genre setting the charter holds** (switched off, a word limit): `folio config set genres.. `. diff --git a/src/folio/charter.py b/src/folio/charter.py index 59b6195..b473ec9 100644 --- a/src/folio/charter.py +++ b/src/folio/charter.py @@ -229,6 +229,9 @@ def _rail_evidence(raw: dict[str, Any], where: str) -> list[str] | None: rail = _only(raw.get("rail"), _RAIL_KEYS, "rail", where) if "evidence" not in rail: return None + if rail["evidence"] is None: + raise FolioError(f"{where}: `rail.evidence` is empty; write `evidence: []` to list every genre " + "together, or name the evidence genres, such as `[protocol, result]`") names = _str_list(rail["evidence"], "rail.evidence", where) if len(set(names)) != len(names): raise FolioError(f"{where}: a genre is listed twice in `rail.evidence`") diff --git a/src/folio/commands/charter_cmds.py b/src/folio/commands/charter_cmds.py index 39d686c..740bf11 100644 --- a/src/folio/commands/charter_cmds.py +++ b/src/folio/commands/charter_cmds.py @@ -20,9 +20,15 @@ def _show(value: Any) -> str: return str(value) -def get(lib: Library, key: str | None) -> str: +def _effective(lib: Library) -> dict[str, Any]: + """The charter's every key with its value, and the defaults the packs give filled in.""" effective = lib.charter.effective() effective["rail"]["evidence"] = lib.rail_evidence() # the packs' default when the charter sets none + return effective + + +def get(lib: Library, key: str | None) -> str: + effective = _effective(lib) if key is None: return yaml.safe_dump(effective, sort_keys=False, allow_unicode=True, width=1000).rstrip() value = charter_mod.get_key(effective, key) @@ -52,7 +58,7 @@ def _commit(lib: Library, raw: dict[str, Any], key: str) -> list[str]: def set_value(lib: Library, key: str, value: str) -> str: typed = charter_mod.coerce(key, value) try: - old = charter_mod.get_key(lib.charter.effective(), key) + old = charter_mod.get_key(_effective(lib), key) except FolioError: old = None indices = _commit(lib, charter_mod.set_key(lib.charter.raw, key, typed), key) diff --git a/src/folio/packs.py b/src/folio/packs.py index a67d927..9d92a82 100644 --- a/src/folio/packs.py +++ b/src/folio/packs.py @@ -64,9 +64,13 @@ def load_pack(folder: Path, source: str) -> Pack: for wf in workflows: if not (folder / "workflows" / f"{wf}.md").is_file(): raise FolioError(f"{card}: lists workflow `{wf}` but workflows/{wf}.md is missing") - evidence = front.get("evidence") or [] - if not isinstance(evidence, list) or not all(isinstance(g, str) for g in evidence): - raise FolioError(f"{card}: `evidence` must be a list of the pack's genres") + evidence: list[str] = [] + if "evidence" in front: + evidence = front["evidence"] + if not isinstance(evidence, list) or not all(isinstance(g, str) for g in evidence): + raise FolioError(f"{card}: `evidence` must be a list of the pack's genres, such as `[protocol, result]`") + if len(set(evidence)) != len(evidence): + raise FolioError(f"{card}: a genre is listed twice in `evidence`") for genre in evidence: if genre not in genres: raise FolioError(f"{card}: `evidence` names `{genre}`, which is not one of the pack's genres") diff --git a/tests/test_rail.py b/tests/test_rail.py index 83ac323..80f8d91 100644 --- a/tests/test_rail.py +++ b/tests/test_rail.py @@ -37,6 +37,14 @@ def test_a_genre_switched_off_leaves_the_packs_evidence(sample: Path) -> None: assert library.load_at(sample).rail_evidence() == ["result"] +def test_config_set_shows_the_packs_default_as_the_old_value(sample: Path, monkeypatch: pytest.MonkeyPatch, + capsys) -> None: + assert folio(sample, "config", "set", "rail.evidence", "", monkeypatch=monkeypatch) == 0 + assert "rail.evidence: [protocol, result] -> []" in capsys.readouterr().out + assert yaml.safe_load((sample / "folio.yaml").read_text())["rail"] == {"evidence": []} + assert library.load_at(sample).rail_evidence() == [] + + def test_the_charter_sets_the_evidence_and_wins_over_the_packs(sample: Path) -> None: edit(sample / "folio.yaml", "checks:", "rail:\n evidence: [result, source]\nchecks:") lib = library.load_at(sample) @@ -51,11 +59,26 @@ def test_an_empty_list_keeps_every_genre_with_the_reading_genres(sample: Path) - assert Site.load(sample).site_data()["rail"] == {"evidence": []} +@pytest.mark.parametrize("block", ["rail:\n", "rail: {}\n"]) +def test_an_empty_rail_block_leaves_the_packs_to_decide(sample: Path, block: str) -> None: + edit(sample / "folio.yaml", "checks:", block + "checks:") + lib = library.load_at(sample) + assert lib.charter.rail_evidence is None + assert lib.rail_evidence() == ["protocol", "result"] + + +def test_an_empty_evidence_line_fails_loud(sample: Path) -> None: + edit(sample / "folio.yaml", "checks:", "rail:\n evidence:\nchecks:") + with pytest.raises(FolioError, match=r"rail.evidence.*\[\]"): + library.load_at(sample) + + @pytest.mark.parametrize("bad, message", [ ({"rail": {"evidence": "protocol"}}, "rail.evidence"), ({"rail": {"evidence": ["result", "result"]}}, "listed twice"), ({"rail": {"order": ["report"]}}, "rail.order"), ({"rail": ["protocol"]}, "`rail` must be a mapping"), + ({"rail": {"evidence": None}}, r"write `evidence: \[\]`"), ]) def test_the_rail_block_is_checked(sample: Path, bad: dict, message: str) -> None: with pytest.raises(FolioError, match=message): @@ -77,6 +100,38 @@ def test_the_gate_names_an_evidence_genre_whose_pack_is_off(sample: Path) -> Non assert len(problems) == 1 and "`source`" in problems[0].message and "knowledge-base" in problems[0].message +def test_the_gate_names_an_evidence_genre_switched_off(sample: Path) -> None: + edit(sample / "folio.yaml", "checks:", "rail:\n evidence: [protocol, result]\ngenres:\n protocol: { enabled: false }\nchecks:") + problems = found(sample, "rail-genres") + assert len(problems) == 1 + assert "`protocol`" in problems[0].message and "it is switched off" in problems[0].message + + +def _pack(tmp_path: Path, evidence: str) -> Path: + pack = tmp_path / "mine" + (pack / "genres" / "trial").mkdir(parents=True, exist_ok=True) + (pack / "genres" / "trial" / "GENRE.md").write_text("---\nname: trial\n---\n") + (pack / "PACK.md").write_text(f"---\nname: mine\ngenres: [trial]\n{evidence}---\n# Mine\n") + return pack + + +@pytest.mark.parametrize("evidence, message", [ + ("evidence:\n", "must be a list"), + ("evidence: trial\n", "must be a list"), + ("evidence: [trial, 3]\n", "must be a list"), + ("evidence: [trial, trial]\n", "listed twice"), + ("evidence: [trial, result]\n", "`result`"), +]) +def test_a_packs_evidence_is_checked(tmp_path: Path, evidence: str, message: str) -> None: + with pytest.raises(FolioError, match=message): + load_pack(_pack(tmp_path, evidence), "library") + + +def test_a_pack_without_evidence_has_none(tmp_path: Path) -> None: + assert load_pack(_pack(tmp_path, ""), "library").evidence == [] + assert load_pack(_pack(tmp_path, "evidence: []\n"), "library").evidence == [] + + def test_a_pack_lists_only_its_own_genres_as_evidence(tmp_path: Path) -> None: pack = tmp_path / "mine" (pack / "genres" / "trial").mkdir(parents=True) @@ -103,6 +158,8 @@ def test_the_shell_lists_evidence_last_and_apart() -> None: assert 'section("By genre", reading.map(' in SHELL assert 'reading.sort(evidence.length ? function (a, b) { return genreRank(a) - genreRank(b)' in SHELL assert 'group("evidence", head, proof, false,' in SHELL + # The closed group's count line follows the evidence order, as its groups do, not reading order. + assert 'text: evidence.map(function (g) {' in SHELL and "summary(proof)" not in SHELL assert '"f-rsection f-revidence"' in SHELL and ".f-revidence" in CSS reading = SHELL.split("var READING = [", 1)[1].split("];", 1)[0] order = [g.strip().strip('"') for g in reading.split(",")]