Lands Gosalyn's round 3 candidate,a2c72ac95(commit candidate, basec771a36f4), approved by Webby (29979, rev 2731). 21 paths: the new packages/library (src, tests, fixtures, package.json, README), scripts/stack-copy.sh (packages/library/src in COPY_PATHS), packages/cli/tests/stack-copy.test.mjs, scripts/mosaic and agents/gosalyn/work/queue-212/BUILD.md. The 21-path patch-id, ea84d3e35470, equals the candidate's. Evidence: Gosalyn's slot run on round 3: 20 files 451 pass, 0 fail; test-queue 28/0 (gosalyn-0086). Landing gate (Sage, 08:51-09:03Z, base4ced5e63d) on a tree that also held row 197's 32 paths: GATE_PASSED, node tests 981 pass 0 fail (library 39, cli 681, control-board 38, harness 16, webui 51, plus the scripts/test-*.sh node suites), selftests 429 passed 0 failed. Row 212 lands alone (mos-1162); row 197 waits for an r6 after its lane proof stopped. No 212 path changed betweenc771a36f4and this commit's parent. `queue review verify-commit 212` -> ok row 212 round 3:a2c72ac95matches the commit candidate (21 paths).
133 lines
7.8 KiB
JavaScript
133 lines
7.8 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. 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';
|
|
|
|
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}$/;
|
|
const CONSTRAINT_KEYS = ['exclude', 'pin'];
|
|
|
|
// 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', 'vaultPrefix', '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, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
|
|
|
|
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 = [];
|
|
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 (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`);
|
|
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);
|
|
}
|
|
const out = { name: universe.name, books, playground };
|
|
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) || universes[source].kind === 'hybrid')
|
|
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a non-hybrid 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');
|
|
|
|
return deepFreeze({
|
|
kind: 'library', remote: entry.remote, branch: entry.branch,
|
|
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
|
|
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;
|
|
}
|