import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { checkMapping, classify, LibraryError } from '../src/index.mjs'; import { mapping, mappingEntry } from './helpers.mjs'; const refuses = (change, code, pattern) => assert.throws(() => checkMapping(change(mappingEntry())), e => e instanceof LibraryError && e.code === code && (!pattern || pattern.test(e.message))); test('the fixture mapping checks and comes back frozen', () => { const m = mapping(); assert.equal(m.universes.harbor.playground[0], 'Book One/Playground/'); assert.ok(Object.isFrozen(m.universes.harbor.books)); assert.throws(() => { m.types.Extra = 'place'; }); }); 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/'], supporting: [] }); 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('C2-01d: supporting prefixes at book level, universe level, in a hybrid and in a draft, [] when absent', () => { const m = mapping(e => { e.universes.harbor.supporting = ['Book One/Notes/', 'Harbor Notes/']; e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], supporting: ['Crossover Notes/'] }; e.universes.idea = { ...draft(), supporting: ['Drafts/New World Notes/'] }; return e; }); assert.deepEqual(m.universes.harbor.supporting, ['Book One/Notes/', 'Harbor Notes/']); assert.deepEqual(m.universes.cross.supporting, ['Crossover Notes/']); assert.deepEqual(m.universes.idea.supporting, ['Drafts/New World Notes/']); assert.deepEqual(m.universes.second.supporting, []); assert.ok(Object.isFrozen(m.universes.harbor.supporting) && Object.isFrozen(m.universes.second.supporting)); assert.deepEqual(mapping().universes.harbor.supporting, []); }); test('C2-01d: each supporting prefix rule refuses with its code', () => { const invalid = (change, pattern) => refuses(change, 'library-mapping-invalid', pattern); invalid(e => { e.universes.harbor.supporting = ['Book One/Notes']; return e; }, /universes.harbor.supporting holds "Book One\/Notes", which is not a folder path ending in \//); invalid(e => { e.universes.harbor.supporting = ['Book One/../Notes/']; return e; }, /which is not a folder path ending in \//); invalid(e => { e.universes.harbor.supporting = 'Book One/Notes/'; return e; }, /universes.harbor.supporting must be a list/); invalid(e => { e.universes.harbor.supporting = ['Book One/Notes/', 'Book One/Notes/Deep/']; return e; }, /supporting "Book One\/Notes\/" holds supporting "Book One\/Notes\/Deep\/"/); invalid(e => { e.universes.harbor.supporting = ['Ideas/']; e.universes.idea = { ...draft(), supporting: ['Ideas/'] }; return e; }, /a supporting prefix is listed twice/); invalid(e => { e.universes.harbor.supporting = ['Book One/']; return e; }, /universes.harbor.supporting "Book One\/" is the whole book folder "Book One"/); invalid(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = []; e.universes.harbor.supporting = ['Shelf/']; return e; }, /universes.harbor.supporting "Shelf\/" holds its own book folder "Shelf\/Book One"/); // Supporting is checked first in scope, so a supporting prefix holding a playground would hide it. invalid(e => { e.universes.harbor.supporting = ['Book One/Workshop/']; e.universes.harbor.playground = ['Book One/Workshop/Playground/']; return e; }, /universes.harbor.supporting "Book One\/Workshop\/" holds playground "Book One\/Workshop\/Playground\/"/); // Across entries any overlap would give one path two universes. for (const [supporting, playground] of [['Ideas/', 'Ideas/'], ['Ideas/Deep/', 'Ideas/'], ['Ideas/', 'Ideas/Deep/']]) invalid(e => { e.universes.harbor.supporting = [supporting]; e.universes.idea = draft([playground]); return e; }, new RegExp(`universes.harbor.supporting "${supporting}" overlaps playground "${playground}" of universes.idea`)); refuses(e => { e.universes.harbor.supporting = ['Second World/Notes/']; return e; }, 'library-supporting-other-universe', /universes.harbor.supporting "Second World\/Notes\/" lies inside book folder "Second World" of universes.second/); refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.supporting = ['Worlds/']; return e; }, 'library-supporting-other-universe', /universes.harbor.supporting "Worlds\/" holds book folder "Worlds\/Second" of universes.second/); refuses(e => { e.universes.idea = { ...draft(), supporting: ['Book One/Idea Notes/'] }; return e; }, 'library-supporting-other-universe', /universes.idea.supporting "Book One\/Idea Notes\/" lies inside book folder "Book One" of universes.harbor/); }); test('C2-01d: between the lists, a supporting prefix may equal its own playground, and a playground may hold its own supporting', () => { const same = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/']; return e; }); assert.deepEqual([same.universes.harbor.playground, same.universes.harbor.supporting], [['Book One/Playground/'], ['Book One/Playground/']]); const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; }); assert.deepEqual(nested.universes.harbor.supporting, ['Book One/Playground/Notes/']); }); test('C2-01d: editor is optional, kept when given, and refuses anything but a plain name and email', () => { assert.equal(Object.hasOwn(mapping(), 'editor'), false); const editor = { name: 'Her Name', email: 'her@example.invalid' }; const m = mapping(e => ({ ...e, editor })); assert.deepEqual(m.editor, editor); assert.notEqual(m.editor, editor); assert.ok(Object.isFrozen(m.editor)); const invalid = (value, pattern) => refuses(e => ({ ...e, editor: value }), 'library-mapping-invalid', pattern); for (const value of [null, 'Her Name', 7, ['Her Name', 'her@example.invalid']]) invalid(value, /editor must be an object/); invalid({ ...editor, signingKey: 'x' }, /editor has unknown key "signingKey"/); // sage-2925: an optional Gitea login for her save, checked as the entry's user is. assert.deepEqual(mapping(e => ({ ...e, editor: { ...editor, user: 'her.login-1' } })).editor, { ...editor, user: 'her.login-1' }); for (const user of ['', 'her login', 'her@host', 'a'.repeat(101), 7, null]) invalid({ ...editor, user }, /editor.user must be a plain account name/); invalid({ name: 'Her Name' }, /editor.email must be non-empty text with no control character, < or >/); invalid({ email: 'her@example.invalid' }, /editor.name must be non-empty text/); for (const bad of ['', ' ', 7, null, 'a\u0000b', 'a\nb', 'a\u001fb', 'a\u007fb', 'a\u0085b', 'a\u009fb', '', 'ab']) { invalid({ ...editor, name: bad }, /editor.name must be non-empty text with no control character, < or >/); invalid({ ...editor, email: bad }, /editor.email must be non-empty text with no control character, < or >/); } // sage-3601, sage-3606: git's crud() strips whitespace and , : ; < > " \ ' from both ends of an // ident, so a value starting or ending with one refuses. Inner < > and controls refuse above. const ends = [' ', '\u00a0', '\u3000', ',', ':', ';', '"', "'", '\\']; for (const c of ends) for (const padded of [`${c}Her Name`, `Her Name${c}`]) { invalid({ ...editor, name: padded }, /editor.name must not start or end with whitespace or any of , : ; " ' \\/); invalid({ ...editor, email: padded }, /editor.email must not start or end with whitespace or any of , : ; " ' \\/); } for (const c of ['<', '>', '\n', '\t']) for (const padded of [`${c}Her Name`, `Her Name${c}`, `Her${c}Name`]) invalid({ ...editor, name: padded }, /editor.name must be non-empty text with no control character, < or >/); assert.equal(mapping(e => ({ ...e, editor: { name: 'Her Middle Name', email: 'her@example.invalid' } })).editor.name, 'Her Middle Name'); for (const name of ['Doe, Jane Q.', 'Jane: "Q" Doe', "O'Neil", 'Back\\slash Name']) assert.equal(mapping(e => ({ ...e, editor: { name, email: 'her@example.invalid' } })).editor.name, name); }); // sage-3606: an accepted editor commits under exactly the saved ident, as author and committer. A // future git that strips more from an ident fails here. test('C2-01d: an accepted editor round-trips through a real git commit unchanged', t => { const editor = mapping(e => ({ ...e, editor: { name: 'Doe, Jane Q.', email: 'jane.doe@example.invalid.' } })).editor; const root = mkdtempSync(join(tmpdir(), 'library-ident-')); t.after(() => rmSync(root, { recursive: true, force: true })); // An ident in the environment would win over -c, so the commit runs without one. GIT_DIR and its // family would beat -C (inside a hook, say), and git reads no global or system config (webby-0236 E5). const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k))); for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[k]; Object.assign(env, { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }); const git = (...args) => execFileSync('git', ['-c', `user.name=${editor.name}`, '-c', `user.email=${editor.email}`, '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { encoding: 'utf8', env }); git('init', '-q'); git('commit', '-q', '--allow-empty', '-m', 'ident'); assert.equal(git('log', '-1', '--format=%an%n%ae%n%cn%n%ce'), `${editor.name}\n${editor.email}\n${editor.name}\n${editor.email}\n`); }); 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/); }); test('shape refusals: kind, remote, branch, unknown keys, types, manuscript, folder names', () => { refuses(e => ({ ...e, kind: 'code' }), 'library-mapping-invalid', /kind/); refuses(e => ({ ...e, remote: 'http://git.example.invalid/x.git' }), 'library-mapping-invalid', /https/); refuses(e => ({ ...e, remote: 'https://user:secret@git.example.invalid/x.git' }), 'library-mapping-invalid', /credentials/); refuses(e => ({ ...e, remote: 'git@git.example.invalid:x.git' }), 'library-mapping-invalid', /https/); refuses(e => ({ ...e, branch: 'main..x' }), 'library-mapping-invalid', /branch/); refuses(e => ({ ...e, root: '/tmp' }), 'library-mapping-invalid', /unknown key "root"/); refuses(e => { e.universes.harbor.extra = 1; return e; }, 'library-mapping-invalid', /unknown key/); refuses(e => { e.types.Characters = 'villain'; return e; }, 'library-mapping-invalid', /types.Characters/); refuses(e => { e.types['A/B'] = 'place'; return e; }, 'library-mapping-invalid', /one folder name/); refuses(e => ({ ...e, manuscript: ['Characters'] }), 'library-mapping-invalid', /also a type folder/); refuses(e => { e.universes.harbor.books = ['../Book One']; return e; }, 'library-mapping-invalid', /folder path/); refuses(e => ({ ...e, vaultPrefix: 'a/b' }), 'library-mapping-invalid', /vaultPrefix/); refuses(e => ({ ...e, universes: {} }), 'library-mapping-invalid', /at least one universe/); refuses(e => { e.universes['bad id'] = e.universes.second; delete e.universes.second; return e; }, 'library-mapping-invalid', /plain id/); }); test('C2-03a sage-2021: user is optional, kept when given, and must be a plain account name', () => { assert.equal(Object.hasOwn(mapping(), 'user'), false); assert.equal(mapping(e => ({ ...e, user: 'books-bot' })).user, 'books-bot'); assert.equal(mapping(e => ({ ...e, user: 'A_b.c-9'.padEnd(100, 'x') })).user.length, 100); for (const user of ['', 'a'.repeat(101), 'books bot', 'bot@host', 'bot:pw', 'bot/x', 'bøt', 'bot\n', 7, null]) refuses(e => ({ ...e, user }), 'library-mapping-invalid', /user must be a plain account name/); }); test('a hybrid universe is a shape the query code can take (1.4), with sources that must exist', () => { const hybrid = e => { e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'], constraints: [{ exclude: 'second/Characters' }, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'eyes', value: 'grey' } }], 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', /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/); }); test('C2-15: each exclude and pin is checked against the hybrid\'s sources', () => { const cross = (...constraints) => e => { e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'], constraints }; return e; }; const pin = entity => ({ pin: { entity, field: 'eyes', value: 'grey' } }); // Both forms, a whole source book and a pin in a source book check. assert.equal(mapping(cross({ exclude: 'second/Characters' }, { exclude: 'Book One/Research' }, { exclude: 'Second World' }, pin('Second World/Characters/Ilse.md'))).universes.cross.constraints.length, 4); // Both readings fit: second's book folder is also named "second". refuses(e => { cross({ exclude: 'second/Characters' })(e); e.universes.second.books = ['second']; return e; }, 'library-mapping-invalid', /constraints\[0\]\.exclude "second\/Characters" reads as both a source\/type and a source path/); // Neither fits: an unmapped path, the hybrid's own book, a type that isn't a key, a non-source universe. for (const exclude of ['Elsewhere/Characters', 'Crossover/Characters', 'second/Ghosts', 'harbor', 'Book']) refuses(cross({ exclude }), 'library-mapping-invalid', /\.exclude ".*" lies inside no book of the hybrid's sources/); refuses(e => { cross({ exclude: 'third/Characters' })(e); e.universes.third = { name: 'Third', books: ['Third World'] }; return e; }, 'library-mapping-invalid', /lies inside no book of the hybrid's sources/); // Under a source's playground, as the prefix itself or inside it. for (const exclude of ['Book One/Playground', 'Book One/Playground/Ideas']) refuses(cross({ exclude }), 'library-mapping-invalid', /\.exclude ".*" lies under a playground of universes\.harbor, which the hybrid never reads/); refuses(cross(pin('Book One/Playground/What If.md')), 'library-mapping-invalid', /\.pin\.entity "Book One\/Playground\/What If\.md" lies under a playground of universes\.harbor/); refuses(cross(pin('Crossover/Characters/Mara Vell.md')), 'library-mapping-invalid', /\.pin\.entity .* lies inside no book of the hybrid's sources/); // Q5: a pinned entity is one .md note. for (const entity of ['Book One/Characters/Mara Vell', 'Book One/Characters', 'Book One/Board.canvas']) refuses(cross(pin(entity)), 'library-mapping-invalid', /\.pin\.entity ".*" is not a \.md file/); }); test('a caller-supplied fail receives the code, as business file loading will pass it (C2-04)', () => { const seen = []; const fail = (code, message) => { seen.push(code); throw new Error(message); }; const entry = mappingEntry(); 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-other-universe']); }); test('scope comes from the path alone (design 1.2)', () => { const m = mapping(); assert.deepEqual(classify(m, 'Book One/Characters/Mara Vell.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' }); assert.deepEqual(classify(m, 'Book One/Chapters/Chapter 01.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: 'manuscript', folder: 'Chapters' }); 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, 'Book One/Playground/Characters/New.md'), { scope: 'playground', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' }); assert.deepEqual(classify(m, 'Book One/Notes.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: null, folder: null }); assert.equal(classify(m, 'Second World/Chapters/01.md').universe, 'second'); for (const path of ['Inbox Note.md', 'Book One/Board.canvas', 'Book One/Playground/Sketch.canvas', 'Book One/Board.base', 'Book One/cover.png', 'Book One/Upper.MD', 'Book Onex/a.md', 'book one/a.md', 'Elsewhere/Book One/a.md']) assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path); });