row 283 (#1788): C2-01d supporting folders. supporting lists on every universe kind with the playground's prefix rules, library-supporting-other-universe and the cross-list rules; optional editor {name, email} refusing controls, < and >; classify checks supporting before playground; supportingPath with the eight checks over the listTree array, exported; survey counts supporting (sage-2923); README; tests T1-T8. Untested: runs wait for Sage's CLEAR
This commit is contained in:
1 parent
4f66dccdec
commit
331653b385
11 files changed
+377
-36
No files matched your search
@@ -27,6 +27,10 @@ test('C2-04: library mappings load without roots, remain frozen, and retain mapp
|
|||||||
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
|
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
|
||||||
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
|
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
|
||||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
|
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
|
||||||
|
// C2-01d: a supporting prefix inside another universe's book refuses with its own code.
|
||||||
|
doc.projects.books = mappingEntry();
|
||||||
|
doc.projects.books.universes.harbor.supporting = ['Second World/Notes/'];
|
||||||
|
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-supporting-other-universe/);
|
||||||
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
|
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
|
||||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
|
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,33 +31,69 @@ deep-frozen copy. `where` names the entry in messages. `fail(code, message)` def
|
|||||||
- `user` is the bot account name from design step 3, required for sync. It is optional in the
|
- `user` is the bot account name from design step 3, required for sync. It is optional in the
|
||||||
mapping and checked with row 199's account name pattern, `[A-Za-z0-9_.-]`, 1 to 100 characters.
|
mapping and checked with row 199's account name pattern, `[A-Za-z0-9_.-]`, 1 to 100 characters.
|
||||||
- `vaultPrefix` is optional, one folder name.
|
- `vaultPrefix` is optional, one folder name.
|
||||||
|
- `editor` is optional, exactly `{ "name", "email" }`: the ident her Console saves commit under
|
||||||
|
(C2-01d). Each value is text that is non-empty after trim, with no control character
|
||||||
|
(`U+0000`-`U+001F`, `U+007F`-`U+009F`), `<` or `>`. It comes back only when given.
|
||||||
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
|
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
|
||||||
`manuscript` lists folder names and shares none with `types`.
|
`manuscript` lists folder names and shares none with `types`.
|
||||||
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
|
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
|
||||||
plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or
|
plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or
|
||||||
`{ "pin": { "entity", "field", "value" } }`) and `notes`, and may have `books: []`. A draft
|
`{ "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
|
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"`.
|
`{ kind: "draft", name, books: [], playground, supporting }`. `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
|
- 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
|
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
|
book folder. A draft's prefixes all lie outside every book folder. No prefix holds another or is
|
||||||
listed twice, across every entry.
|
listed twice, across every entry.
|
||||||
|
- Every universe kind may also have `supporting`, a list of prefixes where agents may write (C2-01d,
|
||||||
|
M12). It comes back on every universe, `[]` when absent. A supporting prefix follows the playground's
|
||||||
|
rules: it ends in `/`, has the same three levels, holds no other supporting prefix and is listed
|
||||||
|
once, across every entry.
|
||||||
|
- Between the two lists: a supporting prefix may equal a playground prefix of its own entry, and a
|
||||||
|
playground prefix may hold one of its own entry's supporting prefixes. A supporting prefix may not
|
||||||
|
hold a playground prefix, which scope would then never reach, and may not equal, hold or lie inside
|
||||||
|
another entry's playground prefix.
|
||||||
|
|
||||||
Refusals:
|
Refusals:
|
||||||
|
|
||||||
| Code | When |
|
| Code | When |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `library-playground-other-universe` | a playground prefix lies inside, or holds, a book folder of another entry |
|
| `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 |
|
| `library-supporting-other-universe` | a supporting 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 or supporting prefix that holds another of its list or is listed twice, a prefix equal to a book folder or holding its own entry's book, a supporting prefix that holds a playground prefix or overlaps another entry's, a bad `editor`, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
|
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
|
||||||
mapped. It checks prefixes first. A path under any entry's playground prefix is `playground`, with that
|
mapped. It checks prefixes first, supporting before playground. A path under any entry's supporting
|
||||||
entry as `universe` and the book folder holding the prefix as `book`, or `null` at universe level and
|
prefix is `supporting`, and otherwise one under a playground prefix is `playground`. Either way that
|
||||||
|
entry is `universe` and the book folder holding the prefix is `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
|
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
|
the first folder after the prefix, or after the book: a `types` name, `manuscript`, or `null`.
|
||||||
`null`.
|
|
||||||
|
## Agent write paths
|
||||||
|
|
||||||
|
`supportingPath(mapping, universe, path, tree)` returns `path` when an agent may write it in that
|
||||||
|
universe, else it refuses `library-write-story-path` (C2-01d, M12). `tree` is the array `listTree`
|
||||||
|
returns for the commit the write lands on; anything else is a TypeError. It reads no git. The checks
|
||||||
|
run in this order, so a path with two faults always names the same one:
|
||||||
|
|
||||||
|
1. a string of at most 1,024 UTF-8 bytes;
|
||||||
|
2. no control character (the `editor` class), and the message leaves the path out;
|
||||||
|
3. no backslash;
|
||||||
|
4. not absolute;
|
||||||
|
5. no empty, `.` or `..` segment, which also refuses `//` and a trailing `/`;
|
||||||
|
6. `classify` gives `supporting` in that same universe, which refuses unmapped, `.canvas`, `.base`
|
||||||
|
and attachment paths, canon, a playground-only folder and another universe's prefix;
|
||||||
|
7. no folder on the way, and not the path itself, is a symlink (`120000`) or gitlink (`160000`) in
|
||||||
|
`tree`;
|
||||||
|
8. no folder on the way, and not the path itself, matches a tree path or tree folder after
|
||||||
|
`normalize('NFC').toLowerCase()` unless it is that exact name. APFS folds case and Unicode form,
|
||||||
|
so her checkout would put the file somewhere else. A tree that already holds case twins refuses a
|
||||||
|
write to either (R7).
|
||||||
|
|
||||||
|
A delete's further rule, that the path exists at the tip as a `100644` blob, belongs to the C2-02
|
||||||
|
broker.
|
||||||
|
|
||||||
## Reader
|
## Reader
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
|
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
|
||||||
export { checkMapping, TYPES } from './mapping.mjs';
|
export { checkMapping, TYPES } from './mapping.mjs';
|
||||||
export { classify } from './scope.mjs';
|
export { classify } from './scope.mjs';
|
||||||
|
export { supportingPath } from './supporting.mjs';
|
||||||
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
|
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
|
||||||
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
|
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
|
||||||
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
|
// 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,
|
// 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
|
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. Supporting
|
||||||
// loading wires it in C2-04, which passes its own `fail` so the refusal reads as a business file
|
// prefixes (C2-01d) follow the same rules and are where agents may write (M12). A pure validator.
|
||||||
// problem. Agents can't write this entry (M9, C2-04).
|
// 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 { raise } from './problems.mjs';
|
||||||
|
|
||||||
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
|
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
|
||||||
@@ -12,6 +13,9 @@ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
|
|||||||
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
|
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
|
||||||
const CONSTRAINT_KEYS = ['exclude', 'pin'];
|
const CONSTRAINT_KEYS = ['exclude', 'pin'];
|
||||||
const KINDS = ['hybrid', 'draft'];
|
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<>]/;
|
||||||
|
|
||||||
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
|
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
|
||||||
// and dot names are not.
|
// and dot names are not.
|
||||||
@@ -40,7 +44,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
object(entry, '');
|
object(entry, '');
|
||||||
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'universes', 'types', 'manuscript'], 'entry');
|
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'editor', 'universes', 'types', 'manuscript'], 'entry');
|
||||||
if (entry.kind !== 'library') bad('kind must be "library"');
|
if (entry.kind !== 'library') bad('kind must be "library"');
|
||||||
let url;
|
let url;
|
||||||
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
|
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
|
||||||
@@ -52,6 +56,15 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
|||||||
if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user)))
|
if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user)))
|
||||||
bad('user must be a plain account name');
|
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, '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'], 'editor');
|
||||||
|
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 >`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
object(entry.types, 'types');
|
object(entry.types, 'types');
|
||||||
for (const [name, type] of Object.entries(entry.types)) {
|
for (const [name, type] of Object.entries(entry.types)) {
|
||||||
@@ -64,6 +77,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
|||||||
object(entry.universes, 'universes');
|
object(entry.universes, 'universes');
|
||||||
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
|
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
|
||||||
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
|
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
|
||||||
|
const supports = [], supportOwner = new Map();
|
||||||
for (const [id, universe] of Object.entries(entry.universes)) {
|
for (const [id, universe] of Object.entries(entry.universes)) {
|
||||||
const name = `universes.${id}`;
|
const name = `universes.${id}`;
|
||||||
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
|
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
|
||||||
@@ -71,8 +85,8 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
|||||||
if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`);
|
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';
|
const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft';
|
||||||
if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`);
|
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']
|
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'supporting', 'constraints', 'notes']
|
||||||
: draft ? ['kind', 'name', 'playground'] : ['name', 'books', 'playground'], name);
|
: 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))
|
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`);
|
bad(`${name}.name must be a short display name`);
|
||||||
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
|
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
|
||||||
@@ -81,12 +95,14 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
|||||||
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
|
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
|
||||||
bookOwner.set(book, id);
|
bookOwner.set(book, id);
|
||||||
}
|
}
|
||||||
const playground = list(universe.playground ?? [], `${name}.playground`,
|
const prefix = value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1));
|
||||||
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
|
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`);
|
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); }
|
for (const p of playground) { prefixes.push(p); prefixOwner.set(p, id); }
|
||||||
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground }; continue; }
|
const supporting = list(universe.supporting ?? [], `${name}.supporting`, prefix, 'a folder path ending in /');
|
||||||
const out = { name: universe.name, books, playground };
|
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) {
|
if (hybrid) {
|
||||||
out.kind = 'hybrid';
|
out.kind = 'hybrid';
|
||||||
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
|
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
|
||||||
@@ -124,18 +140,36 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
|||||||
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 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)}`);
|
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');
|
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
|
// 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
|
// 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
|
// checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book
|
||||||
// into playground.
|
// into playground or supporting.
|
||||||
for (const [prefix, id] of prefixOwner) {
|
const againstBooks = (owners, list, code) => {
|
||||||
for (const [book, owner] of bookOwner) {
|
for (const [prefix, id] of owners) {
|
||||||
const at = `universes.${id}.playground ${JSON.stringify(prefix)}`;
|
for (const [book, owner] of bookOwner) {
|
||||||
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
|
const at = `universes.${id}.${list} ${JSON.stringify(prefix)}`;
|
||||||
const inside = holds(book, prefix), holding = holds(prefix, book);
|
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
|
||||||
if ((inside || holding) && owner !== id)
|
const inside = holds(book, prefix), holding = holds(prefix, book);
|
||||||
fail('library-playground-other-universe', `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
|
if ((inside || holding) && owner !== id)
|
||||||
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
|
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)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +177,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
|||||||
kind: 'library', remote: entry.remote, branch: entry.branch,
|
kind: 'library', remote: entry.remote, branch: entry.branch,
|
||||||
...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}),
|
...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}),
|
||||||
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
|
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
|
||||||
|
...(Object.hasOwn(entry, 'editor') ? { editor: { name: entry.editor.name, email: entry.editor.email } } : {}),
|
||||||
universes, types: { ...entry.types }, manuscript,
|
universes, types: { ...entry.types }, manuscript,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,22 @@
|
|||||||
// Paths are repo-relative and compared exactly, case included (R7 handles case-only twins).
|
// Paths are repo-relative and compared exactly, case included (R7 handles case-only twins).
|
||||||
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
||||||
|
|
||||||
// { scope: 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
|
// { scope: 'supporting' | 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
|
||||||
// Prefixes come first (ruling after r11, C2-01c): a `.md` file under any entry's playground prefix is
|
// Prefixes come first (ruling after r11, C2-01c), supporting before playground (C2-01d, design 1.2):
|
||||||
// playground, with that entry as `universe` and the book folder that holds the prefix as `book`, or
|
// a `.md` file under any entry's supporting prefix is supporting, and otherwise one under a playground
|
||||||
// null at universe level and in a draft. Otherwise a `.md` file in a book folder is canon. `type` is
|
// prefix is playground. Either way `universe` is that entry and `book` is the book folder that holds
|
||||||
// the mapped type of the first folder under the prefix (playground) or under the book (canon),
|
// the prefix, 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 or under the book (canon),
|
||||||
// 'manuscript' for a manuscript folder, else null. `folder` is that folder name. checkMapping keeps
|
// 'manuscript' for a manuscript folder, else null. `folder` is that folder name. checkMapping keeps
|
||||||
// prefixes from overlapping, so at most one matches.
|
// prefixes of one list from overlapping, and keeps a supporting prefix from holding a playground
|
||||||
|
// prefix, so the first match is the only one that counts.
|
||||||
export function classify(mapping, path) {
|
export function classify(mapping, path) {
|
||||||
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
|
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
|
||||||
const entries = Object.entries(mapping.universes);
|
const entries = Object.entries(mapping.universes);
|
||||||
|
for (const [universe, entry] of entries) {
|
||||||
|
const prefix = entry.supporting.find(p => inside(p, path));
|
||||||
|
if (prefix) return place(mapping, path, prefix, { scope: 'supporting', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
|
||||||
|
}
|
||||||
for (const [universe, entry] of entries) {
|
for (const [universe, entry] of entries) {
|
||||||
const prefix = entry.playground.find(p => inside(p, path));
|
const prefix = entry.playground.find(p => inside(p, path));
|
||||||
if (prefix) return place(mapping, path, prefix, { scope: 'playground', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
|
if (prefix) return place(mapping, path, prefix, { scope: 'playground', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// C2-01d (M12): the paths an agent may write. A path passes only under a supporting prefix of the
|
||||||
|
// named universe, and only if nothing in the tree could turn the write into something else: a
|
||||||
|
// symlink or gitlink on the way, or a name that differs from a tree name only in case or Unicode
|
||||||
|
// form (APFS folds both, so her checkout would put the file somewhere else). Pure; reads no git.
|
||||||
|
import { LibraryError } from './problems.mjs';
|
||||||
|
import { classify } from './scope.mjs';
|
||||||
|
|
||||||
|
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
||||||
|
const MAX_BYTES = 1024;
|
||||||
|
const LINKS = new Set(['120000', '160000']);
|
||||||
|
const fold = name => name.normalize('NFC').toLowerCase();
|
||||||
|
|
||||||
|
// `tree` is the array listTree returns ({ mode, type, oid, size, path }). Returns `path` or throws
|
||||||
|
// library-write-story-path naming the class. Checks run in a fixed order, so a path with two faults
|
||||||
|
// always names the same one.
|
||||||
|
export function supportingPath(mapping, universe, path, tree) {
|
||||||
|
if (!Array.isArray(tree)) throw new TypeError('supportingPath needs the listTree array');
|
||||||
|
const refuse = message => { throw new LibraryError('library-write-story-path', message); };
|
||||||
|
const at = JSON.stringify(path);
|
||||||
|
if (typeof path !== 'string') refuse('the path must be a string');
|
||||||
|
if (Buffer.byteLength(path, 'utf8') > MAX_BYTES) refuse(`${at} is longer than ${MAX_BYTES} bytes`);
|
||||||
|
if (CONTROL.test(path)) refuse('the path holds a control character');
|
||||||
|
if (path.includes('\\')) refuse(`${at} holds a backslash`);
|
||||||
|
if (path.startsWith('/')) refuse(`${at} is absolute`);
|
||||||
|
const parts = path.split('/');
|
||||||
|
if (parts.some(part => part === '' || part === '.' || part === '..')) refuse(`${at} has an empty, . or .. segment`);
|
||||||
|
const place = classify(mapping, path);
|
||||||
|
if (place.scope !== 'supporting' || place.universe !== universe)
|
||||||
|
refuse(`${at} is not under a supporting folder of universe ${JSON.stringify(universe)}`);
|
||||||
|
const own = parts.map((_, i) => parts.slice(0, i + 1).join('/'));
|
||||||
|
const modes = new Map(tree.map(entry => [entry.path, entry.mode]));
|
||||||
|
for (const p of own) if (LINKS.has(modes.get(p))) refuse(`${at} goes through the symlink or gitlink ${JSON.stringify(p)}`);
|
||||||
|
const names = new Map();
|
||||||
|
for (const entry of tree) {
|
||||||
|
const segs = entry.path.split('/');
|
||||||
|
for (let i = 1; i <= segs.length; i++) {
|
||||||
|
const name = segs.slice(0, i).join('/');
|
||||||
|
const key = fold(name);
|
||||||
|
if (!names.has(key)) names.set(key, new Set());
|
||||||
|
names.get(key).add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of own) {
|
||||||
|
const seen = names.get(fold(p));
|
||||||
|
if (seen && [...seen].some(name => name !== p))
|
||||||
|
refuse(`${at} differs only in case or Unicode form from ${JSON.stringify([...seen].find(name => name !== p))}`);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ export async function survey({ repo, commit, mapping }) {
|
|||||||
forms, attachments, fields,
|
forms, attachments, fields,
|
||||||
};
|
};
|
||||||
if (mapping) {
|
if (mapping) {
|
||||||
const scopes = { canon: 0, playground: 0, unmapped: 0 };
|
const scopes = { canon: 0, playground: 0, supporting: 0, unmapped: 0 };
|
||||||
for (const entry of entries) scopes[classify(mapping, entry.path).scope]++;
|
for (const entry of entries) scopes[classify(mapping, entry.path).scope]++;
|
||||||
const library = await readLibrary({ repo, commit: head, mapping });
|
const library = await readLibrary({ repo, commit: head, mapping });
|
||||||
const steps = Object.fromEntries(STEPS.map(s => [s, 0]));
|
const steps = Object.fromEntries(STEPS.map(s => [s, 0]));
|
||||||
@@ -89,7 +89,7 @@ export function formatSurvey(s) {
|
|||||||
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}; attachment ${s.attachments}`);
|
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}; attachment ${s.attachments}`);
|
||||||
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
|
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
|
||||||
if (s.scopes) {
|
if (s.scopes) {
|
||||||
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
|
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, supporting ${s.scopes.supporting}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
|
||||||
out.push(`resolved ${STEPS.map(st => `${st} ${s.steps[st]}`).join(', ')}, unresolved ${s.steps.unresolved}`);
|
out.push(`resolved ${STEPS.map(st => `${st} ${s.steps[st]}`).join(', ')}, unresolved ${s.steps.unresolved}`);
|
||||||
out.push(`problems ${Object.entries(s.problems).map(([code, n]) => `${code} ${n}`).join(', ')}`);
|
out.push(`problems ${Object.entries(s.problems).map(([code, n]) => `${code} ${n}`).join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ test('C2-01c: universe-level prefixes lie outside every book, in a plain univers
|
|||||||
|
|
||||||
test('C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []', () => {
|
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; });
|
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.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));
|
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: ['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(), books: [] }; return e; }, 'library-mapping-invalid', /takes no books/);
|
||||||
@@ -56,6 +56,73 @@ test('C2-01c: a draft takes kind, name and playground only, and comes back froze
|
|||||||
refuses(e => { e.universes.harbor.kind = kind; return e; }, 'library-mapping-invalid', /universes.harbor.kind must be "hybrid" or "draft"/);
|
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: '[email protected]' };
|
||||||
|
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', '[email protected]']]) invalid(value, /editor must be an object/);
|
||||||
|
invalid({ ...editor, signingKey: 'x' }, /editor has unknown key "signingKey"/);
|
||||||
|
invalid({ name: 'Her Name' }, /editor.email must be non-empty text with no control character, < or >/);
|
||||||
|
invalid({ email: '[email protected]' }, /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', '<her>', 'a<b', 'a>b']) {
|
||||||
|
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 >/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('no playground prefix holds another, and no book folder holds another', () => {
|
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; },
|
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
|
||||||
'library-mapping-invalid', /holds playground/);
|
'library-mapping-invalid', /holds playground/);
|
||||||
|
|||||||
@@ -53,3 +53,44 @@ test('C2-01c: a canon file linking into a draft gives R6, and into its own unive
|
|||||||
assert.deepEqual(codes(path), [], path);
|
assert.deepEqual(codes(path), [], path);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Supporting prefixes at three levels (C2-01d). Harbor's book-level prefix sits in both lists, and a
|
||||||
|
// second book-level prefix is supporting only.
|
||||||
|
const supporting = () => mapping(e => {
|
||||||
|
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
|
||||||
|
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
|
||||||
|
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
|
||||||
|
return e;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-01d: each supporting level gives its universe, and its book or null', () => {
|
||||||
|
const m = supporting();
|
||||||
|
assert.deepEqual(classify(m, 'Book One/Notes/Characters/Mara.md'),
|
||||||
|
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
|
||||||
|
assert.deepEqual(classify(m, 'Harbor Notes/Places/Island.md'),
|
||||||
|
{ scope: 'supporting', universe: 'harbor', book: null, type: 'place', folder: 'Places' });
|
||||||
|
assert.deepEqual(classify(m, 'Harbor Notes/Loose.md'), { scope: 'supporting', universe: 'harbor', book: null, type: null, folder: null });
|
||||||
|
assert.deepEqual(classify(m, 'Drafts/New World Notes/Chapters/Open.md'),
|
||||||
|
{ scope: 'supporting', universe: 'idea', book: null, type: 'manuscript', folder: 'Chapters' });
|
||||||
|
assert.deepEqual(classify(m, 'Drafts/New World/Ana.md'), { scope: 'playground', universe: 'idea', book: null, type: null, folder: null });
|
||||||
|
assert.deepEqual(classify(m, 'Book One/Places/Dock.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: 'place', folder: 'Places' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-01d: supporting comes before playground; a folder in both lists is supporting, one in the playground only stays playground', () => {
|
||||||
|
const m = supporting();
|
||||||
|
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
|
||||||
|
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: null, folder: null });
|
||||||
|
assert.deepEqual(classify(m, 'Book One/Sketches/Research/Tides.md'),
|
||||||
|
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'research', folder: 'Research' });
|
||||||
|
// A playground may hold a supporting prefix of its own entry: the inner folder is supporting.
|
||||||
|
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
|
||||||
|
assert.equal(classify(nested, 'Book One/Playground/Notes/A.md').scope, 'supporting');
|
||||||
|
assert.equal(classify(nested, 'Book One/Playground/A.md').scope, 'playground');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-01d: only .md files are supporting; a .canvas or .base under a supporting prefix is unmapped', () => {
|
||||||
|
const m = supporting();
|
||||||
|
for (const path of ['Book One/Notes/Board.canvas', 'Harbor Notes/Table.base', 'Drafts/New World Notes/Map.canvas',
|
||||||
|
'Harbor Notes/cover.png', 'Harbor Notesx/a.md'])
|
||||||
|
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { LibraryError, supportingPath } from '../src/index.mjs';
|
||||||
|
import { mapping } from './helpers.mjs';
|
||||||
|
|
||||||
|
// C2-01d (M12): the path checks an agent write passes. The tree is a listTree array built by hand,
|
||||||
|
// with a symlink, a gitlink, case twins and an NFC name, so no git is needed.
|
||||||
|
const m = mapping(e => {
|
||||||
|
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
|
||||||
|
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
|
||||||
|
e.universes.second.supporting = ['Second Notes/'];
|
||||||
|
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
|
||||||
|
return e;
|
||||||
|
});
|
||||||
|
const blob = (path, mode = '100644') => ({ mode, type: mode === '160000' ? 'commit' : 'blob', oid: '0'.repeat(40), size: 1, path });
|
||||||
|
const tree = [
|
||||||
|
blob('Book One/Characters/Mara Vell.md'),
|
||||||
|
blob('Book One/Notes/Existing.md'),
|
||||||
|
blob('Book One/Notes/Linked', '120000'),
|
||||||
|
blob('Book One/Notes/Vendor', '160000'),
|
||||||
|
blob('Book One/Notes/Pointer.md', '120000'),
|
||||||
|
blob('Book One/Notes/Case/Twin.md'),
|
||||||
|
blob('Book One/Notes/Café.md'),
|
||||||
|
blob('Harbor Notes/Places/Island.md'),
|
||||||
|
];
|
||||||
|
const refuses = (universe, path, pattern) => assert.throws(() => supportingPath(m, universe, path, tree),
|
||||||
|
e => e instanceof LibraryError && e.code === 'library-write-story-path' && pattern.test(e.message), JSON.stringify(path));
|
||||||
|
|
||||||
|
test('C2-01d: a path passes at book level, universe level, in a draft and in a folder in both lists', () => {
|
||||||
|
for (const [universe, path] of [['harbor', 'Book One/Notes/Characters/New.md'], ['harbor', 'Book One/Notes/Existing.md'],
|
||||||
|
['harbor', 'Harbor Notes/Places/Island.md'], ['harbor', 'Harbor Notes/Loose.md'], ['idea', 'Drafts/New World Notes/Ana.md'],
|
||||||
|
['harbor', 'Book One/Playground/What If.md'], ['second', 'Second Notes/A.md'], ['harbor', 'Book One/Notes/Café.md']])
|
||||||
|
assert.equal(supportingPath(m, universe, path, tree), path);
|
||||||
|
assert.equal(supportingPath(m, 'harbor', 'Book One/Notes/x.md', []), 'Book One/Notes/x.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-01d: a path of exactly 1024 bytes passes, and 1025 bytes made of multi-byte characters refuses', () => {
|
||||||
|
const prefix = 'Harbor Notes/';
|
||||||
|
const exact = `${prefix}${'a'.repeat(1024 - prefix.length - 3)}.md`;
|
||||||
|
assert.equal(Buffer.byteLength(exact), 1024);
|
||||||
|
assert.equal(supportingPath(m, 'harbor', exact, tree), exact);
|
||||||
|
// é is two bytes: 1025 bytes but far fewer characters.
|
||||||
|
const name = 'é'.repeat((1025 - prefix.length - 3 - 1) / 2);
|
||||||
|
const long = `${prefix}${name}a.md`;
|
||||||
|
assert.equal(Buffer.byteLength(long), 1025);
|
||||||
|
assert.ok(long.length < 1024);
|
||||||
|
refuses('harbor', long, /is longer than 1024 bytes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-01d: a path outside the universe\'s supporting folders refuses', () => {
|
||||||
|
const outside = /is not under a supporting folder of universe/;
|
||||||
|
for (const path of ['Inbox Note.md', 'Book One/Notes/Board.canvas', 'Book One/Notes/Table.base', 'Book One/Notes/cover.png',
|
||||||
|
'Book One/Characters/Mara Vell.md', 'Book One/Sketches/Idea.md', 'Drafts/New World/Ana.md', 'Second Notes/A.md', 'Book One/Notes.md'])
|
||||||
|
refuses('harbor', path, outside);
|
||||||
|
refuses('idea', 'Book One/Notes/A.md', /universe "idea"/);
|
||||||
|
refuses('nowhere', 'Book One/Notes/A.md', /universe "nowhere"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-01d: malformed paths refuse by class, in order', () => {
|
||||||
|
refuses('harbor', '/Book One/Notes/A.md', /"\/Book One\/Notes\/A.md" is absolute/);
|
||||||
|
for (const path of ['Book One/Notes/../Notes/A.md', 'Book One/Notes/./A.md', 'Book One//Notes/A.md', 'Book One/Notes/A/', 'Book One/Notes/..'])
|
||||||
|
refuses('harbor', path, /has an empty, \. or \.\. segment/);
|
||||||
|
refuses('harbor', 'Book One/Notes/A\\B.md', /holds a backslash/);
|
||||||
|
for (const c of ['\u0000', '\u0001', '\n', '\u001f', '\u007f', '\u0085', '\u009f'])
|
||||||
|
assert.throws(() => supportingPath(m, 'harbor', `Book One/Notes/A${c}.md`, tree),
|
||||||
|
e => e.code === 'library-write-story-path' && e.message === 'library-write-story-path: the path holds a control character');
|
||||||
|
refuses('harbor', 7, /the path must be a string/);
|
||||||
|
// A backslash in a path that is also absolute names the backslash: the order is fixed.
|
||||||
|
refuses('harbor', '/a\\b.md', /holds a backslash/);
|
||||||
|
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md', { 'Book One/Notes/A.md': '100644' }), TypeError);
|
||||||
|
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md'), TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-01d: a symlink or gitlink on the way, or at the path itself, refuses', () => {
|
||||||
|
refuses('harbor', 'Book One/Notes/Linked/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Linked"/);
|
||||||
|
refuses('harbor', 'Book One/Notes/Vendor/Deep/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Vendor"/);
|
||||||
|
refuses('harbor', 'Book One/Notes/Pointer.md', /goes through the symlink or gitlink "Book One\/Notes\/Pointer.md"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-01d: a name that differs from a tree name only in case or Unicode form refuses, at the file or any folder', () => {
|
||||||
|
refuses('harbor', 'Book One/Notes/existing.md', /differs only in case or Unicode form from "Book One\/Notes\/Existing.md"/);
|
||||||
|
refuses('harbor', 'Book One/Notes/case/New.md', /differs only in case or Unicode form from "Book One\/Notes\/Case"/);
|
||||||
|
refuses('harbor', 'Book One/Notes/CASE/Twin.md', /from "Book One\/Notes\/Case"/);
|
||||||
|
refuses('harbor', 'Harbor notes/A.md', /is not under a supporting folder/);
|
||||||
|
// NFD e + combining acute against the NFC name in the tree: one file on APFS.
|
||||||
|
refuses('harbor', 'Book One/Notes/Café.md', /differs only in case or Unicode form from "Book One\/Notes\/Café.md"/);
|
||||||
|
// A tree that already holds case twins refuses a write to either (R7).
|
||||||
|
const twins = [...tree, blob('Book One/Notes/existing.md')];
|
||||||
|
for (const path of ['Book One/Notes/Existing.md', 'Book One/Notes/existing.md'])
|
||||||
|
assert.throws(() => supportingPath(m, 'harbor', path, twins), e => e.code === 'library-write-story-path' && /differs only in case/.test(e.message));
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { writeFileSync } from 'node:fs';
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -76,6 +76,17 @@ test('with --mapping the survey adds scope, resolver step and problem counts', a
|
|||||||
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
|
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('C2-01d: with --mapping the survey counts supporting files', async t => {
|
||||||
|
const r = repo(t);
|
||||||
|
const entry = JSON.parse(readFileSync(join(FIXTURES, 'mapping.json'), 'utf8'));
|
||||||
|
entry.universes.harbor.supporting = ['Book One/Places/'];
|
||||||
|
const file = join(r.root, 'supporting.json');
|
||||||
|
writeFileSync(file, JSON.stringify(entry));
|
||||||
|
const out = await run(['survey', r.repo, '--mapping', file]);
|
||||||
|
assert.equal(out.code, 0, out.stderr);
|
||||||
|
assert.match(out.stdout, /^scopes canon 4, playground 1, supporting 1, unmapped 3; indexed 6$/m);
|
||||||
|
});
|
||||||
|
|
||||||
test('survey exit codes: usage 4, bad mapping 3, unreadable repo 2', async t => {
|
test('survey exit codes: usage 4, bad mapping 3, unreadable repo 2', async t => {
|
||||||
const r = repo(t);
|
const r = repo(t);
|
||||||
for (const args of [[], ['scan', r.repo], ['survey'], ['survey', r.repo, '--mapping'], ['survey', r.repo, '--other'], ['survey', r.repo, r.repo]])
|
for (const args of [[], ['scan', r.repo], ['survey'], ['survey', r.repo, '--mapping'], ['survey', r.repo, '--other'], ['survey', r.repo, r.repo]])
|
||||||
|
|||||||
Reference in new issue
Block a user