From 3084497e1fa6858d64321bcd3f06c44bf064a845 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 20 Oct 2026 14:59:45 +0000 Subject: [PATCH 01/31] Start the branch changelog: homebrew guarded by default --- docs/branch-changelog.md | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/branch-changelog.md diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md new file mode 100644 index 000000000..759221e40 --- /dev/null +++ b/docs/branch-changelog.md @@ -0,0 +1,41 @@ + + +# Branch changelog — `feature/guarded-homebrew` + + + +## Why this branch exists + +Homebrew is protected only where code wraps itself in the guard (`homebrew_guard.cljc`). Code +that reads homebrew without the wrapper is unprotected, and nothing says so: grant-rows has +hooks that throw on malformed packs because they were written before the guard and never +wrapped. This branch makes the guarded path the only path: one registry that turns each +content type into options inside the guard, a check that fails the build when code reads +homebrew outside it, and a test that loads junk into every field of every content type. + +## Added + +## Changed + +## Fixed + +## Security From 05b953979c7538868a5072c1d9c8b3491808438c Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 20 Oct 2026 17:19:02 +0000 Subject: [PATCH 02/31] Measure what malformed homebrew crashes, and the design to stop it junk_pack_test takes one realistic entry of each of the 13 content types, puts each of 12 junk values in each field, and runs the pack through startup, every homebrew subscription, the deep check, and a character that picks each entry. The everyday suite runs the base pack only; test/e2e/junk-pack-audit.js runs everything (about 30 minutes while entries throw). On integration it finds 58 crashes, every one a field nothing checks the shape of, two of them at startup. docs/HOMEBREW_GUARD.md records them and proposes the fix: a shape gate in front of every read, one registered way to convert homebrew, and a test that fails when code reads homebrew any other way. --- docs/HOMEBREW_GUARD.md | 160 +++++ test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 255 +++++++ test/cljs/orcpub/test_runner.cljs | 2 + test/e2e/junk-pack-audit.js | 45 ++ test/fixtures/junk-bases.orcbrew | 746 ++++++++++++++++++++ 5 files changed, 1208 insertions(+) create mode 100644 docs/HOMEBREW_GUARD.md create mode 100644 test/cljs/orcpub/dnd/e5/junk_pack_test.cljs create mode 100644 test/e2e/junk-pack-audit.js create mode 100644 test/fixtures/junk-bases.orcbrew diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md new file mode 100644 index 000000000..f649411f2 --- /dev/null +++ b/docs/HOMEBREW_GUARD.md @@ -0,0 +1,160 @@ +# Homebrew guarded by default + +Status: **design, for review.** Branch `feature/guarded-homebrew`, off integration at `d51938c1`. + +## The problem in one paragraph + +Homebrew is untrusted: an imported or hand-edited pack can hold any shape in any field. The app +protects itself with a guard (`homebrew_guard.cljc`), but the guard is a wrapper that code has to +choose to use. Code that reads homebrew without it is unprotected, and nothing says so: no rule, +no lint, no test. That is how grant-rows ended up with hooks that throw on a malformed pack, and +how integration still has crashes nobody had measured. This branch makes the safe path the only +path, so new code is protected without anyone remembering to protect it. + +## How we got here + +- **2026-09-12, PR #34 (`986c25a4`).** The guard landed. It wrapped the seven places integration + turned homebrew into options. From integration's side that was everything. +- **The KB note** (`homebrew-safety-net.md`, on agents/develop) lists what is guarded and why + nothing is fully realized on the everyday path (about 2 s against 40 ms for 12 sources). It has + no rule that new code must use the guard. It is cited by code docstrings but is not on + integration. +- **The wrong-type sweep** that checked every field was a scratch script, never committed; the + note says its rerun "was cut short". Nothing has checked since. +- **grant-rows** wrote its pools and fighting-style hooks between June and 2026-09-08, before the + guard, on another branch. The merge only flagged conflicting lines, so the hooks stayed + unwrapped. + +## What exists today + +| Layer | Where | What it does | +|---|---|---| +| Load floor | `content_specs/valid-item-for-load?` | Keeps an entry if its key is a keyword and it has an `:option-pack` string. Nothing else is checked. | +| Repairs | `e5/mend-library` | Fixes a few known shapes: `:key`, `:traits`, a selection's `:options`, a spell's `:spell-lists`. | +| Guard | `guard-entry` at 7 sites | Catches a throw while one entry converts, sets the entry aside, says so. | +| Deep check | `homebrew_check/deep-check` | Converts and fully realizes 5 of 13 content types; after import, Restore, or a page crash. | +| Error boundaries | views, app root | Catch what throws while a page draws; only the app root runs the deep check. | + +Option builders return lazy sequences. A throw inside one surfaces later (when a page draws it or +a character is built), outside the guard. Modifier functions run only when a character is built, +where nothing can guard them. + +## Baseline: what crashes today + +`test/cljs/orcpub/dnd/e5/junk_pack_test.cljs` takes one realistic entry of each of the 13 content +types (`test/fixtures/junk-bases.orcbrew`), puts each of 12 junk values in each field, and runs +the pack through startup, every homebrew subscription, the deep check, and a character that picks +each entry. `node test/e2e/junk-pack-audit.js` runs all of it (the everyday suite runs only the +base pack, which passes). + +On integration `d51938c1`: **58 crashes**, every one a wrong shape in a field nothing checks. + +| Content | Field | Junk values that throw | Where it throws | +|---|---|---|---| +| any | the whole `classes` or `subclasses` section is `true` | 1 each | **startup**: the boot rescue sets the whole library aside | +| races | `:abilities` | 6 | building the race option list | +| races | `:languages` | 3 | building the race option list | +| races | `:spells` | 3 | after the guard, when the race is used | +| races | `:props :damage-resistance` | 5 | after the guard, when the race is used | +| subraces | `:abilities` | 6 | building the race option list | +| subraces | `:spells` | 3 | after the guard | +| subraces | `:props :weapon-prof` | 5 | after the guard | +| backgrounds | `:equipment-choices`, and its `:options` | 9 | building the background option list | +| backgrounds | `:profs :skill` | 6 | building the background option list | +| feats | `:prereqs` | 7 | building the feat option list | +| classes | `:ability-increase-levels` | 3 | the class list (all classes) | + +"Building the option list" is the template every character page uses, so each of those takes down +the character builder and sheets, not one entry. + +Found by reading the code, **not yet proven** by the audit (it does not reach them yet): + +- **Invocations and boons.** They are converted inside the built-in Warlock. `base-class-options` + catches any throw there and returns no classes at all, with no report: one bad invocation makes + all 12 classes vanish. +- **A subrace on a built-in race** is blamed on the race, so the built-in race is what disappears. +- **Monsters:** the monster list and its filter sort and lower-case names unguarded. +- **My Content** sorts each section; a section that is not a map takes the page down. +- **Shared-link content** is converted the same way but never repaired, and cannot be set aside. +- **`LOAD_HOMEBREW_URL`** writes fetched text straight to storage, skipping import validation. +- `compute/compute-plugin-vals` is a second, drifted copy of the plugin read, with no callers. + +On feature/grant-rows the same audit is expected to add the pool hooks (fighting styles, draconic +ancestry, grant pools), the AC and weapon `:props`, and grant `:order`; to be run there after +this lands. + +## Why per-site fixes keep failing + +Every crash above has the same cause: **code reads a field and assumes its shape.** Guarding each +read site is what we have done, and it misses the next one. Realizing everything inside the guard +costs about 50 times more and still cannot reach modifier functions. Two things fix the class of +bug instead of the instance: + +1. Make every field the right shape **before** any code reads it. +2. Give code exactly one way to read homebrew, and fail the build when it reads it another way. + +## The design + +### 1. A shape gate in front of everything + +- A new `homebrew_shapes.cljc` declares, per content type, every field the app reads and the + shape it must have, as data: `{:abilities ability-map, :spells spell-list, :profs {...}}`. + Nested shapes are declared the same way, down to the depth the builders read. +- `readable` takes a library and returns it with every field that does not match its shape + removed, plus a list of what was removed. Fields nobody reads pass through untouched. +- It runs at **read**, not in storage: stored data is never changed, so an author can still open + the entry in its builder and fix it. The builder says which fields cannot be read, the way the + ability-increase forms do now. +- Startup, the subscriptions, the deep check, shared content and events all read through + `readable`. Memoized on the library, so it costs one pass per change. + +This alone removes every crash in the baseline: none of them is reachable once the field has the +right shape. + +### 2. One door for conversion + +- A registry: each content type registers its converter once (`register-content-type!`), in a leaf + namespace with no subscription dependencies (the load-order trap in + entity-options-architecture.md). +- The converted options come from one parameterized subscription per type, + `[::homebrew/options content-type]`, fed only by that type's readable entries. That keeps the + per-type firewall: editing a spell does not rebuild races. +- Every conversion runs inside the guard. The deep check runs the same registered converters, so it + cannot drift from the live path (on grant-rows it already has: the races and feats checks pass an + argument in the wrong place and check nothing). +- All 13 types are registered, not 5. A subrace or subclass failure is blamed on itself, not its + parent. `base-class-options` reports what it catches instead of silently dropping every class. + +### 3. Enforcement + +- **A source-scan test**, modelled on `library_gate_test` and `export_paths_test`: every read of + raw homebrew (`::e5/plugins`, `::e5/plugin-vals`, `::e5/plugins-with-sources`, + `::e5/shared-plugins`, `(:plugins db)`) must be in the homebrew namespace or carry a marker + comment saying why it needs the raw data (storage, export, rename, quarantine). A new read + without one fails `lein test` with a message saying how to route it. +- **The junk audit in CI.** Once the baseline is zero, a sample of the grid joins the everyday + suite (it takes about a second per pack when nothing throws), and the full audit runs before a + merge to integration. +- **A shape for every field read.** The audit takes its fields from the base entries; a test also + checks that every field the shape gate declares appears in a base entry, so the two lists stay + together. +- **AGENTS.md** gets one rule: homebrew is read through `readable` and converted through the + registry; anything else needs a marker and a reason. + +## Order of work + +1. This document, and the baseline test (done). +2. The shape gate, field by field, with the audit going to zero on integration's content. +3. The registry and the per-type subscriptions; move the 7 guarded sites and the unguarded ones + onto it; the deep check reads the registry. +4. Startup, events, My Content, shared content and `LOAD_HOMEBREW_URL` read through `readable`. +5. The source-scan test and the CI audit; the AGENTS.md rule; the KB note brought to integration. +6. grant-rows merges this and registers its pools; its hooks get the same audit. + +## Open questions for the owner + +- **Remove, or set aside?** The gate removes an unreadable field and keeps the entry (a race with + junk `:spells` keeps its name, size and traits). The alternative is setting the whole entry + aside, which is what the guard does today. Recommended: remove the field, and say so. +- **Write the cleaned data back?** Recommended: no. Storage keeps what the author wrote; the + builder shows what cannot be read. diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs new file mode 100644 index 000000000..e23c79768 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -0,0 +1,255 @@ +(ns orcpub.dnd.e5.junk-pack-test + "Imported homebrew is untrusted: any field of any entry can hold any shape. One entry of each + content type (test/fixtures/junk-bases.orcbrew) gets junk in each field, and the pack goes + through what the app does with homebrew: startup, the homebrew in every subscription realized, + the deep check, and a character that picks each entry. An entry may be set aside; nothing may + throw. The everyday suite runs the base pack only; test/e2e/junk-pack-audit.js runs it all." + (:require [cljs.test :refer-macros [deftest is]] + [clojure.string :as str] + [re-frame.core :as rf] + [re-frame.db :refer [app-db]] + [orcpub.fixtures :refer-macros [fixture-text]] + [orcpub.entity :as entity] + [orcpub.template :as t] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.homebrew-check :as homebrew-check] + [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.backgrounds :as bg5e] + [orcpub.dnd.e5.languages :as langs5e] + [orcpub.dnd.e5.selections :as selections5e] + [orcpub.dnd.e5.races :as races5e] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.feats :as feats5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.monsters :as monsters5e] + [orcpub.dnd.e5.encounters :as encounters5e] + [orcpub.dnd.e5.character-drafts :as drafts] + [orcpub.dnd.e5.events] + [orcpub.dnd.e5.subs] + [orcpub.dnd.e5.equipment-subs] + [orcpub.dnd.e5.spell-subs])) + +(def ^:private source "Junk Pak") + +(def ^:private base-entries + (delay (get (:data (orcbrew-val/parse-edn (fixture-text "test/fixtures/junk-bases.orcbrew"))) + source))) + +(def ^:private junk [nil 42 "x" true :kw [] [42] ["x"] [{}] {} {:a 1} #{42}]) + +(defn- audit? + "Is this the full audit (test/e2e/junk-pack-audit.js)? It takes minutes, so the everyday suite + checks only that the base pack works." + [] + (boolean (re-find #"junk-audit" (str (.-search js/location))))) + +;; --------------------------------------------------------------------------------------------- +;; Cases: [content-type path value]. A path is [field] or [field index-or-key field]. + +(defn- field-paths [entry] + (let [skip #{:key :option-pack}] + (concat + (for [k (keys entry) :when (not (skip k))] [k]) + (for [[k v] entry :when (not (skip k)) + p (cond (map? v) (for [k2 (keys v)] [k k2]) + (and (sequential? v) (map? (first v))) (for [k2 (keys (first v))] [k 0 k2]) + :else nil)] + p)))) + +(defn- library + "The pack with `entries` ({content-type {key entry}}) as its only content." + [entries] + {source entries}) + +(defn- batch + "Every field of every base entry of `content-type` holding `v`, one entry per field." + [content-type v] + (let [base (get-in @base-entries [content-type :junk-base]) + paths (field-paths base)] + {:cases (into {} (map-indexed (fn [i p] [(keyword (str "junk-" i)) p]) paths)) + :entries {content-type + (into {} (map-indexed + (fn [i p] + (let [k (keyword (str "junk-" i))] + [k (assoc (assoc-in base p v) :key k :name (str "Junk " i))])) + paths))}})) + +;; --------------------------------------------------------------------------------------------- +;; What the app does with a pack + +(def ^:private homebrew-subs + [[::e5/plugin-vals] [::e5/plugins-with-sources] + [::bg5e/plugin-backgrounds] [::bg5e/backgrounds] + [::langs5e/plugin-languages] [::langs5e/languages] [::langs5e/language-map] + [::selections5e/plugin-selections] [::selections5e/selection-map] + [::races5e/plugin-races] [::races5e/plugin-subraces] [::races5e/plugin-subraces-map] + [::races5e/races] [::races5e/race-map] + [::classes5e/plugin-subclasses] [::classes5e/plugin-classes] [::classes5e/plugin-subclasses-map] + [::classes5e/classes] [::classes5e/class-map] + [::classes5e/plugin-invocations] [::classes5e/invocations] + [::classes5e/plugin-boons] [::classes5e/boons] + [::feats5e/plugin-feats] [::feats5e/feats] + [::spells5e/plugin-spells] [::spells5e/plugin-spells-map] [::spells5e/spells] + [::spells5e/spells-map] [::spells5e/plugin-spell-lists] [::spells5e/spell-lists] + [::monsters5e/plugin-monsters] [::monsters5e/monsters] [::monsters5e/monster-map] + [::monsters5e/sorted-monsters] + [::encounters5e/plugin-encounters] [::encounters5e/encounters] [::encounters5e/encounter-map] + [::char5e/template-selections] [::char5e/template]]) + +(defn- homebrew? + "Is `x` one of the pack's entries, or an option made from one?" + [x] + (and (map? x) + (or (= source (:option-pack x)) (= source (::t/plugin-source x)) + (let [k (or (::t/key x) (:key x))] + (and (keyword? k) (str/starts-with? (name k) "junk-")))))) + +(defn- realize-homebrew + "Realize the homebrew in a subscription's value and leave the built-in content alone: a template + or a list of selections realizes the homebrew options in each selection, and in the selections + of each option (a subrace sits under its race); a map or list realizes its homebrew items." + [v] + (let [selections (cond (::t/selections v) (::t/selections v) + (and (sequential? v) (some ::t/options v)) v) + items (fn [xs] (mapcat #(if (and (sequential? %) (not (map? %))) % [%]) xs))] + (cond + selections + (doseq [s selections + o (::t/options s) + :when (or (homebrew? o) (some homebrew? (mapcat ::t/options (::t/selections o))))] + (guard/realize o)) + (map? v) (guard/realize (filter homebrew? (items (vals v)))) + (sequential? v) (guard/realize (filter homebrew? (items v))) + :else (guard/realize v)))) + +(defn- msg [e] (first (str/split-lines (or (ex-message e) (str e))))) + +(defn- attempt [stage f] + (try (f) nil (catch :default e {:stage stage :error (msg e)}))) + +(def ^:private stored-character + {::entity/options {:race {::entity/key :human} + :class [{::entity/key :fighter + ::entity/options {:levels [{::entity/key :level-1}]}}]}}) + +(defn- levels [n] (vec (for [i (range 1 (inc n))] {::entity/key (keyword (str "level-" i))}))) + +(defn- seeds + "Characters to start from for each homebrew entry: a class or subclass needs levels to offer its + choices; anything else starts from the stored character." + [entries] + (let [section #(let [v (get entries %)] (when (map? v) (filter (comp map? val) v))) + leveled (fn [cls] {::entity/options {:race {::entity/key :human} + :class [{::entity/key cls + ::entity/options {:levels (levels 20)}}]}})] + (concat + (for [[k _] (section ::e5/classes)] [k (leveled k)]) + (for [[k {cls :class}] (section ::e5/subclasses) :when (keyword? cls)] [k (leveled cls)]) + (for [t [::e5/races ::e5/subraces ::e5/backgrounds ::e5/feats] + [k _] (section t)] + [k stored-character])))) + +(defn- offered + "The selections `c` is offered, and `c` built." + [template c] + (let [built (entity/build c template)] + [built (entity/combine-selections (entity/available-selections c built template))])) + +(defn- pick + "`c` with option `k` picked wherever it is offered and not yet picked, as the builder picks it, + repeated so a pick that offers it again (a subrace under a race) is followed." + [template c k] + (loop [c c i 0] + (let [[_ avail] (offered template c) + s (first (filter (fn [s] (and (some #(= k (::t/key %)) (::t/options s)) + (nil? (entity/get-option template c + (conj (entity/actual-path s) k))))) + avail))] + (if (or (nil? s) (> i 4)) + c + (recur (entity/update-option template c (conj (entity/actual-path s) k) + (fn [o] (if (::t/multiselect? s) + (conj (or o []) {::entity/key k}) + {::entity/key k}))) + (inc i)))))) + +(defn- use-entry! + "Pick homebrew entry `k` in `c`, build it, and draw what the builder draws under it: every + selection whose path runs through `k`, fully realized." + [template k c] + (let [c (pick template c k) + [built avail] (offered template c)] + (guard/realize (char5e/ability-values built)) + (guard/realize (filter #(some #{k} (entity/actual-path %)) avail)))) + +(defn- run-pack! + "Put `lib` in storage, start the app on it, and use it. Returns the failures, each + {:stage :error}; an entry set aside is not a failure." + [lib] + (doseq [k (drafts/draft-keys)] (drafts/remove-draft! k)) + (doseq [k ["plugins" "plugins:rejected" "plugins:corrupt" "character"]] + (.removeItem js/localStorage k)) + (.setItem js/localStorage "plugins" (pr-str lib)) + (.setItem js/localStorage "character" (pr-str (char5e/to-strict stored-character))) + (reset! app-db {}) + (rf/clear-subscription-cache!) + ;; a report means the entry is set aside, which is allowed; drop it rather than queue the event + (guard/call-with-reporter + (constantly nil) + (fn [] + (let [boot (attempt :startup #(rf/dispatch-sync [:initialize-db])) + sub-fails (keep (fn [q] (attempt q #(realize-homebrew @(rf/subscribe q)))) + homebrew-subs) + deep (attempt :deep-check #(homebrew-check/deep-check nil)) + template (try @(rf/subscribe [::char5e/template]) (catch :default _ nil)) + builds (when template + (keep (fn [[k c]] (attempt [:character k] #(use-entry! template k c))) + (cons [nil stored-character] + (seeds (get (get-in @app-db [:plugins]) source)))))] + (doall (remove nil? (concat [boot] sub-fails [deep] builds))))))) + +(defn- cleanup! [] + (doseq [k ["plugins" "plugins:rejected" "plugins:corrupt" "character"]] + (.removeItem js/localStorage k)) + (reset! app-db {}) + (rf/clear-subscription-cache!)) + +;; --------------------------------------------------------------------------------------------- + +(deftest the-base-pack-works + (let [failures (run-pack! (library @base-entries))] + (cleanup!) + (is (empty? failures) (pr-str failures)))) + +(deftest junk-in-any-field-never-throws + (let [failures + (doall + (for [content-type (when (audit?) (keys @base-entries)) + v junk + :let [{:keys [cases entries]} (batch content-type v) + fs (run-pack! (library entries))] + :when (seq fs) + ;; one pack per value holds every field: name the field by running each alone + [k path] cases + :let [single (run-pack! (library {content-type + {k (get-in entries [content-type k])}}))] + :when (seq single)] + [(name content-type) path v (first single)]))] + (cleanup!) + (doseq [f failures] (js/console.log "JUNKFAIL" (pr-str f))) + (is (empty? failures) + (str (count failures) " failures\n" (str/join "\n" (map pr-str failures)))))) + +(deftest a-junk-section-never-throws + (let [failures (doall + (for [content-type (when (audit?) (keys @base-entries)) + v [42 "x" true [] [42] #{42}] + :let [fs (run-pack! (library {content-type v}))] + :when (seq fs)] + [(name content-type) v (first fs)]))] + (cleanup!) + (doseq [f failures] (js/console.log "JUNKFAIL" (pr-str f))) + (is (empty? failures) + (str (count failures) " failures\n" (str/join "\n" (map pr-str failures)))))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index f9dd3fc02..736903cba 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -35,6 +35,7 @@ [orcpub.dnd.e5.homebrew-save-lifecycle-test] [orcpub.dnd.e5.plan-picks-test] [orcpub.dnd.e5.coverage-pak-test] + [orcpub.dnd.e5.junk-pack-test] [orcpub.dnd.e5.reference-web-test] [orcpub.dnd.e5.views-test] [orcpub.dnd.e5.portrait-credit-thumb-test] @@ -82,6 +83,7 @@ 'orcpub.dnd.e5.homebrew-save-lifecycle-test 'orcpub.dnd.e5.plan-picks-test 'orcpub.dnd.e5.coverage-pak-test + 'orcpub.dnd.e5.junk-pack-test 'orcpub.dnd.e5.reference-web-test 'orcpub.dnd.e5.views-test 'orcpub.dnd.e5.portrait-credit-thumb-test diff --git a/test/e2e/junk-pack-audit.js b/test/e2e/junk-pack-audit.js new file mode 100644 index 000000000..432b092a7 --- /dev/null +++ b/test/e2e/junk-pack-audit.js @@ -0,0 +1,45 @@ +// The full junk-pack audit: orcpub.dnd.e5.junk-pack-test with every content type, field and junk +// value (the everyday suite runs only its base-pack check). Takes minutes, longer while entries +// still throw, because each failing pack is rerun one field at a time to name the field. +// +// lein fig:test && NODE_PATH=/opt/node22/lib/node_modules node test/e2e/junk-pack-audit.js +// Prints one JUNKFAIL line per failure and writes them to target/test/junk-audit.txt. +// Exit 0 = nothing threw. +const http = require('http'), fs = require('fs'), path = require('path'); +const { chromium } = require('playwright'); +const { findChrome } = require('../browser/lib/find-chrome'); + +const ROOT = path.resolve('target/test'); +const RUNNER = '
'; +const srv = http.createServer((q, r) => { + const u = decodeURIComponent(q.url.split('?')[0]); + const f = path.join(ROOT, u === '/' ? 'runner-all.html' : u); + if (f === path.join(ROOT, 'runner-all.html') && !fs.existsSync(f)) { + r.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return r.end(RUNNER); + } + if (!f.startsWith(ROOT) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { r.writeHead(404); return r.end(); } + r.writeHead(200, { 'Content-Type': f.endsWith('.js') ? 'application/javascript; charset=utf-8' : 'text/html; charset=utf-8' }); + fs.createReadStream(f).pipe(r); +}); + +(async () => { + await new Promise(r => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + const br = await chromium.launch({ executablePath: findChrome() }); + const pg = await br.newPage(); + const out = []; + pg.on('console', m => out.push(m.text())); + pg.on('pageerror', e => out.push('PAGEERROR ' + e)); + // the tests run while the page loads, so wait for the first byte only + await pg.goto(`http://localhost:${port}/runner-all.html?junk-audit`, { waitUntil: 'commit', timeout: 600000 }); + let done = true; + try { + await pg.waitForFunction(() => /Totals\s+\d+ Tests/.test(document.body.innerText), null, { timeout: 7200000 }); + } catch (e) { done = false; } + const fails = out.filter(l => l.startsWith('JUNKFAIL ')).map(l => l.slice(9)); + fails.forEach(f => console.log('JUNKFAIL ' + f)); + fs.writeFileSync('target/test/junk-audit.txt', fails.join('\n') + '\n'); + console.log(done ? `${fails.length} failure(s)` : 'TIMEOUT before the suite finished'); + process.exitCode = done && fails.length === 0 ? 0 : 1; + await br.close(); srv.close(); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/fixtures/junk-bases.orcbrew b/test/fixtures/junk-bases.orcbrew new file mode 100644 index 000000000..1de87d542 --- /dev/null +++ b/test/fixtures/junk-bases.orcbrew @@ -0,0 +1,746 @@ +{"Junk Pak" + #:orcpub.dnd.e5{:boons + {:junk-base + {:name "Junk boons", + :description "You gain junk.", + :key :junk-base, + :option-pack "Junk Pak"}}, + :languages + {:junk-base + {:name "Junk languages", + :description "Spoken by junk.", + :key :junk-base, + :option-pack "Junk Pak"}}, + :classes + {:junk-base + {:key :junk-base, + :level-modifiers + [{:type :weapon-prof, :value :crossbow-light} + {:type :weapon-prof, :value :dart} + {:type :weapon-prof, :value :sling} + {:type :weapon-prof, :value :dagger} + {:type :weapon-prof, :value :quarterstaff}], + :name "Junk classes", + :option-pack "Junk Pak", + :subclass-title "Cause Plain", + :level-selections + [{:type :metamagic, :num 2, :level 3} + {:type :metamagic, :level 10} + {:type :metamagic, :level 17}], + :spellcasting + {:level-factor 1, + :known-mode :schedule, + :ability :orcpub.dnd.e5.character/cha, + :spells-known + {7 1, + 1 2, + 4 1, + 15 1, + 13 1, + 6 1, + 17 1, + 3 1, + 2 1, + 11 1, + 9 1, + 5 1, + 10 1, + 8 1}, + :cantrips? true, + :cantrips-known {1 4, 4 1, 10 1}, + :spell-list + {0 + #{:shape-water + :dancing-lights + :ray-of-frost + :acid-splash + :mage-hand + :lightning-lure + :toll-the-dead + :frostbite + :booming-blade + :minor-illusion + :thunderclap + :fire-bolt + :guidance + :word-of-radiance + :mending + :sacred-flame + :resistance + :shocking-grasp + :control-flames + :friends + :virtue + :chill-touch + :true-strike + :create-bonfire + :spare-the-dying + :mold-earth + :poison-spray + :sword-burst + :hand-of-radiance + :light + :blade-ward + :thaumaturgy + :prestidigitation + :gust + :infestation + :message + :green-flame-blade}, + 7 + #{:whirlwind + :crown-of-stars + :power-word-pain + :prismatic-spray + :conjure-celestial + :symbol + :divine-word + :temple-of-the-gods + :regenerate + :delayed-blast-fireball + :plane-shift + :reverse-gravity + :etherealness + :teleport + :fire-storm + :finger-of-death + :resurrection}, + 1 + #{:thunderwave + :create-or-destroy-water + :guiding-hand + :ray-of-sickness + :detect-magic + :earth-tremor + :shield + :purify-food-and-drink + :feather-fall + :detect-evil-and-good + :ice-knife + :magic-missile + :protection-from-evil-and-good + :witch-bolt + :command + :fog-cloud + :chaos-bolt + :bless + :sudden-awakening + :comprehend-languages + :bane + :disguise-self + :shield-of-faith + :inflict-wounds + :false-life + :healing-word + :expeditious-retreat + :ceremony + :absorb-elements + :charm-person + :catapult + :guiding-bolt + :sanctuary + :detect-poison-and-disease + :cure-wounds + :mage-armor + :burning-hands + :sleep + :color-spray + :jump + :silent-image + :chromatic-orb}, + 4 + #{:dimension-door + :ice-storm + :confusion + :blight + :dominate-beast + :banishment + :polymorph + :storm-sphere + :locate-creature + :sickening-radiance + :control-water + :watery-sphere + :wall-of-fire + :charm-monster + :divination + :freedom-of-movement + :vitriolic-sphere + :guardian-of-faith + :greater-invisibility + :death-ward + :stone-shape + :stoneskin}, + 6 + #{:move-earth + :planar-ally + :globe-of-invulnerability + :chain-lightning + :mass-suggestion + :sunbeam + :investiture-of-ice + :forbiddance + :harm + :blade-barrier + :scatter + :arcane-gate + :true-seeing + :find-the-path + :heroes-feast + :mental-prison + :heal + :disintegrate + :word-of-recall + :investiture-of-wind + :investiture-of-stone + :eyebite + :investiture-of-flame + :create-undead + :circle-of-death}, + 3 + #{:animate-dead + :daylight + :sending + :counterspell + :thorn-s-minute-meteors + :blink + :sleet-storm + :water-breathing + :stinking-cloud + :remove-curse + :clairvoyance + :dispel-magic + :create-food-and-water + :spirit-guardians + :mass-healing-word + :hypnotic-pattern + :catnap + :feign-death + :meld-into-stone + :glyph-of-warding + :haste + :life-transference + :fly + :tongues + :revivify + :magic-circle + :major-image + :slow + :wall-of-water + :tidal-wave + :enemies-abound + :lightning-bolt + :protection-from-energy + :flame-arrows + :fireball + :bestow-curse + :speak-with-dead + :erupting-earth + :beacon-of-hope + :water-walk + :fear + :thunder-step + :gaseous-form}, + 2 + #{:crown-of-madness + :augury + :calm-emotions + :spiritual-weapon + :gentle-repose + :blindness-deafness + :prayer-of-healing + :mind-spike + :dust-devil + :scorching-ray + :zone-of-truth + :shadow-blade + :silence + :protection-from-poison + :continual-flame + :enhance-ability + :lesser-restoration + :enlarge-reduce + :shatter + :darkvision + :dragon-s-breath + :alter-self + :suggestion + :see-invisibility + :detect-thoughts + :levitate + :cinder-s-earthen-grasp + :mirror-image + :cloud-of-daggers + :phantasmal-force + :warding-bond + :find-traps + :blur + :pyrotechnics + :ember-s-scorcher + :invisibility + :earthbind + :warding-wind + :locate-animals-or-plants + :aid + :hold-person + :gust-of-wind + :spider-climb + :darkness + :knock + :frost-s-snowball-swarm + :web + :misty-step}, + 9 + #{:meteor-swarm + :astral-projection + :power-word-kill + :wish + :time-stop + :mass-heal + :psychic-scream + :gate + :true-resurrection + :mass-polymorph}, + 5 + #{:dawn + :creation + :greater-restoration + :seeming + :cloudkill + :animate-objects + :enervation + :mass-cure-wounds + :commune + :wall-of-light + :dispel-evil-and-good + :raise-dead + :dominate-person + :control-winds + :insect-plague + :synaptic-static + :cone-of-cold + :hold-monster + :teleportation-circle + :planar-binding + :flame-strike + :legend-lore + :hallow + :holy-weapon + :wall-of-stone + :telekinesis + :contagion + :scrying + :immolation + :geas + :far-step + :skill-empowerment}, + 8 + #{:hollows-horrid-wilting + :control-weather + :holy-aura + :dominate-monster + :earthquake + :sunburst + :power-word-stun + :antimagic-field + :incendiary-cloud}}}, + :ability-increase-levels [4 8 12 16 19], + :profs + {:save + #:orcpub.dnd.e5.character{:cha true, :con true}, + :skill-expertise-options {:choose 0, :options {}}, + :skill-options + {:choose 2, + :options + {:arcana true, + :deception true, + :insight true, + :intimidation true, + :persuasion true, + :religion true}}}, + :hit-die 6, + :traits + [{:name "Cliff Storm", + :description + "Thread travel beyond attention glass fortune reason bold shore boat north shade call. Below island patient close dust sun almost follow form along cover castle yard. Tower beneath willow letter cause follow away both company storm ancient deep below another brown gentle. Attention harbor over wave letter reed gentle ocean above reed early. Light fortune salt mist river tree ask mind deep word brown fair appear meadow. Share color field plain wide tower valley south across cold apple figure. Brave bone body company deep cause. Brown distance shell cloth crowd metal candle frost peace praise follow east deep river circle. Back night thread keep answer around honor ancient desert attention mind glass city bear against letter ocean. Call trade field both road harbor almost after broad bird alone. Cloud edge castle call course close wood. Castle early beauty ground appear south dark kind shape climb. Distance flow water custom city frost forest flow brown brown. Cover wind country become arrive fire course book wide ocean every tree. Trade able glass end distance ancient. Both stream pine copper long guard before hand. Again hollow leaf bone boat clean empty. Coast bone long rock about table harvest dance fortune drift harbor fortune east around branch. Song measure dance open again.", + :level 2} + {:name "Small Door", + :level 3, + :description + "Back wind long deep east empty root clean gather. Distance dust reed pine crown copper wind sun. Clear burn near hill light mirror cliff smooth pool ground. East number country brown journey rock crown willow attention road company letter mind figure willow flight. Open land apple frost river able."} + {:name "True Along Letter", + :level 20, + :description + "Reason measure craft alone blue road forest number early color build broad dawn slope. Word grain."} + {:description + "Land begin shore word company green course honor word rain grain summer small quiet gather become. Along bring meadow follow dawn flow. Brown chance ocean burn stream rain lake back silver close around course young crown moon change climb. Over grain above flight over plate flow wide loose morning spark broad crowd. Become black dream along border brown burn journey rain harbor sky carry narrow autumn. Moon year table loose loose frost ask about honor brown circle island burn wind. Answer grass path bright calm choose corner land morning enough grove bold effort simple apple guard. Cloth land any morning order back any branch voyage count simple harvest. Brown being small gather copper cloth drift hill ground clear away earth shadow measure. Branch journey tide rain child break peace small branch sail choose arrive distance honor true. Iron art high willow shore thread attention hand yellow candle pine.", + :name "Alone Already Dance"} + {:description + "Tree land pine bind young bear letter art beyond round another shore young reason. Sail all cross dark first arrive silver attention art shade willow night hollow both shore brother open. Path true shore early already begin winter quiet bird root shade alone leaf salt feather clay spark. Company empty wood road feather.", + :name "Lake"} + {:description + "Every clay almost cross follow frost cover light become art smooth loose metal. Morning notice soft wood grove young dawn sand metal beyond pool another line. Mind fortune below simple call hill shape enough behind paper brown song crown pool distance above. Black cloud company south shell word garden cloth book slope long mountain metal. Valley winter cloth border east trade rock against.", + :name "Wide Among", + :level 6} + {:description + "Rock gather smooth brown grain pine measure mirror. Edge coast wind blue care mist able another small shape salt art crowd kind figure willow. Bring follow color mountain being already color. Almost around along journey long stream river. Against bird tree forest forest flight common every brother river also bear brother. Sail bread rain land bird begin. Mist spark field plate candle iron tide answer share line high care. Frost river appear open before able west road border journey patient weather pale.", + :name "Able", + :level 14} + {:name "Island Bird", + :description + "Fall year clear broad bird calm effort black reason soft tower sun beyond. Away journey border count alone sun. Flow break bring road flame body tree blue company chance gentle. Number mirror small east company ocean enough honor soft almost already young brave measure across rock. Brave patient open silent become song letter appear bind cross sail.", + :level 18}]}}, + :subclasses + {:junk-base + {:key :junk-base, + :level-modifiers + [{:type :armor-prof, :value :heavy}], + :name "Junk subclasses", + :option-pack "Junk Pak", + :cleric-spells + {1 {0 :speak-with-animals, 1 :animal-friendship}, + 2 {0 :barkskin, 1 :spike-growth}, + 3 {0 :plant-growth, 1 :wind-wall}, + 4 {0 :dominate-beast, 1 :grasping-vine}, + 5 {0 :insect-plague, 1 :tree-stride}}, + :level-selections [{:type :druid-cantrips}], + :class :cleric, + :profs + {:skill-options + {:options + {:animal-handling true, + :nature true, + :survival true}}}, + :traits + [{:description + "Yard bear always broad kind frost smooth color forest craft river hollow shore keep. Common desert share dawn word another pine call bind carry attention form high early round.", + :name "Deep Gather Boat"} + {:description + "Meadow appear harvest candle morning yellow color leaf body around shore voyage dream candle. South carry grain sky travel guard heart almost before all far soft break wave common weather keep. Edge glass journey trade root above keep. Always shadow spark answer build cloud sand flow root stream figure carry pool dream. Cliff high number craft dark bind path fire among. Shade moment valley dawn change bank table flight choose. Sand wide wind shore metal pine begin distance. Company fire open north border copper shore grain.", + :level 2, + :name "Line Boat Corner"} + {:description + "Brother light early company attention change dawn beauty always. Shore moon clear feather beauty north being paper travel country. Dark cover frost autumn behind beneath among door circle reed. Brave course mind crowd follow sky bank wide circle first.", + :level 6, + :name "Among Another"} + {:description + "Cold begin brown leaf over become metal south silent guard about around bring bread cloth shade. Copper letter sand broad heart begin share drift honor before. Desert near island dance tide color all child any season travel order branch season close shell field. Book shell mind heart gather crown among order letter. Bread morning east flow rock praise reason root cross.", + :level 8, + :name "Young"} + {:description + "Reed mist round evening beneath dust along silent dance shore. Border dance iron rock horizon wood road wind current. Always boat wide arrive sky gather keep all care chance count true hollow iron quiet timber appear. Narrow high near branch able again art child burn morning beyond.", + :level 17, + :name "Begin City"} + {:description + "Dream able before smooth pine moon any summer light.", + :name "Attention"}]}}, + :monsters + {:junk-base + {:senses "darkvision 60 ft.", + :armor-notes "leather armor, shield", + :key :junk-base, + :int 10, + :speed "30 ft.", + :name "Junk monsters", + :alignment "neutral evil", + :cha 8, + :hit-points + {:mean 7, :die-count 2, :die 6, :modifier 0}, + :type :humanoid, + :actions + [{:name "Scimitar", + :description "Melee Weapon Attack: +4 to hit."}], + :size :small, + :option-pack "Junk Pak", + :armor-class 15, + :skills {:stealth 6}, + :str 8, + :saving-throws {:dex 4}, + :challenge 0.25, + :con 10, + :dex 14, + :wis 8, + :languages "Common", + :subtypes #{:goblinoid}, + :traits + [{:name "Nimble Escape", + :description + "Disengage or Hide as a bonus action."}]}}, + :feats + {:junk-base + {:ability-increases #{}, + :prereqs #{}, + :option-pack "Junk Pak", + :name "Junk feats", + :description + "Plain north beauty sail among water peace close. Hill west burn crowd clean beneath broad reed able bear enough climb. Care quiet flame voyage metal ground number smooth water keep current color ground narrow. Art garden soft timber grass pine country. Valley beyond wave bird ocean break harbor. East west bear leaf forest evening wide any being. Choose common cloth evening hill company behind deep. Moon wave tide round yellow weather carry climb line year weather east. Book clear brother mind mind wind word pine share.", + :props {:language-choice 1, :skill-tool-choice 2}, + :key :junk-base, + :path-prereqs + {:race + {:half-elf-mark-of-detection- true, + :human-mark-of-making- true, + :half-orc true, + :human-mark-of-sentinel- true, + :half-elf-mark-of-storm- true, + :human true, + :half-orc-mark-of-finding- true, + :human-mark-of-finding- true, + :human-mark-of-passage- true, + :human-mark-of-handling- true, + :half-elf true}}}}, + :backgrounds + {:junk-base + {:key :junk-base, + :name "Junk backgrounds", + :equipment {:clothes-traveler-s 1, :pouch 1}, + :treasure {:gp 5}, + :equipment-choices + [{:name "Hill", + :options + {:flute 1, + :horn 1, + :dulcimer 1, + :viol 1, + :drum 1, + :pan-flute 1, + :bagpipes 1, + :shawm 1, + :lute 1, + :lyre 1}}], + :option-pack "Junk Pak", + :help + "Beyond figure guard letter flow apple order always meadow flow choose clay course tree. Garden carry sail cold night almost night common carry wind.", + :profs + {:language-options + {:choose 1, :options {:any true}}, + :skill {:insight true, :perception true}, + :tool-options + {:gaming-set 1, :musical-instrument 1}}, + :traits + [{:name "Slope", + :summary + "Bind first table edge patient bind morning praise. Storm break gather appear beneath beneath near fair always bright almost bank tree plate table winter. Below voyage shell course all edge field clay sand appear road cloth timber. Again order edge brave ask beauty. Leaf hand distance cross common after book leaf carry number count island blue journey. Crown beauty metal kind road garden root boat north change always river cloud. Being season appear above high metal timber high tide arrive tower corner willow before drift order copper. Sail again clean cold high north fair child dance harvest. Brown cold bread meadow first break.", + :description + "Dawn also stream arrive peace form yard cloth far harbor thread field. Flame pale mist moon voyage ancient garden patient clay wide song brown wide form dream. Form drift cross build sail tide forest. Dark balance reed grass earth form bright every dawn almost share. Praise hollow tide fall bank gentle kind below beauty. Change frost follow coast loose bear form around root. Moon meadow moon far silver about across road willow country another heart. Apple yellow pine kind hill flow spring water autumn weather grass reed spring already reason spring. After young chance bold end end sail care small bread deep below."} + {:name "Autumn", + :description + "Castle across form letter land beauty enough below pool stone pool. Smooth all grove mind meadow body patient year grass desert form young soft grain corner able fortune. Far cloth empty boat lake hollow notice edge shade begin gather follow peace empty yard fall yard. Course appear after end frost around alone deep sky frost feather long copper first garden. Arrive river autumn measure clear island soft small willow word young. Cloud weather slope reed across."}]}}, + :invocations + {:junk-base + {:option-pack "Junk Pak", + :description + "Song land island almost common beauty near valley about pool field calm. Dance shadow thread craft mist true broad smooth spring brave bind carry desert dawn. End mist ancient branch summer summer road light evening grass wind burn. Distance garden cloth chance grove pool willow beauty boat brown become empty shore grain burn call pool. Clear company water figure cover bring morning all moment salt pale. Across honor south rain among common cause share song begin sky. Meadow notice reason below mirror across wave willow also keep bear ancient being hill another. Earth south.", + :name "Junk invocations", + :key :junk-base}}, + :subraces + {:junk-base + {:key :junk-base, + :race :elf, + :name "Junk subraces", + :darkvision 120, + :spells + [{:value + {:ability :cha, :key :dancing-lights, :level 0}} + {:level 3, + :value + {:ability :cha, :key :faerie-fire, :level 1}} + {:level 5, + :value + {:ability :cha, :key :darkness, :level 2}}], + :abilities #:orcpub.dnd.e5.character{:cha 1}, + :option-pack "Junk Pak", + :props + {:weapon-prof + {:crossbow-hand true, + :rapier true, + :shortbow false, + :shortsword true}}, + :traits + [{:description + "About ocean letter over letter meadow about candle edge hollow also kind corner. Flame custom body castle cause change bread. Paper east north cloud black early brave slope around country quiet. Grain apple.", + :name "Shape Pine Small"} + {:description + "Feather against count all apple course cover young.", + :name "Ancient"} + {:description + "Share number sun border praise answer feather away effort climb wood. Simple song mirror ocean arrive arrive cloth child honor. Moon brown country care also clay form attention. Bind custom city road spark fire attention hill loose book earth cold busy calm sail. Door black form branch attention long again glass fair. Moon bear island among sail carry round hill against.", + :name "Yard"} + {:description + "Bring along sand body child drift crown hand voyage.", + :name "Ground Shell"}]}}, + :spells + {:junk-base + {:description + "Frost paper brown sand glass voyage mountain country earth grove city attention art. Tide wide harbor flight smooth call sail true hollow storm any root autumn green storm fall. Body path metal wind flight sail reason chance field. Bright count coast build spark brave. Broad reed dream being brother morning catch almost edge common word follow border journey. Already weather land path reason number cliff soft shade reason gentle child timber shadow shore. Stream carry burn yard empty small root willow common pine number. Open spring number black apple weather cover child young moment circle alone. Reed cloud back both timber gentle apple cloth chance road pine tide cold north. Cloud again feather above reed copper travel being dream. Distance loose city spark voyage cross quiet hollow glass wind shell build gather balance yellow shell. Every open dust bread bold thread island ask first quiet early bread share slope away kind. Below river dust country desert fair early stone moon sail body land young ground path. Chance boat across flame already cause bank carry empty north. Root grain land circle hollow flame black carry corner door distance both another. Fortune glass bird border road below. Crown wide grain child ask field slope frost honor. Measure long border spring hollow enough order point table river cold. Sun about chance clean horizon above timber castle burn. Around custom guard reed boat choose plate dawn fall river cloth. Clay among broad praise climb word about river. Smooth reason west country.", + :key :junk-base, + :school "abjuration", + :name "Junk spells", + :duration "Instantaneous", + :level 1, + :option-pack "Junk Pak", + :components + {:material-component + "also wave table dust always burn glass rain also carry", + :verbal true, + :somatic true, + :material true}, + :ritual true, + :edit-event + [:orcpub.dnd.e5.spells/edit-spell + {:description + "Craft shade drift plate cliff summer rock meadow against cloth clear line above far harvest travel. Small ground bring south calm apple quiet small beyond garden salt. Road chance keep west harvest notice silent any long clay silent. Path evening voyage enough road boat carry dream able custom silent wood measure both clear dream. Rock keep near early mist grain summer rock near. Salt every soft season rain coast cold castle door border patient distance harbor close. Any catch almost deep hill peace water first branch. Long light voyage glass gentle green effort bird number pool salt mountain. Calm shore share narrow chance company wave. Company change notice spark paper road green against among beneath bring catch. Storm fall moon leaf bind figure water away another answer iron. Away keep dark chance cause wood already honor evening land before winter guard before. Cross guard blue kind above custom being. Cliff hill bank metal reed shore land alone. Grove sky clear green early any spring across. Grove voyage word forest dance able beyond. Young light bear corner keep chance voyage current catch. Course light both reed call flight cliff. Glass along bank both catch craft every yellow end number praise effort stone balance word sand. Crown wave spring reason fortune clay early tower grove fair point autumn wave stream gather number change. Salt spark bank long country north reason flame carry spark sand honor bank. Light winter end body grass catch metal harbor root build weather circle castle.", + :key :ceremony, + :school "abjuration", + :name "Weather Begin", + :duration "Instantaneous", + :level 1, + :option-pack "Source Collection 09", + :components + {:material-component + "yard almost cover copper before notice mountain among soft distance", + :verbal true, + :somatic true, + :material true}, + :casting-time "1 hour", + :spell-lists {:cleric true, :paladin true}, + :range "Touch"}], + :casting-time "1 hour (ritual)", + :spell-lists {:cleric true, :paladin true}, + :range "Touch"}}, + :encounters + {:junk-base + {:name "Junk encounters", + :creatures + [{:type :monster, + :creature {:monster :goblin, :num 3}}], + :key :junk-base, + :option-pack "Junk Pak"}}, + :selections + {:junk-base + {:options + [{:name "Pine", + :description + "Honor among hollow among choose morning spring about back clay crown bird small table shape mountain open. Break moment path high silent water fire rock flight horizon honor above tide count cause."} + {:name "Meadow Build Year", + :description + "Wide bread north metal all early willow line horizon dream any back number. Trade field cross fall choose tide reed. Cause tower feather figure share harbor distance attention island silent desert. Above corner."} + {:name "Paper West", + :description + "Among plate song west sun art harbor close. Peace field attention leaf against river. Copper spring drift about flame light. Shore plain above moon heart grove timber begin shade brother metal busy long."} + {:description + "Shore sky color cause about branch mist book become slope glass east. Wide reason night smooth harvest count patient distance frost mind. Pale castle order point clear close light after castle.", + :name "Paper Fortune Also"} + {:description + "Current form choose plate quiet stream mirror behind around. Field company silver point back brother shape away close behind. Keep course salt city south salt. Letter guard shade dance letter peace leaf.", + :name "Earth"} + {:description + "Weather boat forest edge ground deep rain plain long feather feather beauty storm clay true against bird. Loose grain sun count answer door. Meadow both any grove sand common yellow clay bird.", + :name "Brown"} + {:description + "Cloud bold iron trade art enough line coast song young climb color land gather. Another edge word lake water flow wind copper against dream. Empty young alone grain storm bird hand.", + :name "Young"} + {:description + "Fortune alone become hand number point. Moment effort country begin tower paper south quiet. Grain water again brother around child. Song brown below yard crown open fair horizon custom against thread long catch.", + :name "Horizon"} + {:description + "Almost metal salt mind island dark season table river dream away paper shape near begin. Round reed again notice far sun rock company. Road alone letter below sand distance below harvest.", + :name "Distance Wood Distance"} + {:description + "Word open broad gather soft black garden night narrow spark point dark form crowd chance. Change order chance drift behind earth sun hand tide dream narrow meadow bread custom always. Early mountain garden.", + :name "Small Earth"} + {:description + "Garden meadow letter company brave flow count earth. Early bring shore long narrow pool. North enough guard path kind after blue already young. Pine again deep follow patient weather balance thread table long.", + :name "Fair Simple"} + {:description + "Heart flame open boat field beneath sky garden notice beauty crown course frost yard open. Against choose book enough cross also beneath rock castle willow timber border notice iron land. Long spark gentle.", + :name "Always Across"} + {:description + "Beneath dream moon sail word also dance. Yard yellow dawn year quiet shell wide shell wave change book. All hill sky grain plain wind measure. Yard wide against call fire attention fire city.", + :name "Bright"} + {:description + "Patient bear able river wood beneath ocean broad dawn ground bread cold. Common weather beauty across reason shape after. Bird bear any book near ancient enough distance. Art over art beneath among candle.", + :name "Table Storm Always"} + {:description + "Against iron ocean true slope heart bank build cross order across beneath. Pine desert rock also begin able cloth loose boat count cross hand sky cloth. Stream company bread plate fortune round grass.", + :name "Enough"} + {:name "Plate Young", + :description + "Common broad change always long follow cover west shadow wide moon cover also. Tower flow garden already child always morning among blue pale dream. Climb beauty behind shell border beyond after over shore."} + {:name "Smooth Behind Bread", + :description + "Weather paper forest become small kind dance alone shape field earth book sail about carry. Blue brown color beauty season point call apple hand metal. End circle call also end cloth. Along common."} + {:name "Cold Tide", + :description + "Boat always garden current shore table corner another smooth always company. Silent cold dark attention castle wind tide cross east build guard wood water alone. Alone gentle horizon iron among north across."} + {:name "Calm Mountain", + :description + "City shade tower able hill grass song order. Heart dark metal order west bank any among loose gather morning spark. Number shore coast letter feather copper beauty plain another city willow already."} + {:name "Bone Answer Grove", + :description + "Pool course garden crown cross always willow share. Fair coast cover near keep quiet balance mind plain moon. Stream fortune beyond enough shore field body art iron cloth along calm high among."} + {:name "Winter Forest", + :description + "Season soft border island above brown across gentle table reed line become. Bread book praise across after harbor above plate dance evening reason about summer journey wind. Answer dream forest almost calm boat."} + {:name "Above Field", + :description + "Cover bright become across count harvest over thread ground choose. Near honor true valley color bring. Water dust climb true color about fall night circle company high bold. Grain attention horizon gentle wave."} + {:name "Soft", + :description + "Gentle heart answer spark coast metal country evening first green grass hollow stream heart. Number order count bind dust cloud call above boat ocean lake dream tree enough. Balance carry narrow fortune."} + {:name "Far", + :description + "Narrow all ocean first early small. Evening weather bright stream hollow cloud door yellow. Paper plain catch away burn desert brave clear grass away paper metal year silent. Soft root shape."} + {:name "Drift Across", + :description + "Young every letter path cause bone fair glass mirror before travel feather cause border. End brave summer brother path beauty ocean tide field letter beneath west dawn east bind willow color. Table light."} + {:name "Order Shape Below", + :description + "Dark below crowd heart over over. Island blue beauty begin stone harvest narrow tree burn castle. Bird mind silver another begin letter storm being silver broad about. Cold meadow current timber."} + {:name "Clean", + :description + "Among hand leaf praise near copper quiet burn heart around keep gather before. Long chance sand weather table dream leaf cold round both bright call brave apple ground loose. Door climb attention."} + {:name "West Leaf Ask", + :description + "Bear silver high across branch crown copper follow brown summer. Bring cross shade any branch bread shell begin bread being break chance custom bear both hollow. True loose number rock flight away."} + {:name "Dust Round", + :description + "Dawn balance over near leaf silent south meadow already. Open able night cloud point wind both shore. Country flow true wood empty order slope fair blue company round iron kind. Across dust arrive."}], + :option-pack "Junk Pak", + :name "Junk selections", + :key :junk-base}}, + :races + {:junk-base + {:key :junk-base, + :speed 30, + :name "Junk races", + :darkvision 60, + :spells + [{:value + {:ability :orcpub.dnd.e5.character/cha, + :key :light}} + {:level 3, + :value + {:level 2, + :key :lesser-restoration, + :ability :orcpub.dnd.e5.character/cha}} + {:value + {:level 3, + :key :daylight, + :ability :orcpub.dnd.e5.character/cha}, + :level 5}], + :abilities + #:orcpub.dnd.e5.character{:wis 1, :cha 2}, + :size :medium, + :option-pack "Junk Pak", + :languages #{"Common" "Celestial"}, + :props + {:damage-resistance + {:necrotic true, :radiant true}}, + :traits + [{:description + "Night hollow door alone land yellow body. Branch valley grass open along clay. Course course road autumn road blue among pool autumn pool. Beauty smooth back notice beyond arrive border custom near dance travel song back circle. Mirror castle table broad gather sun evening. Close candle long ground thread hill round. Keep castle clear field coast narrow broad autumn first away moment evening being road forest. Mind answer over behind lake morning keep. Brother call ground.", + :name "Close"} + {:description + "City also copper quiet measure clear stream sun ask.", + :name "Carry"}]}}}} From 27c7af876d3f4d7f007a101f8be872da275af1d0 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 20 Oct 2026 22:57:31 +0000 Subject: [PATCH 03/31] The junk audit reaches pages, share links and imports, and counts silent drops Each pack now also arrives through the file importer and a share link, and is drawn: My Content, each content type's list, the monster list filtered by name, and each entry opened in its builder. A character picks each entry where the builder offers it (a subrace under its race, an invocation or boon on a Warlock); an entry never offered, and not set aside, is lost silently and fails. Built-in classes, races and backgrounds must all still be offered, and only the pack's own entries may be set aside. The audit runs one content type per page and writes each failure as it is found. --- test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 219 ++++++++++++++++---- test/e2e/junk-pack-audit.js | 51 +++-- 2 files changed, 208 insertions(+), 62 deletions(-) diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index e23c79768..6ac0a67c0 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -1,9 +1,10 @@ (ns orcpub.dnd.e5.junk-pack-test "Imported homebrew is untrusted: any field of any entry can hold any shape. One entry of each - content type (test/fixtures/junk-bases.orcbrew) gets junk in each field, and the pack goes - through what the app does with homebrew: startup, the homebrew in every subscription realized, - the deep check, and a character that picks each entry. An entry may be set aside; nothing may - throw. The everyday suite runs the base pack only; test/e2e/junk-pack-audit.js runs it all." + content type (test/fixtures/junk-bases.orcbrew) gets junk in each field, and the pack goes in + from storage, an import and a share link, then through startup, every subscription, the deep + check, a character picking each entry, and the pages that draw it. An entry may be set aside + (only that entry); nothing may throw and no built-in content may go missing. The everyday + suite runs the base pack only; test/e2e/junk-pack-audit.js runs it all." (:require [cljs.test :refer-macros [deftest is]] [clojure.string :as str] [re-frame.core :as rf] @@ -26,6 +27,7 @@ [orcpub.dnd.e5.monsters :as monsters5e] [orcpub.dnd.e5.encounters :as encounters5e] [orcpub.dnd.e5.character-drafts :as drafts] + [orcpub.dnd.e5.views :as views] [orcpub.dnd.e5.events] [orcpub.dnd.e5.subs] [orcpub.dnd.e5.equipment-subs] @@ -39,11 +41,23 @@ (def ^:private junk [nil 42 "x" true :kw [] [42] ["x"] [{}] {} {:a 1} #{42}]) -(defn- audit? - "Is this the full audit (test/e2e/junk-pack-audit.js)? It takes minutes, so the everyday suite - checks only that the base pack works." +(defn- audit-types + "The content types the audit covers: ?junk-audit=races,feats names some, ?junk-audit all of + them (test/e2e/junk-pack-audit.js runs one type per page). Without it, none: the grid takes + minutes, so the everyday suite checks only that the base pack works." [] - (boolean (re-find #"junk-audit" (str (.-search js/location))))) + (let [q (str (.-search js/location))] + (when-let [[_ named] (re-find #"junk-audit(?:=([a-z,]+))?" q)] + (if named + (filter (set (map #(keyword "orcpub.dnd.e5" %) (str/split named #","))) + (keys @base-entries)) + (keys @base-entries))))) + +(defn- report! + "Log a failure as it is found, so an audit cut short still has what it found." + [f] + (js/console.log "JUNKFAIL" (pr-str f)) + f) ;; --------------------------------------------------------------------------------------------- ;; Cases: [content-type path value]. A path is [field] or [field index-or-key field]. @@ -147,7 +161,13 @@ (concat (for [[k _] (section ::e5/classes)] [k (leveled k)]) (for [[k {cls :class}] (section ::e5/subclasses) :when (keyword? cls)] [k (leveled cls)]) - (for [t [::e5/races ::e5/subraces ::e5/backgrounds ::e5/feats] + (for [t [::e5/invocations ::e5/boons] [k _] (section t)] [k (leveled :warlock)]) + ;; a subrace is offered under its race, so start from that race + (for [[k {race :race}] (section ::e5/subraces)] + [k (if (keyword? race) + (assoc-in stored-character [::entity/options :race] {::entity/key race}) + stored-character)]) + (for [t [::e5/races ::e5/backgrounds ::e5/feats] [k _] (section t)] [k stored-character])))) @@ -163,9 +183,13 @@ [template c k] (loop [c c i 0] (let [[_ avail] (offered template c) + ;; a single choice holds one option (Race: Human), a multiple one a list of them + picked? (fn [s] (let [o (entity/get-option template c (conj (entity/actual-path s) k))] + (if (sequential? o) + (some #(= k (::entity/key %)) o) + (= k (::entity/key o))))) s (first (filter (fn [s] (and (some #(= k (::t/key %)) (::t/options s)) - (nil? (entity/get-option template c - (conj (entity/actual-path s) k))))) + (not (picked? s)))) avail))] (if (or (nil? s) (> i 4)) c @@ -177,38 +201,144 @@ (defn- use-entry! "Pick homebrew entry `k` in `c`, build it, and draw what the builder draws under it: every - selection whose path runs through `k`, fully realized." + selection whose path runs through `k`, fully realized. Throws when `k` is never offered." [template k c] (let [c (pick template c k) [built avail] (offered template c)] + (when (and k (not (some #{k} (tree-seq coll? seq c)))) + (throw (js/Error. "never offered"))) (guard/realize (char5e/ability-values built)) (guard/realize (filter #(some #{k} (entity/actual-path %)) avail)))) +(defn- expand + "Draw `x` the way Reagent would: a vector headed by a function is a child component, so call it + (twice for a component that returns its render function) and draw what it returns." + [x] + (cond (and (vector? x) (fn? (first x))) + (let [[f & args] x + r (apply f args)] + (expand (if (fn? r) (apply r args) r))) + (map? x) x + (coll? x) (doall (map expand x)) + :else x)) + +(def ^:private builders + "Each content type's builder page, and the event that opens an entry in it." + {::e5/spells [::spells5e/set-spell views/spell-builder] + ::e5/monsters [::monsters5e/set-monster views/monster-builder] + ::e5/encounters [::encounters5e/set-encounter views/encounter-builder] + ::e5/backgrounds [::bg5e/set-background views/background-builder] + ::e5/languages [::langs5e/set-language views/language-builder] + ::e5/invocations [::classes5e/set-invocation views/invocation-builder] + ::e5/boons [::classes5e/set-boon views/boon-builder] + ::e5/selections [::selections5e/set-selection views/selection-builder] + ::e5/feats [::feats5e/set-feat views/feat-builder] + ::e5/races [::races5e/set-race views/race-builder] + ::e5/subraces [::races5e/set-subrace views/subrace-builder] + ::e5/subclasses [::classes5e/set-subclass views/subclass-builder] + ::e5/classes [::classes5e/set-class views/class-builder]}) + +(defn- pages + "Draw My Content (the page, and each content type's list for the pack), the monster list + filtered by a name, and each entry of `entries` opened in its builder. Returns the failures." + [entries] + (let [plugin (get-in @app-db [:plugins source])] + (concat + [(attempt [:page :my-content] #(expand [views/my-content]))] + (for [{:keys [type-name type-key icon add-event edit-event delete-event plural]} + views/my-content-types] + (attempt [:page :my-content type-name] + #(expand [views/my-content-type source plugin type-name type-key icon add-event + edit-event delete-event plural "" true]))) + [(attempt [:page :monster-list] + #(do (rf/dispatch-sync [::char5e/filter-monsters "junk"]) + (expand [views/monster-list-items])))] + (for [[t es] entries :when (map? es) + :let [[open builder] (builders t)] + :when open + [k e] es :when (map? e)] + (attempt [:builder k] #(do (rf/dispatch-sync [open e]) (expand [builder]))))))) + +(defn- offered-keys + "The keys of the classes, races and backgrounds the app offers now." + [] + (let [ks #(set (keep (fn [x] (or (:key x) (::t/key x))) @(rf/subscribe [%])))] + {:classes (ks ::classes5e/classes) + :races (ks ::races5e/races) + :backgrounds (ks ::bg5e/backgrounds)})) + +(def ^:private built-in-keys + "What the app offers with no homebrew at all, read once by the base-pack test." + (atom nil)) + +(defn- missing-built-ins [] + (when-let [before @built-in-keys] + (let [now (offered-keys) + gone (into {} (for [[k ks] before + :let [m (remove (now k) ks)] + :when (seq m)] + [k (vec m)]))] + (when (seq gone) (throw (js/Error. (str "built-in content went missing: " (pr-str gone)))))))) + +(defn- wrong-blame + "Reports naming an entry that is not one of the pack's own: something else was set aside." + [reports entries] + (for [{:keys [content-type key]} reports + :when (not (contains? (get entries content-type) key))] + {:stage :set-aside :error (str "set aside " (pr-str content-type) " " (pr-str key) + ", which is not in the pack")})) + (defn- run-pack! - "Put `lib` in storage, start the app on it, and use it. Returns the failures, each - {:stage :error}; an entry set aside is not a failure." - [lib] + "Start the app with `lib` arriving `via` :storage (the stored library; also what + LOAD_HOMEBREW_URL writes) or :share (a share link's homebrew), and use it. Returns the + failures, each {:stage :error}." + [lib & [via]] (doseq [k (drafts/draft-keys)] (drafts/remove-draft! k)) (doseq [k ["plugins" "plugins:rejected" "plugins:corrupt" "character"]] (.removeItem js/localStorage k)) - (.setItem js/localStorage "plugins" (pr-str lib)) + (when (not= via :share) (.setItem js/localStorage "plugins" (pr-str lib))) (.setItem js/localStorage "character" (pr-str (char5e/to-strict stored-character))) (reset! app-db {}) (rf/clear-subscription-cache!) - ;; a report means the entry is set aside, which is allowed; drop it rather than queue the event - (guard/call-with-reporter - (constantly nil) - (fn [] - (let [boot (attempt :startup #(rf/dispatch-sync [:initialize-db])) - sub-fails (keep (fn [q] (attempt q #(realize-homebrew @(rf/subscribe q)))) - homebrew-subs) - deep (attempt :deep-check #(homebrew-check/deep-check nil)) - template (try @(rf/subscribe [::char5e/template]) (catch :default _ nil)) - builds (when template - (keep (fn [[k c]] (attempt [:character k] #(use-entry! template k c))) - (cons [nil stored-character] - (seeds (get (get-in @app-db [:plugins]) source)))))] - (doall (remove nil? (concat [boot] sub-fails [deep] builds))))))) + (let [reports (atom [])] + (guard/call-with-reporter + #(swap! reports conj %) + (fn [] + (let [boot (attempt :startup #(rf/dispatch-sync [:initialize-db])) + shared (when (= via :share) + (attempt :share + #(rf/dispatch-sync [::e5/apply-shared-content {:plugins lib}]))) + entries (get (if (= via :share) (:shared-plugins @app-db) (:plugins @app-db)) source) + sub-fails (keep (fn [q] (attempt q #(realize-homebrew @(rf/subscribe q)))) + homebrew-subs) + built-ins (attempt :built-in-content missing-built-ins) + deep (attempt :deep-check #(homebrew-check/deep-check nil)) + template (try @(rf/subscribe [::char5e/template]) (catch :default _ nil)) + set-aside (set (map :key @reports)) + ;; an entry set aside is not offered, and is allowed; one silently not offered is lost + builds (when template + (remove #(and (= "never offered" (:error %)) (set-aside (second (:stage %)))) + (keep (fn [[k c]] (attempt [:character k] #(use-entry! template k c))) + (cons [nil stored-character] (seeds entries))))) + drawn (pages entries)] + (doall (remove nil? (concat [boot shared] sub-fails [built-ins deep] builds drawn + (wrong-blame @reports (get lib source)))))))))) + +(defn- run-via! + "run-pack! for `lib` arriving `via` :storage, :share, or :import (the file importer's checks + and clean-up first, then what it would store)." + [lib via] + (if (= via :import) + (let [result (atom nil)] + (or (some-> (attempt :import + #(reset! result (orcbrew-val/validate-import + (pr-str lib) {:strategy :progressive :auto-clean true + :existing-plugins {} + :import-source-name source}))) + vector) + (when-let [data (:data @result)] (run-pack! (if (get data source) data {source data}))) + [])) + (run-pack! lib via))) (defn- cleanup! [] (doseq [k ["plugins" "plugins:rejected" "plugins:corrupt" "character"]] @@ -219,37 +349,44 @@ ;; --------------------------------------------------------------------------------------------- (deftest the-base-pack-works - (let [failures (run-pack! (library @base-entries))] + (when-not @built-in-keys + (run-pack! {}) + (reset! built-in-keys (offered-keys))) + ;; one key per type, so picking a race cannot stand in for the feat of the same key + (let [keyed (into {} (for [[t m] @base-entries + :let [k (keyword (str "junk-" (name t)))]] + [t {k (assoc (:junk-base m) :key k)}])) + failures (run-pack! (library keyed))] (cleanup!) (is (empty? failures) (pr-str failures)))) (deftest junk-in-any-field-never-throws (let [failures (doall - (for [content-type (when (audit?) (keys @base-entries)) + (for [content-type (audit-types) v junk + via [:storage :import :share] :let [{:keys [cases entries]} (batch content-type v) - fs (run-pack! (library entries))] + fs (run-via! (library entries) via)] :when (seq fs) ;; one pack per value holds every field: name the field by running each alone [k path] cases - :let [single (run-pack! (library {content-type - {k (get-in entries [content-type k])}}))] + :let [single (run-via! (library {content-type + {k (get-in entries [content-type k])}}) via)] :when (seq single)] - [(name content-type) path v (first single)]))] + (report! [(name content-type) path v via (first single)])))] (cleanup!) - (doseq [f failures] (js/console.log "JUNKFAIL" (pr-str f))) (is (empty? failures) (str (count failures) " failures\n" (str/join "\n" (map pr-str failures)))))) (deftest a-junk-section-never-throws (let [failures (doall - (for [content-type (when (audit?) (keys @base-entries)) + (for [content-type (audit-types) v [42 "x" true [] [42] #{42}] - :let [fs (run-pack! (library {content-type v}))] + via [:storage :import :share] + :let [fs (run-via! (library {content-type v}) via)] :when (seq fs)] - [(name content-type) v (first fs)]))] + (report! [(name content-type) v via (first fs)])))] (cleanup!) - (doseq [f failures] (js/console.log "JUNKFAIL" (pr-str f))) (is (empty? failures) (str (count failures) " failures\n" (str/join "\n" (map pr-str failures)))))) diff --git a/test/e2e/junk-pack-audit.js b/test/e2e/junk-pack-audit.js index 432b092a7..87ef67ba0 100644 --- a/test/e2e/junk-pack-audit.js +++ b/test/e2e/junk-pack-audit.js @@ -1,15 +1,19 @@ -// The full junk-pack audit: orcpub.dnd.e5.junk-pack-test with every content type, field and junk -// value (the everyday suite runs only its base-pack check). Takes minutes, longer while entries -// still throw, because each failing pack is rerun one field at a time to name the field. +// The full junk-pack audit: orcpub.dnd.e5.junk-pack-test with every content type, field, junk +// value and way in (the everyday suite runs only its base-pack check). One page per content type, +// so a slow type does not hold up the rest; each failure is written as soon as it is found. // -// lein fig:test && NODE_PATH=/opt/node22/lib/node_modules node test/e2e/junk-pack-audit.js -// Prints one JUNKFAIL line per failure and writes them to target/test/junk-audit.txt. -// Exit 0 = nothing threw. +// lein fig:test && NODE_PATH=/opt/node22/lib/node_modules node test/e2e/junk-pack-audit.js [types…] +// types: spells monsters encounters backgrounds languages invocations boons selections feats +// races subraces subclasses classes (default: all). Failures go to target/test/junk-audit.txt, +// one per line. Exit 0 = nothing threw. const http = require('http'), fs = require('fs'), path = require('path'); const { chromium } = require('playwright'); const { findChrome } = require('../browser/lib/find-chrome'); +const TYPES = ['spells', 'monsters', 'encounters', 'backgrounds', 'languages', 'invocations', 'boons', + 'selections', 'feats', 'races', 'subraces', 'subclasses', 'classes']; const ROOT = path.resolve('target/test'); +const OUT = path.join(ROOT, 'junk-audit.txt'); const RUNNER = '
'; const srv = http.createServer((q, r) => { const u = decodeURIComponent(q.url.split('?')[0]); @@ -23,23 +27,28 @@ const srv = http.createServer((q, r) => { }); (async () => { + const types = process.argv.slice(2).length ? process.argv.slice(2) : TYPES; await new Promise(r => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port; const br = await chromium.launch({ executablePath: findChrome() }); - const pg = await br.newPage(); - const out = []; - pg.on('console', m => out.push(m.text())); - pg.on('pageerror', e => out.push('PAGEERROR ' + e)); - // the tests run while the page loads, so wait for the first byte only - await pg.goto(`http://localhost:${port}/runner-all.html?junk-audit`, { waitUntil: 'commit', timeout: 600000 }); - let done = true; - try { - await pg.waitForFunction(() => /Totals\s+\d+ Tests/.test(document.body.innerText), null, { timeout: 7200000 }); - } catch (e) { done = false; } - const fails = out.filter(l => l.startsWith('JUNKFAIL ')).map(l => l.slice(9)); - fails.forEach(f => console.log('JUNKFAIL ' + f)); - fs.writeFileSync('target/test/junk-audit.txt', fails.join('\n') + '\n'); - console.log(done ? `${fails.length} failure(s)` : 'TIMEOUT before the suite finished'); - process.exitCode = done && fails.length === 0 ? 0 : 1; + fs.writeFileSync(OUT, ''); + let fails = 0, unfinished = []; + for (const t of types) { + const pg = await br.newPage(); + pg.on('console', m => { + const l = m.text(); + if (l.startsWith('JUNKFAIL ')) { fs.appendFileSync(OUT, l.slice(9) + '\n'); fails++; console.log(l); } + }); + const t0 = Date.now(); + // the tests run while the page loads, so wait for the first byte only + await pg.goto(`http://localhost:${port}/runner-all.html?junk-audit=${t}`, { waitUntil: 'commit', timeout: 600000 }); + try { + await pg.waitForFunction(() => /Totals\s+\d+ Tests/.test(document.body.innerText), null, { timeout: 0, polling: 5000 }); + console.log(`${t}: done in ${Math.round((Date.now() - t0) / 60000)} min`); + } catch (e) { unfinished.push(t); console.log(`${t}: did not finish (${e.message.split('\n')[0]})`); } + await pg.close(); + } + console.log(`${fails} failure(s)` + (unfinished.length ? `; unfinished: ${unfinished.join(' ')}` : '')); + process.exitCode = fails === 0 && !unfinished.length ? 0 : 1; await br.close(); srv.close(); })().catch(e => { console.error(e); process.exit(2); }); From c9ef984ea36aba830cace82aab1e4ec6d01517b9 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 21 Oct 2026 01:50:16 +0000 Subject: [PATCH 04/31] The junk audit's baseline on integration: 498 failures over 46 fields The audit now runs each stage before reading what was set aside (the subscription checks ran lazily, after it, so entries the guard had set aside were counted as lost), picks a single choice that already holds another option (Race: Human), starts a subrace from its race, and gives each clean base entry its own key. docs/HOMEBREW_GUARD.md records the result by where it breaks: startup, every character page, My Content, the monster list, the builders, share links (which skip the load repairs), and entries lost without a word. The full list is docs/HOMEBREW_GUARD-baseline.txt. The design section now fixes a field whose meaning is obvious and skips and flags the rest, leaving what the author wrote in storage. --- docs/HOMEBREW_GUARD-baseline.txt | 498 ++++++++++++++++++++ docs/HOMEBREW_GUARD.md | 102 ++-- test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 15 +- 3 files changed, 560 insertions(+), 55 deletions(-) create mode 100644 docs/HOMEBREW_GUARD-baseline.txt diff --git a/docs/HOMEBREW_GUARD-baseline.txt b/docs/HOMEBREW_GUARD-baseline.txt new file mode 100644 index 000000000..d4520967a --- /dev/null +++ b/docs/HOMEBREW_GUARD-baseline.txt @@ -0,0 +1,498 @@ +["backgrounds" [:equipment-choices 0 :options] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["backgrounds" [:equipment-choices 0 :options] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["backgrounds" [:equipment-choices 0 :options] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["backgrounds" [:equipment-choices 0 :options] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["backgrounds" [:equipment-choices 0 :options] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["backgrounds" [:equipment-choices 0 :options] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["backgrounds" [:equipment-choices 0 :options] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["backgrounds" [:equipment-choices 0 :options] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["backgrounds" [:equipment-choices 0 :options] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["backgrounds" [:equipment-choices 0 :options] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["backgrounds" [:equipment-choices 0 :options] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["backgrounds" [:equipment-choices 0 :options] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["backgrounds" [:equipment-choices 0 :options] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] +["backgrounds" [:equipment-choices 0 :options] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] +["backgrounds" [:equipment-choices 0 :options] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] +["backgrounds" [:equipment-choices 0 :options] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["backgrounds" [:equipment-choices 0 :options] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["backgrounds" [:equipment-choices 0 :options] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["backgrounds" [:equipment-choices] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["backgrounds" [:equipment-choices] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["backgrounds" [:equipment-choices] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["backgrounds" [:equipment-choices] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["backgrounds" [:equipment-choices] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["backgrounds" [:equipment-choices] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["backgrounds" [:equipment-choices] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["backgrounds" [:equipment-choices] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["backgrounds" [:equipment-choices] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["backgrounds" [:profs :skill] "x" :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] "x" :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] "x" :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] ["x"] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] ["x"] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] ["x"] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] +["backgrounds" [:profs :skill] {:a 1} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Cannot read properties of null (reading 'charAt')"}] +["backgrounds" [:profs :skill] {:a 1} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Cannot read properties of null (reading 'charAt')"}] +["backgrounds" [:profs :skill] {:a 1} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Cannot read properties of null (reading 'charAt')"}] +["backgrounds" [:traits] "x" :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] +["backgrounds" [:traits] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["backgrounds" [:traits] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["backgrounds" [:traits] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["backgrounds" [:traits] ["x"] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] +["backgrounds" [:traits] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["backgrounds" [:traits] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["backgrounds" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["backgrounds" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["boons" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["boons" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["classes" [:ability-increase-levels] 42 :import {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] +["classes" [:ability-increase-levels] 42 :share {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] +["classes" [:ability-increase-levels] 42 :storage {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] +["classes" [:ability-increase-levels] :kw :import {:stage [:orcpub.dnd.e5.classes/classes], :error ":kw is not ISeqable"}] +["classes" [:ability-increase-levels] :kw :share {:stage [:orcpub.dnd.e5.classes/classes], :error ":kw is not ISeqable"}] +["classes" [:ability-increase-levels] :kw :storage {:stage [:orcpub.dnd.e5.classes/classes], :error ":kw is not ISeqable"}] +["classes" [:ability-increase-levels] true :import {:stage [:orcpub.dnd.e5.classes/classes], :error "true is not ISeqable"}] +["classes" [:ability-increase-levels] true :share {:stage [:orcpub.dnd.e5.classes/classes], :error "true is not ISeqable"}] +["classes" [:ability-increase-levels] true :storage {:stage [:orcpub.dnd.e5.classes/classes], :error "true is not ISeqable"}] +["classes" [:level-modifiers 0 :type] #{42} :import {:stage [:builder :junk-9], :error "Doesn't support name: #{42}"}] +["classes" [:level-modifiers 0 :type] #{42} :share {:stage [:builder :junk-9], :error "Doesn't support name: #{42}"}] +["classes" [:level-modifiers 0 :type] #{42} :storage {:stage [:builder :junk-9], :error "Doesn't support name: #{42}"}] +["classes" [:level-modifiers 0 :type] 42 :import {:stage [:builder :junk-9], :error "Doesn't support name: 42"}] +["classes" [:level-modifiers 0 :type] 42 :share {:stage [:builder :junk-9], :error "Doesn't support name: 42"}] +["classes" [:level-modifiers 0 :type] 42 :storage {:stage [:builder :junk-9], :error "Doesn't support name: 42"}] +["classes" [:level-modifiers 0 :type] ["x"] :import {:stage [:builder :junk-9], :error "Doesn't support name: [\"x\"]"}] +["classes" [:level-modifiers 0 :type] ["x"] :share {:stage [:builder :junk-9], :error "Doesn't support name: [\"x\"]"}] +["classes" [:level-modifiers 0 :type] ["x"] :storage {:stage [:builder :junk-9], :error "Doesn't support name: [\"x\"]"}] +["classes" [:level-modifiers 0 :type] [42] :import {:stage [:builder :junk-9], :error "Doesn't support name: [42]"}] +["classes" [:level-modifiers 0 :type] [42] :share {:stage [:builder :junk-9], :error "Doesn't support name: [42]"}] +["classes" [:level-modifiers 0 :type] [42] :storage {:stage [:builder :junk-9], :error "Doesn't support name: [42]"}] +["classes" [:level-modifiers 0 :type] [] :import {:stage [:builder :junk-9], :error "Doesn't support name: []"}] +["classes" [:level-modifiers 0 :type] [] :share {:stage [:builder :junk-9], :error "Doesn't support name: []"}] +["classes" [:level-modifiers 0 :type] [] :storage {:stage [:builder :junk-9], :error "Doesn't support name: []"}] +["classes" [:level-modifiers 0 :type] [{}] :import {:stage [:builder :junk-9], :error "Doesn't support name: [{}]"}] +["classes" [:level-modifiers 0 :type] [{}] :share {:stage [:builder :junk-9], :error "Doesn't support name: [{}]"}] +["classes" [:level-modifiers 0 :type] [{}] :storage {:stage [:builder :junk-9], :error "Doesn't support name: [{}]"}] +["classes" [:level-modifiers 0 :type] true :import {:stage [:builder :junk-9], :error "Doesn't support name: true"}] +["classes" [:level-modifiers 0 :type] true :share {:stage [:builder :junk-9], :error "Doesn't support name: true"}] +["classes" [:level-modifiers 0 :type] true :storage {:stage [:builder :junk-9], :error "Doesn't support name: true"}] +["classes" [:level-modifiers 0 :type] {:a 1} :import {:stage [:builder :junk-9], :error "Doesn't support name: {:a 1}"}] +["classes" [:level-modifiers 0 :type] {:a 1} :share {:stage [:builder :junk-9], :error "Doesn't support name: {:a 1}"}] +["classes" [:level-modifiers 0 :type] {:a 1} :storage {:stage [:builder :junk-9], :error "Doesn't support name: {:a 1}"}] +["classes" [:level-modifiers 0 :type] {} :import {:stage [:builder :junk-9], :error "Doesn't support name: {}"}] +["classes" [:level-modifiers 0 :type] {} :share {:stage [:builder :junk-9], :error "Doesn't support name: {}"}] +["classes" [:level-modifiers 0 :type] {} :storage {:stage [:builder :junk-9], :error "Doesn't support name: {}"}] +["classes" [:level-modifiers] 42 :import {:stage [:builder :junk-0], :error "42 is not ISeqable"}] +["classes" [:level-modifiers] 42 :share {:stage [:builder :junk-0], :error "42 is not ISeqable"}] +["classes" [:level-modifiers] 42 :storage {:stage [:builder :junk-0], :error "42 is not ISeqable"}] +["classes" [:level-modifiers] :kw :import {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] +["classes" [:level-modifiers] :kw :share {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] +["classes" [:level-modifiers] :kw :storage {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] +["classes" [:level-modifiers] true :import {:stage [:builder :junk-0], :error "true is not ISeqable"}] +["classes" [:level-modifiers] true :share {:stage [:builder :junk-0], :error "true is not ISeqable"}] +["classes" [:level-modifiers] true :storage {:stage [:builder :junk-0], :error "true is not ISeqable"}] +["classes" [:level-selections] 42 :import {:stage [:builder :junk-3], :error "42 is not ISeqable"}] +["classes" [:level-selections] 42 :share {:stage [:builder :junk-3], :error "42 is not ISeqable"}] +["classes" [:level-selections] 42 :storage {:stage [:builder :junk-3], :error "42 is not ISeqable"}] +["classes" [:level-selections] :kw :import {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] +["classes" [:level-selections] :kw :share {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] +["classes" [:level-selections] :kw :storage {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] +["classes" [:level-selections] true :import {:stage [:builder :junk-3], :error "true is not ISeqable"}] +["classes" [:level-selections] true :share {:stage [:builder :junk-3], :error "true is not ISeqable"}] +["classes" [:level-selections] true :storage {:stage [:builder :junk-3], :error "true is not ISeqable"}] +["classes" [:spellcasting :cantrips-known] "x" :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] +["classes" [:spellcasting :cantrips-known] "x" :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] +["classes" [:spellcasting :cantrips-known] "x" :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] +["classes" [:spellcasting :cantrips-known] #{42} :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] +["classes" [:spellcasting :cantrips-known] #{42} :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] +["classes" [:spellcasting :cantrips-known] #{42} :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] +["classes" [:spellcasting :cantrips-known] 42 :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] +["classes" [:spellcasting :cantrips-known] 42 :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] +["classes" [:spellcasting :cantrips-known] 42 :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] +["classes" [:spellcasting :cantrips-known] :kw :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] +["classes" [:spellcasting :cantrips-known] :kw :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] +["classes" [:spellcasting :cantrips-known] :kw :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] +["classes" [:spellcasting :cantrips-known] ["x"] :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] +["classes" [:spellcasting :cantrips-known] ["x"] :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] +["classes" [:spellcasting :cantrips-known] ["x"] :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] +["classes" [:spellcasting :cantrips-known] [42] :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] +["classes" [:spellcasting :cantrips-known] [42] :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] +["classes" [:spellcasting :cantrips-known] [42] :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] +["classes" [:spellcasting :cantrips-known] [] :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] +["classes" [:spellcasting :cantrips-known] [] :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] +["classes" [:spellcasting :cantrips-known] [] :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] +["classes" [:spellcasting :cantrips-known] [{}] :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] +["classes" [:spellcasting :cantrips-known] [{}] :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] +["classes" [:spellcasting :cantrips-known] [{}] :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] +["classes" [:spellcasting :cantrips-known] true :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] +["classes" [:spellcasting :cantrips-known] true :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] +["classes" [:spellcasting :cantrips-known] true :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] +["classes" [:traits] "x" :share {:stage [:orcpub.dnd.e5.classes/classes], :error "conj on a map takes map entries or seqables of map entries"}] +["classes" [:traits] #{42} :share {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] +["classes" [:traits] 42 :share {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] +["classes" [:traits] :kw :share {:stage [:orcpub.dnd.e5.classes/classes], :error ":kw is not ISeqable"}] +["classes" [:traits] ["x"] :share {:stage [:orcpub.dnd.e5.classes/classes], :error "conj on a map takes map entries or seqables of map entries"}] +["classes" [:traits] [42] :share {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] +["classes" [:traits] true :share {:stage [:orcpub.dnd.e5.classes/classes], :error "true is not ISeqable"}] +["classes" true :import {:stage :startup, :error "true is not ISeqable"}] +["classes" true :share {:stage [:orcpub.dnd.e5.classes/plugin-classes], :error "true is not ISeqable"}] +["classes" true :storage {:stage :startup, :error "true is not ISeqable"}] +["encounters" [:creatures] 42 :import {:stage [:builder :junk-1], :error "42 is not ISeqable"}] +["encounters" [:creatures] 42 :share {:stage [:builder :junk-1], :error "42 is not ISeqable"}] +["encounters" [:creatures] 42 :storage {:stage [:builder :junk-1], :error "42 is not ISeqable"}] +["encounters" [:creatures] :kw :import {:stage [:builder :junk-1], :error ":kw is not ISeqable"}] +["encounters" [:creatures] :kw :share {:stage [:builder :junk-1], :error ":kw is not ISeqable"}] +["encounters" [:creatures] :kw :storage {:stage [:builder :junk-1], :error ":kw is not ISeqable"}] +["encounters" [:creatures] true :import {:stage [:builder :junk-1], :error "true is not ISeqable"}] +["encounters" [:creatures] true :share {:stage [:builder :junk-1], :error "true is not ISeqable"}] +["encounters" [:creatures] true :storage {:stage [:builder :junk-1], :error "true is not ISeqable"}] +["encounters" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["encounters" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["feats" [:ability-increases] "x" :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] +["feats" [:ability-increases] "x" :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] +["feats" [:ability-increases] "x" :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] +["feats" [:ability-increases] 42 :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] +["feats" [:ability-increases] 42 :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] +["feats" [:ability-increases] 42 :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] +["feats" [:ability-increases] :kw :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] +["feats" [:ability-increases] :kw :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] +["feats" [:ability-increases] :kw :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] +["feats" [:ability-increases] ["x"] :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] +["feats" [:ability-increases] ["x"] :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] +["feats" [:ability-increases] ["x"] :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] +["feats" [:ability-increases] [42] :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] +["feats" [:ability-increases] [42] :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] +["feats" [:ability-increases] [42] :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] +["feats" [:ability-increases] [] :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] +["feats" [:ability-increases] [] :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] +["feats" [:ability-increases] [] :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] +["feats" [:ability-increases] [{}] :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] +["feats" [:ability-increases] [{}] :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] +["feats" [:ability-increases] [{}] :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] +["feats" [:ability-increases] true :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] +["feats" [:ability-increases] true :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] +["feats" [:ability-increases] true :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] +["feats" [:ability-increases] {:a 1} :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] +["feats" [:ability-increases] {:a 1} :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] +["feats" [:ability-increases] {:a 1} :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] +["feats" [:ability-increases] {} :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] +["feats" [:ability-increases] {} :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] +["feats" [:ability-increases] {} :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] +["feats" [:prereqs] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] +["feats" [:prereqs] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] +["feats" [:prereqs] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] +["feats" [:prereqs] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["feats" [:prereqs] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["feats" [:prereqs] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["feats" [:prereqs] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["feats" [:prereqs] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["feats" [:prereqs] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["feats" [:prereqs] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] +["feats" [:prereqs] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] +["feats" [:prereqs] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] +["feats" [:prereqs] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: {}"}] +["feats" [:prereqs] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: {}"}] +["feats" [:prereqs] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: {}"}] +["feats" [:prereqs] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["feats" [:prereqs] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["feats" [:prereqs] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["feats" [:prereqs] {:a 1} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: [object Object]"}] +["feats" [:prereqs] {:a 1} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: [object Object]"}] +["feats" [:prereqs] {:a 1} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: [object Object]"}] +["feats" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["feats" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["invocations" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["invocations" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["languages" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["languages" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["monsters" [:size] "x" :import {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] "x" :share {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] "x" :storage {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] #{42} :import {:stage [:page :monster-list], :error "Doesn't support name: #{42}"}] +["monsters" [:size] #{42} :share {:stage [:page :monster-list], :error "Doesn't support name: #{42}"}] +["monsters" [:size] #{42} :storage {:stage [:page :monster-list], :error "Doesn't support name: #{42}"}] +["monsters" [:size] 42 :import {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] 42 :share {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] 42 :storage {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] ["x"] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] ["x"] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] ["x"] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [42] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [42] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [42] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [{}] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [{}] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] [{}] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:size] nil :import {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:size] nil :share {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:size] nil :storage {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:size] true :import {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] true :share {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] true :storage {:stage [:page :monster-list], :error "size.call is not a function"}] +["monsters" [:size] {:a 1} :import {:stage [:page :monster-list], :error "Doesn't support name: {:a 1}"}] +["monsters" [:size] {:a 1} :share {:stage [:page :monster-list], :error "Doesn't support name: {:a 1}"}] +["monsters" [:size] {:a 1} :storage {:stage [:page :monster-list], :error "Doesn't support name: {:a 1}"}] +["monsters" [:size] {} :import {:stage [:page :monster-list], :error "Doesn't support name: {}"}] +["monsters" [:size] {} :share {:stage [:page :monster-list], :error "Doesn't support name: {}"}] +["monsters" [:size] {} :storage {:stage [:page :monster-list], :error "Doesn't support name: {}"}] +["monsters" [:subtypes] 42 :import {:stage [:page :monster-list], :error "42 is not ISeqable"}] +["monsters" [:subtypes] 42 :share {:stage [:page :monster-list], :error "42 is not ISeqable"}] +["monsters" [:subtypes] 42 :storage {:stage [:page :monster-list], :error "42 is not ISeqable"}] +["monsters" [:subtypes] :kw :import {:stage [:page :monster-list], :error ":kw is not ISeqable"}] +["monsters" [:subtypes] :kw :share {:stage [:page :monster-list], :error ":kw is not ISeqable"}] +["monsters" [:subtypes] :kw :storage {:stage [:page :monster-list], :error ":kw is not ISeqable"}] +["monsters" [:subtypes] true :import {:stage [:page :monster-list], :error "true is not ISeqable"}] +["monsters" [:subtypes] true :share {:stage [:page :monster-list], :error "true is not ISeqable"}] +["monsters" [:subtypes] true :storage {:stage [:page :monster-list], :error "true is not ISeqable"}] +["monsters" [:traits] 42 :share {:stage [:builder :junk-21], :error "42 is not ISeqable"}] +["monsters" [:traits] :kw :share {:stage [:builder :junk-21], :error ":kw is not ISeqable"}] +["monsters" [:traits] true :share {:stage [:builder :junk-21], :error "true is not ISeqable"}] +["monsters" [:type] "x" :import {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] "x" :share {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] "x" :storage {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] 42 :import {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] 42 :share {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] 42 :storage {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] ["x"] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] ["x"] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] ["x"] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [42] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [42] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [42] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [{}] :import {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [{}] :share {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] [{}] :storage {:stage [:page :monster-list], :error "Key must be integer"}] +["monsters" [:type] nil :import {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:type] nil :share {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:type] nil :storage {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'call')"}] +["monsters" [:type] true :import {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] true :share {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" [:type] true :storage {:stage [:page :monster-list], :error "type.call is not a function"}] +["monsters" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["monsters" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["races" [:abilities] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["races" [:abilities] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["races" [:abilities] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["races" [:abilities] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["races" [:abilities] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["races" [:abilities] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["races" [:abilities] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["races" [:abilities] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["races" [:abilities] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["races" [:abilities] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["races" [:abilities] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["races" [:abilities] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["races" [:abilities] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] +["races" [:abilities] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] +["races" [:abilities] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] +["races" [:abilities] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["races" [:abilities] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["races" [:abilities] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["races" [:languages] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["races" [:languages] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["races" [:languages] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["races" [:languages] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["races" [:languages] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["races" [:languages] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["races" [:languages] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["races" [:languages] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["races" [:languages] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["races" [:props :damage-resistance] "x" :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["races" [:props :damage-resistance] "x" :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["races" [:props :damage-resistance] "x" :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["races" [:props :damage-resistance] #{42} :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["races" [:props :damage-resistance] #{42} :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["races" [:props :damage-resistance] #{42} :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["races" [:props :damage-resistance] ["x"] :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["races" [:props :damage-resistance] ["x"] :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["races" [:props :damage-resistance] ["x"] :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["races" [:props :damage-resistance] [42] :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["races" [:props :damage-resistance] [42] :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["races" [:props :damage-resistance] [42] :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["races" [:props :damage-resistance] [{}] :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] +["races" [:props :damage-resistance] [{}] :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] +["races" [:props :damage-resistance] [{}] :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] +["races" [:spells] 42 :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "42 is not ISeqable"}] +["races" [:spells] 42 :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "42 is not ISeqable"}] +["races" [:spells] 42 :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "42 is not ISeqable"}] +["races" [:spells] :kw :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error ":kw is not ISeqable"}] +["races" [:spells] :kw :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error ":kw is not ISeqable"}] +["races" [:spells] :kw :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error ":kw is not ISeqable"}] +["races" [:spells] true :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "true is not ISeqable"}] +["races" [:spells] true :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "true is not ISeqable"}] +["races" [:spells] true :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "true is not ISeqable"}] +["races" [:traits] "x" :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] +["races" [:traits] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["races" [:traits] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["races" [:traits] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["races" [:traits] ["x"] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] +["races" [:traits] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["races" [:traits] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["races" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["races" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["selections" [:options] 42 :share {:stage [:builder :junk-0], :error "42 is not ISeqable"}] +["selections" [:options] :kw :share {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] +["selections" [:options] true :share {:stage [:builder :junk-0], :error "true is not ISeqable"}] +["selections" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["selections" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["spells" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["spells" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["subclasses" [:class] :kw :import {:stage [:character :junk-4], :error "never offered"}] +["subclasses" [:class] :kw :share {:stage [:character :junk-4], :error "never offered"}] +["subclasses" [:class] :kw :storage {:stage [:character :junk-4], :error "never offered"}] +["subclasses" [:level-modifiers 0 :type] #{42} :import {:stage [:builder :junk-7], :error "Doesn't support name: #{42}"}] +["subclasses" [:level-modifiers 0 :type] #{42} :share {:stage [:builder :junk-7], :error "Doesn't support name: #{42}"}] +["subclasses" [:level-modifiers 0 :type] #{42} :storage {:stage [:builder :junk-7], :error "Doesn't support name: #{42}"}] +["subclasses" [:level-modifiers 0 :type] 42 :import {:stage [:builder :junk-7], :error "Doesn't support name: 42"}] +["subclasses" [:level-modifiers 0 :type] 42 :share {:stage [:builder :junk-7], :error "Doesn't support name: 42"}] +["subclasses" [:level-modifiers 0 :type] 42 :storage {:stage [:builder :junk-7], :error "Doesn't support name: 42"}] +["subclasses" [:level-modifiers 0 :type] ["x"] :import {:stage [:builder :junk-7], :error "Doesn't support name: [\"x\"]"}] +["subclasses" [:level-modifiers 0 :type] ["x"] :share {:stage [:builder :junk-7], :error "Doesn't support name: [\"x\"]"}] +["subclasses" [:level-modifiers 0 :type] ["x"] :storage {:stage [:builder :junk-7], :error "Doesn't support name: [\"x\"]"}] +["subclasses" [:level-modifiers 0 :type] [42] :import {:stage [:builder :junk-7], :error "Doesn't support name: [42]"}] +["subclasses" [:level-modifiers 0 :type] [42] :share {:stage [:builder :junk-7], :error "Doesn't support name: [42]"}] +["subclasses" [:level-modifiers 0 :type] [42] :storage {:stage [:builder :junk-7], :error "Doesn't support name: [42]"}] +["subclasses" [:level-modifiers 0 :type] [] :import {:stage [:builder :junk-7], :error "Doesn't support name: []"}] +["subclasses" [:level-modifiers 0 :type] [] :share {:stage [:builder :junk-7], :error "Doesn't support name: []"}] +["subclasses" [:level-modifiers 0 :type] [] :storage {:stage [:builder :junk-7], :error "Doesn't support name: []"}] +["subclasses" [:level-modifiers 0 :type] [{}] :import {:stage [:builder :junk-7], :error "Doesn't support name: [{}]"}] +["subclasses" [:level-modifiers 0 :type] [{}] :share {:stage [:builder :junk-7], :error "Doesn't support name: [{}]"}] +["subclasses" [:level-modifiers 0 :type] [{}] :storage {:stage [:builder :junk-7], :error "Doesn't support name: [{}]"}] +["subclasses" [:level-modifiers 0 :type] true :import {:stage [:builder :junk-7], :error "Doesn't support name: true"}] +["subclasses" [:level-modifiers 0 :type] true :share {:stage [:builder :junk-7], :error "Doesn't support name: true"}] +["subclasses" [:level-modifiers 0 :type] true :storage {:stage [:builder :junk-7], :error "Doesn't support name: true"}] +["subclasses" [:level-modifiers 0 :type] {:a 1} :import {:stage [:builder :junk-7], :error "Doesn't support name: {:a 1}"}] +["subclasses" [:level-modifiers 0 :type] {:a 1} :share {:stage [:builder :junk-7], :error "Doesn't support name: {:a 1}"}] +["subclasses" [:level-modifiers 0 :type] {:a 1} :storage {:stage [:builder :junk-7], :error "Doesn't support name: {:a 1}"}] +["subclasses" [:level-modifiers 0 :type] {} :import {:stage [:builder :junk-7], :error "Doesn't support name: {}"}] +["subclasses" [:level-modifiers 0 :type] {} :share {:stage [:builder :junk-7], :error "Doesn't support name: {}"}] +["subclasses" [:level-modifiers 0 :type] {} :storage {:stage [:builder :junk-7], :error "Doesn't support name: {}"}] +["subclasses" [:level-modifiers] 42 :import {:stage [:builder :junk-0], :error "42 is not ISeqable"}] +["subclasses" [:level-modifiers] 42 :share {:stage [:builder :junk-0], :error "42 is not ISeqable"}] +["subclasses" [:level-modifiers] 42 :storage {:stage [:builder :junk-0], :error "42 is not ISeqable"}] +["subclasses" [:level-modifiers] :kw :import {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] +["subclasses" [:level-modifiers] :kw :share {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] +["subclasses" [:level-modifiers] :kw :storage {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] +["subclasses" [:level-modifiers] true :import {:stage [:builder :junk-0], :error "true is not ISeqable"}] +["subclasses" [:level-modifiers] true :share {:stage [:builder :junk-0], :error "true is not ISeqable"}] +["subclasses" [:level-modifiers] true :storage {:stage [:builder :junk-0], :error "true is not ISeqable"}] +["subclasses" [:level-selections] 42 :import {:stage [:builder :junk-3], :error "42 is not ISeqable"}] +["subclasses" [:level-selections] 42 :share {:stage [:builder :junk-3], :error "42 is not ISeqable"}] +["subclasses" [:level-selections] 42 :storage {:stage [:builder :junk-3], :error "42 is not ISeqable"}] +["subclasses" [:level-selections] :kw :import {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] +["subclasses" [:level-selections] :kw :share {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] +["subclasses" [:level-selections] :kw :storage {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] +["subclasses" [:level-selections] true :import {:stage [:builder :junk-3], :error "true is not ISeqable"}] +["subclasses" [:level-selections] true :share {:stage [:builder :junk-3], :error "true is not ISeqable"}] +["subclasses" [:level-selections] true :storage {:stage [:builder :junk-3], :error "true is not ISeqable"}] +["subclasses" [:traits] "x" :share {:stage [:character :junk-6], :error "conj on a map takes map entries or seqables of map entries"}] +["subclasses" [:traits] #{42} :share {:stage [:character :junk-6], :error "42 is not ISeqable"}] +["subclasses" [:traits] 42 :share {:stage [:character :junk-6], :error "42 is not ISeqable"}] +["subclasses" [:traits] :kw :share {:stage [:character :junk-6], :error ":kw is not ISeqable"}] +["subclasses" [:traits] ["x"] :share {:stage [:character :junk-6], :error "conj on a map takes map entries or seqables of map entries"}] +["subclasses" [:traits] [42] :share {:stage [:character :junk-6], :error "42 is not ISeqable"}] +["subclasses" [:traits] true :share {:stage [:character :junk-6], :error "true is not ISeqable"}] +["subclasses" true :import {:stage :startup, :error "true is not ISeqable"}] +["subclasses" true :share {:stage [:orcpub.dnd.e5.classes/plugin-subclasses], :error "true is not ISeqable"}] +["subclasses" true :storage {:stage :startup, :error "true is not ISeqable"}] +["subraces" [:abilities] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["subraces" [:abilities] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["subraces" [:abilities] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["subraces" [:abilities] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["subraces" [:abilities] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["subraces" [:abilities] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["subraces" [:abilities] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["subraces" [:abilities] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["subraces" [:abilities] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["subraces" [:abilities] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["subraces" [:abilities] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["subraces" [:abilities] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] +["subraces" [:abilities] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] +["subraces" [:abilities] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] +["subraces" [:abilities] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] +["subraces" [:abilities] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["subraces" [:abilities] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["subraces" [:abilities] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["subraces" [:props :weapon-prof] "x" :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["subraces" [:props :weapon-prof] "x" :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["subraces" [:props :weapon-prof] "x" :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["subraces" [:props :weapon-prof] #{42} :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["subraces" [:props :weapon-prof] #{42} :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["subraces" [:props :weapon-prof] #{42} :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["subraces" [:props :weapon-prof] ["x"] :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["subraces" [:props :weapon-prof] ["x"] :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["subraces" [:props :weapon-prof] ["x"] :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] +["subraces" [:props :weapon-prof] [42] :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["subraces" [:props :weapon-prof] [42] :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["subraces" [:props :weapon-prof] [42] :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] +["subraces" [:props :weapon-prof] [{}] :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] +["subraces" [:props :weapon-prof] [{}] :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] +["subraces" [:props :weapon-prof] [{}] :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] +["subraces" [:race] "x" :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] "x" :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] "x" :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] #{42} :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] #{42} :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] #{42} :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] 42 :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] 42 :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] 42 :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] :kw :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] :kw :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] :kw :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] ["x"] :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] ["x"] :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] ["x"] :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] [42] :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] [42] :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] [42] :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] [] :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] [] :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] [] :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] [{}] :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] [{}] :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] [{}] :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] nil :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] nil :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] nil :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] true :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] true :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] true :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] {:a 1} :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] {:a 1} :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] {:a 1} :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] {} :import {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] {} :share {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:race] {} :storage {:stage [:character :junk-0], :error "never offered"}] +["subraces" [:spells] 42 :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "42 is not ISeqable"}] +["subraces" [:spells] 42 :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "42 is not ISeqable"}] +["subraces" [:spells] 42 :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "42 is not ISeqable"}] +["subraces" [:spells] :kw :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error ":kw is not ISeqable"}] +["subraces" [:spells] :kw :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error ":kw is not ISeqable"}] +["subraces" [:spells] :kw :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error ":kw is not ISeqable"}] +["subraces" [:spells] true :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "true is not ISeqable"}] +["subraces" [:spells] true :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "true is not ISeqable"}] +["subraces" [:spells] true :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "true is not ISeqable"}] +["subraces" [:traits] "x" :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] +["subraces" [:traits] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["subraces" [:traits] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["subraces" [:traits] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] +["subraces" [:traits] ["x"] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] +["subraces" [:traits] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] +["subraces" [:traits] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["subraces" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] +["subraces" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index f649411f2..0ab66a316 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -42,42 +42,40 @@ where nothing can guard them. ## Baseline: what crashes today `test/cljs/orcpub/dnd/e5/junk_pack_test.cljs` takes one realistic entry of each of the 13 content -types (`test/fixtures/junk-bases.orcbrew`), puts each of 12 junk values in each field, and runs -the pack through startup, every homebrew subscription, the deep check, and a character that picks -each entry. `node test/e2e/junk-pack-audit.js` runs all of it (the everyday suite runs only the -base pack, which passes). - -On integration `d51938c1`: **58 crashes**, every one a wrong shape in a field nothing checks. - -| Content | Field | Junk values that throw | Where it throws | -|---|---|---|---| -| any | the whole `classes` or `subclasses` section is `true` | 1 each | **startup**: the boot rescue sets the whole library aside | -| races | `:abilities` | 6 | building the race option list | -| races | `:languages` | 3 | building the race option list | -| races | `:spells` | 3 | after the guard, when the race is used | -| races | `:props :damage-resistance` | 5 | after the guard, when the race is used | -| subraces | `:abilities` | 6 | building the race option list | -| subraces | `:spells` | 3 | after the guard | -| subraces | `:props :weapon-prof` | 5 | after the guard | -| backgrounds | `:equipment-choices`, and its `:options` | 9 | building the background option list | -| backgrounds | `:profs :skill` | 6 | building the background option list | -| feats | `:prereqs` | 7 | building the feat option list | -| classes | `:ability-increase-levels` | 3 | the class list (all classes) | - -"Building the option list" is the template every character page uses, so each of those takes down -the character builder and sheets, not one entry. - -Found by reading the code, **not yet proven** by the audit (it does not reach them yet): - -- **Invocations and boons.** They are converted inside the built-in Warlock. `base-class-options` - catches any throw there and returns no classes at all, with no report: one bad invocation makes - all 12 classes vanish. -- **A subrace on a built-in race** is blamed on the race, so the built-in race is what disappears. -- **Monsters:** the monster list and its filter sort and lower-case names unguarded. -- **My Content** sorts each section; a section that is not a map takes the page down. -- **Shared-link content** is converted the same way but never repaired, and cannot be set aside. -- **`LOAD_HOMEBREW_URL`** writes fetched text straight to storage, skipping import validation. -- `compute/compute-plugin-vals` is a second, drifted copy of the plugin read, with no callers. +types (`test/fixtures/junk-bases.orcbrew`) and puts each of 12 junk values in each field, one level +deep. Each pack arrives three ways (stored, imported through the file importer, and through a share +link) and goes through startup, every homebrew subscription, the deep check, a character that picks +each entry where the builder offers it, and the pages that draw it: My Content, the monster list +and each entry's builder. It fails when anything throws, when a built-in class, race or background +goes missing, when something other than the junk entry is set aside, and when an entry is neither +offered nor set aside (lost without a word). + +`node test/e2e/junk-pack-audit.js` runs it all, one content type per page (about two and a half +hours on integration, most of it classes); the everyday suite runs only the clean base pack. + +On integration `d51938c1`: **498 failures over 46 fields**, listed in full in +`HOMEBREW_GUARD-baseline.txt`. Every one is a field nothing checks the shape of. + +| Where | What breaks it | Through | +|---|---|---| +| **Startup** | a whole `classes` or `subclasses` section that is `true`: the boot rescue sets the whole library aside | stored, imported | +| **Every character page** (the option list the builder and sheets share) | races and subraces `:abilities`, races `:languages`, backgrounds `:equipment-choices` and its `:options`, backgrounds `:profs :skill`, feats `:prereqs`, classes `:ability-increase-levels` | all three | +| **The race or subrace, once used** (thrown after the guard let it through) | races `:spells`, `:props :damage-resistance`; subraces `:spells`, `:props :weapon-prof` | all three | +| **My Content** | any of the 13 sections being `true` | stored, imported | +| **The monster list** | monsters `:size`, `:type`, `:subtypes` | all three | +| **Builders** | classes and subclasses `:level-modifiers` (and each one's `:type`), `:level-selections`; classes `:spellcasting :cantrips-known`; encounters `:creatures`; feats `:ability-increases` | all three | +| **Share links only** (shared content skips the load repairs) | `:traits` on races, subraces, backgrounds, classes, subclasses and monsters; selections `:options`; a `true` `classes` or `subclasses` section | share | +| **Lost without a word** | a subrace whose `:race`, or a subclass whose `:class`, is not a real one | all three | + +Integration's feat builder bug (`:ability-increases`) is already fixed on PR #69. + +Suspected from reading the code, **not reproduced**: a bad invocation or boon emptying the class +list (with the fields an invocation or boon has, nothing throws), and a subrace's failure being +blamed on its race (nothing was set aside but the junk entry). `LOAD_HOMEBREW_URL` writes into the +same storage slot, so the stored path covers it. + +Not reached yet: junk two levels down (inside a trait card, a level selection's options), and the +pages that only draw a picked entry (the character sheet, the spell page). On feature/grant-rows the same audit is expected to add the pool hooks (fighting styles, draconic ancestry, grant pools), the AC and weapon `:props`, and grant `:order`; to be run there after @@ -100,13 +98,19 @@ bug instead of the instance: - A new `homebrew_shapes.cljc` declares, per content type, every field the app reads and the shape it must have, as data: `{:abilities ability-map, :spells spell-list, :profs {...}}`. Nested shapes are declared the same way, down to the depth the builders read. -- `readable` takes a library and returns it with every field that does not match its shape - removed, plus a list of what was removed. Fields nobody reads pass through untouched. -- It runs at **read**, not in storage: stored data is never changed, so an author can still open - the entry in its builder and fix it. The builder says which fields cannot be read, the way the - ability-increase forms do now. -- Startup, the subscriptions, the deep check, shared content and events all read through - `readable`. Memoized on the library, so it costs one pass per change. +- Each field that does not match goes one of two ways: + - **Fixed, when the meaning is obvious.** `:languages "Elvish"` becomes `["Elvish"]`; one trait + card where a list belongs is wrapped in a list. This is what `mend-library` already does for + traits and selection options, extended to every declared field. Like those repairs, a fix is + written back to storage once, so it is not made again on every load. + - **Skipped and flagged, when it is not.** `:spells 42` or `:abilities true` cannot be guessed. + The app reads the entry without that field, so nothing crashes, and the stored entry is left + exactly as written. The entry's builder says which field cannot be read and why, the way the + ability-increase forms do now, so the author can fix it there. +- Checking a shape is a read, not a write. `readable` (library in, readable library out) runs once + per change to the library (memoized), and startup, the subscriptions, the deep check, shared + content and events all read through it. +- Fields nobody reads pass through untouched. This alone removes every crash in the baseline: none of them is reachable once the field has the right shape. @@ -151,10 +155,10 @@ right shape. 5. The source-scan test and the CI audit; the AGENTS.md rule; the KB note brought to integration. 6. grant-rows merges this and registers its pools; its hooks get the same audit. -## Open questions for the owner +## Proposed, for the owner to confirm -- **Remove, or set aside?** The gate removes an unreadable field and keeps the entry (a race with - junk `:spells` keeps its name, size and traits). The alternative is setting the whole entry - aside, which is what the guard does today. Recommended: remove the field, and say so. -- **Write the cleaned data back?** Recommended: no. Storage keeps what the author wrote; the - builder shows what cannot be read. +- **A field with the wrong shape** is fixed when the meaning is obvious, otherwise skipped and + flagged; the entry stays, with its good fields. Setting the whole entry aside, as the guard does + today, is kept only for an entry that still throws after the gate. +- **Nothing the author wrote is overwritten.** Only the obvious fixes are saved, once; a skipped + field stays in storage as written. diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index 6ac0a67c0..55cd989b0 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -309,18 +309,21 @@ (attempt :share #(rf/dispatch-sync [::e5/apply-shared-content {:plugins lib}]))) entries (get (if (= via :share) (:shared-plugins @app-db) (:plugins @app-db)) source) - sub-fails (keep (fn [q] (attempt q #(realize-homebrew @(rf/subscribe q)))) - homebrew-subs) + ;; each stage runs now, in order: what is set aside depends on what ran before + sub-fails (doall (keep (fn [q] (attempt q #(realize-homebrew @(rf/subscribe q)))) + homebrew-subs)) built-ins (attempt :built-in-content missing-built-ins) deep (attempt :deep-check #(homebrew-check/deep-check nil)) template (try @(rf/subscribe [::char5e/template]) (catch :default _ nil)) set-aside (set (map :key @reports)) ;; an entry set aside is not offered, and is allowed; one silently not offered is lost builds (when template - (remove #(and (= "never offered" (:error %)) (set-aside (second (:stage %)))) - (keep (fn [[k c]] (attempt [:character k] #(use-entry! template k c))) - (cons [nil stored-character] (seeds entries))))) - drawn (pages entries)] + (doall + (remove #(and (= "never offered" (:error %)) (set-aside (second (:stage %)))) + (keep (fn [[k c]] + (attempt [:character k] #(use-entry! template k c))) + (cons [nil stored-character] (seeds entries)))))) + drawn (doall (pages entries))] (doall (remove nil? (concat [boot shared] sub-fails [built-ins deep] builds drawn (wrong-blame @reports (get lib source)))))))))) From 907b8e2c1c29e7a55168415ef2f6f763ba743cf9 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 21 Oct 2026 10:51:18 +0000 Subject: [PATCH 05/31] The guard design read against our own documents What exists now lists the quarantine panel, the link marks and repairs, the health card, the commit gate and the startup rescue. A new section records which findings the documents already knew (a subrace or subclass whose parent is missing is marked in My Content), which documents the baseline contradicts, and what no document covers. The design follows the recorded decisions: the shapes come from the field schemas, not a new table; loading stays no stricter than saving; a field that does not fit is read past and shown item by item in My Content, never rewritten, and a fix is offered through the commit gate (homebrew-keys-design C1, C6; data-safety-layers). It adds a health log the tests read, canaries, and counted coverage. Three questions are left for the owner. --- docs/HOMEBREW_GUARD.md | 156 +++++++++++++++++++++++++---------------- 1 file changed, 96 insertions(+), 60 deletions(-) diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index 0ab66a316..d72f564ff 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -1,6 +1,6 @@ # Homebrew guarded by default -Status: **design, for review.** Branch `feature/guarded-homebrew`, off integration at `d51938c1`. +Status: **design, for review; read against our documents 2026-10-21.** Branch `feature/guarded-homebrew`, off integration at `d51938c1`. ## The problem in one paragraph @@ -29,11 +29,16 @@ path, so new code is protected without anyone remembering to protect it. | Layer | Where | What it does | |---|---|---| -| Load floor | `content_specs/valid-item-for-load?` | Keeps an entry if its key is a keyword and it has an `:option-pack` string. Nothing else is checked. | -| Repairs | `e5/mend-library` | Fixes a few known shapes: `:key`, `:traits`, a selection's `:options`, a spell's `:spell-lists`. | -| Guard | `guard-entry` at 7 sites | Catches a throw while one entry converts, sets the entry aside, says so. | -| Deep check | `homebrew_check/deep-check` | Converts and fully realizes 5 of 13 content types; after import, Restore, or a page crash. | -| Error boundaries | views, app root | Catch what throws while a page draws; only the app root runs the deep check. | +| Load floor | `content_specs/valid-item-for-load?`, `::e5/plugin` | Keeps an entry if its key is a keyword and it has an `:option-pack` string. A whole section may be `true` (the spec allows `boolean?`). Nothing else is checked: a presence contract, not a shape contract (orcbrew-level-modifiers.md). | +| Repairs | `e5/mend-library`, `mend-cards` | On load and import: `:key`, `:traits`, a selection's `:options`, a spell's `:spell-lists`. Not run on share-link content. | +| Guard | `guard-entry` at 7 sites | Catches a throw while one entry converts, sets the entry aside into `plugins:rejected`, says so for 60 s. | +| Deep check | `homebrew_check/deep-check` | Converts and fully realizes 5 of 13 content types; after import, Restore, or a crash that reaches the app root. | +| Quarantine panel | top of My Content | Per source and per item: rename, Restore, Auto-name & Restore, Export raw, Discard. A damaged section is listed whole. | +| Link marks and repairs | `library/dangling`, `suggested-repairs` | A link to nothing is marked on the item in My Content and in its builder, once the template is built; a fix is offered, never applied. | +| Health card | My Content | One line per problem type with a count: conflicts, missing fields, export blockers. | +| Commit gate | `library/commit`, `library_gate_test` | The one way to write the library; a backup in `plugins:pre-fix` before a stored fix. | +| Startup rescue | `boot-step`, `set-aside-unloadable-library!` | If startup throws, the whole library goes to `plugins:corrupt`, the app starts without it and offers a download. | +| Error boundaries | item, tab, app root | Catch what throws while a page draws; only the app root runs the deep check. | Option builders return lazy sequences. A throw inside one surfaces later (when a page draws it or a character is built), outside the guard. Modifier functions run only when a character is built, @@ -48,7 +53,7 @@ link) and goes through startup, every homebrew subscription, the deep check, a c each entry where the builder offers it, and the pages that draw it: My Content, the monster list and each entry's builder. It fails when anything throws, when a built-in class, race or background goes missing, when something other than the junk entry is set aside, and when an entry is neither -offered nor set aside (lost without a word). +offered nor set aside. (That last check does not yet read My Content's link marks.) `node test/e2e/junk-pack-audit.js` runs it all, one content type per page (about two and a half hours on integration, most of it classes); the everyday suite runs only the clean base pack. @@ -65,7 +70,7 @@ On integration `d51938c1`: **498 failures over 46 fields**, listed in full in | **The monster list** | monsters `:size`, `:type`, `:subtypes` | all three | | **Builders** | classes and subclasses `:level-modifiers` (and each one's `:type`), `:level-selections`; classes `:spellcasting :cantrips-known`; encounters `:creatures`; feats `:ability-increases` | all three | | **Share links only** (shared content skips the load repairs) | `:traits` on races, subraces, backgrounds, classes, subclasses and monsters; selections `:options`; a `true` `classes` or `subclasses` section | share | -| **Lost without a word** | a subrace whose `:race`, or a subclass whose `:class`, is not a real one | all three | +| **Not offered** | a subrace whose `:race`, or a subclass whose `:class`, is not a real one: marked in My Content as a link to nothing once the template is built, but not offered in the character builder; a junk parent that is not a keyword is probably not marked | all three | Integration's feat builder bug (`:ability-increases`) is already fixed on PR #69. @@ -81,6 +86,30 @@ On feature/grant-rows the same audit is expected to add the pool hooks (fighting ancestry, grant pools), the AC and weapon `:props`, and grant `:order`; to be run there after this lands. +## What our documents already say + +Read against the baseline (all on agents/develop unless marked): + +**Already known.** A subrace or subclass whose parent does not exist is marked in My Content and +in its builder ("links to nothing", homebrew-keys-design.md I11), not in the character builder, +where it simply is not offered; a junk parent that is not a keyword (`42`) is probably not marked +at all. Throws after the guard come from lazy option builders (homebrew-safety-net.md). +HOMEBREW_REQUIRED_FIELDS.md (integration) lists classes `:ability-increase-levels` and `:traits`. + +**Contradicted by the baseline, to correct once the fix lands:** +- homebrew-safety-net.md: bad homebrew "cannot keep a page down". My Content, the monster list and + the builders go down; startup comes up only by setting the whole library aside. +- homebrew-fixes-persist.md: "whatever none of these can read is set aside". A `true` section is + never set aside: the load spec accepts it. +- HOMEBREW_REQUIRED_FIELDS.md (integration): race `:abilities` and `:languages` are not critical. + Junk in either takes down every character page. +- fail-soft-rendering.md and clojurescript-type-tolerance.md (integration): the crash surface is + narrow (string operations). Most of the 498 are `seq` on a number, a keyword or vector called + as a function, and missing protocols; both documents cover nil, not wrong types. + +**Not in any document:** the monster list, the builder crashes on level modifiers and selections, +share-link content skipping the repairs, and a `true` section crashing My Content. + ## Why per-site fixes keep failing Every crash above has the same cause: **code reads a field and assumes its shape.** Guarding each @@ -95,70 +124,77 @@ bug instead of the instance: ### 1. A shape gate in front of everything -- A new `homebrew_shapes.cljc` declares, per content type, every field the app reads and the - shape it must have, as data: `{:abilities ability-map, :spells spell-list, :profs {...}}`. - Nested shapes are declared the same way, down to the depth the builders read. -- Each field that does not match goes one of two ways: - - **Fixed, when the meaning is obvious.** `:languages "Elvish"` becomes `["Elvish"]`; one trait - card where a list belongs is wrapped in a list. This is what `mend-library` already does for - traits and selection options, extended to every declared field. Like those repairs, a fix is - written back to storage once, so it is not made again on every load. - - **Skipped and flagged, when it is not.** `:spells 42` or `:abilities true` cannot be guessed. - The app reads the entry without that field, so nothing crashes, and the stored entry is left - exactly as written. The entry's builder says which field cannot be read and why, the way the - ability-increase forms do now, so the author can fix it there. -- Checking a shape is a read, not a write. `readable` (library in, readable library out) runs once - per change to the library (memoized), and startup, the subscriptions, the deep check, shared - content and events all read through it. -- Fields nobody reads pass through untouched. +- **The shapes come from what already exists**, not a new table: the field schemas + (`field_schemas.cljc`, `builder_fields.cljc`, `content_types.cljc`, built June 2026) describe + each builder's fields and generate its save spec. They live on grant-rows, not integration (see + open questions). Fields the schemas do not cover yet are added to them. +- **Loading is never stricter than saving.** The gate accepts everything any builder writes, + old forms included (a feat's released set and its 2024 list; race `:languages` as a set of + names), checked by a generated test from the save specs, as `content_specs_test` does today. +- **A field that does not fit is read past, never rewritten.** Where the meaning is obvious + (`:languages "Elvish"`, one trait card where a list belongs) the gate reads it the way it was + meant, as `mend-cards` does today. Where it is not (`:spells 42`) the gate reads the entry + without that field. Either way the stored entry is untouched (data-safety-layers.md: heal on + read, no load-time migration; homebrew-keys-design.md C1). +- **Every one is shown, item by item.** The entry is marked in My Content the way a link to + nothing is, with the field and why, and its builder says the same. A fix is **offered**, through + the commit gate with its backup, never applied silently (C6). Export writes what is stored. +- `readable` (library in, readable library plus a list of what it read past) runs once per change + to the library; the subscriptions, the deep check, shared content and events read through it. This alone removes every crash in the baseline: none of them is reachable once the field has the right shape. ### 2. One door for conversion -- A registry: each content type registers its converter once (`register-content-type!`), in a leaf - namespace with no subscription dependencies (the load-order trap in - entity-options-architecture.md). -- The converted options come from one parameterized subscription per type, - `[::homebrew/options content-type]`, fed only by that type's readable entries. That keeps the - per-type firewall: editing a spell does not rebuild races. +- No new registry: `guard/register-conversions!` (integration) and `content_types.cljc` + (grant-rows) already list the content types; the conversions join them. +- The converted options come from one parameterized subscription per type, fed only by that + type's readable entries, so editing a spell does not rebuild races. - Every conversion runs inside the guard. The deep check runs the same registered converters, so it - cannot drift from the live path (on grant-rows it already has: the races and feats checks pass an - argument in the wrong place and check nothing). -- All 13 types are registered, not 5. A subrace or subclass failure is blamed on itself, not its - parent. `base-class-options` reports what it catches instead of silently dropping every class. + cannot drift from the live path (on grant-rows it already has: the race check passes the entry + where `grantable-pools` goes and checks nothing). +- All 13 types are registered, not 5. A subrace or subclass failure is blamed on itself. + +### 3. Telling us what it did + +- **One homebrew health log**: every repair read, field read past, entry set aside, link to + nothing and crash a boundary caught, each with its entry, field and reason. It feeds My + Content's item-by-item marks, and a test can read it. +- **Tests read it and check it.** The log names the field, so the audit needs no search; the audit + still watches for throws independently, so a crash the log does not explain is a failure. -### 3. Enforcement +### 4. Enforcement, and tests that cannot pass by accident - **A source-scan test**, modelled on `library_gate_test` and `export_paths_test`: every read of raw homebrew (`::e5/plugins`, `::e5/plugin-vals`, `::e5/plugins-with-sources`, `::e5/shared-plugins`, `(:plugins db)`) must be in the homebrew namespace or carry a marker - comment saying why it needs the raw data (storage, export, rename, quarantine). A new read - without one fails `lein test` with a message saying how to route it. -- **The junk audit in CI.** Once the baseline is zero, a sample of the grid joins the everyday - suite (it takes about a second per pack when nothing throws), and the full audit runs before a - merge to integration. -- **A shape for every field read.** The audit takes its fields from the base entries; a test also - checks that every field the shape gate declares appears in a base entry, so the two lists stay - together. -- **AGENTS.md** gets one rule: homebrew is read through `readable` and converted through the - registry; anything else needs a marker and a reason. + comment saying why it needs the raw data (storage, export, rename, quarantine). +- **Every check proves it can fail**: each stage of the audit has a canary, a known-bad entry it + must catch; a canary that passes fails the audit. +- **Coverage is counted**: the audit checks how many entries each stage actually picked, drew and + converted; fewer than expected fails. +- **The junk audit runs in CI** once the baseline is zero, and the full audit before a merge to + integration. A faster audit replaces the slow one only after it reproduces the same failures. +- **AGENTS.md**: homebrew is read through `readable` and converted through the registry; a safety + claim cites a committed test that runs; a report says what it did not cover. ## Order of work -1. This document, and the baseline test (done). -2. The shape gate, field by field, with the audit going to zero on integration's content. -3. The registry and the per-type subscriptions; move the 7 guarded sites and the unguarded ones - onto it; the deep check reads the registry. -4. Startup, events, My Content, shared content and `LOAD_HOMEBREW_URL` read through `readable`. -5. The source-scan test and the CI audit; the AGENTS.md rule; the KB note brought to integration. -6. grant-rows merges this and registers its pools; its hooks get the same audit. - -## Proposed, for the owner to confirm - -- **A field with the wrong shape** is fixed when the meaning is obvious, otherwise skipped and - flagged; the entry stays, with its good fields. Setting the whole entry aside, as the guard does - today, is kept only for an entry that still throws after the gate. -- **Nothing the author wrote is overwritten.** Only the obvious fixes are saved, once; a skipped - field stays in storage as written. +1. This document, the baseline test, and the documents read against it (done). +2. The audit: canaries, counted coverage, the health checks in My Content, and the waste removed + (proven to give the same failures). +3. The shape gate, from the field schemas, with the audit going to zero. +4. The conversions on one registry; the deep check reads it. +5. The health log and the item-by-item marks in My Content. +6. The source-scan test, the CI audit, the AGENTS.md rules; the documents above corrected. +7. grant-rows merges this and registers its pools; its hooks get the same audit. + +## Open questions for the owner + +- **Where to build.** The field schemas the gate should use exist only on grant-rows. Bring them + to integration on this branch first, or build the gate on grant-rows and ship it with grants? +- **Storage path.** A `true` section passes the load spec; closing that changes the load path, + which TODO.md pins as hands-off until deliberately scheduled. Schedule it here? +- **Offered fixes.** Are the obvious fixes offered in My Content (recommended, per C6), or applied + quietly at read with a mark (data-safety-layers.md allows healing on read)? From cd1e94389e7d10927a0208501daeb1e9b4249d24 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 21 Oct 2026 17:17:16 +0000 Subject: [PATCH 06/31] The homebrew shape table, a junk audit in 8 minutes, and a faster deep check homebrew_shapes.cljc says, for each of the 13 content types, every field the app reads and its shape, and `clean` makes an entry fit: a value whose meaning is obvious is fixed ("3" for 3, a lone card for a list of cards, a set of keys for a key-to-true map), anything else is read past, and each is reported with its path. A sound entry comes back as the identical object. Nothing calls it yet: the seams come next. Every real fixture comes through with nothing unreadable, so loading is no stricter than saving; junk in any field comes out fitting. guard/realize walks each object once. A class's spell options recur at every level, and the deep check walked every copy: seconds for one homebrew class, now milliseconds. The safety-net test passes, and fails three checks when realize does nothing. The junk audit runs in about 8 minutes, from 2.5 hours, with the same failures: one pack per type holding every junk value, failures traced to their entry within the run, each entry that arrives the same by storage, import and share link built and drawn once, a build of that test alone, and four pages sharing the work. It also tests a junk :name, which the first version renamed away: 659 failures over 51 fields, recorded in docs/HOMEBREW_GUARD-baseline.txt. The design document now has both seams, the efficiency budget, the prep work to reuse, and the order of work. --- docs/HOMEBREW_GUARD-baseline.txt | 969 ++++++++++-------- docs/HOMEBREW_GUARD.md | 261 +++-- docs/branch-changelog.md | 3 + junk-audit.cljs.edn | 5 + project.clj | 2 + src/cljc/orcpub/dnd/e5/homebrew_guard.cljc | 17 +- src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc | 414 ++++++++ .../dnd/e5/homebrew_shape_table_test.clj | 80 ++ .../orcpub/dnd/e5/homebrew_shapes_test.cljc | 57 ++ test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 347 +++++-- test/cljs/orcpub/junk_audit_runner.cljs | 18 + test/e2e/junk-pack-audit.js | 64 +- 12 files changed, 1628 insertions(+), 609 deletions(-) create mode 100644 junk-audit.cljs.edn create mode 100644 src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc create mode 100644 test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj create mode 100644 test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc create mode 100644 test/cljs/orcpub/junk_audit_runner.cljs diff --git a/docs/HOMEBREW_GUARD-baseline.txt b/docs/HOMEBREW_GUARD-baseline.txt index d4520967a..67f10d08a 100644 --- a/docs/HOMEBREW_GUARD-baseline.txt +++ b/docs/HOMEBREW_GUARD-baseline.txt @@ -1,218 +1,315 @@ -["backgrounds" [:equipment-choices 0 :options] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["backgrounds" [:equipment-choices 0 :options] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["backgrounds" [:equipment-choices 0 :options] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["backgrounds" [:equipment-choices 0 :options] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["backgrounds" [:equipment-choices 0 :options] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["backgrounds" [:equipment-choices 0 :options] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["backgrounds" [:equipment-choices 0 :options] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["backgrounds" [:equipment-choices 0 :options] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["backgrounds" [:equipment-choices 0 :options] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["backgrounds" [:equipment-choices 0 :options] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["backgrounds" [:equipment-choices 0 :options] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["backgrounds" [:equipment-choices 0 :options] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["backgrounds" [:equipment-choices 0 :options] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] -["backgrounds" [:equipment-choices 0 :options] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] -["backgrounds" [:equipment-choices 0 :options] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] -["backgrounds" [:equipment-choices 0 :options] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["backgrounds" [:equipment-choices 0 :options] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["backgrounds" [:equipment-choices 0 :options] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["backgrounds" [:equipment-choices] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["backgrounds" [:equipment-choices] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["backgrounds" [:equipment-choices] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["backgrounds" [:equipment-choices] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["backgrounds" [:equipment-choices] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["backgrounds" [:equipment-choices] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["backgrounds" [:equipment-choices] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["backgrounds" [:equipment-choices] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["backgrounds" [:equipment-choices] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["backgrounds" [:profs :skill] "x" :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] "x" :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] "x" :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] ["x"] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] ["x"] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] ["x"] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "me.cljs$core$IMapEntry$_key$arity$1 is not a function"}] -["backgrounds" [:profs :skill] {:a 1} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Cannot read properties of null (reading 'charAt')"}] -["backgrounds" [:profs :skill] {:a 1} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Cannot read properties of null (reading 'charAt')"}] -["backgrounds" [:profs :skill] {:a 1} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Cannot read properties of null (reading 'charAt')"}] -["backgrounds" [:traits] "x" :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] -["backgrounds" [:traits] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["backgrounds" [:traits] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["backgrounds" [:traits] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["backgrounds" [:traits] ["x"] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] -["backgrounds" [:traits] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["backgrounds" [:traits] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["backgrounds" [:equipment-choices 0 :options] #{42} :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] #{42} :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] #{42} :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [42] :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [42] :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [42] :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [{}] :import {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [{}] :share {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] [{}] :storage {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices 0 :options] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:equipment-choices] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] "x" :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] "x" :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] "x" :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] #{42} :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] #{42} :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] #{42} :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] ["x"] :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] ["x"] :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] ["x"] :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [42] :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [42] :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [42] :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [{}] :import {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [{}] :share {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] [{}] :storage {:error "me.cljs$core$IMapEntry$_key$arity$1 is not a function", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] {:a 1} :import {:error "Cannot read properties of null (reading 'charAt')", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] {:a 1} :share {:error "Cannot read properties of null (reading 'charAt')", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:profs :skill] {:a 1} :storage {:error "Cannot read properties of null (reading 'charAt')", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] "x" :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] #{42} :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] ["x"] :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] [42] :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["backgrounds" [:traits] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] ["backgrounds" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["backgrounds" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["boons" [:name] #{42} :import {:stage [:character :junk-boons-11-0], :error "never offered"}] +["boons" [:name] #{42} :share {:stage [:character :junk-boons-11-0], :error "never offered"}] +["boons" [:name] #{42} :storage {:stage [:character :junk-boons-11-0], :error "never offered"}] +["boons" [:name] 42 :import {:stage [:character :junk-boons-1-0], :error "never offered"}] +["boons" [:name] 42 :share {:stage [:character :junk-boons-1-0], :error "never offered"}] +["boons" [:name] 42 :storage {:stage [:character :junk-boons-1-0], :error "never offered"}] +["boons" [:name] :kw :import {:stage [:character :junk-boons-4-0], :error "never offered"}] +["boons" [:name] :kw :share {:stage [:character :junk-boons-4-0], :error "never offered"}] +["boons" [:name] :kw :storage {:stage [:character :junk-boons-4-0], :error "never offered"}] +["boons" [:name] ["x"] :import {:stage [:character :junk-boons-7-0], :error "never offered"}] +["boons" [:name] ["x"] :share {:stage [:character :junk-boons-7-0], :error "never offered"}] +["boons" [:name] ["x"] :storage {:stage [:character :junk-boons-7-0], :error "never offered"}] +["boons" [:name] [42] :import {:stage [:character :junk-boons-6-0], :error "never offered"}] +["boons" [:name] [42] :share {:stage [:character :junk-boons-6-0], :error "never offered"}] +["boons" [:name] [42] :storage {:stage [:character :junk-boons-6-0], :error "never offered"}] +["boons" [:name] [] :import {:stage [:character :junk-boons-5-0], :error "never offered"}] +["boons" [:name] [] :share {:stage [:character :junk-boons-5-0], :error "never offered"}] +["boons" [:name] [] :storage {:stage [:character :junk-boons-5-0], :error "never offered"}] +["boons" [:name] [{}] :import {:stage [:character :junk-boons-8-0], :error "never offered"}] +["boons" [:name] [{}] :share {:stage [:character :junk-boons-8-0], :error "never offered"}] +["boons" [:name] [{}] :storage {:stage [:character :junk-boons-8-0], :error "never offered"}] +["boons" [:name] nil :import {:stage [:character :junk-boons-0-0], :error "never offered"}] +["boons" [:name] nil :share {:stage [:character :junk-boons-0-0], :error "never offered"}] +["boons" [:name] nil :storage {:stage [:character :junk-boons-0-0], :error "never offered"}] +["boons" [:name] true :import {:stage [:character :junk-boons-3-0], :error "never offered"}] +["boons" [:name] true :share {:stage [:character :junk-boons-3-0], :error "never offered"}] +["boons" [:name] true :storage {:stage [:character :junk-boons-3-0], :error "never offered"}] +["boons" [:name] {:a 1} :import {:stage [:character :junk-boons-10-0], :error "never offered"}] +["boons" [:name] {:a 1} :share {:stage [:character :junk-boons-10-0], :error "never offered"}] +["boons" [:name] {:a 1} :storage {:stage [:character :junk-boons-10-0], :error "never offered"}] +["boons" [:name] {} :import {:stage [:character :junk-boons-9-0], :error "never offered"}] +["boons" [:name] {} :share {:stage [:character :junk-boons-9-0], :error "never offered"}] +["boons" [:name] {} :storage {:stage [:character :junk-boons-9-0], :error "never offered"}] ["boons" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["boons" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] -["classes" [:ability-increase-levels] 42 :import {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] -["classes" [:ability-increase-levels] 42 :share {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] -["classes" [:ability-increase-levels] 42 :storage {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] -["classes" [:ability-increase-levels] :kw :import {:stage [:orcpub.dnd.e5.classes/classes], :error ":kw is not ISeqable"}] -["classes" [:ability-increase-levels] :kw :share {:stage [:orcpub.dnd.e5.classes/classes], :error ":kw is not ISeqable"}] -["classes" [:ability-increase-levels] :kw :storage {:stage [:orcpub.dnd.e5.classes/classes], :error ":kw is not ISeqable"}] -["classes" [:ability-increase-levels] true :import {:stage [:orcpub.dnd.e5.classes/classes], :error "true is not ISeqable"}] -["classes" [:ability-increase-levels] true :share {:stage [:orcpub.dnd.e5.classes/classes], :error "true is not ISeqable"}] -["classes" [:ability-increase-levels] true :storage {:stage [:orcpub.dnd.e5.classes/classes], :error "true is not ISeqable"}] -["classes" [:level-modifiers 0 :type] #{42} :import {:stage [:builder :junk-9], :error "Doesn't support name: #{42}"}] -["classes" [:level-modifiers 0 :type] #{42} :share {:stage [:builder :junk-9], :error "Doesn't support name: #{42}"}] -["classes" [:level-modifiers 0 :type] #{42} :storage {:stage [:builder :junk-9], :error "Doesn't support name: #{42}"}] -["classes" [:level-modifiers 0 :type] 42 :import {:stage [:builder :junk-9], :error "Doesn't support name: 42"}] -["classes" [:level-modifiers 0 :type] 42 :share {:stage [:builder :junk-9], :error "Doesn't support name: 42"}] -["classes" [:level-modifiers 0 :type] 42 :storage {:stage [:builder :junk-9], :error "Doesn't support name: 42"}] -["classes" [:level-modifiers 0 :type] ["x"] :import {:stage [:builder :junk-9], :error "Doesn't support name: [\"x\"]"}] -["classes" [:level-modifiers 0 :type] ["x"] :share {:stage [:builder :junk-9], :error "Doesn't support name: [\"x\"]"}] -["classes" [:level-modifiers 0 :type] ["x"] :storage {:stage [:builder :junk-9], :error "Doesn't support name: [\"x\"]"}] -["classes" [:level-modifiers 0 :type] [42] :import {:stage [:builder :junk-9], :error "Doesn't support name: [42]"}] -["classes" [:level-modifiers 0 :type] [42] :share {:stage [:builder :junk-9], :error "Doesn't support name: [42]"}] -["classes" [:level-modifiers 0 :type] [42] :storage {:stage [:builder :junk-9], :error "Doesn't support name: [42]"}] -["classes" [:level-modifiers 0 :type] [] :import {:stage [:builder :junk-9], :error "Doesn't support name: []"}] -["classes" [:level-modifiers 0 :type] [] :share {:stage [:builder :junk-9], :error "Doesn't support name: []"}] -["classes" [:level-modifiers 0 :type] [] :storage {:stage [:builder :junk-9], :error "Doesn't support name: []"}] -["classes" [:level-modifiers 0 :type] [{}] :import {:stage [:builder :junk-9], :error "Doesn't support name: [{}]"}] -["classes" [:level-modifiers 0 :type] [{}] :share {:stage [:builder :junk-9], :error "Doesn't support name: [{}]"}] -["classes" [:level-modifiers 0 :type] [{}] :storage {:stage [:builder :junk-9], :error "Doesn't support name: [{}]"}] -["classes" [:level-modifiers 0 :type] true :import {:stage [:builder :junk-9], :error "Doesn't support name: true"}] -["classes" [:level-modifiers 0 :type] true :share {:stage [:builder :junk-9], :error "Doesn't support name: true"}] -["classes" [:level-modifiers 0 :type] true :storage {:stage [:builder :junk-9], :error "Doesn't support name: true"}] -["classes" [:level-modifiers 0 :type] {:a 1} :import {:stage [:builder :junk-9], :error "Doesn't support name: {:a 1}"}] -["classes" [:level-modifiers 0 :type] {:a 1} :share {:stage [:builder :junk-9], :error "Doesn't support name: {:a 1}"}] -["classes" [:level-modifiers 0 :type] {:a 1} :storage {:stage [:builder :junk-9], :error "Doesn't support name: {:a 1}"}] -["classes" [:level-modifiers 0 :type] {} :import {:stage [:builder :junk-9], :error "Doesn't support name: {}"}] -["classes" [:level-modifiers 0 :type] {} :share {:stage [:builder :junk-9], :error "Doesn't support name: {}"}] -["classes" [:level-modifiers 0 :type] {} :storage {:stage [:builder :junk-9], :error "Doesn't support name: {}"}] -["classes" [:level-modifiers] 42 :import {:stage [:builder :junk-0], :error "42 is not ISeqable"}] -["classes" [:level-modifiers] 42 :share {:stage [:builder :junk-0], :error "42 is not ISeqable"}] -["classes" [:level-modifiers] 42 :storage {:stage [:builder :junk-0], :error "42 is not ISeqable"}] -["classes" [:level-modifiers] :kw :import {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] -["classes" [:level-modifiers] :kw :share {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] -["classes" [:level-modifiers] :kw :storage {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] -["classes" [:level-modifiers] true :import {:stage [:builder :junk-0], :error "true is not ISeqable"}] -["classes" [:level-modifiers] true :share {:stage [:builder :junk-0], :error "true is not ISeqable"}] -["classes" [:level-modifiers] true :storage {:stage [:builder :junk-0], :error "true is not ISeqable"}] -["classes" [:level-selections] 42 :import {:stage [:builder :junk-3], :error "42 is not ISeqable"}] -["classes" [:level-selections] 42 :share {:stage [:builder :junk-3], :error "42 is not ISeqable"}] -["classes" [:level-selections] 42 :storage {:stage [:builder :junk-3], :error "42 is not ISeqable"}] -["classes" [:level-selections] :kw :import {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] -["classes" [:level-selections] :kw :share {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] -["classes" [:level-selections] :kw :storage {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] -["classes" [:level-selections] true :import {:stage [:builder :junk-3], :error "true is not ISeqable"}] -["classes" [:level-selections] true :share {:stage [:builder :junk-3], :error "true is not ISeqable"}] -["classes" [:level-selections] true :storage {:stage [:builder :junk-3], :error "true is not ISeqable"}] -["classes" [:spellcasting :cantrips-known] "x" :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] -["classes" [:spellcasting :cantrips-known] "x" :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] -["classes" [:spellcasting :cantrips-known] "x" :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] -["classes" [:spellcasting :cantrips-known] #{42} :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] -["classes" [:spellcasting :cantrips-known] #{42} :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] -["classes" [:spellcasting :cantrips-known] #{42} :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] -["classes" [:spellcasting :cantrips-known] 42 :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] -["classes" [:spellcasting :cantrips-known] 42 :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] -["classes" [:spellcasting :cantrips-known] 42 :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] -["classes" [:spellcasting :cantrips-known] :kw :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] -["classes" [:spellcasting :cantrips-known] :kw :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] -["classes" [:spellcasting :cantrips-known] :kw :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] -["classes" [:spellcasting :cantrips-known] ["x"] :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] -["classes" [:spellcasting :cantrips-known] ["x"] :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] -["classes" [:spellcasting :cantrips-known] ["x"] :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] -["classes" [:spellcasting :cantrips-known] [42] :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] -["classes" [:spellcasting :cantrips-known] [42] :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] -["classes" [:spellcasting :cantrips-known] [42] :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] -["classes" [:spellcasting :cantrips-known] [] :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] -["classes" [:spellcasting :cantrips-known] [] :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] -["classes" [:spellcasting :cantrips-known] [] :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] -["classes" [:spellcasting :cantrips-known] [{}] :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] -["classes" [:spellcasting :cantrips-known] [{}] :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] -["classes" [:spellcasting :cantrips-known] [{}] :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] -["classes" [:spellcasting :cantrips-known] true :import {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] -["classes" [:spellcasting :cantrips-known] true :share {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] -["classes" [:spellcasting :cantrips-known] true :storage {:stage [:builder :junk-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] -["classes" [:traits] "x" :share {:stage [:orcpub.dnd.e5.classes/classes], :error "conj on a map takes map entries or seqables of map entries"}] -["classes" [:traits] #{42} :share {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] -["classes" [:traits] 42 :share {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] -["classes" [:traits] :kw :share {:stage [:orcpub.dnd.e5.classes/classes], :error ":kw is not ISeqable"}] -["classes" [:traits] ["x"] :share {:stage [:orcpub.dnd.e5.classes/classes], :error "conj on a map takes map entries or seqables of map entries"}] -["classes" [:traits] [42] :share {:stage [:orcpub.dnd.e5.classes/classes], :error "42 is not ISeqable"}] -["classes" [:traits] true :share {:stage [:orcpub.dnd.e5.classes/classes], :error "true is not ISeqable"}] +["classes" [:ability-increase-levels] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:ability-increase-levels] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:level-modifiers 0 :type] #{42} :import {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: #{42}"}] +["classes" [:level-modifiers 0 :type] #{42} :share {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: #{42}"}] +["classes" [:level-modifiers 0 :type] #{42} :storage {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: #{42}"}] +["classes" [:level-modifiers 0 :type] 42 :import {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: 42"}] +["classes" [:level-modifiers 0 :type] 42 :share {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: 42"}] +["classes" [:level-modifiers 0 :type] 42 :storage {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: 42"}] +["classes" [:level-modifiers 0 :type] ["x"] :import {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: [\"x\"]"}] +["classes" [:level-modifiers 0 :type] ["x"] :share {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: [\"x\"]"}] +["classes" [:level-modifiers 0 :type] ["x"] :storage {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: [\"x\"]"}] +["classes" [:level-modifiers 0 :type] [42] :import {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: [42]"}] +["classes" [:level-modifiers 0 :type] [42] :share {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: [42]"}] +["classes" [:level-modifiers 0 :type] [42] :storage {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: [42]"}] +["classes" [:level-modifiers 0 :type] [] :import {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: []"}] +["classes" [:level-modifiers 0 :type] [] :share {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: []"}] +["classes" [:level-modifiers 0 :type] [] :storage {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: []"}] +["classes" [:level-modifiers 0 :type] [{}] :import {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: [{}]"}] +["classes" [:level-modifiers 0 :type] [{}] :share {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: [{}]"}] +["classes" [:level-modifiers 0 :type] [{}] :storage {:stage [:builder :junk-classes-2-9], :error "Doesn't support name: [{}]"}] +["classes" [:level-modifiers 0 :type] true :import {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: true"}] +["classes" [:level-modifiers 0 :type] true :share {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: true"}] +["classes" [:level-modifiers 0 :type] true :storage {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: true"}] +["classes" [:level-modifiers 0 :type] {:a 1} :import {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: {:a 1}"}] +["classes" [:level-modifiers 0 :type] {:a 1} :share {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: {:a 1}"}] +["classes" [:level-modifiers 0 :type] {:a 1} :storage {:stage [:builder :junk-classes-1-9], :error "Doesn't support name: {:a 1}"}] +["classes" [:level-modifiers 0 :type] {} :import {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: {}"}] +["classes" [:level-modifiers 0 :type] {} :share {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: {}"}] +["classes" [:level-modifiers 0 :type] {} :storage {:stage [:builder :junk-classes-0-9], :error "Doesn't support name: {}"}] +["classes" [:level-modifiers] 42 :import {:stage [:builder :junk-classes-1-0], :error "42 is not ISeqable"}] +["classes" [:level-modifiers] 42 :share {:stage [:builder :junk-classes-1-0], :error "42 is not ISeqable"}] +["classes" [:level-modifiers] 42 :storage {:stage [:builder :junk-classes-1-0], :error "42 is not ISeqable"}] +["classes" [:level-modifiers] :kw :import {:stage [:builder :junk-classes-1-0], :error ":kw is not ISeqable"}] +["classes" [:level-modifiers] :kw :share {:stage [:builder :junk-classes-1-0], :error ":kw is not ISeqable"}] +["classes" [:level-modifiers] :kw :storage {:stage [:builder :junk-classes-1-0], :error ":kw is not ISeqable"}] +["classes" [:level-modifiers] true :import {:stage [:builder :junk-classes-0-0], :error "true is not ISeqable"}] +["classes" [:level-modifiers] true :share {:stage [:builder :junk-classes-0-0], :error "true is not ISeqable"}] +["classes" [:level-modifiers] true :storage {:stage [:builder :junk-classes-0-0], :error "true is not ISeqable"}] +["classes" [:level-selections] 42 :import {:stage [:builder :junk-classes-1-3], :error "42 is not ISeqable"}] +["classes" [:level-selections] 42 :share {:stage [:builder :junk-classes-1-3], :error "42 is not ISeqable"}] +["classes" [:level-selections] 42 :storage {:stage [:builder :junk-classes-1-3], :error "42 is not ISeqable"}] +["classes" [:level-selections] :kw :import {:stage [:builder :junk-classes-1-3], :error ":kw is not ISeqable"}] +["classes" [:level-selections] :kw :share {:stage [:builder :junk-classes-1-3], :error ":kw is not ISeqable"}] +["classes" [:level-selections] :kw :storage {:stage [:builder :junk-classes-1-3], :error ":kw is not ISeqable"}] +["classes" [:level-selections] true :import {:stage [:builder :junk-classes-0-3], :error "true is not ISeqable"}] +["classes" [:level-selections] true :share {:stage [:builder :junk-classes-0-3], :error "true is not ISeqable"}] +["classes" [:level-selections] true :storage {:stage [:builder :junk-classes-0-3], :error "true is not ISeqable"}] +["classes" [:spellcasting :cantrips-known] "x" :import {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] +["classes" [:spellcasting :cantrips-known] "x" :share {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] +["classes" [:spellcasting :cantrips-known] "x" :storage {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type string: x"}] +["classes" [:spellcasting :cantrips-known] #{42} :import {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] +["classes" [:spellcasting :cantrips-known] #{42} :share {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] +["classes" [:spellcasting :cantrips-known] #{42} :storage {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentHashSet: #{42}"}] +["classes" [:spellcasting :cantrips-known] 42 :import {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] +["classes" [:spellcasting :cantrips-known] 42 :share {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] +["classes" [:spellcasting :cantrips-known] 42 :storage {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type number: 42"}] +["classes" [:spellcasting :cantrips-known] :kw :import {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] +["classes" [:spellcasting :cantrips-known] :kw :share {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] +["classes" [:spellcasting :cantrips-known] :kw :storage {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/Keyword: :kw"}] +["classes" [:spellcasting :cantrips-known] ["x"] :import {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] +["classes" [:spellcasting :cantrips-known] ["x"] :share {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] +["classes" [:spellcasting :cantrips-known] ["x"] :storage {:stage [:builder :junk-classes-1-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [\"x\"]"}] +["classes" [:spellcasting :cantrips-known] [42] :import {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] +["classes" [:spellcasting :cantrips-known] [42] :share {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] +["classes" [:spellcasting :cantrips-known] [42] :storage {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [42]"}] +["classes" [:spellcasting :cantrips-known] [] :import {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] +["classes" [:spellcasting :cantrips-known] [] :share {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] +["classes" [:spellcasting :cantrips-known] [] :storage {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: []"}] +["classes" [:spellcasting :cantrips-known] [{}] :import {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] +["classes" [:spellcasting :cantrips-known] [{}] :share {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] +["classes" [:spellcasting :cantrips-known] [{}] :storage {:stage [:builder :junk-classes-2-19], :error "No protocol method IMap.-dissoc defined for type cljs.core/PersistentVector: [{}]"}] +["classes" [:spellcasting :cantrips-known] true :import {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] +["classes" [:spellcasting :cantrips-known] true :share {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] +["classes" [:spellcasting :cantrips-known] true :storage {:stage [:builder :junk-classes-0-19], :error "No protocol method IMap.-dissoc defined for type boolean: true"}] +["classes" [:traits] "x" :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] #{42} :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] ["x"] :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] [42] :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] +["classes" [:traits] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.classes/classes]}] ["classes" true :import {:stage :startup, :error "true is not ISeqable"}] ["classes" true :share {:stage [:orcpub.dnd.e5.classes/plugin-classes], :error "true is not ISeqable"}] ["classes" true :storage {:stage :startup, :error "true is not ISeqable"}] -["encounters" [:creatures] 42 :import {:stage [:builder :junk-1], :error "42 is not ISeqable"}] -["encounters" [:creatures] 42 :share {:stage [:builder :junk-1], :error "42 is not ISeqable"}] -["encounters" [:creatures] 42 :storage {:stage [:builder :junk-1], :error "42 is not ISeqable"}] -["encounters" [:creatures] :kw :import {:stage [:builder :junk-1], :error ":kw is not ISeqable"}] -["encounters" [:creatures] :kw :share {:stage [:builder :junk-1], :error ":kw is not ISeqable"}] -["encounters" [:creatures] :kw :storage {:stage [:builder :junk-1], :error ":kw is not ISeqable"}] -["encounters" [:creatures] true :import {:stage [:builder :junk-1], :error "true is not ISeqable"}] -["encounters" [:creatures] true :share {:stage [:builder :junk-1], :error "true is not ISeqable"}] -["encounters" [:creatures] true :storage {:stage [:builder :junk-1], :error "true is not ISeqable"}] +["encounters" [:creatures] 42 :import {:stage [:builder :junk-encounters-1-1], :error "42 is not ISeqable"}] +["encounters" [:creatures] 42 :share {:stage [:builder :junk-encounters-1-1], :error "42 is not ISeqable"}] +["encounters" [:creatures] 42 :storage {:stage [:builder :junk-encounters-1-1], :error "42 is not ISeqable"}] +["encounters" [:creatures] :kw :import {:stage [:builder :junk-encounters-4-1], :error ":kw is not ISeqable"}] +["encounters" [:creatures] :kw :share {:stage [:builder :junk-encounters-4-1], :error ":kw is not ISeqable"}] +["encounters" [:creatures] :kw :storage {:stage [:builder :junk-encounters-4-1], :error ":kw is not ISeqable"}] +["encounters" [:creatures] true :import {:stage [:builder :junk-encounters-3-1], :error "true is not ISeqable"}] +["encounters" [:creatures] true :share {:stage [:builder :junk-encounters-3-1], :error "true is not ISeqable"}] +["encounters" [:creatures] true :storage {:stage [:builder :junk-encounters-3-1], :error "true is not ISeqable"}] ["encounters" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["encounters" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] -["feats" [:ability-increases] "x" :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] -["feats" [:ability-increases] "x" :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] -["feats" [:ability-increases] "x" :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] -["feats" [:ability-increases] 42 :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] -["feats" [:ability-increases] 42 :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] -["feats" [:ability-increases] 42 :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] -["feats" [:ability-increases] :kw :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] -["feats" [:ability-increases] :kw :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] -["feats" [:ability-increases] :kw :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] -["feats" [:ability-increases] ["x"] :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] -["feats" [:ability-increases] ["x"] :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] -["feats" [:ability-increases] ["x"] :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] -["feats" [:ability-increases] [42] :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] -["feats" [:ability-increases] [42] :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] -["feats" [:ability-increases] [42] :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] -["feats" [:ability-increases] [] :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] -["feats" [:ability-increases] [] :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] -["feats" [:ability-increases] [] :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] -["feats" [:ability-increases] [{}] :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] -["feats" [:ability-increases] [{}] :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] -["feats" [:ability-increases] [{}] :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] -["feats" [:ability-increases] true :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] -["feats" [:ability-increases] true :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] -["feats" [:ability-increases] true :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] -["feats" [:ability-increases] {:a 1} :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] -["feats" [:ability-increases] {:a 1} :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] -["feats" [:ability-increases] {:a 1} :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] -["feats" [:ability-increases] {} :import {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] -["feats" [:ability-increases] {} :share {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] -["feats" [:ability-increases] {} :storage {:stage [:builder :junk-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] -["feats" [:prereqs] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] -["feats" [:prereqs] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] -["feats" [:prereqs] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] -["feats" [:prereqs] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["feats" [:prereqs] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["feats" [:prereqs] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["feats" [:prereqs] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["feats" [:prereqs] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["feats" [:prereqs] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["feats" [:prereqs] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] -["feats" [:prereqs] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] -["feats" [:prereqs] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: 42"}] -["feats" [:prereqs] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: {}"}] -["feats" [:prereqs] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: {}"}] -["feats" [:prereqs] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: {}"}] -["feats" [:prereqs] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["feats" [:prereqs] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["feats" [:prereqs] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["feats" [:prereqs] {:a 1} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: [object Object]"}] -["feats" [:prereqs] {:a 1} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: [object Object]"}] -["feats" [:prereqs] {:a 1} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "Doesn't support name: [object Object]"}] +["feats" [:ability-increases] "x" :import {:stage [:builder :junk-feats-2-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] +["feats" [:ability-increases] "x" :share {:stage [:builder :junk-feats-2-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] +["feats" [:ability-increases] "x" :storage {:stage [:builder :junk-feats-2-0], :error "No protocol method ISet.-disjoin defined for type string: x"}] +["feats" [:ability-increases] 42 :import {:stage [:builder :junk-feats-1-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] +["feats" [:ability-increases] 42 :share {:stage [:builder :junk-feats-1-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] +["feats" [:ability-increases] 42 :storage {:stage [:builder :junk-feats-1-0], :error "No protocol method ISet.-disjoin defined for type number: 42"}] +["feats" [:ability-increases] :kw :import {:stage [:builder :junk-feats-4-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] +["feats" [:ability-increases] :kw :share {:stage [:builder :junk-feats-4-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] +["feats" [:ability-increases] :kw :storage {:stage [:builder :junk-feats-4-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/Keyword: :kw"}] +["feats" [:ability-increases] ["x"] :import {:stage [:builder :junk-feats-7-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] +["feats" [:ability-increases] ["x"] :share {:stage [:builder :junk-feats-7-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] +["feats" [:ability-increases] ["x"] :storage {:stage [:builder :junk-feats-7-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [\"x\"]"}] +["feats" [:ability-increases] [42] :import {:stage [:builder :junk-feats-6-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] +["feats" [:ability-increases] [42] :share {:stage [:builder :junk-feats-6-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] +["feats" [:ability-increases] [42] :storage {:stage [:builder :junk-feats-6-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [42]"}] +["feats" [:ability-increases] [] :import {:stage [:builder :junk-feats-5-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] +["feats" [:ability-increases] [] :share {:stage [:builder :junk-feats-5-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] +["feats" [:ability-increases] [] :storage {:stage [:builder :junk-feats-5-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: []"}] +["feats" [:ability-increases] [{}] :import {:stage [:builder :junk-feats-8-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] +["feats" [:ability-increases] [{}] :share {:stage [:builder :junk-feats-8-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] +["feats" [:ability-increases] [{}] :storage {:stage [:builder :junk-feats-8-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentVector: [{}]"}] +["feats" [:ability-increases] true :import {:stage [:builder :junk-feats-3-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] +["feats" [:ability-increases] true :share {:stage [:builder :junk-feats-3-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] +["feats" [:ability-increases] true :storage {:stage [:builder :junk-feats-3-0], :error "No protocol method ISet.-disjoin defined for type boolean: true"}] +["feats" [:ability-increases] {:a 1} :import {:stage [:builder :junk-feats-10-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] +["feats" [:ability-increases] {:a 1} :share {:stage [:builder :junk-feats-10-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] +["feats" [:ability-increases] {:a 1} :storage {:stage [:builder :junk-feats-10-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {:a 1}"}] +["feats" [:ability-increases] {} :import {:stage [:builder :junk-feats-9-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] +["feats" [:ability-increases] {} :share {:stage [:builder :junk-feats-9-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] +["feats" [:ability-increases] {} :storage {:stage [:builder :junk-feats-9-0], :error "No protocol method ISet.-disjoin defined for type cljs.core/PersistentArrayMap: {}"}] +["feats" [:prereqs] #{42} :import {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] #{42} :share {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] #{42} :storage {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [42] :import {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [42] :share {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [42] :storage {:error "Doesn't support name: 42", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [{}] :import {:error "Doesn't support name: {}", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [{}] :share {:error "Doesn't support name: {}", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] [{}] :storage {:error "Doesn't support name: {}", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] {:a 1} :import {:error "Doesn't support name: [object Object]", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] {:a 1} :share {:error "Doesn't support name: [object Object]", :stage [:orcpub.dnd.e5.character/template-selections]}] +["feats" [:prereqs] {:a 1} :storage {:error "Doesn't support name: [object Object]", :stage [:orcpub.dnd.e5.character/template-selections]}] ["feats" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["feats" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["invocations" [:name] #{42} :import {:stage [:character :junk-invocations-11-1], :error "never offered"}] +["invocations" [:name] #{42} :share {:stage [:character :junk-invocations-11-1], :error "never offered"}] +["invocations" [:name] #{42} :storage {:stage [:character :junk-invocations-11-1], :error "never offered"}] +["invocations" [:name] 42 :import {:stage [:character :junk-invocations-1-1], :error "never offered"}] +["invocations" [:name] 42 :share {:stage [:character :junk-invocations-1-1], :error "never offered"}] +["invocations" [:name] 42 :storage {:stage [:character :junk-invocations-1-1], :error "never offered"}] +["invocations" [:name] :kw :import {:stage [:character :junk-invocations-4-1], :error "never offered"}] +["invocations" [:name] :kw :share {:stage [:character :junk-invocations-4-1], :error "never offered"}] +["invocations" [:name] :kw :storage {:stage [:character :junk-invocations-4-1], :error "never offered"}] +["invocations" [:name] ["x"] :import {:stage [:character :junk-invocations-7-1], :error "never offered"}] +["invocations" [:name] ["x"] :share {:stage [:character :junk-invocations-7-1], :error "never offered"}] +["invocations" [:name] ["x"] :storage {:stage [:character :junk-invocations-7-1], :error "never offered"}] +["invocations" [:name] [42] :import {:stage [:character :junk-invocations-6-1], :error "never offered"}] +["invocations" [:name] [42] :share {:stage [:character :junk-invocations-6-1], :error "never offered"}] +["invocations" [:name] [42] :storage {:stage [:character :junk-invocations-6-1], :error "never offered"}] +["invocations" [:name] [] :import {:stage [:character :junk-invocations-5-1], :error "never offered"}] +["invocations" [:name] [] :share {:stage [:character :junk-invocations-5-1], :error "never offered"}] +["invocations" [:name] [] :storage {:stage [:character :junk-invocations-5-1], :error "never offered"}] +["invocations" [:name] [{}] :import {:stage [:character :junk-invocations-8-1], :error "never offered"}] +["invocations" [:name] [{}] :share {:stage [:character :junk-invocations-8-1], :error "never offered"}] +["invocations" [:name] [{}] :storage {:stage [:character :junk-invocations-8-1], :error "never offered"}] +["invocations" [:name] nil :share {:stage [:character :junk-invocations-0-1], :error "never offered"}] +["invocations" [:name] nil :storage {:stage [:character :junk-invocations-0-1], :error "never offered"}] +["invocations" [:name] true :import {:stage [:character :junk-invocations-3-1], :error "never offered"}] +["invocations" [:name] true :share {:stage [:character :junk-invocations-3-1], :error "never offered"}] +["invocations" [:name] true :storage {:stage [:character :junk-invocations-3-1], :error "never offered"}] +["invocations" [:name] {:a 1} :import {:stage [:character :junk-invocations-10-1], :error "never offered"}] +["invocations" [:name] {:a 1} :share {:stage [:character :junk-invocations-10-1], :error "never offered"}] +["invocations" [:name] {:a 1} :storage {:stage [:character :junk-invocations-10-1], :error "never offered"}] +["invocations" [:name] {} :import {:stage [:character :junk-invocations-9-1], :error "never offered"}] +["invocations" [:name] {} :share {:stage [:character :junk-invocations-9-1], :error "never offered"}] +["invocations" [:name] {} :storage {:stage [:character :junk-invocations-9-1], :error "never offered"}] ["invocations" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["invocations" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] ["languages" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["languages" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] +["monsters" [:name] #{42} :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare #{42} to Sea Horse"}] +["monsters" [:name] #{42} :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare #{42} to Sea Horse"}] +["monsters" [:name] #{42} :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare #{42} to Sea Horse"}] +["monsters" [:name] 42 :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare 42 to Sea Horse"}] +["monsters" [:name] 42 :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare 42 to Sea Horse"}] +["monsters" [:name] 42 :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare 42 to Sea Horse"}] +["monsters" [:name] :kw :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare :kw to Sea Horse"}] +["monsters" [:name] :kw :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare :kw to Sea Horse"}] +["monsters" [:name] :kw :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare :kw to Sea Horse"}] +["monsters" [:name] ["x"] :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [\"x\"] to Sea Horse"}] +["monsters" [:name] ["x"] :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [\"x\"] to Sea Horse"}] +["monsters" [:name] ["x"] :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [\"x\"] to Sea Horse"}] +["monsters" [:name] [42] :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [42] to Sea Horse"}] +["monsters" [:name] [42] :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [42] to Sea Horse"}] +["monsters" [:name] [42] :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [42] to Sea Horse"}] +["monsters" [:name] [] :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [] to Sea Horse"}] +["monsters" [:name] [] :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [] to Sea Horse"}] +["monsters" [:name] [] :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [] to Sea Horse"}] +["monsters" [:name] [{}] :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [{}] to Sea Horse"}] +["monsters" [:name] [{}] :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [{}] to Sea Horse"}] +["monsters" [:name] [{}] :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare [{}] to Sea Horse"}] +["monsters" [:name] nil :share {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'toLowerCase')"}] +["monsters" [:name] nil :storage {:stage [:page :monster-list], :error "Cannot read properties of null (reading 'toLowerCase')"}] +["monsters" [:name] true :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare true to Sea Horse"}] +["monsters" [:name] true :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare true to Sea Horse"}] +["monsters" [:name] true :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare true to Sea Horse"}] +["monsters" [:name] {:a 1} :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {:a 1} to Sea Horse"}] +["monsters" [:name] {:a 1} :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {:a 1} to Sea Horse"}] +["monsters" [:name] {:a 1} :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {:a 1} to Sea Horse"}] +["monsters" [:name] {} :import {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {} to Sea Horse"}] +["monsters" [:name] {} :share {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {} to Sea Horse"}] +["monsters" [:name] {} :storage {:stage [:orcpub.dnd.e5.monsters/sorted-monsters], :error "Cannot compare {} to Sea Horse"}] ["monsters" [:size] "x" :import {:stage [:page :monster-list], :error "size.call is not a function"}] ["monsters" [:size] "x" :share {:stage [:page :monster-list], :error "size.call is not a function"}] ["monsters" [:size] "x" :storage {:stage [:page :monster-list], :error "size.call is not a function"}] @@ -255,9 +352,9 @@ ["monsters" [:subtypes] true :import {:stage [:page :monster-list], :error "true is not ISeqable"}] ["monsters" [:subtypes] true :share {:stage [:page :monster-list], :error "true is not ISeqable"}] ["monsters" [:subtypes] true :storage {:stage [:page :monster-list], :error "true is not ISeqable"}] -["monsters" [:traits] 42 :share {:stage [:builder :junk-21], :error "42 is not ISeqable"}] -["monsters" [:traits] :kw :share {:stage [:builder :junk-21], :error ":kw is not ISeqable"}] -["monsters" [:traits] true :share {:stage [:builder :junk-21], :error "true is not ISeqable"}] +["monsters" [:traits] 42 :share {:stage [:builder :junk-monsters-1-21], :error "42 is not ISeqable"}] +["monsters" [:traits] :kw :share {:stage [:builder :junk-monsters-1-21], :error ":kw is not ISeqable"}] +["monsters" [:traits] true :share {:stage [:builder :junk-monsters-0-21], :error "true is not ISeqable"}] ["monsters" [:type] "x" :import {:stage [:page :monster-list], :error "type.call is not a function"}] ["monsters" [:type] "x" :share {:stage [:page :monster-list], :error "type.call is not a function"}] ["monsters" [:type] "x" :storage {:stage [:page :monster-list], :error "type.call is not a function"}] @@ -284,215 +381,279 @@ ["monsters" [:type] true :storage {:stage [:page :monster-list], :error "type.call is not a function"}] ["monsters" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["monsters" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] -["races" [:abilities] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["races" [:abilities] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["races" [:abilities] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["races" [:abilities] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["races" [:abilities] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["races" [:abilities] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["races" [:abilities] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["races" [:abilities] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["races" [:abilities] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["races" [:abilities] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["races" [:abilities] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["races" [:abilities] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["races" [:abilities] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] -["races" [:abilities] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] -["races" [:abilities] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] -["races" [:abilities] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["races" [:abilities] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["races" [:abilities] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["races" [:languages] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["races" [:languages] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["races" [:languages] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["races" [:languages] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["races" [:languages] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["races" [:languages] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["races" [:languages] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["races" [:languages] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["races" [:languages] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["races" [:props :damage-resistance] "x" :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["races" [:props :damage-resistance] "x" :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["races" [:props :damage-resistance] "x" :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["races" [:props :damage-resistance] #{42} :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["races" [:props :damage-resistance] #{42} :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["races" [:props :damage-resistance] #{42} :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["races" [:props :damage-resistance] ["x"] :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["races" [:props :damage-resistance] ["x"] :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["races" [:props :damage-resistance] ["x"] :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["races" [:props :damage-resistance] [42] :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["races" [:props :damage-resistance] [42] :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["races" [:props :damage-resistance] [42] :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["races" [:props :damage-resistance] [{}] :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] -["races" [:props :damage-resistance] [{}] :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] -["races" [:props :damage-resistance] [{}] :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] -["races" [:spells] 42 :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "42 is not ISeqable"}] -["races" [:spells] 42 :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "42 is not ISeqable"}] -["races" [:spells] 42 :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "42 is not ISeqable"}] -["races" [:spells] :kw :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error ":kw is not ISeqable"}] -["races" [:spells] :kw :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error ":kw is not ISeqable"}] -["races" [:spells] :kw :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error ":kw is not ISeqable"}] -["races" [:spells] true :import {:stage [:orcpub.dnd.e5.races/plugin-races], :error "true is not ISeqable"}] -["races" [:spells] true :share {:stage [:orcpub.dnd.e5.races/plugin-races], :error "true is not ISeqable"}] -["races" [:spells] true :storage {:stage [:orcpub.dnd.e5.races/plugin-races], :error "true is not ISeqable"}] -["races" [:traits] "x" :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] -["races" [:traits] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["races" [:traits] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["races" [:traits] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["races" [:traits] ["x"] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] -["races" [:traits] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["races" [:traits] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["races" [:abilities] #{42} :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] #{42} :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] #{42} :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [42] :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [42] :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [42] :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [{}] :import {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [{}] :share {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] [{}] :storage {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:abilities] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:languages] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:props :damage-resistance] "x" :import {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] "x" :share {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] "x" :storage {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] #{42} :import {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] #{42} :share {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] #{42} :storage {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] ["x"] :import {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] ["x"] :share {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] ["x"] :storage {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [42] :import {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [42] :share {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [42] :storage {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [{}] :import {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [{}] :share {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:props :damage-resistance] [{}] :storage {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:spells] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-races]}] +["races" [:traits] "x" :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] #{42} :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] ["x"] :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] [42] :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["races" [:traits] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] ["races" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["races" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] -["selections" [:options] 42 :share {:stage [:builder :junk-0], :error "42 is not ISeqable"}] -["selections" [:options] :kw :share {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] -["selections" [:options] true :share {:stage [:builder :junk-0], :error "true is not ISeqable"}] +["selections" [:options] 42 :share {:stage [:builder :junk-selections-1-0], :error "42 is not ISeqable"}] +["selections" [:options] :kw :share {:stage [:builder :junk-selections-4-0], :error ":kw is not ISeqable"}] +["selections" [:options] true :share {:stage [:builder :junk-selections-3-0], :error "true is not ISeqable"}] ["selections" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["selections" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] ["spells" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["spells" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] -["subclasses" [:class] :kw :import {:stage [:character :junk-4], :error "never offered"}] -["subclasses" [:class] :kw :share {:stage [:character :junk-4], :error "never offered"}] -["subclasses" [:class] :kw :storage {:stage [:character :junk-4], :error "never offered"}] -["subclasses" [:level-modifiers 0 :type] #{42} :import {:stage [:builder :junk-7], :error "Doesn't support name: #{42}"}] -["subclasses" [:level-modifiers 0 :type] #{42} :share {:stage [:builder :junk-7], :error "Doesn't support name: #{42}"}] -["subclasses" [:level-modifiers 0 :type] #{42} :storage {:stage [:builder :junk-7], :error "Doesn't support name: #{42}"}] -["subclasses" [:level-modifiers 0 :type] 42 :import {:stage [:builder :junk-7], :error "Doesn't support name: 42"}] -["subclasses" [:level-modifiers 0 :type] 42 :share {:stage [:builder :junk-7], :error "Doesn't support name: 42"}] -["subclasses" [:level-modifiers 0 :type] 42 :storage {:stage [:builder :junk-7], :error "Doesn't support name: 42"}] -["subclasses" [:level-modifiers 0 :type] ["x"] :import {:stage [:builder :junk-7], :error "Doesn't support name: [\"x\"]"}] -["subclasses" [:level-modifiers 0 :type] ["x"] :share {:stage [:builder :junk-7], :error "Doesn't support name: [\"x\"]"}] -["subclasses" [:level-modifiers 0 :type] ["x"] :storage {:stage [:builder :junk-7], :error "Doesn't support name: [\"x\"]"}] -["subclasses" [:level-modifiers 0 :type] [42] :import {:stage [:builder :junk-7], :error "Doesn't support name: [42]"}] -["subclasses" [:level-modifiers 0 :type] [42] :share {:stage [:builder :junk-7], :error "Doesn't support name: [42]"}] -["subclasses" [:level-modifiers 0 :type] [42] :storage {:stage [:builder :junk-7], :error "Doesn't support name: [42]"}] -["subclasses" [:level-modifiers 0 :type] [] :import {:stage [:builder :junk-7], :error "Doesn't support name: []"}] -["subclasses" [:level-modifiers 0 :type] [] :share {:stage [:builder :junk-7], :error "Doesn't support name: []"}] -["subclasses" [:level-modifiers 0 :type] [] :storage {:stage [:builder :junk-7], :error "Doesn't support name: []"}] -["subclasses" [:level-modifiers 0 :type] [{}] :import {:stage [:builder :junk-7], :error "Doesn't support name: [{}]"}] -["subclasses" [:level-modifiers 0 :type] [{}] :share {:stage [:builder :junk-7], :error "Doesn't support name: [{}]"}] -["subclasses" [:level-modifiers 0 :type] [{}] :storage {:stage [:builder :junk-7], :error "Doesn't support name: [{}]"}] -["subclasses" [:level-modifiers 0 :type] true :import {:stage [:builder :junk-7], :error "Doesn't support name: true"}] -["subclasses" [:level-modifiers 0 :type] true :share {:stage [:builder :junk-7], :error "Doesn't support name: true"}] -["subclasses" [:level-modifiers 0 :type] true :storage {:stage [:builder :junk-7], :error "Doesn't support name: true"}] -["subclasses" [:level-modifiers 0 :type] {:a 1} :import {:stage [:builder :junk-7], :error "Doesn't support name: {:a 1}"}] -["subclasses" [:level-modifiers 0 :type] {:a 1} :share {:stage [:builder :junk-7], :error "Doesn't support name: {:a 1}"}] -["subclasses" [:level-modifiers 0 :type] {:a 1} :storage {:stage [:builder :junk-7], :error "Doesn't support name: {:a 1}"}] -["subclasses" [:level-modifiers 0 :type] {} :import {:stage [:builder :junk-7], :error "Doesn't support name: {}"}] -["subclasses" [:level-modifiers 0 :type] {} :share {:stage [:builder :junk-7], :error "Doesn't support name: {}"}] -["subclasses" [:level-modifiers 0 :type] {} :storage {:stage [:builder :junk-7], :error "Doesn't support name: {}"}] -["subclasses" [:level-modifiers] 42 :import {:stage [:builder :junk-0], :error "42 is not ISeqable"}] -["subclasses" [:level-modifiers] 42 :share {:stage [:builder :junk-0], :error "42 is not ISeqable"}] -["subclasses" [:level-modifiers] 42 :storage {:stage [:builder :junk-0], :error "42 is not ISeqable"}] -["subclasses" [:level-modifiers] :kw :import {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] -["subclasses" [:level-modifiers] :kw :share {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] -["subclasses" [:level-modifiers] :kw :storage {:stage [:builder :junk-0], :error ":kw is not ISeqable"}] -["subclasses" [:level-modifiers] true :import {:stage [:builder :junk-0], :error "true is not ISeqable"}] -["subclasses" [:level-modifiers] true :share {:stage [:builder :junk-0], :error "true is not ISeqable"}] -["subclasses" [:level-modifiers] true :storage {:stage [:builder :junk-0], :error "true is not ISeqable"}] -["subclasses" [:level-selections] 42 :import {:stage [:builder :junk-3], :error "42 is not ISeqable"}] -["subclasses" [:level-selections] 42 :share {:stage [:builder :junk-3], :error "42 is not ISeqable"}] -["subclasses" [:level-selections] 42 :storage {:stage [:builder :junk-3], :error "42 is not ISeqable"}] -["subclasses" [:level-selections] :kw :import {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] -["subclasses" [:level-selections] :kw :share {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] -["subclasses" [:level-selections] :kw :storage {:stage [:builder :junk-3], :error ":kw is not ISeqable"}] -["subclasses" [:level-selections] true :import {:stage [:builder :junk-3], :error "true is not ISeqable"}] -["subclasses" [:level-selections] true :share {:stage [:builder :junk-3], :error "true is not ISeqable"}] -["subclasses" [:level-selections] true :storage {:stage [:builder :junk-3], :error "true is not ISeqable"}] -["subclasses" [:traits] "x" :share {:stage [:character :junk-6], :error "conj on a map takes map entries or seqables of map entries"}] -["subclasses" [:traits] #{42} :share {:stage [:character :junk-6], :error "42 is not ISeqable"}] -["subclasses" [:traits] 42 :share {:stage [:character :junk-6], :error "42 is not ISeqable"}] -["subclasses" [:traits] :kw :share {:stage [:character :junk-6], :error ":kw is not ISeqable"}] -["subclasses" [:traits] ["x"] :share {:stage [:character :junk-6], :error "conj on a map takes map entries or seqables of map entries"}] -["subclasses" [:traits] [42] :share {:stage [:character :junk-6], :error "42 is not ISeqable"}] -["subclasses" [:traits] true :share {:stage [:character :junk-6], :error "true is not ISeqable"}] +["subclasses" [:class] :kw :import {:stage [:character :junk-subclasses-1-4], :error "never offered"}] +["subclasses" [:class] :kw :share {:stage [:character :junk-subclasses-1-4], :error "never offered"}] +["subclasses" [:class] :kw :storage {:stage [:character :junk-subclasses-1-4], :error "never offered"}] +["subclasses" [:level-modifiers 0 :type] #{42} :import {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: #{42}"}] +["subclasses" [:level-modifiers 0 :type] #{42} :share {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: #{42}"}] +["subclasses" [:level-modifiers 0 :type] #{42} :storage {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: #{42}"}] +["subclasses" [:level-modifiers 0 :type] 42 :import {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: 42"}] +["subclasses" [:level-modifiers 0 :type] 42 :share {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: 42"}] +["subclasses" [:level-modifiers 0 :type] 42 :storage {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: 42"}] +["subclasses" [:level-modifiers 0 :type] ["x"] :import {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: [\"x\"]"}] +["subclasses" [:level-modifiers 0 :type] ["x"] :share {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: [\"x\"]"}] +["subclasses" [:level-modifiers 0 :type] ["x"] :storage {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: [\"x\"]"}] +["subclasses" [:level-modifiers 0 :type] [42] :import {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: [42]"}] +["subclasses" [:level-modifiers 0 :type] [42] :share {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: [42]"}] +["subclasses" [:level-modifiers 0 :type] [42] :storage {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: [42]"}] +["subclasses" [:level-modifiers 0 :type] [] :import {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: []"}] +["subclasses" [:level-modifiers 0 :type] [] :share {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: []"}] +["subclasses" [:level-modifiers 0 :type] [] :storage {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: []"}] +["subclasses" [:level-modifiers 0 :type] [{}] :import {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: [{}]"}] +["subclasses" [:level-modifiers 0 :type] [{}] :share {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: [{}]"}] +["subclasses" [:level-modifiers 0 :type] [{}] :storage {:stage [:builder :junk-subclasses-2-7], :error "Doesn't support name: [{}]"}] +["subclasses" [:level-modifiers 0 :type] true :import {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: true"}] +["subclasses" [:level-modifiers 0 :type] true :share {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: true"}] +["subclasses" [:level-modifiers 0 :type] true :storage {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: true"}] +["subclasses" [:level-modifiers 0 :type] {:a 1} :import {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: {:a 1}"}] +["subclasses" [:level-modifiers 0 :type] {:a 1} :share {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: {:a 1}"}] +["subclasses" [:level-modifiers 0 :type] {:a 1} :storage {:stage [:builder :junk-subclasses-1-7], :error "Doesn't support name: {:a 1}"}] +["subclasses" [:level-modifiers 0 :type] {} :import {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: {}"}] +["subclasses" [:level-modifiers 0 :type] {} :share {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: {}"}] +["subclasses" [:level-modifiers 0 :type] {} :storage {:stage [:builder :junk-subclasses-0-7], :error "Doesn't support name: {}"}] +["subclasses" [:level-modifiers] 42 :import {:stage [:builder :junk-subclasses-1-0], :error "42 is not ISeqable"}] +["subclasses" [:level-modifiers] 42 :share {:stage [:builder :junk-subclasses-1-0], :error "42 is not ISeqable"}] +["subclasses" [:level-modifiers] 42 :storage {:stage [:builder :junk-subclasses-1-0], :error "42 is not ISeqable"}] +["subclasses" [:level-modifiers] :kw :import {:stage [:builder :junk-subclasses-1-0], :error ":kw is not ISeqable"}] +["subclasses" [:level-modifiers] :kw :share {:stage [:builder :junk-subclasses-1-0], :error ":kw is not ISeqable"}] +["subclasses" [:level-modifiers] :kw :storage {:stage [:builder :junk-subclasses-1-0], :error ":kw is not ISeqable"}] +["subclasses" [:level-modifiers] true :import {:stage [:builder :junk-subclasses-0-0], :error "true is not ISeqable"}] +["subclasses" [:level-modifiers] true :share {:stage [:builder :junk-subclasses-0-0], :error "true is not ISeqable"}] +["subclasses" [:level-modifiers] true :storage {:stage [:builder :junk-subclasses-0-0], :error "true is not ISeqable"}] +["subclasses" [:level-selections] 42 :import {:stage [:builder :junk-subclasses-1-3], :error "42 is not ISeqable"}] +["subclasses" [:level-selections] 42 :share {:stage [:builder :junk-subclasses-1-3], :error "42 is not ISeqable"}] +["subclasses" [:level-selections] 42 :storage {:stage [:builder :junk-subclasses-1-3], :error "42 is not ISeqable"}] +["subclasses" [:level-selections] :kw :import {:stage [:builder :junk-subclasses-1-3], :error ":kw is not ISeqable"}] +["subclasses" [:level-selections] :kw :share {:stage [:builder :junk-subclasses-1-3], :error ":kw is not ISeqable"}] +["subclasses" [:level-selections] :kw :storage {:stage [:builder :junk-subclasses-1-3], :error ":kw is not ISeqable"}] +["subclasses" [:level-selections] true :import {:stage [:builder :junk-subclasses-0-3], :error "true is not ISeqable"}] +["subclasses" [:level-selections] true :share {:stage [:builder :junk-subclasses-0-3], :error "true is not ISeqable"}] +["subclasses" [:level-selections] true :storage {:stage [:builder :junk-subclasses-0-3], :error "true is not ISeqable"}] +["subclasses" [:name] #{42} :import {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] #{42} :share {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] #{42} :storage {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] 42 :import {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] 42 :share {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] 42 :storage {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] :kw :import {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] :kw :share {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] :kw :storage {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] ["x"] :import {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] ["x"] :share {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] ["x"] :storage {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] [42] :import {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] [42] :share {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] [42] :storage {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] [] :import {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [] :share {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [] :storage {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [{}] :import {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [{}] :share {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] [{}] :storage {:stage [:character :junk-subclasses-2-1], :error "never offered"}] +["subclasses" [:name] nil :share {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] nil :storage {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] true :import {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] true :share {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] true :storage {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] {:a 1} :import {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] {:a 1} :share {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] {:a 1} :storage {:stage [:character :junk-subclasses-1-1], :error "never offered"}] +["subclasses" [:name] {} :import {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] {} :share {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:name] {} :storage {:stage [:character :junk-subclasses-0-1], :error "never offered"}] +["subclasses" [:traits] "x" :share {:stage [:character :junk-subclasses-2-6], :error "conj on a map takes map entries or seqables of map entries"}] +["subclasses" [:traits] #{42} :share {:stage [:character :junk-subclasses-2-6], :error "42 is not ISeqable"}] +["subclasses" [:traits] 42 :share {:stage [:character :junk-subclasses-1-6], :error "42 is not ISeqable"}] +["subclasses" [:traits] :kw :share {:stage [:character :junk-subclasses-1-6], :error ":kw is not ISeqable"}] +["subclasses" [:traits] ["x"] :share {:stage [:character :junk-subclasses-1-6], :error "conj on a map takes map entries or seqables of map entries"}] +["subclasses" [:traits] [42] :share {:stage [:character :junk-subclasses-0-6], :error "42 is not ISeqable"}] +["subclasses" [:traits] true :share {:stage [:character :junk-subclasses-0-6], :error "true is not ISeqable"}] ["subclasses" true :import {:stage :startup, :error "true is not ISeqable"}] ["subclasses" true :share {:stage [:orcpub.dnd.e5.classes/plugin-subclasses], :error "true is not ISeqable"}] ["subclasses" true :storage {:stage :startup, :error "true is not ISeqable"}] -["subraces" [:abilities] #{42} :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["subraces" [:abilities] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["subraces" [:abilities] #{42} :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["subraces" [:abilities] 42 :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["subraces" [:abilities] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["subraces" [:abilities] 42 :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["subraces" [:abilities] :kw :import {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["subraces" [:abilities] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["subraces" [:abilities] :kw :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["subraces" [:abilities] [42] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["subraces" [:abilities] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["subraces" [:abilities] [42] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type function Number() { [native code] }"}] -["subraces" [:abilities] [{}] :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] -["subraces" [:abilities] [{}] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] -["subraces" [:abilities] [{}] :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "nth not supported on this type cljs.core/PersistentArrayMap"}] -["subraces" [:abilities] true :import {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["subraces" [:abilities] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["subraces" [:abilities] true :storage {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] -["subraces" [:props :weapon-prof] "x" :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["subraces" [:props :weapon-prof] "x" :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["subraces" [:props :weapon-prof] "x" :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["subraces" [:props :weapon-prof] #{42} :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["subraces" [:props :weapon-prof] #{42} :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["subraces" [:props :weapon-prof] #{42} :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["subraces" [:props :weapon-prof] ["x"] :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["subraces" [:props :weapon-prof] ["x"] :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["subraces" [:props :weapon-prof] ["x"] :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type string: x"}] -["subraces" [:props :weapon-prof] [42] :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["subraces" [:props :weapon-prof] [42] :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["subraces" [:props :weapon-prof] [42] :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type number: 42"}] -["subraces" [:props :weapon-prof] [{}] :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] -["subraces" [:props :weapon-prof] [{}] :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] -["subraces" [:props :weapon-prof] [{}] :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}"}] -["subraces" [:race] "x" :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] "x" :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] "x" :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] #{42} :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] #{42} :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] #{42} :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] 42 :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] 42 :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] 42 :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] :kw :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] :kw :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] :kw :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] ["x"] :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] ["x"] :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] ["x"] :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] [42] :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] [42] :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] [42] :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] [] :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] [] :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] [] :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] [{}] :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] [{}] :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] [{}] :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] nil :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] nil :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] nil :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] true :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] true :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] true :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] {:a 1} :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] {:a 1} :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] {:a 1} :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] {} :import {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] {} :share {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:race] {} :storage {:stage [:character :junk-0], :error "never offered"}] -["subraces" [:spells] 42 :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "42 is not ISeqable"}] -["subraces" [:spells] 42 :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "42 is not ISeqable"}] -["subraces" [:spells] 42 :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "42 is not ISeqable"}] -["subraces" [:spells] :kw :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error ":kw is not ISeqable"}] -["subraces" [:spells] :kw :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error ":kw is not ISeqable"}] -["subraces" [:spells] :kw :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error ":kw is not ISeqable"}] -["subraces" [:spells] true :import {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "true is not ISeqable"}] -["subraces" [:spells] true :share {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "true is not ISeqable"}] -["subraces" [:spells] true :storage {:stage [:orcpub.dnd.e5.races/plugin-subraces], :error "true is not ISeqable"}] -["subraces" [:traits] "x" :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] -["subraces" [:traits] #{42} :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["subraces" [:traits] 42 :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["subraces" [:traits] :kw :share {:stage [:orcpub.dnd.e5.character/template-selections], :error ":kw is not ISeqable"}] -["subraces" [:traits] ["x"] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "conj on a map takes map entries or seqables of map entries"}] -["subraces" [:traits] [42] :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "42 is not ISeqable"}] -["subraces" [:traits] true :share {:stage [:orcpub.dnd.e5.character/template-selections], :error "true is not ISeqable"}] +["subraces" [:abilities] #{42} :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] #{42} :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] #{42} :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [42] :import {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [42] :share {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [42] :storage {:error "nth not supported on this type function Number() { [native code] }", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [{}] :import {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [{}] :share {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] [{}] :storage {:error "nth not supported on this type cljs.core/PersistentArrayMap", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:abilities] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:name] #{42} :import {:stage [:character :junk-subraces-11-1], :error "never offered"}] +["subraces" [:name] #{42} :share {:stage [:character :junk-subraces-11-1], :error "never offered"}] +["subraces" [:name] #{42} :storage {:stage [:character :junk-subraces-11-1], :error "never offered"}] +["subraces" [:name] 42 :import {:stage [:character :junk-subraces-1-1], :error "never offered"}] +["subraces" [:name] 42 :share {:stage [:character :junk-subraces-1-1], :error "never offered"}] +["subraces" [:name] 42 :storage {:stage [:character :junk-subraces-1-1], :error "never offered"}] +["subraces" [:name] :kw :import {:stage [:character :junk-subraces-4-1], :error "never offered"}] +["subraces" [:name] :kw :share {:stage [:character :junk-subraces-4-1], :error "never offered"}] +["subraces" [:name] :kw :storage {:stage [:character :junk-subraces-4-1], :error "never offered"}] +["subraces" [:name] ["x"] :import {:stage [:character :junk-subraces-7-1], :error "never offered"}] +["subraces" [:name] ["x"] :share {:stage [:character :junk-subraces-7-1], :error "never offered"}] +["subraces" [:name] ["x"] :storage {:stage [:character :junk-subraces-7-1], :error "never offered"}] +["subraces" [:name] [42] :import {:stage [:character :junk-subraces-6-1], :error "never offered"}] +["subraces" [:name] [42] :share {:stage [:character :junk-subraces-6-1], :error "never offered"}] +["subraces" [:name] [42] :storage {:stage [:character :junk-subraces-6-1], :error "never offered"}] +["subraces" [:name] [] :import {:stage [:character :junk-subraces-5-1], :error "never offered"}] +["subraces" [:name] [] :share {:stage [:character :junk-subraces-5-1], :error "never offered"}] +["subraces" [:name] [] :storage {:stage [:character :junk-subraces-5-1], :error "never offered"}] +["subraces" [:name] [{}] :import {:stage [:character :junk-subraces-8-1], :error "never offered"}] +["subraces" [:name] [{}] :share {:stage [:character :junk-subraces-8-1], :error "never offered"}] +["subraces" [:name] [{}] :storage {:stage [:character :junk-subraces-8-1], :error "never offered"}] +["subraces" [:name] nil :share {:stage [:character :junk-subraces-0-1], :error "never offered"}] +["subraces" [:name] nil :storage {:stage [:character :junk-subraces-0-1], :error "never offered"}] +["subraces" [:name] true :import {:stage [:character :junk-subraces-3-1], :error "never offered"}] +["subraces" [:name] true :share {:stage [:character :junk-subraces-3-1], :error "never offered"}] +["subraces" [:name] true :storage {:stage [:character :junk-subraces-3-1], :error "never offered"}] +["subraces" [:name] {:a 1} :import {:stage [:character :junk-subraces-10-1], :error "never offered"}] +["subraces" [:name] {:a 1} :share {:stage [:character :junk-subraces-10-1], :error "never offered"}] +["subraces" [:name] {:a 1} :storage {:stage [:character :junk-subraces-10-1], :error "never offered"}] +["subraces" [:name] {} :import {:stage [:character :junk-subraces-9-1], :error "never offered"}] +["subraces" [:name] {} :share {:stage [:character :junk-subraces-9-1], :error "never offered"}] +["subraces" [:name] {} :storage {:stage [:character :junk-subraces-9-1], :error "never offered"}] +["subraces" [:props :weapon-prof] "x" :import {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] "x" :share {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] "x" :storage {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] #{42} :import {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] #{42} :share {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] #{42} :storage {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] ["x"] :import {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] ["x"] :share {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] ["x"] :storage {:error "No protocol method IMapEntry.-val defined for type string: x", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [42] :import {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [42] :share {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [42] :storage {:error "No protocol method IMapEntry.-val defined for type number: 42", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [{}] :import {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [{}] :share {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:props :weapon-prof] [{}] :storage {:error "No protocol method IMapEntry.-val defined for type cljs.core/PersistentArrayMap: {}", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:race] "x" :import {:stage [:character :junk-subraces-2-0], :error "never offered"}] +["subraces" [:race] "x" :share {:stage [:character :junk-subraces-2-0], :error "never offered"}] +["subraces" [:race] "x" :storage {:stage [:character :junk-subraces-2-0], :error "never offered"}] +["subraces" [:race] #{42} :import {:stage [:character :junk-subraces-11-0], :error "never offered"}] +["subraces" [:race] #{42} :share {:stage [:character :junk-subraces-11-0], :error "never offered"}] +["subraces" [:race] #{42} :storage {:stage [:character :junk-subraces-11-0], :error "never offered"}] +["subraces" [:race] 42 :import {:stage [:character :junk-subraces-1-0], :error "never offered"}] +["subraces" [:race] 42 :share {:stage [:character :junk-subraces-1-0], :error "never offered"}] +["subraces" [:race] 42 :storage {:stage [:character :junk-subraces-1-0], :error "never offered"}] +["subraces" [:race] :kw :import {:stage [:character :junk-subraces-4-0], :error "never offered"}] +["subraces" [:race] :kw :share {:stage [:character :junk-subraces-4-0], :error "never offered"}] +["subraces" [:race] :kw :storage {:stage [:character :junk-subraces-4-0], :error "never offered"}] +["subraces" [:race] ["x"] :import {:stage [:character :junk-subraces-7-0], :error "never offered"}] +["subraces" [:race] ["x"] :share {:stage [:character :junk-subraces-7-0], :error "never offered"}] +["subraces" [:race] ["x"] :storage {:stage [:character :junk-subraces-7-0], :error "never offered"}] +["subraces" [:race] [42] :import {:stage [:character :junk-subraces-6-0], :error "never offered"}] +["subraces" [:race] [42] :share {:stage [:character :junk-subraces-6-0], :error "never offered"}] +["subraces" [:race] [42] :storage {:stage [:character :junk-subraces-6-0], :error "never offered"}] +["subraces" [:race] [] :import {:stage [:character :junk-subraces-5-0], :error "never offered"}] +["subraces" [:race] [] :share {:stage [:character :junk-subraces-5-0], :error "never offered"}] +["subraces" [:race] [] :storage {:stage [:character :junk-subraces-5-0], :error "never offered"}] +["subraces" [:race] [{}] :import {:stage [:character :junk-subraces-8-0], :error "never offered"}] +["subraces" [:race] [{}] :share {:stage [:character :junk-subraces-8-0], :error "never offered"}] +["subraces" [:race] [{}] :storage {:stage [:character :junk-subraces-8-0], :error "never offered"}] +["subraces" [:race] nil :import {:stage [:character :junk-subraces-0-0], :error "never offered"}] +["subraces" [:race] nil :share {:stage [:character :junk-subraces-0-0], :error "never offered"}] +["subraces" [:race] nil :storage {:stage [:character :junk-subraces-0-0], :error "never offered"}] +["subraces" [:race] true :import {:stage [:character :junk-subraces-3-0], :error "never offered"}] +["subraces" [:race] true :share {:stage [:character :junk-subraces-3-0], :error "never offered"}] +["subraces" [:race] true :storage {:stage [:character :junk-subraces-3-0], :error "never offered"}] +["subraces" [:race] {:a 1} :import {:stage [:character :junk-subraces-10-0], :error "never offered"}] +["subraces" [:race] {:a 1} :share {:stage [:character :junk-subraces-10-0], :error "never offered"}] +["subraces" [:race] {:a 1} :storage {:stage [:character :junk-subraces-10-0], :error "never offered"}] +["subraces" [:race] {} :import {:stage [:character :junk-subraces-9-0], :error "never offered"}] +["subraces" [:race] {} :share {:stage [:character :junk-subraces-9-0], :error "never offered"}] +["subraces" [:race] {} :storage {:stage [:character :junk-subraces-9-0], :error "never offered"}] +["subraces" [:spells] 42 :import {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] 42 :storage {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] :kw :import {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] :kw :storage {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] true :import {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:spells] true :storage {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.races/plugin-subraces]}] +["subraces" [:traits] "x" :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] #{42} :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] 42 :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] :kw :share {:error ":kw is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] ["x"] :share {:error "conj on a map takes map entries or seqables of map entries", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] [42] :share {:error "42 is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] +["subraces" [:traits] true :share {:error "true is not ISeqable", :stage [:orcpub.dnd.e5.character/template-selections]}] ["subraces" true :import {:stage [:page :my-content], :error "true is not ISeqable"}] ["subraces" true :storage {:stage [:page :my-content], :error "true is not ISeqable"}] diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index d72f564ff..c8360aa5f 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -55,11 +55,15 @@ and each entry's builder. It fails when anything throws, when a built-in class, goes missing, when something other than the junk entry is set aside, and when an entry is neither offered nor set aside. (That last check does not yet read My Content's link marks.) -`node test/e2e/junk-pack-audit.js` runs it all, one content type per page (about two and a half -hours on integration, most of it classes); the everyday suite runs only the clean base pack. +`lein fig:junk && node test/e2e/junk-pack-audit.js` runs it all on four pages at once in about +8 minutes on integration; the everyday suite runs only the clean base pack. Each pack holds one +type's entries with every junk value, a failure is traced to its entry within the run, and an +entry that arrives the same by all three ways is built and drawn once. -On integration `d51938c1`: **498 failures over 46 fields**, listed in full in -`HOMEBREW_GUARD-baseline.txt`. Every one is a field nothing checks the shape of. +On integration `d51938c1`: **659 failures over 51 fields**, listed in full in +`HOMEBREW_GUARD-baseline.txt`. Every one is a field nothing checks the shape of. The first audit +(498) never tested a junk `:name`, because it renamed every entry after putting the junk in; the +161 more are those, and the faster audit reproduces the other 498 exactly. | Where | What breaks it | Through | |---|---|---| @@ -67,7 +71,8 @@ On integration `d51938c1`: **498 failures over 46 fields**, listed in full in | **Every character page** (the option list the builder and sheets share) | races and subraces `:abilities`, races `:languages`, backgrounds `:equipment-choices` and its `:options`, backgrounds `:profs :skill`, feats `:prereqs`, classes `:ability-increase-levels` | all three | | **The race or subrace, once used** (thrown after the guard let it through) | races `:spells`, `:props :damage-resistance`; subraces `:spells`, `:props :weapon-prof` | all three | | **My Content** | any of the 13 sections being `true` | stored, imported | -| **The monster list** | monsters `:size`, `:type`, `:subtypes` | all three | +| **The monster list** | monsters `:size`, `:type`, `:subtypes`, `:name` | all three | +| **Not offered, not reported** | a junk `:name` on a subrace, subclass, invocation or boon (boons and invocations key their option by name; why subraces and subclasses drop out is still to trace) | all three | | **Builders** | classes and subclasses `:level-modifiers` (and each one's `:type`), `:level-selections`; classes `:spellcasting :cantrips-known`; encounters `:creatures`; feats `:ability-increases` | all three | | **Share links only** (shared content skips the load repairs) | `:traits` on races, subraces, backgrounds, classes, subclasses and monsters; selections `:options`; a `true` `classes` or `subclasses` section | share | | **Not offered** | a subrace whose `:race`, or a subclass whose `:class`, is not a real one: marked in My Content as a link to nothing once the template is built, but not offered in the character builder; a junk parent that is not a keyword is probably not marked | all three | @@ -122,79 +127,175 @@ bug instead of the instance: ## The design -### 1. A shape gate in front of everything - -- **The shapes come from what already exists**, not a new table: the field schemas - (`field_schemas.cljc`, `builder_fields.cljc`, `content_types.cljc`, built June 2026) describe - each builder's fields and generate its save spec. They live on grant-rows, not integration (see - open questions). Fields the schemas do not cover yet are added to them. -- **Loading is never stricter than saving.** The gate accepts everything any builder writes, +The app trusts the shape of everything that passes a thin load check. The design stops trusting +it at two kinds of seam, so nothing downstream has to change, and contains and reports whatever +still gets through. + +### 1. One cleaning step, from a table of shapes + +- **`homebrew_shapes.cljc`** (cljc, a leaf: requires nothing from the app) says, for each of the + 13 content types, every field the app reads and its shape, nested as deep as the readers go: + `{:abilities [:map-of :ability :number], :spells [:vector-of {...}], ...}`. Fields nobody reads + are not listed and pass untouched. The builder field schemas (`builder_fields.cljc`, on + grant-rows) only cover two grant-rows types, so the 13 core types' shapes are written here; + where a builder schema exists later, it should produce its type's entry in this table. +- **`clean`** takes an entry and returns it with each listed field the right shape, plus what it + found, each `{:path :problem :action}`: + - **fixed** when the meaning is obvious: a string where a list of strings belongs, one card + where a list of cards belongs (what `mend-cards` does today, which becomes table entries); + - **unreadable** when it is not (`:spells 42`, a number where a map belongs). Which of these + sets the entry aside and which reads past the field is the owner's ruling below. + - A whole content section that is not a map is unreadable as a section. +- **Loading is never stricter than saving.** The table accepts everything any builder writes, old forms included (a feat's released set and its 2024 list; race `:languages` as a set of - names), checked by a generated test from the save specs, as `content_specs_test` does today. -- **A field that does not fit is read past, never rewritten.** Where the meaning is obvious - (`:languages "Elvish"`, one trait card where a list belongs) the gate reads it the way it was - meant, as `mend-cards` does today. Where it is not (`:spells 42`) the gate reads the entry - without that field. Either way the stored entry is untouched (data-safety-layers.md: heal on - read, no load-time migration; homebrew-keys-design.md C1). -- **Every one is shown, item by item.** The entry is marked in My Content the way a link to - nothing is, with the field and why, and its builder says the same. A fix is **offered**, through - the commit gate with its backup, never applied silently (C6). Export writes what is stored. -- `readable` (library in, readable library plus a list of what it read past) runs once per change - to the library; the subscriptions, the deep check, shared content and events read through it. - -This alone removes every crash in the baseline: none of them is reachable once the field has the -right shape. - -### 2. One door for conversion - -- No new registry: `guard/register-conversions!` (integration) and `content_types.cljc` - (grant-rows) already list the content types; the conversions join them. -- The converted options come from one parameterized subscription per type, fed only by that - type's readable entries, so editing a spell does not rebuild races. -- Every conversion runs inside the guard. The deep check runs the same registered converters, so it - cannot drift from the live path (on grant-rows it already has: the race check passes the entry - where `grantable-pools` goes and checks nothing). -- All 13 types are registered, not 5. A subrace or subclass failure is blamed on itself. - -### 3. Telling us what it did - -- **One homebrew health log**: every repair read, field read past, entry set aside, link to - nothing and crash a boundary caught, each with its entry, field and reason. It feeds My - Content's item-by-item marks, and a test can read it. -- **Tests read it and check it.** The log names the field, so the audit needs no search; the audit - still watches for throws independently, so a crash the log does not explain is a failure. - -### 4. Enforcement, and tests that cannot pass by accident - -- **A source-scan test**, modelled on `library_gate_test` and `export_paths_test`: every read of - raw homebrew (`::e5/plugins`, `::e5/plugin-vals`, `::e5/plugins-with-sources`, - `::e5/shared-plugins`, `(:plugins db)`) must be in the homebrew namespace or carry a marker - comment saying why it needs the raw data (storage, export, rename, quarantine). -- **Every check proves it can fail**: each stage of the audit has a canary, a known-bad entry it - must catch; a canary that passes fails the audit. -- **Coverage is counted**: the audit checks how many entries each stage actually picked, drew and - converted; fewer than expected fails. -- **The junk audit runs in CI** once the baseline is zero, and the full audit before a merge to - integration. A faster audit replaces the slow one only after it reproduces the same failures. -- **AGENTS.md**: homebrew is read through `readable` and converted through the registry; a safety - claim cites a committed test that runs; a report says what it did not cover. - -## Order of work - -1. This document, the baseline test, and the documents read against it (done). -2. The audit: canaries, counted coverage, the health checks in My Content, and the waste removed - (proven to give the same failures). -3. The shape gate, from the field schemas, with the audit going to zero. -4. The conversions on one registry; the deep check reads it. -5. The health log and the item-by-item marks in My Content. -6. The source-scan test, the CI audit, the AGENTS.md rules; the documents above corrected. -7. grant-rows merges this and registers its pools; its hooks get the same audit. - -## Open questions for the owner - -- **Where to build.** The field schemas the gate should use exist only on grant-rows. Bring them - to integration on this branch first, or build the gate on grant-rows and ship it with grants? -- **Storage path.** A `true` section passes the load spec; closing that changes the load path, - which TODO.md pins as hands-off until deliberately scheduled. Schedule it here? -- **Offered fixes.** Are the obvious fixes offered in My Content (recommended, per C6), or applied - quietly at read with a mark (data-safety-layers.md allows healing on read)? + names). A generated test checks the save specs against it, as `content_specs_test` does today. + +#### The shape forms + +Each form, and in brackets what `clean` does when a value has the wrong shape but its meaning is +obvious: + +``` + :any :string :number :int :keyword :boolean :map + :num a number (a numeric string is read as its number) + :key a keyword (text is read as its keyword: "Medium" -> :medium) + [:enum #{...}] one of these values + [:or s ...] any one of them + [:list s] a vector or list of s (a lone s becomes a list of one) + [:coll s] a vector, list or set of s + [:set s] a set of s (a list of s becomes a set; a lone s a set of one) + [:map-of k v] a map whose keys are k and values v + [:flags k] a map of k to true or false (a set or list of k marks each true) + [:counts k] a map of k to a number (a set or list of k counts each once) + [:indexed s] a map of whole numbers to s (a list of s is numbered from 0) + [:map {field s ...}] a map; the listed fields are checked, the rest pass untouched +``` + +### 2. Entry seams: clean once, store the result + +Where homebrew comes in, the cleaned entry is what is kept, so the same file is not fixed again +on every load and export writes clean data: + +| Seam | Where | What happens | +|---|---|---| +| **Import** | `orcbrew-val/validate-import`, after its own repairs | Fixed entries are stored fixed; unreadable ones go to the quarantine panel with the field and reason. The import report lists both. | +| **Load** (startup, and another tab's write) | the `::e5/plugins` load step, after `mend-library` | The same; a fix is written back once through `library/commit`, after the `plugins:pre-fix` backup, as homebrew-keys-design §4a.2 does for the loader's repairs. Later loads find nothing to fix. | +| **Share link** | `apply-shared-content`, and the party page's share load | Cleaned before the load gate. Shared content is never stored, so an unreadable entry is left out of the view and the banner says so. | + +`LOAD_HOMEBREW_URL` writes into storage, so the load seam covers it. Builder drafts are restored by +`reg-local-store-cofx`, which TODO.md pins as hands-off; drafts are not touched, and the builders +are covered by containment (4). + +### 3. The read seam: nothing reads raw homebrew + +Anything that changed after load (data written straight into the app's memory, an event that +stores raw data, a bug of ours) skips the entry seams. So everything that interprets homebrew +reads it through `readable` (a library in, the cleaned library and its findings out): + +- `process-plugin-vals` and `process-plugins-with-sources`, which every homebrew subscription + already goes through; +- the few event handlers and pages that read `(:plugins db)` or `::e5/plugins` to interpret it + (the class keys at startup, key reconciliation, the former-key index, My Content's lists). + +Storage, export, rename and the quarantine keep reading the raw library, each with a marker +comment saying why; the source-scan test (6) holds that line. + +### 4. Containment for what still gets through + +- **The guard on all 13 types**, through the existing `register-conversions!` list; the deep + check runs the same conversions, so it cannot drift; a subrace or subclass is blamed on itself. +- **Per-item boundaries** (the existing `render-guard`) on My Content's rows, the monster list's + items and each builder form, so one bad item takes down that item, not the page. +- `base-class-options` reports what it catches instead of silently returning no classes. + +### 5. The health log: never silent + +Every finding from `clean`, every entry set aside, every link to nothing and every crash a +boundary catches goes to one log in app-db, each with its entry, field and reason, deduplicated. +It feeds a per-item mark in My Content (the way a link to nothing is marked) and the builder's +note, and the audit reads it to name the failing field. + +### 6. Enforcement, and tests that cannot pass by accident + +- **Source scan** (like `library_gate_test`): a read of `::e5/plugins`, `::e5/plugin-vals`, + `::e5/plugins-with-sources`, `::e5/shared-plugins` or `(:plugins db)` outside the homebrew + namespace needs a marker comment and a reason, or `lein test` fails. +- **The junk audit** (`node test/e2e/junk-pack-audit.js`): every type, field, junk value and way + in; target under 10 minutes; a faster version replaces a slower one only after it reproduces + the same failures. Each stage gets a **canary**, a known-bad entry it must catch; a canary that + passes fails the audit. **Coverage is counted**: fewer entries picked, drawn or converted than + expected fails. +- **AGENTS.md**: homebrew is read through `readable`; a safety claim cites a committed test that + runs; a report says what it did not cover. + +## Efficiency + +Nothing here may make the app slower to use. The costs, and how each is kept small: + +| Cost | Kept small by | Budget | +|---|---|---| +| `clean` over a library | Only listed fields are walked; O(size of the entry). | 12-source test library: under 30 ms | +| The read seam on every library change | It runs inside `process-plugin-vals`, which already walks every entry on each change, not as a second pass. Cleaned entries are cached by entry identity, so editing one entry cleans one entry. | no measurable change | +| Subscriptions downstream | An unchanged entry comes back as the identical object, so the per-type `=` firewall still stops propagation: editing a spell does not rebuild races. | no extra recomputation | +| Events reading the library | `readable` is memoized on the library's identity: one clean per change, shared. | one pass per change | +| Startup | The load seam writes its fixes once; later boots clean and find nothing, and write nothing. | under 30 ms added | +| Import | One pass per file, beside parsing. | negligible | +| Laziness | Not realizing option lists stays as is (realizing everything costs ~50x); clean shapes make the lazy throws unreachable instead. | none | +| Health log | Built in the same pass as `clean`; deduplicated by entry and field; capped. | none | +| Source scan, audit | Build and CI time only. | audit under 10 min | + +**Measured, not assumed:** before and after each step, with the 12-source test library: startup, +`process-plugin-vals`, the template build, one keystroke in a builder, and an import. A step that +adds more than its budget is not merged. + +## Prep work already done, to reuse + +- **testing/develop**: a Playwright suite of 60 scenarios from July 2026, never merged and run + by nothing: `bad-data`, `broken-content-survives-use`, `boot-resilience`, + `error-boundary-verify`, `quarantine-repair`, `resilient-loader` and others. Run against + integration to see what still holds; port what the junk audit does not cover. +- **Fixtures on integration**: `broken-content.orcbrew`, `cruft-shapes.orcbrew`, + `keyword-trap.orcbrew`, `test-pak.orcbrew`: audit inputs beside the junk grid. +- **On integration**: `mend-library`/`mend-cards` (their fixes become table entries), + `library/commit` and `plugins:pre-fix` (the write-back), the quarantine panel and + `repair-quarantined-source` (unreadable entries), `library/dangling` (extend to parents that + are not keywords), `render-guard`/`isolate-culprit` (containment), `register-conversions!` and + `deep-check`, `library_gate_test` (the source scan's model), HOMEBREW_REQUIRED_FIELDS.md (the + nil analysis), the browser probes and their baseline. +- **On grant-rows**: `builder_fields.cljc` (field predicates and where-messages) and + `content_types.cljc`; to converge with the shape table, not to duplicate it. +- **Known, not fixed**: homebrew boons and invocations drop their `:key` and key their option by + name (`pact-boon-options`, `classes.cljc`), so two with one name collide and a renamed one is + lost to the characters that picked it. content-extensibility-compatibility.md (agents/develop) + calls it a latent footgun and says to pass the stored `:key`. Fixed in step 7 with the guard. + +## Order of work, each step gated + +1. **The audit** (in progress): under 10 minutes, the same failures as the baseline (differences + explained), canaries, counted coverage. *Gate:* parity; time. +2. **The shape table and `clean`**, cljc, with a JVM test feeding every listed field every junk + value. No app wiring. *Gate:* every baseline field is fixed or reported unreadable in the unit + grid; save specs accepted. +3. **The read seam.** *Gate:* the audit drops by the fields covered; the everyday suites and the + homebrew e2e scripts pass; the benchmark is in budget. +4. **The share seam**, then **the import seam**. *Gate:* the same, plus the import report test. +5. **The load seam** with the write-back once. *Gate:* `fixes_persist_e2e`, `boot_rescue_e2e`, + `quarantine-restore`, `library-repairs` pass; a second boot writes nothing. +6. **The health log and the marks** in My Content and the builders. +7. **Containment**: all 13 types guarded, per-item boundaries, `base-class-options` reporting. +8. **Enforcement**: the source scan, the audit in CI, the AGENTS.md rules, the documents + corrected, the testing/develop scenarios run and ported. +9. **grant-rows** merges this, registers its pools in the table and the guard, and runs the + audit. + +Shared hardening is built on integration; future work goes there unless it touches only the +refactor branch (owner, 2026-10-21). + +## For the owner + +- **An unreadable field**: set the whole entry aside in the quarantine panel with the reason + (recommended: a number where a map belongs is an authoring mistake, not something to read + past), or keep the entry and read past the field, with a mark? +- **Boons and invocations keyed by name** (known, above): fix in step 7 by passing the stored key? + Characters that picked one hold the name-derived key; where the stored key differs (a source + tag), they need healing through `:former-keys`, the way renamed keys heal today. diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index 759221e40..d5e3cc881 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -36,6 +36,9 @@ homebrew outside it, and a test that loads junk into every field of every conten ## Changed +- **Homebrew:** checking homebrew after an import or a Restore takes a fraction of the time for + classes: each shared part of a class is checked once, not once per level. + ## Fixed ## Security diff --git a/junk-audit.cljs.edn b/junk-audit.cljs.edn new file mode 100644 index 000000000..ac49b5c0f --- /dev/null +++ b/junk-audit.cljs.edn @@ -0,0 +1,5 @@ +{:main orcpub.junk-audit-runner + :output-to "target/junk-audit/js/junk-audit.js" + :output-dir "target/junk-audit/js/out" + :asset-path "js/out" + :optimizations :none} diff --git a/project.clj b/project.clj index 2ec591250..0168c33c4 100644 --- a/project.clj +++ b/project.clj @@ -194,6 +194,8 @@ "fig:build" ["run" "-m" "figwheel.main" "--" "--build-once" "dev"] "fig:prod" ["run" "-m" "figwheel.main" "--" "--build-once" "prod"] "fig:test" ["run" "-m" "figwheel.main" "--" "--build-once" "test"] + ;; only the junk-pack test, for test/e2e/junk-pack-audit.js + "fig:junk" ["run" "-m" "figwheel.main" "--" "--build-once" "junk-audit"] ;; Preview the character-load report email (no send) — email endpoint dev tool "report-preview" ["with-profile" "+tools" "run" "-m" "orcpub.dev.report-preview"] ;; Single-command production build: clean → CLJS → uberjar. diff --git a/src/cljc/orcpub/dnd/e5/homebrew_guard.cljc b/src/cljc/orcpub/dnd/e5/homebrew_guard.cljc index 8778410ea..78845afd1 100644 --- a/src/cljc/orcpub/dnd/e5/homebrew_guard.cljc +++ b/src/cljc/orcpub/dnd/e5/homebrew_guard.cljc @@ -42,10 +42,21 @@ (defn realize "Walk `x` so every lazy sequence inside it runs now. Option builders return lazy - sequences, and a throw inside one would otherwise surface later, outside the guard." + sequences, and a throw inside one would otherwise surface later, outside the guard. + Each object is walked once: an option tree shares its parts (a class's spell options + recur at every level), and walking every copy took seconds for one homebrew class." [x] - (dorun (tree-seq coll? seq x)) - x) + (let [seen #?(:clj (java.util.Collections/newSetFromMap (java.util.IdentityHashMap.)) + :cljs (js/Set.)) + first-visit? (fn [y] #?(:clj (.add seen y) + :cljs (when-not (.has seen y) (.add seen y) true)))] + (letfn [(walk [y] + (when (and (coll? y) (first-visit? y)) + (if (map? y) + (doseq [[k v] y] (walk k) (walk v)) + (doseq [v y] (walk v)))))] + (walk x)) + x)) (defn guard-entry "(f entry), or nil when it throws; the failure is reported with the entry's identity." diff --git a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc new file mode 100644 index 000000000..b6ea9cde6 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc @@ -0,0 +1,414 @@ +(ns orcpub.dnd.e5.homebrew-shapes + "The shape of every homebrew field the app reads, and `clean`, which makes an entry fit them. + Homebrew is untrusted: an imported, hand-edited or injected entry can hold anything anywhere. + Code that reads homebrew assumes these shapes, so the seams run entries through here first. + A leaf: requires nothing from the app. See docs/HOMEBREW_GUARD.md." + (:require [clojure.string :as str] + #?(:clj [clojure.edn :as edn]))) + +;; --------------------------------------------------------------------------------------------- +;; Shapes are data: :string, :num, [:list s], [:map {field s}] and so on. What each form means, +;; and what `clean` fixes for each, is in docs/HOMEBREW_GUARD.md, "The shape forms". + +(defn fits? + "Does `v` fit `shape` as it is? Allocates nothing, so a sound entry costs one walk." + [shape v] + (if (keyword? shape) + (case shape + :any true + :string (string? v) + :number (number? v) + :int (integer? v) + :keyword (keyword? v) + :boolean (boolean? v) + :map (map? v) + :num (number? v) + :key (keyword? v) + false) + (let [[op a b] shape] + (case op + :enum (contains? a v) + :or (boolean (some #(fits? % v) (rest shape))) + :list (and (sequential? v) (every? #(fits? a %) v)) + :coll (and (or (sequential? v) (set? v)) (every? #(fits? a %) v)) + :set (and (set? v) (every? #(fits? a %) v)) + :map-of (and (map? v) (every? (fn [[k x]] (and (fits? a k) (fits? b x))) v)) + :flags (and (map? v) (every? (fn [[k x]] (and (fits? a k) (boolean? x))) v)) + :counts (and (map? v) (every? (fn [[k x]] (and (fits? a k) (number? x))) v)) + :indexed (and (map? v) (every? (fn [[k x]] (and (integer? k) (fits? a x))) v)) + :map (and (map? v) (every? (fn [[k s]] (or (not (contains? v k)) (fits? s (get v k)))) a)) + false)))) + +(defn- describe + "A value's kind, in a reader's words." + [v] + (cond (nil? v) "empty" (string? v) "text" (number? v) "a number" (keyword? v) "a keyword" + (boolean? v) (str v) (map? v) "a map" (set? v) "a set" (sequential? v) "a list" + :else "something else")) + +(defn shape-name + "A shape in a reader's words: \"a list of text\", \"a map\"." + [shape] + (if (keyword? shape) + (case shape + :any "anything" :string "text" :number "a number" :int "a whole number" + :keyword "a keyword" :boolean "true or false" :map "a map" :num "a number" + :key "a keyword" (name shape)) + (let [[op a b] shape] + (case op + :or (str/join " or " (map shape-name (rest shape))) + (:list :coll) (str "a list of " (shape-name a)) + :set (str "a set of " (shape-name a)) + :map-of (str "a map of " (shape-name a) " to " (shape-name b)) + :enum (str "one of " (str/join ", " (map pr-str (sort-by str a)))) + :flags (str "a map of " (shape-name a) " to true or false") + :counts (str "a map of " (shape-name a) " to a number") + :indexed (str "a numbered map of " (shape-name a)) + :map "a map" + (pr-str shape))))) + +(defn- finding [path action v shape] + {:path path :action action :found (describe v) :wanted (shape-name shape)}) + +(defn- parse-number + "The number `s` spells, or nil: \"3\" and \"0.5\" read, \"three\" does not." + [s] + (when (re-matches #"\s*-?\d+(\.\d+)?\s*" s) + #?(:clj (edn/read-string (str/trim s)) :cljs (js/parseFloat s)))) + +(defn- text->key + "The keyword text stands for, or nil for blank text: \"Medium\" and \":medium\" read as :medium." + [s] + (let [t (-> s str/trim (str/replace #"^:" "") str/lower-case (str/replace #"\s+" "-"))] + (when-not (str/blank? t) (keyword t)))) + +(declare clean-value) + +;; Each clean-* returns {:value v' :findings [...]}, or only {:findings [...]} when nothing of the +;; value can be read; `v` has already been found not to fit. + +(defn- clean-each + "Each element of `xs` cleaned against `shape`; one that cannot be read is left out." + [shape xs path] + (reduce (fn [{:keys [value findings]} [i x]] + (let [r (clean-value shape x (conj path i))] + {:value (if (contains? r :value) (conj value (:value r)) value) + :findings (into findings (:findings r))})) + {:value [] :findings []} + (map-indexed vector xs))) + +(defn- unreadable [path v shape] + {:findings [(finding path :unreadable v shape)]}) + +(defn- fixed + "`r`, the cleaned inside of `v`, as a fix of `v` at `path`." + [r path v shape] + (update r :findings #(into [(finding path :fixed v shape)] %))) + +(defn- lone [v] + (and (some? v) (not (coll? v)))) + +(defn- clean-scalar [shape v path] + (let [parsed (case shape + :num (when (string? v) (parse-number v)) + :key (when (string? v) (text->key v)) + nil)] + (if (some? parsed) + {:value parsed :findings [(finding path :fixed v shape)]} + (unreadable path v shape)))) + +(defn- clean-list [a v path shape] + (cond + (sequential? v) (clean-each a v path) + (or (map? v) (lone v)) (let [r (clean-value a v (conj path 0))] + (if (contains? r :value) + (fixed {:value [(:value r)] :findings (:findings r)} path v shape) + (unreadable path v shape))) + :else (unreadable path v shape))) + +(defn- clean-set [a v path shape] + (cond + (set? v) (update (clean-each a v path) :value set) + (sequential? v) (fixed (update (clean-each a v path) :value set) path v shape) + (lone v) (let [r (clean-value a v (conj path 0))] + (if (contains? r :value) + (fixed {:value #{(:value r)} :findings (:findings r)} path v shape) + (unreadable path v shape))) + :else (unreadable path v shape))) + +(defn- clean-entries + "A map's entries, each key checked against `ks` and each value cleaned by `clean-v`." + [ks clean-v v path] + (reduce-kv (fn [{:keys [value findings]} k x] + (let [p (conj path k)] + (if-not (fits? ks k) + {:value value :findings (into findings (:findings (unreadable p k ks)))} + (let [r (clean-v x p)] + {:value (if (contains? r :value) (assoc value k (:value r)) value) + :findings (into findings (:findings r))})))) + {:value {} :findings []} + v)) + +(defn- clean-marks + "[:flags k] or [:counts k]: a map is cleaned entry by entry; a set or list of keys, or a lone + key, marks each one (true, or 1)." + [op a v path shape] + (let [mark (if (= op :flags) true 1)] + (cond + (map? v) + (clean-entries a (fn [x p] + (cond + (if (= op :flags) (boolean? x) (number? x)) {:value x :findings []} + ;; a flag holding any other value counts as set when it is truthy + (= op :flags) {:value (boolean x) + :findings [(finding p :fixed x :boolean)]} + :else (clean-scalar :num x p))) + v path) + (or (set? v) (sequential? v) (fits? a v)) + (let [ks (if (coll? v) (vec v) [v])] + (fixed {:value (zipmap (filter #(fits? a %) ks) (repeat mark)) + :findings (vec (for [[i k] (map-indexed vector ks) :when (not (fits? a k))] + (finding (conj path i) :unreadable k a)))} + path v shape)) + :else (unreadable path v shape)))) + +(defn- clean-map [fields v path] + (reduce-kv (fn [{:keys [value findings] :as acc} k s] + (if-not (contains? value k) + acc + (let [r (clean-value s (get value k) (conj path k))] + {:value (if (contains? r :value) (assoc value k (:value r)) (dissoc value k)) + :findings (into findings (:findings r))}))) + {:value v :findings []} + fields)) + +(defn clean-value + "`v` made to fit `shape` at `path`: {:value v' :findings [...]}, or only {:findings [...]} when + nothing of it can be read. A value that already fits comes back as the identical object with no + findings. A finding is {:path :action :found :wanted}; :action is :fixed when the meaning was + clear (\"3\" for 3, a lone item for a list of one) and :unreadable when it was not." + [shape v path] + (cond + (fits? shape v) {:value v :findings []} + (keyword? shape) (clean-scalar shape v path) + :else + (let [[op a b] shape] + (case op + ;; the first alternative something can be read from + :or (or (first (filter #(contains? % :value) (map #(clean-value % v path) (rest shape)))) + (unreadable path v shape)) + :list (clean-list a v path shape) + :coll (if (or (sequential? v) (set? v)) + (cond-> (clean-each a v path) (set? v) (update :value set)) + (unreadable path v shape)) + :set (clean-set a v path shape) + :map-of (if (map? v) + (clean-entries a #(clean-value b %1 %2) v path) + (unreadable path v shape)) + (:flags :counts) (clean-marks op a v path shape) + :indexed (cond + (map? v) (clean-entries :int #(clean-value a %1 %2) v path) + (sequential? v) (let [r (clean-each a v path)] + (fixed {:value (zipmap (range) (:value r)) + :findings (:findings r)} + path v shape)) + :else (unreadable path v shape)) + :map (if (map? v) (clean-map a v path) (unreadable path v shape)) + (unreadable path v shape))))) + +;; --------------------------------------------------------------------------------------------- +;; The table. Each field a reader assumes a shape of, as the code reads it. Where the code or the +;; builders accept more than one shape the field allows all of them: loading is never stricter +;; than saving. Where a field's meaning is unsettled it is left as :any, not guessed. + +(def ^:private card + "A trait, an option or an action: what `traits-modifiers` and the sheets read." + [:map {:name :string :description :string :summary :string :level :num :type :key}]) + +(def ^:private cards [:list card]) + +(def ^:private choice + "A choose-N: {:choose 2 :options {:athletics true}} (or {:any true})." + [:map {:choose :num :options [:flags :keyword] :order :num}]) + +(def ^:private flags [:flags :keyword]) +(def ^:private counts [:counts :keyword]) + +(def ^:private equipment-choices [:list [:map {:name :string :options counts}]]) + +(def ^:private profs + [:map {:skill flags :skill-options choice :skill-expertise-options choice + :tool flags :tool-options counts :language-options choice + :armor flags :weapon flags :save flags :weapon-proficiency-options choice + :multiclass-skill-options choice :multiclass-tool-options counts}]) + +(def ^:private props + "What `make-feat-modifiers` reads; other keys pass untouched." + [:map {:initiative :num :speed :num :flying-speed :num :swimming-speed :num :max-hp-bonus :num + :language flags :saving-throw-advantage flags :armor-prof flags :weapon-prof flags + :tool-prof-or-expertise flags :skill-prof flags :skill-prof-or-expertise flags + :damage-resistance flags :damage-immunity flags + :weapon-prof-choice :num :language-choice :num :skill-tool-choice :num}]) + +(def ^:private abilities [:map-of :keyword :num]) + +(def ^:private known-spells + "A race's or subrace's spells: [{:level 3 :value {:key :light :ability ... :level 0}}]." + [:list [:map {:level :num :value [:map {:key :keyword :ability :keyword :level :num}]}]]) + +(def ^:private level-modifiers [:list [:map {:type :key :level :num}]]) +(def ^:private level-selections [:list [:map {:type :key :level :num :num :num}]]) + +(def ^:private expanded-spells + "A subclass's always-prepared spells: {1 {0 :bless 1 :cure-wounds} ...}." + [:map-of :int [:indexed :keyword]]) + +(def ^:private raw-structures + "Template selections and modifiers spliced in as they are; only a list of maps can be." + [:list :map]) + +(def shapes + "Content type -> the shape of one entry." + {:orcpub.dnd.e5/spells + [:map {:name :string :level :num :school :string :spell-lists flags + :components [:map {:verbal :any :somatic :any :material :any :material-component :string}] + :description :string :summary :string}] + + :orcpub.dnd.e5/monsters + [:map {:name :string :size :key :type :key :subtypes [:set :key] :challenge :num + :hit-points [:map {:die :num :die-count :num :modifier :num :mean :num}] + :saving-throws [:map-of :keyword :num] :skills [:map-of :keyword :num] + :condition-immunities :string + :props [:map {:damage-resistance flags :damage-immunity flags + :damage-vulnerability flags :condition-immunity flags :language flags}] + :traits cards :actions cards :reactions cards + :legendary-actions [:map {:description :string :actions cards}]}] + + :orcpub.dnd.e5/encounters + [:map {:name :string + :creatures [:list [:map {:type :key + :creature [:map {:monster :keyword :num :num + :character :num}]}]]}] + + :orcpub.dnd.e5/backgrounds + [:map {:name :string :profs profs :traits cards + :equipment-choices equipment-choices :weapon-choices equipment-choices + :armor-choices equipment-choices + :equipment counts :weapons counts :armor counts :treasure counts + :custom-equipment [:map-of :string :num] :custom-treasure [:map-of :string :num] + :selections raw-structures :modifiers raw-structures}] + + :orcpub.dnd.e5/languages [:map {:name :string}] + :orcpub.dnd.e5/invocations [:map {:name :string}] + :orcpub.dnd.e5/boons [:map {:name :string}] + + :orcpub.dnd.e5/selections [:map {:name :string :options cards}] + + :orcpub.dnd.e5/feats + ;; :ability-increases is a set of ability keys (the released form) or, from PR #69, a list + [:map {:name :string :description :string + :ability-increases [:or [:set :keyword] [:list :any]] + :prereqs [:set :keyword] :path-prereqs [:map {:race flags}] :props props}] + + :orcpub.dnd.e5/races + [:map {:name :string :size :key :speed :num :darkvision :num :abilities abilities + :languages [:set :string] :profs profs :props props :spells known-spells :traits cards + :subraces [:list :map] :selections raw-structures}] + + :orcpub.dnd.e5/subraces + [:map {:race :key :name :string :size :key :speed :num :darkvision :num :abilities abilities + :profs profs :props props :spells known-spells :traits cards + :selections raw-structures}] + + :orcpub.dnd.e5/subclasses + ;; :spellcasting :spell-list is a keyword or a map; which the code means is unsettled + [:map {:class :key :name :string :level-modifiers level-modifiers + :level-selections level-selections + :spellcasting [:map {:level-factor :num :known-mode :keyword :spell-list-kw :keyword}] + :cleric-spells expanded-spells + :paladin-spells [:map-of [:enum #{1 2 3 4 5}] [:indexed :keyword]] + :warlock-spells expanded-spells + :profs profs :props props :traits cards :selections raw-structures}] + + :orcpub.dnd.e5/classes + [:map {:name :string :hit-die :num :subclass-level :num :subclass-title :string + :ability-increase-levels [:list :num] :level-modifiers level-modifiers + :level-selections level-selections :traits cards :profs profs :props props + :spellcasting [:map {:level-factor :num :known-mode :keyword :ability :keyword + :spells-known [:map-of :num :any] + :cantrips-known [:map-of :num :num] + :spell-list [:map-of :num [:set :keyword]] + :spell-list-kw :keyword}] + :weapons counts :armor counts :equipment counts + :weapon-choices equipment-choices :armor-choices equipment-choices + :equipment-choices equipment-choices :equipment-selections [:list :map] + :subclasses [:list :map] :selections raw-structures}]}) + +(def ^:private entry-fields + "What every entry of every type holds." + {:former-keys [:list :keyword]}) + +(defn clean-entry + "`entry` of `content-type` made to fit the table: {:entry e' :findings [...]}, or only + {:findings [...]} when the entry is not a map. A sound entry comes back identical." + [content-type entry] + (let [shape (get shapes content-type [:map {}]) + [_ fields] shape + r (clean-value [:map (merge entry-fields fields)] entry [])] + (if (contains? r :value) + {:entry (:value r) :findings (:findings r)} + {:findings (:findings r)}))) + +(def ^:private a-section + "A content section: entries by key." + [:map-of :keyword :map]) + +(defn- clean-section + "One content section: each entry cleaned. Returns {:section s' :findings [...]}, `s'` identical + when nothing changed." + [content-type section] + (reduce-kv (fn [{:keys [section findings] :as acc} k entry] + (let [r (clean-entry content-type entry) + fs (map #(assoc % :key k) (:findings r))] + (cond + (not (contains? r :entry)) {:section (dissoc section k) + :findings (into findings fs)} + (identical? entry (:entry r)) acc + :else {:section (assoc section k (:entry r)) :findings (into findings fs)}))) + {:section section :findings []} + section)) + +(defn clean-library + "A {source-name source} library with every entry the app reads made to fit the table: + {:library l' :findings [{:source :content-type :key :path :action :found :wanted}]}. A section + stored as true or false (the format allows it) reads as empty; any other section that is not a + map, and any entry that is not, cannot be read and is left out. A sound library comes back + identical, and so does each sound source and section, so readers downstream recompute nothing." + [library] + (if-not (map? library) + {:library library :findings []} + (reduce-kv + (fn [acc src source] + (if-not (map? source) + acc + (let [{:keys [source findings]} + (reduce-kv + (fn [{:keys [source findings] :as acc} ct section] + (let [tag #(assoc % :source src :content-type ct)] + (cond + (not (contains? shapes ct)) acc + (boolean? section) {:source (assoc source ct {}) :findings findings} + (not (map? section)) + {:source (dissoc source ct) + :findings (conj findings (tag (finding [] :unreadable section a-section)))} + :else (let [r (clean-section ct section)] + (if (identical? section (:section r)) + acc + {:source (assoc source ct (:section r)) + :findings (into findings (map tag (:findings r)))}))))) + {:source source :findings []} + source)] + (cond-> (update acc :findings into findings) + (not (identical? source (get library src))) (assoc-in [:library src] source))))) + {:library library :findings []} + library))) diff --git a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj new file mode 100644 index 000000000..540c13911 --- /dev/null +++ b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj @@ -0,0 +1,80 @@ +(ns orcpub.dnd.e5.homebrew-shape-table-test + "The shape table against real content and against junk. Real content must come through with + nothing unreadable (loading is never stricter than saving); junk in any field must come out + fitting the table, without a throw." + (:require [clojure.test :refer [deftest testing is]] + [clojure.edn :as edn] + [orcpub.dnd.e5.homebrew-shapes :as shapes])) + +(defn- fixture [f] + (edn/read-string {:default tagged-literal} (slurp (str "test/fixtures/" f)))) + +(def ^:private sound + ["test-pak.orcbrew" "coverage-pak.orcbrew" "sourced-classes.orcbrew" "junk-bases.orcbrew"]) + +(defn- library [data] + ;; a file holds one source or a {source-name source} library + (if (every? string? (keys data)) data {"Fixture" data})) + +(deftest real-content-has-nothing-unreadable + (doseq [f sound] + (let [lib (library (fixture f)) + {:keys [findings]} (shapes/clean-library lib) + unreadable (filter #(= :unreadable (:action %)) findings)] + (is (empty? unreadable) (str f ": " (pr-str (take 5 unreadable))))))) + +(deftest real-content-comes-back-as-it-was + (testing "a library with nothing to fix is the identical object, so nothing downstream recomputes" + (let [lib (library (fixture "coverage-pak.orcbrew"))] + (is (identical? lib (:library (shapes/clean-library lib)))))) + (testing "what the table fixes in real content, listed so a change to it is seen" + (let [fixes (for [f sound + x (:findings (shapes/clean-library (library (fixture f)))) + :when (= :fixed (:action x))] + [f (:content-type x) (:path x) (:found x)])] + (is (= #{["sourced-classes.orcbrew" :orcpub.dnd.e5/races [:size] "text"]} + (set fixes)) + (pr-str (distinct fixes)))))) + +(def ^:private junk [nil 42 "x" true :kw [] [42] ["x"] [{}] {} {:a 1} #{42} (list 42) 1.5]) + +(defn- field-paths + "Each field of `entry`, and each field one level inside a map or a list of maps." + [entry] + (concat (for [k (keys entry)] [k]) + (for [[k v] entry + p (cond (map? v) (for [k2 (keys v)] [k k2]) + (and (sequential? v) (map? (first v))) (for [k2 (keys (first v))] [k 0 k2]) + :else nil)] + p))) + +(deftest junk-in-any-field-comes-out-fitting + (let [base-entries (get (fixture "junk-bases.orcbrew") "Junk Pak") + failures (for [[ct {base :junk-base}] base-entries + path (field-paths base) + v junk + :let [entry (assoc-in base path v) + r (try (shapes/clean-entry ct entry) + (catch Throwable e {:threw (.getMessage e)}))] + :when (or (:threw r) + (not (shapes/fits? (get shapes/shapes ct) (:entry r))))] + [ct path v (or (:threw r) "does not fit")])] + (is (empty? failures) (pr-str (take 10 failures))))) + +(deftest a-section-that-is-not-a-map + (let [clean #(shapes/clean-library {"P" {:orcpub.dnd.e5/races %}})] + (testing "true or false, which the format allows, reads as empty" + (is (= {} (get-in (clean true) [:library "P" :orcpub.dnd.e5/races]))) + (is (empty? (:findings (clean false))))) + (testing "anything else cannot be read, and is named" + (doseq [v [42 "x" [] [42] #{42}]] + (let [{lib :library findings :findings} (clean v)] + (is (not (contains? (get lib "P") :orcpub.dnd.e5/races)) (pr-str v)) + (is (= [{:source "P" :content-type :orcpub.dnd.e5/races :path [] :action :unreadable}] + (map #(select-keys % [:source :content-type :path :action]) findings)) + (pr-str v))))))) + +(deftest damaged-content-never-throws + (doseq [f ["cruft-shapes.orcbrew" "broken-content.orcbrew" "keyword-trap.orcbrew"]] + (let [r (shapes/clean-library (library (fixture f)))] + (is (map? (:library r)) f)))) diff --git a/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc b/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc new file mode 100644 index 000000000..1375c81f0 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc @@ -0,0 +1,57 @@ +(ns orcpub.dnd.e5.homebrew-shapes-test + (:require #?(:clj [clojure.test :refer [deftest testing is]] + :cljs [cljs.test :refer-macros [deftest testing is]]) + [orcpub.dnd.e5.homebrew-shapes :as shapes])) + +(def ^:private card [:map {:name :string :description :string}]) +(def ^:private entry-shape + [:map {:name :string + :abilities [:map-of :keyword :number] + :languages [:coll :string] + :traits [:list card] + :size [:or :keyword :string]}]) + +(defn- clean [v] (shapes/clean-value entry-shape v [])) + +(deftest a-sound-entry-comes-back-identical + (let [e {:name "Elf" :abilities {:dex 2} :languages #{"Elvish"} :traits [{:name "Keen"}] + :size :medium :unlisted 42} + r (clean e)] + (is (identical? e (:value r)) "the same object, so nothing downstream recomputes") + (is (empty? (:findings r))))) + +(deftest a-lone-item-where-a-list-belongs-is-wrapped + (let [r (clean {:traits {:name "Keen"}})] + (is (= [{:name "Keen"}] (get-in r [:value :traits]))) + (is (= [{:path [:traits] :action :fixed :found "a map" :wanted "a list of a map"}] + (:findings r))))) + +(deftest what-cannot-be-read-is-read-past-and-named + (testing "a field" + (let [r (clean {:name "Elf" :abilities 42})] + (is (= {:name "Elf"} (:value r))) + (is (= [{:path [:abilities] :action :unreadable :found "a number" + :wanted "a map of a keyword to a number"}] + (:findings r))))) + (testing "an element of a list, keeping the rest" + (let [r (clean {:traits [{:name "Keen"} 42 {:name "Trance"}]})] + (is (= [{:name "Keen"} {:name "Trance"}] (get-in r [:value :traits]))) + (is (= [[:traits 1]] (map :path (:findings r)))))) + (testing "a value inside a map, keeping the rest" + (let [r (clean {:abilities {:dex 2 :str "x"}})] + (is (= {:dex 2} (get-in r [:value :abilities]))) + (is (= [[:abilities :str]] (map :path (:findings r)))))) + (testing "a field inside an element" + (let [r (clean {:traits [{:name 42 :description "d"}]})] + (is (= [{:description "d"}] (get-in r [:value :traits]))) + (is (= [[:traits 0 :name]] (map :path (:findings r))))))) + +(deftest every-junk-value-is-handled-and-never-throws + (doseq [v [nil 42 "x" true :kw [] [42] ["x"] [{}] {} {:a 1} #{42} (list 1) 1.5] + field [:name :abilities :languages :traits :size]] + (let [r (clean {field v})] + (is (map? (:value r)) (pr-str field v)) + (is (shapes/fits? entry-shape (:value r)) (str "the result fits: " (pr-str field v)))))) + +(deftest a-set-stays-a-set + (is (= #{"Elvish"} (get-in (clean {:languages #{"Elvish" 42}}) [:value :languages])))) diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index 55cd989b0..d472f439c 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -4,13 +4,15 @@ from storage, an import and a share link, then through startup, every subscription, the deep check, a character picking each entry, and the pages that draw it. An entry may be set aside (only that entry); nothing may throw and no built-in content may go missing. The everyday - suite runs the base pack only; test/e2e/junk-pack-audit.js runs it all." + suite runs the base pack only; test/e2e/junk-pack-audit.js runs it all, in a few minutes: one + pack per junk value holds every type, and a failure is traced to its entry within the run." (:require [cljs.test :refer-macros [deftest is]] [clojure.string :as str] [re-frame.core :as rf] [re-frame.db :refer [app-db]] [orcpub.fixtures :refer-macros [fixture-text]] [orcpub.entity :as entity] + [orcpub.common :as common] [orcpub.template :as t] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.homebrew-guard :as guard] @@ -43,8 +45,7 @@ (defn- audit-types "The content types the audit covers: ?junk-audit=races,feats names some, ?junk-audit all of - them (test/e2e/junk-pack-audit.js runs one type per page). Without it, none: the grid takes - minutes, so the everyday suite checks only that the base pack works." + them. Without it, none: the everyday suite checks only that the base pack works." [] (let [q (str (.-search js/location))] (when-let [[_ named] (re-find #"junk-audit(?:=([a-z,]+))?" q)] @@ -53,6 +54,23 @@ (keys @base-entries)) (keys @base-entries))))) +(defn- my-part + "This page's share of `jobs` (?part=1/4: the second of four pages run in parallel). Each job, + heaviest first by `weight`, goes to the page with the least work so far, so the pages finish + together; every page computes the same split." + [jobs & [weight]] + (let [[_ i n] (re-find #"part=(\d+)/(\d+)" (str (.-search js/location))) + [i n] (if n [(js/parseInt i) (js/parseInt n)] [0 1]) + weight (or weight (constantly 1)) + loads (reduce (fn [loads [idx job]] + (let [p (apply min-key #(get-in loads [% :load]) (range n))] + (-> loads + (update-in [p :load] + (weight job)) + (update-in [p :jobs] conj [idx job])))) + (vec (repeat n {:load 0 :jobs []})) + (sort-by (comp - weight second) (map-indexed vector jobs)))] + (map second (sort-by first (get-in loads [i :jobs]))))) + (defn- report! "Log a failure as it is found, so an audit cut short still has what it found." [f] @@ -77,19 +95,6 @@ [entries] {source entries}) -(defn- batch - "Every field of every base entry of `content-type` holding `v`, one entry per field." - [content-type v] - (let [base (get-in @base-entries [content-type :junk-base]) - paths (field-paths base)] - {:cases (into {} (map-indexed (fn [i p] [(keyword (str "junk-" i)) p]) paths)) - :entries {content-type - (into {} (map-indexed - (fn [i p] - (let [k (keyword (str "junk-" i))] - [k (assoc (assoc-in base p v) :key k :name (str "Junk " i))])) - paths))}})) - ;; --------------------------------------------------------------------------------------------- ;; What the app does with a pack @@ -120,25 +125,56 @@ (let [k (or (::t/key x) (:key x))] (and (keyword? k) (str/starts-with? (name k) "junk-")))))) +(defn- msg [e] (first (str/split-lines (or (ex-message e) (str e))))) + +(def ^:private seen + "What this pack has already realized, by identity. The class list, the class map, the template + and the template's selections hold the same option objects, and a class's spell options recur + at every level: walked once each, a homebrew class takes milliseconds instead of seconds." + (atom (js/Set.))) + +(defn- realize! + "Realize every lazy part of `x`, skipping what this pack has already walked." + [x] + (let [s @seen] + (letfn [(walk [x] + (when (and (coll? x) (not (.has s x))) + (.add s x) + (if (map? x) + (doseq [[k v] x] (walk k) (walk v)) + (doseq [v x] (walk v)))))] + (walk x)) + x)) + +(defn- realize-each + "Realize each item on its own, so one that throws names itself and does not hide the next. + Returns the failures, each {:key :error}." + [xs] + (doall (keep (fn [x] (try (realize! x) nil + (catch :default e {:key (or (::t/key x) (:key x)) :error (msg e)}))) + xs))) + (defn- realize-homebrew "Realize the homebrew in a subscription's value and leave the built-in content alone: a template - or a list of selections realizes the homebrew options in each selection, and in the selections - of each option (a subrace sits under its race); a map or list realizes its homebrew items." + or a list of selections realizes the homebrew options in each selection, and those in the + selections of each built-in option (a subrace sits under its race); a map or list realizes its + homebrew items. Returns the failures, each {:key :error}." [v] (let [selections (cond (::t/selections v) (::t/selections v) (and (sequential? v) (some ::t/options v)) v) items (fn [xs] (mapcat #(if (and (sequential? %) (not (map? %))) % [%]) xs))] (cond selections - (doseq [s selections - o (::t/options s) - :when (or (homebrew? o) (some homebrew? (mapcat ::t/options (::t/selections o))))] - (guard/realize o)) - (map? v) (guard/realize (filter homebrew? (items (vals v)))) - (sequential? v) (guard/realize (filter homebrew? (items v))) - :else (guard/realize v)))) - -(defn- msg [e] (first (str/split-lines (or (ex-message e) (str e))))) + (doall (mapcat (fn [s] + (let [[own built-in] ((juxt filter remove) homebrew? (::t/options s))] + (concat (realize-each own) + (realize-each (filter homebrew? (mapcat ::t/options + (mapcat ::t/selections + built-in))))))) + selections)) + (map? v) (realize-each (filter homebrew? (items (vals v)))) + (sequential? v) (realize-each (filter homebrew? (items v))) + :else (do (realize! v) nil)))) (defn- attempt [stage f] (try (f) nil (catch :default e {:stage stage :error (msg e)}))) @@ -179,18 +215,20 @@ (defn- pick "`c` with option `k` picked wherever it is offered and not yet picked, as the builder picks it, - repeated so a pick that offers it again (a subrace under a race) is followed." - [template c k] + repeated so a pick that offers it again (a subrace under a race) is followed. `k` may be a set + of keys: boons and invocations key their option by the entry's name, not its :key." + [template c ks] (loop [c c i 0] (let [[_ avail] (offered template c) ;; a single choice holds one option (Race: Human), a multiple one a list of them - picked? (fn [s] (let [o (entity/get-option template c (conj (entity/actual-path s) k))] - (if (sequential? o) - (some #(= k (::entity/key %)) o) - (= k (::entity/key o))))) - s (first (filter (fn [s] (and (some #(= k (::t/key %)) (::t/options s)) - (not (picked? s)))) - avail))] + picked? (fn [s k] (let [o (entity/get-option template c (conj (entity/actual-path s) k))] + (if (sequential? o) + (some #(= k (::entity/key %)) o) + (= k (::entity/key o))))) + [s k] (first (for [s avail + k (keep (comp ks ::t/key) (::t/options s)) + :when (not (picked? s k))] + [s k]))] (if (or (nil? s) (> i 4)) c (recur (entity/update-option template c (conj (entity/actual-path s) k) @@ -202,13 +240,15 @@ (defn- use-entry! "Pick homebrew entry `k` in `c`, build it, and draw what the builder draws under it: every selection whose path runs through `k`, fully realized. Throws when `k` is never offered." - [template k c] - (let [c (pick template c k) + [template k c & [entry]] + (let [nm (:name entry) + ks (cond-> #{k} (string? nm) (conj (common/name-to-kw nm))) + c (pick template c ks) [built avail] (offered template c)] - (when (and k (not (some #{k} (tree-seq coll? seq c)))) + (when (and k (not (some ks (tree-seq coll? seq c)))) (throw (js/Error. "never offered"))) - (guard/realize (char5e/ability-values built)) - (guard/realize (filter #(some #{k} (entity/actual-path %)) avail)))) + (realize! (char5e/ability-values built)) + (realize! (filter #(some ks (entity/actual-path %)) avail)))) (defn- expand "Draw `x` the way Reagent would: a vector headed by a function is a child component, so call it @@ -238,27 +278,6 @@ ::e5/subclasses [::classes5e/set-subclass views/subclass-builder] ::e5/classes [::classes5e/set-class views/class-builder]}) -(defn- pages - "Draw My Content (the page, and each content type's list for the pack), the monster list - filtered by a name, and each entry of `entries` opened in its builder. Returns the failures." - [entries] - (let [plugin (get-in @app-db [:plugins source])] - (concat - [(attempt [:page :my-content] #(expand [views/my-content]))] - (for [{:keys [type-name type-key icon add-event edit-event delete-event plural]} - views/my-content-types] - (attempt [:page :my-content type-name] - #(expand [views/my-content-type source plugin type-name type-key icon add-event - edit-event delete-event plural "" true]))) - [(attempt [:page :monster-list] - #(do (rf/dispatch-sync [::char5e/filter-monsters "junk"]) - (expand [views/monster-list-items])))] - (for [[t es] entries :when (map? es) - :let [[open builder] (builders t)] - :when open - [k e] es :when (map? e)] - (attempt [:builder k] #(do (rf/dispatch-sync [open e]) (expand [builder]))))))) - (defn- offered-keys "The keys of the classes, races and backgrounds the app offers now." [] @@ -288,11 +307,66 @@ {:stage :set-aside :error (str "set aside " (pr-str content-type) " " (pr-str key) ", which is not in the pack")})) +(def ^:private built + "A result already found, by stage and what it ran on: an entry that arrives the same by storage, + import and share link is built, drawn and deep-checked once." + (atom {})) + +(defn- once + "`(attempt stage f)`, or what it gave the last time it ran on the same `id`." + [id stage f] + (if (contains? @built id) + (get @built id) + (let [r (attempt stage f)] (swap! built assoc id r) r))) + +(defn- traced + "`failure` from drawing everything, traced to the entries that fail when drawn alone by + `draw-one` (each [key f]); the failure itself when none does." + [failure draw-one] + (when failure + (or (seq (keep (fn [[k f]] (some-> (attempt (:stage failure) f) (assoc :key k))) draw-one)) + [failure]))) + +(defn- pages + "Draw My Content (the page, and each content type's list for the pack), the monster list + filtered by a name, and, with `builders?`, each entry of `entries` opened in its builder. + Returns the failures." + [entries builders?] + (let [plugin (get-in @app-db [:plugins source]) + type-list (fn [{:keys [type-name type-key icon add-event edit-event delete-event plural]} + plugin] + #(expand [views/my-content-type source plugin type-name type-key icon add-event + edit-event delete-event plural "" true]))] + (concat + [(attempt [:page :my-content] #(expand [views/my-content]))] + (mapcat (fn [{:keys [type-name type-key] :as t}] + (let [es (get plugin type-key)] + (traced (attempt [:page :my-content type-name] (type-list t plugin)) + (when (map? es) + (for [[k e] es] [k (type-list t (assoc plugin type-key {k e}))]))))) + views/my-content-types) + (traced (attempt [:page :monster-list] + #(do (rf/dispatch-sync [::char5e/filter-monsters "junk"]) + (expand [views/monster-list-items]))) + ;; the list is lazy: realize it inside the try, or it throws outside it + (for [m (try (doall @(rf/subscribe [::monsters5e/filtered-monsters])) + (catch :default _ nil)) + :when (homebrew? m)] + [(:key m) #(expand [views/monster-list-item m])])) + (for [[t es] (when builders? entries) :when (map? es) + :let [[open builder] (builders t)] + :when open + [k e] es :when (map? e)] + (some-> (once [:builder (hash e)] [:builder k] + #(do (rf/dispatch-sync [open e]) (expand [builder]))) + (assoc :stage [:builder k] :key k)))))) + (defn- run-pack! "Start the app with `lib` arriving `via` :storage (the stored library; also what - LOAD_HOMEBREW_URL writes) or :share (a share link's homebrew), and use it. Returns the - failures, each {:stage :error}." - [lib & [via]] + LOAD_HOMEBREW_URL writes) or :share (a share link's homebrew), and use it; with `light?`, skip + the characters and builders. Returns the failures, each {:stage :error}, with :key when the + failure is traced to an entry." + [lib & [via {:keys [light?]}]] (doseq [k (drafts/draft-keys)] (drafts/remove-draft! k)) (doseq [k ["plugins" "plugins:rejected" "plugins:corrupt" "character"]] (.removeItem js/localStorage k)) @@ -300,6 +374,7 @@ (.setItem js/localStorage "character" (pr-str (char5e/to-strict stored-character))) (reset! app-db {}) (rf/clear-subscription-cache!) + (reset! seen (js/Set.)) (let [reports (atom [])] (guard/call-with-reporter #(swap! reports conj %) @@ -310,27 +385,45 @@ #(rf/dispatch-sync [::e5/apply-shared-content {:plugins lib}]))) entries (get (if (= via :share) (:shared-plugins @app-db) (:plugins @app-db)) source) ;; each stage runs now, in order: what is set aside depends on what ran before - sub-fails (doall (keep (fn [q] (attempt q #(realize-homebrew @(rf/subscribe q)))) - homebrew-subs)) + ;; light: only what a failure can hide in that is not traced to an item (a + ;; subscription that throws as a whole); the items are traced in the full run + sub-fails (doall (mapcat (fn [q] + (if light? + (keep #(attempt q (fn [] (dorun @(rf/subscribe %)))) [q]) + (let [each (atom nil)] + (or (some-> (attempt q #(reset! each + (realize-homebrew + @(rf/subscribe q)))) + vector) + (map #(assoc % :stage q) @each))))) + homebrew-subs)) built-ins (attempt :built-in-content missing-built-ins) - deep (attempt :deep-check #(homebrew-check/deep-check nil)) + deep (when-not light? + (once [:deep-check (hash entries)] :deep-check + #(homebrew-check/deep-check nil))) template (try @(rf/subscribe [::char5e/template]) (catch :default _ nil)) set-aside (set (map :key @reports)) ;; an entry set aside is not offered, and is allowed; one silently not offered is lost - builds (when template + entry (fn [k] (some #(when (map? %) (get % k)) (vals entries))) + build (fn [[k c]] + (let [id [(hash (entry k)) (hash c)] + r (if (and k (contains? @built id)) + (get @built id) + (attempt [:character k] #(use-entry! template k c (entry k))))] + (when k (swap! built assoc id r)) + (some-> r (assoc :stage [:character k]) (cond-> k (assoc :key k))))) + builds (when (and template (not light?)) (doall (remove #(and (= "never offered" (:error %)) (set-aside (second (:stage %)))) - (keep (fn [[k c]] - (attempt [:character k] #(use-entry! template k c))) - (cons [nil stored-character] (seeds entries)))))) - drawn (doall (pages entries))] + (keep build (cons [nil stored-character] (seeds entries)))))) + drawn (doall (pages entries (not light?)))] (doall (remove nil? (concat [boot shared] sub-fails [built-ins deep] builds drawn (wrong-blame @reports (get lib source)))))))))) (defn- run-via! "run-pack! for `lib` arriving `via` :storage, :share, or :import (the file importer's checks and clean-up first, then what it would store)." - [lib via] + [lib via & [opts]] (if (= via :import) (let [result (atom nil)] (or (some-> (attempt :import @@ -339,9 +432,10 @@ :existing-plugins {} :import-source-name source}))) vector) - (when-let [data (:data @result)] (run-pack! (if (get data source) data {source data}))) + (when-let [data (:data @result)] + (run-pack! (if (get data source) data {source data}) :storage opts)) [])) - (run-pack! lib via))) + (run-pack! lib via opts))) (defn- cleanup! [] (doseq [k ["plugins" "plugins:rejected" "plugins:corrupt" "character"]] @@ -363,33 +457,102 @@ (cleanup!) (is (empty? failures) (pr-str failures)))) +(defn- field-pack + "Every field of every type's base entry holding each of `vs`: one entry per field and value, + keyed :junk---. Returns {:cases {key [type path value]} + :entries {type {key entry}}}." + [types vs] + (let [cases (into {} (for [t types + [j v] (map-indexed vector vs) + [i p] (map-indexed vector (field-paths (get-in @base-entries + [t :junk-base])))] + [(keyword (str "junk-" (name t) "-" j "-" i)) [t p v]]))] + {:cases cases + ;; the name is the key's own text, so a type that keys its options by name (boons, + ;; invocations) still names this entry; a junk :name stays junk + :entries (reduce-kv (fn [m k [t p v]] + (assoc-in m [t k] (assoc-in (assoc (get-in @base-entries [t :junk-base]) + :key k :name (name k)) + p v))) + {} cases)})) + +(defn- sub-pack [entries ks] + (let [ks (set ks)] + (into {} (for [[t es] entries] [t (into {} (filter (comp ks key)) es)])))) + +(defn- untraced + "The failures in `fs` not traced to an entry in `ks`." + [fs ks] + (remove #(contains? ks (:key %)) fs)) + +(defn- culprits + "The entries among `ks` whose failure could not be traced in a run of all of them, found by + halving: [[key failure]]. Two entries that fail only together are returned as one, keyed by + their first." + [entries ks via] + (let [ks (vec ks) + fs (untraced (run-via! (library (sub-pack entries ks)) via {:light? true}) (set ks))] + (cond + (empty? fs) [] + (= 1 (count ks)) [[(first ks) (first fs)]] + :else (let [half (quot (count ks) 2) + found (concat (culprits entries (subvec ks 0 half) via) + (culprits entries (subvec ks half) via))] + (if (seq found) found [[(first ks) (assoc (first fs) :together (count ks))]]))))) + +(defn- field-job + "Run the pack of `types`' fields holding each of `vs`, arriving `via`, and return [key failure] + for each entry that fails: traced within the run, or found by halving when a failure cannot be + traced (startup, a whole subscription). Entries found that way are taken out and the pack run + again, since their failure can hide others (a broken class list leaves no template)." + [types vs via] + (let [{:keys [cases entries]} (field-pack types vs)] + (loop [ks (set (keys cases)) found {}] + (let [fs (run-via! (library (sub-pack entries ks)) via) + named (into {} (for [f (reverse fs) :when (contains? ks (:key f))] [(:key f) f])) + found (merge named found) + left (untraced fs ks) + loose (when (seq left) (culprits entries ks via))] + (cond + (seq loose) (recur (apply disj ks (map first loose)) (into found loose)) + ;; it failed, and failed nowhere when run again: say so rather than drop it + (seq left) {:cases cases + :found (assoc found :pack (assoc (first left) :not-repeated true))} + :else {:cases cases :found found}))))) + (deftest junk-in-any-field-never-throws - (let [failures - (doall - (for [content-type (audit-types) - v junk - via [:storage :import :share] - :let [{:keys [cases entries]} (batch content-type v) - fs (run-via! (library entries) via)] - :when (seq fs) - ;; one pack per value holds every field: name the field by running each alone - [k path] cases - :let [single (run-via! (library {content-type - {k (get-in entries [content-type k])}}) via)] - :when (seq single)] - (report! [(name content-type) path v via (first single)])))] + ;; one type per pack, every value in it: entries of other types can stop one being offered, + ;; and each pack pays for the built-in content once + ;; a job is one type's values by all three ways in, on one page, so an entry that arrives the + ;; same each way is drawn and built once; the heaviest types are split in four + ;; seconds a job took on integration (classes ~240 a group): spreads the work across the pages + (let [heavy #{::e5/classes ::e5/subclasses ::e5/monsters} + groups #(if (heavy %) (partition-all 3 junk) [junk]) + weight {::e5/classes 240 ::e5/monsters 65 ::e5/subraces 70 ::e5/subclasses 15 ::e5/races 35 + ::e5/backgrounds 25 ::e5/feats 15} + failures (doall + (for [[t vs] (my-part (for [t (audit-types) vs (groups t)] [t vs]) + (fn [[t]] (get weight t 3))) + via [:storage :import :share] + :let [t0 (js/performance.now) + {:keys [cases found]} (field-job [t] vs via) + _ (js/console.log "JUNKJOB" (name t) (name via) (count vs) "values" + (int (/ (- (js/performance.now) t0) 1000)) "s")] + [k f] (sort-by (comp str key) found) + :let [[t path v] (cases k)]] + (report! [(if t (name t) "pack") path v via (dissoc f :key)])))] (cleanup!) (is (empty? failures) (str (count failures) " failures\n" (str/join "\n" (map pr-str failures)))))) (deftest a-junk-section-never-throws (let [failures (doall - (for [content-type (audit-types) + (for [content-type (my-part (audit-types)) v [42 "x" true [] [42] #{42}] via [:storage :import :share] :let [fs (run-via! (library {content-type v}) via)] :when (seq fs)] - (report! [(name content-type) v via (first fs)])))] + (report! [(name content-type) v via (dissoc (first fs) :key)])))] (cleanup!) (is (empty? failures) (str (count failures) " failures\n" (str/join "\n" (map pr-str failures)))))) diff --git a/test/cljs/orcpub/junk_audit_runner.cljs b/test/cljs/orcpub/junk_audit_runner.cljs new file mode 100644 index 000000000..b856f1660 --- /dev/null +++ b/test/cljs/orcpub/junk_audit_runner.cljs @@ -0,0 +1,18 @@ +(ns orcpub.junk-audit-runner + "Runs only orcpub.dnd.e5.junk-pack-test, for test/e2e/junk-pack-audit.js: the audit's pages load + this build instead of the whole browser suite." + (:require [cljs.test :refer-macros [run-tests]] + [re-frame.core :as rf] + [orcpub.dnd.e5.junk-pack-test])) + +;; as in orcpub.test-runner: building the template must not start the autosave watcher +(rf/reg-fx :orcpub.dnd.e5.autosave-fx/ensure-template-cache (fn [_])) + +;; the audit reads subscriptions outside a component on purpose, thousands of times: without this +;; each read logs a warning, and carrying those to the runner took most of its time +(rf/set-loggers! {:warn (fn [& _])}) + +(defmethod cljs.test/report [:cljs.test/default :end-run-tests] [m] + (js/console.log "JUNKDONE" (pr-str (select-keys m [:test :pass :fail :error])))) + +(run-tests 'orcpub.dnd.e5.junk-pack-test) diff --git a/test/e2e/junk-pack-audit.js b/test/e2e/junk-pack-audit.js index 87ef67ba0..decd6be0b 100644 --- a/test/e2e/junk-pack-audit.js +++ b/test/e2e/junk-pack-audit.js @@ -1,54 +1,58 @@ // The full junk-pack audit: orcpub.dnd.e5.junk-pack-test with every content type, field, junk -// value and way in (the everyday suite runs only its base-pack check). One page per content type, -// so a slow type does not hold up the rest; each failure is written as soon as it is found. +// value and way in (the everyday suite runs only its base-pack check). It loads a build of that +// test alone and splits the work across pages running in parallel; each failure is written as +// soon as it is found. // -// lein fig:test && NODE_PATH=/opt/node22/lib/node_modules node test/e2e/junk-pack-audit.js [types…] -// types: spells monsters encounters backgrounds languages invocations boons selections feats -// races subraces subclasses classes (default: all). Failures go to target/test/junk-audit.txt, -// one per line. Exit 0 = nothing threw. +// lein fig:junk && NODE_PATH=/opt/node22/lib/node_modules node test/e2e/junk-pack-audit.js [pages] [types] +// pages: how many run at once (default 4). types: a comma list such as races,feats (default all). +// Failures go to target/junk-audit/junk-audit.txt, one per line, and each page's console to +// console-.log beside it. Exit 0 = nothing threw. const http = require('http'), fs = require('fs'), path = require('path'); const { chromium } = require('playwright'); const { findChrome } = require('../browser/lib/find-chrome'); -const TYPES = ['spells', 'monsters', 'encounters', 'backgrounds', 'languages', 'invocations', 'boons', - 'selections', 'feats', 'races', 'subraces', 'subclasses', 'classes']; -const ROOT = path.resolve('target/test'); +const ROOT = path.resolve('target/junk-audit'); const OUT = path.join(ROOT, 'junk-audit.txt'); -const RUNNER = '
'; +const PAGE = ''; const srv = http.createServer((q, r) => { const u = decodeURIComponent(q.url.split('?')[0]); - const f = path.join(ROOT, u === '/' ? 'runner-all.html' : u); - if (f === path.join(ROOT, 'runner-all.html') && !fs.existsSync(f)) { - r.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return r.end(RUNNER); - } + if (u === '/' || u === '/audit.html') { r.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return r.end(PAGE); } + const f = path.join(ROOT, u); if (!f.startsWith(ROOT) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { r.writeHead(404); return r.end(); } - r.writeHead(200, { 'Content-Type': f.endsWith('.js') ? 'application/javascript; charset=utf-8' : 'text/html; charset=utf-8' }); + r.writeHead(200, { 'Content-Type': f.endsWith('.js') ? 'application/javascript; charset=utf-8' : 'application/octet-stream' }); fs.createReadStream(f).pipe(r); }); (async () => { - const types = process.argv.slice(2).length ? process.argv.slice(2) : TYPES; + const n = Number(process.argv[2] || 4); + const types = process.argv[3] ? `=${process.argv[3]}` : ''; + if (!fs.existsSync(path.join(ROOT, 'js/junk-audit.js'))) { console.error('Run lein fig:junk first.'); process.exit(2); } await new Promise(r => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port; const br = await chromium.launch({ executablePath: findChrome() }); fs.writeFileSync(OUT, ''); - let fails = 0, unfinished = []; - for (const t of types) { + const t0 = Date.now(); + let fails = 0; + const results = await Promise.all([...Array(n).keys()].map(async i => { const pg = await br.newPage(); - pg.on('console', m => { + let summary = null; + const log = path.join(ROOT, `console-${i + 1}.log`); + fs.writeFileSync(log, ''); + const done = new Promise(res => pg.on('console', m => { const l = m.text(); + fs.appendFileSync(log, l + '\n'); if (l.startsWith('JUNKFAIL ')) { fs.appendFileSync(OUT, l.slice(9) + '\n'); fails++; console.log(l); } - }); - const t0 = Date.now(); - // the tests run while the page loads, so wait for the first byte only - await pg.goto(`http://localhost:${port}/runner-all.html?junk-audit=${t}`, { waitUntil: 'commit', timeout: 600000 }); - try { - await pg.waitForFunction(() => /Totals\s+\d+ Tests/.test(document.body.innerText), null, { timeout: 0, polling: 5000 }); - console.log(`${t}: done in ${Math.round((Date.now() - t0) / 60000)} min`); - } catch (e) { unfinished.push(t); console.log(`${t}: did not finish (${e.message.split('\n')[0]})`); } + if (l.startsWith('JUNKJOB ')) console.log(`page ${i + 1}: ${l.slice(8)}`); + if (l.startsWith('JUNKDONE ')) { summary = l.slice(9); res(); } + })); + pg.on('pageerror', e => console.log(`page ${i + 1}: ${e.message.split('\n')[0]}`)); + await pg.goto(`http://localhost:${port}/audit.html?junk-audit${types}&part=${i}/${n}`, { waitUntil: 'commit' }); + await done; + console.log(`page ${i + 1}/${n} done in ${Math.round((Date.now() - t0) / 1000)} s: ${summary}`); await pg.close(); - } - console.log(`${fails} failure(s)` + (unfinished.length ? `; unfinished: ${unfinished.join(' ')}` : '')); - process.exitCode = fails === 0 && !unfinished.length ? 0 : 1; + return summary; + })); + console.log(`${fails} failure(s) in ${Math.round((Date.now() - t0) / 1000)} s`); + process.exitCode = fails === 0 && results.every(Boolean) ? 0 : 1; await br.close(); srv.close(); })().catch(e => { console.error(e); process.exit(2); }); From 7557ebf874edaf289b7964fc38daabeed536398e Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 21 Oct 2026 21:08:17 +0000 Subject: [PATCH 07/31] The guard design as agreed: checks where homebrew comes in and where it is read, containment with what exists --- docs/HOMEBREW_GUARD.md | 253 +++++++++++++++++++++-------------------- 1 file changed, 127 insertions(+), 126 deletions(-) diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index c8360aa5f..548813fb7 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -1,6 +1,7 @@ # Homebrew guarded by default -Status: **design, for review; read against our documents 2026-10-21.** Branch `feature/guarded-homebrew`, off integration at `d51938c1`. +Status: **design, for review (2026-10-21).** Branch `feature/guarded-homebrew`, off integration +at `d51938c1`. ## The problem in one paragraph @@ -127,28 +128,33 @@ bug instead of the instance: ## The design -The app trusts the shape of everything that passes a thin load check. The design stops trusting -it at two kinds of seam, so nothing downstream has to change, and contains and reports whatever -still gets through. - -### 1. One cleaning step, from a table of shapes - -- **`homebrew_shapes.cljc`** (cljc, a leaf: requires nothing from the app) says, for each of the - 13 content types, every field the app reads and its shape, nested as deep as the readers go: - `{:abilities [:map-of :ability :number], :spells [:vector-of {...}], ...}`. Fields nobody reads - are not listed and pass untouched. The builder field schemas (`builder_fields.cljc`, on - grant-rows) only cover two grant-rows types, so the 13 core types' shapes are written here; - where a builder schema exists later, it should produce its type's entry in this table. -- **`clean`** takes an entry and returns it with each listed field the right shape, plus what it - found, each `{:path :problem :action}`: - - **fixed** when the meaning is obvious: a string where a list of strings belongs, one card - where a list of cards belongs (what `mend-cards` does today, which becomes table entries); - - **unreadable** when it is not (`:spells 42`, a number where a map belongs). Which of these - sets the entry aside and which reads past the field is the owner's ruling below. - - A whole content section that is not a map is unreadable as a section. -- **Loading is never stricter than saving.** The table accepts everything any builder writes, - old forms included (a feat's released set and its 2024 list; race `:languages` as a set of - names). A generated test checks the save specs against it, as `content_specs_test` does today. +### What it promises, and what proves it + +1. **Bad homebrew cannot crash the app at startup or take down a page.** At worst the one bad + item does not show, and the user is told which one. *Proof:* the junk audit (every field, + every junk value, by storage, import and share link) goes from 659 failures to 0, and its + canaries show it can still fail. +2. **A fix is made once: written, used and exported.** `"5"` fixed to `5` is stored as `5`, read + as `5`, exported as `5`. *Proof:* `fixes_persist_e2e`, and a second load that finds nothing to + fix and writes nothing. +3. **Nothing disappears without a word.** *Proof:* the audit's "neither offered nor set aside" + check stays at zero. + +**What it cannot promise:** that code not yet written has no bugs. A bug in our own code is +contained to one item and reported (5), and the audit, rerun in minutes, finds the gap. + +### 1. One cleaning step, from a table of shapes (built) + +- **`homebrew_shapes.cljc`** says, for each of the 13 content types, every field the app reads and + its shape. **`clean-entry` / `clean-library`** return the entry made to fit, and what they found: + - **fixed** when the meaning is obvious (`"5"` → `5`; one card → a list of one; a set of keys → + a key-to-true map). `mend-cards`' rules move into the table: one repair mechanism, not two; + - **unreadable** when it is not (a number where a map belongs). +- A sound entry comes back as the identical object. Loading is never stricter than saving: every + real fixture comes through with nothing unreadable (a test). Fixes produce only shapes the + builders write, so exported files still import into older builds. +- The table decides how good a fix or a message is. It is not what keeps the app safe: a field it + does not list is still covered by the conversion check (2) and by containment (5). #### The shape forms @@ -171,131 +177,126 @@ obvious: [:map {field s ...}] a map; the listed fields are checked, the rest pass untouched ``` -### 2. Entry seams: clean once, store the result - -Where homebrew comes in, the cleaned entry is what is kept, so the same file is not fixed again -on every load and export writes clean data: - -| Seam | Where | What happens | -|---|---|---| -| **Import** | `orcbrew-val/validate-import`, after its own repairs | Fixed entries are stored fixed; unreadable ones go to the quarantine panel with the field and reason. The import report lists both. | -| **Load** (startup, and another tab's write) | the `::e5/plugins` load step, after `mend-library` | The same; a fix is written back once through `library/commit`, after the `plugins:pre-fix` backup, as homebrew-keys-design §4a.2 does for the loader's repairs. Later loads find nothing to fix. | -| **Share link** | `apply-shared-content`, and the party page's share load | Cleaned before the load gate. Shared content is never stored, so an unreadable entry is left out of the view and the banner says so. | - -`LOAD_HOMEBREW_URL` writes into storage, so the load seam covers it. Builder drafts are restored by -`reg-local-store-cofx`, which TODO.md pins as hands-off; drafts are not touched, and the builders -are covered by containment (4). - -### 3. The read seam: nothing reads raw homebrew - -Anything that changed after load (data written straight into the app's memory, an event that -stores raw data, a bug of ours) skips the entry seams. So everything that interprets homebrew -reads it through `readable` (a library in, the cleaned library and its findings out): - -- `process-plugin-vals` and `process-plugins-with-sources`, which every homebrew subscription - already goes through; -- the few event handlers and pages that read `(:plugins db)` or `::e5/plugins` to interpret it - (the class keys at startup, key reconciliation, the former-key index, My Content's lists). - -Storage, export, rename and the quarantine keep reading the raw library, each with a marker -comment saying why; the source-scan test (6) holds that line. - -### 4. Containment for what still gets through - -- **The guard on all 13 types**, through the existing `register-conversions!` list; the deep - check runs the same conversions, so it cannot drift; a subrace or subclass is blamed on itself. -- **Per-item boundaries** (the existing `render-guard`) on My Content's rows, the monster list's - items and each builder form, so one bad item takes down that item, not the page. -- `base-class-options` reports what it catches instead of silently returning no classes. - -### 5. The health log: never silent - -Every finding from `clean`, every entry set aside, every link to nothing and every crash a -boundary catches goes to one log in app-db, each with its entry, field and reason, deduplicated. -It feeds a per-item mark in My Content (the way a link to nothing is marked) and the builder's -note, and the audit reads it to name the failing field. +### 2. Where homebrew comes in: fix once, check it works, store it + +At each way in, an entry is cleaned, then **converted by the app's own code** (the existing deep +check: convert it to a character option and realize every part, inside the guard). Fixes are +written; an entry that cannot be read or does not convert moves to the **quarantine panel** with +its reason, the original kept there to fix or restore. The active library is then clean, so +nothing is found again on the next load. + +| Way in | Where | +|---|---| +| **Import** | `validate-import`, then the write gate. The import report lists fixes and quarantined entries. | +| **Every other library write** (builder saves, rename, move, restore, conflict resolution) | Inside the existing write gate, `commit-library`. `library_gate_test` already fails the build for a write that goes around it. A builder save checks one entry. | +| **Load** (startup, another tab's write, what `LOAD_HOMEBREW_URL` stored) | A library saved before this change is checked once; fixes written back through the loader's existing write-back (`persist-set-aside!`) after the `plugins:pre-fix` backup. | +| **Share link** | `apply-shared-content` and the party page's share load. Shared content is never stored: an entry that fails is left out of the view and the banner says so. | + +Builder drafts are restored by `reg-local-store-cofx`, which TODO.md pins as hands-off; they are +covered by (5). + +### 3. Inside the app: every read is checked + +Data can change after it came in: a script writing to the app's memory, an event storing raw +data, a bug of ours. So the shape check also runs where the app reads homebrew: +`process-plugin-vals` and `process-plugins-with-sources` (every homebrew subscription, so every +builder, page, option and character, reads through them), and the few event handlers that read +the library directly. Whatever is in memory is checked there, so the functions after it only ever +receive the right shapes, and none of them changes. Anything unclean found there is cleaned for +reading and reported as a bug: nothing should reach it that way. + +The check is a read-only walk. It runs over the whole library on each change if that measures as +cheap; if not, it checks only entries that changed (by object identity: data is immutable, so a +change is a new object, and gets checked). + +### 4. Never silent, with what exists + +Fixes show where load repairs show today; quarantined entries in the quarantine panel and its +notice; the health card counts them. A crash caught by (5) reports through the guard's reporter, +as the page-level fallback does today. No new log. + +### 5. Containment for bugs in our own code + +The checks above protect the app from bad data. A function passing the wrong thing to another is +a bug in our code; these catch it, using what exists: + +- **The page fallback** (app root, `core.cljs`) stays the last resort: a crashed page shows + "Something went wrong on this page", runs the deep check, sets aside the cause, and retries. +- **Per-item boundaries** (the existing `render-guard`) on My Content's rows, the monster list, + each builder form and the sheet's lists, so a bad item costs that item, not the page. +- **One `reg-event-error-handler`** (re-frame 1.4.4 has it): an event that throws (a click, a save, + a load) is reported, and the app's state stays as it was before that event. Error boundaries + only catch drawing; this catches events. The existing `check-spec-interceptor` on character + events then reports through it instead of throwing with nothing listening. +- **The guard on all 13 types** (through `register-conversions!`; today 5), a subrace or subclass + blamed on itself, and `base-class-options` reporting what it catches instead of silently + returning no classes. ### 6. Enforcement, and tests that cannot pass by accident -- **Source scan** (like `library_gate_test`): a read of `::e5/plugins`, `::e5/plugin-vals`, - `::e5/plugins-with-sources`, `::e5/shared-plugins` or `(:plugins db)` outside the homebrew - namespace needs a marker comment and a reason, or `lein test` fails. -- **The junk audit** (`node test/e2e/junk-pack-audit.js`): every type, field, junk value and way - in; target under 10 minutes; a faster version replaces a slower one only after it reproduces - the same failures. Each stage gets a **canary**, a known-bad entry it must catch; a canary that - passes fails the audit. **Coverage is counted**: fewer entries picked, drawn or converted than - expected fails. -- **AGENTS.md**: homebrew is read through `readable`; a safety claim cites a committed test that +- **Writes:** the existing `library_gate_test`. +- **The junk audit** (`lein fig:junk && node test/e2e/junk-pack-audit.js`, about 8 minutes), in CI + once it reads zero. Each stage has a **canary**, a planted bad entry it must catch; **coverage is + counted**: fewer entries picked, drawn or converted than expected fails. +- **AGENTS.md:** write the library through the gate; a safety claim cites a committed test that runs; a report says what it did not cover. ## Efficiency -Nothing here may make the app slower to use. The costs, and how each is kept small: +Nothing here may make the app slower to use. | Cost | Kept small by | Budget | |---|---|---| -| `clean` over a library | Only listed fields are walked; O(size of the entry). | 12-source test library: under 30 ms | -| The read seam on every library change | It runs inside `process-plugin-vals`, which already walks every entry on each change, not as a second pass. Cleaned entries are cached by entry identity, so editing one entry cleans one entry. | no measurable change | -| Subscriptions downstream | An unchanged entry comes back as the identical object, so the per-type `=` firewall still stops propagation: editing a spell does not rebuild races. | no extra recomputation | -| Events reading the library | `readable` is memoized on the library's identity: one clean per change, shared. | one pass per change | -| Startup | The load seam writes its fixes once; later boots clean and find nothing, and write nothing. | under 30 ms added | -| Import | One pass per file, beside parsing. | negligible | -| Laziness | Not realizing option lists stays as is (realizing everything costs ~50x); clean shapes make the lazy throws unreachable instead. | none | -| Health log | Built in the same pass as `clean`; deduplicated by entry and field; capped. | none | -| Source scan, audit | Build and CI time only. | audit under 10 min | +| Cleaning | Only on a write: an import, a builder save (one entry), the first load after this lands. Reading costs nothing. | 12-source test library: under 30 ms | +| Later loads | The library is already clean: a read-only walk that finds nothing and writes nothing. | under 10 ms | +| The in-app read check | A read-only walk on each library change; if it measures above budget, only entries that changed. | under 10 ms | +| The conversion check at a way in | Only the entries that came in or changed; `guard/realize` now walks each shared part once. | a builder save: one entry | +| Downstream | A clean entry is the identical object, so the per-type `=` firewall still holds: editing a spell does not rebuild races. | no extra recomputation | +| The deep check | `guard/realize` walks each shared part once (done): one homebrew class went from seconds to milliseconds. | done | +| Audit | Build and CI time only. | under 10 min (now about 8) | **Measured, not assumed:** before and after each step, with the 12-source test library: startup, -`process-plugin-vals`, the template build, one keystroke in a builder, and an import. A step that -adds more than its budget is not merged. +`process-plugin-vals`, the template build, one keystroke in a builder, and an import. ## Prep work already done, to reuse -- **testing/develop**: a Playwright suite of 60 scenarios from July 2026, never merged and run - by nothing: `bad-data`, `broken-content-survives-use`, `boot-resilience`, - `error-boundary-verify`, `quarantine-repair`, `resilient-loader` and others. Run against - integration to see what still holds; port what the junk audit does not cover. -- **Fixtures on integration**: `broken-content.orcbrew`, `cruft-shapes.orcbrew`, - `keyword-trap.orcbrew`, `test-pak.orcbrew`: audit inputs beside the junk grid. -- **On integration**: `mend-library`/`mend-cards` (their fixes become table entries), - `library/commit` and `plugins:pre-fix` (the write-back), the quarantine panel and - `repair-quarantined-source` (unreadable entries), `library/dangling` (extend to parents that - are not keywords), `render-guard`/`isolate-culprit` (containment), `register-conversions!` and - `deep-check`, `library_gate_test` (the source scan's model), HOMEBREW_REQUIRED_FIELDS.md (the - nil analysis), the browser probes and their baseline. -- **On grant-rows**: `builder_fields.cljc` (field predicates and where-messages) and - `content_types.cljc`; to converge with the shape table, not to duplicate it. -- **Known, not fixed**: homebrew boons and invocations drop their `:key` and key their option by - name (`pact-boon-options`, `classes.cljc`), so two with one name collide and a renamed one is - lost to the characters that picked it. content-extensibility-compatibility.md (agents/develop) - calls it a latent footgun and says to pass the stored `:key`. Fixed in step 7 with the guard. +- **testing/develop**: a Playwright suite of 60 scenarios from July 2026, never merged and run by + nothing (`bad-data`, `broken-content-survives-use`, `boot-resilience`, `quarantine-repair`, …). + Go through it once and port what the junk audit does not cover, rather than keep two suites. +- **Fixtures on integration**: `broken-content`, `cruft-shapes`, `keyword-trap`, `test-pak`. +- **On integration**: the write gate and `library_gate_test`; `persist-set-aside!` and + `plugins:pre-fix`; `mend-*` (folded into the table); the quarantine panel and + `repair-quarantined-source`; `library/dangling`; `render-guard`; `register-conversions!` and + `deep-check`; HOMEBREW_REQUIRED_FIELDS.md; the browser probes. +- **On grant-rows**: `builder_fields.cljc` and `content_types.cljc`; their schemas feed this table + when grant-rows merges, so there is one description of a field, not two. +- **Known, not fixed**: homebrew boons and invocations key their option by name, not their stored + `:key` (`pact-boon-options`, `classes.cljc`; content-extensibility-compatibility.md calls it a + latent footgun). ## Order of work, each step gated -1. **The audit** (in progress): under 10 minutes, the same failures as the baseline (differences - explained), canaries, counted coverage. *Gate:* parity; time. -2. **The shape table and `clean`**, cljc, with a JVM test feeding every listed field every junk - value. No app wiring. *Gate:* every baseline field is fixed or reported unreadable in the unit - grid; save specs accepted. -3. **The read seam.** *Gate:* the audit drops by the fields covered; the everyday suites and the - homebrew e2e scripts pass; the benchmark is in budget. -4. **The share seam**, then **the import seam**. *Gate:* the same, plus the import report test. -5. **The load seam** with the write-back once. *Gate:* `fixes_persist_e2e`, `boot_rescue_e2e`, - `quarantine-restore`, `library-repairs` pass; a second boot writes nothing. -6. **The health log and the marks** in My Content and the builders. -7. **Containment**: all 13 types guarded, per-item boundaries, `base-class-options` reporting. -8. **Enforcement**: the source scan, the audit in CI, the AGENTS.md rules, the documents - corrected, the testing/develop scenarios run and ported. -9. **grant-rows** merges this, registers its pools in the table and the guard, and runs the - audit. +1. **Done:** the baseline (659), the shape table and `clean` (not yet called by the app), the + audit in about 8 minutes, `guard/realize` walking each part once. +2. **Audit canaries and counted coverage.** +3. **Import and the write gate:** clean, convert, quarantine. *Gate:* the audit drops; the + everyday suites and the homebrew e2e scripts pass; the timings stay in budget. +4. **Load and share links.** *Gate:* `fixes_persist_e2e`, `boot_rescue_e2e`, `quarantine-restore`, + `library-repairs` pass; a second load writes nothing. +5. **The in-app read check**, measured; changed-only only if needed. +6. **Containment:** per-item boundaries, the event error handler, the guard on 13 types. +7. **Enforcement:** the audit in CI at zero, the AGENTS.md rules, the documents corrected, the + testing/develop scenarios gone through. +8. **grant-rows** merges this, adds its fields to the table, and runs the audit. Shared hardening is built on integration; future work goes there unless it touches only the refactor branch (owner, 2026-10-21). ## For the owner -- **An unreadable field**: set the whole entry aside in the quarantine panel with the reason - (recommended: a number where a map belongs is an authoring mistake, not something to read - past), or keep the entry and read past the field, with a mark? -- **Boons and invocations keyed by name** (known, above): fix in step 7 by passing the stored key? +- **An unreadable field**: move the whole entry to the quarantine panel with the reason + (recommended: it is what keeps the active library clean, so nothing is found again), or keep + the entry and read past the field, with a mark? +- **Boons and invocations keyed by name** (known, above): fix it here by passing the stored key? Characters that picked one hold the name-derived key; where the stored key differs (a source - tag), they need healing through `:former-keys`, the way renamed keys heal today. + tag), they heal through `:former-keys`, the way renamed keys heal today. From caf5df405242547999d4e40fb8402c12cbce0dfb Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 21 Oct 2026 23:48:04 +0000 Subject: [PATCH 08/31] Guard design: the check runs in a global interceptor on every library change; hardened core helpers ranked by the audit --- docs/HOMEBREW_GUARD.md | 59 ++++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index 548813fb7..3b459f20a 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -179,18 +179,28 @@ obvious: ### 2. Where homebrew comes in: fix once, check it works, store it -At each way in, an entry is cleaned, then **converted by the app's own code** (the existing deep -check: convert it to a character option and realize every part, inside the guard). Fixes are -written; an entry that cannot be read or does not convert moves to the **quarantine panel** with -its reason, the original kept there to fix or restore. The active library is then clean, so -nothing is found again on the next load. - -| Way in | Where | +**One global interceptor** (re-frame 1.4.4's `reg-global-interceptor`; the app has none yet) runs +after every event. If the event left the library as the identical object, which is almost every +event, it does nothing. If the event changed it, however it changed it: + +- the changed entries are cleaned, and the fixes go into the new state and are **written to + storage** with it (`"5"` stored as `5`, read as `5`, exported as `5`); +- the changed sources are then **converted by the app's own code** (the existing deep check, + `check-and-set-aside!`, which runs after the new state is in place): an entry that cannot be + read or does not convert moves to the **quarantine panel** with its reason, the original kept + there to fix or restore. + +The active library is then clean, so nothing is found again on the next load. Because it runs on +every event, it does not depend on code calling the right function, or on `library_gate_test` +recognising how a write was made (it scans for `assoc`, `update` and `dissoc` on `:plugins`, not +`merge` or a helper). That test stays, as the check that every write also reaches storage. + +| Way in | What the interceptor sees | |---|---| -| **Import** | `validate-import`, then the write gate. The import report lists fixes and quarantined entries. | -| **Every other library write** (builder saves, rename, move, restore, conflict resolution) | Inside the existing write gate, `commit-library`. `library_gate_test` already fails the build for a write that goes around it. A builder save checks one entry. | -| **Load** (startup, another tab's write, what `LOAD_HOMEBREW_URL` stored) | A library saved before this change is checked once; fixes written back through the loader's existing write-back (`persist-set-aside!`) after the `plugins:pre-fix` backup. | -| **Share link** | `apply-shared-content` and the party page's share load. Shared content is never stored: an entry that fails is left out of the view and the banner says so. | +| **Import** | the import event storing the file; the import report lists fixes and quarantined entries | +| **Builder saves, rename, move, restore, conflict resolution** | each of those events; a builder save changes one entry, so one entry is checked | +| **Load** (startup, another tab's write, what `LOAD_HOMEBREW_URL` stored) | `:initialize-db` and `library-changed-elsewhere`; a library saved before this change is fixed once and written back after the `plugins:pre-fix` backup | +| **Share link** | `apply-shared-content` puts shared content in `:shared-plugins`, which the interceptor checks the same way; it is never stored, so an entry that fails is left out of the view and the banner says so | Builder drafts are restored by `reg-local-store-cofx`, which TODO.md pins as hands-off; they are covered by (5). @@ -231,10 +241,23 @@ a bug in our code; these catch it, using what exists: - **The guard on all 13 types** (through `register-conversions!`; today 5), a subrace or subclass blamed on itself, and `base-class-options` reporting what it catches instead of silently returning no classes. +- **Hardened core helpers.** The few functions of ours every option, choice and modifier passes + through (`t/option-cfg`, `t/selection-cfg`, the modifier constructors, `safe-capitalize-kw`, the + trait builder) check what they are given, skip what they cannot use, and report it, the way + `safe-compare` already made every sort safe. Which ones: the audit records where in the code + each crash happened; the helpers most crashes pass through go first, and each must show a drop + in the audit count. + +Replacing Clojure's own functions (`seq`, `keys`, `map`, …) with safe versions is not part of +this: inside Clojure they call each other, and `for` and destructuring expand to them, so our +versions would only apply where our code calls them by name; a namespace that forgot them would be +unprotected; and returning nothing instead of throwing hides bad data rather than reporting it. +Clean data at the seams makes all of them safe at once. ### 6. Enforcement, and tests that cannot pass by accident -- **Writes:** the existing `library_gate_test`. +- **Writes:** the global interceptor checks every change, and `library_gate_test` still requires + every write to reach storage through the gate. - **The junk audit** (`lein fig:junk && node test/e2e/junk-pack-audit.js`, about 8 minutes), in CI once it reads zero. Each stage has a **canary**, a planted bad entry it must catch; **coverage is counted**: fewer entries picked, drawn or converted than expected fails. @@ -248,6 +271,7 @@ Nothing here may make the app slower to use. | Cost | Kept small by | Budget | |---|---|---| | Cleaning | Only on a write: an import, a builder save (one entry), the first load after this lands. Reading costs nothing. | 12-source test library: under 30 ms | +| The global interceptor | One identity comparison per event; work only when the library changed, and only on the entries that changed. | no measurable change | | Later loads | The library is already clean: a read-only walk that finds nothing and writes nothing. | under 10 ms | | The in-app read check | A read-only walk on each library change; if it measures above budget, only entries that changed. | under 10 ms | | The conversion check at a way in | Only the entries that came in or changed; `guard/realize` now walks each shared part once. | a builder save: one entry | @@ -279,11 +303,12 @@ Nothing here may make the app slower to use. 1. **Done:** the baseline (659), the shape table and `clean` (not yet called by the app), the audit in about 8 minutes, `guard/realize` walking each part once. 2. **Audit canaries and counted coverage.** -3. **Import and the write gate:** clean, convert, quarantine. *Gate:* the audit drops; the - everyday suites and the homebrew e2e scripts pass; the timings stay in budget. -4. **Load and share links.** *Gate:* `fixes_persist_e2e`, `boot_rescue_e2e`, `quarantine-restore`, - `library-repairs` pass; a second load writes nothing. -5. **The in-app read check**, measured; changed-only only if needed. +3. **The global interceptor:** clean, write, convert, quarantine, for every library change + (import, saves, load, share). *Gate:* the audit drops; the everyday suites and the homebrew e2e + scripts (`fixes_persist_e2e`, `boot_rescue_e2e`, `quarantine-restore`, `library-repairs`) pass; + a second load writes nothing; the timings stay in budget. +4. **The in-app read check**, measured; changed-only only if needed. +5. **Hardened core helpers**, ranked by the audit. 6. **Containment:** per-item boundaries, the event error handler, the guard on 13 types. 7. **Enforcement:** the audit in CI at zero, the AGENTS.md rules, the documents corrected, the testing/develop scenarios gone through. From ca8b7d6a45d1a78cdb7dc0aeff7004c97f932ff4 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 21 Oct 2026 23:55:30 +0000 Subject: [PATCH 09/31] The junk audit's checks each catch a planted failure, and count what they covered each-check-catches-its-canary gives every stage a failure it must report: a race that throws once used past the guard, a race that silently goes missing, an entry set aside that is not the pack's, a builder that throws, a My Content list that throws, startup that throws, the deep check that throws, and a built-in class that goes missing. The clean base pack now also checks that every entry was picked by a character, opened in its builder and listed in My Content. Both run in the everyday suite. Turning the never-offered check off fails its canary; leaving races out of the characters fails the coverage count. --- test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 137 ++++++++++++++++---- 1 file changed, 111 insertions(+), 26 deletions(-) diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index d472f439c..b9f194898 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -6,7 +6,7 @@ (only that entry); nothing may throw and no built-in content may go missing. The everyday suite runs the base pack only; test/e2e/junk-pack-audit.js runs it all, in a few minutes: one pack per junk value holds every type, and a failure is traced to its entry within the run." - (:require [cljs.test :refer-macros [deftest is]] + (:require [cljs.test :refer-macros [deftest is testing]] [clojure.string :as str] [re-frame.core :as rf] [re-frame.db :refer [app-db]] @@ -16,6 +16,7 @@ [orcpub.template :as t] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.homebrew-check :as homebrew-check] [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] [orcpub.dnd.e5.character :as char5e] @@ -179,6 +180,13 @@ (defn- attempt [stage f] (try (f) nil (catch :default e {:stage stage :error (msg e)}))) +(def ^:private coverage + "What the last pack exercised, by entry key: :picked by a character, :drawn in its builder, and + the My Content lists :listed. A stage that ran on nothing must not read as a pass." + (atom {})) + +(defn- covered! [stage x] (swap! coverage update stage (fnil conj #{}) x)) + (def ^:private stored-character {::entity/options {:race {::entity/key :human} :class [{::entity/key :fighter @@ -247,6 +255,7 @@ [built avail] (offered template c)] (when (and k (not (some ks (tree-seq coll? seq c)))) (throw (js/Error. "never offered"))) + (when k (covered! :picked k)) (realize! (char5e/ability-values built)) (realize! (filter #(some ks (entity/actual-path %)) avail)))) @@ -263,20 +272,21 @@ :else x)) (def ^:private builders - "Each content type's builder page, and the event that opens an entry in it." - {::e5/spells [::spells5e/set-spell views/spell-builder] - ::e5/monsters [::monsters5e/set-monster views/monster-builder] - ::e5/encounters [::encounters5e/set-encounter views/encounter-builder] - ::e5/backgrounds [::bg5e/set-background views/background-builder] - ::e5/languages [::langs5e/set-language views/language-builder] - ::e5/invocations [::classes5e/set-invocation views/invocation-builder] - ::e5/boons [::classes5e/set-boon views/boon-builder] - ::e5/selections [::selections5e/set-selection views/selection-builder] - ::e5/feats [::feats5e/set-feat views/feat-builder] - ::e5/races [::races5e/set-race views/race-builder] - ::e5/subraces [::races5e/set-subrace views/subrace-builder] - ::e5/subclasses [::classes5e/set-subclass views/subclass-builder] - ::e5/classes [::classes5e/set-class views/class-builder]}) + "Each content type's builder page, and the event that opens an entry in it. Each page is looked + up when drawn, so a canary can stand in for it." + {::e5/spells [::spells5e/set-spell #(views/spell-builder)] + ::e5/monsters [::monsters5e/set-monster #(views/monster-builder)] + ::e5/encounters [::encounters5e/set-encounter #(views/encounter-builder)] + ::e5/backgrounds [::bg5e/set-background #(views/background-builder)] + ::e5/languages [::langs5e/set-language #(views/language-builder)] + ::e5/invocations [::classes5e/set-invocation #(views/invocation-builder)] + ::e5/boons [::classes5e/set-boon #(views/boon-builder)] + ::e5/selections [::selections5e/set-selection #(views/selection-builder)] + ::e5/feats [::feats5e/set-feat #(views/feat-builder)] + ::e5/races [::races5e/set-race #(views/race-builder)] + ::e5/subraces [::races5e/set-subrace #(views/subrace-builder)] + ::e5/subclasses [::classes5e/set-subclass #(views/subclass-builder)] + ::e5/classes [::classes5e/set-class #(views/class-builder)]}) (defn- offered-keys "The keys of the classes, races and backgrounds the app offers now." @@ -341,6 +351,7 @@ [(attempt [:page :my-content] #(expand [views/my-content]))] (mapcat (fn [{:keys [type-name type-key] :as t}] (let [es (get plugin type-key)] + (covered! :listed type-name) (traced (attempt [:page :my-content type-name] (type-list t plugin)) (when (map? es) (for [[k e] es] [k (type-list t (assoc plugin type-key {k e}))]))))) @@ -357,9 +368,10 @@ :let [[open builder] (builders t)] :when open [k e] es :when (map? e)] - (some-> (once [:builder (hash e)] [:builder k] - #(do (rf/dispatch-sync [open e]) (expand [builder]))) - (assoc :stage [:builder k] :key k)))))) + (do (covered! :drawn k) + (some-> (once [:builder (hash e)] [:builder k] + #(do (rf/dispatch-sync [open e]) (expand [builder]))) + (assoc :stage [:builder k] :key k))))))) (defn- run-pack! "Start the app with `lib` arriving `via` :storage (the stored library; also what @@ -375,6 +387,7 @@ (reset! app-db {}) (rf/clear-subscription-cache!) (reset! seen (js/Set.)) + (reset! coverage {}) (let [reports (atom [])] (guard/call-with-reporter #(swap! reports conj %) @@ -445,17 +458,89 @@ ;; --------------------------------------------------------------------------------------------- -(deftest the-base-pack-works +(defn- base-pack + "One clean entry of each type, keyed :junk-, so picking a race cannot stand in for the + feat of the same key." + [] + (into {} (for [[t m] @base-entries + :let [k (keyword (str "junk-" (name t)))]] + [t {k (assoc (:junk-base m) :key k)}]))) + +(def ^:private pickable + "The types a character picks: each needs a character that offers it." + #{::e5/classes ::e5/subclasses ::e5/invocations ::e5/boons ::e5/subraces ::e5/races + ::e5/backgrounds ::e5/feats}) + +(defn- know-built-ins! + "What the app offers with no homebrew at all, read once." + [] (when-not @built-in-keys (run-pack! {}) - (reset! built-in-keys (offered-keys))) - ;; one key per type, so picking a race cannot stand in for the feat of the same key - (let [keyed (into {} (for [[t m] @base-entries - :let [k (keyword (str "junk-" (name t)))]] - [t {k (assoc (:junk-base m) :key k)}])) - failures (run-pack! (library keyed))] + (reset! built-in-keys (offered-keys)))) + +(deftest the-base-pack-works + (know-built-ins!) + (let [pack (base-pack) + failures (run-pack! (library pack)) + {:keys [picked drawn listed]} @coverage + k #(keyword (str "junk-" (name %)))] + (cleanup!) + (is (empty? failures) (pr-str failures)) + (testing "every stage ran on every entry" + (is (= (set (map k (filter pickable (keys pack)))) picked) "picked by a character") + (is (= (set (map k (keys pack))) drawn) "opened in its builder") + (is (= (set (map :type-name views/my-content-types)) listed) "listed in My Content")))) + +(defn- canary-run + "The base pack's failures, with nothing remembered from an earlier run." + [] + (reset! built {}) + (let [fs (run-pack! (library (base-pack)))] (cleanup!) - (is (empty? failures) (pr-str failures)))) + fs)) + +(defn- with-race-option + "Run `f` with `opt5e/race-option` passing the junk race's option through `change`." + [change f] + (let [real opt5e/race-option] + (with-redefs [opt5e/race-option (fn [& args] + (let [o (apply real args)] + (if (= :junk-races (::t/key o)) (change o) o)))] + (f)))) + +(deftest each-check-catches-its-canary + ;; a check that cannot fail is not a check: each stage is given a failure and must report it + (know-built-ins!) + (let [canary #(throw (js/Error. "canary")) + caught? (fn [fs pred] (boolean (some pred fs)))] + (testing "a race that throws once it is used, past the guard" + (is (caught? (with-race-option #(assoc % ::t/selections (lazy-seq (canary))) canary-run) + #(and (= :junk-races (:key %)) (= "canary" (:error %)))))) + (testing "a race that silently goes missing" + (is (caught? (with-race-option (constantly nil) canary-run) + #(= {:stage [:character :junk-races] :error "never offered"} + (select-keys % [:stage :error]))))) + (testing "an entry set aside that is not the pack's own" + (is (caught? (with-race-option #(do (guard/report! {:content-type ::e5/races :key :elf}) %) + canary-run) + #(= :set-aside (:stage %))))) + (testing "a builder that throws" + (with-redefs [views/race-builder (fn [] (canary))] + (is (caught? (canary-run) #(= [:builder :junk-races] (:stage %)))))) + (testing "a My Content list that throws" + (with-redefs [views/my-content-type (fn [& _] (canary))] + (is (caught? (canary-run) #(= [:page :my-content] (take 2 (:stage %))))))) + (testing "startup that throws" + (with-redefs [e5/mend-library (fn [_] (canary))] + (is (caught? (canary-run) #(= :startup (:stage %)))))) + (testing "the deep check that throws" + (with-redefs [homebrew-check/deep-check (fn [_] (canary))] + (is (caught? (canary-run) #(= :deep-check (:stage %)))))) + (testing "built-in content that goes missing" + (let [before @built-in-keys] + (swap! built-in-keys update :classes conj :canary-class) + (try (is (caught? (canary-run) #(= :built-in-content (:stage %)))) + (finally (reset! built-in-keys before))))))) (defn- field-pack "Every field of every type's base entry holding each of `vs`: one entry per field and value, From 538fae1162a181df3970d5540c59d9657f781f94 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 22 Oct 2026 01:22:45 +0000 Subject: [PATCH 10/31] Check every homebrew library change against the shape table One global re-frame interceptor runs after every event. When an event changed the library or shared content, however it changed it, the changed entries are made to fit homebrew-shapes: fixes go into the state and are stored through the gate after the plugins:pre-fix copy; an entry with an unreadable field is reported through the guard's reporter and set aside as written; the changed entries (not whole sources) get the deep check, about 35 ms for a save instead of 9 s for a 464-entry source. Import and restore no longer call the deep check themselves. The loader applies the table right after its mend, since it reads storage before any event: a section stored as true no longer stops startup. Its fixes are written back after the backup; unreadable entries join its set-aside. Shared content: fixes used, unreadable entries left out and reported. Junk audit: 659 -> 37 failures, in 132 s. The audit counts entries in the quarantine panel or marked as links to nothing in My Content as set aside. fixes_persist_e2e no longer hands the whole app state back to the test runner. --- docs/HOMEBREW_GUARD.md | 47 ++++++---- docs/branch-changelog.md | 24 +++-- src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc | 37 ++++++-- src/cljs/orcpub/dnd/e5/db.cljs | 20 +++- src/cljs/orcpub/dnd/e5/events.cljs | 92 ++++++++++++++++--- src/cljs/orcpub/dnd/e5/homebrew_check.cljs | 40 +++++--- test/browser/fixes_persist_e2e.js | 2 +- .../dnd/e5/homebrew_shape_table_test.clj | 18 ++++ test/cljs/orcpub/dnd/e5/db_test.cljs | 25 +++++ .../dnd/e5/homebrew_interceptor_test.cljs | 82 +++++++++++++++++ test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 31 +++++-- test/cljs/orcpub/test_runner.cljs | 2 + 12 files changed, 350 insertions(+), 70 deletions(-) create mode 100644 test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index 3b459f20a..cc8678a22 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -177,18 +177,23 @@ obvious: [:map {field s ...}] a map; the listed fields are checked, the rest pass untouched ``` -### 2. Where homebrew comes in: fix once, check it works, store it +### 2. Where homebrew comes in: fix once, check it works, store it (built) -**One global interceptor** (re-frame 1.4.4's `reg-global-interceptor`; the app has none yet) runs -after every event. If the event left the library as the identical object, which is almost every +**One global interceptor** (re-frame 1.4.4's `reg-global-interceptor`, `::homebrew-shapes` in +events.cljs, `homebrew-shapes-checked`) runs after every event. If the event left the library as the identical object, which is almost every event, it does nothing. If the event changed it, however it changed it: - the changed entries are cleaned, and the fixes go into the new state and are **written to storage** with it (`"5"` stored as `5`, read as `5`, exported as `5`); -- the changed sources are then **converted by the app's own code** (the existing deep check, - `check-and-set-aside!`, which runs after the new state is in place): an entry that cannot be - read or does not convert moves to the **quarantine panel** with its reason, the original kept - there to fix or restore. +- an entry with a field that cannot be read is reported through the guard's reporter (the same + path as an entry that fails to convert) and moves to the **quarantine panel** with its reason, + as it was written, to fix or restore; +- the changed entries are then **converted by the app's own code** (the existing deep check, + `check-and-set-aside!`, after the new state is in place): one that does not convert moves to + the quarantine panel too. Only the entries that changed: the check costs about 20 ms an entry, + so a whole 464-entry source took 9 s and a save now takes about 35 ms. Import and restore no + longer call it themselves. An entry is checked when it changes, so a change to a race that + breaks its subraces is left to containment (5). The active library is then clean, so nothing is found again on the next load. Because it runs on every event, it does not depend on code calling the right function, or on `library_gate_test` @@ -199,8 +204,8 @@ recognising how a write was made (it scans for `assoc`, `update` and `dissoc` on |---|---| | **Import** | the import event storing the file; the import report lists fixes and quarantined entries | | **Builder saves, rename, move, restore, conflict resolution** | each of those events; a builder save changes one entry, so one entry is checked | -| **Load** (startup, another tab's write, what `LOAD_HOMEBREW_URL` stored) | `:initialize-db` and `library-changed-elsewhere`; a library saved before this change is fixed once and written back after the `plugins:pre-fix` backup | -| **Share link** | `apply-shared-content` puts shared content in `:shared-plugins`, which the interceptor checks the same way; it is never stored, so an entry that fails is left out of the view and the banner says so | +| **Load** (startup, another tab's write, what `LOAD_HOMEBREW_URL` stored) | not the interceptor: the loader (`::e5/plugins` cofx) reads storage before any event can, and a section stored as `true` stopped startup there. So the loader applies the table straight after its mend: fixes are written back with its repairs, after the `plugins:pre-fix` backup; an unreadable entry joins what it sets aside in `plugins:rejected`, as written. The interceptor then finds nothing | +| **Share link** | `apply-shared-content` puts shared content in `:shared-plugins`, which the interceptor checks the same way; it is never stored, so a fix is used and an entry that cannot be read is left out and reported (to the console; a line in the share banner is still to do) | Builder drafts are restored by `reg-local-store-cofx`, which TODO.md pins as hands-off; they are covered by (5). @@ -277,11 +282,15 @@ Nothing here may make the app slower to use. | The conversion check at a way in | Only the entries that came in or changed; `guard/realize` now walks each shared part once. | a builder save: one entry | | Downstream | A clean entry is the identical object, so the per-type `=` firewall still holds: editing a spell does not rebuild races. | no extra recomputation | | The deep check | `guard/realize` walks each shared part once (done): one homebrew class went from seconds to milliseconds. | done | -| Audit | Build and CI time only. | under 10 min (now about 8) | +| Audit | Build and CI time only. | under 10 min (now about 2) | **Measured, not assumed:** before and after each step, with the 12-source test library: startup, `process-plugin-vals`, the template build, one keystroke in a builder, and an import. +After step 3, test-pak stored (464 entries, 764 KB), dev build: an event that leaves the library +alone 0.001 ms; an event that changes one entry 0.05 ms, then its deep check 35 ms (of which +25 ms is setting up the conversions); the loader 170-190 ms, of which the shape walk is 6-7 ms. + ## Prep work already done, to reuse - **testing/develop**: a Playwright suite of 60 scenarios from July 2026, never merged and run by @@ -300,13 +309,17 @@ Nothing here may make the app slower to use. ## Order of work, each step gated -1. **Done:** the baseline (659), the shape table and `clean` (not yet called by the app), the - audit in about 8 minutes, `guard/realize` walking each part once. -2. **Audit canaries and counted coverage.** -3. **The global interceptor:** clean, write, convert, quarantine, for every library change - (import, saves, load, share). *Gate:* the audit drops; the everyday suites and the homebrew e2e - scripts (`fixes_persist_e2e`, `boot_rescue_e2e`, `quarantine-restore`, `library-repairs`) pass; - a second load writes nothing; the timings stay in budget. +1. **Done:** the baseline (659), the shape table and `clean`, the audit in about 8 minutes, + `guard/realize` walking each part once. +2. **Done:** audit canaries and counted coverage. +3. **Done:** the global interceptor, and the table in the loader. The audit went from 659 to 37 + in 132 s. Left: a feat's `:ability-increases` as a list in its builder (27; the builder's + copy, step 4), a background skill that names no skill (6; a lookup in our code, step 5), and a + subrace or subclass from a share link whose parent does not exist (4; not shown in My Content, + so silent). The everyday suites, `homebrew_safety_net_e2e`, `fixes_persist_e2e`, + `quarantine-restore` and `library-repairs` pass; a second load writes nothing (a test). + `boot_rescue_e2e` fails 3 of 16 before and after this step: it clicks an "expand" link My + Content no longer has. 4. **The in-app read check**, measured; changed-only only if needed. 5. **Hardened core helpers**, ranked by the audit. 6. **Containment:** per-item boundaries, the event error handler, the guard on 13 types. diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index d5e3cc881..d36ba89ca 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -25,20 +25,32 @@ Rules: ## Why this branch exists -Homebrew is protected only where code wraps itself in the guard (`homebrew_guard.cljc`). Code -that reads homebrew without the wrapper is unprotected, and nothing says so: grant-rows has -hooks that throw on malformed packs because they were written before the guard and never -wrapped. This branch makes the guarded path the only path: one registry that turns each -content type into options inside the guard, a check that fails the build when code reads -homebrew outside it, and a test that loads junk into every field of every content type. +Homebrew was checked where code remembered to check it, and trusted everywhere else: a value of +the wrong shape in one field could break a page, stop the app from starting, or make an entry +vanish without a word. This branch checks every change to the homebrew library in one place, +whichever way it came in, against one table of what each field holds; and a test loads junk +into every field of every content type, by every way in, to prove it (docs/HOMEBREW_GUARD.md). ## Added ## Changed +- **Homebrew:** a value in the wrong form whose meaning is plain (`"25"` for a speed, one trait + where a list belongs) is corrected once, on load, import, save or from a share link, and the + correction is what is stored and exported. A library saved before is backed up first. +- **Homebrew:** an entry with a field that cannot be read (a number where a list of traits + belongs) is set aside in My Content for repair, as it was written, instead of breaking pages. + From a share link it is left out. - **Homebrew:** checking homebrew after an import or a Restore takes a fraction of the time for classes: each shared part of a class is checked once, not once per level. ## Fixed +- **Homebrew:** a library with a content section stored as `true` no longer stops the app from + starting. +- **Homebrew:** a monster from a share link with a broken name, size or type no longer breaks the + monster list. +- **Homebrew:** a subrace, subclass, boon or invocation whose name could not be read no longer + disappears without a word; it is set aside for repair. + ## Security diff --git a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc index b6ea9cde6..f070fab92 100644 --- a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc +++ b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc @@ -364,11 +364,13 @@ [:map-of :keyword :map]) (defn- clean-section - "One content section: each entry cleaned. Returns {:section s' :findings [...]}, `s'` identical - when nothing changed." - [content-type section] + "One content section: each entry cleaned, except one identical to the same entry in `before`. + Returns {:section s' :findings [...]}, `s'` identical when nothing changed." + [content-type section before] (reduce-kv (fn [{:keys [section findings] :as acc} k entry] - (let [r (clean-entry content-type entry) + (let [r (if (identical? entry (get before k)) + {:entry entry} + (clean-entry content-type entry)) fs (map #(assoc % :key k) (:findings r))] (cond (not (contains? r :entry)) {:section (dissoc section k) @@ -383,13 +385,14 @@ {:library l' :findings [{:source :content-type :key :path :action :found :wanted}]}. A section stored as true or false (the format allows it) reads as empty; any other section that is not a map, and any entry that is not, cannot be read and is left out. A sound library comes back - identical, and so does each sound source and section, so readers downstream recompute nothing." - [library] + identical, and so does each sound source and section. With `before`, the library as it was, + only what is not identical to it is walked: a change to one entry walks one entry." + [library & [before]] (if-not (map? library) {:library library :findings []} (reduce-kv (fn [acc src source] - (if-not (map? source) + (if (or (not (map? source)) (identical? source (get before src))) acc (let [{:keys [source findings]} (reduce-kv @@ -397,11 +400,12 @@ (let [tag #(assoc % :source src :content-type ct)] (cond (not (contains? shapes ct)) acc + (identical? section (get-in before [src ct])) acc (boolean? section) {:source (assoc source ct {}) :findings findings} (not (map? section)) {:source (dissoc source ct) :findings (conj findings (tag (finding [] :unreadable section a-section)))} - :else (let [r (clean-section ct section)] + :else (let [r (clean-section ct section (get-in before [src ct]))] (if (identical? section (:section r)) acc {:source (assoc source ct (:section r)) @@ -412,3 +416,20 @@ (not (identical? source (get library src))) (assoc-in [:library src] source))))) {:library library :findings []} library))) + +(defn split-unreadable + "`cleaned` (from clean-library) without each entry `findings` could not read, and those entries + as `library` held them: {:library l :set-aside {source {content-type {key entry}}}}. A section + that could not be read is set aside whole, as written." + [cleaned library findings] + (reduce (fn [acc {:keys [source content-type key action]}] + (cond + (not= :unreadable action) acc + key (-> acc + (update-in [:library source content-type] dissoc key) + (assoc-in [:set-aside source content-type key] + (get-in library [source content-type key]))) + :else (assoc-in acc [:set-aside source content-type] + (get-in library [source content-type])))) + {:library cleaned :set-aside {}} + findings)) diff --git a/src/cljs/orcpub/dnd/e5/db.cljs b/src/cljs/orcpub/dnd/e5/db.cljs index f825b4105..d2f5ba3da 100644 --- a/src/cljs/orcpub/dnd/e5/db.cljs +++ b/src/cljs/orcpub/dnd/e5/db.cljs @@ -4,6 +4,7 @@ [orcpub.common :as common] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.content-specs :as content-specs] + [orcpub.dnd.e5.homebrew-shapes :as shapes] [orcpub.dnd.e5.template :as t5e] [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.character-drafts :as drafts] @@ -793,23 +794,36 @@ ;; It's a map: salvage per source — keep the valid sources and ;; reconcile the name-keyed quarantine map (see reconcile-rejected). - (let [;; An entry with no source takes the name of the source it is + (let [;; Then made to fit homebrew-shapes, before anything reads it: the fixes are + ;; written back below, after a copy of the library as it was; an entry + ;; that cannot be read is set aside with the rest, as written. + {cleaned :library findings :findings} (shapes/clean-library mended) + {shaped :library unreadable :set-aside} + (shapes/split-unreadable cleaned mended findings) + reshaped? (not (identical? shaped mended)) + _ (when reshaped? (keep-pre-fix-copy! mended)) + ;; An entry with no source takes the name of the source it is ;; stored under (e5/source-for) rather than being set aside -- ;; written back with the repairs below. - {sourced :library fills :filled} (e5/fill-library-sources mended) + {sourced :library fills :filled} (e5/fill-library-sources shaped) {:keys [kept rejected]} ;; PER-ENTRY salvage: keep each source's valid items, set aside only its broken ;; ones, so one bad entry can't drop a whole source. The item floor comes from ;; the shared content-specs registry (save & load agree, not inline, so it can't ;; drift); `rejected` is usually empty here, a defensive net for when the floor tightens. (e5/salvage-library-items content-specs/valid-item-for-load? sourced) + rejected (e5/merge-all-plugins rejected unreadable) reconciled (e5/reconcile-rejected-items (get-local-storage-item local-storage-plugins-rejected-key) rejected kept)] (persist-set-aside! set-item #(when js/window.localStorage (.removeItem js/window.localStorage %)) - kept reconciled rejected (or (seq repairs) (seq fills))) + kept reconciled rejected (or (seq repairs) (seq fills) reshaped?)) + (when reshaped? + (js/console.warn + (str "Made damaged homebrew fit on load: " + (pr-str (mapv (juxt :source :key :path :action) findings))))) (when (seq fills) (js/console.warn (str "Gave " (count fills) " homebrew entr" (if (= 1 (count fills)) "y" "ies") diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 8dcd7ed8e..bceb53602 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -105,11 +105,12 @@ [orcpub.dnd.e5.character-drafts :as drafts] [orcpub.dnd.e5.homebrew-check :as homebrew-check] [orcpub.dnd.e5.library :as library] + [orcpub.dnd.e5.homebrew-shapes :as shapes] [orcpub.dnd.e5.picks :as picks] [orcpub.dnd.e5.event-utils :as event-utils] [orcpub.dnd.e5.compute :as compute] [re-frame.core :refer [reg-event-db reg-event-fx reg-fx reg-cofx inject-cofx path - after dispatch ->interceptor]] + after dispatch ->interceptor reg-global-interceptor]] [cljs.spec.alpha :as spec] [orcpub.dnd.e5.http-safe :as http] [cljs.core.async :refer [ (select-keys committed [:db :dispatch-n]) - ok? (update :db dissoc :homebrew-reported) - ;; check the sources this import changed for an entry that breaks the character - ;; options, now rather than the next time someone opens the builder - (and ok? changed) (assoc ::homebrew-check/check-builds changed))))) + ok? (update :db dissoc :homebrew-reported))))) ;; `plugins->local-store` dispatches this when the localStorage write ;; fails (typically a full quota). The save lives in memory but would vanish on @@ -6234,9 +6231,9 @@ (if-not ok? {:db db' :dispatch-n refused} (do (set-rejected-plugins new-rejected) + ;; a restored entry that still breaks the character options is set aside again, + ;; by the homebrew-shapes interceptor's check of what this wrote {:db (-> db' (assoc :quarantined-plugins new-rejected) (dissoc :homebrew-reported)) - ;; a restored entry that still breaks the character options is set aside again - ::homebrew-check/check-builds (when (pos? n-fixed) #{source-name}) :dispatch [(if (pos? n-fixed) :show-warning-message :show-error-message) (restore-message source-name bad kept-items still-bad)]}))))))) @@ -6647,13 +6644,14 @@ (defn set-aside-broken-entries "Move each reported entry from `plugins` into `rejected`. Returns {:plugins :rejected :moved [{:source :content-type :key :entry}]}. A report whose entry is not in the - library (shared content, or already gone) moves nothing." + library (shared content, or already gone) moves nothing. A report's `:original` is what is + set aside when it has one: the entry as it was written, before anything was read past." [plugins rejected reports] - (reduce (fn [acc {:keys [content-type key] :as report}] + (reduce (fn [acc {:keys [content-type key original] :as report}] (if-let [[src entry] (find-broken-entry (:plugins acc) report)] (-> acc (update-in [:plugins src content-type] dissoc key) - (assoc-in [:rejected src content-type key] entry) + (assoc-in [:rejected src content-type key] (or original entry)) (update :moved conj {:source src :content-type content-type :key key :entry entry})) acc)) {:plugins plugins :rejected (or rejected {}) :moved []} @@ -6716,6 +6714,72 @@ :dispatch [:show-warning-message (broken-homebrew-notice moved) 60000]} {:db db' :dispatch-n refused}))))))) +(reg-fx + ::keep-pre-fix-copy + (fn [plugins] (keep-pre-fix-copy! plugins))) + +(defn- unreadable-reports + "A report for each entry `findings` could not read, carrying the entry as `library` held it." + [library findings] + (for [[[src ct k] fs] (group-by (juxt :source :content-type :key) + (filter #(= :unreadable (:action %)) findings)) + :when k] + {:content-type ct :key k :source src :original (get-in library [src ct k]) + :error (str (s/join ", " (map #(pr-str (:path %)) fs)) " could not be read")})) + +(defn homebrew-shapes-checked + "An event's effects, with the homebrew it changed made to fit homebrew-shapes. The library: what + was fixed is in the new state and stored through the gate (after a copy of the library as it + was); an entry with a field that cannot be read is set aside, as written; the entries it + changed are converted by the deep check. Shared content is never stored: what is fixed is used, + and an entry that cannot be read is left out and reported. + Nothing to do when the event left both as they were, which is nearly every event." + [before-db effects] + (let [db (:db effects) + before (:plugins before-db) + lib (:plugins db) + shared-before (:shared-plugins before-db) + shared (:shared-plugins db)] + (if (or (nil? db) (and (identical? lib before) (identical? shared shared-before))) + effects + (let [{cleaned :library findings :findings} (if (map? lib) + (shapes/clean-library lib before) + {:library lib}) + {shared' :library shared-findings :findings} + (if (map? shared) (shapes/clean-library shared shared-before) {:library shared}) + shared' (:library (shapes/split-unreadable shared' shared shared-findings)) + changed (when (and (map? cleaned) (map? before)) + (set (for [[src source] cleaned + :when (and (map? source) (not (identical? source (get before src)))) + [ct section] source + :when (and (map? section) + (not (identical? section (get-in before [src ct])))) + [k entry] section + :when (not (identical? entry (get-in before [src ct k])))] + [src ct k]))) + fx (concat + (when-not (identical? cleaned lib) + [[::keep-pre-fix-copy lib] + [:dispatch [::e5/set-plugins cleaned]]]) + (for [r (concat (unreadable-reports lib findings) + (unreadable-reports shared shared-findings))] + [::homebrew-check/report r]) + (when (seq changed) + [[::homebrew-check/check-entries changed]]))] + (cond-> (assoc effects :db + (cond-> db + (not (identical? cleaned lib)) (assoc :plugins cleaned) ;; library gate + (not (identical? shared' shared)) (assoc :shared-plugins shared'))) + (seq fx) (update :fx (fnil into []) fx)))))) + +;; Every event, whatever it changed and however: the homebrew it leaves is checked here, so no +;; write can skip the check by going around commit-library. See docs/HOMEBREW_GUARD.md. +(reg-global-interceptor + (->interceptor + :id ::homebrew-shapes + :after (fn [ctx] + (update ctx :effects #(homebrew-shapes-checked (get-in ctx [:coeffects :db]) %))))) + ;; ============================================================================ ;; Export Warning Modal Events ;; ============================================================================ diff --git a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs index 82833ec51..3c7c29cd4 100644 --- a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs +++ b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs @@ -2,9 +2,9 @@ "Wires homebrew-guard into the app. deep-check converts and fully realizes every entry to catch a throw hidden in a lazy part of an option, one that per-entry builder guarding would miss. - GOTCHA: realizing everything costs ~2s for a 12-source library vs ~40ms without - realizing, so call it only for sources an import/restore changed, or after a - page has already failed. See homebrew-safety-net.md." + GOTCHA: realizing costs ~20ms an entry (9s for test-pak's 464), so call it only for the + entries a change wrote (the homebrew-shapes interceptor's ::check-entries), or after a page + has already failed. See docs/HOMEBREW_GUARD.md." (:require [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.homebrew-guard :as guard] [re-frame.core :refer [reg-fx dispatch subscribe]] @@ -19,13 +19,16 @@ (defn deep-check "Convert and fully realize every homebrew entry from `sources` (every source when nil), - one at a time, with the conversions the subscriptions register. Returns a report for - each entry that fails." - [sources] + one at a time, with the conversions the subscriptions register; with `entries`, a set of + [source content-type key], only those (~20ms each, so a save checks what it saved). Returns + a report for each entry that fails." + [sources & [entries]] (let [found (atom []) sub #(deref (subscribe %)) - in-scope? #(or (nil? sources) - (contains? sources (or (:plugin-source %) (:option-pack %))))] + in-scope? (fn [content-type entry] + (let [src (or (:plugin-source entry) (:option-pack entry))] + (and (or (nil? sources) (contains? sources src)) + (or (nil? entries) (contains? entries [src content-type (:key entry)])))))] (guard/call-with-reporter #(swap! found conj (update % :error message)) (fn [] @@ -37,7 +40,7 @@ (try (let [f (convert sub)] (doseq [entry (entries sub) - :when (in-scope? entry)] + :when (in-scope? content-type entry)] (guard/guard-option content-type entry f))) (catch :default e (js/console.error "Could not check homebrew" (str content-type) e))))))))) @@ -45,15 +48,22 @@ (defn check-and-set-aside! "deep-check, then set aside whatever failed. Returns the reports." - [sources] - (let [found (deep-check sources)] + [sources & [entries]] + (let [found (deep-check sources entries)] (when (seq found) (dispatch [::e5/set-aside-broken-homebrew found])) found)) + +(reg-fx + ::report + ;; an entry found broken outside a conversion, by the same path as one that failed in it + (fn [info] (guard/report! info))) + (reg-fx - ::check-builds - (fn [sources] - (when (seq sources) + ::check-entries + ;; entries a change wrote, as [source content-type key] + (fn [entries] + (when (seq entries) ;; after the current event, so its own notice draws first - (js/setTimeout #(check-and-set-aside! sources) 0)))) + (js/setTimeout #(check-and-set-aside! (set (map first entries)) entries) 0)))) diff --git a/test/browser/fixes_persist_e2e.js b/test/browser/fixes_persist_e2e.js index 6a38b9ea1..0d746a6fd 100644 --- a/test/browser/fixes_persist_e2e.js +++ b/test/browser/fixes_persist_e2e.js @@ -257,7 +257,7 @@ const letterFirst = k => /^[a-z]/.test(k); await dispatch(s, '[:orcpub.dnd.e5.spells/set-spell {:name "9 Lives" :option-pack ""}]'); await dispatch(s, '[:orcpub.dnd.e5.spells/save-spell-anyway]'); await settle(s); - await s.page.evaluate(() => cljs.core.swap_BANG_.call(null, re_frame.db.app_db, cljs.core.assoc, + await s.page.evaluate(() => void cljs.core.swap_BANG_.call(null, re_frame.db.app_db, cljs.core.assoc, cljs.reader.read_string(':orcpub.dnd.e5.selections/builder-item'), cljs.reader.read_string('{:name "9 Lives" :option-pack "" :options [{:name "First"}]}'))); await dispatch(s, '[:orcpub.dnd.e5.selections/save-selection-anyway]'); diff --git a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj index 540c13911..1d822ff6f 100644 --- a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj +++ b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj @@ -78,3 +78,21 @@ (doseq [f ["cruft-shapes.orcbrew" "broken-content.orcbrew" "keyword-trap.orcbrew"]] (let [r (shapes/clean-library (library (fixture f)))] (is (map? (:library r)) f)))) + +(deftest only-what-changed-is-walked + (let [junk-race {:name "Junk" :abilities 42} + before {"P" {:orcpub.dnd.e5/races {:a junk-race}}}] + (testing "an entry identical to the one before is not walked again" + (is (empty? (:findings (shapes/clean-library before before))))) + (testing "a changed one is" + (let [after (assoc-in before ["P" :orcpub.dnd.e5/races :b] junk-race)] + (is (= [:b] (map :key (:findings (shapes/clean-library after before))))))))) + +(deftest what-cannot-be-read-is-split-out-as-written + (let [bad {:name "Bad" :abilities 42} + lib {"P" {:orcpub.dnd.e5/races {:bad bad :ok {:name "Ok"}} + :orcpub.dnd.e5/feats 42}} + {cleaned :library findings :findings} (shapes/clean-library lib) + {kept :library set-aside :set-aside} (shapes/split-unreadable cleaned lib findings)] + (is (= {"P" {:orcpub.dnd.e5/races {:ok {:name "Ok"}}}} kept)) + (is (= {"P" {:orcpub.dnd.e5/races {:bad bad} :orcpub.dnd.e5/feats 42}} set-aside)))) diff --git a/test/cljs/orcpub/dnd/e5/db_test.cljs b/test/cljs/orcpub/dnd/e5/db_test.cljs index 882669874..bba4fbda8 100644 --- a/test/cljs/orcpub/dnd/e5/db_test.cljs +++ b/test/cljs/orcpub/dnd/e5/db_test.cljs @@ -189,6 +189,31 @@ (is (= stored (.getItem js/window.localStorage db/local-storage-plugins-key)) "so a second load changes nothing"))) +(deftest loading-makes-the-library-fit-its-shapes-once + ;; A subclasses section stored as true stopped the app from starting; the loader read it + ;; before anything else could. See docs/HOMEBREW_GUARD.md. + (.setItem js/window.localStorage db/local-storage-plugins-key + (str "{\"P\" {:orcpub.dnd.e5/subclasses true :orcpub.dnd.e5/races {" + ":hill {:key :hill :option-pack \"P\" :name \"Hill\" :speed \"25\"} " + ":bad {:key :bad :option-pack \"P\" :name \"Bad\" :abilities 42}}}}")) + (let [as-stored (.getItem js/window.localStorage db/local-storage-plugins-key) + loaded (get (load-plugins!) "P")] + (is (= 25 (get-in loaded [:orcpub.dnd.e5/races :hill :speed])) "a fix is used") + (is (not (contains? (:orcpub.dnd.e5/races loaded) :bad)) "an unreadable entry is not loaded") + (is (not (true? (:orcpub.dnd.e5/subclasses loaded)))) + (testing "the unreadable entry is set aside as written" + (is (= 42 (get-in (reader/read-string (.getItem js/window.localStorage + db/local-storage-plugins-rejected-key)) + ["P" :orcpub.dnd.e5/races :bad :abilities])))) + (testing "the library as it was is kept, and the fix is written back" + (is (= (reader/read-string as-stored) + (reader/read-string (.getItem js/window.localStorage db/local-storage-pre-fix-key)))) + (is (not (.includes (.getItem js/window.localStorage db/local-storage-plugins-key) "\"25\"")))) + (testing "so a second load changes nothing" + (let [stored (.getItem js/window.localStorage db/local-storage-plugins-key)] + (is (= loaded (get (load-plugins!) "P"))) + (is (= stored (.getItem js/window.localStorage db/local-storage-plugins-key))))))) + (deftest a-library-that-stops-startup-is-set-aside-intact (.setItem js/window.localStorage db/local-storage-plugins-key "{\"P\" {}}") (is (= "{\"P\" {}}" (db/set-aside-unloadable-library!))) diff --git a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs new file mode 100644 index 000000000..60a7fe1a2 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs @@ -0,0 +1,82 @@ +(ns orcpub.dnd.e5.homebrew-interceptor-test + "The global interceptor: whatever an event does to the homebrew library, and however, what it + leaves is made to fit the shape table. See docs/HOMEBREW_GUARD.md." + (:require [cljs.test :refer-macros [deftest testing is]] + [re-frame.core :as rf] + [re-frame.db :refer [app-db]] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.homebrew-check :as homebrew-check] + [orcpub.dnd.e5.events :as events])) + +(def ^:private race {:name "Hill" :key :hill :option-pack "P" :speed "25"}) +(def ^:private lib {"P" {::e5/races {:hill race}}}) + +(defn- fx-of [effects kind] + (for [[k v] (:fx effects) :when (= k kind)] v)) + +(deftest an-event-that-leaves-the-library-alone-is-left-alone + (let [db {:plugins lib} + effects {:db (assoc db :other 1)}] + (is (identical? effects (events/homebrew-shapes-checked db effects))))) + +(deftest a-fix-is-used-and-stored + (let [effects (events/homebrew-shapes-checked {:plugins {}} {:db {:plugins lib}}) + cleaned (get-in effects [:db :plugins])] + (is (= 25 (get-in cleaned ["P" ::e5/races :hill :speed])) "the app reads 25 at once") + (is (= [lib] (fx-of effects ::events/keep-pre-fix-copy)) "the library as it was is kept first") + (is (= [[::e5/set-plugins cleaned]] (fx-of effects :dispatch)) "and the fix is stored"))) + +(deftest an-unreadable-entry-is-set-aside-as-written + (let [bad (assoc race :abilities 42) + written {"P" {::e5/races {:hill bad}}} + effects (events/homebrew-shapes-checked {:plugins {}} {:db {:plugins written}}) + broke (fx-of effects ::homebrew-check/report)] + (is (= 1 (count broke))) + (is (= {:content-type ::e5/races :key :hill :source "P" :original bad} + (select-keys (first broke) [:content-type :key :source :original]))) + (is (not (contains? (get-in effects [:db :plugins "P" ::e5/races :hill]) :abilities)) + "the app reads past the field until the entry is set aside"))) + +(deftest a-changed-entry-is-converted + (let [before {"P" {::e5/races {:hill (assoc race :speed 25)}} "Q" {}} + after (assoc-in before ["P" ::e5/races :dale] {:name "Dale" :key :dale :option-pack "P"})] + (is (= [#{["P" ::e5/races :dale]}] + (fx-of (events/homebrew-shapes-checked {:plugins before} {:db {:plugins after}}) + ::homebrew-check/check-entries)) + "only the entry that changed") + (is (empty? (fx-of (events/homebrew-shapes-checked {} {:db {:plugins after}}) + ::homebrew-check/check-entries)) + "not the whole library on load"))) + +(deftest shared-content-is-used-not-stored + (let [effects (events/homebrew-shapes-checked {} {:db {:shared-plugins lib}})] + (is (= 25 (get-in effects [:db :shared-plugins "P" ::e5/races :hill :speed]))) + (is (empty? (:fx effects)) "never stored")) + (testing "an entry that cannot be read is left out, and named" + (let [bad {"P" {::e5/races {:hill (assoc race :abilities 42) :dale (assoc race :key :dale)}}} + effects (events/homebrew-shapes-checked {} {:db {:shared-plugins bad}})] + (is (= #{:dale} (set (keys (get-in effects [:db :shared-plugins "P" ::e5/races]))))) + (is (= [:hill] (map :key (fx-of effects ::homebrew-check/report))))))) + +;; an event that writes the library without the gate, as a bug or a script could +(rf/reg-event-db ::write-around-the-gate + (fn [db [_ plugins]] (assoc db :plugins plugins))) ;; not app-db + +(deftest a-write-that-goes-around-the-gate-is-still-checked + (let [was @app-db] + (try + (reset! app-db {:plugins {}}) + (rf/dispatch-sync [::write-around-the-gate lib]) + (is (= 25 (get-in @app-db [:plugins "P" ::e5/races :hill :speed]))) + (finally + (rf/purge-event-queue) + (.removeItem js/localStorage "plugins:pre-fix") + (.removeItem js/localStorage "plugins:pre-fix-at") + (reset! app-db was))))) + +(deftest a-set-aside-entry-keeps-what-was-written + (let [bad (assoc race :abilities 42) + {:keys [rejected]} (events/set-aside-broken-entries + {"P" {::e5/races {:hill (dissoc bad :abilities)}}} {} + [{:content-type ::e5/races :key :hill :source "P" :original bad}])] + (is (= bad (get-in rejected ["P" ::e5/races :hill]))))) diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index b9f194898..a1d4b4a3a 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -16,6 +16,7 @@ [orcpub.template :as t] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.library :as library] [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.homebrew-check :as homebrew-check] [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] @@ -180,6 +181,11 @@ (defn- attempt [stage f] (try (f) nil (catch :default e {:stage stage :error (msg e)}))) +(def ^:private storage-slots + "What a pack can leave in browser storage." + ["plugins" "plugins:rejected" "plugins:corrupt" "plugins:pre-fix" "plugins:pre-fix-at" + "character"]) + (def ^:private coverage "What the last pack exercised, by entry key: :picked by a character, :drawn in its builder, and the My Content lists :listed. A stage that ran on nothing must not read as a pass." @@ -380,8 +386,9 @@ failure is traced to an entry." [lib & [via {:keys [light?]}]] (doseq [k (drafts/draft-keys)] (drafts/remove-draft! k)) - (doseq [k ["plugins" "plugins:rejected" "plugins:corrupt" "character"]] - (.removeItem js/localStorage k)) + ;; what an earlier pack queued (a write, a set-aside) must not land in this one + (rf/purge-event-queue) + (doseq [k storage-slots] (.removeItem js/localStorage k)) (when (not= via :share) (.setItem js/localStorage "plugins" (pr-str lib))) (.setItem js/localStorage "character" (pr-str (char5e/to-strict stored-character))) (reset! app-db {}) @@ -415,8 +422,20 @@ (once [:deep-check (hash entries)] :deep-check #(homebrew-check/deep-check nil))) template (try @(rf/subscribe [::char5e/template]) (catch :default _ nil)) - set-aside (set (map :key @reports)) - ;; an entry set aside is not offered, and is allowed; one silently not offered is lost + ;; an entry set aside (reported, or in the quarantine panel) is not offered, and is + ;; allowed, as is one My Content marks as a link to nothing (only stored content is + ;; shown there); one silently not offered is lost + set-aside (into (into (set (map :key @reports)) + (for [[_ section] (get (:quarantined-plugins @app-db) source) + :when (map? section) + k (keys section)] + k)) + (when (and template (not= via :share)) + ;; reads every option, so a throw in one is the stages' to report + (try (map last (keys (library/dangling + (:plugins @app-db) + (library/offered-by-type template)))) + (catch :default _ nil)))) entry (fn [k] (some #(when (map? %) (get % k)) (vals entries))) build (fn [[k c]] (let [id [(hash (entry k)) (hash c)] @@ -451,8 +470,8 @@ (run-pack! lib via opts))) (defn- cleanup! [] - (doseq [k ["plugins" "plugins:rejected" "plugins:corrupt" "character"]] - (.removeItem js/localStorage k)) + (rf/purge-event-queue) + (doseq [k storage-slots] (.removeItem js/localStorage k)) (reset! app-db {}) (rf/clear-subscription-cache!)) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index 736903cba..e257154f2 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -36,6 +36,7 @@ [orcpub.dnd.e5.plan-picks-test] [orcpub.dnd.e5.coverage-pak-test] [orcpub.dnd.e5.junk-pack-test] + [orcpub.dnd.e5.homebrew-interceptor-test] [orcpub.dnd.e5.reference-web-test] [orcpub.dnd.e5.views-test] [orcpub.dnd.e5.portrait-credit-thumb-test] @@ -84,6 +85,7 @@ 'orcpub.dnd.e5.plan-picks-test 'orcpub.dnd.e5.coverage-pak-test 'orcpub.dnd.e5.junk-pack-test + 'orcpub.dnd.e5.homebrew-interceptor-test 'orcpub.dnd.e5.reference-web-test 'orcpub.dnd.e5.views-test 'orcpub.dnd.e5.portrait-credit-thumb-test From 2ce7925aeb6d34733156790e52fe9cc10aac66c8 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 25 Oct 2026 04:07:11 +0000 Subject: [PATCH 11/31] Check homebrew where the app reads it, not only where it is written One function, homebrew-check/checked, gives the app the library and shared content as the shape table allows, whatever wrote app-db last (a script, the console, a bug of ours). It runs in the two subscriptions every homebrew view is built on, and in the global interceptor's :before, so each of the 57 handlers that read (:plugins db) gets the checked library unchanged. It remembers the last library read: one comparison when nothing changed, a walk of only the changed entries when something did. Found data is read with its fixes, unreadable entries are left out and reported, and the whole is logged as a bug. A feat's :ability-increases is read as a set, the only form this build's builder reads; a list reached the builder and broke it. PR #69's spreads join the row when it merges. boot_rescue_e2e keeps the What's New panel closed when run by hand (17/17); the cljs harness no longer gives the page's load event 30 s, as the tests run during it. Junk audit: 37 -> 10 failures, in 135 s. --- docs/HOMEBREW_GUARD.md | 53 +++++++++++-------- docs/branch-changelog.md | 2 + src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc | 6 ++- src/cljs/orcpub/dnd/e5/events.cljs | 24 +++++---- src/cljs/orcpub/dnd/e5/homebrew_check.cljs | 40 +++++++++++++- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 7 ++- test/browser/boot_rescue_e2e.js | 7 +++ .../dnd/e5/homebrew_interceptor_test.cljs | 39 ++++++++++++++ test/e2e/cljs-harness.js | 4 +- 9 files changed, 143 insertions(+), 39 deletions(-) diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index cc8678a22..c206aec9d 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -210,19 +210,25 @@ recognising how a write was made (it scans for `assoc`, `update` and `dissoc` on Builder drafts are restored by `reg-local-store-cofx`, which TODO.md pins as hands-off; they are covered by (5). -### 3. Inside the app: every read is checked +### 3. Inside the app: every read is checked (built) -Data can change after it came in: a script writing to the app's memory, an event storing raw -data, a bug of ours. So the shape check also runs where the app reads homebrew: -`process-plugin-vals` and `process-plugins-with-sources` (every homebrew subscription, so every -builder, page, option and character, reads through them), and the few event handlers that read -the library directly. Whatever is in memory is checked there, so the functions after it only ever -receive the right shapes, and none of them changes. Anything unclean found there is cleaned for -reading and reported as a bug: nothing should reach it that way. +Data can change after it came in without an event: a script writing to the app's memory, the +console, a bug of ours. So the shape check also runs where the app reads homebrew, in one +function, `homebrew-check/checked`, called from two places: -The check is a read-only walk. It runs over the whole library on each change if that measures as -cheap; if not, it checks only entries that changed (by object identity: data is immutable, so a -change is a new object, and gets checked). +- **every view**: the `::e5/plugins` and `::e5/shared-plugins` subscriptions, which every other + homebrew subscription (so every builder, page, option and character) is built on; +- **every event handler**: the global interceptor's `:before` hands each handler the library as + checked, so the 57 handlers that read `(:plugins db)` directly need no change. + +Whatever is in memory is checked there, so the functions after it only ever receive the right +shapes, and none of them changes. Anything unclean found there is read with its fixes, an entry +that cannot be read is left out and reported (to the quarantine panel, by the guard's reporter), +and the whole is logged as a bug: nothing should reach it that way. It writes nothing. + +It remembers the last library it read and what it gave for it, so a library already checked costs +one comparison (0.002 ms), and a new one is walked only where it differs from the last (one +entry changed: 0.2 ms). ### 4. Never silent, with what exists @@ -278,7 +284,7 @@ Nothing here may make the app slower to use. | Cleaning | Only on a write: an import, a builder save (one entry), the first load after this lands. Reading costs nothing. | 12-source test library: under 30 ms | | The global interceptor | One identity comparison per event; work only when the library changed, and only on the entries that changed. | no measurable change | | Later loads | The library is already clean: a read-only walk that finds nothing and writes nothing. | under 10 ms | -| The in-app read check | A read-only walk on each library change; if it measures above budget, only entries that changed. | under 10 ms | +| The in-app read check | One comparison per read; on a library change, only the entries that changed. | 0.002 ms; 0.2 ms after a change | | The conversion check at a way in | Only the entries that came in or changed; `guard/realize` now walks each shared part once. | a builder save: one entry | | Downstream | A clean entry is the identical object, so the per-type `=` firewall still holds: editing a spell does not rebuild races. | no extra recomputation | | The deep check | `guard/realize` walks each shared part once (done): one homebrew class went from seconds to milliseconds. | done | @@ -313,14 +319,18 @@ alone 0.001 ms; an event that changes one entry 0.05 ms, then its deep check 35 `guard/realize` walking each part once. 2. **Done:** audit canaries and counted coverage. 3. **Done:** the global interceptor, and the table in the loader. The audit went from 659 to 37 - in 132 s. Left: a feat's `:ability-increases` as a list in its builder (27; the builder's - copy, step 4), a background skill that names no skill (6; a lookup in our code, step 5), and a - subrace or subclass from a share link whose parent does not exist (4; not shown in My Content, - so silent). The everyday suites, `homebrew_safety_net_e2e`, `fixes_persist_e2e`, + in 132 s. The everyday suites, `homebrew_safety_net_e2e`, `fixes_persist_e2e`, `quarantine-restore` and `library-repairs` pass; a second load writes nothing (a test). - `boot_rescue_e2e` fails 3 of 16 before and after this step: it clicks an "expand" link My - Content no longer has. -4. **The in-app read check**, measured; changed-only only if needed. +4. **Done:** the in-app read check, changed-only from the start (the full walk is 7 ms on + test-pak, on every read it would not be). A feat's `:ability-increases` is now read as a set, + the only form this build's builder reads (27 failures: a list reached the builder). The audit + went from 37 to 10 in 135 s. Left: a background skill that names no skill (6; a lookup in + our code, step 5) and a subrace or subclass from a share link whose parent does not exist (4; + not shown in My Content, so silent). `boot_rescue_e2e` now passes 17/17: run by hand, it had + no overlay suppression, and the What's New panel covered the link it clicks. + **When PR #69 merges**, its list of `[amount pool]` spreads goes into the feats row with the + builder that reads it, in the merge itself: until then this build would turn a spread into a + set on load and set it aside, as it cannot read it. 5. **Hardened core helpers**, ranked by the audit. 6. **Containment:** per-item boundaries, the event error handler, the guard on 13 types. 7. **Enforcement:** the audit in CI at zero, the AGENTS.md rules, the documents corrected, the @@ -332,9 +342,8 @@ refactor branch (owner, 2026-10-21). ## For the owner -- **An unreadable field**: move the whole entry to the quarantine panel with the reason - (recommended: it is what keeps the active library clean, so nothing is found again), or keep - the entry and read past the field, with a mark? +- **An unreadable field**: the whole entry moves to the quarantine panel with the reason + (owner, 2026-10-25). - **Boons and invocations keyed by name** (known, above): fix it here by passing the stored key? Characters that picked one hold the name-derived key; where the stored key differs (a source tag), they heal through `:former-keys`, the way renamed keys heal today. diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index d36ba89ca..c8906d77a 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -52,5 +52,7 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB monster list. - **Homebrew:** a subrace, subclass, boon or invocation whose name could not be read no longer disappears without a word; it is set aside for repair. +- **Homebrew:** a feat whose ability score increases were stored as a list no longer breaks its + builder. ## Security diff --git a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc index f070fab92..1c74a5f89 100644 --- a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc +++ b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc @@ -305,9 +305,11 @@ :orcpub.dnd.e5/selections [:map {:name :string :options cards}] :orcpub.dnd.e5/feats - ;; :ability-increases is a set of ability keys (the released form) or, from PR #69, a list + ;; :ability-increases is a set of ability keys, the only form this build's feat builder reads. + ;; PR #69 adds a list of [amount pool] spreads and the builder that reads them: when it merges, + ;; this becomes [:or [:set :keyword] ], or its spreads are turned into sets on load. [:map {:name :string :description :string - :ability-increases [:or [:set :keyword] [:list :any]] + :ability-increases [:set :keyword] :prereqs [:set :keyword] :path-prereqs [:map {:race flags}] :props props}] :orcpub.dnd.e5/races diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index bceb53602..e8568574f 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -6718,15 +6718,6 @@ ::keep-pre-fix-copy (fn [plugins] (keep-pre-fix-copy! plugins))) -(defn- unreadable-reports - "A report for each entry `findings` could not read, carrying the entry as `library` held it." - [library findings] - (for [[[src ct k] fs] (group-by (juxt :source :content-type :key) - (filter #(= :unreadable (:action %)) findings)) - :when k] - {:content-type ct :key k :source src :original (get-in library [src ct k]) - :error (str (s/join ", " (map #(pr-str (:path %)) fs)) " could not be read")})) - (defn homebrew-shapes-checked "An event's effects, with the homebrew it changed made to fit homebrew-shapes. The library: what was fixed is in the new state and stored through the gate (after a copy of the library as it @@ -6761,8 +6752,8 @@ (when-not (identical? cleaned lib) [[::keep-pre-fix-copy lib] [:dispatch [::e5/set-plugins cleaned]]]) - (for [r (concat (unreadable-reports lib findings) - (unreadable-reports shared shared-findings))] + (for [r (concat (homebrew-check/unreadable-reports lib findings) + (homebrew-check/unreadable-reports shared shared-findings))] [::homebrew-check/report r]) (when (seq changed) [[::homebrew-check/check-entries changed]]))] @@ -6777,6 +6768,17 @@ (reg-global-interceptor (->interceptor :id ::homebrew-shapes + ;; and on the way in: the handler reads the library as checked, whatever wrote app-db last + :before (fn [ctx] + (update-in ctx [:coeffects :db] + (fn [db] + (let [lib (:plugins db) + shared (:shared-plugins db) + lib' (homebrew-check/checked :plugins lib) + shared' (homebrew-check/checked :shared-plugins shared)] + (cond-> db + (not (identical? lib lib')) (assoc :plugins lib') ;; not app-db + (not (identical? shared shared')) (assoc :shared-plugins shared')))))) :after (fn [ctx] (update ctx :effects #(homebrew-shapes-checked (get-in ctx [:coeffects :db]) %))))) diff --git a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs index 3c7c29cd4..401b03883 100644 --- a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs +++ b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs @@ -5,8 +5,10 @@ GOTCHA: realizing costs ~20ms an entry (9s for test-pak's 464), so call it only for the entries a change wrote (the homebrew-shapes interceptor's ::check-entries), or after a page has already failed. See docs/HOMEBREW_GUARD.md." - (:require [orcpub.dnd.e5 :as e5] + (:require [clojure.string :as s] + [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.homebrew-shapes :as shapes] [re-frame.core :refer [reg-fx dispatch subscribe]] [reagent.core :as r])) @@ -67,3 +69,39 @@ (when (seq entries) ;; after the current event, so its own notice draws first (js/setTimeout #(check-and-set-aside! (set (map first entries)) entries) 0)))) + +(defn unreadable-reports + "A report for each entry `findings` could not read, carrying the entry as `library` held it." + [library findings] + (for [[[src ct k] fs] (group-by (juxt :source :content-type :key) + (filter #(= :unreadable (:action %)) findings)) + :when k] + {:content-type ct :key k :source src :original (get-in library [src ct k]) + :error (str (s/join ", " (map #(pr-str (:path %)) fs)) " could not be read")})) + +;; Per db slot (:plugins, :shared-plugins): the last library read through `checked`, and what the +;; app was given for it. Each event and each view read compares against it, so a library already +;; checked costs one comparison, and a new one is walked only where it differs from the last. +(defonce ^:private last-read (atom {})) + +(defn checked + "`library`, from the db `slot`, as the app may read it. Every write goes through the + homebrew-shapes interceptor, so this finds nothing unless something wrote app-db another way (a + script, the console, a bug of ours): then the fixes are read, an entry that cannot be read is + left out and reported, and the whole is logged as a bug. Writes nothing." + [slot library] + (let [{:keys [raw given]} (get @last-read slot)] + (cond + (identical? library raw) given + (or (identical? library given) (not (map? library))) library + :else + (let [{cleaned :library findings :findings} (shapes/clean-library library given) + {given' :library} (shapes/split-unreadable cleaned library findings)] + (when (seq findings) + (js/console.error "Homebrew reached the app without the shape check (a bug):" + (pr-str slot (map #(select-keys % [:source :content-type :key :path :action]) + findings))) + (doseq [r (unreadable-reports library findings)] + (guard/report! r))) + (swap! last-read assoc slot {:raw library :given given'}) + given')))) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index dbfb6b536..caa352513 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -25,6 +25,7 @@ [orcpub.dnd.e5.equipment :as equipment5e] [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.homebrew-check :as homebrew-check] [orcpub.dnd.e5.starting-equipment-ledger :as sel] [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] [orcpub.route-map :as routes] @@ -38,10 +39,12 @@ ;; Version: 1.05 - Fix: preserve map keys for classes/subclasses (enables renamed keys) ;; ============================================================================= +;; Every homebrew subscription reads the library and shared content through these two, so the +;; read check is here, once. See homebrew-check/checked. (reg-sub ::e5/plugins (fn [db _] - (get db :plugins))) + (homebrew-check/checked :plugins (get db :plugins)))) ;; Whether the footer's diagnostic tools are revealed on this device. (reg-sub @@ -93,7 +96,7 @@ (reg-sub ::e5/shared-plugins (fn [db _] - (get db :shared-plugins))) + (homebrew-check/checked :shared-plugins (get db :shared-plugins)))) ;; Summary for the shared-content banner: {:count n :collisions [...]} while a ;; shared character with embedded homebrew is being viewed, else nil. diff --git a/test/browser/boot_rescue_e2e.js b/test/browser/boot_rescue_e2e.js index 9c0ad9b66..3ae981819 100644 --- a/test/browser/boot_rescue_e2e.js +++ b/test/browser/boot_rescue_e2e.js @@ -10,6 +10,8 @@ // that is simply always on would pass everything above. const { chromium } = require('playwright'); const { findChrome } = require('./lib/find-chrome'); +// a run by hand gets no overlay preload, so the What's New panel would cover every click +const { suppressWhatsNew } = require('./lib/orcbrew-import'); const fs = require('fs'), path = require('path'); const BASE = 'http://localhost:8890'; const OUT = process.env.PROBE_OUT || '/tmp/boot-rescue'; @@ -27,6 +29,7 @@ function check(name, pass, detail) { // Seeds localStorage before any app code runs, so the control has something to save. async function seeded(browser, { breakage } = {}) { const ctx = await browser.newContext({ acceptDownloads: true }); + await suppressWhatsNew(ctx); await ctx.addInitScript(v => { try { localStorage.setItem('plugins', v); @@ -161,6 +164,7 @@ async function rescues(page, label, expected = PLUGINS) { // built after the page opened never reached the copy it kept. { const ctx = await browser.newContext({ acceptDownloads: true }); + await suppressWhatsNew(ctx); // Deliberately opens with NOTHING stored, so a load-time read would both // fail to arm the control and have nothing to give. await ctx.addInitScript(() => { @@ -213,6 +217,7 @@ async function rescues(page, label, expected = PLUGINS) { // panel is what handles this. { const ctx = await browser.newContext(); + await suppressWhatsNew(ctx); await ctx.addInitScript(() => { try { localStorage.setItem('plugins', '{:this is not (valid edn at all'); @@ -234,6 +239,7 @@ async function rescues(page, label, expected = PLUGINS) { // boundary, so every page stayed on the loading spinner with only this control. { const ctx = await browser.newContext(); + await suppressWhatsNew(ctx); await ctx.addInitScript(() => { try { localStorage.setItem('plugins', '{"Bad Key Pak" {:orcpub.dnd.e5/races {:x {:key "text" :option-pack "Bad Key Pak" :name "X" :speed 30}}}}'); @@ -255,6 +261,7 @@ async function rescues(page, label, expected = PLUGINS) { // ---- 5. sensitivity: no homebrew stored means no control ---------------- { const ctx = await browser.newContext(); + await suppressWhatsNew(ctx); const page = await ctx.newPage(); await page.goto(BASE + '/dnd/5e/my-content', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2500); diff --git a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs index 60a7fe1a2..b2c1004ef 100644 --- a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs +++ b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs @@ -6,6 +6,7 @@ [re-frame.db :refer [app-db]] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.homebrew-check :as homebrew-check] + [orcpub.dnd.e5.homebrew-guard :as guard] [orcpub.dnd.e5.events :as events])) (def ^:private race {:name "Hill" :key :hill :option-pack "P" :speed "25"}) @@ -80,3 +81,41 @@ {"P" {::e5/races {:hill (dissoc bad :abilities)}}} {} [{:content-type ::e5/races :key :hill :source "P" :original bad}])] (is (= bad (get-in rejected ["P" ::e5/races :hill]))))) + +;; --------------------------------------------------------------------------- +;; The read check: what a script or a bug writes to app-db outside any event +;; --------------------------------------------------------------------------- + +(def ^:private read-by-handler (atom nil)) +(rf/reg-event-db ::read-the-library (fn [db _] (reset! read-by-handler (:plugins db)) db)) + +(deftest a-library-written-outside-any-event-is-read-checked + (let [was @app-db + reports (atom []) + bad (assoc race :key :bad :abilities 42) + written {"P" {::e5/races {:hill race :bad bad}}}] + (try + (guard/call-with-reporter + #(swap! reports conj %) + (fn [] + (reset! app-db {:plugins written}) + (testing "a view reads the fix, and not the entry that cannot be read" + (let [races (get-in @(rf/subscribe [::e5/plugins]) ["P" ::e5/races])] + (is (= 25 (get-in races [:hill :speed]))) + (is (not (contains? races :bad))))) + (testing "so does an event handler" + (rf/dispatch-sync [::read-the-library]) + (is (= 25 (get-in @read-by-handler ["P" ::e5/races :hill :speed]))) + (is (not (contains? (get-in @read-by-handler ["P" ::e5/races]) :bad)))) + (testing "the entry is reported once, as written, however often it is read" + @(rf/subscribe [::e5/plugins]) + (rf/dispatch-sync [::read-the-library]) + (is (= [[:bad bad]] (map (juxt :key :original) @reports)))))) + (finally + (rf/purge-event-queue) + (reset! app-db was))))) + +(deftest a-checked-library-costs-one-comparison + (let [lib' {"P" {::e5/races {:hill (assoc race :speed 25)}}}] + (is (identical? lib' (homebrew-check/checked ::test lib')) "a sound library is the same object") + (is (identical? lib' (homebrew-check/checked ::test lib'))))) diff --git a/test/e2e/cljs-harness.js b/test/e2e/cljs-harness.js index d0e9f677a..b8d7963df 100644 --- a/test/e2e/cljs-harness.js +++ b/test/e2e/cljs-harness.js @@ -11,7 +11,9 @@ const srv=http.createServer((q,r)=>{const u=decodeURIComponent(q.url.split('?')[ (async()=>{await new Promise(r=>srv.listen(0,r));const port=srv.address().port; const br=await chromium.launch({executablePath:findChrome()});const pg=await br.newPage();const out=[]; pg.on('console',m=>out.push(m.text()));pg.on('pageerror',e=>out.push('PAGEERROR '+e)); - await pg.goto(`http://localhost:${port}/runner-all.html`); + // The tests run while the page loads, so its load event comes only when they finish (~30s): + // wait for navigation alone, and leave the time limit to the totals wait below. + await pg.goto(`http://localhost:${port}/runner-all.html`,{waitUntil:'commit'}); // Figwheel's auto-testing page reports a "Totals" block, not cljs.test's "Ran N tests" line. try{await pg.waitForFunction(()=>/Ran \d+ tests|Totals\s+\d+ Tests/.test(document.body.innerText),null,{timeout:240000});}catch(e){out.push('TIMEOUT waiting for the totals');} const body=await pg.evaluate(()=>document.body.innerText); From d63d8ff5c47a12c882400cf663c24e068f1d0c8f Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 25 Oct 2026 16:47:59 +0000 Subject: [PATCH 12/31] Offer homebrew boons and invocations under their stored key pact-boon-options and eldritch-invocation-options let option-cfg make the key from the name, so a boon or invocation whose stored :key differs (renamed, or tagged on import to settle a conflict) was offered under another key, and the picks of it, the link checks and the deep check all missed. They pass the stored :key now. A character's pick of the name's key heals to the stored one, as a background's already does (items-with-formers). The junk audit no longer accepts the name's key for these two types; boons and invocations stay at zero without it. --- docs/HOMEBREW_GUARD.md | 11 +++++------ docs/branch-changelog.md | 2 ++ src/cljc/orcpub/dnd/e5/classes.cljc | 9 +++++++-- .../orcpub/dnd/e5/content_reconciliation.cljs | 11 +++++++---- .../dnd/e5/warlock_homebrew_keys_test.clj | 15 +++++++++++++++ .../dnd/e5/content_reconciliation_test.cljs | 17 +++++++++++++++++ test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 10 ++++------ 7 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 test/clj/orcpub/dnd/e5/warlock_homebrew_keys_test.clj diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index c206aec9d..1a2370a26 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -309,9 +309,10 @@ alone 0.001 ms; an event that changes one entry 0.05 ms, then its deep check 35 `deep-check`; HOMEBREW_REQUIRED_FIELDS.md; the browser probes. - **On grant-rows**: `builder_fields.cljc` and `content_types.cljc`; their schemas feed this table when grant-rows merges, so there is one description of a field, not two. -- **Known, not fixed**: homebrew boons and invocations key their option by name, not their stored - `:key` (`pact-boon-options`, `classes.cljc`; content-extensibility-compatibility.md calls it a - latent footgun). +- **Fixed here** (owner, 2026-10-25): homebrew boons and invocations keyed their option by name, + not their stored `:key` (`pact-boon-options`, `classes.cljc`; content-extensibility-compatibility.md + called it a latent footgun, as a rule for later grant work that never came). They are offered + under the stored key; a pick of the name's key heals to it, as a background's does. ## Order of work, each step gated @@ -344,6 +345,4 @@ refactor branch (owner, 2026-10-21). - **An unreadable field**: the whole entry moves to the quarantine panel with the reason (owner, 2026-10-25). -- **Boons and invocations keyed by name** (known, above): fix it here by passing the stored key? - Characters that picked one hold the name-derived key; where the stored key differs (a source - tag), they heal through `:former-keys`, the way renamed keys heal today. +- **Boons and invocations keyed by name**: fixed here (owner, 2026-10-25), above. diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index c8906d77a..14828fadf 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -54,5 +54,7 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB disappears without a word; it is set aside for repair. - **Homebrew:** a feat whose ability score increases were stored as a list no longer breaks its builder. +- **Homebrew:** a homebrew pact boon or eldritch invocation that was renamed, or given a new key + on import to settle a conflict, stays picked on the warlocks that chose it. ## Security diff --git a/src/cljc/orcpub/dnd/e5/classes.cljc b/src/cljc/orcpub/dnd/e5/classes.cljc index 4bbff7ce0..4b2238b4c 100644 --- a/src/cljc/orcpub/dnd/e5/classes.cljc +++ b/src/cljc/orcpub/dnd/e5/classes.cljc @@ -2636,9 +2636,12 @@ (defn pact-boon-options [plugin-boons spell-lists spells-map] (concat (map - (fn [{:keys [name description edit-event]}] + ;; offered under the stored :key, not one made from the name: a renamed or conflict-tagged + ;; boon kept its picks only by luck. Picks of the name's key heal (items-with-formers). + (fn [{:keys [name key description edit-event]}] (t/option-cfg {:name name + :key key :modifiers [(mod5e/trait-cfg {:name (str "Pact Boon: " name) :description description})] @@ -2684,9 +2687,11 @@ (defn eldritch-invocation-options [plugin-invocations spell-lists spells-map] (concat (map - (fn [{:keys [name description]}] + ;; under the stored :key, as boons are + (fn [{:keys [name key description]}] (t/option-cfg {:name name + :key key :modifiers [(mod5e/trait-cfg {:name (str "Eldritch Invocation: " name) :description description})]})) diff --git a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs index 52b5f7e6a..6eb86225e 100644 --- a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs +++ b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs @@ -378,9 +378,12 @@ (def former-keys links/former-keys) (def record-former-key links/record-former-key) +(def ^:private named-before + #{:orcpub.dnd.e5/backgrounds :orcpub.dnd.e5/boons :orcpub.dnd.e5/invocations}) + (defn- items-with-formers - "[{:type :key :formers}] for every item in `plugins`. A background's name-derived key counts as - a former key." + "[{:type :key :formers}] for every item in `plugins`. The name-derived key of a background, boon + or invocation counts as a former key." [plugins] (for [[_ plugin] plugins :when (map? plugin) @@ -388,11 +391,11 @@ :when (map? content) [k item] content :when (map? item)] - ;; a background was offered under its name's key until the stored one was used + ;; these were offered under their name's key until the stored one was used {:type ct :key k :formers (cond-> (vec (former-keys item)) - (and (= ct :orcpub.dnd.e5/backgrounds) (string? (:name item))) + (and (named-before ct) (string? (:name item))) (conj (common/name-to-kw (:name item))))})) (defn- claimed-once diff --git a/test/clj/orcpub/dnd/e5/warlock_homebrew_keys_test.clj b/test/clj/orcpub/dnd/e5/warlock_homebrew_keys_test.clj new file mode 100644 index 000000000..0193e9d8e --- /dev/null +++ b/test/clj/orcpub/dnd/e5/warlock_homebrew_keys_test.clj @@ -0,0 +1,15 @@ +(ns orcpub.dnd.e5.warlock-homebrew-keys-test + "Homebrew boons and invocations are offered under their stored :key, not one made from the name. + Picks of the name's key heal (content-reconciliation, items-with-formers)." + (:require [clojure.test :refer [deftest is]] + [orcpub.template :as t] + [orcpub.dnd.e5.classes :as classes])) + +(deftest a-homebrew-boon-is-offered-under-its-stored-key + (is (= :pact-of-ash-pk + (::t/key (first (classes/pact-boon-options [{:name "Pact of Ash" :key :pact-of-ash-pk}] {} {})))))) + +(deftest a-homebrew-invocation-is-offered-under-its-stored-key + (is (= :ember-sight-2 + (::t/key (first (classes/eldritch-invocation-options [{:name "Ember Sight" :key :ember-sight-2}] + {} {})))))) diff --git a/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs b/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs index 4cb2899b1..b3f20a54f 100644 --- a/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs +++ b/test/cljs/orcpub/dnd/e5/content_reconciliation_test.cljs @@ -730,6 +730,23 @@ (is (nil? (get (reconcile/former-key-index plugins #{:folk-hero}) :folk-hero)) "a built-in background with that key still answers"))) +(deftest a-boon-or-invocation-picked-by-its-name-heals-to-its-stored-key + ;; both were offered under their name's key until they were offered under the stored one + (let [plugins {"Pak" {:orcpub.dnd.e5/boons {:pact-of-ash-pk {:name "Pact of Ash" :key :pact-of-ash-pk}} + :orcpub.dnd.e5/invocations {:ember-sight-2 {:name "Ember Sight" :key :ember-sight-2}}}} + warlock {::entity/options {:class [{::entity/key :warlock + ::entity/options {:pact-boon {::entity/key :pact-of-ash} + :eldritch-invocations + [{::entity/key :ember-sight}]}}]}} + {:keys [character rewrote]} + (reconcile/reconcile-former-keys + warlock (reconcile/former-key-indexes plugins #{:warlock :pact-of-ash-pk :ember-sight-2} {}))] + (is (= :pact-of-ash-pk (get-in character [::entity/options :class 0 ::entity/options :pact-boon ::entity/key]))) + (is (= :ember-sight-2 (get-in character [::entity/options :class 0 ::entity/options + :eldritch-invocations 0 ::entity/key]))) + (is (= #{{:from :pact-of-ash :to :pact-of-ash-pk} {:from :ember-sight :to :ember-sight-2}} + (set rewrote))))) + ;; A key names nothing without its type: a heal or a lookup that ignores the type reads one kind ;; of content as another (docs/kb/typed-keys.md). diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index a1d4b4a3a..9bdbab618 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -229,8 +229,8 @@ (defn- pick "`c` with option `k` picked wherever it is offered and not yet picked, as the builder picks it, - repeated so a pick that offers it again (a subrace under a race) is followed. `k` may be a set - of keys: boons and invocations key their option by the entry's name, not its :key." + repeated so a pick that offers it again (a subrace under a race) is followed. `ks`: a set of + keys." [template c ks] (loop [c c i 0] (let [[_ avail] (offered template c) @@ -255,8 +255,7 @@ "Pick homebrew entry `k` in `c`, build it, and draw what the builder draws under it: every selection whose path runs through `k`, fully realized. Throws when `k` is never offered." [template k c & [entry]] - (let [nm (:name entry) - ks (cond-> #{k} (string? nm) (conj (common/name-to-kw nm))) + (let [ks #{k} c (pick template c ks) [built avail] (offered template c)] (when (and k (not (some ks (tree-seq coll? seq c)))) @@ -572,8 +571,7 @@ [t :junk-base])))] [(keyword (str "junk-" (name t) "-" j "-" i)) [t p v]]))] {:cases cases - ;; the name is the key's own text, so a type that keys its options by name (boons, - ;; invocations) still names this entry; a junk :name stays junk + ;; the name is the key's own text; a junk :name stays junk :entries (reduce-kv (fn [m k [t p v]] (assoc-in m [t k] (assoc-in (assoc (get-in @base-entries [t :junk-base]) :key k :name (name k)) From a17f2f76be5771c706871bb8e96534f59944a9f9 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 25 Oct 2026 17:41:45 +0000 Subject: [PATCH 13/31] Harden the helpers every option passes through, ranked by the unguarded audit The junk audit gains JUNK_UNGUARDED=1, which turns the shape check off so junk reaches our own code; run once with stacks, it showed most crashes going through plugin-modifiers and collect-map-modifiers, then the monster list's filter. Those, the feat builders' :ability-increases, safe-capitalize-kw and the skill-proficiency modifier now take what they are given through common/usable: the wrong shape costs that field and a console warning, not the page. The monster list looks size and type up rather than calling them, checks the name is text, and sorts with safe-compare. Homebrew subraces were offered under their name's key, like boons; the audit's allowance for name keys had hidden it. They use the stored key, and picks of the name's key heal. Unguarded audit 554 -> 396; guarded 10 -> 5, all share-link entries never offered. --- docs/HOMEBREW_GUARD.md | 15 +++++++++- docs/branch-changelog.md | 6 ++-- src/cljc/orcpub/common.cljc | 14 +++++++++- src/cljc/orcpub/dnd/e5/modifiers.cljc | 2 +- src/cljc/orcpub/dnd/e5/options.cljc | 19 +++++++++---- src/cljc/orcpub/dnd/e5/skills.cljc | 6 ++++ .../orcpub/dnd/e5/content_reconciliation.cljs | 7 +++-- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 12 ++++---- test/cljc/orcpub/hardened_helpers_test.cljc | 28 +++++++++++++++++++ test/cljs/orcpub/junk_audit_runner.cljs | 6 ++++ test/cljs/orcpub/test_runner.cljs | 2 ++ test/e2e/junk-pack-audit.js | 3 +- 12 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 test/cljc/orcpub/hardened_helpers_test.cljc diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index 1a2370a26..15b99df2a 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -332,7 +332,20 @@ alone 0.001 ms; an event that changes one entry 0.05 ms, then its deep check 35 **When PR #69 merges**, its list of `[amount pool]` spreads goes into the feats row with the builder that reads it, in the merge itself: until then this build would turn a spread into a set on load and set it aside, as it cannot read it. -5. **Hardened core helpers**, ranked by the audit. +5. **Done:** hardened core helpers. The audit with the shape check off (`JUNK_UNGUARDED=1`, which + lets every junk value reach our code) recorded where each crash happened; most went through + `plugin-modifiers` and `collect-map-modifiers`, then the monster list's filter. Those, the feat + builders' `:ability-increases`, `safe-capitalize-kw` and the skill-proficiency modifier now + take what they are given through `common/usable`: a value of the wrong shape costs that field + and a console warning, not the page. Unguarded: 554 to 396 (monsters 103 to 14, subraces 101 + to 50). What is left there are single-field readers in each option builder, which the shape + table covers and step 6 contains; hardening each one is the per-site work this design avoids. + Lifting the audit's allowance for name-derived keys showed homebrew subraces had the same bug + as boons: offered under their name's key. They now use the stored key, and old picks heal. + Guarded: 10 to 5, all from a share link, all "never offered": a subrace or subclass whose + parent does not exist (4), and a subclass named "x" (1, not yet traced). Found on the way: + `class-option` merges homebrew subclasses into a set sorted by name, so a homebrew subclass + with the same name as another (built in, or from another source) is dropped without a word. 6. **Containment:** per-item boundaries, the event error handler, the guard on 13 types. 7. **Enforcement:** the audit in CI at zero, the AGENTS.md rules, the documents corrected, the testing/develop scenarios gone through. diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index 14828fadf..c21b6ef6d 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -54,7 +54,9 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB disappears without a word; it is set aside for repair. - **Homebrew:** a feat whose ability score increases were stored as a list no longer breaks its builder. -- **Homebrew:** a homebrew pact boon or eldritch invocation that was renamed, or given a new key - on import to settle a conflict, stays picked on the warlocks that chose it. +- **Homebrew:** a homebrew subrace, pact boon or eldritch invocation that was renamed, or given a + new key on import to settle a conflict, stays picked on the characters that chose it. +- **Homebrew:** a background, feat or class granting a skill that does not exist no longer stops + the character options from loading. ## Security diff --git a/src/cljc/orcpub/common.cljc b/src/cljc/orcpub/common.cljc index 23f4efdcf..3f7d3fe8e 100644 --- a/src/cljc/orcpub/common.cljc +++ b/src/cljc/orcpub/common.cljc @@ -294,11 +294,23 @@ (name kw) (warn (str "non-keyword value passed to safe-name: " kw)))) +(defn usable + "`x` when `(ok? x)`, else `fallback`, with a warning naming `what` unless `x` is nil. For the + helpers every option, choice and modifier passes through: homebrew reaches them already made to + fit (docs/HOMEBREW_GUARD.md), so a value of the wrong shape here is a bug of ours, and costs + that field rather than the page." + [ok? x fallback what] + (cond + (ok? x) x + (nil? x) fallback + :else (do (warn (str "Skipped " what ", which is not the shape it should be: " (pr-str x))) + fallback))) + (defn safe-capitalize [s] (when (string? s) (s/capitalize s))) (defn safe-capitalize-kw [kw] - (some-> kw + (some-> (usable #(or (keyword? %) (string? %)) kw nil "a name to capitalize") name safe-capitalize)) diff --git a/src/cljc/orcpub/dnd/e5/modifiers.cljc b/src/cljc/orcpub/dnd/e5/modifiers.cljc index 7df8a4dc2..75fd45590 100644 --- a/src/cljc/orcpub/dnd/e5/modifiers.cljc +++ b/src/cljc/orcpub/dnd/e5/modifiers.cljc @@ -338,7 +338,7 @@ [~skill-kw ~source] true) - (s/capitalize (-> ~skill-kw skill5e/skills-map :name)) + (skill5e/skill-name ~skill-kw) "proficiency" ~conditions)) diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index fc3194b6f..91cf65992 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -2042,6 +2042,7 @@ languages source {:keys [name + key abilities profs size @@ -2063,7 +2064,10 @@ clojure.core/key (filter val options)))] (t/option-cfg + ;; a homebrew subrace under its stored :key (a built-in one has none, and keeps its name's); + ;; picks of the name's key heal (content-reconciliation, items-with-formers) {:name name + :key key :edit-event edit-event :selections (concat (when (seq skill-kws) @@ -3452,14 +3456,16 @@ spell-lists)] nil))) -(defn collect-map-modifiers [m modifier-fn] +(defn collect-map-modifiers + "A modifier for each key `m`, a map of key to true or false, marks true." + [m modifier-fn] (sequence (comp filter-true (map (fn [[k]] (modifier-fn k)))) - m)) + (common/usable map? m {} "a map of keys to true"))) (defn make-feat-modifiers [k v option-key] (when v @@ -3532,10 +3538,11 @@ (concat mods feat-mods) mods))) [] - props)) + (common/usable map? props {} "an option's :props"))) (defn feat-modifiers [key name description props ability-increases] - (let [without-saves (sets/intersection ability-increases + (let [without-saves (sets/intersection (common/usable set? ability-increases #{} + "a feat's :ability-increases") (into #{} character/ability-keys))] (concat (plugin-modifiers props key) @@ -3552,7 +3559,9 @@ :description description})]))) (defn feat-selections [language-map spells-map spell-lists proficiency-weapons props ability-increases] - (let [without-saves (sets/intersection ability-increases + (let [ability-increases (common/usable set? ability-increases #{} "a feat's :ability-increases") + props (common/usable map? props {} "a feat's :props") + without-saves (sets/intersection ability-increases (into #{} character/ability-keys))] (reduce (fn [selections [k v]] diff --git a/src/cljc/orcpub/dnd/e5/skills.cljc b/src/cljc/orcpub/dnd/e5/skills.cljc index ffe4a66c9..623bbdf68 100644 --- a/src/cljc/orcpub/dnd/e5/skills.cljc +++ b/src/cljc/orcpub/dnd/e5/skills.cljc @@ -99,5 +99,11 @@ check. The GM might also call for a Dexterity (Sleight of Hand) check to determi (def skills-map (common/map-by-key skills)) +(defn skill-name + "The name of the skill `kw` keys, capitalized; a key that names no skill shows as its own text." + [kw] + (or (:name (skills-map kw)) + (common/safe-capitalize-kw (common/usable keyword? kw nil "a skill key that names no skill")))) + (def skill-abilities (into {} (map (juxt :key :ability)) skills)) diff --git a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs index 6eb86225e..38d8b4d28 100644 --- a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs +++ b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs @@ -379,11 +379,12 @@ (def record-former-key links/record-former-key) (def ^:private named-before - #{:orcpub.dnd.e5/backgrounds :orcpub.dnd.e5/boons :orcpub.dnd.e5/invocations}) + #{:orcpub.dnd.e5/backgrounds :orcpub.dnd.e5/boons :orcpub.dnd.e5/invocations + :orcpub.dnd.e5/subraces}) (defn- items-with-formers - "[{:type :key :formers}] for every item in `plugins`. The name-derived key of a background, boon - or invocation counts as a former key." + "[{:type :key :formers}] for every item in `plugins`. The name-derived key of a background, boon, + invocation or subrace counts as a former key." [plugins] (for [[_ plugin] plugins :when (map? plugin) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index caa352513..948f08e5d 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -1273,13 +1273,14 @@ :<- [::char5e/monster-sort-criteria] :<- [::char5e/monster-sort-direction] (fn [[monsters sort-criteria sort-direction]] - (let [comparator (if (= sort-direction "asc") compare #(compare %2 %1))] + (let [comparator (if (= sort-direction "asc") common/safe-compare #(common/safe-compare %2 %1))] (case sort-criteria "name" (sort-by :name comparator monsters) "cr" (sort-by :challenge comparator monsters))))) (defn all-subtypes-removed? [subtypes hidden-subtypes] - (and (seq subtypes) + (and (coll? subtypes) + (seq subtypes) (seq hidden-subtypes) (->> subtypes (remove @@ -1290,10 +1291,11 @@ (let [lower-case-filter-text (s/lower-case filter-text)] (filter (fn [{:keys [name type subtypes size]}] + ;; looked up, not called: a size or type that is not a keyword must not break the list (and (or (< (count filter-text) 3) - (s/includes? (s/lower-case name) lower-case-filter-text)) - (not (or (-> monster-filters :size size) - (-> monster-filters :type type) + (and (string? name) (s/includes? (s/lower-case name) lower-case-filter-text))) + (not (or (get (:size monster-filters) size) + (get (:type monster-filters) type) (all-subtypes-removed? subtypes (:subtype monster-filters)))))) monsters))) diff --git a/test/cljc/orcpub/hardened_helpers_test.cljc b/test/cljc/orcpub/hardened_helpers_test.cljc new file mode 100644 index 000000000..a3bb02c34 --- /dev/null +++ b/test/cljc/orcpub/hardened_helpers_test.cljc @@ -0,0 +1,28 @@ +(ns orcpub.hardened-helpers-test + "The helpers every option, choice and modifier passes through skip a value of the wrong shape + instead of throwing (docs/HOMEBREW_GUARD.md, 5)." + (:require #?(:clj [clojure.test :refer [deftest testing is]] + :cljs [cljs.test :refer-macros [deftest testing is]]) + [orcpub.common :as common] + [orcpub.dnd.e5.skills :as skills] + [orcpub.dnd.e5.options :as opt5e])) + +(def ^:private junk [42 "x" true :kw [] [42] [{}] {:a 1} #{42} 1.5]) + +(deftest usable-gives-back-what-fits-and-the-fallback-otherwise + (is (= {:a true} (common/usable map? {:a true} {} "x"))) + (is (= {} (common/usable map? nil {} "x"))) + (is (= {} (common/usable map? 42 {} "x")))) + +(deftest the-helpers-never-throw-on-junk + (doseq [v junk] + (testing (pr-str v) + (is (seq? (doall (opt5e/collect-map-modifiers v identity)))) + (is (sequential? (doall (opt5e/plugin-modifiers v :k)))) + (is (sequential? (doall (opt5e/feat-modifiers :k "F" "d" {} v)))) + (is (or (nil? (common/safe-capitalize-kw v)) (string? (common/safe-capitalize-kw v))))))) + +(deftest a-skill-key-that-names-no-skill-shows-as-its-text + (is (= "Athletics" (skills/skill-name :athletics))) + (is (= "Juggling" (skills/skill-name :juggling))) + (is (nil? (skills/skill-name 42)))) diff --git a/test/cljs/orcpub/junk_audit_runner.cljs b/test/cljs/orcpub/junk_audit_runner.cljs index b856f1660..78db2b368 100644 --- a/test/cljs/orcpub/junk_audit_runner.cljs +++ b/test/cljs/orcpub/junk_audit_runner.cljs @@ -3,6 +3,7 @@ this build instead of the whole browser suite." (:require [cljs.test :refer-macros [run-tests]] [re-frame.core :as rf] + [orcpub.dnd.e5.homebrew-shapes :as shapes] [orcpub.dnd.e5.junk-pack-test])) ;; as in orcpub.test-runner: building the template must not start the autosave watcher @@ -15,4 +16,9 @@ (defmethod cljs.test/report [:cljs.test/default :end-run-tests] [m] (js/console.log "JUNKDONE" (pr-str (select-keys m [:test :pass :fail :error])))) +;; ?unguarded: the shape check lets everything through, so the junk reaches our own code. What +;; fails then is what containment and the hardened helpers must catch (docs/HOMEBREW_GUARD.md, 5). +(when (re-find #"[?&]unguarded" (str (.-search js/location))) + (set! shapes/clean-library (fn [library & _] {:library library :findings []}))) + (run-tests 'orcpub.dnd.e5.junk-pack-test) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index e257154f2..43093a517 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -3,6 +3,7 @@ [re-frame.core :as rf] ;; .cljc tests (run on both JVM and CLJS) [orcpub.common-test] + [orcpub.hardened-helpers-test] [orcpub.dnd.e5.homebrew-guard-test] [orcpub.dnd.e5.event-utils-test] [orcpub.dnd.e5.compute-test] @@ -60,6 +61,7 @@ (defn -main [] (run-tests 'orcpub.common-test + 'orcpub.hardened-helpers-test 'orcpub.dnd.e5.homebrew-guard-test 'orcpub.dnd.e5.event-utils-test 'orcpub.dnd.e5.compute-test diff --git a/test/e2e/junk-pack-audit.js b/test/e2e/junk-pack-audit.js index decd6be0b..c36d2c980 100644 --- a/test/e2e/junk-pack-audit.js +++ b/test/e2e/junk-pack-audit.js @@ -5,6 +5,7 @@ // // lein fig:junk && NODE_PATH=/opt/node22/lib/node_modules node test/e2e/junk-pack-audit.js [pages] [types] // pages: how many run at once (default 4). types: a comma list such as races,feats (default all). +// JUNK_UNGUARDED=1 turns the shape check off, to measure what our own code does with junk. // Failures go to target/junk-audit/junk-audit.txt, one per line, and each page's console to // console-.log beside it. Exit 0 = nothing threw. const http = require('http'), fs = require('fs'), path = require('path'); @@ -25,7 +26,7 @@ const srv = http.createServer((q, r) => { (async () => { const n = Number(process.argv[2] || 4); - const types = process.argv[3] ? `=${process.argv[3]}` : ''; + const types = (process.argv[3] ? `=${process.argv[3]}` : '') + (process.env.JUNK_UNGUARDED ? '&unguarded' : ''); if (!fs.existsSync(path.join(ROOT, 'js/junk-audit.js'))) { console.error('Run lein fig:junk first.'); process.exit(2); } await new Promise(r => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port; From 07757325e0f45d7b97c527f629aaa684a5290ee8 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 26 Oct 2026 15:09:46 +0000 Subject: [PATCH 14/31] A homebrew addition that breaks a class or race costs only itself base-class-options built the twelve built-in classes together and, when any threw, returned none: a homebrew subclass, invocation or boon that broke one class emptied the builder's class list, with only a console line. Each class is now built on its own; when one fails, its homebrew additions are tried alone, the ones that fail are reported through the guard (which sets them aside), and the class is built without them. A subrace is built in a guard of its own inside its race, as a subclass already is inside its class, so one that fails is blamed and set aside, not its race. The deep check now covers subraces (in their race), invocations and boons (in the warlock), selections, spells and languages: every content type that becomes a character option. Monsters and encounters become none; their pages contain a bad item (next). --- docs/branch-changelog.md | 3 + src/cljc/orcpub/dnd/e5/options.cljc | 7 +- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 109 ++++++++++++++---- test/cljs/orcpub/dnd/e5/containment_test.cljs | 50 ++++++++ test/cljs/orcpub/test_runner.cljs | 2 + 5 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 test/cljs/orcpub/dnd/e5/containment_test.cljs diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index c21b6ef6d..09d9a9d99 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -58,5 +58,8 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB new key on import to settle a conflict, stays picked on the characters that chose it. - **Homebrew:** a background, feat or class granting a skill that does not exist no longer stops the character options from loading. +- **Homebrew:** a homebrew subclass, invocation or boon that breaks a built-in class costs only + itself: it is set aside, named, and the class is still offered. Before, every built-in class + disappeared from the builder. A subrace that breaks is set aside on its own, not with its race. ## Security diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index 91cf65992..629b6cbcc 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -2206,8 +2206,11 @@ :min (if subraces 1 0) :options (cond-> (if (seq subraces) - (map - (partial subrace-option race spell-lists spells-map language-map source) + ;; each subrace in a guard of its own, so one that fails is blamed, not its race + (keep + #(guard/guard-entry :orcpub.dnd.e5/subraces % + (partial subrace-option race spell-lists spells-map + language-map source)) (if source (map (fn [sr] (assoc sr :source source)) subraces) subraces)) diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 948f08e5d..9f802c23a 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -1068,19 +1068,50 @@ tiefling-option-cfg])))))) +(def ^:private built-in-classes + "Each built-in class's key, and how to build it with the homebrew it takes." + (let [plain (fn [option-fn] (fn [sl sm psm lm wm _ _] (option-fn sl sm psm lm wm)))] + [[:barbarian (plain classes5e/barbarian-option)] + [:bard (plain classes5e/bard-option)] + [:cleric (plain classes5e/cleric-option)] + [:druid (plain classes5e/druid-option)] + [:fighter (plain classes5e/fighter-option)] + [:monk (plain classes5e/monk-option)] + [:paladin (plain classes5e/paladin-option)] + [:ranger (plain classes5e/ranger-option)] + [:rogue (plain classes5e/rogue-option)] + [:sorcerer (plain classes5e/sorcerer-option)] + [:warlock classes5e/warlock-option] + [:wizard (plain classes5e/wizard-option)]])) + +(defn- base-class-option + "Built-in class `k`, built by `build` with its homebrew subclasses (and the warlock's invocations + and boons). When one of those stops it building, each is tried alone: the ones that fail are + reported through the guard, which sets them aside, and the class is built without them. A + class that fails with none is a bug of ours: logged, and left out, not the other eleven." + [k build spell-lists spells-map plugin-subclasses-map language-map weapons-map invocations boons] + (let [b #(build spell-lists spells-map %1 language-map weapons-map %2 %3)] + (try + (b plugin-subclasses-map invocations boons) + (catch :default _ + (let [alone (fn [content-type entries make] + ;; the entries that build alone, the rest reported + (filterv #(guard/guard-entry content-type % (fn [e] (make e) e)) entries)) + subclasses (alone ::e5/subclasses (get plugin-subclasses-map k) #(b {k [%]} [] [])) + warlock? (= k :warlock) + invocations (if warlock? (alone ::e5/invocations invocations #(b {} [%] [])) invocations) + boons (if warlock? (alone ::e5/boons boons #(b {} [] [%])) boons)] + (try + (b (assoc plugin-subclasses-map k subclasses) invocations boons) + (catch :default e + (js/console.error "Could not build the built-in class" (str k) e) + nil))))))) + (defn base-class-options [spell-lists spells-map plugin-subclasses-map language-map weapons-map invocations boons] - [(classes5e/barbarian-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/bard-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/cleric-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/druid-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/fighter-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/monk-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/paladin-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/ranger-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/rogue-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/sorcerer-option spell-lists spells-map plugin-subclasses-map language-map weapons-map) - (classes5e/warlock-option spell-lists spells-map plugin-subclasses-map language-map weapons-map invocations boons) - (classes5e/wizard-option spell-lists spells-map plugin-subclasses-map language-map weapons-map)]) + (vec (keep (fn [[k build]] + (base-class-option k build spell-lists spells-map plugin-subclasses-map language-map + weapons-map invocations boons)) + built-in-classes))) (reg-sub ::classes5e/classes @@ -1093,12 +1124,9 @@ :<- [::classes5e/boons] :<- [::mi5e/custom-and-standard-weapons-map] (fn [[spell-lists spells-map plugin-subclasses-map language-map plugin-classes invocations boons weapons-map] _] - ;; Defensive handling: ensure base classes always render even if plugin classes fail - (let [base-classes (try - (base-class-options spell-lists spells-map plugin-subclasses-map language-map weapons-map invocations boons) - (catch js/Error e - (js/console.error "Failed to build base classes:" e) - [])) + ;; each built-in class is built on its own, so homebrew that breaks one costs only itself + (let [base-classes (base-class-options spell-lists spells-map plugin-subclasses-map language-map + weapons-map invocations boons) plugin-class-options (keep (fn [plugin-class] (try @@ -1567,6 +1595,45 @@ (or (some #(when (= class-key (:key %)) (opt5e/class-option spell-lists spells-map only-this language-map weapons-map %)) plugin-classes) - (some #(when (= class-key (::t/key %)) %) - (base-class-options spell-lists spells-map only-this language-map - weapons-map invocations boons)))))))}}) + ;; its built-in class alone, built raw so a throw is this subclass's + (some (fn [[k build]] + (when (= k class-key) + (build spell-lists spells-map only-this language-map weapons-map + invocations boons))) + built-in-classes))))))} + ;; a subrace is built inside its race, as that race's only subrace, and blamed on itself + ::e5/subraces + {:entries #(% [::races5e/plugin-subraces]) + :convert (fn [sub] + (let [races (sub [::races5e/races]) + convert (partial opt5e/race-option (sub [::spells5e/spell-lists]) + (sub [::spells5e/spells-map]) (sub [::langs5e/language-map]) + (sub [::mi5e/all-weapons-map]))] + (fn [{race-key :race :as subrace}] + (some #(when (= race-key (:key %)) (convert (assoc % :subraces [subrace]))) + races))))} + ;; the warlock, with this invocation or boon as its only homebrew one + ::e5/invocations + {:entries #(% [::classes5e/plugin-invocations]) + :convert (fn [sub] + #(classes5e/warlock-option (sub [::spells5e/spell-lists]) (sub [::spells5e/spells-map]) {} + (sub [::langs5e/language-map]) + (sub [::mi5e/custom-and-standard-weapons-map]) [%] []))} + ::e5/boons + {:entries #(% [::classes5e/plugin-boons]) + :convert (fn [sub] + #(classes5e/warlock-option (sub [::spells5e/spell-lists]) (sub [::spells5e/spells-map]) {} + (sub [::langs5e/language-map]) + (sub [::mi5e/custom-and-standard-weapons-map]) [] [%]))} + ;; a selection is offered where a class or subclass level names it + ::e5/selections + {:entries #(% [::selections5e/plugin-selections]) + :convert (fn [_] + #(level-selection :selection-check {(:key %) %} {:type (:key %) :num 1}))} + ::e5/spells + {:entries #(% [::spells5e/plugin-spells]) + :convert (fn [_] + #(spell-option {(:key %) %} [nil (:key %) ::char5e/int "Wizard"]))} + ::e5/languages + {:entries #(% [::langs5e/plugin-languages]) + :convert (fn [_] #(opt5e/language-selection-aux [%] 1))}}) diff --git a/test/cljs/orcpub/dnd/e5/containment_test.cljs b/test/cljs/orcpub/dnd/e5/containment_test.cljs new file mode 100644 index 000000000..94cd30225 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/containment_test.cljs @@ -0,0 +1,50 @@ +(ns orcpub.dnd.e5.containment-test + "A bug of ours, or homebrew the checks before it miss, costs the one item it is in, and is + reported: docs/HOMEBREW_GUARD.md, 5." + (:require [cljs.test :refer-macros [deftest testing is]] + [orcpub.template :as t] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.spell-subs :as spell-subs])) + +(deftest an-invocation-that-breaks-the-warlock-costs-only-itself + (let [reports (atom []) + bad {:key :bad :name "Bad" :option-pack "P"} + good {:key :good :name "Good" :option-pack "P"} + real classes5e/eldritch-invocation-options] + (with-redefs [classes5e/eldritch-invocation-options + (fn [invocations & more] + (if (some #(= :bad (:key %)) invocations) + (throw (js/Error. "a bug in one invocation")) + (apply real invocations more)))] + (let [classes (guard/call-with-reporter + #(swap! reports conj %) + #(spell-subs/base-class-options {} {} {} {} {} [bad good] []))] + (testing "every built-in class is still offered, the warlock included" + (is (= 12 (count classes))) + (is (some #(= :warlock (::t/key %)) classes))) + (testing "the invocation that broke it is reported, by itself" + (is (= [[::e5/invocations :bad]] (map (juxt :content-type :key) @reports)))))))) + +(deftest a-subrace-that-fails-is-blamed-not-its-race + (let [reports (atom []) + race {:name "Hill" :key :hill :option-pack "P" + :subraces [{:name "Bad" :key :bad :race :hill :option-pack "P"} + {:name "Good" :key :good :race :hill :option-pack "P"}]} + real opt5e/subrace-option] + (with-redefs [opt5e/subrace-option (fn [& args] + (if (= :bad (:key (last args))) + (throw (js/Error. "a bug in one subrace")) + (apply real args)))] + (let [option (guard/call-with-reporter #(swap! reports conj %) + #(guard/realize (opt5e/race-option {} {} {} {} race)))] + (is (= :hill (::t/key option)) "the race is still offered") + (is (= [[::e5/subraces :bad]] (map (juxt :content-type :key) @reports))))))) + +(deftest the-deep-check-covers-every-type-that-becomes-a-character-option + ;; monsters and encounters become no option: their pages contain a bad one instead + (is (= #{::e5/races ::e5/subraces ::e5/backgrounds ::e5/feats ::e5/classes ::e5/subclasses + ::e5/invocations ::e5/boons ::e5/selections ::e5/spells ::e5/languages} + (set (keys (guard/conversions)))))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index 43093a517..7c7f3fd19 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -38,6 +38,7 @@ [orcpub.dnd.e5.coverage-pak-test] [orcpub.dnd.e5.junk-pack-test] [orcpub.dnd.e5.homebrew-interceptor-test] + [orcpub.dnd.e5.containment-test] [orcpub.dnd.e5.reference-web-test] [orcpub.dnd.e5.views-test] [orcpub.dnd.e5.portrait-credit-thumb-test] @@ -88,6 +89,7 @@ 'orcpub.dnd.e5.coverage-pak-test 'orcpub.dnd.e5.junk-pack-test 'orcpub.dnd.e5.homebrew-interceptor-test + 'orcpub.dnd.e5.containment-test 'orcpub.dnd.e5.reference-web-test 'orcpub.dnd.e5.views-test 'orcpub.dnd.e5.portrait-credit-thumb-test From 625f92d4ca3a85955879f1173a65e1a5ba217a36 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 26 Oct 2026 15:28:47 +0000 Subject: [PATCH 15/31] An event that throws says so and changes nothing re-frame applies no effect of a handler that did not finish, so the state was already safe; but its default error handler rethrew, which drops every event still queued, and nothing told the person. events/event-failed logs the error with its event and shows the error notice, once in a few seconds per event, never for the notice itself. core.cljs registers it after the startup steps: startup's recovery needs :initialize-db to throw, so it must not be swallowed there. The test builds never load core, so they keep re-frame's handler and a throw still fails a test (a test checks). event_error_notice_e2e: in the real app, a throwing event shows the notice, its change is not applied, and the next event still runs; 3 of 6 fail without the handler. --- docs/branch-changelog.md | 3 + scripts/test/probe-baseline.json | 4 + scripts/test/run-browser-probes.js | 1 + src/cljs/orcpub/dnd/e5/events.cljs | 28 +++++++ test/browser/event_error_notice_e2e.js | 78 +++++++++++++++++++ test/cljs/orcpub/dnd/e5/containment_test.cljs | 21 +++++ web/cljs/orcpub/core.cljs | 6 +- 7 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 test/browser/event_error_notice_e2e.js diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index 09d9a9d99..d16b02e72 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -46,6 +46,9 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB ## Fixed +- **Site:** an action that fails now says so and changes nothing. Before, nothing visible + happened, and the actions queued after it were dropped. + - **Homebrew:** a library with a content section stored as `true` no longer stops the app from starting. - **Homebrew:** a monster from a share link with a broken name, size or type no longer breaks the diff --git a/scripts/test/probe-baseline.json b/scripts/test/probe-baseline.json index c7e90c3b8..33ffe25ab 100644 --- a/scripts/test/probe-baseline.json +++ b/scripts/test/probe-baseline.json @@ -95,5 +95,9 @@ "cookie_notice_e2e.js": { "checks": 9, "seconds": 10 + }, + "event_error_notice_e2e.js": { + "checks": 6, + "seconds": 20 } } diff --git a/scripts/test/run-browser-probes.js b/scripts/test/run-browser-probes.js index 99c521f6e..13e63ea9e 100755 --- a/scripts/test/run-browser-probes.js +++ b/scripts/test/run-browser-probes.js @@ -62,6 +62,7 @@ const PROBES = [ { file: 'export_busy_retry_e2e.js', needs: 'busy-server' }, { file: 'boot_rescue_e2e.js', needs: 'server' }, { file: 'builder_card_export_e2e.js', needs: 'server' }, + { file: 'event_error_notice_e2e.js', needs: 'server' }, { file: 'fixes_persist_e2e.js', needs: 'server' }, { file: 'header_menus_e2e.js', needs: 'server' }, { file: 'homebrew_safety_net_e2e.js', needs: 'server' }, diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index e8568574f..984fd4f95 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -6782,6 +6782,34 @@ :after (fn [ctx] (update ctx :effects #(homebrew-shapes-checked (get-in ctx [:coeffects :db]) %))))) +;; An event that throws changes nothing: re-frame applies no effect of a handler that did not +;; finish. This says so to the person, at most once a few seconds for the same event, and keeps +;; the error in the console with the event that raised it. re-frame's own handler rethrew, which +;; drops every event still queued (this notice with them), so a click that failed did nothing +;; visible. core.cljs registers it once startup is done: startup's recovery needs +;; :initialize-db to throw, and the test builds, which never load core, keep re-frame's handler, +;; so a throw still fails a test. See docs/HOMEBREW_GUARD.md, 5. +(def ^:private last-event-error (atom nil)) + +(def event-failed-message + "That didn't work, so nothing was changed. If it keeps happening, refresh the page.") + +(defn event-failed + "Handles an event that threw: logs it, and dispatches the notice unless the same event was + noticed in the last few seconds, or the event was the notice itself." + [original-error re-frame-error] + (let [{:keys [event-v direction interceptor]} (ex-data re-frame-error) + id (first event-v) + now (.now js/Date) + {:keys [at event]} @last-event-error] + (js/console.error "An event failed and changed nothing:" (pr-str id) + (str "(" (pr-str interceptor) ", " (name (or direction :handler)) ")") + original-error) + (when-not (or (= id :show-error-message) + (and (= id event) (< (- now at) 5000))) + (reset! last-event-error {:at now :event id}) + (dispatch [:show-error-message event-failed-message])))) + ;; ============================================================================ ;; Export Warning Modal Events ;; ============================================================================ diff --git a/test/browser/event_error_notice_e2e.js b/test/browser/event_error_notice_e2e.js new file mode 100644 index 000000000..63e2b3754 --- /dev/null +++ b/test/browser/event_error_notice_e2e.js @@ -0,0 +1,78 @@ +// Overlays: suppressed (cookie notice and What's New). +// Does an event that throws in the real app tell the person, and change nothing? +// +// re-frame applies no effect of a handler that did not finish; events/event-failed, registered +// by core.cljs once startup is done, says so in the error notice instead of rethrowing (which +// dropped every queued event, the notice with them). See docs/HOMEBREW_GUARD.md, 5. +// +// 1. the app starts with a library, so startup's recovery is not broken by the handler +// 2. an event that throws shows the notice once, and its change is not applied +// 3. the same event failing again straight away does not show a second notice +// 4. the next event still runs: the queue was not dropped +// +// Needs: server (`lein e2e-server`). No login. +// Runs in: ~20s. +const { chromium } = require('playwright'); +const { findChrome } = require('./lib/find-chrome'); +const { suppressOverlays } = require('./lib/orcbrew-import'); +const BASE = process.env.E2E_BASE || 'http://localhost:8890'; +const MESSAGE = "That didn't work, so nothing was changed."; + +const results = []; +function check(name, pass, detail) { + results.push({ name, pass, detail }); + console.log((pass ? 'PASS ' : 'FAIL ') + name + (detail ? ' [' + detail + ']' : '')); +} + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + const ctx = await browser.newContext(); + await suppressOverlays(ctx); + await ctx.addInitScript(() => { + try { + localStorage.setItem('plugins', '{"P" {:orcpub.dnd.e5/races {:hill {:name "Hill" :key :hill :option-pack "P"}}}}'); + } catch (e) {} + }); + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + await page.goto(`${BASE}/dnd/5e/my-content`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.re_frame && cljs.core.get(cljs.core.deref(re_frame.db.app_db), + cljs.core.keyword('plugins')), + null, { timeout: 120000 }); + check('the app starts with its library', true); + + const notices = () => page.locator(`text=${MESSAGE}`).count(); + const marked = () => page.evaluate(() => + cljs.core.get(cljs.core.deref(re_frame.db.app_db), cljs.core.keyword('e2e-marker')) != null); + + await page.evaluate(() => { + const kw = cljs.core.keyword; + // writes a marker, then throws before finishing: the marker must not land + re_frame.core.reg_event_db(kw('e2e/broken'), (db) => { + cljs.core.assoc(db, kw('e2e-marker'), true); + throw new Error('a deliberate bug'); + }); + re_frame.core.reg_event_db(kw('e2e/works'), (db) => cljs.core.assoc(db, kw('e2e-after'), true)); + re_frame.core.dispatch(cljs.core.vector(kw('e2e/broken'))); + }); + await page.waitForTimeout(1000); + check('an event that throws shows the notice', (await notices()) === 1, String(await notices())); + check('and its change is not applied', !(await marked())); + + await page.evaluate(() => { + const kw = cljs.core.keyword; + re_frame.core.dispatch(cljs.core.vector(kw('e2e/broken'))); + re_frame.core.dispatch(cljs.core.vector(kw('e2e/works'))); + }); + await page.waitForTimeout(1000); + check('failing again straight away adds no second notice', (await notices()) <= 1, String(await notices())); + check('the next event still runs', await page.evaluate(() => + cljs.core.get(cljs.core.deref(re_frame.db.app_db), cljs.core.keyword('e2e-after')) === true)); + check('nothing escaped as an uncaught error', errors.length === 0, errors.join(' | ')); + + await browser.close(); + const failed = results.filter(r => !r.pass).length; + console.log(`\n${results.length - failed}/${results.length} checks passed`); + process.exit(failed ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/cljs/orcpub/dnd/e5/containment_test.cljs b/test/cljs/orcpub/dnd/e5/containment_test.cljs index 94cd30225..975b11b77 100644 --- a/test/cljs/orcpub/dnd/e5/containment_test.cljs +++ b/test/cljs/orcpub/dnd/e5/containment_test.cljs @@ -5,6 +5,10 @@ [orcpub.template :as t] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.classes :as classes5e] + [orcpub.dnd.e5.events :as events] + [re-frame.core] + [re-frame.interceptor] + [re-frame.registrar] [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.homebrew-guard :as guard] [orcpub.dnd.e5.spell-subs :as spell-subs])) @@ -48,3 +52,20 @@ (is (= #{::e5/races ::e5/subraces ::e5/backgrounds ::e5/feats ::e5/classes ::e5/subclasses ::e5/invocations ::e5/boons ::e5/selections ::e5/spells ::e5/languages} (set (keys (guard/conversions)))))) + +(deftest an-event-that-throws-is-noticed-once-and-changes-nothing + (let [dispatched (atom []) + err #(ex-info "re-frame" {:event-v [::broken %] :interceptor :db-handler :direction :before})] + (with-redefs [re-frame.core/dispatch #(swap! dispatched conj %)] + (events/event-failed (js/Error. "a bug") (err 1)) + (events/event-failed (js/Error. "a bug") (err 2)) + (is (= [[:show-error-message events/event-failed-message]] @dispatched) + "told once, however many times it fails in a row") + (events/event-failed (js/Error. "a bug") + (ex-info "re-frame" {:event-v [:show-error-message "x"]})) + (is (= 1 (count @dispatched)) "the notice failing does not notice itself")))) + +(deftest the-tests-keep-re-frames-handler + ;; so an event that throws in a test fails it; the app registers its own in core.cljs + (is (= re-frame.interceptor/default-error-handler + (re-frame.registrar/get-handler :error :event-handler)))) diff --git a/web/cljs/orcpub/core.cljs b/web/cljs/orcpub/core.cljs index 8ecff249c..3cc584712 100644 --- a/web/cljs/orcpub/core.cljs +++ b/web/cljs/orcpub/core.cljs @@ -13,7 +13,7 @@ [orcpub.user-agent :as user-agent] [cljs-http.client :as http] [clojure.string :as s] - [re-frame.core :refer [dispatch dispatch-sync subscribe]] + [re-frame.core :refer [dispatch dispatch-sync subscribe reg-event-error-handler]] [reagent.core :as r] [reagent.dom.client :as rdc] [goog.events]) @@ -62,6 +62,10 @@ ;; undo a load that worked. (boot-step "settle-loaded-library" #(dispatch [:orcpub.dnd.e5/settle-loaded-library])) +;; From here an event that throws is noticed and changes nothing, rather than rethrown: not +;; before, as the recovery above needs :initialize-db to throw. See events/event-failed. +(reg-event-error-handler events/event-failed) + ;; Startup builds nothing from homebrew, except the character template on a page that shows a ;; character (build-template, below). See homebrew-safety-net.md. From 724d305004cfc28d5833eb0574bb4c8671f27036 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 26 Oct 2026 18:06:34 +0000 Subject: [PATCH 16/31] A homebrew entry that cannot be drawn costs its own part of the page Each builder's form is drawn with every section in a render-guard (guarded-form, used by builder-page for all thirteen builders, which all return their form directly): a field that throws costs that section, and the rest still edits and saves. Each My Content list, each source row and each monster row has its own guard. The plugin race and subrace subscriptions realize their entries inside the guard; their modifiers are lazy, and a throw escaped it. The junk audit draws error boundaries as React does, and builders through guarded-form; what a guard catches is still a failure, marked "contained:", as junk should not reach a page. With the shape check off: builder and My Content crashes 184 -> 0, all contained; monster rows contained; the plugin race and subrace subscriptions no longer throw. With it on: 5, unchanged, nothing contained. boot_rescue_e2e's page crash now comes from the move/copy bar, which no row guard holds; breaking a source row no longer reaches the app root. containment_e2e (was event_error_notice_e2e) checks that a broken row shows its fallback and the page works. --- docs/branch-changelog.md | 3 ++ scripts/test/probe-baseline.json | 6 ++-- scripts/test/run-browser-probes.js | 2 +- src/cljs/orcpub/dnd/e5/spell_subs.cljs | 6 ++-- src/cljs/orcpub/dnd/e5/views.cljs | 31 ++++++++++++++++--- test/browser/boot_rescue_e2e.js | 19 ++++++------ ...error_notice_e2e.js => containment_e2e.js} | 29 +++++++++++++---- test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 30 +++++++++++++++--- 8 files changed, 97 insertions(+), 29 deletions(-) rename test/browser/{event_error_notice_e2e.js => containment_e2e.js} (69%) diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index d16b02e72..8ee4aea2b 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -48,6 +48,9 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB - **Site:** an action that fails now says so and changes nothing. Before, nothing visible happened, and the actions queued after it were dropped. +- **Homebrew:** a homebrew entry that cannot be drawn costs its own part of the page: one section + of its builder (the rest still edits and saves), one list or source in My Content, one row of + the monster list. Before, the whole page fell over. - **Homebrew:** a library with a content section stored as `true` no longer stops the app from starting. diff --git a/scripts/test/probe-baseline.json b/scripts/test/probe-baseline.json index 33ffe25ab..c8f23b200 100644 --- a/scripts/test/probe-baseline.json +++ b/scripts/test/probe-baseline.json @@ -96,8 +96,8 @@ "checks": 9, "seconds": 10 }, - "event_error_notice_e2e.js": { - "checks": 6, - "seconds": 20 + "containment_e2e.js": { + "checks": 10, + "seconds": 25 } } diff --git a/scripts/test/run-browser-probes.js b/scripts/test/run-browser-probes.js index 13e63ea9e..b1a43317c 100755 --- a/scripts/test/run-browser-probes.js +++ b/scripts/test/run-browser-probes.js @@ -62,7 +62,7 @@ const PROBES = [ { file: 'export_busy_retry_e2e.js', needs: 'busy-server' }, { file: 'boot_rescue_e2e.js', needs: 'server' }, { file: 'builder_card_export_e2e.js', needs: 'server' }, - { file: 'event_error_notice_e2e.js', needs: 'server' }, + { file: 'containment_e2e.js', needs: 'server' }, { file: 'fixes_persist_e2e.js', needs: 'server' }, { file: 'header_menus_e2e.js', needs: 'server' }, { file: 'homebrew_safety_net_e2e.js', needs: 'server' }, diff --git a/src/cljs/orcpub/dnd/e5/spell_subs.cljs b/src/cljs/orcpub/dnd/e5/spell_subs.cljs index 9f802c23a..bfbd91950 100644 --- a/src/cljs/orcpub/dnd/e5/spell_subs.cljs +++ b/src/cljs/orcpub/dnd/e5/spell_subs.cljs @@ -303,7 +303,8 @@ (fn [plugins _] (keep (fn [race] - (guard/guard-entry + ;; realized inside the guard: the modifiers are lazy, and a throw in them escaped it + (guard/guard-option ::e5/races race #(assoc % :modifiers @@ -319,7 +320,8 @@ (fn [plugins _] (keep (fn [subrace] - (guard/guard-entry + ;; realized inside the guard: the modifiers are lazy, and a throw in them escaped it + (guard/guard-option ::e5/subraces subrace #(assoc % :modifiers (concat (opt5e/plugin-modifiers (:props %) diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index b429ee067..570de7222 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -9571,7 +9571,10 @@ (doall (for [{:keys [type-name type-key icon add-event edit-event delete-event plural]} my-content-types] ^{:key type-name} - [my-content-type name plugin type-name type-key icon add-event edit-event delete-event plural q show?])) + ;; one list that cannot draw costs that list, not the source or the page + [render-guard {:source name :type type-name} + [my-content-type name plugin type-name type-key icon add-event edit-event delete-event + plural q show?]])) (let [missing (remove #(seq ((:type-key %) plugin)) my-content-types)] (when (seq missing) [add-content-menu name missing]))]])])))) @@ -9767,7 +9770,7 @@ (map (fn [[name plugin]] ^{:key name} - [my-content-item name plugin]) + [render-guard {:source name} [my-content-item name plugin]]) plugins)))]])) (defn quarantine-source-repair @@ -10429,6 +10432,25 @@ " \u201c" (if (keyword? target) (name target) target) "\u201d"))) ", which isn't in your library.")])]))) +(defn guarded-form + "`builder`'s form with each of its sections in a render-guard, so a field that throws while + drawing costs that section and the rest of the form still edits and saves; one that throws + before it has a form shows `item` in the fallback. Every builder returns its form directly + (none keeps state in a render function), so calling it here draws the same thing." + [item builder] + (try + (let [form (builder)] + (if (and (vector? form) (keyword? (first form))) + (let [[tag & more] form + [attrs children] (if (map? (first more)) [(first more) (rest more)] [nil more])] + (into (cond-> [tag] attrs (conj attrs)) + (map #(if (vector? %) [render-guard item %] %)) + children)) + form)) + (catch :default e + (js/console.error "A builder could not draw its form:" e) + [guard-fallback item e]))) + (defn builder-page [item-title reset-event save-event builder & [title]] ;; The draft event and the builder-item sub are both derived from save-event ;; (events/draft-event-for, events/builder-drafts), so the Export-draft hatch and the @@ -10450,7 +10472,7 @@ :icon "download" :on-click #(dispatch [export-draft-event])}] [:div - [builder] + (guarded-form item builder) ;; The key is the item's address, not a field an author fills in — so it sits after the form, ;; under a hairline, in a muted register. [item-key-row item save-event]]])) @@ -11283,7 +11305,8 @@ (map (fn [{:keys [name] :as monster}] ^{:key name} - [monster-list-item monster]) + ;; a monster that cannot draw costs its own row + [render-guard monster [monster-list-item monster]]) filtered-monsters))])) (defn clear-monsters-filter [] diff --git a/test/browser/boot_rescue_e2e.js b/test/browser/boot_rescue_e2e.js index 3ae981819..ca03924ad 100644 --- a/test/browser/boot_rescue_e2e.js +++ b/test/browser/boot_rescue_e2e.js @@ -113,8 +113,9 @@ async function rescues(page, label, expected = PLUGINS) { // The app boots fine and clears the control; only then does a render throw // into the app-root error boundary. This is the case a naive "remove on // mount" gets wrong, and the reason the control hides rather than deletes. - // reduce-kv is poisoned because My Content calls it per source on every - // render, so the next render is a real throw through React's own error path. + // The move/copy bar is poisoned: My Content draws it outside the per-source and per-list + // guards (which contain a throw to their own row), so the next render of the page is a real + // throw through React's own error path to the app root. { const { ctx, page } = await seeded(browser); await page.goto(BASE + '/dnd/5e/my-content', { waitUntil: 'domcontentloaded' }); @@ -128,20 +129,20 @@ async function rescues(page, label, expected = PLUGINS) { // so the rescue must hand back what is stored at the crash, not the seed. const storedBeforeCrash = await page.evaluate(() => localStorage.getItem('plugins')); - // Poison the function My Content calls while rendering each source row, so - // the next render is a genuine throw through React's own error path. + // Poison the bar My Content draws in select mode, so the next render of the page itself is + // a genuine throw through React's own error path. const poisoned = await page.evaluate(() => { var v = window.orcpub && window.orcpub.dnd && window.orcpub.dnd.e5 && window.orcpub.dnd.e5.views; - if (!v || !v.source_disabled_counts) return 'views ns unreachable'; - v.source_disabled_counts = function () { throw new Error('simulated render failure'); }; + if (!v || !v.relocate_bar) return 'views ns unreachable'; + v.relocate_bar = function () { throw new Error('simulated render failure'); }; return 'ok'; }); check('could poison a render-path fn', poisoned === 'ok', String(poisoned)); - // Force the next render of that row. - const expand = page.locator('text=expand').first(); - const clicked = await expand.click({ timeout: 10000 }).then(() => 'clicked') + // Turn select mode on, so the page draws the bar. + const toggle = page.locator('button.b-move').first(); + const clicked = await toggle.click({ timeout: 10000 }).then(() => 'clicked') .catch(e => e.message.split('\n')[0]); check('could force a re-render', clicked === 'clicked', String(clicked)); await page.waitForTimeout(2500); diff --git a/test/browser/event_error_notice_e2e.js b/test/browser/containment_e2e.js similarity index 69% rename from test/browser/event_error_notice_e2e.js rename to test/browser/containment_e2e.js index 63e2b3754..60d5704aa 100644 --- a/test/browser/event_error_notice_e2e.js +++ b/test/browser/containment_e2e.js @@ -1,17 +1,18 @@ // Overlays: suppressed (cookie notice and What's New). -// Does an event that throws in the real app tell the person, and change nothing? -// -// re-frame applies no effect of a handler that did not finish; events/event-failed, registered -// by core.cljs once startup is done, says so in the error notice instead of rethrowing (which -// dropped every queued event, the notice with them). See docs/HOMEBREW_GUARD.md, 5. +// Does a bug cost only the item it is in, in the real app? See docs/HOMEBREW_GUARD.md, 5. // +// Events: re-frame applies no effect of a handler that did not finish; events/event-failed, +// registered by core.cljs once startup is done, says so in the error notice instead of +// rethrowing (which dropped every queued event, the notice with them). // 1. the app starts with a library, so startup's recovery is not broken by the handler // 2. an event that throws shows the notice once, and its change is not applied // 3. the same event failing again straight away does not show a second notice // 4. the next event still runs: the queue was not dropped +// Drawing: each My Content source row is in a render-guard (React's error boundary). +// 5. a row that throws while drawing shows its fallback, and the page keeps working // // Needs: server (`lein e2e-server`). No login. -// Runs in: ~20s. +// Runs in: ~25s. const { chromium } = require('playwright'); const { findChrome } = require('./lib/find-chrome'); const { suppressOverlays } = require('./lib/orcbrew-import'); @@ -71,6 +72,22 @@ function check(name, pass, detail) { cljs.core.get(cljs.core.deref(re_frame.db.app_db), cljs.core.keyword('e2e-after')) === true)); check('nothing escaped as an uncaught error', errors.length === 0, errors.join(' | ')); + // ── a source row that throws while drawing ─────────────────────────────────── + const poisoned = await page.evaluate(() => { + const v = orcpub.dnd.e5.views; + if (!v.source_disabled_counts) return 'views ns unreachable'; + v.source_disabled_counts = function () { throw new Error('a deliberate drawing bug'); }; + return 'ok'; + }); + check('could break the row', poisoned === 'ok', poisoned); + // the row draws again when it is expanded + await page.locator('text=expand').first().click({ timeout: 10000 }); + await page.waitForTimeout(1500); + check('the row shows its fallback', (await page.locator('text=couldn’t be displayed').count()) > 0); + check('the page does not fall over', + (await page.locator('text=Something went wrong on this page').count()) === 0); + check('the rest of the page still works', await page.locator('button.b-export').isVisible()); + await browser.close(); const failed = results.filter(r => !r.pass).length; console.log(`\n${results.length - failed}/${results.length} checks passed`); diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index 9bdbab618..5093038e7 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -178,8 +178,16 @@ (sequential? v) (realize-each (filter homebrew? (items v))) :else (do (realize! v) nil)))) +(def ^:private contained + "The first error a render-guard caught while the current stage drew: shown on screen, but still + a failure here, as junk should not reach a page at all (the shape table missed a field)." + (atom nil)) + (defn- attempt [stage f] - (try (f) nil (catch :default e {:stage stage :error (msg e)}))) + (reset! contained nil) + (try (f) + (when-let [e @contained] {:stage stage :error (str "contained: " e)}) + (catch :default e {:stage stage :error (msg e)}))) (def ^:private storage-slots "What a pack can leave in browser storage." @@ -268,7 +276,20 @@ "Draw `x` the way Reagent would: a vector headed by a function is a child component, so call it (twice for a component that returns its render function) and draw what it returns." [x] - (cond (and (vector? x) (fn? (first x))) + (cond (and (vector? x) (= views/guard-fallback (first x))) + ;; the fallback a guard draws for what it caught, whichever guard + (let [[_ data e] x] + (swap! contained #(or % (msg e))) + (expand (views/guard-fallback data e))) + (and (vector? x) (= views/error-boundary (first x))) + ;; as React does: the child, or the fallback when drawing it throws. What it contains is + ;; drawn on screen, so it is not a failure + (let [[_ fallback child] x] + (try (expand child) + (catch :default e + (swap! contained #(or % (msg e))) + (expand (fallback e nil nil))))) + (and (vector? x) (fn? (first x))) (let [[f & args] x r (apply f args)] (expand (if (fn? r) (apply r args) r))) @@ -368,14 +389,15 @@ (for [m (try (doall @(rf/subscribe [::monsters5e/filtered-monsters])) (catch :default _ nil)) :when (homebrew? m)] - [(:key m) #(expand [views/monster-list-item m])])) + ;; drawn as the list draws each one, inside its guard + [(:key m) #(expand [views/render-guard m [views/monster-list-item m]])])) (for [[t es] (when builders? entries) :when (map? es) :let [[open builder] (builders t)] :when open [k e] es :when (map? e)] (do (covered! :drawn k) (some-> (once [:builder (hash e)] [:builder k] - #(do (rf/dispatch-sync [open e]) (expand [builder]))) + #(do (rf/dispatch-sync [open e]) (expand (views/guarded-form e builder)))) (assoc :stage [:builder k] :key k))))))) (defn- run-pack! From 3ee4ad5be871ca13e682efc12b1e45e2e15b4a9c Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 26 Oct 2026 18:23:01 +0000 Subject: [PATCH 17/31] The share banner names the shared homebrew it cannot use A shared entry that could not be read or broke was reported, but set-aside-broken-homebrew had nothing to move for it, so nobody was told; it is now kept in :shared-content-info :left-out, and the banner names it. A shared subrace or subclass whose race or class the link did not carry is never offered; the banner names those too, from library/orphans: the items whose missing link is their parent. Only that keeps an entry from being offered. The junk audit counted any link to nothing as a reason for an entry not to be offered; it now counts only a missing parent, as the banner does, and for share links too. That showed a homebrew subclass named "x" is never offered by any way in, which the looser rule hid: recorded as open in docs/HOMEBREW_GUARD.md. Guarded audit: 4, all that case. --- docs/HOMEBREW_GUARD.md | 21 +++++++++++- docs/branch-changelog.md | 3 ++ src/cljc/orcpub/dnd/e5/library.cljc | 12 +++++++ src/cljs/orcpub/dnd/e5/events.cljs | 16 ++++++--- src/cljs/orcpub/dnd/e5/subs.cljs | 9 +++++ .../orcpub/dnd/e5/views/notifications.cljs | 18 ++++++++-- test/cljc/orcpub/dnd/e5/library_test.cljc | 10 ++++++ test/cljs/orcpub/dnd/e5/containment_test.cljs | 33 ++++++++++++++++++- test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 13 +++++--- 9 files changed, 122 insertions(+), 13 deletions(-) diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index 15b99df2a..2ce3fcb10 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -346,7 +346,26 @@ alone 0.001 ms; an event that changes one entry 0.05 ms, then its deep check 35 parent does not exist (4), and a subclass named "x" (1, not yet traced). Found on the way: `class-option` merges homebrew subclasses into a set sorted by name, so a homebrew subclass with the same name as another (built in, or from another source) is dropped without a word. -6. **Containment:** per-item boundaries, the event error handler, the guard on 13 types. +6. **Done:** containment. + - **Events:** `events/event-failed`, registered by `core.cljs` after the startup steps (whose + recovery needs `:initialize-db` to throw): an event that throws shows the error notice and + changes nothing; re-frame's own handler rethrew, dropping every queued event. + - **Character options:** each built-in class is built on its own, and a homebrew subclass, + invocation or boon that breaks it is set aside by itself (before, all twelve classes went); + each subrace has its own guard inside its race. The deep check covers the eleven types that + become options; monsters and encounters become none. + - **Pages:** each builder section, My Content list, source row and monster row has a + render-guard. With the shape check off, 184 page crashes are now contained; with it on, none + is reached. What still throws with the check off is the template building options lazily: + the deep check at each write and the page fallback hold that, as designed. + - **Share links:** the banner names what the link carried that cannot be used: entries that + could not be read or broke (reported, with nothing to set aside), and subraces or subclasses + whose race or class the link did not carry (`library/orphans`). Only a missing parent keeps an + entry from being offered; the audit counted any link to nothing until now. + - **Open:** a homebrew subclass named "x" (or whose missing name an import fills) is never + offered, by any way in; its class is there. The audit's former allowances hid it. Suspected, + not shown: `class-option` merges subclasses into a set sorted by name. Guarded audit: 4, all + this case. 7. **Enforcement:** the audit in CI at zero, the AGENTS.md rules, the documents corrected, the testing/develop scenarios gone through. 8. **grant-rows** merges this, adds its fields to the table, and runs the audit. diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index 8ee4aea2b..2eff7288f 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -48,6 +48,9 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB - **Site:** an action that fails now says so and changes nothing. Before, nothing visible happened, and the actions queued after it were dropped. +- **Sharing:** the banner for a shared character names the homebrew it carried that cannot be + used: entries that could not be read, and subraces or subclasses whose race or class was not + shared. - **Homebrew:** a homebrew entry that cannot be drawn costs its own part of the page: one section of its builder (the rest still edits and saves), one list or source in My Content, one row of the monster list. Before, the whole page fell over. diff --git a/src/cljc/orcpub/dnd/e5/library.cljc b/src/cljc/orcpub/dnd/e5/library.cljc index 94bd26e3f..d9010efa6 100644 --- a/src/cljc/orcpub/dnd/e5/library.cljc +++ b/src/cljc/orcpub/dnd/e5/library.cljc @@ -214,6 +214,18 @@ (into {} (map (fn [[path t]] [t (offered-at template path)])) pick-homes) built-in-keys)) +(def parent-links + "The links whose target an entry is built inside, so with a target missing it is not offered: + a subclass's class, a subrace's race. Any other link to nothing leaves the entry offered." + #{:subclass->class :subrace->race}) + +(defn orphans + "The [source type key] of each item in `dangling` (from `dangling`) whose parent is missing." + [dangling] + (set (for [[address missing] dangling + :when (some #(contains? parent-links (:link %)) missing)] + address))) + (defn dangling "{[source type key] [{:link :target}]} for every item in `plugins` with a link to nothing: neither `offered` (from `offered-by-type`) nor a library item of the link's type holds the diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 984fd4f95..6385a2754 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -6694,10 +6694,18 @@ (reg-event-fx ::e5/set-aside-broken-homebrew (fn [{:keys [db]} [_ reports]] - (let [{:keys [plugins rejected moved]} - (set-aside-broken-entries (:plugins db) (get-rejected-plugins) - (concat reports (:homebrew-broken db))) - db (dissoc db :homebrew-broken)] + (let [all-reports (concat reports (:homebrew-broken db)) + {:keys [plugins rejected moved]} + (set-aside-broken-entries (:plugins db) (get-rejected-plugins) all-reports) + ;; one from a share link has nothing to set aside: the share banner names it instead + moved? (set (map (juxt :content-type :key) moved)) + left-out (for [{:keys [content-type key name source option-pack]} all-reports + :when (and (contains? (:shared-plugins db) (or source option-pack)) + (not (moved? [content-type key])))] + {:content-type content-type :key key :name name}) + db (cond-> (dissoc db :homebrew-broken) + (and (seq left-out) (:shared-content-info db)) + (update-in [:shared-content-info :left-out] (fnil into #{}) left-out))] (if (empty? moved) {:db db} ;; The set-aside copy first, as the loader does: if storage refuses it, nothing moves, diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index 238bdeddc..41078191e 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -1891,6 +1891,15 @@ (fn [[plugins offered] _] (library/dangling plugins offered))) +;; The same for a share link's content, which My Content does not show: the share banner names +;; these, as they are not offered. +(reg-sub + :orcpub.dnd.e5/shared-dangling + :<- [:orcpub.dnd.e5/shared-plugins] + :<- [::content-recon/offered-by-type] + (fn [[shared offered] _] + (library/dangling shared offered))) + (reg-sub ::content-recon/offered-keys (fn [db _] diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs index bd0c8d43b..cb8493602 100644 --- a/src/cljs/orcpub/dnd/e5/views/notifications.cljs +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -7,7 +7,8 @@ (:require [re-frame.core :refer [subscribe dispatch]] [clojure.string :as s] [orcpub.dnd.e5 :as e5] - [orcpub.dnd.e5.character :as char])) + [orcpub.dnd.e5.character :as char] + [orcpub.dnd.e5.library :as library])) (defn- as-parts "Normalise a producer's message into {:title :details}, or {:body hiccup}. @@ -125,8 +126,13 @@ flags entries that collide by name with the viewer's own content (the shared version wins on this sheet, their copy is untouched). Renders nothing when there's no shared content." [id] - (when-let [{:keys [count item-count collisions]} @(subscribe [::e5/shared-content-info])] + (when-let [{:keys [count item-count collisions left-out]} @(subscribe [::e5/shared-content-info])] (let [char-name @(subscribe [::char/character-name id]) + shared @(subscribe [::e5/shared-plugins]) + ;; never silent: what the link carried that cannot be used here is named + dangling (for [[src ct k] (library/orphans @(subscribe [::e5/shared-dangling]))] + (or (get-in shared [src ct k :name]) (name k))) + names (fn [xs] (str " (" (s/join ", " (take 4 xs)) (when (> (clojure.core/count xs) 4) ", …") ")")) n-coll (clojure.core/count collisions) item-count (or item-count 0) parts (cond-> [] @@ -145,6 +151,14 @@ (when-let [names (seq (map :name (take 4 collisions)))] (str " (" (s/join ", " names) (when (> n-coll 4) ", …") ")")) ".")]) + (when (seq left-out) + [:div.f-s-12.m-t-5 {:style {:color "#d9a520"}} + (str (clojure.core/count left-out) " could not be read, and are left out" + (names (map #(or (:name %) (name (:key %))) left-out)) ".")]) + (when (seq dangling) + [:div.f-s-12.m-t-5 {:style {:color "#d9a520"}} + (str (clojure.core/count dangling) " belong to a race or class the link did not carry, " + "so they are not offered" (names dangling) ".")]) (when (pos? item-count) [:div.f-s-12.m-t-5 {:style {:opacity 0.8}} "Custom magic items are shown for this view only (keeping items isn't supported yet)."])] diff --git a/test/cljc/orcpub/dnd/e5/library_test.cljc b/test/cljc/orcpub/dnd/e5/library_test.cljc index f8ddac79c..d7794a76d 100644 --- a/test/cljc/orcpub/dnd/e5/library_test.cljc +++ b/test/cljc/orcpub/dnd/e5/library_test.cljc @@ -254,3 +254,13 @@ (is (= [:orcpub.dnd.e5/backgrounds] (library/pick-types [:background] types))) (is (= [:orcpub.dnd.e5/feats] (library/pick-types [:class :background] types)) "a class's own choice named :background is not the background"))) + +(deftest only-a-missing-parent-makes-an-orphan + ;; a link to a missing spell or language leaves the entry offered; a missing race or class + ;; does not + (is (= #{["P" :orcpub.dnd.e5/subraces :hill-kin]} + (library/orphans + {["P" :orcpub.dnd.e5/subraces :hill-kin] [{:link :subrace->race :to :orcpub.dnd.e5/races + :target :hill}] + ["P" :orcpub.dnd.e5/subclasses :oath] [{:link :class->own-list + :to :orcpub.dnd.e5/spells :target :zap}]})))) diff --git a/test/cljs/orcpub/dnd/e5/containment_test.cljs b/test/cljs/orcpub/dnd/e5/containment_test.cljs index 975b11b77..f17c78672 100644 --- a/test/cljs/orcpub/dnd/e5/containment_test.cljs +++ b/test/cljs/orcpub/dnd/e5/containment_test.cljs @@ -9,9 +9,11 @@ [re-frame.core] [re-frame.interceptor] [re-frame.registrar] + [re-frame.db] [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.homebrew-guard :as guard] - [orcpub.dnd.e5.spell-subs :as spell-subs])) + [orcpub.dnd.e5.spell-subs :as spell-subs] + [orcpub.dnd.e5.views.notifications :as notifications])) (deftest an-invocation-that-breaks-the-warlock-costs-only-itself (let [reports (atom []) @@ -69,3 +71,32 @@ ;; so an event that throws in a test fails it; the app registers its own in core.cljs (is (= re-frame.interceptor/default-error-handler (re-frame.registrar/get-handler :error :event-handler)))) + +(deftest a-shared-entry-that-fails-is-named-in-the-share-banner + ;; it is not in the library, so there is nothing to set aside: the banner names it instead + (let [was @re-frame.db/app-db] + (try + (reset! re-frame.db/app-db {:plugins {} + :shared-plugins {"Shared Pak" {::e5/races {:ok {:name "Ok"}}}} + :shared-content-info {:count 1}}) + (re-frame.core/dispatch-sync + [::e5/set-aside-broken-homebrew + [{:content-type ::e5/races :key :bad :name "Bad" :source "Shared Pak"}]]) + (is (= #{{:content-type ::e5/races :key :bad :name "Bad"}} + (get-in @re-frame.db/app-db [:shared-content-info :left-out]))) + (finally + (re-frame.core/purge-event-queue) + (reset! re-frame.db/app-db was))))) + +(deftest the-share-banner-names-what-it-cannot-use + (let [was @re-frame.db/app-db + text (fn [] (pr-str (notifications/shared-content-banner nil)))] + (try + (reset! re-frame.db/app-db + {:shared-plugins {"S" {::e5/subraces {:kin {:name "Hill Kin" :key :kin :race :hill}}}} + :shared-content-info {:count 1 :left-out #{{:content-type ::e5/races :key :bad + :name "Bad Race"}}} + :orcpub.dnd.e5.content-reconciliation/offered-by-type {::e5/races #{:dwarf}}}) + (is (re-find #"1 could not be read, and are left out \(Bad Race\)" (text))) + (is (re-find #"1 belong to a race or class the link did not carry.*\(Hill Kin\)" (text))) + (finally (reset! re-frame.db/app-db was))))) diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index 5093038e7..39298641b 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -444,17 +444,20 @@ #(homebrew-check/deep-check nil))) template (try @(rf/subscribe [::char5e/template]) (catch :default _ nil)) ;; an entry set aside (reported, or in the quarantine panel) is not offered, and is - ;; allowed, as is one My Content marks as a link to nothing (only stored content is - ;; shown there); one silently not offered is lost + ;; allowed, as is one marked as a link to nothing (in My Content, or for a share + ;; link in its banner); one silently not offered is lost set-aside (into (into (set (map :key @reports)) (for [[_ section] (get (:quarantined-plugins @app-db) source) :when (map? section) k (keys section)] k)) - (when (and template (not= via :share)) + (when template ;; reads every option, so a throw in one is the stages' to report - (try (map last (keys (library/dangling - (:plugins @app-db) + ;; only a missing parent keeps an entry from being offered + (try (map last (library/orphans (library/dangling + (get @app-db (if (= via :share) + :shared-plugins + :plugins)) (library/offered-by-type template)))) (catch :default _ nil)))) entry (fn [k] (some #(when (map? %) (get % k)) (vals entries))) From 0303dc4c9bd8c7dc51a876e33cabf34f6878598f Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 26 Oct 2026 18:46:33 +0000 Subject: [PATCH 18/31] Plain words in what people read about set-aside homebrew The set-aside notice said an entry "stopped the character options from loading", which was jargon and wrong when the reason was a field that could not be read; it now says the entry "couldn't be used", true for both. The share banner says entries "can't be picked in the character builder", not that they "are not offered"; the junk audit's label and the branch changelog use the same plain words. --- docs/branch-changelog.md | 6 +++--- src/cljs/orcpub/dnd/e5/events.cljs | 3 ++- src/cljs/orcpub/dnd/e5/views/notifications.cljs | 2 +- test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 8 ++++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index 2eff7288f..84a78fe56 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -65,10 +65,10 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB builder. - **Homebrew:** a homebrew subrace, pact boon or eldritch invocation that was renamed, or given a new key on import to settle a conflict, stays picked on the characters that chose it. -- **Homebrew:** a background, feat or class granting a skill that does not exist no longer stops - the character options from loading. +- **Homebrew:** a background, feat or class granting a skill that does not exist no longer breaks + the character builder. - **Homebrew:** a homebrew subclass, invocation or boon that breaks a built-in class costs only - itself: it is set aside, named, and the class is still offered. Before, every built-in class + itself: it is set aside, named, and the class is still in the builder. Before, every built-in class disappeared from the builder. A subrace that breaks is set aside on its own, not with its race. ## Security diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 6385a2754..8a1210da3 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -6668,7 +6668,8 @@ {:title (if (= 1 n) "A homebrew entry was set aside" (str n " homebrew entries were set aside")) :details [(str (s/join ", " (map label (take 3 moved))) (when (> n 3) (str " and " (- n 3) " more")) - " stopped the character options from loading, so " + ;; true whether a field could not be read or the entry broke the builder + " couldn't be used, so " (if (= 1 n) "it was" "they were") " set aside. Everything else still works.") [:span.pointer.underline.orange diff --git a/src/cljs/orcpub/dnd/e5/views/notifications.cljs b/src/cljs/orcpub/dnd/e5/views/notifications.cljs index cb8493602..aac1974c3 100644 --- a/src/cljs/orcpub/dnd/e5/views/notifications.cljs +++ b/src/cljs/orcpub/dnd/e5/views/notifications.cljs @@ -158,7 +158,7 @@ (when (seq dangling) [:div.f-s-12.m-t-5 {:style {:color "#d9a520"}} (str (clojure.core/count dangling) " belong to a race or class the link did not carry, " - "so they are not offered" (names dangling) ".")]) + "so they can't be picked in the character builder" (names dangling) ".")]) (when (pos? item-count) [:div.f-s-12.m-t-5 {:style {:opacity 0.8}} "Custom magic items are shown for this view only (keeping items isn't supported yet)."])] diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index 39298641b..339deb971 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -261,13 +261,13 @@ (defn- use-entry! "Pick homebrew entry `k` in `c`, build it, and draw what the builder draws under it: every - selection whose path runs through `k`, fully realized. Throws when `k` is never offered." + selection whose path runs through `k`, fully realized. Throws when `k` is not in the character builder." [template k c & [entry]] (let [ks #{k} c (pick template c ks) [built avail] (offered template c)] (when (and k (not (some ks (tree-seq coll? seq c)))) - (throw (js/Error. "never offered"))) + (throw (js/Error. "not in the character builder"))) (when k (covered! :picked k)) (realize! (char5e/ability-values built)) (realize! (filter #(some ks (entity/actual-path %)) avail)))) @@ -470,7 +470,7 @@ (some-> r (assoc :stage [:character k]) (cond-> k (assoc :key k))))) builds (when (and template (not light?)) (doall - (remove #(and (= "never offered" (:error %)) (set-aside (second (:stage %)))) + (remove #(and (= "not in the character builder" (:error %)) (set-aside (second (:stage %)))) (keep build (cons [nil stored-character] (seeds entries)))))) drawn (doall (pages entries (not light?)))] (doall (remove nil? (concat [boot shared] sub-fails [built-ins deep] builds drawn @@ -561,7 +561,7 @@ #(and (= :junk-races (:key %)) (= "canary" (:error %)))))) (testing "a race that silently goes missing" (is (caught? (with-race-option (constantly nil) canary-run) - #(= {:stage [:character :junk-races] :error "never offered"} + #(= {:stage [:character :junk-races] :error "not in the character builder"} (select-keys % [:stage :error]))))) (testing "an entry set aside that is not the pack's own" (is (caught? (with-race-option #(do (guard/report! {:content-type ::e5/races :key :elf}) %) From 16ade191d6ec8fd23d1775c04e41c357818e902a Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 26 Oct 2026 19:02:08 +0000 Subject: [PATCH 19/31] Move the view guards out of views.cljs into views/guards.cljs No behaviour change. error-boundary, locate-hint, guard-fallback, render-guard, isolate-culprit, guarded-feature-list, app-error-fallback and guarded-form are one family: what keeps a broken item from taking its page down. views.cljs refers the four it uses; core.cljs and the junk audit name the new namespace. views.cljs 11,557 -> 11,342 lines; guards.cljs 224. --- src/cljs/orcpub/dnd/e5/views.cljs | 219 +------------------ src/cljs/orcpub/dnd/e5/views/guards.cljs | 224 ++++++++++++++++++++ test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 11 +- web/cljs/orcpub/core.cljs | 5 +- 4 files changed, 235 insertions(+), 224 deletions(-) create mode 100644 src/cljs/orcpub/dnd/e5/views/guards.cljs diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 570de7222..d0bd09811 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -60,6 +60,8 @@ [orcpub.fork.user-tier] [orcpub.ver :as v] [clojure.string :as s] + [orcpub.dnd.e5.views.guards :refer [error-boundary render-guard guarded-feature-list + guarded-form]] [orcpub.artist-links :as artist-links] [goog.crypt.base64 :as b64] [cljs.reader :as reader] @@ -2470,127 +2472,6 @@ {:padding "33px 0"}) -(declare error-boundary) - -(defn locate-hint - "Best-effort human locator for a malformed feature, built only from fields that - actually point at a place in the builder — which class/subclass or homebrew - source it came from, and the level it's gained. Never its :name (usually the - missing part), and never its :page: the page is a physical-rulebook reference, - not a place to fix anything in the app. Returns nil when there's nothing - builder-locating to go on (the raw data dump still shows the page etc.)." - [{:keys [class-key source level]}] - (let [where (cond - (string? source) (str "the “" source "” source") - source (str "the “" (common/kw-to-name source) "” source") - class-key (str "your " (s/capitalize (common/kw-to-name class-key))) - :else nil) - bits (cond-> [] - where (conj (str "from " where)) - level (conj (str "gained at level " level)))] - (when (seq bits) (s/join ", " bits)))) - -(defn guard-fallback - "Inline fallback for a single item that threw while rendering: say where it - likely came from (so the user can find and fix it in the builder) and dump its - raw data so the exact malformed value is visible. No per-bug detector required; - works for whatever was being rendered." - [data _error] - (let [hint (try (locate-hint data) (catch :default _ nil))] - [:div.m-t-10.p-5.f-s-12 - {:style {:border "1px dashed #e9a227" :border-radius "4px"}} - [:div.f-w-b "⚠ A feature here couldn’t be displayed"] - (when hint [:div.m-t-2 (str "Likely " hint " — open it in the builder to fix.")]) - [:pre.wsp-prw.f-s-11.m-t-5 (pr-str data)]])) - -(defn render-guard - "Wrap one piece of UI so a render exception is contained to just this item - (showing its raw data via guard-fallback) instead of blanking the whole page. - General — catches any error type, not a specific known failure mode." - [data child] - [error-boundary - (fn [error _stack _retry] [guard-fallback data error]) - child]) - -(defn- without-nth - "items with index i removed (nil-safe, unlike keep-indexed)." - [items i] - (into (subvec items 0 i) (subvec items (inc i)))) - -(defn isolate-culprit - "Fault isolation by re-execution, the active counterpart to render-guard. `render-coll` - processes the collection `items` and may throw. Returns the item(s) whose removal stops - it throwing -- catching aggregate failures (a sort over a nil key) a per-item guard never - sees -- or nil if it doesn't throw. GOTCHA: with two independent faulty items neither is - necessary, so it falls back to the smallest failing subset found by bisection." - [render-coll items] - (letfn [(throws? [coll] - (try (doall (render-coll coll)) false (catch :default _ true))) - (self-bad [coll] - ;; items that still break when paired with a copy of themselves — i.e. - ;; that individually carry the bad value, distinct from an innocent - ;; neighbour only implicated because the failure needs ≥2 elements. - (filterv #(throws? [% %]) coll)) - (bisect [its] - (if (<= (count its) 1) - its - (let [mid (quot (count its) 2) - l (subvec its 0 mid) r (subvec its mid)] - (cond (throws? l) (recur l) - (throws? r) (recur r) - :else its))))] - (let [items (vec items)] - (when (throws? items) - (let [necessary (mapv items (filterv #(not (throws? (without-nth items %))) - (range (count items))))] - (cond - (= 1 (count necessary)) necessary - ;; >1 "necessary" is the comparison-failure trap: removing ANY of a - ;; small set drops below the ≥2 elements the comparator needs, so each - ;; looks necessary. Keep only the ones that break paired with themselves. - (seq necessary) (let [sb (self-bad necessary)] - (if (seq sb) sb necessary)) - ;; nothing individually necessary (independent multi-fault): the items - ;; that break self-paired, else bisect to a smallest failing subset. - :else (let [sb (self-bad items)] - (if (seq sb) sb (bisect items))))))))) - -(defn guarded-feature-list - "Render `items` via `render-coll`. If the whole render throws, LEAD with the - problem — surface the item(s) the isolator traced the failure to, with where to - fix them — then render everything that DID work below. This is the auditing - version of render-guard: it handles failures in the collection-level logic, not - just per-item render throws." - [render-coll items] - ;; Render once: the realized result is reused on the happy path, so the sort and - ;; hiccup build don't run twice on every render — only the error path pays extra. - (let [rendered (try (doall (render-coll items)) (catch :default _ ::failed))] - (if-not (= rendered ::failed) - (into [:<>] rendered) - (let [culprits (isolate-culprit render-coll items) - culprit? (set culprits) - survivors (remove culprit? items)] - [:div - ;; the problem, first and prominent — that's the thing the user must act on - [:div.p-10.m-b-10.f-s-14 - {:style {:border "1px solid #e9a227" :border-radius "5px" - :background "rgba(233,162,39,0.12)"}} - [:div.f-w-b.f-s-16 - (if (= 1 (count culprits)) - "⚠ A feature in this section couldn’t be displayed" - (str "⚠ " (count culprits) " features in this section couldn’t be displayed"))] - (doall - (map-indexed - (fn [i c] - (let [hint (try (locate-hint c) (catch :default _ nil))] - ^{:key i} - [:div.m-t-5 - (when hint [:div.f-w-600 (str "Likely " hint " — open it in the builder to fix.")]) - [:pre.wsp-prw.f-s-11.m-t-2 {:style {:user-select "text"}} (pr-str c)]])) - culprits))] - ;; everything that rendered fine, below - (when (seq survivors) (render-coll survivors))])))) - (defn- vec-remove [v i] (vec (concat (subvec v 0 i) (subvec v (inc i))))) @@ -4586,83 +4467,6 @@ ;; selectable + copy-button so the user can actually grab it [:pre.f-s-12.m-t-5.wsp-prw {:style {:user-select "text"}} report]])]])))) -(defn error-boundary - "React error boundary: if a child throws while rendering, renders - (fallback error component-stack retry) instead of unmounting the whole app. Key it on its - content (e.g. the selected tab) so navigating away clears the error. - GOTCHA: only getDerivedStateFromError re-renders into the fallback; component-did-catch - logs the component stack and stores it for the fallback." - [_fallback _child] - (r/create-class - {:display-name "error-boundary" - :constructor (fn [this _props] - (set! (.-state this) #js {:error nil :componentStack nil})) - :get-derived-state-from-error (fn [error] #js {:error error}) - :component-did-catch (fn [this e info] - (let [stack (some-> info .-componentStack)] - (js/console.error "[character render error]" e stack) - ;; Stash the component stack so the recovery panel can show - ;; *which* component threw — the only location info we can - ;; offer for a failure we didn't anticipate. - (.setState this #js {:componentStack stack}))) - :reagent-render - (fn [fallback child] - (let [this (r/current-component)] - (if-let [e (.. this -state -error)] - (fallback e - (.. this -state -componentStack) - ;; Reagent re-renders on new props, not on React state alone, so - ;; clearing the error has to force the redraw as well. - #(.setState this #js {:error nil :componentStack nil} - (fn [] (.forceUpdate this)))) - child)))})) - -(defn app-error-fallback - "Top-level catch-all shown when ANY page throws below the app-root boundary — - the guarantee that the app never black-screens (goal #1). Generic by design (it - can be any page): say something broke, reassure the data is safe, offer recovery, - and expose the error for a report. Finer boundaries below this one give better, - more specific messages where they apply; this is the last line of defense." - [_error _stack retry] - ;; Bring the boot-shell rescue control back: the app rendered cleanly once and - ;; took it away, then this page threw. Getting homebrew out matters most in - ;; exactly the state where the app has stopped being able to export it. - (when-let [rescue (aget js/window "orcpubBootRescue")] - (rescue)) - ;; The page may have failed on homebrew that only breaks once it is drawn. Check every - ;; entry deeply; if any are set aside, try the page again. - (js/setTimeout #(when (seq (homebrew-check/check-and-set-aside! nil)) - (js/setTimeout retry 500)) - 0) - (let [show? (r/atom false) - copied? (r/atom false)] - (fn [error stack retry] - (let [report (str (or (some-> error .-stack) (str error)) - (when stack (str "\n\nComponent stack:" stack)))] - [:div.p-20.f-s-14.main-text-color - [:div.f-w-b.f-s-24.m-b-10 "Something went wrong on this page"] - [:div.m-b-15.l-h-19 - "This page hit an unexpected error and couldn’t finish loading. Your saved " - "data wasn’t affected. Head back to your characters, or reload to try again — " - "if it keeps happening, copy the details below and send them to us."] - [:div.flex.flex-wrap.align-items-c - [:button.form-button.m-r-10.m-b-5 - {:on-click #(set! (.-href js/window.location) "/pages/dnd/5e/characters")} - "Go to my characters"] - [:button.form-button.m-b-5 {:on-click #(.reload js/window.location)} "Reload page"]] - [:div.m-t-10 - [:span.orange.underline.pointer - {:on-click #(swap! show? not)} - (if @show? "Hide details" "Show details")] - (when @show? - [:div - [:button.form-button.f-s-11.m-t-5 - {:on-click (fn [] - (some-> js/navigator .-clipboard (.writeText report)) - (reset! copied? true))} - (if @copied? "Copied!" "Copy")] - [:pre.f-s-12.m-t-5.wsp-prw {:style {:user-select "text"}} report]])]])))) - (defn has-nameless-feature? "Does the built character still carry a feature/action/reaction with no usable name? Used as the re-test for selection-isolation on the proactive banner." @@ -10432,25 +10236,6 @@ " \u201c" (if (keyword? target) (name target) target) "\u201d"))) ", which isn't in your library.")])]))) -(defn guarded-form - "`builder`'s form with each of its sections in a render-guard, so a field that throws while - drawing costs that section and the rest of the form still edits and saves; one that throws - before it has a form shows `item` in the fallback. Every builder returns its form directly - (none keeps state in a render function), so calling it here draws the same thing." - [item builder] - (try - (let [form (builder)] - (if (and (vector? form) (keyword? (first form))) - (let [[tag & more] form - [attrs children] (if (map? (first more)) [(first more) (rest more)] [nil more])] - (into (cond-> [tag] attrs (conj attrs)) - (map #(if (vector? %) [render-guard item %] %)) - children)) - form)) - (catch :default e - (js/console.error "A builder could not draw its form:" e) - [guard-fallback item e]))) - (defn builder-page [item-title reset-event save-event builder & [title]] ;; The draft event and the builder-item sub are both derived from save-event ;; (events/draft-event-for, events/builder-drafts), so the Export-draft hatch and the diff --git a/src/cljs/orcpub/dnd/e5/views/guards.cljs b/src/cljs/orcpub/dnd/e5/views/guards.cljs new file mode 100644 index 000000000..e66cd164e --- /dev/null +++ b/src/cljs/orcpub/dnd/e5/views/guards.cljs @@ -0,0 +1,224 @@ +(ns orcpub.dnd.e5.views.guards + "What keeps one broken thing from taking a page down with it: React's error boundary, the + per-item render-guard and its fallback, the feature list that traces a failure to the item + that caused it, a builder's form with each section guarded, and the app-root fallback, the + last resort. See docs/HOMEBREW_GUARD.md, 5." + (:require [reagent.core :as r] + [clojure.string :as s] + [orcpub.common :as common] + [orcpub.dnd.e5.homebrew-check :as homebrew-check])) + +(defn error-boundary + "React error boundary: if a child throws while rendering, renders + (fallback error component-stack retry) instead of unmounting the whole app. Key it on its + content (e.g. the selected tab) so navigating away clears the error. + GOTCHA: only getDerivedStateFromError re-renders into the fallback; component-did-catch + logs the component stack and stores it for the fallback." + [_fallback _child] + (r/create-class + {:display-name "error-boundary" + :constructor (fn [this _props] + (set! (.-state this) #js {:error nil :componentStack nil})) + :get-derived-state-from-error (fn [error] #js {:error error}) + :component-did-catch (fn [this e info] + (let [stack (some-> info .-componentStack)] + (js/console.error "[character render error]" e stack) + ;; Stash the component stack so the recovery panel can show + ;; *which* component threw — the only location info we can + ;; offer for a failure we didn't anticipate. + (.setState this #js {:componentStack stack}))) + :reagent-render + (fn [fallback child] + (let [this (r/current-component)] + (if-let [e (.. this -state -error)] + (fallback e + (.. this -state -componentStack) + ;; Reagent re-renders on new props, not on React state alone, so + ;; clearing the error has to force the redraw as well. + #(.setState this #js {:error nil :componentStack nil} + (fn [] (.forceUpdate this)))) + child)))})) + +(defn locate-hint + "Best-effort human locator for a malformed feature, built only from fields that + actually point at a place in the builder — which class/subclass or homebrew + source it came from, and the level it's gained. Never its :name (usually the + missing part), and never its :page: the page is a physical-rulebook reference, + not a place to fix anything in the app. Returns nil when there's nothing + builder-locating to go on (the raw data dump still shows the page etc.)." + [{:keys [class-key source level]}] + (let [where (cond + (string? source) (str "the “" source "” source") + source (str "the “" (common/kw-to-name source) "” source") + class-key (str "your " (s/capitalize (common/kw-to-name class-key))) + :else nil) + bits (cond-> [] + where (conj (str "from " where)) + level (conj (str "gained at level " level)))] + (when (seq bits) (s/join ", " bits)))) + +(defn guard-fallback + "Inline fallback for a single item that threw while rendering: say where it + likely came from (so the user can find and fix it in the builder) and dump its + raw data so the exact malformed value is visible. No per-bug detector required; + works for whatever was being rendered." + [data _error] + (let [hint (try (locate-hint data) (catch :default _ nil))] + [:div.m-t-10.p-5.f-s-12 + {:style {:border "1px dashed #e9a227" :border-radius "4px"}} + [:div.f-w-b "⚠ A feature here couldn’t be displayed"] + (when hint [:div.m-t-2 (str "Likely " hint " — open it in the builder to fix.")]) + [:pre.wsp-prw.f-s-11.m-t-5 (pr-str data)]])) + +(defn render-guard + "Wrap one piece of UI so a render exception is contained to just this item + (showing its raw data via guard-fallback) instead of blanking the whole page. + General — catches any error type, not a specific known failure mode." + [data child] + [error-boundary + (fn [error _stack _retry] [guard-fallback data error]) + child]) + +(defn- without-nth + "items with index i removed (nil-safe, unlike keep-indexed)." + [items i] + (into (subvec items 0 i) (subvec items (inc i)))) + +(defn isolate-culprit + "Fault isolation by re-execution, the active counterpart to render-guard. `render-coll` + processes the collection `items` and may throw. Returns the item(s) whose removal stops + it throwing -- catching aggregate failures (a sort over a nil key) a per-item guard never + sees -- or nil if it doesn't throw. GOTCHA: with two independent faulty items neither is + necessary, so it falls back to the smallest failing subset found by bisection." + [render-coll items] + (letfn [(throws? [coll] + (try (doall (render-coll coll)) false (catch :default _ true))) + (self-bad [coll] + ;; items that still break when paired with a copy of themselves — i.e. + ;; that individually carry the bad value, distinct from an innocent + ;; neighbour only implicated because the failure needs ≥2 elements. + (filterv #(throws? [% %]) coll)) + (bisect [its] + (if (<= (count its) 1) + its + (let [mid (quot (count its) 2) + l (subvec its 0 mid) r (subvec its mid)] + (cond (throws? l) (recur l) + (throws? r) (recur r) + :else its))))] + (let [items (vec items)] + (when (throws? items) + (let [necessary (mapv items (filterv #(not (throws? (without-nth items %))) + (range (count items))))] + (cond + (= 1 (count necessary)) necessary + ;; >1 "necessary" is the comparison-failure trap: removing ANY of a + ;; small set drops below the ≥2 elements the comparator needs, so each + ;; looks necessary. Keep only the ones that break paired with themselves. + (seq necessary) (let [sb (self-bad necessary)] + (if (seq sb) sb necessary)) + ;; nothing individually necessary (independent multi-fault): the items + ;; that break self-paired, else bisect to a smallest failing subset. + :else (let [sb (self-bad items)] + (if (seq sb) sb (bisect items))))))))) + +(defn guarded-feature-list + "Render `items` via `render-coll`. If the whole render throws, LEAD with the + problem — surface the item(s) the isolator traced the failure to, with where to + fix them — then render everything that DID work below. This is the auditing + version of render-guard: it handles failures in the collection-level logic, not + just per-item render throws." + [render-coll items] + ;; Render once: the realized result is reused on the happy path, so the sort and + ;; hiccup build don't run twice on every render — only the error path pays extra. + (let [rendered (try (doall (render-coll items)) (catch :default _ ::failed))] + (if-not (= rendered ::failed) + (into [:<>] rendered) + (let [culprits (isolate-culprit render-coll items) + culprit? (set culprits) + survivors (remove culprit? items)] + [:div + ;; the problem, first and prominent — that's the thing the user must act on + [:div.p-10.m-b-10.f-s-14 + {:style {:border "1px solid #e9a227" :border-radius "5px" + :background "rgba(233,162,39,0.12)"}} + [:div.f-w-b.f-s-16 + (if (= 1 (count culprits)) + "⚠ A feature in this section couldn’t be displayed" + (str "⚠ " (count culprits) " features in this section couldn’t be displayed"))] + (doall + (map-indexed + (fn [i c] + (let [hint (try (locate-hint c) (catch :default _ nil))] + ^{:key i} + [:div.m-t-5 + (when hint [:div.f-w-600 (str "Likely " hint " — open it in the builder to fix.")]) + [:pre.wsp-prw.f-s-11.m-t-2 {:style {:user-select "text"}} (pr-str c)]])) + culprits))] + ;; everything that rendered fine, below + (when (seq survivors) (render-coll survivors))])))) + +(defn app-error-fallback + "Top-level catch-all shown when ANY page throws below the app-root boundary — + the guarantee that the app never black-screens (goal #1). Generic by design (it + can be any page): say something broke, reassure the data is safe, offer recovery, + and expose the error for a report. Finer boundaries below this one give better, + more specific messages where they apply; this is the last line of defense." + [_error _stack retry] + ;; Bring the boot-shell rescue control back: the app rendered cleanly once and + ;; took it away, then this page threw. Getting homebrew out matters most in + ;; exactly the state where the app has stopped being able to export it. + (when-let [rescue (aget js/window "orcpubBootRescue")] + (rescue)) + ;; The page may have failed on homebrew that only breaks once it is drawn. Check every + ;; entry deeply; if any are set aside, try the page again. + (js/setTimeout #(when (seq (homebrew-check/check-and-set-aside! nil)) + (js/setTimeout retry 500)) + 0) + (let [show? (r/atom false) + copied? (r/atom false)] + (fn [error stack retry] + (let [report (str (or (some-> error .-stack) (str error)) + (when stack (str "\n\nComponent stack:" stack)))] + [:div.p-20.f-s-14.main-text-color + [:div.f-w-b.f-s-24.m-b-10 "Something went wrong on this page"] + [:div.m-b-15.l-h-19 + "This page hit an unexpected error and couldn’t finish loading. Your saved " + "data wasn’t affected. Head back to your characters, or reload to try again — " + "if it keeps happening, copy the details below and send them to us."] + [:div.flex.flex-wrap.align-items-c + [:button.form-button.m-r-10.m-b-5 + {:on-click #(set! (.-href js/window.location) "/pages/dnd/5e/characters")} + "Go to my characters"] + [:button.form-button.m-b-5 {:on-click #(.reload js/window.location)} "Reload page"]] + [:div.m-t-10 + [:span.orange.underline.pointer + {:on-click #(swap! show? not)} + (if @show? "Hide details" "Show details")] + (when @show? + [:div + [:button.form-button.f-s-11.m-t-5 + {:on-click (fn [] + (some-> js/navigator .-clipboard (.writeText report)) + (reset! copied? true))} + (if @copied? "Copied!" "Copy")] + [:pre.f-s-12.m-t-5.wsp-prw {:style {:user-select "text"}} report]])]])))) + +(defn guarded-form + "`builder`'s form with each of its sections in a render-guard, so a field that throws while + drawing costs that section and the rest of the form still edits and saves; one that throws + before it has a form shows `item` in the fallback. Every builder returns its form directly + (none keeps state in a render function), so calling it here draws the same thing." + [item builder] + (try + (let [form (builder)] + (if (and (vector? form) (keyword? (first form))) + (let [[tag & more] form + [attrs children] (if (map? (first more)) [(first more) (rest more)] [nil more])] + (into (cond-> [tag] attrs (conj attrs)) + (map #(if (vector? %) [render-guard item %] %)) + children)) + form)) + (catch :default e + (js/console.error "A builder could not draw its form:" e) + [guard-fallback item e]))) diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index 339deb971..b802e4659 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -16,6 +16,7 @@ [orcpub.template :as t] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.views.guards :as guards] [orcpub.dnd.e5.library :as library] [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.homebrew-check :as homebrew-check] @@ -276,12 +277,12 @@ "Draw `x` the way Reagent would: a vector headed by a function is a child component, so call it (twice for a component that returns its render function) and draw what it returns." [x] - (cond (and (vector? x) (= views/guard-fallback (first x))) + (cond (and (vector? x) (= guards/guard-fallback (first x))) ;; the fallback a guard draws for what it caught, whichever guard (let [[_ data e] x] (swap! contained #(or % (msg e))) - (expand (views/guard-fallback data e))) - (and (vector? x) (= views/error-boundary (first x))) + (expand (guards/guard-fallback data e))) + (and (vector? x) (= guards/error-boundary (first x))) ;; as React does: the child, or the fallback when drawing it throws. What it contains is ;; drawn on screen, so it is not a failure (let [[_ fallback child] x] @@ -390,14 +391,14 @@ (catch :default _ nil)) :when (homebrew? m)] ;; drawn as the list draws each one, inside its guard - [(:key m) #(expand [views/render-guard m [views/monster-list-item m]])])) + [(:key m) #(expand [guards/render-guard m [views/monster-list-item m]])])) (for [[t es] (when builders? entries) :when (map? es) :let [[open builder] (builders t)] :when open [k e] es :when (map? e)] (do (covered! :drawn k) (some-> (once [:builder (hash e)] [:builder k] - #(do (rf/dispatch-sync [open e]) (expand (views/guarded-form e builder)))) + #(do (rf/dispatch-sync [open e]) (expand (guards/guarded-form e builder)))) (assoc :stage [:builder k] :key k))))))) (defn- run-pack! diff --git a/web/cljs/orcpub/core.cljs b/web/cljs/orcpub/core.cljs index 3cc584712..1e85068c6 100644 --- a/web/cljs/orcpub/core.cljs +++ b/web/cljs/orcpub/core.cljs @@ -6,6 +6,7 @@ [orcpub.dnd.e5.events :as events] [orcpub.dnd.e5.db :as db] [orcpub.dnd.e5.views :as views] + [orcpub.dnd.e5.views.guards :as guards] [orcpub.dnd.e5.views-2 :as views-2] [orcpub.dnd.e5.views.conflict-resolution :as conflict-views] [orcpub.dnd.e5.views.whats-new :as whats-new-view] @@ -176,8 +177,8 @@ ;; page is caught here as a last resort; finer boundaries below catch closer ;; and give better messages first. Keyed by route so navigating clears it. ^{:key handler} - [views/error-boundary - (fn [error stack retry] [views/app-error-fallback error stack retry]) + [guards/error-boundary + (fn [error stack retry] [guards/app-error-fallback error stack retry]) [:<> [view (assoc route-params :query query-map)] [boot-ok]]] From a2d63b66792ab7a82eae68ea47ddbccae8db3d29 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 26 Oct 2026 19:13:52 +0000 Subject: [PATCH 20/31] Move the set-aside and write-check logic out of events.cljs into homebrew-check No behaviour change. find-broken-entry, set-aside-broken-entries, broken-homebrew-notice, the ::keep-pre-fix-copy effect and homebrew-shapes-checked join the rest of the homebrew safety wiring in homebrew_check.cljs (the reporter, the deep check, the read check). events.cljs keeps only what must sit beside the library gate: the two set-aside events and the interceptor registration, one line each into homebrew-check. The effect is now ::homebrew-check/keep-pre-fix-copy; tests name the new home. events.cljs 8,904 -> 8,805 lines; homebrew_check.cljs 107 -> 216. --- src/cljs/orcpub/dnd/e5/events.cljs | 105 +---------------- src/cljs/orcpub/dnd/e5/homebrew_check.cljs | 109 ++++++++++++++++++ test/cljs/orcpub/dnd/e5/events_test.cljs | 5 +- .../dnd/e5/homebrew_interceptor_test.cljs | 18 +-- 4 files changed, 124 insertions(+), 113 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 8a1210da3..a8c123efc 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -105,7 +105,6 @@ [orcpub.dnd.e5.character-drafts :as drafts] [orcpub.dnd.e5.homebrew-check :as homebrew-check] [orcpub.dnd.e5.library :as library] - [orcpub.dnd.e5.homebrew-shapes :as shapes] [orcpub.dnd.e5.picks :as picks] [orcpub.dnd.e5.event-utils :as event-utils] [orcpub.dnd.e5.compute :as compute] @@ -6629,55 +6628,6 @@ ;; Homebrew that breaks the character options ;; ============================================================================ -(defn- find-broken-entry - "Where a reported entry sits in the library, as [source entry]: the source the report - names, then the entry's own :option-pack, then any source holding that key." - [plugins {:keys [content-type key source option-pack]}] - (let [holds? (fn [src] (map? (get-in plugins [src content-type key]))) - src (cond - (and source (holds? source)) source - (and option-pack (holds? option-pack)) option-pack - :else (some #(when (holds? %) %) (keys plugins)))] - (when src - [src (get-in plugins [src content-type key])]))) - -(defn set-aside-broken-entries - "Move each reported entry from `plugins` into `rejected`. Returns {:plugins :rejected - :moved [{:source :content-type :key :entry}]}. A report whose entry is not in the - library (shared content, or already gone) moves nothing. A report's `:original` is what is - set aside when it has one: the entry as it was written, before anything was read past." - [plugins rejected reports] - (reduce (fn [acc {:keys [content-type key original] :as report}] - (if-let [[src entry] (find-broken-entry (:plugins acc) report)] - (-> acc - (update-in [:plugins src content-type] dissoc key) - (assoc-in [:rejected src content-type key] (or original entry)) - (update :moved conj {:source src :content-type content-type :key key :entry entry})) - acc)) - {:plugins plugins :rejected (or rejected {}) :moved []} - reports)) - -(defn- broken-homebrew-notice [moved] - (let [n (count moved) - label (fn [{:keys [source content-type key entry]}] - (str "“" (if (and (string? (:name entry)) (not (s/blank? (:name entry)))) - (:name entry) - (str key)) - "” (" (s/lower-case (get orcbrew-val/content-type-singular content-type "entry")) - ") in “" source "”"))] - {:title (if (= 1 n) "A homebrew entry was set aside" (str n " homebrew entries were set aside")) - :details [(str (s/join ", " (map label (take 3 moved))) - (when (> n 3) (str " and " (- n 3) " more")) - ;; true whether a field could not be read or the entry broke the builder - " couldn't be used, so " - (if (= 1 n) "it was" "they were") - " set aside. Everything else still works.") - [:span.pointer.underline.orange - {:on-click (fn [e] - (.stopPropagation e) - (dispatch [:route routes/dnd-e5-my-content-route]))} - "Fix or discard it in My Content"]]})) - ;; Conversions re-run whenever their inputs change, so one bad entry is reported many ;; times; each is queued once, and the queue is handled together a moment later. (reg-event-fx @@ -6697,7 +6647,7 @@ (fn [{:keys [db]} [_ reports]] (let [all-reports (concat reports (:homebrew-broken db)) {:keys [plugins rejected moved]} - (set-aside-broken-entries (:plugins db) (get-rejected-plugins) all-reports) + (homebrew-check/set-aside-broken-entries (:plugins db) (get-rejected-plugins) all-reports) ;; one from a share link has nothing to set aside: the share banner names it instead moved? (set (map (juxt :content-type :key) moved)) left-out (for [{:keys [content-type key name source option-pack]} all-reports @@ -6720,58 +6670,9 @@ (let [{db' :db ok? :ok? refused :dispatch-n} (commit-library db plugins {:deleting? true})] (if ok? {:db (assoc db' :quarantined-plugins rejected) - :dispatch [:show-warning-message (broken-homebrew-notice moved) 60000]} + :dispatch [:show-warning-message (homebrew-check/broken-homebrew-notice moved) 60000]} {:db db' :dispatch-n refused}))))))) -(reg-fx - ::keep-pre-fix-copy - (fn [plugins] (keep-pre-fix-copy! plugins))) - -(defn homebrew-shapes-checked - "An event's effects, with the homebrew it changed made to fit homebrew-shapes. The library: what - was fixed is in the new state and stored through the gate (after a copy of the library as it - was); an entry with a field that cannot be read is set aside, as written; the entries it - changed are converted by the deep check. Shared content is never stored: what is fixed is used, - and an entry that cannot be read is left out and reported. - Nothing to do when the event left both as they were, which is nearly every event." - [before-db effects] - (let [db (:db effects) - before (:plugins before-db) - lib (:plugins db) - shared-before (:shared-plugins before-db) - shared (:shared-plugins db)] - (if (or (nil? db) (and (identical? lib before) (identical? shared shared-before))) - effects - (let [{cleaned :library findings :findings} (if (map? lib) - (shapes/clean-library lib before) - {:library lib}) - {shared' :library shared-findings :findings} - (if (map? shared) (shapes/clean-library shared shared-before) {:library shared}) - shared' (:library (shapes/split-unreadable shared' shared shared-findings)) - changed (when (and (map? cleaned) (map? before)) - (set (for [[src source] cleaned - :when (and (map? source) (not (identical? source (get before src)))) - [ct section] source - :when (and (map? section) - (not (identical? section (get-in before [src ct])))) - [k entry] section - :when (not (identical? entry (get-in before [src ct k])))] - [src ct k]))) - fx (concat - (when-not (identical? cleaned lib) - [[::keep-pre-fix-copy lib] - [:dispatch [::e5/set-plugins cleaned]]]) - (for [r (concat (homebrew-check/unreadable-reports lib findings) - (homebrew-check/unreadable-reports shared shared-findings))] - [::homebrew-check/report r]) - (when (seq changed) - [[::homebrew-check/check-entries changed]]))] - (cond-> (assoc effects :db - (cond-> db - (not (identical? cleaned lib)) (assoc :plugins cleaned) ;; library gate - (not (identical? shared' shared)) (assoc :shared-plugins shared'))) - (seq fx) (update :fx (fnil into []) fx)))))) - ;; Every event, whatever it changed and however: the homebrew it leaves is checked here, so no ;; write can skip the check by going around commit-library. See docs/HOMEBREW_GUARD.md. (reg-global-interceptor @@ -6789,7 +6690,7 @@ (not (identical? lib lib')) (assoc :plugins lib') ;; not app-db (not (identical? shared shared')) (assoc :shared-plugins shared')))))) :after (fn [ctx] - (update ctx :effects #(homebrew-shapes-checked (get-in ctx [:coeffects :db]) %))))) + (update ctx :effects #(homebrew-check/homebrew-shapes-checked (get-in ctx [:coeffects :db]) %))))) ;; An event that throws changes nothing: re-frame applies no effect of a handler that did not ;; finish. This says so to the person, at most once a few seconds for the same event, and keeps diff --git a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs index 401b03883..3f60d8073 100644 --- a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs +++ b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs @@ -6,7 +6,10 @@ entries a change wrote (the homebrew-shapes interceptor's ::check-entries), or after a page has already failed. See docs/HOMEBREW_GUARD.md." (:require [clojure.string :as s] + [orcpub.route-map :as routes] [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.db :refer [keep-pre-fix-copy!]] + [orcpub.dnd.e5.orcbrew-validation :as orcbrew-val] [orcpub.dnd.e5.homebrew-guard :as guard] [orcpub.dnd.e5.homebrew-shapes :as shapes] [re-frame.core :refer [reg-fx dispatch subscribe]] @@ -105,3 +108,109 @@ (guard/report! r))) (swap! last-read assoc slot {:raw library :given given'}) given')))) + +;; ============================================================================ +;; Setting aside, and the shape check on every write (registered in events.cljs, which holds +;; the library gate these events go through) +;; ============================================================================ + +(defn- find-broken-entry + "Where a reported entry sits in the library, as [source entry]: the source the report + names, then the entry's own :option-pack, then any source holding that key." + [plugins {:keys [content-type key source option-pack]}] + (let [holds? (fn [src] (map? (get-in plugins [src content-type key]))) + src (cond + (and source (holds? source)) source + (and option-pack (holds? option-pack)) option-pack + :else (some #(when (holds? %) %) (keys plugins)))] + (when src + [src (get-in plugins [src content-type key])]))) + +(defn set-aside-broken-entries + "Move each reported entry from `plugins` into `rejected`. Returns {:plugins :rejected + :moved [{:source :content-type :key :entry}]}. A report whose entry is not in the + library (shared content, or already gone) moves nothing. A report's `:original` is what is + set aside when it has one: the entry as it was written, before anything was read past." + [plugins rejected reports] + (reduce (fn [acc {:keys [content-type key original] :as report}] + (if-let [[src entry] (find-broken-entry (:plugins acc) report)] + (-> acc + (update-in [:plugins src content-type] dissoc key) + (assoc-in [:rejected src content-type key] (or original entry)) + (update :moved conj {:source src :content-type content-type :key key :entry entry})) + acc)) + {:plugins plugins :rejected (or rejected {}) :moved []} + reports)) + +(defn broken-homebrew-notice + "The warning notice for `moved` (set-aside-broken-entries' :moved): names up to three, links to + My Content." + [moved] + (let [n (count moved) + label (fn [{:keys [source content-type key entry]}] + (str "“" (if (and (string? (:name entry)) (not (s/blank? (:name entry)))) + (:name entry) + (str key)) + "” (" (s/lower-case (get orcbrew-val/content-type-singular content-type "entry")) + ") in “" source "”"))] + {:title (if (= 1 n) "A homebrew entry was set aside" (str n " homebrew entries were set aside")) + :details [(str (s/join ", " (map label (take 3 moved))) + (when (> n 3) (str " and " (- n 3) " more")) + ;; true whether a field could not be read or the entry broke the builder + " couldn't be used, so " + (if (= 1 n) "it was" "they were") + " set aside. Everything else still works.") + [:span.pointer.underline.orange + {:on-click (fn [e] + (.stopPropagation e) + (dispatch [:route routes/dnd-e5-my-content-route]))} + "Fix or discard it in My Content"]]})) + +(reg-fx + ::keep-pre-fix-copy + (fn [plugins] (keep-pre-fix-copy! plugins))) + +(defn homebrew-shapes-checked + "An event's effects, with the homebrew it changed made to fit homebrew-shapes. The library: what + was fixed is in the new state and stored through the gate (after a copy of the library as it + was); an entry with a field that cannot be read is set aside, as written; the entries it + changed are converted by the deep check. Shared content is never stored: what is fixed is used, + and an entry that cannot be read is left out and reported. + Nothing to do when the event left both as they were, which is nearly every event." + [before-db effects] + (let [db (:db effects) + before (:plugins before-db) + lib (:plugins db) + shared-before (:shared-plugins before-db) + shared (:shared-plugins db)] + (if (or (nil? db) (and (identical? lib before) (identical? shared shared-before))) + effects + (let [{cleaned :library findings :findings} (if (map? lib) + (shapes/clean-library lib before) + {:library lib}) + {shared' :library shared-findings :findings} + (if (map? shared) (shapes/clean-library shared shared-before) {:library shared}) + shared' (:library (shapes/split-unreadable shared' shared shared-findings)) + changed (when (and (map? cleaned) (map? before)) + (set (for [[src source] cleaned + :when (and (map? source) (not (identical? source (get before src)))) + [ct section] source + :when (and (map? section) + (not (identical? section (get-in before [src ct])))) + [k entry] section + :when (not (identical? entry (get-in before [src ct k])))] + [src ct k]))) + fx (concat + (when-not (identical? cleaned lib) + [[::keep-pre-fix-copy lib] + [:dispatch [::e5/set-plugins cleaned]]]) + (for [r (concat (unreadable-reports lib findings) + (unreadable-reports shared shared-findings))] + [::report r]) + (when (seq changed) + [[::check-entries changed]]))] + (cond-> (assoc effects :db + (cond-> db + (not (identical? cleaned lib)) (assoc :plugins cleaned) ;; library gate + (not (identical? shared' shared)) (assoc :shared-plugins shared'))) + (seq fx) (update :fx (fnil into []) fx)))))) diff --git a/test/cljs/orcpub/dnd/e5/events_test.cljs b/test/cljs/orcpub/dnd/e5/events_test.cljs index 5857b866d..0dc013147 100644 --- a/test/cljs/orcpub/dnd/e5/events_test.cljs +++ b/test/cljs/orcpub/dnd/e5/events_test.cljs @@ -48,7 +48,8 @@ [orcpub.dnd.e5.subs] [orcpub.dnd.e5.equipment-subs] ;; Side effect: registers all event handlers - [orcpub.dnd.e5.events :as events])) + [orcpub.dnd.e5.events :as events] + [orcpub.dnd.e5.homebrew-check :as homebrew-check])) ;; --------------------------------------------------------------------------- ;; Fixtures @@ -1432,7 +1433,7 @@ (.removeItem js/localStorage "plugins")))) (deftest a-report-for-content-not-in-the-library-moves-nothing - (is (= [] (:moved (events/set-aside-broken-entries {"P" {::e5/races {}}} nil + (is (= [] (:moved (homebrew-check/set-aside-broken-entries {"P" {::e5/races {}}} nil [{:content-type ::e5/races :key :gone :option-pack "P"}]))))) (deftest reporting-a-character-problem-sends-the-auth-token diff --git a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs index b2c1004ef..28c04788b 100644 --- a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs +++ b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs @@ -18,19 +18,19 @@ (deftest an-event-that-leaves-the-library-alone-is-left-alone (let [db {:plugins lib} effects {:db (assoc db :other 1)}] - (is (identical? effects (events/homebrew-shapes-checked db effects))))) + (is (identical? effects (homebrew-check/homebrew-shapes-checked db effects))))) (deftest a-fix-is-used-and-stored - (let [effects (events/homebrew-shapes-checked {:plugins {}} {:db {:plugins lib}}) + (let [effects (homebrew-check/homebrew-shapes-checked {:plugins {}} {:db {:plugins lib}}) cleaned (get-in effects [:db :plugins])] (is (= 25 (get-in cleaned ["P" ::e5/races :hill :speed])) "the app reads 25 at once") - (is (= [lib] (fx-of effects ::events/keep-pre-fix-copy)) "the library as it was is kept first") + (is (= [lib] (fx-of effects ::homebrew-check/keep-pre-fix-copy)) "the library as it was is kept first") (is (= [[::e5/set-plugins cleaned]] (fx-of effects :dispatch)) "and the fix is stored"))) (deftest an-unreadable-entry-is-set-aside-as-written (let [bad (assoc race :abilities 42) written {"P" {::e5/races {:hill bad}}} - effects (events/homebrew-shapes-checked {:plugins {}} {:db {:plugins written}}) + effects (homebrew-check/homebrew-shapes-checked {:plugins {}} {:db {:plugins written}}) broke (fx-of effects ::homebrew-check/report)] (is (= 1 (count broke))) (is (= {:content-type ::e5/races :key :hill :source "P" :original bad} @@ -42,20 +42,20 @@ (let [before {"P" {::e5/races {:hill (assoc race :speed 25)}} "Q" {}} after (assoc-in before ["P" ::e5/races :dale] {:name "Dale" :key :dale :option-pack "P"})] (is (= [#{["P" ::e5/races :dale]}] - (fx-of (events/homebrew-shapes-checked {:plugins before} {:db {:plugins after}}) + (fx-of (homebrew-check/homebrew-shapes-checked {:plugins before} {:db {:plugins after}}) ::homebrew-check/check-entries)) "only the entry that changed") - (is (empty? (fx-of (events/homebrew-shapes-checked {} {:db {:plugins after}}) + (is (empty? (fx-of (homebrew-check/homebrew-shapes-checked {} {:db {:plugins after}}) ::homebrew-check/check-entries)) "not the whole library on load"))) (deftest shared-content-is-used-not-stored - (let [effects (events/homebrew-shapes-checked {} {:db {:shared-plugins lib}})] + (let [effects (homebrew-check/homebrew-shapes-checked {} {:db {:shared-plugins lib}})] (is (= 25 (get-in effects [:db :shared-plugins "P" ::e5/races :hill :speed]))) (is (empty? (:fx effects)) "never stored")) (testing "an entry that cannot be read is left out, and named" (let [bad {"P" {::e5/races {:hill (assoc race :abilities 42) :dale (assoc race :key :dale)}}} - effects (events/homebrew-shapes-checked {} {:db {:shared-plugins bad}})] + effects (homebrew-check/homebrew-shapes-checked {} {:db {:shared-plugins bad}})] (is (= #{:dale} (set (keys (get-in effects [:db :shared-plugins "P" ::e5/races]))))) (is (= [:hill] (map :key (fx-of effects ::homebrew-check/report))))))) @@ -77,7 +77,7 @@ (deftest a-set-aside-entry-keeps-what-was-written (let [bad (assoc race :abilities 42) - {:keys [rejected]} (events/set-aside-broken-entries + {:keys [rejected]} (homebrew-check/set-aside-broken-entries {"P" {::e5/races {:hill (dissoc bad :abilities)}}} {} [{:content-type ::e5/races :key :hill :source "P" :original bad}])] (is (= bad (get-in rejected ["P" ::e5/races :hill]))))) From 8a501731647bb4dc2cab8fa0f8168f8c5b0890d4 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 26 Oct 2026 19:59:32 +0000 Subject: [PATCH 21/31] One repair path: fold e5/mend-* into homebrew-shapes The old repair step (e5/mend-*) and the shape table both fixed homebrew; now there is one. homebrew-shapes reads how a library is stored (read-library, read-import: a library, source or section kept as text or a list), and the table fixes fields, including the old step's rules: text where a card belongs is a card with that name, and a :key that is not a key takes the one it is filed under. The loader and the import use it; the import applies the table's fixes and lists each in its log, with each entry it will set aside, kept as written. e5.cljc 508 -> 324 lines. An unreadable :traits or :spell-lists now sets its entry aside, as the owner decided for any unreadable field; the old step dropped the field. A subrace with no :race, or a subclass with no :class, cannot be read either, rather than never being shown. Fixed on the way: the table counted a field holding nil as unreadable, which would have set aside a class saved with a nil :spell-list-kw on load; nil now reads as absent. An entry or section that came back unchanged dropped its findings. Two comments over the length limit are shortened, and the library gate test names homebrew_check, which holds the write check. --- docs/HOMEBREW_GUARD.md | 26 ++- docs/branch-changelog.md | 2 + src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc | 217 ++++++++++++++++-- src/cljs/orcpub/dnd/e5.cljc | 188 +-------------- src/cljs/orcpub/dnd/e5/db.cljs | 4 +- src/cljs/orcpub/dnd/e5/events.cljs | 11 +- .../orcpub/dnd/e5/orcbrew_validation.cljs | 45 +++- src/cljs/orcpub/dnd/e5/views/import_log.cljs | 2 +- .../dnd/e5/homebrew_shape_table_test.clj | 11 + .../orcpub/dnd/e5/homebrew_structure_test.clj | 123 ++++++++++ test/clj/orcpub/dnd/e5_test.clj | 99 +------- test/clj/orcpub/library_gate_test.clj | 4 +- test/cljs/orcpub/dnd/e5/db_test.cljs | 16 +- test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 3 +- .../dnd/e5/orcbrew_validation_test.cljs | 26 ++- 15 files changed, 436 insertions(+), 341 deletions(-) create mode 100644 test/clj/orcpub/dnd/e5/homebrew_structure_test.clj diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index 2ce3fcb10..6b3e00ce6 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -31,7 +31,7 @@ path, so new code is protected without anyone remembering to protect it. | Layer | Where | What it does | |---|---|---| | Load floor | `content_specs/valid-item-for-load?`, `::e5/plugin` | Keeps an entry if its key is a keyword and it has an `:option-pack` string. A whole section may be `true` (the spec allows `boolean?`). Nothing else is checked: a presence contract, not a shape contract (orcbrew-level-modifiers.md). | -| Repairs | `e5/mend-library`, `mend-cards` | On load and import: `:key`, `:traits`, a selection's `:options`, a spell's `:spell-lists`. Not run on share-link content. | +| Repairs | `homebrew-shapes/read-library`, `read-import` (were `e5/mend-*`) | On load and import: a library, source or section kept as text or a list. Field repairs are the table's. Not run on share-link content. | | Guard | `guard-entry` at 7 sites | Catches a throw while one entry converts, sets the entry aside into `plugins:rejected`, says so for 60 s. | | Deep check | `homebrew_check/deep-check` | Converts and fully realizes 5 of 13 content types; after import, Restore, or a crash that reaches the app root. | | Quarantine panel | top of My Content | Per source and per item: rename, Restore, Auto-name & Restore, Export raw, Discard. A damaged section is listed whole. | @@ -148,8 +148,14 @@ contained to one item and reported (5), and the audit, rerun in minutes, finds t - **`homebrew_shapes.cljc`** says, for each of the 13 content types, every field the app reads and its shape. **`clean-entry` / `clean-library`** return the entry made to fit, and what they found: - **fixed** when the meaning is obvious (`"5"` → `5`; one card → a list of one; a set of keys → - a key-to-true map). `mend-cards`' rules move into the table: one repair mechanism, not two; - - **unreadable** when it is not (a number where a map belongs). + a key-to-true map; text where a card belongs → a card with that name; a :key that is not a + key → the one it is filed under). One repair path: `e5/mend-*` is folded in here; + - **unreadable** when it is not (a number where a map belongs), or when a field an entry cannot + be used without is missing (a subrace's `:race`, a subclass's `:class`). +- A field holding nil reads as absent, as every reader takes it: the builders store some on + purpose (a class's `:spell-list-kw`), and the import keeps them (`nil-preserve-fields`). +- How a library is stored comes first (`read-library`, `read-import`): a library, source or + section kept as text is read back, a section kept as a list is keyed, an empty one dropped. - A sound entry comes back as the identical object. Loading is never stricter than saving: every real fixture comes through with nothing unreadable (a test). Fixes produce only shapes the builders write, so exported files still import into older builds. @@ -168,6 +174,7 @@ obvious: [:enum #{...}] one of these values [:or s ...] any one of them [:list s] a vector or list of s (a lone s becomes a list of one) + [:cards card] a list of cards (text names a card; a lone card or text is a list of one) [:coll s] a vector, list or set of s [:set s] a set of s (a list of s becomes a set; a lone s a set of one) [:map-of k v] a map whose keys are k and values v @@ -304,7 +311,7 @@ alone 0.001 ms; an event that changes one entry 0.05 ms, then its deep check 35 Go through it once and port what the junk audit does not cover, rather than keep two suites. - **Fixtures on integration**: `broken-content`, `cruft-shapes`, `keyword-trap`, `test-pak`. - **On integration**: the write gate and `library_gate_test`; `persist-set-aside!` and - `plugins:pre-fix`; `mend-*` (folded into the table); the quarantine panel and + `plugins:pre-fix`; `mend-*` (folded into homebrew-shapes); the quarantine panel and `repair-quarantined-source`; `library/dangling`; `render-guard`; `register-conversions!` and `deep-check`; HOMEBREW_REQUIRED_FIELDS.md; the browser probes. - **On grant-rows**: `builder_fields.cljc` and `content_types.cljc`; their schemas feed this table @@ -366,6 +373,17 @@ alone 0.001 ms; an event that changes one entry 0.05 ms, then its deep check 35 offered, by any way in; its class is there. The audit's former allowances hid it. Suspected, not shown: `class-option` merges subclasses into a set sorted by name. Guarded audit: 4, all this case. + **Consolidated** (owner, 2026-10-26): one repair path, `e5/mend-*` folded into homebrew-shapes + (`read-library` and `read-import` for structure, the table for fields); the import lists the + table's fixes and what will be set aside in its log. An unreadable `:traits` or `:spell-lists` + now sets its entry aside, as the owner decided for any unreadable field; the old repair + dropped the field. Not duplicated after all: `content-specs` is the identity floor (a + letter-first key and a source), the save specs and the import validator check which fields + are present; the table checks the form of those that are. Found on the way and fixed: the + table counted nil as unreadable, which would have set aside a class saved with a nil + `:spell-list-kw` on load; and an unchanged entry dropped its findings. Moved, no behaviour + change: the view guards to `views/guards.cljs` (views.cljs 11,557 to 11,342 lines); the + set-aside and write-check logic to `homebrew_check.cljs` (events.cljs 8,904 to 8,805). 7. **Enforcement:** the audit in CI at zero, the AGENTS.md rules, the documents corrected, the testing/develop scenarios gone through. 8. **grant-rows** merges this, adds its fields to the table, and runs the audit. diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index 84a78fe56..e52834470 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -41,6 +41,8 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB - **Homebrew:** an entry with a field that cannot be read (a number where a list of traits belongs) is set aside in My Content for repair, as it was written, instead of breaking pages. From a share link it is left out. +- **Homebrew:** the import report lists each field it fixed, and each entry it will set aside + for repair. - **Homebrew:** checking homebrew after an import or a Restore takes a fraction of the time for classes: each shared part of a class is checked once, not once per level. diff --git a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc index 1c74a5f89..d9451e70e 100644 --- a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc +++ b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc @@ -2,9 +2,12 @@ "The shape of every homebrew field the app reads, and `clean`, which makes an entry fit them. Homebrew is untrusted: an imported, hand-edited or injected entry can hold anything anywhere. Code that reads homebrew assumes these shapes, so the seams run entries through here first. - A leaf: requires nothing from the app. See docs/HOMEBREW_GUARD.md." + `read-structure` comes first, for a library as stored: a library, source or section kept as + text, a section kept as a list. Requires only orcpub.common. See docs/HOMEBREW_GUARD.md." (:require [clojure.string :as str] - #?(:clj [clojure.edn :as edn]))) + #?(:clj [clojure.edn :as edn]) + #?(:cljs [cljs.reader :as reader]) + [orcpub.common :as common])) ;; --------------------------------------------------------------------------------------------- ;; Shapes are data: :string, :num, [:list s], [:map {field s}] and so on. What each form means, @@ -29,14 +32,15 @@ (case op :enum (contains? a v) :or (boolean (some #(fits? % v) (rest shape))) - :list (and (sequential? v) (every? #(fits? a %) v)) + (:list :cards) (and (sequential? v) (every? #(fits? a %) v)) :coll (and (or (sequential? v) (set? v)) (every? #(fits? a %) v)) :set (and (set? v) (every? #(fits? a %) v)) :map-of (and (map? v) (every? (fn [[k x]] (and (fits? a k) (fits? b x))) v)) :flags (and (map? v) (every? (fn [[k x]] (and (fits? a k) (boolean? x))) v)) :counts (and (map? v) (every? (fn [[k x]] (and (fits? a k) (number? x))) v)) :indexed (and (map? v) (every? (fn [[k x]] (and (integer? k) (fits? a x))) v)) - :map (and (map? v) (every? (fn [[k s]] (or (not (contains? v k)) (fits? s (get v k)))) a)) + ;; a field holding nil is absent (see clean-map) + :map (and (map? v) (every? (fn [[k s]] (let [x (get v k)] (or (nil? x) (fits? s x)))) a)) false)))) (defn- describe @@ -88,10 +92,11 @@ ;; value can be read; `v` has already been found not to fit. (defn- clean-each - "Each element of `xs` cleaned against `shape`; one that cannot be read is left out." - [shape xs path] + "Each element of `xs` cleaned against `shape` (by `clean-x`, clean-value by default); one that + cannot be read is left out." + [shape xs path & [clean-x]] (reduce (fn [{:keys [value findings]} [i x]] - (let [r (clean-value shape x (conj path i))] + (let [r ((or clean-x clean-value) shape x (conj path i))] {:value (if (contains? r :value) (conj value (:value r)) value) :findings (into findings (:findings r))})) {:value [] :findings []} @@ -126,6 +131,26 @@ (unreadable path v shape))) :else (unreadable path v shape))) +(defn- card-or-text + "A card cleaned against `a`; text where a card belongs is a card with that name (a trait listed + by name)." + [a x path] + (if (and (string? x) (not (str/blank? x))) + {:value {:name x} :findings [(finding path :fixed x a)]} + (clean-value a x path))) + +(defn- clean-cards + "[:cards card]: a list of cards, where text names a card; a lone card, or lone text, is a list + of one." + [a v path shape] + (cond + (sequential? v) (clean-each a v path card-or-text) + (or (map? v) (string? v)) (let [r (card-or-text a v (conj path 0))] + (if (contains? r :value) + (fixed {:value [(:value r)] :findings (:findings r)} path v shape) + (unreadable path v shape))) + :else (unreadable path v shape))) + (defn- clean-set [a v path shape] (cond (set? v) (update (clean-each a v path) :value set) @@ -172,9 +197,13 @@ path v shape)) :else (unreadable path v shape)))) -(defn- clean-map [fields v path] +(defn- clean-map + "`v`'s listed `fields` made to fit their shapes; the rest pass untouched. A field holding nil + reads as absent, as every reader takes it: the builders store some nils on purpose (a class's + :spell-list-kw, a spellcasting :ability), so nil is never a finding." + [fields v path] (reduce-kv (fn [{:keys [value findings] :as acc} k s] - (if-not (contains? value k) + (if (nil? (get value k)) acc (let [r (clean-value s (get value k) (conj path k))] {:value (if (contains? r :value) (assoc value k (:value r)) (dissoc value k)) @@ -198,6 +227,7 @@ :or (or (first (filter #(contains? % :value) (map #(clean-value % v path) (rest shape)))) (unreadable path v shape)) :list (clean-list a v path shape) + :cards (clean-cards a v path shape) :coll (if (or (sequential? v) (set? v)) (cond-> (clean-each a v path) (set? v) (update :value set)) (unreadable path v shape)) @@ -225,7 +255,7 @@ "A trait, an option or an action: what `traits-modifiers` and the sheets read." [:map {:name :string :description :string :summary :string :level :num :type :key}]) -(def ^:private cards [:list card]) +(def ^:private cards [:cards card]) (def ^:private choice "A choose-N: {:choose 2 :options {:athletics true}} (or {:any true})." @@ -350,17 +380,32 @@ "What every entry of every type holds." {:former-keys [:list :keyword]}) +(def ^:private required + "Fields an entry cannot be used without, by type: what it is built inside. Missing, the entry + cannot be read, rather than silently never shown." + {:orcpub.dnd.e5/subraces {:race :keyword} + :orcpub.dnd.e5/subclasses {:class :keyword}}) + (defn clean-entry "`entry` of `content-type` made to fit the table: {:entry e' :findings [...]}, or only {:findings [...]} when the entry is not a map. A sound entry comes back identical." [content-type entry] (let [shape (get shapes content-type [:map {}]) [_ fields] shape - r (clean-value [:map (merge entry-fields fields)] entry [])] + r (clean-value [:map (merge entry-fields fields)] entry []) + missing (when (contains? r :value) + (for [[field wanted] (get required content-type) + :when (nil? (get (:value r) field))] + (finding [field] :unreadable nil wanted)))] (if (contains? r :value) - {:entry (:value r) :findings (:findings r)} + {:entry (:value r) :findings (into (:findings r) missing)} {:findings (:findings r)}))) +(defn- key-like? + "A keyword starting with a letter: what an entry's :key, and the key it is filed under, are." + [k] + (and (keyword? k) (some? (re-find #"^[A-Za-z]" (name k))))) + (def ^:private a-section "A content section: entries by key." [:map-of :keyword :map]) @@ -372,12 +417,18 @@ (reduce-kv (fn [{:keys [section findings] :as acc} k entry] (let [r (if (identical? entry (get before k)) {:entry entry} - (clean-entry content-type entry)) + (let [restore? (and (map? entry) (contains? entry :key) + (not (key-like? (:key entry))) (key-like? k)) + r (clean-entry content-type (cond-> entry restore? (assoc :key k)))] + ;; a :key that is not a key takes the one the entry is filed under + (cond-> r + restore? (update :findings conj + (finding [:key] :fixed (:key entry) :keyword))))) fs (map #(assoc % :key k) (:findings r))] (cond (not (contains? r :entry)) {:section (dissoc section k) :findings (into findings fs)} - (identical? entry (:entry r)) acc + (identical? entry (:entry r)) (update acc :findings into fs) :else {:section (assoc section k (:entry r)) :findings (into findings fs)}))) {:section section :findings []} section)) @@ -408,10 +459,11 @@ {:source (dissoc source ct) :findings (conj findings (tag (finding [] :unreadable section a-section)))} :else (let [r (clean-section ct section (get-in before [src ct]))] - (if (identical? section (:section r)) - acc - {:source (assoc source ct (:section r)) - :findings (into findings (map tag (:findings r)))}))))) + ;; unchanged, it may still have findings (a required field missing) + {:source (cond-> source + (not (identical? section (:section r))) + (assoc ct (:section r))) + :findings (into findings (map tag (:findings r)))})))) {:source source :findings []} source)] (cond-> (update acc :findings into findings) @@ -435,3 +487,132 @@ (get-in library [source content-type])))) {:library cleaned :set-aside {}} findings)) + +(defn fix-library + "`library` with every fix the table makes, and each entry it cannot read left exactly as written: + the shape check sets that one aside, with its original, when the library is stored. For an + import, which shows the fixes before anything is stored. {:library l :findings [...]}." + [library] + (let [{cleaned :library findings :findings} (clean-library library) + {kept :library aside :set-aside} (split-unreadable cleaned library findings)] + {:library (reduce-kv (fn [l src sections] + (reduce-kv (fn [l ct v] + (if (map? v) + (update-in l [src ct] merge v) + (assoc-in l [src ct] v))) + l sections)) + kept aside) + :findings findings})) + +;; --------------------------------------------------------------------------------------------- +;; How a library is stored, before its fields: a library, a source or a section kept as text, a +;; section kept as a list. Repairs are {:source :section :repair}, worded for the import log by +;; orcbrew-validation/repair-description. + +(defn- read-back + "Text read back as data, or nil when it does not read." + [s] + (try #?(:clj (edn/read-string s) :cljs (reader/read-string s)) + (catch #?(:clj Exception :cljs :default) _ nil))) + +(defn- content-section? + "A key naming a content type (`:orcpub.dnd.e5/spells`), as opposed to `:disabled?`." + [k] + (and (qualified-keyword? k) (= "orcpub.dnd.e5" (namespace k)))) + +(defn- list->entries + "Entries stored as a list, keyed the way the library keys them: each entry's own :key, or the + key its :name derives. nil when any element is not an entry, has neither, or repeats a key: + guessing there would invent or silently lose content." + [xs] + (reduce (fn [m item] + (let [k (when (map? item) + (or (:key item) + (when (string? (:name item)) (common/name-to-kw (:name item)))))] + (if (and (keyword? k) (not (contains? m k))) + (assoc m k (assoc item :key k)) + (reduced nil)))) + {} + xs)) + +(defn read-section + "A content section as stored, in the shape the library uses: {:section v}, {:section v :repair + how}, {:dropped :empty-section}, or {:damaged v} when nothing readable is left (the loader sets + that aside whole). Left alone, a section stored as text or as a list imported as successful, + loaded silently, and made export refuse the source." + [v] + (cond + (or (map? v) (boolean? v)) {:section v} + (or (nil? v) + (and (coll? v) (empty? v)) + (and (string? v) (re-matches #"\s*" v))) {:dropped :empty-section} + (string? v) (let [x (read-back v)] + (cond + (map? x) {:section x :repair :section-from-text} + (and (sequential? x) (seq x)) (if-let [m (list->entries x)] + {:section m :repair :section-from-text} + {:damaged v}) + :else {:damaged v})) + (sequential? v) (if-let [m (list->entries v)] + {:section m :repair :section-from-list} + {:damaged v}) + :else {:damaged v})) + +(defn read-source + "`read-section` over one source's sections: {:source s :repairs [{:section :repair}]}. A section + nothing can be read from stays where it is, for the loader to set aside." + [source] + (reduce-kv + (fn [acc k v] + (if-not (content-section? k) + acc + (let [{:keys [section repair dropped]} (read-section v)] + (cond + dropped (-> acc (update :source dissoc k) (update :repairs conj {:section k :repair dropped})) + repair (-> acc (assoc-in [:source k] section) (update :repairs conj {:section k :repair repair})) + :else acc)))) + {:source source :repairs []} + source)) + +(defn read-library + "A {source-name source} library as stored, in the shape clean-library reads: the whole library, + or a source, read back from text, then `read-source` over each source. Text counts as a library + only when it reads as one. {:library l :repairs [{:source :section :repair}]}." + [library] + (let [from-text (when (string? library) (read-back library)) + whole? (and (map? from-text) (every? string? (keys from-text))) + library (if whole? from-text library)] + (if-not (map? library) + {:library library :repairs []} + (cond-> (reduce-kv + (fn [acc src source] + (let [from-text (when (string? source) (read-back source)) + source (if (map? from-text) from-text source)] + (if (map? source) + (let [{s :source rs :repairs} (read-source source)] + (-> acc + (assoc-in [:library src] s) + (update :repairs into + (cond->> (map #(assoc % :source src) rs) + (map? from-text) (cons {:source src :repair :source-from-text}))))) + (assoc-in acc [:library src] source)))) + {:library {} :repairs []} + library) + whole? (update :repairs #(into [{:repair :library-from-text}] %)))))) + +(defn read-import + "An imported file's data as read: a whole file kept as text read back, then a single source or a + {source-name source} library put in shape. {:data d :repairs [...]}; `d` is not a map only when + nothing could be read." + [data] + (let [from-text (when (string? data) (read-back data)) + data (if (map? from-text) from-text data) + whole (when (map? from-text) [{:repair :file-from-text}])] + (cond + (not (map? data)) {:data data :repairs []} + (and (seq data) (every? string? (keys data))) + (let [{l :library rs :repairs} (read-library data)] + {:data l :repairs (into (vec whole) rs)}) + :else + (let [{s :source rs :repairs} (read-source data)] + {:data s :repairs (into (vec whole) rs)})))) diff --git a/src/cljs/orcpub/dnd/e5.cljc b/src/cljs/orcpub/dnd/e5.cljc index f8449a310..1eb08fe5d 100644 --- a/src/cljs/orcpub/dnd/e5.cljc +++ b/src/cljs/orcpub/dnd/e5.cljc @@ -1,8 +1,6 @@ (ns orcpub.dnd.e5 (:require #?(:cljs [cljs.spec.alpha :as spec]) #?(:clj [clojure.spec.alpha :as spec]) - #?(:cljs [cljs.reader :as reader]) - #?(:clj [clojure.edn :as edn]) [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.languages :as languages] [orcpub.common :as common] @@ -130,190 +128,6 @@ [k v] (and (content-section? k) (not (map? v)) (not (boolean? v)))) -(defn- read-back - "Text read back as data, or nil when it does not read." - [s] - (try #?(:clj (edn/read-string s) :cljs (reader/read-string s)) - (catch #?(:clj Exception :cljs :default) _ nil))) - -(defn- list->entries - "Entries stored as a list, keyed the way the library keys them: each entry's own - :key, or the key its :name derives. nil when any element is not an entry, has - neither, or repeats a key -- guessing there would invent or silently lose content." - [xs] - (reduce (fn [m item] - (let [k (when (map? item) - (or (:key item) - (when (string? (:name item)) (common/name-to-kw (:name item)))))] - (if (and (keyword? k) (not (contains? m k))) - (assoc m k (assoc item :key k)) - (reduced nil)))) - {} - xs)) - -(defn mend-section - "Put a content section that still holds its data back in the shape the library uses. - Returns {:section v} unchanged, {:section v :repair how}, {:dropped how} for an empty - section, or {:damaged v} when nothing readable is left for code to recover. - - These are the shapes real damage takes. Left alone, a section stored as text or as - a list imported as \"successful\", loaded silently, and made export refuse the source." - [v] - (cond - (or (map? v) (boolean? v)) {:section v} - (or (nil? v) - (and (coll? v) (empty? v)) - (and (string? v) (re-matches #"\s*" v))) {:dropped :empty-section} - (string? v) (let [x (read-back v)] - (cond - (map? x) {:section x :repair :section-from-text} - (and (sequential? x) (seq x)) (if-let [m (list->entries x)] - {:section m :repair :section-from-text} - {:damaged v}) - :else {:damaged v})) - (sequential? v) (if-let [m (list->entries v)] - {:section m :repair :section-from-list} - {:damaged v}) - :else {:damaged v})) - -(defn mend-cards - "Put a :traits or :options value back into a list of cards (maps). Text in the list - becomes a card with that name and anything else in it is dropped; a lone card or a - lone name becomes a list of one; any other value is removed. Returns {:value v} - when nothing changed, {:value v :repair :cards-mended :named n :dropped n}, or - {:remove? true :repair :cards-not-a-list}." - [v] - (cond - (nil? v) {:value v} - (map? v) {:value [v] :repair :cards-mended :named 0 :dropped 0} - (source-name? v) {:value [{:name v}] :repair :cards-mended :named 1 :dropped 0} - (sequential? v) - (let [named (count (filter source-name? v)) - cards (vec (keep #(cond (map? %) % (source-name? %) {:name %}) v)) - dropped (- (count v) (count cards))] - (if (and (vector? v) (zero? named) (zero? dropped)) - {:value v} - {:value cards :repair :cards-mended :named named :dropped dropped})) - :else {:remove? true :repair :cards-not-a-list})) - -(defn- entry-key? [k] - (and (keyword? k) (common/keyword-starts-with-letter? k))) - -(defn mend-entry - "Repair damage inside one entry, filed under `entry-key` in section `section-key`. A - :key that is not a letter-first keyword takes the key the entry is filed under; - :traits, and a selection's :options, go through mend-cards; a spell's :spell-lists - that is not a map of classes is removed, leaving the spell on no class list. A sound - entry comes back untouched. Returns {:entry e :repairs [{:field :repair ...}]}." - [section-key entry-key entry] - (if-not (map? entry) - {:entry entry :repairs []} - (let [restore-key? (and (contains? entry :key) - (not (entry-key? (:key entry))) - (entry-key? entry-key)) - drop-spell-lists? (and (= section-key ::spells) - (some? (:spell-lists entry)) - (not (map? (:spell-lists entry)))) - fields (cond-> [:traits] (= section-key ::selections) (conj :options))] - (reduce (fn [{e :entry :as acc} field] - (if-not (contains? e field) - acc - (let [{:keys [value repair remove?] :as r} (mend-cards (get e field))] - (cond - remove? (-> acc - (update :entry dissoc field) - (update :repairs conj {:field field :repair repair})) - repair (-> acc - (assoc-in [:entry field] value) - (update :repairs conj (-> r (dissoc :value) (assoc :field field)))) - :else acc)))) - {:entry (cond-> entry - restore-key? (assoc :key entry-key) - drop-spell-lists? (dissoc :spell-lists)) - :repairs (cond-> [] - restore-key? (conj {:field :key :repair :key-restored}) - drop-spell-lists? (conj {:field :spell-lists :repair :spell-lists-not-a-map}))} - fields)))) - -(defn- mend-entries [section-key section] - (reduce-kv (fn [acc entry-key entry] - (let [{e :entry rs :repairs} (mend-entry section-key entry-key entry)] - (cond-> acc - (seq rs) (-> (assoc-in [:section entry-key] e) - (update :repairs into (map #(assoc % :key entry-key) rs)))))) - {:section section :repairs []} - section)) - -(defn mend-plugin - "`mend-section` over one source, then `mend-entry` over each section's entries. - Returns {:plugin p :repairs [{:section k :repair how}]}; an entry repair also names - its :key and :field. A healthy source has no repairs, so nothing is written back. - A section nothing can be read from is left where it is, for salvage to set aside." - [plugin] - (reduce-kv - (fn [acc k v] - (if-not (content-section? k) - acc - (let [{:keys [section repair dropped]} (mend-section v)] - (if dropped - (-> acc (update :plugin dissoc k) (update :repairs conj {:section k :repair dropped})) - (let [{entries :section entry-repairs :repairs} (when (map? section) - (mend-entries k section))] - (cond-> acc - repair (-> (assoc-in [:plugin k] section) - (update :repairs conj {:section k :repair repair})) - (seq entry-repairs) (-> (assoc-in [:plugin k] entries) - (update :repairs into (map #(assoc % :section k) entry-repairs))))))))) - {:plugin plugin :repairs []} - plugin)) - -(defn mend-library - "`mend-plugin` over every source of a {source-name source} library, reading back the - whole library, or a source, stored as text first. Text only counts as a library when - it reads as one: a map keyed by source names. Returns {:library l :repairs [{:source - :section :repair}]}. Runs before salvage on load, so each repair is written back once." - [library] - (let [from-text (when (string? library) (read-back library)) - whole? (and (map? from-text) (every? string? (keys from-text))) - library (if whole? from-text library)] - (if-not (map? library) - {:library library :repairs []} - (cond-> (reduce-kv - (fn [acc src plugin] - (let [from-text (when (string? plugin) (read-back plugin)) - plugin (if (map? from-text) from-text plugin)] - (if (map? plugin) - (let [{p :plugin rs :repairs} (mend-plugin plugin)] - (-> acc - (assoc-in [:library src] p) - (update :repairs into - (cond->> (map #(assoc % :source src) rs) - (map? from-text) (cons {:source src :repair :source-from-text}))))) - (assoc-in acc [:library src] plugin)))) - {:library {} :repairs []} - library) - whole? (update :repairs #(into [{:repair :library-from-text}] %)))))) - -(defn mend-import-data - "Repair freshly read import data before anything else walks it: a whole file stored - as text, then either a single source or a {source-name source} library. - Returns {:data d :repairs [...]}; `d` is not a map only when nothing could be read." - [data] - (let [from-text (when (string? data) (read-back data)) - data (if (map? from-text) from-text data) - whole (when (map? from-text) [{:repair :file-from-text}])] - (cond - (not (map? data)) - {:data data :repairs []} - - (and (seq data) (every? string? (keys data))) - (let [{l :library rs :repairs} (mend-library data)] - {:data l :repairs (into (vec whole) rs)}) - - :else - (let [{p :plugin rs :repairs} (mend-plugin data)] - {:data p :repairs (into (vec whole) rs)})))) - (defn salvage-plugin-items "Per-ENTRY salvage of ONE source: splits each content group's items by `valid-item?` (a fn of [content-type item-key item]) into {:kept :rejected }, so one bad entry is set @@ -333,7 +147,7 @@ v) ;; A content section that is text, a list, a number cannot load. Kept, it ;; loaded silently and made export refuse the whole source; set aside, the - ;; needs-attention panel can export or discard it. mend-section has already + ;; needs-attention panel can export or discard it. homebrew-shapes/read-section has already ;; recovered whatever was readable before this runs. (damaged-section? k v) (assoc-in acc [:rejected k] v) diff --git a/src/cljs/orcpub/dnd/e5/db.cljs b/src/cljs/orcpub/dnd/e5/db.cljs index d2f5ba3da..3dde14d59 100644 --- a/src/cljs/orcpub/dnd/e5/db.cljs +++ b/src/cljs/orcpub/dnd/e5/db.cljs @@ -771,10 +771,10 @@ (assoc cofx ::e5/plugins (when-let [stored (get-local-storage-item local-storage-plugins-key)] - ;; Put damaged shapes back first (e5/mend-library), a library stored as + ;; Put damaged structure back first (homebrew-shapes/read-library), a library stored as ;; text included. The mended library is written back below, so each ;; repair happens once. - (let [{mended :library repairs :repairs} (e5/mend-library stored)] + (let [{mended :library repairs :repairs} (shapes/read-library stored)] (if (not (map? mended)) ;; Still not a library: preserve raw in the :corrupt slot — NOT ;; :rejected, a clean name-keyed map we must not clobber — and load diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index a8c123efc..f6d8bce53 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -6692,13 +6692,10 @@ :after (fn [ctx] (update ctx :effects #(homebrew-check/homebrew-shapes-checked (get-in ctx [:coeffects :db]) %))))) -;; An event that throws changes nothing: re-frame applies no effect of a handler that did not -;; finish. This says so to the person, at most once a few seconds for the same event, and keeps -;; the error in the console with the event that raised it. re-frame's own handler rethrew, which -;; drops every event still queued (this notice with them), so a click that failed did nothing -;; visible. core.cljs registers it once startup is done: startup's recovery needs -;; :initialize-db to throw, and the test builds, which never load core, keep re-frame's handler, -;; so a throw still fails a test. See docs/HOMEBREW_GUARD.md, 5. +;; An event that throws changes nothing (re-frame applies no effect of an unfinished handler): +;; event-failed says so, once a few seconds per event, and logs it; re-frame's own handler rethrew, +;; dropping every queued event. core.cljs registers it after startup, whose recovery needs +;; :initialize-db to throw; the test builds keep re-frame's. See docs/HOMEBREW_GUARD.md, 5. (def ^:private last-event-error (atom nil)) (def event-failed-message diff --git a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs index 59d6ae717..f39c30984 100644 --- a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs +++ b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs @@ -10,6 +10,7 @@ ;; the rename history (:former-keys) is written here and read by the heal path [orcpub.dnd.e5.content-reconciliation :as content-recon] [orcpub.dnd.e5.library-links :as links] + [orcpub.dnd.e5.homebrew-shapes :as shapes] [orcpub.common :as common])) ;; Forward declarations for functions used before definition @@ -1804,8 +1805,22 @@ ;; Main Validation Entry Point ;; ============================================================================ +(defn finding-description + "Import-log wording for one homebrew-shapes finding (homebrew-shapes/fix-library): a field fixed, + or one that cannot be read, whose entry is set aside for repair when the import is stored." + [{:keys [source content-type key path action found]}] + (let [entry (if (some? key) + (str (get content-type-singular content-type "Entry") " " key) + (str "the " (get content-type-names content-type (name content-type)) " section")) + in (when (seq source) (str " in \"" source "\"")) + field (str/join " " (map #(if (keyword? %) (name %) (str %)) path))] + (if (= :fixed action) + (str "Fixed " (if (seq field) field "the value") " of " entry in " (it was " (pr-str found) ")") + (str entry in ": " (if (seq field) field "it") " could not be read, so it will be set aside for " + "repair in My Content")))) + (defn repair-description - "Import-log wording for one e5/mend-import-data repair." + "Import-log wording for one homebrew-shapes/read-import repair." [{:keys [source section key field repair named dropped]}] (let [what (when section (str "the " (get content-type-names section (name section)) " section")) in (when source (str " in \"" source "\"")) @@ -1902,15 +1917,25 @@ (if (:success parse-result) - ;; Step 2.25: put damaged sections back in shape before anything walks them. - ;; A section stored as text or as a list still holds its data; left alone it - ;; imported as "successful" and failed later at export, and a whole file - ;; stored as text crashed the steps below. - (let [{mended :data repairs :repairs} (e5/mend-import-data (:data parse-result)) - repair-changes (mapv (fn [r] {:type :repaired-section - :description (repair-description r) - :entry (when (:key r) [(:source r) (:section r) (:key r)])}) - repairs)] + ;; Step 2.25: damaged structure read back (a file, source or section kept as text or a list) + ;; before anything walks it, then the shape table's fixes, each listed. An entry it cannot + ;; read stays as written, and is set aside for repair when the import is stored. + (let [{read-data :data repairs :repairs} (shapes/read-import (:data parse-result)) + library? (and (map? read-data) (every? string? (keys read-data))) + {fixed :library findings :findings} (when (map? read-data) + (shapes/fix-library (if library? read-data {"" read-data}))) + mended (cond (not (map? read-data)) read-data + library? fixed + :else (get fixed "")) + repair-changes (into (mapv (fn [r] {:type :repaired-section + :description (repair-description r) + :entry (when (:key r) [(:source r) (:section r) (:key r)])}) + repairs) + (map (fn [f] {:type :repaired-section + :description (finding-description f) + :entry (when (:key f) + [(not-empty (:source f)) (:content-type f) (:key f)])})) + findings)] (if-not (map? mended) {:success false :errors ["This file doesn't contain homebrew content that can be read."] diff --git a/src/cljs/orcpub/dnd/e5/views/import_log.cljs b/src/cljs/orcpub/dnd/e5/views/import_log.cljs index 02f1d91a5..fd8725248 100644 --- a/src/cljs/orcpub/dnd/e5/views/import_log.cljs +++ b/src/cljs/orcpub/dnd/e5/views/import_log.cljs @@ -57,7 +57,7 @@ [:span [:i.fa.fa-font.m-r-5 {:style {:color "#47eaf8"}}] (or description "Normalized Unicode characters to ASCII")] - ;; e5/mend-import-data: a section stored as text or as a list, read back + ;; homebrew-shapes/read-import and fix-library: a damaged section read back, a field fixed :repaired-section [:span [:i.fa.fa-wrench.m-r-5] description] diff --git a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj index 1d822ff6f..658217db9 100644 --- a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj +++ b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj @@ -96,3 +96,14 @@ {kept :library set-aside :set-aside} (shapes/split-unreadable cleaned lib findings)] (is (= {"P" {:orcpub.dnd.e5/races {:ok {:name "Ok"}}}} kept)) (is (= {"P" {:orcpub.dnd.e5/races {:bad bad} :orcpub.dnd.e5/feats 42}} set-aside)))) + +(deftest a-field-holding-nil-reads-as-absent + ;; the class builder stores :spell-list-kw and :ability as nil on purpose (orcbrew-validation's + ;; nil-preserve-fields); such a class must load, not be set aside + (let [cls {:name "Warden" :key :warden + :spellcasting {:spell-list-kw nil :ability nil :level-factor 3} + :description nil} + lib {"P" {:orcpub.dnd.e5/classes {:warden cls}}} + {cleaned :library findings :findings} (shapes/clean-library lib)] + (is (empty? findings)) + (is (identical? lib cleaned)))) diff --git a/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj b/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj new file mode 100644 index 000000000..7fb0a55c3 --- /dev/null +++ b/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj @@ -0,0 +1,123 @@ +(ns orcpub.dnd.e5.homebrew-structure-test + "How a library is stored, put back before its fields are read (homebrew-shapes/read-*), and the + table's own fixes for cards and keys. These were e5/mend-*, now folded into homebrew-shapes: + one repair path." + (:require [clojure.test :refer [deftest testing is]] + [clojure.spec.alpha :as spec] + [orcpub.dnd.e5 :as e5] + [orcpub.dnd.e5.homebrew-shapes :as shapes])) + +(def ^:private spell-a {:option-pack "P" :key :fire-bolt :name "Fire Bolt" :level 0 :school "evocation"}) +(def ^:private spell-b {:option-pack "P" :key :witch-bolt :name "Witch Bolt" :level 1 :school "evocation"}) + +(deftest read-section-recovers-the-shapes-damage-takes + (testing "a section stored as text" + (is (= {:section {:fire-bolt spell-a} :repair :section-from-text} + (shapes/read-section (pr-str {:fire-bolt spell-a}))))) + (testing "a section stored as a list, keyed by each entry's own key" + (is (= {:section {:fire-bolt spell-a :witch-bolt spell-b} :repair :section-from-list} + (shapes/read-section [spell-a spell-b])))) + (testing "a list entry without a key gets the key its name derives" + (is (= :fire-bolt (-> (shapes/read-section [(dissoc spell-a :key)]) :section :fire-bolt :key)))) + (testing "an empty section is dropped" + (doseq [v [nil [] "" " " '()]] + (is (= {:dropped :empty-section} (shapes/read-section v)) (pr-str v)))) + (testing "a good section and a true/false are left alone" + (is (= {:section {:fire-bolt spell-a}} (shapes/read-section {:fire-bolt spell-a}))) + (is (= {:section true} (shapes/read-section true))))) + +(deftest read-section-does-not-guess + (testing "text that does not read as entries" + (is (= {:damaged "corrupted"} (shapes/read-section "corrupted")))) + (testing "two list entries claiming one key: keying them would lose one" + (is (contains? (shapes/read-section [spell-a spell-a]) :damaged))) + (testing "a list element that is not an entry" + (is (contains? (shapes/read-section [spell-a 42]) :damaged)))) + +(deftest read-source-reports-each-repair + (let [{s :source rs :repairs} (shapes/read-source {:orcpub.dnd.e5/spells (pr-str {:fire-bolt spell-a}) + :orcpub.dnd.e5/feats nil + :disabled? false})] + (is (= {:fire-bolt spell-a} (:orcpub.dnd.e5/spells s))) + (is (not (contains? s :orcpub.dnd.e5/feats))) + (is (false? (:disabled? s)) "non-content keys are untouched") + (is (= #{{:section :orcpub.dnd.e5/spells :repair :section-from-text} + {:section :orcpub.dnd.e5/feats :repair :empty-section}} + (set rs)))) + (testing "a healthy source reports no repairs, so nothing is written back" + (is (= [] (:repairs (shapes/read-source {:orcpub.dnd.e5/races {:fine {:key :fine :name "Fine"}}})))))) + +(deftest a-read-section-passes-the-load-check + (is (spec/valid? ::e5/plugin (:source (shapes/read-source {:orcpub.dnd.e5/spells [spell-a spell-b]}))))) + +(deftest read-library-reads-back-what-was-stored-as-text + (testing "a source" + (let [{l :library rs :repairs} (shapes/read-library {"P" (pr-str {:orcpub.dnd.e5/spells {:fire-bolt spell-a}})})] + (is (= {:fire-bolt spell-a} (get-in l ["P" :orcpub.dnd.e5/spells]))) + (is (= [{:source "P" :repair :source-from-text}] rs)))) + (testing "the whole library" + (let [lib {"P" {:orcpub.dnd.e5/feats {:tough {:option-pack "P" :key :tough :name "Tough"}}}} + {:keys [library repairs]} (shapes/read-library (pr-str lib))] + (is (= lib library)) + (is (= [{:repair :library-from-text}] repairs)))) + (testing "text that reads as a single source is not taken for a library" + (is (string? (:library (shapes/read-library (pr-str {:orcpub.dnd.e5/feats {}})))))) + (testing "text that does not read stays as it is" + (is (= "nope {" (:library (shapes/read-library "nope {")))))) + +(deftest read-import-reads-a-whole-file-stored-as-text + (let [{d :data rs :repairs} (shapes/read-import (pr-str {:orcpub.dnd.e5/spells [spell-a]}))] + (is (= {:fire-bolt spell-a} (:orcpub.dnd.e5/spells d))) + (is (= [:file-from-text :section-from-list] (map :repair rs))))) + +(defn- clean + "`entry` of `content-type`, filed under `k` in source \"P\", through clean-library: + {:entry e' :findings [[path action] ...]}." + [content-type k entry] + (let [{l :library fs :findings} (shapes/clean-library {"P" {content-type {k entry}}})] + {:entry (get-in l ["P" content-type k]) + :findings (set (map (juxt :path :action) fs))})) + +(deftest a-card-list-is-put-back-together + (let [race #(clean :orcpub.dnd.e5/races :elfish {:key :elfish :name "Elfish" :traits %})] + (testing "text where a card belongs is a card with that name" + (is (= [{:name "Darkvision"} {:name "B"}] (:traits (:entry (race ["Darkvision" {:name "B"}])))))) + (testing "a lone card, or lone text, is a list of one" + (is (= [{:name "Solo"}] (:traits (:entry (race {:name "Solo"}))))) + (is (= [{:name "Darkvision"}] (:traits (:entry (race "Darkvision")))))) + (testing "anything else cannot be read, so the entry is set aside (it was dropped with the field)" + (is (contains? (:findings (race 42)) [[:traits] :unreadable])) + (is (contains? (:findings (race ["Darkvision" 42])) [[:traits 1] :unreadable]))) + (testing "a sound list is left alone" + (is (empty? (:findings (race [{:name "A"}]))))))) + +(deftest a-key-that-is-not-a-key-takes-the-one-it-is-filed-under + ;; a text key stopped the app from starting + (let [{:keys [entry findings]} (clean :orcpub.dnd.e5/races :elfish {:key "text" :name "Elfish"})] + (is (= :elfish (:key entry))) + (is (contains? findings [[:key] :fixed])))) + +(deftest a-selection-whose-options-are-not-a-list-is-set-aside + (is (contains? (:findings (clean :orcpub.dnd.e5/selections :pick {:key :pick :name "Pick" :options 7})) + [[:options] :unreadable]))) + +(deftest a-spell-whose-class-lists-cannot-be-read-is-set-aside + ;; it stopped the character options from building; it used to load without its class lists + (is (contains? (:findings (clean :orcpub.dnd.e5/spells :bolt {:key :bolt :name "Bolt" :spell-lists 42})) + [[:spell-lists] :unreadable])) + (is (empty? (:findings (clean :orcpub.dnd.e5/spells :fine {:key :fine :name "Fine" :spell-lists {:wizard true}}))))) + +(deftest a-subrace-or-subclass-without-its-parent-cannot-be-read + ;; it would never be shown, with nothing to say why + (is (contains? (:findings (clean :orcpub.dnd.e5/subraces :kin {:key :kin :name "Kin"})) [[:race] :unreadable])) + (is (contains? (:findings (clean :orcpub.dnd.e5/subclasses :oath {:key :oath :name "Oath" :class nil})) + [[:class] :unreadable]))) + +(deftest fix-library-keeps-an-unreadable-entry-as-written + ;; the shape check sets it aside, with that original, when the import is stored + (let [bad {:key :bad :name "Bad" :abilities 42} + lib {"P" {:orcpub.dnd.e5/races {:hill {:key :hill :name "Hill" :speed "25"} :bad bad}}} + {:keys [library findings]} (shapes/fix-library lib)] + (is (= 25 (get-in library ["P" :orcpub.dnd.e5/races :hill :speed])) "a fix is applied") + (is (= bad (get-in library ["P" :orcpub.dnd.e5/races :bad])) "the unreadable entry is untouched") + (is (some #(= [:bad :unreadable] ((juxt :key :action) %)) findings)))) diff --git a/test/clj/orcpub/dnd/e5_test.clj b/test/clj/orcpub/dnd/e5_test.clj index e0cfc4c32..3e418d45f 100644 --- a/test/clj/orcpub/dnd/e5_test.clj +++ b/test/clj/orcpub/dnd/e5_test.clj @@ -511,60 +511,10 @@ (is (= {} (e5/reconcile-rejected-items nil nil nil))))) ;; --------------------------------------------------------------------------- -;; Mending damaged sections, and setting aside what cannot be mended +;; Setting aside what cannot be loaded. Reading damaged structure back, and fixing fields: +;; homebrew_structure_test, homebrew_shape_table_test. ;; --------------------------------------------------------------------------- -(def ^:private spell-a {:option-pack "P" :key :fire-bolt :name "Fire Bolt" :level 0 :school "evocation"}) -(def ^:private spell-b {:option-pack "P" :key :witch-bolt :name "Witch Bolt" :level 1 :school "evocation"}) - -(deftest mend-section-recovers-the-shapes-damage-takes - (testing "a section stored as text" - (is (= {:section {:fire-bolt spell-a} :repair :section-from-text} - (e5/mend-section (pr-str {:fire-bolt spell-a}))))) - (testing "a section stored as a list, keyed by each entry's own key" - (is (= {:section {:fire-bolt spell-a :witch-bolt spell-b} :repair :section-from-list} - (e5/mend-section [spell-a spell-b])))) - (testing "a list entry without a key gets the key its name derives" - (is (= :fire-bolt (-> (e5/mend-section [(dissoc spell-a :key)]) :section :fire-bolt :key)))) - (testing "an empty section is dropped" - (doseq [v [nil [] "" " " '()]] - (is (= {:dropped :empty-section} (e5/mend-section v)) (pr-str v)))) - (testing "a good section and a true/false are left alone" - (is (= {:section {:fire-bolt spell-a}} (e5/mend-section {:fire-bolt spell-a}))) - (is (= {:section true} (e5/mend-section true))))) - -(deftest mend-section-does-not-guess - (testing "text that does not read as entries" - (is (= {:damaged "corrupted"} (e5/mend-section "corrupted")))) - (testing "two list entries claiming one key -- keying them would lose one" - (is (contains? (e5/mend-section [spell-a spell-a]) :damaged))) - (testing "a list element that is not an entry" - (is (contains? (e5/mend-section [spell-a 42]) :damaged)))) - -(deftest mend-plugin-reports-each-repair - (let [{p :plugin rs :repairs} (e5/mend-plugin {:orcpub.dnd.e5/spells (pr-str {:fire-bolt spell-a}) - :orcpub.dnd.e5/feats nil - :disabled? false})] - (is (= {:fire-bolt spell-a} (:orcpub.dnd.e5/spells p))) - (is (not (contains? p :orcpub.dnd.e5/feats))) - (is (false? (:disabled? p)) "non-content keys are untouched") - (is (= #{{:section :orcpub.dnd.e5/spells :repair :section-from-text} - {:section :orcpub.dnd.e5/feats :repair :empty-section}} - (set rs))))) - -(deftest mend-library-reads-a-source-stored-as-text - (let [{l :library rs :repairs} (e5/mend-library {"P" (pr-str {:orcpub.dnd.e5/spells {:fire-bolt spell-a}})})] - (is (= {:fire-bolt spell-a} (get-in l ["P" :orcpub.dnd.e5/spells]))) - (is (= [{:source "P" :repair :source-from-text}] rs)))) - -(deftest mend-import-data-reads-a-whole-file-stored-as-text - (let [{d :data rs :repairs} (e5/mend-import-data (pr-str {:orcpub.dnd.e5/spells [spell-a]}))] - (is (= {:fire-bolt spell-a} (:orcpub.dnd.e5/spells d))) - (is (= [:file-from-text :section-from-list] (map :repair rs))))) - -(deftest a-mended-section-passes-the-load-check - (is (spec/valid? ::e5/plugin (:plugin (e5/mend-plugin {:orcpub.dnd.e5/spells [spell-a spell-b]}))))) - (deftest salvage-sets-aside-a-damaged-section-and-keeps-the-rest (let [{:keys [kept rejected]} (e5/salvage-plugin-items content-specs/valid-item-for-load? @@ -597,16 +547,6 @@ (e5/merge-plugins {:orcpub.dnd.e5/spells {:a 1}} {:orcpub.dnd.e5/spells {:b 2} :orcpub.dnd.e5/feats {:c 3}})))) -(deftest mend-library-reads-back-a-library-stored-as-text - (let [lib {"P" {:orcpub.dnd.e5/feats {:tough {:option-pack "P" :key :tough :name "Tough"}}}} - {:keys [library repairs]} (e5/mend-library (pr-str lib))] - (is (= lib library)) - (is (= [{:repair :library-from-text}] repairs))) - (testing "text that reads as a single source is not taken for a library" - (is (string? (:library (e5/mend-library (pr-str {:orcpub.dnd.e5/feats {}})))))) - (testing "text that does not read stays as it is" - (is (= "nope {" (:library (e5/mend-library "nope {")))))) - (deftest an-entry-with-no-source-takes-the-name-of-its-source (let [lib {"My Pak" {:orcpub.dnd.e5/spells {:a {:key :a :option-pack "My Pak" :name "A"} :b {:key :b :name "B"} @@ -628,41 +568,6 @@ (is (= "garbage" (get-in (:plugin (e5/fill-missing-sources {:orcpub.dnd.e5/feats {:x "garbage"}} "Mine")) [:orcpub.dnd.e5/feats :x]))))) -(deftest mend-cards-puts-a-card-list-back-together - (is (= {:value [{:name "A"}]} (e5/mend-cards [{:name "A"}])) "a sound list is left alone") - (is (= {:value [{:name "Darkvision"} {:name "B"}] :repair :cards-mended :named 1 :dropped 1} - (e5/mend-cards ["Darkvision" 42 {:name "B"}]))) - (is (= [{:name "Solo"}] (:value (e5/mend-cards {:name "Solo"})))) - (is (= [{:name "Darkvision"}] (:value (e5/mend-cards "Darkvision")))) - (is (= {:remove? true :repair :cards-not-a-list} (e5/mend-cards 42))) - (is (= {:remove? true :repair :cards-not-a-list} (e5/mend-cards :kw)))) - -(deftest mend-plugin-repairs-damaged-entries-and-says-which - (let [plugin {:orcpub.dnd.e5/races {:elfish {:key "text" :name "Elfish" :option-pack "P" - :traits [42 "Keen Senses"] :options 7} - :fine {:key :fine :name "Fine" :option-pack "P" :traits [{:name "T"}]}} - :orcpub.dnd.e5/selections {:pick {:key :pick :name "Pick" :option-pack "P" :options 7}}} - {p :plugin rs :repairs} (e5/mend-plugin plugin)] - (is (= :elfish (get-in p [:orcpub.dnd.e5/races :elfish :key])) "a text key stopped the app from starting") - (is (= [{:name "Keen Senses"}] (get-in p [:orcpub.dnd.e5/races :elfish :traits]))) - (is (= 7 (get-in p [:orcpub.dnd.e5/races :elfish :options])) ":options is only a card list on a selection") - (is (= (get-in plugin [:orcpub.dnd.e5/races :fine]) (get-in p [:orcpub.dnd.e5/races :fine])) "a sound entry is untouched") - (is (not (contains? (get-in p [:orcpub.dnd.e5/selections :pick]) :options))) - (is (= #{[:elfish :key :key-restored] [:elfish :traits :cards-mended] [:pick :options :cards-not-a-list]} - (set (map (juxt :key :field :repair) rs))))) - (testing "a healthy source reports no repairs, so nothing is written back" - (is (= [] (:repairs (e5/mend-plugin {:orcpub.dnd.e5/races {:fine {:key :fine :name "Fine" :option-pack "P" - :traits [{:name "T"}]}}})))))) - -(deftest a-spell-whose-class-lists-are-not-a-map-keeps-loading-without-them - (let [{p :plugin rs :repairs} (e5/mend-plugin {:orcpub.dnd.e5/spells - {:bolt {:key :bolt :name "Bolt" :option-pack "P" :spell-lists 42} - :fine {:key :fine :name "Fine" :option-pack "P" :spell-lists {:wizard true}}}})] - (is (= {:key :bolt :name "Bolt" :option-pack "P"} (get-in p [:orcpub.dnd.e5/spells :bolt])) - "it stopped the character options from building") - (is (= {:wizard true} (get-in p [:orcpub.dnd.e5/spells :fine :spell-lists]))) - (is (= [[:bolt :spell-lists :spell-lists-not-a-map]] (map (juxt :key :field :repair) rs))))) - (deftest test-rekey-plugin-records-the-old-key-and-repoints-links (let [plugin {:orcpub.dnd.e5/subclasses {:oath {:name "Oath" :class :9-lives}} :orcpub.dnd.e5/classes {:9-lives {:name "Nine Lives" :option-pack "P"}}} diff --git a/test/clj/orcpub/library_gate_test.clj b/test/clj/orcpub/library_gate_test.clj index c8512d267..c4efe0898 100644 --- a/test/clj/orcpub/library_gate_test.clj +++ b/test/clj/orcpub/library_gate_test.clj @@ -29,8 +29,10 @@ (testing "every write is marked" (is (empty? (remove (fn [{:keys [text]}] (some #(str/includes? text %) markers)) writes)) "a library write outside the gate: route it through events/commit-library")) + ;; homebrew_check holds the shape check every event's write passes through, registered as a + ;; global interceptor in events.cljs beside commit-library: part of the gate (testing "and the gate is where it says" - (is (every? #(re-find #"events\.cljs$|db\.cljs$" (:file %)) + (is (every? #(re-find #"events\.cljs$|db\.cljs$|homebrew_check\.cljs$" (:file %)) (filter #(str/includes? (:text %) ";; library gate") writes)))) (testing "the scan finds the gate at all" (is (<= 3 (count (filter #(str/includes? (:text %) ";; library gate") writes))))))) diff --git a/test/cljs/orcpub/dnd/e5/db_test.cljs b/test/cljs/orcpub/dnd/e5/db_test.cljs index bba4fbda8..e2011ba00 100644 --- a/test/cljs/orcpub/dnd/e5/db_test.cljs +++ b/test/cljs/orcpub/dnd/e5/db_test.cljs @@ -178,11 +178,19 @@ "so a second load changes nothing"))) (deftest loading-repairs-a-damaged-entry-once + ;; a text key stopped the app from starting; traits that cannot be read set the entry aside (.setItem js/window.localStorage db/local-storage-plugins-key - "{\"Bad Pak\" {:orcpub.dnd.e5/races {:x {:key \"text\" :option-pack \"Bad Pak\" :name \"X\" :traits 42}}}}") - (let [race (get-in (load-plugins!) ["Bad Pak" :orcpub.dnd.e5/races :x])] - (is (= :x (:key race)) "a text key stopped the app from starting") - (is (not (contains? race :traits)))) + (str "{\"Bad Pak\" {:orcpub.dnd.e5/races {" + ":x {:key \"text\" :option-pack \"Bad Pak\" :name \"X\" :traits \"Keen\"} " + ":y {:key :y :option-pack \"Bad Pak\" :name \"Y\" :traits 42}}}}")) + (let [races (get-in (load-plugins!) ["Bad Pak" :orcpub.dnd.e5/races])] + (is (= :x (get-in races [:x :key]))) + (is (= [{:name "Keen"}] (get-in races [:x :traits]))) + (is (not (contains? races :y))) + (is (= 42 (get-in (reader/read-string (.getItem js/window.localStorage + db/local-storage-plugins-rejected-key)) + ["Bad Pak" :orcpub.dnd.e5/races :y :traits])) + "set aside as written")) (let [stored (.getItem js/window.localStorage db/local-storage-plugins-key)] (is (not (.includes stored "\"text\"")) "written back") (load-plugins!) diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index b802e4659..5c2cf0991 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -16,6 +16,7 @@ [orcpub.template :as t] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.homebrew-guard :as guard] + [orcpub.dnd.e5.homebrew-shapes :as shapes] [orcpub.dnd.e5.views.guards :as guards] [orcpub.dnd.e5.library :as library] [orcpub.dnd.e5.options :as opt5e] @@ -575,7 +576,7 @@ (with-redefs [views/my-content-type (fn [& _] (canary))] (is (caught? (canary-run) #(= [:page :my-content] (take 2 (:stage %))))))) (testing "startup that throws" - (with-redefs [e5/mend-library (fn [_] (canary))] + (with-redefs [shapes/read-library (fn [_] (canary))] (is (caught? (canary-run) #(= :startup (:stage %)))))) (testing "the deep check that throws" (with-redefs [homebrew-check/deep-check (fn [_] (canary))] diff --git a/test/cljs/orcpub/dnd/e5/orcbrew_validation_test.cljs b/test/cljs/orcpub/dnd/e5/orcbrew_validation_test.cljs index a9f3c0dbe..91e5930f1 100644 --- a/test/cljs/orcpub/dnd/e5/orcbrew_validation_test.cljs +++ b/test/cljs/orcpub/dnd/e5/orcbrew_validation_test.cljs @@ -1588,15 +1588,23 @@ (get-in (import! "{:orcpub.dnd.e5/feats {:tough {:key :tough :name \"Tough\"}}}") [:data :orcpub.dnd.e5/feats :tough :option-pack])))))) -(deftest import-mends-card-lists-and-keys-instead-of-crashing - (let [result (orcbrew-val/validate-import - "{\"P\" {:orcpub.dnd.e5/classes {:x {:key \"text\" :option-pack \"P\" :name \"X\" :traits [42 \"Darkvision\"]}}}}" - {:strategy :progressive :auto-clean true})] - (is (:success result) "it threw: No protocol method IAssociative.-assoc defined for type number") - (is (= :x (get-in result [:data "P" :orcpub.dnd.e5/classes :x :key]))) - (is (= "Darkvision" (get-in result [:data "P" :orcpub.dnd.e5/classes :x :traits 0 :name]))) - (is (some #(re-find #"Repaired 1 damaged entry" %) - (map #(if (map? %) (:text %) %) (:details (orcbrew-val/format-import-result result))))))) +(deftest import-fixes-cards-and-keys-and-keeps-what-it-cannot-read-instead-of-crashing + (testing "a text key and a trait listed by name are fixed, and the import log says so" + (let [result (orcbrew-val/validate-import + "{\"P\" {:orcpub.dnd.e5/classes {:x {:key \"text\" :option-pack \"P\" :name \"X\" :traits [\"Darkvision\"]}}}}" + {:strategy :progressive :auto-clean true})] + (is (:success result) "it threw: No protocol method IAssociative.-assoc defined for type number") + (is (= :x (get-in result [:data "P" :orcpub.dnd.e5/classes :x :key]))) + (is (= "Darkvision" (get-in result [:data "P" :orcpub.dnd.e5/classes :x :traits 0 :name]))) + (is (some #(re-find #"Fixed traits 0 of Class :x" (str (:description %))) (:changes result))))) + (testing "a trait that cannot be read: the entry stays as written, to be set aside when stored" + (let [result (orcbrew-val/validate-import + "{\"P\" {:orcpub.dnd.e5/classes {:x {:key \"text\" :option-pack \"P\" :name \"X\" :traits [42 \"Darkvision\"]}}}}" + {:strategy :progressive :auto-clean true})] + (is (:success result)) + (is (= [42 "Darkvision"] (get-in result [:data "P" :orcpub.dnd.e5/classes :x :traits]))) + (is (some #(re-find #"could not be read, so it will be set aside" (str (:description %))) + (:changes result)))))) (deftest import-of-a-source-that-is-not-a-map-does-not-crash (doseq [text ["{\"P\" 42}" "{\"P\" :kw}" "{\"P\" \"text\"}"]] From 59f255eb8ca71e419d090bfc7c94642e31836489 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Mon, 26 Oct 2026 20:14:54 +0000 Subject: [PATCH 22/31] Put the view guards back in views.cljs Reverts the move to views/guards.cljs: seven functions, about 220 lines, are not a namespace's worth. A namespace holds a worthwhile family of related code (AGENTS.md, "Shrinking the big files"); one file per small group would scatter views into dozens. No behaviour change. views.cljs 11,342 -> 11,557 lines again. --- docs/HOMEBREW_GUARD.md | 5 +- src/cljs/orcpub/dnd/e5/views.cljs | 219 ++++++++++++++++++- src/cljs/orcpub/dnd/e5/views/guards.cljs | 224 -------------------- test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 11 +- web/cljs/orcpub/core.cljs | 5 +- 5 files changed, 227 insertions(+), 237 deletions(-) delete mode 100644 src/cljs/orcpub/dnd/e5/views/guards.cljs diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md index 6b3e00ce6..36dbc4122 100644 --- a/docs/HOMEBREW_GUARD.md +++ b/docs/HOMEBREW_GUARD.md @@ -382,8 +382,9 @@ alone 0.001 ms; an event that changes one entry 0.05 ms, then its deep check 35 are present; the table checks the form of those that are. Found on the way and fixed: the table counted nil as unreadable, which would have set aside a class saved with a nil `:spell-list-kw` on load; and an unchanged entry dropped its findings. Moved, no behaviour - change: the view guards to `views/guards.cljs` (views.cljs 11,557 to 11,342 lines); the - set-aside and write-check logic to `homebrew_check.cljs` (events.cljs 8,904 to 8,805). + change: the set-aside and write-check logic to `homebrew_check.cljs`, which already held the + rest of this wiring (events.cljs 8,904 to 8,805). The view guards stay in views.cljs: ~220 + lines are not a namespace's worth (owner, 2026-10-26). 7. **Enforcement:** the audit in CI at zero, the AGENTS.md rules, the documents corrected, the testing/develop scenarios gone through. 8. **grant-rows** merges this, adds its fields to the table, and runs the audit. diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index d0bd09811..570de7222 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -60,8 +60,6 @@ [orcpub.fork.user-tier] [orcpub.ver :as v] [clojure.string :as s] - [orcpub.dnd.e5.views.guards :refer [error-boundary render-guard guarded-feature-list - guarded-form]] [orcpub.artist-links :as artist-links] [goog.crypt.base64 :as b64] [cljs.reader :as reader] @@ -2472,6 +2470,127 @@ {:padding "33px 0"}) +(declare error-boundary) + +(defn locate-hint + "Best-effort human locator for a malformed feature, built only from fields that + actually point at a place in the builder — which class/subclass or homebrew + source it came from, and the level it's gained. Never its :name (usually the + missing part), and never its :page: the page is a physical-rulebook reference, + not a place to fix anything in the app. Returns nil when there's nothing + builder-locating to go on (the raw data dump still shows the page etc.)." + [{:keys [class-key source level]}] + (let [where (cond + (string? source) (str "the “" source "” source") + source (str "the “" (common/kw-to-name source) "” source") + class-key (str "your " (s/capitalize (common/kw-to-name class-key))) + :else nil) + bits (cond-> [] + where (conj (str "from " where)) + level (conj (str "gained at level " level)))] + (when (seq bits) (s/join ", " bits)))) + +(defn guard-fallback + "Inline fallback for a single item that threw while rendering: say where it + likely came from (so the user can find and fix it in the builder) and dump its + raw data so the exact malformed value is visible. No per-bug detector required; + works for whatever was being rendered." + [data _error] + (let [hint (try (locate-hint data) (catch :default _ nil))] + [:div.m-t-10.p-5.f-s-12 + {:style {:border "1px dashed #e9a227" :border-radius "4px"}} + [:div.f-w-b "⚠ A feature here couldn’t be displayed"] + (when hint [:div.m-t-2 (str "Likely " hint " — open it in the builder to fix.")]) + [:pre.wsp-prw.f-s-11.m-t-5 (pr-str data)]])) + +(defn render-guard + "Wrap one piece of UI so a render exception is contained to just this item + (showing its raw data via guard-fallback) instead of blanking the whole page. + General — catches any error type, not a specific known failure mode." + [data child] + [error-boundary + (fn [error _stack _retry] [guard-fallback data error]) + child]) + +(defn- without-nth + "items with index i removed (nil-safe, unlike keep-indexed)." + [items i] + (into (subvec items 0 i) (subvec items (inc i)))) + +(defn isolate-culprit + "Fault isolation by re-execution, the active counterpart to render-guard. `render-coll` + processes the collection `items` and may throw. Returns the item(s) whose removal stops + it throwing -- catching aggregate failures (a sort over a nil key) a per-item guard never + sees -- or nil if it doesn't throw. GOTCHA: with two independent faulty items neither is + necessary, so it falls back to the smallest failing subset found by bisection." + [render-coll items] + (letfn [(throws? [coll] + (try (doall (render-coll coll)) false (catch :default _ true))) + (self-bad [coll] + ;; items that still break when paired with a copy of themselves — i.e. + ;; that individually carry the bad value, distinct from an innocent + ;; neighbour only implicated because the failure needs ≥2 elements. + (filterv #(throws? [% %]) coll)) + (bisect [its] + (if (<= (count its) 1) + its + (let [mid (quot (count its) 2) + l (subvec its 0 mid) r (subvec its mid)] + (cond (throws? l) (recur l) + (throws? r) (recur r) + :else its))))] + (let [items (vec items)] + (when (throws? items) + (let [necessary (mapv items (filterv #(not (throws? (without-nth items %))) + (range (count items))))] + (cond + (= 1 (count necessary)) necessary + ;; >1 "necessary" is the comparison-failure trap: removing ANY of a + ;; small set drops below the ≥2 elements the comparator needs, so each + ;; looks necessary. Keep only the ones that break paired with themselves. + (seq necessary) (let [sb (self-bad necessary)] + (if (seq sb) sb necessary)) + ;; nothing individually necessary (independent multi-fault): the items + ;; that break self-paired, else bisect to a smallest failing subset. + :else (let [sb (self-bad items)] + (if (seq sb) sb (bisect items))))))))) + +(defn guarded-feature-list + "Render `items` via `render-coll`. If the whole render throws, LEAD with the + problem — surface the item(s) the isolator traced the failure to, with where to + fix them — then render everything that DID work below. This is the auditing + version of render-guard: it handles failures in the collection-level logic, not + just per-item render throws." + [render-coll items] + ;; Render once: the realized result is reused on the happy path, so the sort and + ;; hiccup build don't run twice on every render — only the error path pays extra. + (let [rendered (try (doall (render-coll items)) (catch :default _ ::failed))] + (if-not (= rendered ::failed) + (into [:<>] rendered) + (let [culprits (isolate-culprit render-coll items) + culprit? (set culprits) + survivors (remove culprit? items)] + [:div + ;; the problem, first and prominent — that's the thing the user must act on + [:div.p-10.m-b-10.f-s-14 + {:style {:border "1px solid #e9a227" :border-radius "5px" + :background "rgba(233,162,39,0.12)"}} + [:div.f-w-b.f-s-16 + (if (= 1 (count culprits)) + "⚠ A feature in this section couldn’t be displayed" + (str "⚠ " (count culprits) " features in this section couldn’t be displayed"))] + (doall + (map-indexed + (fn [i c] + (let [hint (try (locate-hint c) (catch :default _ nil))] + ^{:key i} + [:div.m-t-5 + (when hint [:div.f-w-600 (str "Likely " hint " — open it in the builder to fix.")]) + [:pre.wsp-prw.f-s-11.m-t-2 {:style {:user-select "text"}} (pr-str c)]])) + culprits))] + ;; everything that rendered fine, below + (when (seq survivors) (render-coll survivors))])))) + (defn- vec-remove [v i] (vec (concat (subvec v 0 i) (subvec v (inc i))))) @@ -4467,6 +4586,83 @@ ;; selectable + copy-button so the user can actually grab it [:pre.f-s-12.m-t-5.wsp-prw {:style {:user-select "text"}} report]])]])))) +(defn error-boundary + "React error boundary: if a child throws while rendering, renders + (fallback error component-stack retry) instead of unmounting the whole app. Key it on its + content (e.g. the selected tab) so navigating away clears the error. + GOTCHA: only getDerivedStateFromError re-renders into the fallback; component-did-catch + logs the component stack and stores it for the fallback." + [_fallback _child] + (r/create-class + {:display-name "error-boundary" + :constructor (fn [this _props] + (set! (.-state this) #js {:error nil :componentStack nil})) + :get-derived-state-from-error (fn [error] #js {:error error}) + :component-did-catch (fn [this e info] + (let [stack (some-> info .-componentStack)] + (js/console.error "[character render error]" e stack) + ;; Stash the component stack so the recovery panel can show + ;; *which* component threw — the only location info we can + ;; offer for a failure we didn't anticipate. + (.setState this #js {:componentStack stack}))) + :reagent-render + (fn [fallback child] + (let [this (r/current-component)] + (if-let [e (.. this -state -error)] + (fallback e + (.. this -state -componentStack) + ;; Reagent re-renders on new props, not on React state alone, so + ;; clearing the error has to force the redraw as well. + #(.setState this #js {:error nil :componentStack nil} + (fn [] (.forceUpdate this)))) + child)))})) + +(defn app-error-fallback + "Top-level catch-all shown when ANY page throws below the app-root boundary — + the guarantee that the app never black-screens (goal #1). Generic by design (it + can be any page): say something broke, reassure the data is safe, offer recovery, + and expose the error for a report. Finer boundaries below this one give better, + more specific messages where they apply; this is the last line of defense." + [_error _stack retry] + ;; Bring the boot-shell rescue control back: the app rendered cleanly once and + ;; took it away, then this page threw. Getting homebrew out matters most in + ;; exactly the state where the app has stopped being able to export it. + (when-let [rescue (aget js/window "orcpubBootRescue")] + (rescue)) + ;; The page may have failed on homebrew that only breaks once it is drawn. Check every + ;; entry deeply; if any are set aside, try the page again. + (js/setTimeout #(when (seq (homebrew-check/check-and-set-aside! nil)) + (js/setTimeout retry 500)) + 0) + (let [show? (r/atom false) + copied? (r/atom false)] + (fn [error stack retry] + (let [report (str (or (some-> error .-stack) (str error)) + (when stack (str "\n\nComponent stack:" stack)))] + [:div.p-20.f-s-14.main-text-color + [:div.f-w-b.f-s-24.m-b-10 "Something went wrong on this page"] + [:div.m-b-15.l-h-19 + "This page hit an unexpected error and couldn’t finish loading. Your saved " + "data wasn’t affected. Head back to your characters, or reload to try again — " + "if it keeps happening, copy the details below and send them to us."] + [:div.flex.flex-wrap.align-items-c + [:button.form-button.m-r-10.m-b-5 + {:on-click #(set! (.-href js/window.location) "/pages/dnd/5e/characters")} + "Go to my characters"] + [:button.form-button.m-b-5 {:on-click #(.reload js/window.location)} "Reload page"]] + [:div.m-t-10 + [:span.orange.underline.pointer + {:on-click #(swap! show? not)} + (if @show? "Hide details" "Show details")] + (when @show? + [:div + [:button.form-button.f-s-11.m-t-5 + {:on-click (fn [] + (some-> js/navigator .-clipboard (.writeText report)) + (reset! copied? true))} + (if @copied? "Copied!" "Copy")] + [:pre.f-s-12.m-t-5.wsp-prw {:style {:user-select "text"}} report]])]])))) + (defn has-nameless-feature? "Does the built character still carry a feature/action/reaction with no usable name? Used as the re-test for selection-isolation on the proactive banner." @@ -10236,6 +10432,25 @@ " \u201c" (if (keyword? target) (name target) target) "\u201d"))) ", which isn't in your library.")])]))) +(defn guarded-form + "`builder`'s form with each of its sections in a render-guard, so a field that throws while + drawing costs that section and the rest of the form still edits and saves; one that throws + before it has a form shows `item` in the fallback. Every builder returns its form directly + (none keeps state in a render function), so calling it here draws the same thing." + [item builder] + (try + (let [form (builder)] + (if (and (vector? form) (keyword? (first form))) + (let [[tag & more] form + [attrs children] (if (map? (first more)) [(first more) (rest more)] [nil more])] + (into (cond-> [tag] attrs (conj attrs)) + (map #(if (vector? %) [render-guard item %] %)) + children)) + form)) + (catch :default e + (js/console.error "A builder could not draw its form:" e) + [guard-fallback item e]))) + (defn builder-page [item-title reset-event save-event builder & [title]] ;; The draft event and the builder-item sub are both derived from save-event ;; (events/draft-event-for, events/builder-drafts), so the Export-draft hatch and the diff --git a/src/cljs/orcpub/dnd/e5/views/guards.cljs b/src/cljs/orcpub/dnd/e5/views/guards.cljs deleted file mode 100644 index e66cd164e..000000000 --- a/src/cljs/orcpub/dnd/e5/views/guards.cljs +++ /dev/null @@ -1,224 +0,0 @@ -(ns orcpub.dnd.e5.views.guards - "What keeps one broken thing from taking a page down with it: React's error boundary, the - per-item render-guard and its fallback, the feature list that traces a failure to the item - that caused it, a builder's form with each section guarded, and the app-root fallback, the - last resort. See docs/HOMEBREW_GUARD.md, 5." - (:require [reagent.core :as r] - [clojure.string :as s] - [orcpub.common :as common] - [orcpub.dnd.e5.homebrew-check :as homebrew-check])) - -(defn error-boundary - "React error boundary: if a child throws while rendering, renders - (fallback error component-stack retry) instead of unmounting the whole app. Key it on its - content (e.g. the selected tab) so navigating away clears the error. - GOTCHA: only getDerivedStateFromError re-renders into the fallback; component-did-catch - logs the component stack and stores it for the fallback." - [_fallback _child] - (r/create-class - {:display-name "error-boundary" - :constructor (fn [this _props] - (set! (.-state this) #js {:error nil :componentStack nil})) - :get-derived-state-from-error (fn [error] #js {:error error}) - :component-did-catch (fn [this e info] - (let [stack (some-> info .-componentStack)] - (js/console.error "[character render error]" e stack) - ;; Stash the component stack so the recovery panel can show - ;; *which* component threw — the only location info we can - ;; offer for a failure we didn't anticipate. - (.setState this #js {:componentStack stack}))) - :reagent-render - (fn [fallback child] - (let [this (r/current-component)] - (if-let [e (.. this -state -error)] - (fallback e - (.. this -state -componentStack) - ;; Reagent re-renders on new props, not on React state alone, so - ;; clearing the error has to force the redraw as well. - #(.setState this #js {:error nil :componentStack nil} - (fn [] (.forceUpdate this)))) - child)))})) - -(defn locate-hint - "Best-effort human locator for a malformed feature, built only from fields that - actually point at a place in the builder — which class/subclass or homebrew - source it came from, and the level it's gained. Never its :name (usually the - missing part), and never its :page: the page is a physical-rulebook reference, - not a place to fix anything in the app. Returns nil when there's nothing - builder-locating to go on (the raw data dump still shows the page etc.)." - [{:keys [class-key source level]}] - (let [where (cond - (string? source) (str "the “" source "” source") - source (str "the “" (common/kw-to-name source) "” source") - class-key (str "your " (s/capitalize (common/kw-to-name class-key))) - :else nil) - bits (cond-> [] - where (conj (str "from " where)) - level (conj (str "gained at level " level)))] - (when (seq bits) (s/join ", " bits)))) - -(defn guard-fallback - "Inline fallback for a single item that threw while rendering: say where it - likely came from (so the user can find and fix it in the builder) and dump its - raw data so the exact malformed value is visible. No per-bug detector required; - works for whatever was being rendered." - [data _error] - (let [hint (try (locate-hint data) (catch :default _ nil))] - [:div.m-t-10.p-5.f-s-12 - {:style {:border "1px dashed #e9a227" :border-radius "4px"}} - [:div.f-w-b "⚠ A feature here couldn’t be displayed"] - (when hint [:div.m-t-2 (str "Likely " hint " — open it in the builder to fix.")]) - [:pre.wsp-prw.f-s-11.m-t-5 (pr-str data)]])) - -(defn render-guard - "Wrap one piece of UI so a render exception is contained to just this item - (showing its raw data via guard-fallback) instead of blanking the whole page. - General — catches any error type, not a specific known failure mode." - [data child] - [error-boundary - (fn [error _stack _retry] [guard-fallback data error]) - child]) - -(defn- without-nth - "items with index i removed (nil-safe, unlike keep-indexed)." - [items i] - (into (subvec items 0 i) (subvec items (inc i)))) - -(defn isolate-culprit - "Fault isolation by re-execution, the active counterpart to render-guard. `render-coll` - processes the collection `items` and may throw. Returns the item(s) whose removal stops - it throwing -- catching aggregate failures (a sort over a nil key) a per-item guard never - sees -- or nil if it doesn't throw. GOTCHA: with two independent faulty items neither is - necessary, so it falls back to the smallest failing subset found by bisection." - [render-coll items] - (letfn [(throws? [coll] - (try (doall (render-coll coll)) false (catch :default _ true))) - (self-bad [coll] - ;; items that still break when paired with a copy of themselves — i.e. - ;; that individually carry the bad value, distinct from an innocent - ;; neighbour only implicated because the failure needs ≥2 elements. - (filterv #(throws? [% %]) coll)) - (bisect [its] - (if (<= (count its) 1) - its - (let [mid (quot (count its) 2) - l (subvec its 0 mid) r (subvec its mid)] - (cond (throws? l) (recur l) - (throws? r) (recur r) - :else its))))] - (let [items (vec items)] - (when (throws? items) - (let [necessary (mapv items (filterv #(not (throws? (without-nth items %))) - (range (count items))))] - (cond - (= 1 (count necessary)) necessary - ;; >1 "necessary" is the comparison-failure trap: removing ANY of a - ;; small set drops below the ≥2 elements the comparator needs, so each - ;; looks necessary. Keep only the ones that break paired with themselves. - (seq necessary) (let [sb (self-bad necessary)] - (if (seq sb) sb necessary)) - ;; nothing individually necessary (independent multi-fault): the items - ;; that break self-paired, else bisect to a smallest failing subset. - :else (let [sb (self-bad items)] - (if (seq sb) sb (bisect items))))))))) - -(defn guarded-feature-list - "Render `items` via `render-coll`. If the whole render throws, LEAD with the - problem — surface the item(s) the isolator traced the failure to, with where to - fix them — then render everything that DID work below. This is the auditing - version of render-guard: it handles failures in the collection-level logic, not - just per-item render throws." - [render-coll items] - ;; Render once: the realized result is reused on the happy path, so the sort and - ;; hiccup build don't run twice on every render — only the error path pays extra. - (let [rendered (try (doall (render-coll items)) (catch :default _ ::failed))] - (if-not (= rendered ::failed) - (into [:<>] rendered) - (let [culprits (isolate-culprit render-coll items) - culprit? (set culprits) - survivors (remove culprit? items)] - [:div - ;; the problem, first and prominent — that's the thing the user must act on - [:div.p-10.m-b-10.f-s-14 - {:style {:border "1px solid #e9a227" :border-radius "5px" - :background "rgba(233,162,39,0.12)"}} - [:div.f-w-b.f-s-16 - (if (= 1 (count culprits)) - "⚠ A feature in this section couldn’t be displayed" - (str "⚠ " (count culprits) " features in this section couldn’t be displayed"))] - (doall - (map-indexed - (fn [i c] - (let [hint (try (locate-hint c) (catch :default _ nil))] - ^{:key i} - [:div.m-t-5 - (when hint [:div.f-w-600 (str "Likely " hint " — open it in the builder to fix.")]) - [:pre.wsp-prw.f-s-11.m-t-2 {:style {:user-select "text"}} (pr-str c)]])) - culprits))] - ;; everything that rendered fine, below - (when (seq survivors) (render-coll survivors))])))) - -(defn app-error-fallback - "Top-level catch-all shown when ANY page throws below the app-root boundary — - the guarantee that the app never black-screens (goal #1). Generic by design (it - can be any page): say something broke, reassure the data is safe, offer recovery, - and expose the error for a report. Finer boundaries below this one give better, - more specific messages where they apply; this is the last line of defense." - [_error _stack retry] - ;; Bring the boot-shell rescue control back: the app rendered cleanly once and - ;; took it away, then this page threw. Getting homebrew out matters most in - ;; exactly the state where the app has stopped being able to export it. - (when-let [rescue (aget js/window "orcpubBootRescue")] - (rescue)) - ;; The page may have failed on homebrew that only breaks once it is drawn. Check every - ;; entry deeply; if any are set aside, try the page again. - (js/setTimeout #(when (seq (homebrew-check/check-and-set-aside! nil)) - (js/setTimeout retry 500)) - 0) - (let [show? (r/atom false) - copied? (r/atom false)] - (fn [error stack retry] - (let [report (str (or (some-> error .-stack) (str error)) - (when stack (str "\n\nComponent stack:" stack)))] - [:div.p-20.f-s-14.main-text-color - [:div.f-w-b.f-s-24.m-b-10 "Something went wrong on this page"] - [:div.m-b-15.l-h-19 - "This page hit an unexpected error and couldn’t finish loading. Your saved " - "data wasn’t affected. Head back to your characters, or reload to try again — " - "if it keeps happening, copy the details below and send them to us."] - [:div.flex.flex-wrap.align-items-c - [:button.form-button.m-r-10.m-b-5 - {:on-click #(set! (.-href js/window.location) "/pages/dnd/5e/characters")} - "Go to my characters"] - [:button.form-button.m-b-5 {:on-click #(.reload js/window.location)} "Reload page"]] - [:div.m-t-10 - [:span.orange.underline.pointer - {:on-click #(swap! show? not)} - (if @show? "Hide details" "Show details")] - (when @show? - [:div - [:button.form-button.f-s-11.m-t-5 - {:on-click (fn [] - (some-> js/navigator .-clipboard (.writeText report)) - (reset! copied? true))} - (if @copied? "Copied!" "Copy")] - [:pre.f-s-12.m-t-5.wsp-prw {:style {:user-select "text"}} report]])]])))) - -(defn guarded-form - "`builder`'s form with each of its sections in a render-guard, so a field that throws while - drawing costs that section and the rest of the form still edits and saves; one that throws - before it has a form shows `item` in the fallback. Every builder returns its form directly - (none keeps state in a render function), so calling it here draws the same thing." - [item builder] - (try - (let [form (builder)] - (if (and (vector? form) (keyword? (first form))) - (let [[tag & more] form - [attrs children] (if (map? (first more)) [(first more) (rest more)] [nil more])] - (into (cond-> [tag] attrs (conj attrs)) - (map #(if (vector? %) [render-guard item %] %)) - children)) - form)) - (catch :default e - (js/console.error "A builder could not draw its form:" e) - [guard-fallback item e]))) diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index 5c2cf0991..a46bd629c 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -17,7 +17,6 @@ [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.homebrew-guard :as guard] [orcpub.dnd.e5.homebrew-shapes :as shapes] - [orcpub.dnd.e5.views.guards :as guards] [orcpub.dnd.e5.library :as library] [orcpub.dnd.e5.options :as opt5e] [orcpub.dnd.e5.homebrew-check :as homebrew-check] @@ -278,12 +277,12 @@ "Draw `x` the way Reagent would: a vector headed by a function is a child component, so call it (twice for a component that returns its render function) and draw what it returns." [x] - (cond (and (vector? x) (= guards/guard-fallback (first x))) + (cond (and (vector? x) (= views/guard-fallback (first x))) ;; the fallback a guard draws for what it caught, whichever guard (let [[_ data e] x] (swap! contained #(or % (msg e))) - (expand (guards/guard-fallback data e))) - (and (vector? x) (= guards/error-boundary (first x))) + (expand (views/guard-fallback data e))) + (and (vector? x) (= views/error-boundary (first x))) ;; as React does: the child, or the fallback when drawing it throws. What it contains is ;; drawn on screen, so it is not a failure (let [[_ fallback child] x] @@ -392,14 +391,14 @@ (catch :default _ nil)) :when (homebrew? m)] ;; drawn as the list draws each one, inside its guard - [(:key m) #(expand [guards/render-guard m [views/monster-list-item m]])])) + [(:key m) #(expand [views/render-guard m [views/monster-list-item m]])])) (for [[t es] (when builders? entries) :when (map? es) :let [[open builder] (builders t)] :when open [k e] es :when (map? e)] (do (covered! :drawn k) (some-> (once [:builder (hash e)] [:builder k] - #(do (rf/dispatch-sync [open e]) (expand (guards/guarded-form e builder)))) + #(do (rf/dispatch-sync [open e]) (expand (views/guarded-form e builder)))) (assoc :stage [:builder k] :key k))))))) (defn- run-pack! diff --git a/web/cljs/orcpub/core.cljs b/web/cljs/orcpub/core.cljs index 1e85068c6..3cc584712 100644 --- a/web/cljs/orcpub/core.cljs +++ b/web/cljs/orcpub/core.cljs @@ -6,7 +6,6 @@ [orcpub.dnd.e5.events :as events] [orcpub.dnd.e5.db :as db] [orcpub.dnd.e5.views :as views] - [orcpub.dnd.e5.views.guards :as guards] [orcpub.dnd.e5.views-2 :as views-2] [orcpub.dnd.e5.views.conflict-resolution :as conflict-views] [orcpub.dnd.e5.views.whats-new :as whats-new-view] @@ -177,8 +176,8 @@ ;; page is caught here as a last resort; finer boundaries below catch closer ;; and give better messages first. Keyed by route so navigating clears it. ^{:key handler} - [guards/error-boundary - (fn [error stack retry] [guards/app-error-fallback error stack retry]) + [views/error-boundary + (fn [error stack retry] [views/app-error-fallback error stack retry]) [:<> [view (assoc route-params :query query-map)] [boot-ok]]] From c804ea5ba1df19c6c74f11d02e53cb44dc596efd Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 28 Oct 2026 22:33:01 -0400 Subject: [PATCH 23/31] Docstrings for the guard's helper functions and tests --- src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc | 29 +++++++++++---- .../dnd/e5/homebrew_shape_table_test.clj | 9 +++-- .../orcpub/dnd/e5/homebrew_structure_test.clj | 2 +- .../orcpub/dnd/e5/homebrew_shapes_test.cljc | 4 ++- .../dnd/e5/homebrew_interceptor_test.cljs | 4 ++- test/cljs/orcpub/dnd/e5/junk_pack_test.cljs | 36 ++++++++++++++----- 6 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc index 7d0051e22..3d2347e5b 100644 --- a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc +++ b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc @@ -71,7 +71,10 @@ :map "a map" (pr-str shape))))) -(defn- finding [path action v shape] +(defn- finding + "A finding record: the `path` it is at, the `action` taken (:fixed or :unreadable), a + description of the found value `v`, and the name of the wanted `shape`." + [path action v shape] {:path path :action action :found (describe v) :wanted (shape-name shape)}) (defn- parse-number @@ -102,7 +105,10 @@ {:value [] :findings []} (map-indexed vector xs))) -(defn- unreadable [path v shape] +(defn- unreadable + "The result for a value `v` at `path` that cannot be made to fit `shape`: only an :unreadable + finding, no :value." + [path v shape] {:findings [(finding path :unreadable v shape)]}) (defn- fixed @@ -110,10 +116,15 @@ [r path v shape] (update r :findings #(into [(finding path :fixed v shape)] %))) -(defn- lone [v] +(defn- lone + "True when `v` is a single value (not nil, not a collection), which a list or set shape wraps." + [v] (and (some? v) (not (coll? v)))) -(defn- clean-scalar [shape v path] +(defn- clean-scalar + "Text `v` at `path` read as the scalar `shape` (:num or :key), as a :fixed result; any other + shape or unreadable text gives an :unreadable result." + [shape v path] (let [parsed (case shape :num (when (string? v) (parse-number v)) :key (when (string? v) (text->key v)) @@ -122,7 +133,10 @@ {:value parsed :findings [(finding path :fixed v shape)]} (unreadable path v shape)))) -(defn- clean-list [a v path shape] +(defn- clean-list + "`v` cleaned as a list of shape `a`: a sequence is cleaned element by element; a lone value or + map is a list of one (a :fixed result); anything else is :unreadable." + [a v path shape] (cond (sequential? v) (clean-each a v path) (or (map? v) (lone v)) (let [r (clean-value a v (conj path 0))] @@ -151,7 +165,10 @@ (unreadable path v shape))) :else (unreadable path v shape))) -(defn- clean-set [a v path shape] +(defn- clean-set + "`v` cleaned as a set of shape `a`: a set element by element; a sequence or a lone value becomes + a set (a :fixed result); anything else is :unreadable." + [a v path shape] (cond (set? v) (update (clean-each a v path) :value set) (sequential? v) (fixed (update (clean-each a v path) :value set) path v shape) diff --git a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj index 658217db9..a11cfbc1e 100644 --- a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj +++ b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj @@ -6,13 +6,18 @@ [clojure.edn :as edn] [orcpub.dnd.e5.homebrew-shapes :as shapes])) -(defn- fixture [f] +(defn- fixture + "The EDN in test/fixtures/`f`, read with unknown tagged literals kept as tagged-literal values." + [f] (edn/read-string {:default tagged-literal} (slurp (str "test/fixtures/" f)))) (def ^:private sound ["test-pak.orcbrew" "coverage-pak.orcbrew" "sourced-classes.orcbrew" "junk-bases.orcbrew"]) -(defn- library [data] +(defn- library + "`data` as a {source-name source} library: a file holding one bare source is wrapped as + \"Fixture\"." + [data] ;; a file holds one source or a {source-name source} library (if (every? string? (keys data)) data {"Fixture" data})) diff --git a/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj b/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj index 7fb0a55c3..887ba65ed 100644 --- a/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj +++ b/test/clj/orcpub/dnd/e5/homebrew_structure_test.clj @@ -102,7 +102,7 @@ [[:options] :unreadable]))) (deftest a-spell-whose-class-lists-cannot-be-read-is-set-aside - ;; it stopped the character options from building; it used to load without its class lists + ;; it stopped the character options from building; it now sets the spell aside (is (contains? (:findings (clean :orcpub.dnd.e5/spells :bolt {:key :bolt :name "Bolt" :spell-lists 42})) [[:spell-lists] :unreadable])) (is (empty? (:findings (clean :orcpub.dnd.e5/spells :fine {:key :fine :name "Fine" :spell-lists {:wizard true}}))))) diff --git a/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc b/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc index 1375c81f0..1d764cbde 100644 --- a/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc +++ b/test/cljc/orcpub/dnd/e5/homebrew_shapes_test.cljc @@ -11,7 +11,9 @@ :traits [:list card] :size [:or :keyword :string]}]) -(defn- clean [v] (shapes/clean-value entry-shape v [])) +(defn- clean + "Runs `v` through the table against entry-shape at the root path. Returns {:value :findings}." + [v] (shapes/clean-value entry-shape v [])) (deftest a-sound-entry-comes-back-identical (let [e {:name "Elf" :abilities {:dex 2} :languages #{"Elvish"} :traits [{:name "Keen"}] diff --git a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs index 28c04788b..756f24666 100644 --- a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs +++ b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs @@ -12,7 +12,9 @@ (def ^:private race {:name "Hill" :key :hill :option-pack "P" :speed "25"}) (def ^:private lib {"P" {::e5/races {:hill race}}}) -(defn- fx-of [effects kind] +(defn- fx-of + "The values of the `kind` effects in `effects`' :fx list, as a lazy seq." + [effects kind] (for [[k v] (:fx effects) :when (= k kind)] v)) (deftest an-event-that-leaves-the-library-alone-is-left-alone diff --git a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs index a46bd629c..5c9addcfe 100644 --- a/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs +++ b/test/cljs/orcpub/dnd/e5/junk_pack_test.cljs @@ -83,7 +83,10 @@ ;; --------------------------------------------------------------------------------------------- ;; Cases: [content-type path value]. A path is [field] or [field index-or-key field]. -(defn- field-paths [entry] +(defn- field-paths + "Each field of `entry` except :key and :option-pack, as a path: [field], plus [field inner] for a + map field and [field 0 inner] for a list of maps (first element's fields)." + [entry] (let [skip #{:key :option-pack}] (concat (for [k (keys entry) :when (not (skip k))] [k]) @@ -128,7 +131,9 @@ (let [k (or (::t/key x) (:key x))] (and (keyword? k) (str/starts-with? (name k) "junk-")))))) -(defn- msg [e] (first (str/split-lines (or (ex-message e) (str e))))) +(defn- msg + "The first line of the error `e`'s message (or of `e` printed when it has none)." + [e] (first (str/split-lines (or (ex-message e) (str e))))) (def ^:private seen "What this pack has already realized, by identity. The class list, the class map, the template @@ -184,7 +189,10 @@ a failure here, as junk should not reach a page at all (the shape table missed a field)." (atom nil)) -(defn- attempt [stage f] +(defn- attempt + "Runs the thunk `f` for `stage`. Returns {:stage :error} if it throws, or if a render-guard + caught an error while it ran; nil when it ran clean." + [stage f] (reset! contained nil) (try (f) (when-let [e @contained] {:stage stage :error (str "contained: " e)}) @@ -200,14 +208,18 @@ the My Content lists :listed. A stage that ran on nothing must not read as a pass." (atom {})) -(defn- covered! [stage x] (swap! coverage update stage (fnil conj #{}) x)) +(defn- covered! + "Records `x` (an entry key) in the coverage set for `stage`. Returns the new coverage map." + [stage x] (swap! coverage update stage (fnil conj #{}) x)) (def ^:private stored-character {::entity/options {:race {::entity/key :human} :class [{::entity/key :fighter ::entity/options {:levels [{::entity/key :level-1}]}}]}}) -(defn- levels [n] (vec (for [i (range 1 (inc n))] {::entity/key (keyword (str "level-" i))}))) +(defn- levels + "A stored class's level options for levels 1 to `n`, as a vector of {::entity/key :level-i}." + [n] (vec (for [i (range 1 (inc n))] {::entity/key (keyword (str "level-" i))}))) (defn- seeds "Characters to start from for each homebrew entry: a class or subclass needs levels to offer its @@ -327,7 +339,10 @@ "What the app offers with no homebrew at all, read once by the base-pack test." (atom nil)) -(defn- missing-built-ins [] +(defn- missing-built-ins + "Throws if any class, race or background key recorded in `built-in-keys` is no longer offered. + Returns nil when all are present, or when nothing was recorded." + [] (when-let [before @built-in-keys] (let [now (offered-keys) gone (into {} (for [[k ks] before @@ -494,7 +509,10 @@ [])) (run-pack! lib via opts))) -(defn- cleanup! [] +(defn- cleanup! + "Resets what a pack run leaves behind: the re-frame event queue, the browser storage slots, + app-db and the subscription cache. Returns nothing useful." + [] (rf/purge-event-queue) (doseq [k storage-slots] (.removeItem js/localStorage k)) (reset! app-db {}) @@ -604,7 +622,9 @@ p v))) {} cases)})) -(defn- sub-pack [entries ks] +(defn- sub-pack + "`entries` ({type {key entry}}) cut down to the entries whose key is in `ks`." + [entries ks] (let [ks (set ks)] (into {} (for [[t es] entries] [t (into {} (filter (comp ks key)) es)])))) From 091cee52b64b08528f30395c446fb8b85b67516b Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 29 Oct 2026 00:16:23 -0400 Subject: [PATCH 24/31] Offer a homebrew subclass under its stored key, so one named "x" or with no name is still offered subclass-option left the option key to the name, so a subclass whose name does not make its stored key (a short name, or none) was offered under another key (or none) and could never be picked in the character builder. --- src/cljc/orcpub/dnd/e5/options.cljc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cljc/orcpub/dnd/e5/options.cljc b/src/cljc/orcpub/dnd/e5/options.cljc index 9b43c58a4..26e73c52a 100644 --- a/src/cljc/orcpub/dnd/e5/options.cljc +++ b/src/cljc/orcpub/dnd/e5/options.cljc @@ -2948,7 +2948,10 @@ conj (total-levels-prereq-2 lvl (:key cls))))) -(defn subclass-option [spell-lists +(defn subclass-option + "The template option for subclass `subcls` of class `cls`, offered under the subclass's + stored `:key` (its name's key when it has none), whatever its `:name` is." + [spell-lists spells-map language-map cls @@ -3012,6 +3015,7 @@ levels)] (t/option-cfg {:name name + :key kw :edit-event edit-event :prereqs prereqs :selections (map From 84e381c570179b11c354539a107e1ef3701b0770 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 29 Oct 2026 00:17:28 -0400 Subject: [PATCH 25/31] Test that a homebrew subclass is offered under its stored key Seen failing with the fix reverted (options.cljc at 097f983ad), lein test :only orcpub.dnd.e5.subclass-key-test: FAIL a name that does not make the stored key: (not (= :my-oath :x)) FAIL no name at all: (not (= :nameless nil)) Ran 1 tests containing 3 assertions. 2 failures, 0 errors. The name-derived-key claim passes either way. With the fix: 0 failures, 0 errors. --- test/clj/orcpub/dnd/e5/subclass_key_test.clj | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 test/clj/orcpub/dnd/e5/subclass_key_test.clj diff --git a/test/clj/orcpub/dnd/e5/subclass_key_test.clj b/test/clj/orcpub/dnd/e5/subclass_key_test.clj new file mode 100644 index 000000000..fd410c25a --- /dev/null +++ b/test/clj/orcpub/dnd/e5/subclass_key_test.clj @@ -0,0 +1,23 @@ +(ns orcpub.dnd.e5.subclass-key-test + "A homebrew subclass is offered under its stored key, whatever its name is." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.template :as t] + [orcpub.common :as common] + [orcpub.dnd.e5.options :as opt5e] + [orcpub.dnd.e5.spells :as spells5e] + [orcpub.dnd.e5.spell-lists :as sl5e])) + +(defn- offered-key + "The key under which `subclass-option` offers `subclass` of the fighter class." + [subclass] + (::t/key (opt5e/subclass-option sl5e/spell-lists spells5e/spell-map + (common/map-by-key [{:name "Common" :key :common}]) + {:key :fighter} subclass))) + +(deftest subclass-is-offered-under-its-stored-key + (testing "a name that does not make the stored key" + (is (= :my-oath (offered-key {:key :my-oath :name "x"})))) + (testing "no name at all" + (is (= :nameless (offered-key {:key :nameless})))) + (testing "no stored key: the name's key, as before" + (is (= :champion (offered-key {:name "Champion"}))))) From 603853b351f629ed6bffa23a8de300199accb18a Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 29 Oct 2026 00:21:50 -0400 Subject: [PATCH 26/31] Branch changelog: homebrew subclasses are offered under their stored key --- docs/branch-changelog.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index e52834470..fbf92af10 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -65,8 +65,10 @@ into every field of every content type, by every way in, to prove it (docs/HOMEB disappears without a word; it is set aside for repair. - **Homebrew:** a feat whose ability score increases were stored as a list no longer breaks its builder. -- **Homebrew:** a homebrew subrace, pact boon or eldritch invocation that was renamed, or given a - new key on import to settle a conflict, stays picked on the characters that chose it. +- **Homebrew:** a homebrew subrace, subclass, pact boon or eldritch invocation that was renamed, or + given a new key on import to settle a conflict, stays picked on the characters that chose it. +- **Homebrew:** a homebrew subclass with a very short name, or none, is offered in the character + builder. Before, its class was there and the subclass was missing. - **Homebrew:** a background, feat or class granting a skill that does not exist no longer breaks the character builder. - **Homebrew:** a homebrew subclass, invocation or boon that breaks a built-in class costs only From 04e153bbd8da73487c8ba056438eacd53f370352 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 29 Oct 2026 00:28:08 -0400 Subject: [PATCH 27/31] Point guard comments at the KB page; the design doc moves to agents/develop docs/kb/homebrew-guard.md --- docs/HOMEBREW_GUARD.md | 399 ------------------ docs/branch-changelog.md | 2 +- src/cljc/orcpub/common.cljc | 2 +- src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc | 4 +- src/cljs/orcpub/dnd/e5/events.cljs | 4 +- src/cljs/orcpub/dnd/e5/homebrew_check.cljs | 2 +- test/browser/containment_e2e.js | 2 +- test/cljc/orcpub/hardened_helpers_test.cljc | 2 +- test/cljs/orcpub/dnd/e5/containment_test.cljs | 2 +- test/cljs/orcpub/dnd/e5/db_test.cljs | 2 +- .../dnd/e5/homebrew_interceptor_test.cljs | 2 +- test/cljs/orcpub/junk_audit_runner.cljs | 2 +- 12 files changed, 13 insertions(+), 412 deletions(-) delete mode 100644 docs/HOMEBREW_GUARD.md diff --git a/docs/HOMEBREW_GUARD.md b/docs/HOMEBREW_GUARD.md deleted file mode 100644 index 36dbc4122..000000000 --- a/docs/HOMEBREW_GUARD.md +++ /dev/null @@ -1,399 +0,0 @@ -# Homebrew guarded by default - -Status: **design, for review (2026-10-21).** Branch `feature/guarded-homebrew`, off integration -at `d51938c1`. - -## The problem in one paragraph - -Homebrew is untrusted: an imported or hand-edited pack can hold any shape in any field. The app -protects itself with a guard (`homebrew_guard.cljc`), but the guard is a wrapper that code has to -choose to use. Code that reads homebrew without it is unprotected, and nothing says so: no rule, -no lint, no test. That is how grant-rows ended up with hooks that throw on a malformed pack, and -how integration still has crashes nobody had measured. This branch makes the safe path the only -path, so new code is protected without anyone remembering to protect it. - -## How we got here - -- **2026-09-12, PR #34 (`986c25a4`).** The guard landed. It wrapped the seven places integration - turned homebrew into options. From integration's side that was everything. -- **The KB note** (`homebrew-safety-net.md`, on agents/develop) lists what is guarded and why - nothing is fully realized on the everyday path (about 2 s against 40 ms for 12 sources). It has - no rule that new code must use the guard. It is cited by code docstrings but is not on - integration. -- **The wrong-type sweep** that checked every field was a scratch script, never committed; the - note says its rerun "was cut short". Nothing has checked since. -- **grant-rows** wrote its pools and fighting-style hooks between June and 2026-09-08, before the - guard, on another branch. The merge only flagged conflicting lines, so the hooks stayed - unwrapped. - -## What exists today - -| Layer | Where | What it does | -|---|---|---| -| Load floor | `content_specs/valid-item-for-load?`, `::e5/plugin` | Keeps an entry if its key is a keyword and it has an `:option-pack` string. A whole section may be `true` (the spec allows `boolean?`). Nothing else is checked: a presence contract, not a shape contract (orcbrew-level-modifiers.md). | -| Repairs | `homebrew-shapes/read-library`, `read-import` (were `e5/mend-*`) | On load and import: a library, source or section kept as text or a list. Field repairs are the table's. Not run on share-link content. | -| Guard | `guard-entry` at 7 sites | Catches a throw while one entry converts, sets the entry aside into `plugins:rejected`, says so for 60 s. | -| Deep check | `homebrew_check/deep-check` | Converts and fully realizes 5 of 13 content types; after import, Restore, or a crash that reaches the app root. | -| Quarantine panel | top of My Content | Per source and per item: rename, Restore, Auto-name & Restore, Export raw, Discard. A damaged section is listed whole. | -| Link marks and repairs | `library/dangling`, `suggested-repairs` | A link to nothing is marked on the item in My Content and in its builder, once the template is built; a fix is offered, never applied. | -| Health card | My Content | One line per problem type with a count: conflicts, missing fields, export blockers. | -| Commit gate | `library/commit`, `library_gate_test` | The one way to write the library; a backup in `plugins:pre-fix` before a stored fix. | -| Startup rescue | `boot-step`, `set-aside-unloadable-library!` | If startup throws, the whole library goes to `plugins:corrupt`, the app starts without it and offers a download. | -| Error boundaries | item, tab, app root | Catch what throws while a page draws; only the app root runs the deep check. | - -Option builders return lazy sequences. A throw inside one surfaces later (when a page draws it or -a character is built), outside the guard. Modifier functions run only when a character is built, -where nothing can guard them. - -## Baseline: what crashes today - -`test/cljs/orcpub/dnd/e5/junk_pack_test.cljs` takes one realistic entry of each of the 13 content -types (`test/fixtures/junk-bases.orcbrew`) and puts each of 12 junk values in each field, one level -deep. Each pack arrives three ways (stored, imported through the file importer, and through a share -link) and goes through startup, every homebrew subscription, the deep check, a character that picks -each entry where the builder offers it, and the pages that draw it: My Content, the monster list -and each entry's builder. It fails when anything throws, when a built-in class, race or background -goes missing, when something other than the junk entry is set aside, and when an entry is neither -offered nor set aside. (That last check does not yet read My Content's link marks.) - -`lein fig:junk && node test/e2e/junk-pack-audit.js` runs it all on four pages at once in about -8 minutes on integration; the everyday suite runs only the clean base pack. Each pack holds one -type's entries with every junk value, a failure is traced to its entry within the run, and an -entry that arrives the same by all three ways is built and drawn once. - -On integration `d51938c1`: **659 failures over 51 fields**, listed in full in -`HOMEBREW_GUARD-baseline.txt`. Every one is a field nothing checks the shape of. The first audit -(498) never tested a junk `:name`, because it renamed every entry after putting the junk in; the -161 more are those, and the faster audit reproduces the other 498 exactly. - -| Where | What breaks it | Through | -|---|---|---| -| **Startup** | a whole `classes` or `subclasses` section that is `true`: the boot rescue sets the whole library aside | stored, imported | -| **Every character page** (the option list the builder and sheets share) | races and subraces `:abilities`, races `:languages`, backgrounds `:equipment-choices` and its `:options`, backgrounds `:profs :skill`, feats `:prereqs`, classes `:ability-increase-levels` | all three | -| **The race or subrace, once used** (thrown after the guard let it through) | races `:spells`, `:props :damage-resistance`; subraces `:spells`, `:props :weapon-prof` | all three | -| **My Content** | any of the 13 sections being `true` | stored, imported | -| **The monster list** | monsters `:size`, `:type`, `:subtypes`, `:name` | all three | -| **Not offered, not reported** | a junk `:name` on a subrace, subclass, invocation or boon (boons and invocations key their option by name; why subraces and subclasses drop out is still to trace) | all three | -| **Builders** | classes and subclasses `:level-modifiers` (and each one's `:type`), `:level-selections`; classes `:spellcasting :cantrips-known`; encounters `:creatures`; feats `:ability-increases` | all three | -| **Share links only** (shared content skips the load repairs) | `:traits` on races, subraces, backgrounds, classes, subclasses and monsters; selections `:options`; a `true` `classes` or `subclasses` section | share | -| **Not offered** | a subrace whose `:race`, or a subclass whose `:class`, is not a real one: marked in My Content as a link to nothing once the template is built, but not offered in the character builder; a junk parent that is not a keyword is probably not marked | all three | - -Integration's feat builder bug (`:ability-increases`) is already fixed on PR #69. - -Suspected from reading the code, **not reproduced**: a bad invocation or boon emptying the class -list (with the fields an invocation or boon has, nothing throws), and a subrace's failure being -blamed on its race (nothing was set aside but the junk entry). `LOAD_HOMEBREW_URL` writes into the -same storage slot, so the stored path covers it. - -Not reached yet: junk two levels down (inside a trait card, a level selection's options), and the -pages that only draw a picked entry (the character sheet, the spell page). - -On feature/grant-rows the same audit is expected to add the pool hooks (fighting styles, draconic -ancestry, grant pools), the AC and weapon `:props`, and grant `:order`; to be run there after -this lands. - -## What our documents already say - -Read against the baseline (all on agents/develop unless marked): - -**Already known.** A subrace or subclass whose parent does not exist is marked in My Content and -in its builder ("links to nothing", homebrew-keys-design.md I11), not in the character builder, -where it simply is not offered; a junk parent that is not a keyword (`42`) is probably not marked -at all. Throws after the guard come from lazy option builders (homebrew-safety-net.md). -HOMEBREW_REQUIRED_FIELDS.md (integration) lists classes `:ability-increase-levels` and `:traits`. - -**Contradicted by the baseline, to correct once the fix lands:** -- homebrew-safety-net.md: bad homebrew "cannot keep a page down". My Content, the monster list and - the builders go down; startup comes up only by setting the whole library aside. -- homebrew-fixes-persist.md: "whatever none of these can read is set aside". A `true` section is - never set aside: the load spec accepts it. -- HOMEBREW_REQUIRED_FIELDS.md (integration): race `:abilities` and `:languages` are not critical. - Junk in either takes down every character page. -- fail-soft-rendering.md and clojurescript-type-tolerance.md (integration): the crash surface is - narrow (string operations). Most of the 498 are `seq` on a number, a keyword or vector called - as a function, and missing protocols; both documents cover nil, not wrong types. - -**Not in any document:** the monster list, the builder crashes on level modifiers and selections, -share-link content skipping the repairs, and a `true` section crashing My Content. - -## Why per-site fixes keep failing - -Every crash above has the same cause: **code reads a field and assumes its shape.** Guarding each -read site is what we have done, and it misses the next one. Realizing everything inside the guard -costs about 50 times more and still cannot reach modifier functions. Two things fix the class of -bug instead of the instance: - -1. Make every field the right shape **before** any code reads it. -2. Give code exactly one way to read homebrew, and fail the build when it reads it another way. - -## The design - -### What it promises, and what proves it - -1. **Bad homebrew cannot crash the app at startup or take down a page.** At worst the one bad - item does not show, and the user is told which one. *Proof:* the junk audit (every field, - every junk value, by storage, import and share link) goes from 659 failures to 0, and its - canaries show it can still fail. -2. **A fix is made once: written, used and exported.** `"5"` fixed to `5` is stored as `5`, read - as `5`, exported as `5`. *Proof:* `fixes_persist_e2e`, and a second load that finds nothing to - fix and writes nothing. -3. **Nothing disappears without a word.** *Proof:* the audit's "neither offered nor set aside" - check stays at zero. - -**What it cannot promise:** that code not yet written has no bugs. A bug in our own code is -contained to one item and reported (5), and the audit, rerun in minutes, finds the gap. - -### 1. One cleaning step, from a table of shapes (built) - -- **`homebrew_shapes.cljc`** says, for each of the 13 content types, every field the app reads and - its shape. **`clean-entry` / `clean-library`** return the entry made to fit, and what they found: - - **fixed** when the meaning is obvious (`"5"` → `5`; one card → a list of one; a set of keys → - a key-to-true map; text where a card belongs → a card with that name; a :key that is not a - key → the one it is filed under). One repair path: `e5/mend-*` is folded in here; - - **unreadable** when it is not (a number where a map belongs), or when a field an entry cannot - be used without is missing (a subrace's `:race`, a subclass's `:class`). -- A field holding nil reads as absent, as every reader takes it: the builders store some on - purpose (a class's `:spell-list-kw`), and the import keeps them (`nil-preserve-fields`). -- How a library is stored comes first (`read-library`, `read-import`): a library, source or - section kept as text is read back, a section kept as a list is keyed, an empty one dropped. -- A sound entry comes back as the identical object. Loading is never stricter than saving: every - real fixture comes through with nothing unreadable (a test). Fixes produce only shapes the - builders write, so exported files still import into older builds. -- The table decides how good a fix or a message is. It is not what keeps the app safe: a field it - does not list is still covered by the conversion check (2) and by containment (5). - -#### The shape forms - -Each form, and in brackets what `clean` does when a value has the wrong shape but its meaning is -obvious: - -``` - :any :string :number :int :keyword :boolean :map - :num a number (a numeric string is read as its number) - :key a keyword (text is read as its keyword: "Medium" -> :medium) - [:enum #{...}] one of these values - [:or s ...] any one of them - [:list s] a vector or list of s (a lone s becomes a list of one) - [:cards card] a list of cards (text names a card; a lone card or text is a list of one) - [:coll s] a vector, list or set of s - [:set s] a set of s (a list of s becomes a set; a lone s a set of one) - [:map-of k v] a map whose keys are k and values v - [:flags k] a map of k to true or false (a set or list of k marks each true) - [:counts k] a map of k to a number (a set or list of k counts each once) - [:indexed s] a map of whole numbers to s (a list of s is numbered from 0) - [:map {field s ...}] a map; the listed fields are checked, the rest pass untouched -``` - -### 2. Where homebrew comes in: fix once, check it works, store it (built) - -**One global interceptor** (re-frame 1.4.4's `reg-global-interceptor`, `::homebrew-shapes` in -events.cljs, `homebrew-shapes-checked`) runs after every event. If the event left the library as the identical object, which is almost every -event, it does nothing. If the event changed it, however it changed it: - -- the changed entries are cleaned, and the fixes go into the new state and are **written to - storage** with it (`"5"` stored as `5`, read as `5`, exported as `5`); -- an entry with a field that cannot be read is reported through the guard's reporter (the same - path as an entry that fails to convert) and moves to the **quarantine panel** with its reason, - as it was written, to fix or restore; -- the changed entries are then **converted by the app's own code** (the existing deep check, - `check-and-set-aside!`, after the new state is in place): one that does not convert moves to - the quarantine panel too. Only the entries that changed: the check costs about 20 ms an entry, - so a whole 464-entry source took 9 s and a save now takes about 35 ms. Import and restore no - longer call it themselves. An entry is checked when it changes, so a change to a race that - breaks its subraces is left to containment (5). - -The active library is then clean, so nothing is found again on the next load. Because it runs on -every event, it does not depend on code calling the right function, or on `library_gate_test` -recognising how a write was made (it scans for `assoc`, `update` and `dissoc` on `:plugins`, not -`merge` or a helper). That test stays, as the check that every write also reaches storage. - -| Way in | What the interceptor sees | -|---|---| -| **Import** | the import event storing the file; the import report lists fixes and quarantined entries | -| **Builder saves, rename, move, restore, conflict resolution** | each of those events; a builder save changes one entry, so one entry is checked | -| **Load** (startup, another tab's write, what `LOAD_HOMEBREW_URL` stored) | not the interceptor: the loader (`::e5/plugins` cofx) reads storage before any event can, and a section stored as `true` stopped startup there. So the loader applies the table straight after its mend: fixes are written back with its repairs, after the `plugins:pre-fix` backup; an unreadable entry joins what it sets aside in `plugins:rejected`, as written. The interceptor then finds nothing | -| **Share link** | `apply-shared-content` puts shared content in `:shared-plugins`, which the interceptor checks the same way; it is never stored, so a fix is used and an entry that cannot be read is left out and reported (to the console; a line in the share banner is still to do) | - -Builder drafts are restored by `reg-local-store-cofx`, which TODO.md pins as hands-off; they are -covered by (5). - -### 3. Inside the app: every read is checked (built) - -Data can change after it came in without an event: a script writing to the app's memory, the -console, a bug of ours. So the shape check also runs where the app reads homebrew, in one -function, `homebrew-check/checked`, called from two places: - -- **every view**: the `::e5/plugins` and `::e5/shared-plugins` subscriptions, which every other - homebrew subscription (so every builder, page, option and character) is built on; -- **every event handler**: the global interceptor's `:before` hands each handler the library as - checked, so the 57 handlers that read `(:plugins db)` directly need no change. - -Whatever is in memory is checked there, so the functions after it only ever receive the right -shapes, and none of them changes. Anything unclean found there is read with its fixes, an entry -that cannot be read is left out and reported (to the quarantine panel, by the guard's reporter), -and the whole is logged as a bug: nothing should reach it that way. It writes nothing. - -It remembers the last library it read and what it gave for it, so a library already checked costs -one comparison (0.002 ms), and a new one is walked only where it differs from the last (one -entry changed: 0.2 ms). - -### 4. Never silent, with what exists - -Fixes show where load repairs show today; quarantined entries in the quarantine panel and its -notice; the health card counts them. A crash caught by (5) reports through the guard's reporter, -as the page-level fallback does today. No new log. - -### 5. Containment for bugs in our own code - -The checks above protect the app from bad data. A function passing the wrong thing to another is -a bug in our code; these catch it, using what exists: - -- **The page fallback** (app root, `core.cljs`) stays the last resort: a crashed page shows - "Something went wrong on this page", runs the deep check, sets aside the cause, and retries. -- **Per-item boundaries** (the existing `render-guard`) on My Content's rows, the monster list, - each builder form and the sheet's lists, so a bad item costs that item, not the page. -- **One `reg-event-error-handler`** (re-frame 1.4.4 has it): an event that throws (a click, a save, - a load) is reported, and the app's state stays as it was before that event. Error boundaries - only catch drawing; this catches events. The existing `check-spec-interceptor` on character - events then reports through it instead of throwing with nothing listening. -- **The guard on all 13 types** (through `register-conversions!`; today 5), a subrace or subclass - blamed on itself, and `base-class-options` reporting what it catches instead of silently - returning no classes. -- **Hardened core helpers.** The few functions of ours every option, choice and modifier passes - through (`t/option-cfg`, `t/selection-cfg`, the modifier constructors, `safe-capitalize-kw`, the - trait builder) check what they are given, skip what they cannot use, and report it, the way - `safe-compare` already made every sort safe. Which ones: the audit records where in the code - each crash happened; the helpers most crashes pass through go first, and each must show a drop - in the audit count. - -Replacing Clojure's own functions (`seq`, `keys`, `map`, …) with safe versions is not part of -this: inside Clojure they call each other, and `for` and destructuring expand to them, so our -versions would only apply where our code calls them by name; a namespace that forgot them would be -unprotected; and returning nothing instead of throwing hides bad data rather than reporting it. -Clean data at the seams makes all of them safe at once. - -### 6. Enforcement, and tests that cannot pass by accident - -- **Writes:** the global interceptor checks every change, and `library_gate_test` still requires - every write to reach storage through the gate. -- **The junk audit** (`lein fig:junk && node test/e2e/junk-pack-audit.js`, about 8 minutes), in CI - once it reads zero. Each stage has a **canary**, a planted bad entry it must catch; **coverage is - counted**: fewer entries picked, drawn or converted than expected fails. -- **AGENTS.md:** write the library through the gate; a safety claim cites a committed test that - runs; a report says what it did not cover. - -## Efficiency - -Nothing here may make the app slower to use. - -| Cost | Kept small by | Budget | -|---|---|---| -| Cleaning | Only on a write: an import, a builder save (one entry), the first load after this lands. Reading costs nothing. | 12-source test library: under 30 ms | -| The global interceptor | One identity comparison per event; work only when the library changed, and only on the entries that changed. | no measurable change | -| Later loads | The library is already clean: a read-only walk that finds nothing and writes nothing. | under 10 ms | -| The in-app read check | One comparison per read; on a library change, only the entries that changed. | 0.002 ms; 0.2 ms after a change | -| The conversion check at a way in | Only the entries that came in or changed; `guard/realize` now walks each shared part once. | a builder save: one entry | -| Downstream | A clean entry is the identical object, so the per-type `=` firewall still holds: editing a spell does not rebuild races. | no extra recomputation | -| The deep check | `guard/realize` walks each shared part once (done): one homebrew class went from seconds to milliseconds. | done | -| Audit | Build and CI time only. | under 10 min (now about 2) | - -**Measured, not assumed:** before and after each step, with the 12-source test library: startup, -`process-plugin-vals`, the template build, one keystroke in a builder, and an import. - -After step 3, test-pak stored (464 entries, 764 KB), dev build: an event that leaves the library -alone 0.001 ms; an event that changes one entry 0.05 ms, then its deep check 35 ms (of which -25 ms is setting up the conversions); the loader 170-190 ms, of which the shape walk is 6-7 ms. - -## Prep work already done, to reuse - -- **testing/develop**: a Playwright suite of 60 scenarios from July 2026, never merged and run by - nothing (`bad-data`, `broken-content-survives-use`, `boot-resilience`, `quarantine-repair`, …). - Go through it once and port what the junk audit does not cover, rather than keep two suites. -- **Fixtures on integration**: `broken-content`, `cruft-shapes`, `keyword-trap`, `test-pak`. -- **On integration**: the write gate and `library_gate_test`; `persist-set-aside!` and - `plugins:pre-fix`; `mend-*` (folded into homebrew-shapes); the quarantine panel and - `repair-quarantined-source`; `library/dangling`; `render-guard`; `register-conversions!` and - `deep-check`; HOMEBREW_REQUIRED_FIELDS.md; the browser probes. -- **On grant-rows**: `builder_fields.cljc` and `content_types.cljc`; their schemas feed this table - when grant-rows merges, so there is one description of a field, not two. -- **Fixed here** (owner, 2026-10-25): homebrew boons and invocations keyed their option by name, - not their stored `:key` (`pact-boon-options`, `classes.cljc`; content-extensibility-compatibility.md - called it a latent footgun, as a rule for later grant work that never came). They are offered - under the stored key; a pick of the name's key heals to it, as a background's does. - -## Order of work, each step gated - -1. **Done:** the baseline (659), the shape table and `clean`, the audit in about 8 minutes, - `guard/realize` walking each part once. -2. **Done:** audit canaries and counted coverage. -3. **Done:** the global interceptor, and the table in the loader. The audit went from 659 to 37 - in 132 s. The everyday suites, `homebrew_safety_net_e2e`, `fixes_persist_e2e`, - `quarantine-restore` and `library-repairs` pass; a second load writes nothing (a test). -4. **Done:** the in-app read check, changed-only from the start (the full walk is 7 ms on - test-pak, on every read it would not be). A feat's `:ability-increases` is now read as a set, - the only form this build's builder reads (27 failures: a list reached the builder). The audit - went from 37 to 10 in 135 s. Left: a background skill that names no skill (6; a lookup in - our code, step 5) and a subrace or subclass from a share link whose parent does not exist (4; - not shown in My Content, so silent). `boot_rescue_e2e` now passes 17/17: run by hand, it had - no overlay suppression, and the What's New panel covered the link it clicks. - **When PR #69 merges**, its list of `[amount pool]` spreads goes into the feats row with the - builder that reads it, in the merge itself: until then this build would turn a spread into a - set on load and set it aside, as it cannot read it. -5. **Done:** hardened core helpers. The audit with the shape check off (`JUNK_UNGUARDED=1`, which - lets every junk value reach our code) recorded where each crash happened; most went through - `plugin-modifiers` and `collect-map-modifiers`, then the monster list's filter. Those, the feat - builders' `:ability-increases`, `safe-capitalize-kw` and the skill-proficiency modifier now - take what they are given through `common/usable`: a value of the wrong shape costs that field - and a console warning, not the page. Unguarded: 554 to 396 (monsters 103 to 14, subraces 101 - to 50). What is left there are single-field readers in each option builder, which the shape - table covers and step 6 contains; hardening each one is the per-site work this design avoids. - Lifting the audit's allowance for name-derived keys showed homebrew subraces had the same bug - as boons: offered under their name's key. They now use the stored key, and old picks heal. - Guarded: 10 to 5, all from a share link, all "never offered": a subrace or subclass whose - parent does not exist (4), and a subclass named "x" (1, not yet traced). Found on the way: - `class-option` merges homebrew subclasses into a set sorted by name, so a homebrew subclass - with the same name as another (built in, or from another source) is dropped without a word. -6. **Done:** containment. - - **Events:** `events/event-failed`, registered by `core.cljs` after the startup steps (whose - recovery needs `:initialize-db` to throw): an event that throws shows the error notice and - changes nothing; re-frame's own handler rethrew, dropping every queued event. - - **Character options:** each built-in class is built on its own, and a homebrew subclass, - invocation or boon that breaks it is set aside by itself (before, all twelve classes went); - each subrace has its own guard inside its race. The deep check covers the eleven types that - become options; monsters and encounters become none. - - **Pages:** each builder section, My Content list, source row and monster row has a - render-guard. With the shape check off, 184 page crashes are now contained; with it on, none - is reached. What still throws with the check off is the template building options lazily: - the deep check at each write and the page fallback hold that, as designed. - - **Share links:** the banner names what the link carried that cannot be used: entries that - could not be read or broke (reported, with nothing to set aside), and subraces or subclasses - whose race or class the link did not carry (`library/orphans`). Only a missing parent keeps an - entry from being offered; the audit counted any link to nothing until now. - - **Open:** a homebrew subclass named "x" (or whose missing name an import fills) is never - offered, by any way in; its class is there. The audit's former allowances hid it. Suspected, - not shown: `class-option` merges subclasses into a set sorted by name. Guarded audit: 4, all - this case. - **Consolidated** (owner, 2026-10-26): one repair path, `e5/mend-*` folded into homebrew-shapes - (`read-library` and `read-import` for structure, the table for fields); the import lists the - table's fixes and what will be set aside in its log. An unreadable `:traits` or `:spell-lists` - now sets its entry aside, as the owner decided for any unreadable field; the old repair - dropped the field. Not duplicated after all: `content-specs` is the identity floor (a - letter-first key and a source), the save specs and the import validator check which fields - are present; the table checks the form of those that are. Found on the way and fixed: the - table counted nil as unreadable, which would have set aside a class saved with a nil - `:spell-list-kw` on load; and an unchanged entry dropped its findings. Moved, no behaviour - change: the set-aside and write-check logic to `homebrew_check.cljs`, which already held the - rest of this wiring (events.cljs 8,904 to 8,805). The view guards stay in views.cljs: ~220 - lines are not a namespace's worth (owner, 2026-10-26). -7. **Enforcement:** the audit in CI at zero, the AGENTS.md rules, the documents corrected, the - testing/develop scenarios gone through. -8. **grant-rows** merges this, adds its fields to the table, and runs the audit. - -Shared hardening is built on integration; future work goes there unless it touches only the -refactor branch (owner, 2026-10-21). - -## For the owner - -- **An unreadable field**: the whole entry moves to the quarantine panel with the reason - (owner, 2026-10-25). -- **Boons and invocations keyed by name**: fixed here (owner, 2026-10-25), above. diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index fbf92af10..3e52c0128 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -29,7 +29,7 @@ Homebrew was checked where code remembered to check it, and trusted everywhere e the wrong shape in one field could break a page, stop the app from starting, or make an entry vanish without a word. This branch checks every change to the homebrew library in one place, whichever way it came in, against one table of what each field holds; and a test loads junk -into every field of every content type, by every way in, to prove it (docs/HOMEBREW_GUARD.md). +into every field of every content type, by every way in, to prove it (homebrew-guard.md). ## Added diff --git a/src/cljc/orcpub/common.cljc b/src/cljc/orcpub/common.cljc index a40b096a1..3e4297c05 100644 --- a/src/cljc/orcpub/common.cljc +++ b/src/cljc/orcpub/common.cljc @@ -316,7 +316,7 @@ (defn usable "`x` when `(ok? x)`, else `fallback`, with a warning naming `what` unless `x` is nil. For the helpers every option, choice and modifier passes through: homebrew reaches them already made to - fit (docs/HOMEBREW_GUARD.md), so a value of the wrong shape here is a bug of ours, and costs + fit (homebrew-guard.md), so a value of the wrong shape here is a bug of ours, and costs that field rather than the page." [ok? x fallback what] (cond diff --git a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc index 3d2347e5b..bcbb11ddb 100644 --- a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc +++ b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc @@ -3,7 +3,7 @@ Homebrew is untrusted: an imported, hand-edited or injected entry can hold anything anywhere. Code that reads homebrew assumes these shapes, so the seams run entries through here first. `read-structure` comes first, for a library as stored: a library, source or section kept as - text, a section kept as a list. Requires only orcpub.common. See docs/HOMEBREW_GUARD.md." + text, a section kept as a list. Requires only orcpub.common. See homebrew-guard.md." (:require [clojure.string :as str] #?(:clj [clojure.edn :as edn]) #?(:cljs [cljs.reader :as reader]) @@ -11,7 +11,7 @@ ;; --------------------------------------------------------------------------------------------- ;; Shapes are data: :string, :num, [:list s], [:map {field s}] and so on. What each form means, -;; and what `clean` fixes for each, is in docs/HOMEBREW_GUARD.md, "The shape forms". +;; and what `clean` fixes for each, is in homebrew-guard.md, "The shape forms". (defn fits? "Does `v` fit `shape` as it is? Allocates nothing, so a sound entry costs one walk." diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 15c06e0be..988dc7293 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -6813,7 +6813,7 @@ {:db db' :dispatch-n refused}))))))) ;; Every event, whatever it changed and however: the homebrew it leaves is checked here, so no -;; write can skip the check by going around commit-library. See docs/HOMEBREW_GUARD.md. +;; write can skip the check by going around commit-library. See homebrew-guard.md. (reg-global-interceptor (->interceptor :id ::homebrew-shapes @@ -6834,7 +6834,7 @@ ;; An event that throws changes nothing (re-frame applies no effect of an unfinished handler): ;; event-failed says so, once a few seconds per event, and logs it; re-frame's own handler rethrew, ;; dropping every queued event. core.cljs registers it after startup, whose recovery needs -;; :initialize-db to throw; the test builds keep re-frame's. See docs/HOMEBREW_GUARD.md, 5. +;; :initialize-db to throw; the test builds keep re-frame's. See homebrew-guard.md, 5. (def ^:private last-event-error (atom nil)) (def event-failed-message diff --git a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs index 3f60d8073..0627054f3 100644 --- a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs +++ b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs @@ -4,7 +4,7 @@ builder guarding would miss. GOTCHA: realizing costs ~20ms an entry (9s for test-pak's 464), so call it only for the entries a change wrote (the homebrew-shapes interceptor's ::check-entries), or after a page - has already failed. See docs/HOMEBREW_GUARD.md." + has already failed. See homebrew-guard.md." (:require [clojure.string :as s] [orcpub.route-map :as routes] [orcpub.dnd.e5 :as e5] diff --git a/test/browser/containment_e2e.js b/test/browser/containment_e2e.js index 60d5704aa..cd4f1e5cb 100644 --- a/test/browser/containment_e2e.js +++ b/test/browser/containment_e2e.js @@ -1,5 +1,5 @@ // Overlays: suppressed (cookie notice and What's New). -// Does a bug cost only the item it is in, in the real app? See docs/HOMEBREW_GUARD.md, 5. +// Does a bug cost only the item it is in, in the real app? See homebrew-guard.md, 5. // // Events: re-frame applies no effect of a handler that did not finish; events/event-failed, // registered by core.cljs once startup is done, says so in the error notice instead of diff --git a/test/cljc/orcpub/hardened_helpers_test.cljc b/test/cljc/orcpub/hardened_helpers_test.cljc index a3bb02c34..69f0c12a3 100644 --- a/test/cljc/orcpub/hardened_helpers_test.cljc +++ b/test/cljc/orcpub/hardened_helpers_test.cljc @@ -1,6 +1,6 @@ (ns orcpub.hardened-helpers-test "The helpers every option, choice and modifier passes through skip a value of the wrong shape - instead of throwing (docs/HOMEBREW_GUARD.md, 5)." + instead of throwing (homebrew-guard.md, 5)." (:require #?(:clj [clojure.test :refer [deftest testing is]] :cljs [cljs.test :refer-macros [deftest testing is]]) [orcpub.common :as common] diff --git a/test/cljs/orcpub/dnd/e5/containment_test.cljs b/test/cljs/orcpub/dnd/e5/containment_test.cljs index f17c78672..ae5167959 100644 --- a/test/cljs/orcpub/dnd/e5/containment_test.cljs +++ b/test/cljs/orcpub/dnd/e5/containment_test.cljs @@ -1,6 +1,6 @@ (ns orcpub.dnd.e5.containment-test "A bug of ours, or homebrew the checks before it miss, costs the one item it is in, and is - reported: docs/HOMEBREW_GUARD.md, 5." + reported: homebrew-guard.md, 5." (:require [cljs.test :refer-macros [deftest testing is]] [orcpub.template :as t] [orcpub.dnd.e5 :as e5] diff --git a/test/cljs/orcpub/dnd/e5/db_test.cljs b/test/cljs/orcpub/dnd/e5/db_test.cljs index e2011ba00..f3088aac0 100644 --- a/test/cljs/orcpub/dnd/e5/db_test.cljs +++ b/test/cljs/orcpub/dnd/e5/db_test.cljs @@ -199,7 +199,7 @@ (deftest loading-makes-the-library-fit-its-shapes-once ;; A subclasses section stored as true stopped the app from starting; the loader read it - ;; before anything else could. See docs/HOMEBREW_GUARD.md. + ;; before anything else could. See homebrew-guard.md. (.setItem js/window.localStorage db/local-storage-plugins-key (str "{\"P\" {:orcpub.dnd.e5/subclasses true :orcpub.dnd.e5/races {" ":hill {:key :hill :option-pack \"P\" :name \"Hill\" :speed \"25\"} " diff --git a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs index 756f24666..d7f6841d1 100644 --- a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs +++ b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs @@ -1,6 +1,6 @@ (ns orcpub.dnd.e5.homebrew-interceptor-test "The global interceptor: whatever an event does to the homebrew library, and however, what it - leaves is made to fit the shape table. See docs/HOMEBREW_GUARD.md." + leaves is made to fit the shape table. See homebrew-guard.md." (:require [cljs.test :refer-macros [deftest testing is]] [re-frame.core :as rf] [re-frame.db :refer [app-db]] diff --git a/test/cljs/orcpub/junk_audit_runner.cljs b/test/cljs/orcpub/junk_audit_runner.cljs index 78db2b368..9228bf170 100644 --- a/test/cljs/orcpub/junk_audit_runner.cljs +++ b/test/cljs/orcpub/junk_audit_runner.cljs @@ -17,7 +17,7 @@ (js/console.log "JUNKDONE" (pr-str (select-keys m [:test :pass :fail :error])))) ;; ?unguarded: the shape check lets everything through, so the junk reaches our own code. What -;; fails then is what containment and the hardened helpers must catch (docs/HOMEBREW_GUARD.md, 5). +;; fails then is what containment and the hardened helpers must catch (homebrew-guard.md, 5). (when (re-find #"[?&]unguarded" (str (.-search js/location))) (set! shapes/clean-library (fn [library & _] {:library library :findings []}))) From 14b0603ff7d6a73fa006775082fb28e23e0b0093 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 29 Oct 2026 00:58:58 -0400 Subject: [PATCH 28/31] Guarded homebrew: fix the review's findings (leading-zero numbers, monster scores, lone ability increase, shared reports, stale set-aside, shared count, auto-clean, pre-fix copy, audit exit) --- src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc | 6 ++- src/cljs/orcpub/dnd/e5/db.cljs | 5 ++- src/cljs/orcpub/dnd/e5/events.cljs | 24 +++++----- src/cljs/orcpub/dnd/e5/homebrew_check.cljs | 45 +++++++++++-------- .../orcpub/dnd/e5/orcbrew_validation.cljs | 4 +- test/e2e/junk-pack-audit.js | 6 ++- 6 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc index bcbb11ddb..9edc96623 100644 --- a/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc +++ b/src/cljc/orcpub/dnd/e5/homebrew_shapes.cljc @@ -81,7 +81,8 @@ "The number `s` spells, or nil: \"3\" and \"0.5\" read, \"three\" does not." [s] (when (re-matches #"\s*-?\d+(\.\d+)?\s*" s) - #?(:clj (edn/read-string (str/trim s)) :cljs (js/parseFloat s)))) + #?(:clj (edn/read-string (str/replace (str/trim s) #"^(-?)0+(?=\d)" "$1")) + :cljs (js/parseFloat s)))) (defn- text->key "The keyword text stands for, or nil for blank text: \"Medium\" and \":medium\" read as :medium." @@ -323,6 +324,7 @@ :orcpub.dnd.e5/monsters [:map {:name :string :size :key :type :key :subtypes [:set :key] :challenge :num + :str :num :dex :num :con :num :int :num :wis :num :cha :num :hit-points [:map {:die :num :die-count :num :modifier :num :mean :num}] :saving-throws [:map-of :keyword :num] :skills [:map-of :keyword :num] :condition-immunities :string @@ -356,7 +358,7 @@ ;; pairs (a spread, as for races: options.cljc compile-ability-increases); the pool is ;; checked there, so it is :any here. [:map {:name :string :description :string - :ability-increases [:or [:set :keyword] [:list [:list :any]]] + :ability-increases [:or [:set :keyword] [:coll [:list :any]]] :prereqs [:set :keyword] :path-prereqs [:map {:race flags}] :props props}] :orcpub.dnd.e5/races diff --git a/src/cljs/orcpub/dnd/e5/db.cljs b/src/cljs/orcpub/dnd/e5/db.cljs index 3dde14d59..7ceb3947e 100644 --- a/src/cljs/orcpub/dnd/e5/db.cljs +++ b/src/cljs/orcpub/dnd/e5/db.cljs @@ -801,7 +801,8 @@ {shaped :library unreadable :set-aside} (shapes/split-unreadable cleaned mended findings) reshaped? (not (identical? shaped mended)) - _ (when reshaped? (keep-pre-fix-copy! mended)) + ;; the repaired library is written back only once a copy of the original is kept + backed-up? (not (and reshaped? (:failed? (keep-pre-fix-copy! mended)))) ;; An entry with no source takes the name of the source it is ;; stored under (e5/source-for) rather than being set aside -- ;; written back with the repairs below. @@ -819,7 +820,7 @@ kept)] (persist-set-aside! set-item #(when js/window.localStorage (.removeItem js/window.localStorage %)) - kept reconciled rejected (or (seq repairs) (seq fills) reshaped?)) + kept reconciled rejected (or (seq repairs) (seq fills) (and reshaped? backed-up?))) (when reshaped? (js/console.warn (str "Made damaged homebrew fit on load: " diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 988dc7293..7fd3b3a71 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -6769,6 +6769,14 @@ ;; Conversions re-run whenever their inputs change, so one bad entry is reported many ;; times; each is queued once, and the queue is handled together a moment later. +(defn- count-plugin-items + "The number of content entries in library `pl` (a {source {content-type {key entry}}} map)." + [pl] + (reduce + 0 (for [[_ p] pl + [ct items] p + :when (and (qualified-keyword? ct) (map? items))] + (count items)))) + (reg-event-fx ::e5/homebrew-entry-broke (fn [{:keys [db]} [_ report]] @@ -6789,13 +6797,15 @@ (homebrew-check/set-aside-broken-entries (:plugins db) (get-rejected-plugins) all-reports) ;; one from a share link has nothing to set aside: the share banner names it instead moved? (set (map (juxt :content-type :key) moved)) - left-out (for [{:keys [content-type key name source option-pack]} all-reports - :when (and (contains? (:shared-plugins db) (or source option-pack)) - (not (moved? [content-type key])))] + left-out (for [{:keys [content-type key name source option-pack shared?]} all-reports + :when (or shared? + (and (contains? (:shared-plugins db) (or source option-pack)) + (not (moved? [content-type key]))))] {:content-type content-type :key key :name name}) db (cond-> (dissoc db :homebrew-broken) (and (seq left-out) (:shared-content-info db)) - (update-in [:shared-content-info :left-out] (fnil into #{}) left-out))] + (-> (update-in [:shared-content-info :left-out] (fnil into #{}) left-out) + (assoc-in [:shared-content-info :count] (count-plugin-items (:shared-plugins db)))))] (if (empty? moved) {:db db} ;; The set-aside copy first, as the loader does: if storage refuses it, nothing moves, @@ -7376,12 +7386,6 @@ (fn [_ [_ payload]] {::decode-shared! payload})) -(defn- count-plugin-items [pl] - (reduce + 0 (for [[_ p] pl - [ct items] p - :when (and (qualified-keyword? ct) (map? items))] - (count items)))) - (reg-event-fx ::e5/apply-shared-content (fn [{:keys [db]} [_ {:keys [plugins custom-items]}]] diff --git a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs index 0627054f3..927a53076 100644 --- a/src/cljs/orcpub/dnd/e5/homebrew_check.cljs +++ b/src/cljs/orcpub/dnd/e5/homebrew_check.cljs @@ -74,13 +74,18 @@ (js/setTimeout #(check-and-set-aside! (set (map first entries)) entries) 0)))) (defn unreadable-reports - "A report for each entry `findings` could not read, carrying the entry as `library` held it." - [library findings] - (for [[[src ct k] fs] (group-by (juxt :source :content-type :key) - (filter #(= :unreadable (:action %)) findings)) - :when k] - {:content-type ct :key k :source src :original (get-in library [src ct k]) - :error (str (s/join ", " (map #(pr-str (:path %)) fs)) " could not be read")})) + "A report for each entry `findings` could not read, carrying the entry as `library` held it and, + as :unreadable, [path value] for each field that could not be read, as `current` (the library + the app now holds; `library` when omitted) has it, so a later set-aside can tell the entry has + not been replaced since." + ([library findings] (unreadable-reports library findings library)) + ([library findings current] + (for [[[src ct k] fs] (group-by (juxt :source :content-type :key) + (filter #(= :unreadable (:action %)) findings)) + :when k] + {:content-type ct :key k :source src :original (get-in library [src ct k]) + :unreadable (mapv (fn [{:keys [path]}] [path (get-in current (into [src ct k] path))]) fs) + :error (str (s/join ", " (map #(pr-str (:path %)) fs)) " could not be read")}))) ;; Per db slot (:plugins, :shared-plugins): the last library read through `checked`, and what the ;; app was given for it. Each event and each view read compares against it, so a library already @@ -105,7 +110,7 @@ (pr-str slot (map #(select-keys % [:source :content-type :key :path :action]) findings))) (doseq [r (unreadable-reports library findings)] - (guard/report! r))) + (guard/report! (cond-> r (= slot :shared-plugins) (assoc :shared? true))))) (swap! last-read assoc slot {:raw library :given given'}) given')))) @@ -128,16 +133,19 @@ (defn set-aside-broken-entries "Move each reported entry from `plugins` into `rejected`. Returns {:plugins :rejected - :moved [{:source :content-type :key :entry}]}. A report whose entry is not in the - library (shared content, or already gone) moves nothing. A report's `:original` is what is + :moved [{:source :content-type :key :entry}]}. A report moves nothing when its entry is not in + the library (already gone), came from shared content (:shared?), or has been replaced since: + an :unreadable field the entry no longer holds as reported. A report's `:original` is what is set aside when it has one: the entry as it was written, before anything was read past." [plugins rejected reports] - (reduce (fn [acc {:keys [content-type key original] :as report}] - (if-let [[src entry] (find-broken-entry (:plugins acc) report)] - (-> acc - (update-in [:plugins src content-type] dissoc key) - (assoc-in [:rejected src content-type key] (or original entry)) - (update :moved conj {:source src :content-type content-type :key key :entry entry})) + (reduce (fn [acc {:keys [content-type key original unreadable shared?] :as report}] + (if-let [[src entry] (when-not shared? (find-broken-entry (:plugins acc) report))] + (if-not (every? (fn [[path v]] (= v (get-in entry path))) unreadable) + acc + (-> acc + (update-in [:plugins src content-type] dissoc key) + (assoc-in [:rejected src content-type key] (or original entry)) + (update :moved conj {:source src :content-type content-type :key key :entry entry}))) acc)) {:plugins plugins :rejected (or rejected {}) :moved []} reports)) @@ -204,8 +212,9 @@ (when-not (identical? cleaned lib) [[::keep-pre-fix-copy lib] [:dispatch [::e5/set-plugins cleaned]]]) - (for [r (concat (unreadable-reports lib findings) - (unreadable-reports shared shared-findings))] + (for [r (concat (unreadable-reports lib findings cleaned) + (map #(assoc % :shared? true) + (unreadable-reports shared shared-findings)))] [::report r]) (when (seq changed) [[::check-entries changed]]))] diff --git a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs index 76cbef6a7..e124f2516 100644 --- a/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs +++ b/src/cljs/orcpub/dnd/e5/orcbrew_validation.cljs @@ -1941,9 +1941,9 @@ ;; read stays as written, and is set aside for repair when the import is stored. (let [{read-data :data repairs :repairs} (shapes/read-import (:data parse-result)) library? (and (map? read-data) (every? string? (keys read-data))) - {fixed :library findings :findings} (when (map? read-data) + {fixed :library findings :findings} (when (and auto-clean (map? read-data)) (shapes/fix-library (if library? read-data {"" read-data}))) - mended (cond (not (map? read-data)) read-data + mended (cond (or (not auto-clean) (not (map? read-data))) read-data library? fixed :else (get fixed "")) repair-changes (into (mapv (fn [r] {:type :repaired-section diff --git a/test/e2e/junk-pack-audit.js b/test/e2e/junk-pack-audit.js index c36d2c980..5b6c873fc 100644 --- a/test/e2e/junk-pack-audit.js +++ b/test/e2e/junk-pack-audit.js @@ -34,6 +34,7 @@ const srv = http.createServer((q, r) => { fs.writeFileSync(OUT, ''); const t0 = Date.now(); let fails = 0; + let pageErrors = 0; const results = await Promise.all([...Array(n).keys()].map(async i => { const pg = await br.newPage(); let summary = null; @@ -46,7 +47,7 @@ const srv = http.createServer((q, r) => { if (l.startsWith('JUNKJOB ')) console.log(`page ${i + 1}: ${l.slice(8)}`); if (l.startsWith('JUNKDONE ')) { summary = l.slice(9); res(); } })); - pg.on('pageerror', e => console.log(`page ${i + 1}: ${e.message.split('\n')[0]}`)); + pg.on('pageerror', e => { pageErrors++; console.log(`page ${i + 1}: ${e.message.split('\n')[0]}`); }); await pg.goto(`http://localhost:${port}/audit.html?junk-audit${types}&part=${i}/${n}`, { waitUntil: 'commit' }); await done; console.log(`page ${i + 1}/${n} done in ${Math.round((Date.now() - t0) / 1000)} s: ${summary}`); @@ -54,6 +55,7 @@ const srv = http.createServer((q, r) => { return summary; })); console.log(`${fails} failure(s) in ${Math.round((Date.now() - t0) / 1000)} s`); - process.exitCode = fails === 0 && results.every(Boolean) ? 0 : 1; + const failed = results.some(s => !s || /:(fail|error) [1-9]/.test(s)); + process.exitCode = fails === 0 && pageErrors === 0 && !failed ? 0 : 1; await br.close(); srv.close(); })().catch(e => { console.error(e); process.exit(2); }); From 71d930173074968bdb007ed6c7e53504c9f22cab Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 29 Oct 2026 01:01:39 -0400 Subject: [PATCH 29/31] Test the review's fixes: leading-zero numbers, monster scores, lone ability increase, shared reports, replaced entries Fail first, with the fixes reverted: clj shape table 3 failures + 2 errors (leading zeros, monster score, lone ability increase); cljs suite 3 failures, only a-report-from-shared-content-never-sets-aside-a-local-entry and an-entry-replaced-since-its-report-is-not-set-aside. --- .../dnd/e5/homebrew_shape_table_test.clj | 20 +++++++++++++++++++ .../dnd/e5/homebrew_interceptor_test.cljs | 18 +++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj index a11cfbc1e..2876c878d 100644 --- a/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj +++ b/test/clj/orcpub/dnd/e5/homebrew_shape_table_test.clj @@ -112,3 +112,23 @@ {cleaned :library findings :findings} (shapes/clean-library lib)] (is (empty? findings)) (is (identical? lib cleaned)))) + +(defn- cleaned-entry + "`entry` of `content-type`, run through the shape table: {:entry the cleaned entry :findings [...]}." + [content-type entry] + (let [{cleaned :library findings :findings} (shapes/clean-library {"P" {content-type {:k entry}}})] + {:entry (get-in cleaned ["P" content-type :k]) :findings findings})) + +(deftest a-number-written-with-leading-zeros-reads-as-that-number + (is (= 8 (:speed (:entry (cleaned-entry :orcpub.dnd.e5/races {:name "R" :speed "08"}))))) + (is (= -8 (:speed (:entry (cleaned-entry :orcpub.dnd.e5/races {:name "R" :speed "-008"}))))) + (is (= 0.5 (:speed (:entry (cleaned-entry :orcpub.dnd.e5/races {:name "R" :speed "00.5"})))))) + +(deftest a-monster-score-that-is-not-a-number-is-read-or-reported + (let [{:keys [entry findings]} (cleaned-entry :orcpub.dnd.e5/monsters {:name "M" :str "18" :dex "tall"})] + (is (= 18 (:str entry))) + (is (some #(and (= [:dex] (:path %)) (= :unreadable (:action %))) findings)))) + +(deftest a-lone-number-is-not-an-ability-increase + (let [{:keys [findings]} (cleaned-entry :orcpub.dnd.e5/feats {:name "F" :ability-increases 42})] + (is (some #(and (= [:ability-increases] (:path %)) (= :unreadable (:action %))) findings)))) diff --git a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs index d7f6841d1..b0a150dfc 100644 --- a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs +++ b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs @@ -84,6 +84,24 @@ [{:content-type ::e5/races :key :hill :source "P" :original bad}])] (is (= bad (get-in rejected ["P" ::e5/races :hill]))))) +(deftest a-report-from-shared-content-never-sets-aside-a-local-entry + (let [lib {"P" {::e5/races {:hill (dissoc race :abilities)}}} + {:keys [plugins moved]} (homebrew-check/set-aside-broken-entries + lib {} [{:content-type ::e5/races :key :hill :source "P" + :shared? true :original (assoc race :abilities 42)}])] + (is (= lib plugins)) + (is (empty? moved)))) + +(deftest an-entry-replaced-since-its-report-is-not-set-aside + (let [report (first (homebrew-check/unreadable-reports + {"P" {::e5/races {:hill (assoc race :abilities 42)}}} + [{:source "P" :content-type ::e5/races :key :hill + :path [:abilities] :action :unreadable}])) + healthy {"P" {::e5/races {:hill race}}} + still-bad {"P" {::e5/races {:hill (assoc race :abilities 42)}}}] + (is (empty? (:moved (homebrew-check/set-aside-broken-entries healthy {} [report])))) + (is (= 1 (count (:moved (homebrew-check/set-aside-broken-entries still-bad {} [report]))))))) + ;; --------------------------------------------------------------------------- ;; The read check: what a script or a bug writes to app-db outside any event ;; --------------------------------------------------------------------------- From c1b4d2b9600ff277de797dcee4408cace39ca2dd Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 29 Oct 2026 01:12:57 -0400 Subject: [PATCH 30/31] Add the agent-trailers PR check; rename a shadowing test local --- .github/workflows/agent-trailers.yml | 34 +++++++++++++++++++ .../dnd/e5/homebrew_interceptor_test.cljs | 6 ++-- 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/agent-trailers.yml diff --git a/.github/workflows/agent-trailers.yml b/.github/workflows/agent-trailers.yml new file mode 100644 index 000000000..bbbf7221a --- /dev/null +++ b/.github/workflows/agent-trailers.yml @@ -0,0 +1,34 @@ +# Guard: no commit in a pull request carries an agent attribution trailer or a session link. +# The commit-msg hook strips them where it is armed; a cloud session's first commits can precede +# that setup, and the PR is the last place a reword is still possible. + +name: Agent trailers + +on: + pull_request: + branches: [integration, develop, main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + no-agent-trailers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Scan the pull request's own commits + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + bad="$(git log --format='%h %s%n%b' "$BASE..$HEAD" | + grep -iE '^(Co-Authored-By:[[:space:]]*Claude|Claude-Session:|Generated with \[?Claude Code)|claude\.ai/code/session' | head -10 || true)" + if [ -n "$bad" ]; then + echo "::error::Commits in this pull request carry agent attribution trailers or session links:" + echo "$bad" + echo "Reword them (git rebase; git commit --amend for the tip) and force-push the branch." + exit 1 + fi diff --git a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs index b0a150dfc..47eb5b516 100644 --- a/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs +++ b/test/cljs/orcpub/dnd/e5/homebrew_interceptor_test.cljs @@ -85,11 +85,11 @@ (is (= bad (get-in rejected ["P" ::e5/races :hill]))))) (deftest a-report-from-shared-content-never-sets-aside-a-local-entry - (let [lib {"P" {::e5/races {:hill (dissoc race :abilities)}}} + (let [held {"P" {::e5/races {:hill (dissoc race :abilities)}}} {:keys [plugins moved]} (homebrew-check/set-aside-broken-entries - lib {} [{:content-type ::e5/races :key :hill :source "P" + held {} [{:content-type ::e5/races :key :hill :source "P" :shared? true :original (assoc race :abilities 42)}])] - (is (= lib plugins)) + (is (= held plugins)) (is (empty? moved)))) (deftest an-entry-replaced-since-its-report-is-not-set-aside From 90838e729f9941648f04aef2398f88399a98773b Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 29 Oct 2026 01:13:03 -0400 Subject: [PATCH 31/31] Fold the branch changelog into the Summer Patch --- CHANGELOG.md | 35 ++++++++++++++++++ docs/branch-changelog.md | 78 ---------------------------------------- 2 files changed, 35 insertions(+), 78 deletions(-) delete mode 100644 docs/branch-changelog.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ade639496..5fad58941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,16 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Server:** the first start after upgrading builds new database indexes and gives older accounts an email lookup key, both in the background; until that finishes, an older account with a mixed-case address signs in by username rather than email. (#721) - **Site:** tick boxes are rounded and fill with the theme's accent when ticked (amber in the dark theme, blue in the light), and the sign-up page's updates box matches the rest. +- **Homebrew:** a value in the wrong form whose meaning is plain (`"25"` for a speed, one trait + where a list belongs) is corrected once, on load, import, save or from a share link, and the + correction is what is stored and exported. A library saved before is backed up first. +- **Homebrew:** an entry with a field that cannot be read (a number where a list of traits + belongs) is set aside in My Content for repair, as it was written, instead of breaking pages. + From a share link it is left out. +- **Homebrew:** the import report lists each field it fixed, and each entry it will set aside + for repair. +- **Homebrew:** checking homebrew after an import or a Restore takes a fraction of the time for + classes: each shared part of a class is checked once, not once per level. ### Fixed @@ -210,6 +220,31 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Characters:** a monk with a low Strength no longer attacks at +0 with a weapon Martial Arts does not cover; the attack uses the real modifier (#713). - **Site:** all seven header tabs and their menus fit on a 320px phone. - **Spells:** a spell can no longer be prepared once every preparation is used. +- **Site:** an action that fails now says so and changes nothing. Before, nothing visible + happened, and the actions queued after it were dropped. +- **Sharing:** the banner for a shared character names the homebrew it carried that cannot be + used: entries that could not be read, and subraces or subclasses whose race or class was not + shared. +- **Homebrew:** a homebrew entry that cannot be drawn costs its own part of the page: one section + of its builder (the rest still edits and saves), one list or source in My Content, one row of + the monster list. Before, the whole page fell over. +- **Homebrew:** a library with a content section stored as `true` no longer stops the app from + starting. +- **Homebrew:** a monster from a share link with a broken name, size or type no longer breaks the + monster list. +- **Homebrew:** a subrace, subclass, boon or invocation whose name could not be read no longer + disappears without a word; it is set aside for repair. +- **Homebrew:** a feat whose ability score increases were stored as a list no longer breaks its + builder. +- **Homebrew:** a homebrew subrace, subclass, pact boon or eldritch invocation that was renamed, or + given a new key on import to settle a conflict, stays picked on the characters that chose it. +- **Homebrew:** a homebrew subclass with a very short name, or none, is offered in the character + builder. Before, its class was there and the subclass was missing. +- **Homebrew:** a background, feat or class granting a skill that does not exist no longer breaks + the character builder. +- **Homebrew:** a homebrew subclass, invocation or boon that breaks a built-in class costs only + itself: it is set aside, named, and the class is still in the builder. Before, every built-in class + disappeared from the builder. A subrace that breaks is set aside on its own, not with its race. ### Security diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md deleted file mode 100644 index 3e52c0128..000000000 --- a/docs/branch-changelog.md +++ /dev/null @@ -1,78 +0,0 @@ - - -# Branch changelog — `feature/guarded-homebrew` - - - -## Why this branch exists - -Homebrew was checked where code remembered to check it, and trusted everywhere else: a value of -the wrong shape in one field could break a page, stop the app from starting, or make an entry -vanish without a word. This branch checks every change to the homebrew library in one place, -whichever way it came in, against one table of what each field holds; and a test loads junk -into every field of every content type, by every way in, to prove it (homebrew-guard.md). - -## Added - -## Changed - -- **Homebrew:** a value in the wrong form whose meaning is plain (`"25"` for a speed, one trait - where a list belongs) is corrected once, on load, import, save or from a share link, and the - correction is what is stored and exported. A library saved before is backed up first. -- **Homebrew:** an entry with a field that cannot be read (a number where a list of traits - belongs) is set aside in My Content for repair, as it was written, instead of breaking pages. - From a share link it is left out. -- **Homebrew:** the import report lists each field it fixed, and each entry it will set aside - for repair. -- **Homebrew:** checking homebrew after an import or a Restore takes a fraction of the time for - classes: each shared part of a class is checked once, not once per level. - -## Fixed - -- **Site:** an action that fails now says so and changes nothing. Before, nothing visible - happened, and the actions queued after it were dropped. -- **Sharing:** the banner for a shared character names the homebrew it carried that cannot be - used: entries that could not be read, and subraces or subclasses whose race or class was not - shared. -- **Homebrew:** a homebrew entry that cannot be drawn costs its own part of the page: one section - of its builder (the rest still edits and saves), one list or source in My Content, one row of - the monster list. Before, the whole page fell over. - -- **Homebrew:** a library with a content section stored as `true` no longer stops the app from - starting. -- **Homebrew:** a monster from a share link with a broken name, size or type no longer breaks the - monster list. -- **Homebrew:** a subrace, subclass, boon or invocation whose name could not be read no longer - disappears without a word; it is set aside for repair. -- **Homebrew:** a feat whose ability score increases were stored as a list no longer breaks its - builder. -- **Homebrew:** a homebrew subrace, subclass, pact boon or eldritch invocation that was renamed, or - given a new key on import to settle a conflict, stays picked on the characters that chose it. -- **Homebrew:** a homebrew subclass with a very short name, or none, is offered in the character - builder. Before, its class was there and the subclass was missing. -- **Homebrew:** a background, feat or class granting a skill that does not exist no longer breaks - the character builder. -- **Homebrew:** a homebrew subclass, invocation or boon that breaks a built-in class costs only - itself: it is set aside, named, and the class is still in the builder. Before, every built-in class - disappeared from the builder. A subrace that breaks is set aside on its own, not with its race. - -## Security