row 268 (C2-01c, #1773, Vikunja 464): playgrounds at three levels; classify checks prefixes first

checkMapping takes kind absent, hybrid or draft. A draft has a name and one
or more prefixes and no books, and comes back with books []. A prefix inside
or holding another entry's book refuses library-playground-other-universe,
which replaces library-playground-outside-book. A prefix equal to a book or
holding its own refuses library-mapping-invalid, as does a draft source.
classify returns a playground file's entry and the book holding its prefix,
or null at universe level and in a draft.
This commit is contained in:
2026-10-22 00:04:42 -05:00
parent ce6a96ad61
commit c27207f6e6
9 changed files with 268 additions and 44 deletions
+80
View File
@@ -0,0 +1,80 @@
# Row 268 build notes (Gizmoduck)
Case 2 C2-01c, playground levels. Gitea #1773, Vikunja 464. Brief: docs/plans/2026-10-20_case2-rows.md,
"C2-01c playground levels: universe-level prefixes and draft universes" (blob a6ac627c8). The ruling is
agents/gosalyn/work/case2/DESIGN.md "Ruling after r11: playgrounds at three levels" (:49-88 at
db1e7732a), and it wins where the two differ. Reviewer Webby, gate owner Sage. Branch gizmoduck/row268
from refactor db1e7732a. Line numbers are on this branch unless a commit is named.
## mapping.mjs
- `kind` (:71). An entry with a `kind` key must say `"hybrid"` or `"draft"`. Before, a plain universe
with `kind` refused as an unknown key, and `kind: "draft"` did the same.
- A draft (:73-86) refuses `books` with its own message before the key check, so the cause reads
plainly. It takes `kind`, `name` and `playground`, needs one prefix or more, and comes back as
`{ kind: 'draft', name, books: [], playground }`, frozen with the rest.
- The prefix level check moved out of the per-entry loop to :127-140, after every entry is read,
because it needs every entry's books. It runs after the book-holds-book, prefix-holds-prefix and
listed-twice checks, so an overlap still reports as before. For each prefix and each book folder:
- equal to any book folder: `library-mapping-invalid` (brief, What ships, "or equal to a book folder");
- inside or holding another entry's book: `library-playground-other-universe`, and the message says
which, with the book and its entry;
- holding its own entry's book: `library-mapping-invalid`.
A prefix inside its own book is book level, and one that touches no book is universe level. A draft
has no books, so its prefixes are either outside every book or refused as another entry's.
- `sources` (:117-120) now refuses any entry with a `kind`, so a draft can't be a source either. The
message changed from "not a non-hybrid universe" to "not a plain universe". mapping.test.mjs:61 on
db1e7732a pinned the old words, and that line moves with it.
- `library-playground-outside-book` is gone from packages and docs/case2.
## scope.mjs
`classify` (:12-24) now runs two passes. The first finds a prefix in any entry, and the file is
`playground` with that entry's id and `book` set to the entry's book folder that holds the prefix, or
`null`. The second finds a book folder, and the file is `canon`. `place` (:26-32) works out `type` and
`folder` from the first folder under the prefix or the book, as before. The validator keeps prefixes
from overlapping, so the first pass finds at most one.
## Readers on this base
- reader.mjs:182 and :194 spread `classify`'s answer into each file record, so a universe-level or
draft file carries `book: null`. Nothing in the reader reads `book`.
- reader.mjs:206-209. R5 fires for a canon file linking into any playground. R6 fires when the target's
universe differs and isn't a source. A draft can't be a source, so a link into one gives R5 and R6.
A link into the file's own universe-level prefix gives R5 only. No code change was needed. The new
test covers both.
- survey.mjs:66 and mirror.mjs:246 read only `scope`. mirror.mjs:251 calls `readLibrary` and checks
only `path`.
- No other package calls this `classify` or reads `universes` (git grep over packages/*/src).
- C2-05a (row 216) and C2-06 (row 217) have no candidate branch on origin at the time of writing. I
check again before in-review.
## Tests
- mapping.test.mjs: the old outside-book test splits into the other-universe refusals (inside another
entry's book, holding one, a draft prefix inside a book), the mapping-invalid ones (equal to a book,
own or another's, and holding its own), universe level in a plain universe and in a hybrid with
`books: []`, and the draft shape (frozen with `books: []`, with `books`, with none or empty
prefixes, an extra key, and five bad `kind` values). The overlap test gains a cross-entry hold and a
cross-entry duplicate. The hybrid test gains a draft source. The caller-supplied `fail` test now
uses `Second World/Playground/` and sees the new code, since `Elsewhere/` is valid now.
- scope.test.mjs, new: one case per level (book, universe in a plain universe, universe in a hybrid,
draft), `.canvas` under each new prefix staying unmapped, prefixes first inside a book that holds a
prefix, and the reader case for R5 and R6, with `book: null` on the read records.
- business.test.mjs: `Elsewhere/Playground/` now loads, and `Second World/Playground/` refuses with
`library-playground-other-universe` through `validateBusinessDocument`.
## Docs
- packages/library/README.md: the universe keys, a line on the three levels, the refusal table and
the Scope section.
- docs/case2/SETUP-SHEET.md "Your playground": one line per level and the draft entry.
- docs/case2/SURVEY-RUN.md:114: the new code and message.
## Hash pins
`git grep -E '[0-9a-f]{64}'` over packages/*/tests finds no pin on any changed file.
## Runs
None yet. Every run waits for Sage's CLEAR.
+9 -3
View File
@@ -22,9 +22,15 @@ edits it by hand, and the agents can't change it. He will ask you to confirm it
## Your playground
A folder named in the `playground` list of that entry is your playground. Each playground folder sits
inside one book folder, so one per book works well, for example `<book>/playground/`. You choose the
names and tell Jason. Until you have one, every note in a book folder is canon.
A folder named in the `playground` list of that entry is your playground. A playground folder can sit
at one of three levels:
- Book level: inside one book folder, for ideas about that book, for example `<book>/playground/`.
- Universe level: outside every book folder, for ideas about a universe that aren't tied to one book.
- A new universe: a draft universe entry with a name and its own playground folders and no books, for a
world you are only starting. When it is real, Jason turns it into a plain universe with books and you
move the notes in Obsidian.
You choose the names and tell Jason. Until you have one, every note in a book folder is canon.
What the playground means for the agents:
- They search canon unless they ask for the playground on purpose.
+1 -1
View File
@@ -111,7 +111,7 @@ repo, so a bad mapping gives exit 3 even when the repo path is wrong too.
| 4 | `library survey: usage: mosaic library survey <repo> [--mapping <file>]` | no repo, two repos, an unknown flag, `--mapping` without a file or twice | Fix the command |
| 3 | `library survey: library-mapping-invalid: <file> is not a readable JSON file` | missing, unreadable by `mosaic`, or not JSON | Fix the file or its mode |
| 3 | `library survey: library-mapping-invalid: <file> <reason>` | the entry breaks the schema. The reason names the key | Give it to Jason for the draft |
| 3 | `library survey: library-playground-outside-book: <file> universes.<u>.playground "<prefix>" is not inside one of its books` | a playground prefix outside its universe's books | Give it to Jason for the draft |
| 3 | `library survey: library-playground-other-universe: <file> universes.<u>.playground "<prefix>" lies inside book folder "<book>" of universes.<v>`, or `holds book folder` | a playground prefix inside, or holding, another universe's book folder | Give it to Jason for the draft |
| 2 | `library survey: library-git: <repo> is not a directory` | wrong path | Check `<repo>` |
| 2 | `library survey: library-git: repository has no readable HEAD commit (project-git)` | not a repo, a folder inside one, another owner, no commits, a `HEAD` with no commit, or no `git` on `PATH` | Check each. If `<repo>` is the bus mirror, stop and report to Sage |
| 2 | `library survey: library-git: <other reason>`, such as `tree listing refused (project-git)` | git failed or ran past 100 seconds | Report to Sage |
+5 -1
View File
@@ -21,8 +21,12 @@ test('C2-04: library mappings load without roots, remain frozen, and retain mapp
assert.ok(Object.isFrozen(loaded.projects.books.universes.harbor.books));
const only = structuredClone(doc); delete only.projects.stack;
assert.deepEqual(Object.keys(validateBusinessDocument(only, file, { rolesDir: REPO_ROLES }).projects), ['books']);
// C2-01c: a prefix outside every book is a universe-level playground; one inside another
// universe's book refuses.
doc.projects.books.universes.harbor.playground = ['Elsewhere/Playground/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-outside-book/);
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
});
+15 -7
View File
@@ -34,22 +34,30 @@ deep-frozen copy. `where` names the entry in messages. `fail(code, message)` def
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
`manuscript` lists folder names and shares none with `types`.
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
non-hybrid `sources`, `constraints` (each one `{ "exclude": ... }` or
`{ "pin": { "entity", "field", "value" } }`) and `notes`.
plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or
`{ "pin": { "entity", "field", "value" } }`) and `notes`, and may have `books: []`. A draft
universe has only `kind: "draft"`, `name` and one or more `playground` prefixes, and comes back as
`{ kind: "draft", name, books: [], playground }`. `kind` is absent, `"hybrid"` or `"draft"`.
- A playground prefix ends in `/` and has one of three levels (C2-01c). Book level lies inside one of
its own entry's book folders. Universe level, in a plain universe or a hybrid, lies outside every
book folder. A draft's prefixes all lie outside every book folder. No prefix holds another or is
listed twice, across every entry.
Refusals:
| Code | When |
|---|---|
| `library-playground-outside-book` | a playground prefix is not strictly inside a book of its own universe |
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, a book folder in two universes, a book folder that holds another, a playground prefix that holds another, an unknown hybrid source |
| `library-playground-other-universe` | a playground prefix lies inside, or holds, a book folder of another entry |
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground prefix that holds another or is listed twice, a prefix equal to a book folder or holding its own entry's book, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
## Scope
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
mapped. A path under a playground prefix is `playground`. Any other path under a book folder is
`canon`. Everything else is `unmapped`. The type is the first folder after the book, or after the
playground prefix: a `types` name, `manuscript`, or `null`.
mapped. It checks prefixes first. A path under any entry's playground prefix is `playground`, with that
entry as `universe` and the book folder holding the prefix as `book`, or `null` at universe level and
in a draft. Any other path under a book folder is `canon`. Everything else is `unmapped`. The type is
the first folder after the playground prefix, or after the book: a `types` name, `manuscript`, or
`null`.
## Reader
+30 -14
View File
@@ -1,5 +1,6 @@
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
// books, type folders, manuscript folders and playground prefixes. A pure validator. Business file
// books, type folders, manuscript folders and playground prefixes at three levels (ruling after r11,
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. A pure validator. Business file
// loading wires it in C2-04, which passes its own `fail` so the refusal reads as a business file
// problem. Agents can't write this entry (M9, C2-04).
import { raise } from './problems.mjs';
@@ -10,6 +11,7 @@ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
// C2-03a (sage-2021): the bot account sync answers git's Username prompt with. Row 199's NAME.
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
const CONSTRAINT_KEYS = ['exclude', 'pin'];
const KINDS = ['hybrid', 'draft'];
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
// and dot names are not.
@@ -61,29 +63,29 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
object(entry.universes, 'universes');
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
const universes = {}, bookOwner = new Map(), prefixes = [];
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
for (const [id, universe] of Object.entries(entry.universes)) {
const name = `universes.${id}`;
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
object(universe, name);
const hybrid = universe.kind === 'hybrid';
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes'] : ['name', 'books', 'playground'], name);
if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`);
const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft';
if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`);
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes']
: draft ? ['kind', 'name', 'playground'] : ['name', 'books', 'playground'], name);
if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name))
bad(`${name}.name must be a short display name`);
const books = list(universe.books, `${name}.books`, folder, 'a folder path');
if (!hybrid && !books.length) bad(`${name}.books must name at least one book folder`);
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
if (!hybrid && !draft && !books.length) bad(`${name}.books must name at least one book folder`);
for (const book of books) {
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
bookOwner.set(book, id);
}
const playground = list(universe.playground ?? [], `${name}.playground`,
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
for (const prefix of playground) {
// Every playground file must have a universe and a book (1.1, B1).
if (!books.some(book => holds(book, prefix) && prefix !== `${book}/`))
fail('library-playground-outside-book', `${where} ${name}.playground ${JSON.stringify(prefix)} is not inside one of its books`);
prefixes.push(prefix);
}
if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`);
for (const prefix of playground) { prefixes.push(prefix); prefixOwner.set(prefix, id); }
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground }; continue; }
const out = { name: universe.name, books, playground };
if (hybrid) {
out.kind = 'hybrid';
@@ -114,14 +116,28 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
}
for (const [id, universe] of Object.entries(universes)) {
for (const source of universe.sources ?? []) {
if (!Object.hasOwn(universes, source) || universes[source].kind === 'hybrid')
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a non-hybrid universe`);
if (!Object.hasOwn(universes, source) || Object.hasOwn(universes[source], 'kind'))
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a plain universe`);
}
}
const books = [...bookOwner.keys()];
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice');
// A prefix's level comes from where it sits (ruling after r11). Inside one of its own entry's books
// is book level, outside every book is universe level, and a draft's prefixes are all outside. Scope
// checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book
// into playground.
for (const [prefix, id] of prefixOwner) {
for (const [book, owner] of bookOwner) {
const at = `universes.${id}.playground ${JSON.stringify(prefix)}`;
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
const inside = holds(book, prefix), holding = holds(prefix, book);
if ((inside || holding) && owner !== id)
fail('library-playground-other-universe', `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
}
}
return deepFreeze({
kind: 'library', remote: entry.remote, branch: entry.branch,
+21 -10
View File
@@ -3,19 +3,30 @@
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
// { scope: 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
// `type` is the mapped type of the first folder under the book (canon) or under the playground
// prefix (playground), 'manuscript' for a manuscript folder, else null. `folder` is that folder name.
// Prefixes come first (ruling after r11, C2-01c): a `.md` file under any entry's playground prefix is
// playground, with that entry as `universe` and the book folder that holds the prefix as `book`, or
// null at universe level and in a draft. Otherwise a `.md` file in a book folder is canon. `type` is
// the mapped type of the first folder under the prefix (playground) or under the book (canon),
// 'manuscript' for a manuscript folder, else null. `folder` is that folder name. checkMapping keeps
// prefixes from overlapping, so at most one matches.
export function classify(mapping, path) {
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
for (const [universe, entry] of Object.entries(mapping.universes)) {
const book = entry.books.find(b => inside(b, path));
if (!book) continue;
const entries = Object.entries(mapping.universes);
for (const [universe, entry] of entries) {
const prefix = entry.playground.find(p => inside(p, path));
const rest = path.slice((prefix ?? `${book}/`).length).split('/');
const folder = rest.length > 1 ? rest[0] : null;
const type = folder === null ? null : Object.hasOwn(mapping.types, folder) ? mapping.types[folder]
: mapping.manuscript.includes(folder) ? 'manuscript' : null;
return { scope: prefix ? 'playground' : 'canon', universe, book, type, folder };
if (prefix) return place(mapping, path, prefix, { scope: 'playground', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
}
for (const [universe, entry] of entries) {
const book = entry.books.find(b => inside(b, path));
if (book) return place(mapping, path, `${book}/`, { scope: 'canon', universe, book });
}
return { scope: 'unmapped' };
}
function place(mapping, path, under, where) {
const rest = path.slice(under.length).split('/');
const folder = rest.length > 1 ? rest[0] : null;
const type = folder === null ? null : Object.hasOwn(mapping.types, folder) ? mapping.types[folder]
: mapping.manuscript.includes(folder) ? 'manuscript' : null;
return { ...where, type, folder };
}
+52 -8
View File
@@ -13,16 +13,57 @@ test('the fixture mapping checks and comes back frozen', () => {
assert.throws(() => { m.types.Extra = 'place'; });
});
test('a playground prefix outside its universe books refuses library-playground-outside-book', () => {
refuses(e => { e.universes.harbor.playground = ['Second World/Playground/']; return e; }, 'library-playground-outside-book');
refuses(e => { e.universes.harbor.playground = ['Elsewhere/']; return e; }, 'library-playground-outside-book');
// The whole book as its playground would leave no canon: not inside the book.
refuses(e => { e.universes.harbor.playground = ['Book One/']; return e; }, 'library-playground-outside-book');
const draft = (playground = ['Drafts/New World/']) => ({ kind: 'draft', name: 'New World', playground });
test('C2-01c: a prefix inside or holding another entry\'s book refuses library-playground-other-universe', () => {
refuses(e => { e.universes.harbor.playground = ['Second World/Playground/']; return e; },
'library-playground-other-universe', /universes.harbor.playground "Second World\/Playground\/" lies inside book folder "Second World" of universes.second/);
refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.playground = ['Worlds/']; return e; },
'library-playground-other-universe', /"Worlds\/" holds book folder "Worlds\/Second" of universes.second/);
// A draft has no books, so a draft prefix inside a book is always inside another entry's.
refuses(e => { e.universes.idea = draft(['Book One/New World/']); return e; },
'library-playground-other-universe', /universes.idea.playground "Book One\/New World\/" lies inside book folder "Book One" of universes.harbor/);
});
test('C2-01c: a prefix equal to a book folder, or holding its own, refuses library-mapping-invalid', () => {
// The whole book as its playground would leave no canon.
refuses(e => { e.universes.harbor.playground = ['Book One/']; return e; }, 'library-mapping-invalid', /is the whole book folder "Book One"/);
refuses(e => { e.universes.harbor.playground = ['Second World/']; return e; }, 'library-mapping-invalid', /is the whole book folder "Second World"/);
refuses(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = ['Shelf/']; return e; },
'library-mapping-invalid', /"Shelf\/" holds its own book folder "Shelf\/Book One"/);
});
test('C2-01c: universe-level prefixes lie outside every book, in a plain universe and in a hybrid with no books', () => {
const m = mapping(e => {
e.universes.harbor.playground = ['Book One/Playground/', 'Harbor Ideas/'];
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], playground: ['Crossover Ideas/'] };
return e;
});
assert.deepEqual(m.universes.harbor.playground, ['Book One/Playground/', 'Harbor Ideas/']);
assert.deepEqual([m.universes.cross.books, m.universes.cross.playground], [[], ['Crossover Ideas/']]);
});
test('C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []', () => {
const m = mapping(e => { e.universes.idea = draft(); return e; });
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });
assert.ok(Object.isFrozen(m.universes.idea) && Object.isFrozen(m.universes.idea.books) && Object.isFrozen(m.universes.idea.playground));
refuses(e => { e.universes.idea = { ...draft(), books: ['New World'] }; return e; }, 'library-mapping-invalid', /universes.idea is a draft and takes no books/);
refuses(e => { e.universes.idea = { ...draft(), books: [] }; return e; }, 'library-mapping-invalid', /takes no books/);
refuses(e => { e.universes.idea = draft([]); return e; }, 'library-mapping-invalid', /universes.idea.playground must name at least one prefix/);
refuses(e => { e.universes.idea = { kind: 'draft', name: 'New World' }; return e; }, 'library-mapping-invalid', /at least one prefix/);
refuses(e => { e.universes.idea = { ...draft(), notes: 'x' }; return e; }, 'library-mapping-invalid', /unknown key "notes"/);
for (const kind of ['library', 'Draft', 'plain', null, 1])
refuses(e => { e.universes.harbor.kind = kind; return e; }, 'library-mapping-invalid', /universes.harbor.kind must be "hybrid" or "draft"/);
});
test('no playground prefix holds another, and no book folder holds another', () => {
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
'library-mapping-invalid', /holds playground/);
// The rule covers every level and every entry (C2-01c).
refuses(e => { e.universes.harbor.playground = ['Ideas/']; e.universes.idea = draft(['Ideas/New World/']); return e; },
'library-mapping-invalid', /playground "Ideas\/" holds playground "Ideas\/New World\/"/);
refuses(e => { e.universes.harbor.playground = ['Ideas/']; e.universes.idea = draft(['Ideas/']); return e; },
'library-mapping-invalid', /listed twice/);
refuses(e => { e.universes.second.books = ['Second World', 'Book One/Part Two']; return e; }, 'library-mapping-invalid', /holds book folder/);
refuses(e => { e.universes.second.books = ['Book One']; return e; }, 'library-mapping-invalid', /in two universes/);
});
@@ -58,7 +99,10 @@ test('a hybrid universe is a shape the query code can take (1.4), with sources t
notes: 'Set after book one.' }; return e; };
const m = checkMapping(hybrid(mappingEntry()));
assert.deepEqual(m.universes.cross.sources, ['harbor', 'second']);
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor', 'nowhere']; return e; }, 'library-mapping-invalid', /non-hybrid universe/);
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor', 'nowhere']; return e; }, 'library-mapping-invalid', /not a plain universe/);
// A draft has no canon to draw on (C2-01c).
refuses(e => { hybrid(e); e.universes.idea = draft(); e.universes.cross.sources = ['harbor', 'idea']; return e; },
'library-mapping-invalid', /sources names "idea", which is not a plain universe/);
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor']; return e; }, 'library-mapping-invalid', /two or more/);
refuses(e => { hybrid(e); e.universes.cross.constraints = [{ exclude: 'x', pin: {} }]; return e; }, 'library-mapping-invalid', /exactly one/);
});
@@ -67,9 +111,9 @@ test('a caller-supplied fail receives the code, as business file loading will pa
const seen = [];
const fail = (code, message) => { seen.push(code); throw new Error(message); };
const entry = mappingEntry();
entry.universes.harbor.playground = ['Elsewhere/'];
entry.universes.harbor.playground = ['Second World/Playground/'];
assert.throws(() => checkMapping(entry, 'businesses/acme.json projects.books', { fail }), /businesses\/acme.json projects.books/);
assert.deepEqual(seen, ['library-playground-outside-book']);
assert.deepEqual(seen, ['library-playground-other-universe']);
});
test('scope comes from the path alone (design 1.2)', () => {
+55
View File
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { classify, readLibrary } from '../src/index.mjs';
import { BASE, mapping, repo } from './helpers.mjs';
// Playgrounds at three levels (ruling after r11, C2-01c). The fixture's harbor keeps its book-level
// prefix and gains a universe-level one. A hybrid with no books has a universe-level prefix, and a
// draft has its own.
const levels = () => mapping(e => {
e.universes.harbor.playground = ['Book One/Playground/', 'Harbor Ideas/'];
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], playground: ['Crossover Ideas/'] };
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'] };
return e;
});
test('C2-01c: each level gives its universe, and its book or null', () => {
const m = levels();
assert.deepEqual(classify(m, 'Book One/Playground/Characters/New.md'),
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
assert.deepEqual(classify(m, 'Harbor Ideas/Places/Island.md'),
{ scope: 'playground', universe: 'harbor', book: null, type: 'place', folder: 'Places' });
assert.deepEqual(classify(m, 'Harbor Ideas/Loose.md'), { scope: 'playground', universe: 'harbor', book: null, type: null, folder: null });
assert.deepEqual(classify(m, 'Crossover Ideas/Chapters/Meeting.md'),
{ scope: 'playground', universe: 'cross', book: null, type: 'manuscript', folder: 'Chapters' });
assert.deepEqual(classify(m, 'Drafts/New World/Characters/Ana.md'),
{ scope: 'playground', universe: 'idea', book: null, type: 'character', folder: 'Characters' });
for (const path of ['Harbor Ideas/Board.canvas', 'Crossover Ideas/Sketch.canvas', 'Drafts/New World/Map.canvas',
'Drafts/Other.md', 'Drafts/New World.md', 'Harbor Ideasx/a.md'])
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
});
test('C2-01c: prefixes come first: in a book that holds a prefix, a file outside it is canon and one under it playground', () => {
const m = levels();
assert.deepEqual(classify(m, 'Book One/Places/Dock.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: 'place', folder: 'Places' });
assert.deepEqual(classify(m, 'Book One/Playground.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: null, folder: null });
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: null, folder: null });
assert.deepEqual(classify(m, 'Second World/Chapters/01.md'), { scope: 'canon', universe: 'second', book: 'Second World', type: 'manuscript', folder: 'Chapters' });
});
test('C2-01c: a canon file linking into a draft gives R6, and into its own universe-level prefix gives R5', async t => {
const r = repo(t, { ...BASE, 'Harbor Ideas/Island.md': 'An island.\n', 'Drafts/New World/Ana.md': 'Ana.\n',
'Book One/Places/Dock.md': 'See [[Island]].\n', 'Book One/Places/Pier.md': 'Like [[Ana]].\n' });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: levels() });
const codes = path => result.problems.filter(p => p.path === path).map(p => p.code).sort();
const file = path => result.files.find(f => f.path === path);
assert.deepEqual(codes('Book One/Places/Dock.md'), ['R5']);
// Any playground gives R5. A draft is never a source, so it gives R6 as well.
assert.deepEqual(codes('Book One/Places/Pier.md'), ['R5', 'R6']);
assert.deepEqual(file('Book One/Places/Pier.md').links.map(l => l.path), ['Drafts/New World/Ana.md']);
for (const [path, universe] of [['Harbor Ideas/Island.md', 'harbor'], ['Drafts/New World/Ana.md', 'idea']]) {
assert.deepEqual([file(path).scope, file(path).universe, file(path).book], ['playground', universe, null], path);
assert.deepEqual(codes(path), [], path);
}
});