Files

225 lines
14 KiB
JavaScript

// The library project entry of a business file (design 1.1): her folders mapped onto universes,
// 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. Supporting
// prefixes (C2-01d) follow the same rules and are where agents may write (M12). 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';
import { typeForm } from './scope.mjs';
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
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'];
// C2-01d (F41): the editor ident her Console saves commit under. Git drops `<` and `>` from an ident
// and commits under a different name, so they refuse here (webby-0169 R1).
const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;
// Git's ident.c crud() strips any byte <= 0x20 and , : ; < > " \ ' from both ends of an ident, so a
// value that starts or ends with one would save one name and commit another (sage-3601, sage-3606).
const IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/;
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
// and dot names are not.
function segment(value) {
return typeof value === 'string' && value.length > 0 && value.length <= 255 && value !== '.' && value !== '..'
&& !/[\/\\\u0000-\u001f\u007f]/.test(value) && value.trim() === value;
}
function folder(value) {
return typeof value === 'string' && value.length <= 1024 && value.split('/').every(segment);
}
const holds = (outer, inner) => inner.startsWith(outer.endsWith('/') ? outer : `${outer}/`);
export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
const bad = message => fail('library-mapping-invalid', `${where} ${message}`);
const object = (value, name) => {
if (value === null || typeof value !== 'object' || Array.isArray(value)) bad(`${name} must be an object`);
};
const only = (value, keys, name) => {
for (const key of Object.keys(value)) if (!keys.includes(key)) bad(`${name} has unknown key ${JSON.stringify(key)}`);
};
const list = (value, name, check, what) => {
if (!Array.isArray(value)) bad(`${name} must be a list`);
for (const item of value) if (!check(item)) bad(`${name} holds ${JSON.stringify(item)}, which is not ${what}`);
if (new Set(value).size !== value.length) bad(`${name} names an entry twice`);
return [...value];
};
object(entry, '');
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'editor', 'universes', 'types', 'manuscript'], 'entry');
if (entry.kind !== 'library') bad('kind must be "library"');
let url;
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || entry.remote.includes('\0'))
bad('remote must be an https:// URL with no credentials, query or fragment');
if (typeof entry.branch !== 'string' || !BRANCH.test(entry.branch) || entry.branch.includes('..')
|| entry.branch.includes('//') || entry.branch.endsWith('/') || entry.branch.endsWith('.lock'))
bad('branch must be a plain branch name');
if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user)))
bad('user must be a plain account name');
if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
if (Object.hasOwn(entry, 'editor')) {
object(entry.editor, 'editor');
only(entry.editor, ['name', 'email', 'user'], 'editor');
// Her Gitea login for the library-human slot, which C2-02 passes in (sage-2925). Optional here.
if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' || !USER.test(entry.editor.user)))
bad('editor.user must be a plain account name');
for (const key of ['name', 'email']) {
const value = entry.editor[key];
if (typeof value !== 'string' || !value.trim() || IDENT_BAD.test(value))
bad(`editor.${key} must be non-empty text with no control character, < or >`);
if (value.trim() !== value || IDENT_CRUD_END.test(value))
bad(`editor.${key} must not start or end with whitespace or any of , : ; " ' \\`);
}
}
object(entry.types, 'types');
for (const [name, type] of Object.entries(entry.types)) {
if (!segment(name)) bad(`types has ${JSON.stringify(name)}, which is not one folder name`);
if (!TYPES.includes(type)) bad(`types.${name} must be one of ${TYPES.join(', ')}`);
}
const manuscript = list(entry.manuscript, 'manuscript', segment, 'one folder name');
for (const name of manuscript) if (Object.hasOwn(entry.types, name)) bad(`manuscript folder ${JSON.stringify(name)} is also a type folder`);
object(entry.universes, 'universes');
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
const supports = [], supportOwner = 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);
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', 'supporting', 'constraints', 'notes']
: draft ? ['kind', 'name', 'playground', 'supporting'] : ['name', 'books', 'playground', 'supporting'], 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 = 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 prefix = value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1));
const playground = list(universe.playground ?? [], `${name}.playground`, prefix, 'a folder path ending in /');
if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`);
for (const p of playground) { prefixes.push(p); prefixOwner.set(p, id); }
const supporting = list(universe.supporting ?? [], `${name}.supporting`, prefix, 'a folder path ending in /');
for (const p of supporting) { supports.push(p); supportOwner.set(p, id); }
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground, supporting }; continue; }
const out = { name: universe.name, books, playground, supporting };
if (hybrid) {
out.kind = 'hybrid';
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
if (out.sources.length < 2) bad(`${name}.sources must name two or more universes`);
if (!Array.isArray(universe.constraints ?? [])) bad(`${name}.constraints must be a list`);
out.constraints = (universe.constraints ?? []).map((constraint, i) => {
const at = `${name}.constraints[${i}]`;
object(constraint, at);
only(constraint, CONSTRAINT_KEYS, at);
if (Object.keys(constraint).length !== 1) bad(`${at} must hold exactly one of ${CONSTRAINT_KEYS.join(', ')}`);
if (Object.hasOwn(constraint, 'exclude')) {
if (!folder(constraint.exclude)) bad(`${at}.exclude must be a source path`);
return { exclude: constraint.exclude };
}
object(constraint.pin, `${at}.pin`);
only(constraint.pin, ['entity', 'field', 'value'], `${at}.pin`);
if (!folder(constraint.pin.entity) || typeof constraint.pin.field !== 'string' || !constraint.pin.field
|| !['string', 'number', 'boolean'].includes(typeof constraint.pin.value))
bad(`${at}.pin needs an entity path, a field name and a scalar value`);
return { pin: { ...constraint.pin } };
});
if (Object.hasOwn(universe, 'notes') && (typeof universe.notes !== 'string' || universe.notes.length > 4000))
bad(`${name}.notes must be text of at most 4000 characters`);
if (Object.hasOwn(universe, 'notes')) out.notes = universe.notes;
}
universes[id] = out;
}
for (const [id, universe] of Object.entries(universes)) {
for (const source of universe.sources ?? []) {
if (!Object.hasOwn(universes, source) || Object.hasOwn(universes[source], 'kind'))
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a plain universe`);
}
}
// C2-15 (design 1.4): each constraint against the hybrid's sources, now that every source is read. An
// exclude reads as the type form `source/Type` or as a path in a source's book, never both. A path, or
// a pinned entity, under a source's playground is one the hybrid never reads (REQ-LIB-3). Whether the
// path exists at a commit is the reader's to check.
for (const [id, universe] of Object.entries(universes)) {
if (universe.kind !== 'hybrid') continue;
const sources = universe.sources.map(source => [source, universes[source]]);
const inSourceBook = path => sources.some(([, s]) => s.books.some(book => path === book || holds(book, path)));
const underPlayground = path => sources.find(([, s]) => s.playground.some(p => path === p.slice(0, -1) || path.startsWith(p)))?.[0];
const sourcePath = (path, at) => {
if (!inSourceBook(path)) bad(`${at} ${JSON.stringify(path)} lies inside no book of the hybrid's sources`);
const owner = underPlayground(path);
if (owner) bad(`${at} ${JSON.stringify(path)} lies under a playground of universes.${owner}, which the hybrid never reads`);
};
universe.constraints.forEach((constraint, i) => {
const at = `universes.${id}.constraints[${i}]`;
if (constraint.pin) {
if (!constraint.pin.entity.endsWith('.md')) bad(`${at}.pin.entity ${JSON.stringify(constraint.pin.entity)} is not a .md file`);
return sourcePath(constraint.pin.entity, `${at}.pin.entity`);
}
const type = typeForm(entry, universe, constraint.exclude);
if (type && inSourceBook(constraint.exclude))
bad(`${at}.exclude ${JSON.stringify(constraint.exclude)} reads as both a source/type and a source path`);
if (!type) sourcePath(constraint.exclude, `${at}.exclude`);
});
}
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');
for (const a of supports) for (const b of supports) if (a !== b && holds(a, b)) bad(`supporting ${JSON.stringify(a)} holds supporting ${JSON.stringify(b)}`);
if (new Set(supports).size !== supports.length) bad('a supporting 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 or supporting.
const againstBooks = (owners, list, code) => {
for (const [prefix, id] of owners) {
for (const [book, owner] of bookOwner) {
const at = `universes.${id}.${list} ${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(code, `${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)}`);
}
}
};
againstBooks(prefixOwner, 'playground', 'library-playground-other-universe');
againstBooks(supportOwner, 'supporting', 'library-supporting-other-universe');
// C2-01d, between the lists. A supporting prefix may equal a playground prefix of its own entry, and
// a playground may hold one of its own entry's supporting prefixes. A supporting prefix that holds a
// playground would hide it, since scope checks supporting first. Any overlap across entries would
// give one path two universes.
for (const [s, sid] of supportOwner) {
for (const [p, pid] of prefixOwner) {
const at = `universes.${sid}.supporting ${JSON.stringify(s)}`;
if (sid !== pid && (s === p || holds(s, p) || holds(p, s)))
bad(`${at} overlaps playground ${JSON.stringify(p)} of universes.${pid}`);
if (sid === pid && s !== p && holds(s, p)) bad(`${at} holds playground ${JSON.stringify(p)}`);
}
}
return deepFreeze({
kind: 'library', remote: entry.remote, branch: entry.branch,
...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}),
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
...(Object.hasOwn(entry, 'editor') ? { editor: { name: entry.editor.name, email: entry.editor.email,
...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {}) } } : {}),
universes, types: { ...entry.types }, manuscript,
});
}
function deepFreeze(value) {
if (value && typeof value === 'object') { for (const item of Object.values(value)) deepFreeze(item); Object.freeze(value); }
return value;
}