(cherry picked from commit367fd0d240) (cherry picked from commit068e1e0f1c)
225 lines
14 KiB
JavaScript
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;
|
|
}
|