import test from 'node:test'; import assert from 'node:assert/strict'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { readLibrary, parseFrontMatter, LibraryError, MAX_BLOB } from '../src/index.mjs'; import { BASE, git, MARKER, mapping, repo } from './helpers.mjs'; const codes = (result, path) => result.problems.filter(p => p.path === path).map(p => p.code); const file = (result, path) => result.files.find(f => f.path === path); test('the fixture reads clean: every resolver step, appears_in and relationships wikilinks indexed', async t => { const r = repo(t); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); assert.equal(result.commit, r.commit); assert.deepEqual(result.files.map(f => [f.path, f.scope]), [ ['Book One/Chapters/Chapter 01.md', 'canon'], ['Book One/Characters/Mara Vell.md', 'canon'], ['Book One/Characters/Tomas.md', 'canon'], ['Book One/Places/Harbor Town.md', 'canon'], ['Book One/Playground/What If.md', 'playground'], ['Second World/Chapters/01.md', 'canon']]); const chapter = file(result, 'Book One/Chapters/Chapter 01.md'); assert.deepEqual(chapter.links.map(l => [l.path, l.step, l.line]), [ ['Book One/Characters/Mara Vell.md', 'shortest', 2], ['Book One/Places/Harbor Town.md', 'root', 2], ['Book One/Places/Harbor Town.md', 'relative', 3], ['Book One/Characters/Tomas.md', 'vault-prefix', 3]]); const mara = file(result, 'Book One/Characters/Mara Vell.md'); assert.deepEqual(mara.frontmatter, { type: 'character', name: 'Mara Vell', appears_in: '[[Book One/Chapters/Chapter 01]]', relationships: ['[[Tomas]]', 'The Ferryman'] }); assert.deepEqual(mara.links.map(l => [l.field, l.path, l.line]), [ ['appears_in', 'Book One/Chapters/Chapter 01.md', 4], ['relationships', 'Book One/Characters/Tomas.md', 6]]); assert.deepEqual(file(result, 'Book One/Characters/Tomas.md').links.map(l => [l.field, l.path]), [['appears_in', 'Book One/Chapters/Chapter 01.md']]); assert.equal(mara.bodyLine, 9); assert.equal(mara.lines[8][0], 9); assert.equal(mara.type, 'character'); // Only the unmapped paths are problems: the top-level note and both .canvas files. assert.deepEqual(result.problems.map(p => [p.code, p.path]), [ ['R2', 'Book One/Board.canvas'], ['R2', 'Book One/Playground/Sketch.canvas'], ['R2', 'Inbox Note.md']]); assert.ok(Object.isFrozen(result.files[0].links)); }); test('R1: executables, symlinks, gitlinks, blobs over 1 MiB and non-UTF-8 text are not indexed', async t => { const r = repo(t, { ...BASE, 'Book One/Places/Run.md': { text: 'x\n', mode: 0o755 }, 'Book One/Places/Link.md': { link: 'Harbor Town.md' }, 'Book One/Places/Big.md': 'x'.repeat(MAX_BLOB + 1), 'Book One/Places/Edge.md': 'y'.repeat(MAX_BLOB), 'Book One/Places/Latin.md': { bytes: Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x0a]) }, 'Book One/Places/Nul.md': 'a\0b\n' }); git(r.repo, 'update-index', '--add', '--cacheinfo', `160000,${r.commit},Book One/Places/Sub.md`); git(r.repo, 'commit', '-qm', 'gitlink'); const result = await readLibrary({ repo: r.repo, commit: git(r.repo, 'rev-parse', 'HEAD'), mapping: mapping() }); for (const name of ['Run', 'Link', 'Big', 'Latin', 'Nul', 'Sub']) { assert.deepEqual(codes(result, `Book One/Places/${name}.md`), ['R1'], name); assert.equal(file(result, `Book One/Places/${name}.md`), undefined, name); } assert.ok(file(result, 'Book One/Places/Edge.md'), 'exactly 1 MiB is read'); }); test('R2: unmapped paths are reported and not indexed', async t => { const r = repo(t); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); for (const path of ['Inbox Note.md', 'Book One/Board.canvas', 'Book One/Playground/Sketch.canvas']) { assert.deepEqual(codes(result, path), ['R2']); assert.equal(file(result, path), undefined); } }); test('R3: front matter not fully parsed stays raw, with the line where parsing stopped', async t => { const r = repo(t, { ...BASE, 'Book One/Places/Nested.md': ['---', 'type: place', 'coords:', ' lat: 1', '---', 'Body [[Tomas]]', ''].join('\n'), 'Book One/Places/Block.md': ['---', 'summary: |', ' text', '---', ''].join('\n'), 'Book One/Places/Open.md': ['---', 'type: place', 'never closed', ''].join('\n') }); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); const nested = file(result, 'Book One/Places/Nested.md'); assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Places/Nested.md'), [{ code: 'R3', path: 'Book One/Places/Nested.md', line: 4, message: 'front matter not fully parsed' }]); assert.equal(nested.frontmatter, null); assert.equal(nested.frontmatterRaw, 'type: place\ncoords:\n lat: 1'); assert.deepEqual(nested.links.map(l => l.path), ['Book One/Characters/Tomas.md'], 'still indexed by body'); assert.equal(result.problems.find(p => p.path === 'Book One/Places/Block.md').line, 2); assert.equal(result.problems.find(p => p.path === 'Book One/Places/Open.md').line, 1); assert.equal(file(result, 'Book One/Places/Open.md').bodyLine, 1); }); test('front matter forms the reader accepts and refuses', () => { const parse = text => parseFrontMatter(text.split('\n')); assert.deepEqual(parse('---\na: plain words\nb: "quoted: yes"\nc: \'it\'\'s\'\nd: [x, "y z", 3]\ne:\n- one\n- "[[Two]]"\nf: [[Wiki]]\ng: true\nh:\n---').frontmatter, { a: 'plain words', b: 'quoted: yes', c: "it's", d: ['x', 'y z', 3], e: ['one', '[[Two]]'], f: '[[Wiki]]', g: true, h: null }); for (const bad of ['a: {x: 1}', 'a: &anchor x', 'a: [x, [y]]', 'a: x: y', 'a: 1\na: 2', ' a: 1', 'a: "open', '- loose']) assert.notEqual(parse(`---\n${bad}\n---`).stop, null, bad); assert.deepEqual(parse('no front matter'), { frontmatter: null, raw: null, bodyLine: 1, stop: null }); }); test('R4: a link to no file, to more than one, or into another repo is reported and not followed', async t => { const r = repo(t, { ...BASE, 'Book One/Research/Notes.md': 'a\n', 'Book One/Places/Notes.md': 'b\n', 'Book One/Chapters/Links.md': ['[[Nobody]]', '[[Notes]]', '[[bean-brain/Journal/Today]]', '[[Tomas]]', ''].join('\n') }); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Chapters/Links.md').map(p => [p.code, p.line]), [['R4', 1], ['R4', 2], ['R4', 3]]); assert.deepEqual(file(result, 'Book One/Chapters/Links.md').links.map(l => l.path), ['Book One/Characters/Tomas.md']); }); test('a block list link carries its item line, a scalar or flow link its key line, and problems follow', async t => { const r = repo(t, { ...BASE, 'Book One/Characters/Ana.md': ['---', 'appears_in: ["[[Nobody]]"]', 'relationships:', ' - "[[Tomas]]"', ' - Plain Name', ' - "[[Ghost]]"', ' - "[[What If]]"', '---', ''].join('\n') }); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); assert.deepEqual(file(result, 'Book One/Characters/Ana.md').links.map(l => [l.field, l.path, l.line]), [ ['relationships', 'Book One/Characters/Tomas.md', 4], ['relationships', 'Book One/Playground/What If.md', 7]]); assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Characters/Ana.md').map(p => [p.code, p.line]), [['R4', 2], ['R4', 6], ['R5', 7]]); }); // sage-1900: only an unresolved body embed with an extension other than .md is an attachment. A plain // link to one and a front matter value stay R4 when they resolve to nothing. test('an unresolved attachment embed gives no problem; a link or front matter value to one is R4; a note embed resolves', async t => { const r = repo(t, { ...BASE, 'Book One/Places/note.md': 'n\n', 'Book One/Places/Shore.md': ['---', 'relationships:', ' - "![[portrait.JPG]]"', '---', '![[cover.png]] [[Board.canvas]] ![[map.pdf|Map]]', '![[missing-note]] ![[note.md]] ![[Harbor Town]]', ''].join('\n') }); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Places/Shore.md').map(p => [p.code, p.line]), [['R4', 3], ['R4', 5], ['R4', 6]]); assert.deepEqual(file(result, 'Book One/Places/Shore.md').links.map(l => [l.path, l.embed]), [['Book One/Places/note.md', true], ['Book One/Places/Harbor Town.md', true]]); }); // webby-0070 B1: a note whose name looks like it has an extension resolves before the attachment test. test('an indexed Dr.Quinn.md resolves as a body link, a block list link and an embed', async t => { const r = repo(t, { ...BASE, 'Book One/Characters/Dr.Quinn.md': 'q\n', 'Book One/Characters/Ana.md': ['---', 'relationships:', ' - "[[Dr.Quinn]]"', '---', '[[Dr.Quinn]] ![[Dr.Quinn]]', ''].join('\n') }); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); assert.deepEqual(file(result, 'Book One/Characters/Ana.md').links.map(l => [l.field ?? null, l.path, l.line, l.embed]), [ [null, 'Book One/Characters/Dr.Quinn.md', 5, false], [null, 'Book One/Characters/Dr.Quinn.md', 5, true], ['relationships', 'Book One/Characters/Dr.Quinn.md', 3, false]]); assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Characters/Ana.md'), []); }); test('R5: a canon file that refers into the playground is reported, and the link still goes by path', async t => { const r = repo(t, { ...BASE, 'Book One/Places/Dock.md': 'See [[What If]].\n' }); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); assert.deepEqual(codes(result, 'Book One/Places/Dock.md'), ['R5']); assert.deepEqual(file(result, 'Book One/Places/Dock.md').links.map(l => l.path), ['Book One/Playground/What If.md']); // The playground file links to canon: no problem, R5 is only about canon. assert.deepEqual(codes(result, 'Book One/Playground/What If.md'), []); }); test('R6: a canon file that refers into another universe is reported; a hybrid may refer into its sources', async t => { const r = repo(t, { ...BASE, 'Book One/Places/Far.md': 'Like [[Second World/Chapters/01]].\n', 'Crossover/Chapters/A.md': 'From [[Mara Vell]] and [[Second World/Chapters/01]].\n' }); const hybrid = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'] }; return e; }); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: hybrid }); assert.deepEqual(codes(result, 'Book One/Places/Far.md'), ['R6']); assert.deepEqual(file(result, 'Book One/Places/Far.md').links.map(l => l.path), ['Second World/Chapters/01.md']); assert.deepEqual(codes(result, 'Crossover/Chapters/A.md'), []); }); // C2-15: a hybrid over harbor and second with one exclude and, optionally, a pin on Mara's eyes. const crossWith = (...constraints) => mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'], constraints }; return e; }); const EYES = { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'eyes', value: 'grey' } }; const character = (name, extra = []) => ['---', 'type: character', ...(name ? [`name: ${name}`] : []), ...extra, '---', MARKER, ''].join('\n'); const MARA_GREY = character('Mara Vell', ['eyes: grey']); test('C2-15 R6: a hybrid file that links into an excluded source path is reported, in both exclude forms', async t => { const r = repo(t, { ...BASE, 'Second World/Characters/Ilse.md': character('Ilse'), 'Book One/Research/Logs.md': '# Logs\n', 'Crossover/Chapters/A.md': 'From [[Mara Vell]] and [[Ilse]].\nThen [[Logs]].\n' }); for (const [exclude, line] of [['second/Characters', 1], ['Book One/Research', 2]]) { const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: crossWith({ exclude }) }); assert.deepEqual(result.problems.filter(p => p.path === 'Crossover/Chapters/A.md'), [{ code: 'R6', path: 'Crossover/Chapters/A.md', line, message: 'canon file refers into another universe' }], exclude); // The link stays in the file's links, as for every R-rule; the index's sees() is what hides it. assert.equal(file(result, 'Crossover/Chapters/A.md').links.length, 3); // A source's own link into the same path is not the hybrid's and stays clean. assert.deepEqual(codes(result, 'Book One/Chapters/Chapter 01.md'), []); } }); test('C2-15 R8: the pinned entity reads clean when its field holds the value, and is reported when missing or changed', async t => { const at = async files => { const r = repo(t, { ...BASE, ...files }); return (await readLibrary({ repo: r.repo, commit: r.commit, mapping: crossWith(EYES) })).problems.filter(p => p.code === 'R8'); }; assert.deepEqual(await at({ 'Book One/Characters/Mara Vell.md': MARA_GREY }), []); // Missing: no line. const base = { ...BASE }; delete base['Book One/Characters/Mara Vell.md']; const r = repo(t, base); assert.deepEqual((await readLibrary({ repo: r.repo, commit: r.commit, mapping: crossWith(EYES) })).problems.filter(p => p.code === 'R8'), [{ code: 'R8', path: 'Book One/Characters/Mara Vell.md', message: 'pinned value changed' }]); // Two pins on the missing entity give one entry (Filbert N1). const twoPins = crossWith(EYES, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'name', value: 'Mara Vell' } }); assert.deepEqual((await readLibrary({ repo: r.repo, commit: r.commit, mapping: twoPins })).problems.filter(p => p.code === 'R8'), [{ code: 'R8', path: 'Book One/Characters/Mara Vell.md', message: 'pinned value changed' }]); // Changed, with the key's line; a missing key has no line. assert.deepEqual(await at({ 'Book One/Characters/Mara Vell.md': character('Mara Vell', ['eyes: green']) }), [{ code: 'R8', path: 'Book One/Characters/Mara Vell.md', line: 4, message: 'pinned value changed' }]); assert.deepEqual((await at({})).map(p => [p.path, p.line]), [['Book One/Characters/Mara Vell.md', undefined]]); // A list holding the value, or null, is not the value (String(['grey']) would be 'grey'). for (const eyes of ['eyes: [grey]', 'eyes:', 'eyes: ~']) assert.deepEqual((await at({ 'Book One/Characters/Mara Vell.md': character('Mara Vell', [eyes]) })).map(p => p.line), [4], eyes); // Unparsed (R3) front matter counts as changed. assert.equal((await at({ 'Book One/Characters/Mara Vell.md': character('Mara Vell', ['eyes: grey', 'bad: {a: 1}']) })).length, 1); // A number pin compares as text. const r2 = repo(t, { ...BASE, 'Book One/Characters/Mara Vell.md': character('Mara Vell', ['age: 30']) }); const age = crossWith({ pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'age', value: 30 } }); assert.deepEqual((await readLibrary({ repo: r2.repo, commit: r2.commit, mapping: age })).problems.filter(p => p.code === 'R8'), []); }); test('C2-15 R8: a hybrid version of the entity that sets the field to another value is reported on that line; a link alone is not a version', async t => { const read = async files => { const r = repo(t, { ...BASE, 'Book One/Characters/Mara Vell.md': MARA_GREY, ...files }); return (await readLibrary({ repo: r.repo, commit: r.commit, mapping: crossWith(EYES) })).problems.filter(p => p.code === 'R8'); }; // Same type and name, no link to the entity: a version. assert.deepEqual(await read({ 'Crossover/Characters/Older Mara.md': character('Mara Vell', ['eyes: blue']) }), [{ code: 'R8', path: 'Crossover/Characters/Older Mara.md', line: 4, message: 'pinned value changed' }]); // No name on the version: the file names compare. assert.deepEqual((await read({ 'Crossover/Characters/Mara Vell.md': character(null, ['eyes: blue']) })).map(p => [p.path, p.line]), [['Crossover/Characters/Mara Vell.md', 3]]); // A version that keeps the value, or doesn't set the field, is clean. assert.deepEqual(await read({ 'Crossover/Characters/Older Mara.md': character('Mara Vell', ['eyes: grey']), 'Crossover/Characters/Young Mara.md': character('Mara Vell') }), []); // Not versions: a place with the field, a different character that links her, a playground copy, a source file. assert.deepEqual(await read({ 'Crossover/Places/Mara Vell.md': ['---', 'type: place', 'name: Mara Vell', 'eyes: blue', '---', '', ''].join('\n'), 'Crossover/Characters/Ilse.md': character('Ilse', ['eyes: blue', 'relationships:', ' - "[[Mara Vell]]"']), 'Second World/Characters/Mara Vell.md': character('Mara Vell', ['eyes: blue']), }), []); }); test('R7: two paths that differ only in case are both left out', async t => { const r = repo(t, BASE); writeFileSync(join(r.root, 'twin'), 'twin\n'); const blob = git(r.repo, 'hash-object', '-w', join(r.root, 'twin')); git(r.repo, 'update-index', '--add', '--cacheinfo', `100644,${blob},Book One/Characters/tomas.md`); git(r.repo, 'commit', '-qm', 'case twin'); const result = await readLibrary({ repo: r.repo, commit: git(r.repo, 'rev-parse', 'HEAD'), mapping: mapping() }); for (const path of ['Book One/Characters/Tomas.md', 'Book One/Characters/tomas.md']) { assert.deepEqual(codes(result, path), ['R7'], path); assert.equal(file(result, path), undefined); } }); test('the reader reads the commit, never the working tree, and refuses a bad commit id', async t => { const r = repo(t); writeFileSync(join(r.repo, 'Book One/Places/Harbor Town.md'), 'unsaved [[Nobody]]\n'); writeFileSync(join(r.repo, 'Book One/Places/Untracked.md'), 'x\n'); const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); assert.equal(file(result, 'Book One/Places/Harbor Town.md').lines[0][1], '---'); assert.equal(file(result, 'Book One/Places/Untracked.md'), undefined); for (const commit of ['HEAD', r.commit.slice(0, 12), '0'.repeat(40), git(r.repo, 'rev-parse', 'HEAD^{tree}')]) await assert.rejects(readLibrary({ repo: r.repo, commit, mapping: mapping() }), e => e instanceof LibraryError, commit); await assert.rejects(readLibrary({ repo: r.repo, commit: r.commit, mapping: {} }), /library-mapping-invalid/); }); test('a bare mirror reads the same as the clone', async t => { const r = repo(t); const bare = join(r.root, 'mirror.git'); git(r.root, 'clone', '-q', '--bare', r.repo, bare); const a = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() }); const b = await readLibrary({ repo: bare, commit: r.commit, mapping: mapping() }); assert.deepEqual(b, a); });