import test from 'node:test'; import assert from 'node:assert/strict'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { checkReply, GuardError, libraryReader, parseReply, FIXED_TEXTS } from '../src/guard.mjs'; import { resolveReplyLimits, checkReplyLimits } from '../src/limits.mjs'; import { readLibrary, listTree } from '../src/reader.mjs'; import { indexFor } from '../src/index-build.mjs'; import { BASE, MARKER, mapping, repo, git } from './helpers.mjs'; const defaults = resolveReplyLimits({ readable: true, document: {} }); const body = citations => JSON.stringify({ text: 'A fixture answer.', citations }); const cite = (commit, extra = {}) => ({ path: 'Book One/Characters/Mara Vell.md', commit, lines: [9, 9], quote: 'Grey eyes.', ...extra }); const context = r => ({ limits: defaults, mapping: mapping(), library: libraryReader(r.repo, r.commit, mapping()), web: { allowed: true, pages: [] } }); const refusal = code => e => e instanceof GuardError && e.code === code && typeof e.reason === 'string' && e.reason.length <= 300; test('C2-06 reply schema: free bodies, exact objects and safe refusal reasons', async t => { const c = context(repo(t)); for (const text of ['plain', 'null', '42', '[]', '"text"', '{broken']) { assert.equal(parseReply(text).cited, false); assert.equal(await checkReply(text, c), null); } assert.deepEqual(await checkReply(body([]), c), { citations: [] }); for (const value of [{}, { text: 'x', citations: [], scope: 'canon' }, { text: 1, citations: [] }, { text: 'x', citations: {} }, { text: 'x', citations: [{ url: 'https://invalid.test', quote: 'x', agent: 'forged' }] }, { text: 'x', citations: [cite('a'.repeat(40), { as: 'supporting' })] }]) await assert.rejects(checkReply(JSON.stringify(value), c), refusal('reply-invalid')); }); test('C2-06 citation setting and all four exact fixed texts', async t => { const c = context(repo(t)); c.limits = { ...defaults, requireCitation: true }; await assert.rejects(checkReply('plain', c), refusal('citation-missing')); await assert.rejects(checkReply(body([]), c), refusal('citation-missing')); for (const text of Object.values(FIXED_TEXTS)) { assert.equal(await checkReply(text, c), null); await assert.rejects(checkReply(text + ' ', c), refusal('citation-missing')); } }); test('C2-06 default citation cap passes 20 and refuses 21, even with optional citations', async t => { const r = repo(t), c = context(r); assert.equal((await checkReply(body(Array(20).fill(cite(r.commit))), c)).citations.length, 20); await assert.rejects(checkReply(body(Array(21).fill(cite(r.commit))), c), refusal('citations-over-limit')); }); test('C2-06 real Git citation scope, reachability, lines and normalized quotes', async t => { const r = repo(t), c = context(r); assert.equal((await checkReply(body([cite(r.commit, { quote: 'Grey\n eyes.' })]), c)).citations[0].scope, 'canon'); for (const extra of [{ path: 'Inbox Note.md', lines: [1, 1], quote: MARKER }, { path: 'Book One/Board.canvas' }, { path: 'Book One/Characters/Missing.md' }, { commit: '0'.repeat(40) }, { commit: git(r.repo, 'rev-parse', 'HEAD^{tree}') }, { lines: [999, 999] }, { quote: 'not in her fixture' }]) await assert.rejects(checkReply(body([cite(r.commit, extra)]), c), refusal('citation-invalid')); // A future commit exists locally but is not reachable from the served commit. writeFileSync(join(r.repo, 'Inbox Note.md'), 'new\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'future'); await assert.rejects(checkReply(body([cite(git(r.repo, 'rev-parse', 'HEAD'))]), c), refusal('citation-invalid')); assert.equal((await checkReply(body([cite(r.commit)]), c)).citations[0].scope, 'canon'); }); test('C2-06 playground cites pass but cannot state canon', async t => { const r = repo(t), c = context(r), p = cite(r.commit, { path: 'Book One/Playground/What If.md', lines: [4, 4], quote: MARKER }); assert.equal((await checkReply(body([p]), c)).citations[0].scope, 'playground'); await assert.rejects(checkReply(body([{ ...p, as: 'canon' }]), c), refusal('playground-cited-as-canon')); }); test('C2-06 independent universes refuse; a hybrid and only its sources pass', async t => { const r = repo(t, { ...BASE, 'Crossover/Chapters/01.md': 'A hybrid fixture.\n' }), c = context(r); const other = cite(r.commit, { path: 'Second World/Chapters/01.md', lines: [1, 1], quote: MARKER }); await assert.rejects(checkReply(body([cite(r.commit), other]), c), refusal('citation-cross-universe')); c.mapping = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'] }; return e; }); c.library = libraryReader(r.repo, r.commit, c.mapping); const hybrid = cite(r.commit, { path: 'Crossover/Chapters/01.md', lines: [1, 1], quote: 'hybrid fixture' }); assert.equal((await checkReply(body([hybrid, cite(r.commit), other]), c)).citations.length, 3); }); test('C2-15 a hybrid cite beside an excluded source path refuses citation-cross-universe, both forms', async t => { const r = repo(t, { ...BASE, 'Crossover/Chapters/01.md': 'A hybrid fixture.\n', 'Second World/Characters/Ilse.md': 'Ilse fixture.\n', 'Book One/Research/Logs.md': 'Logs fixture.\n' }), c = context(r); const at = (path, quote) => cite(r.commit, { path, lines: [1, 1], quote }); const hybrid = at('Crossover/Chapters/01.md', 'hybrid fixture'); for (const [exclude, hidden, quote] of [['second/Characters', 'Second World/Characters/Ilse.md', 'Ilse fixture'], ['Book One/Research', 'Book One/Research/Logs.md', 'Logs fixture']]) { c.mapping = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'], constraints: [{ exclude }] }; return e; }); c.library = libraryReader(r.repo, r.commit, c.mapping); await assert.rejects(checkReply(body([hybrid, cite(r.commit), at(hidden, quote)]), c), e => refusal('citation-cross-universe')(e) && e.reason === `citation 3 (${hidden}) is excluded from hybrid cross`, exclude); // The hybrid with source canon it can see passes. assert.equal((await checkReply(body([hybrid, cite(r.commit)]), c)).citations.length, 2, exclude); // A cite of only the excluded source path is that source universe's item, as before. assert.deepEqual((await checkReply(body([at(hidden, quote)]), c)).citations.map(x => x.scope), ['canon'], exclude); } }); test('C2-06 web cites require allowed definition, this session fetch and kept quote', async t => { const c = context(repo(t)), web = { url: 'https://fixture.invalid/page', quote: 'kept text' }; await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched')); c.web.pages = [{ url: web.url, finalUrl: 'https://fixture.invalid/final', text: 'The kept\n text is here.' }]; assert.deepEqual(await checkReply(body([web]), c), { citations: [{ scope: 'web', agent: null }] }); assert.equal((await checkReply(body([{ ...web, url: c.web.pages[0].finalUrl }]), c)).citations.length, 1); c.web.pages.push({ url: web.url, text: 'A later fetch.' }); assert.equal((await checkReply(body([{ ...web, quote: 'later fetch' }]), c)).citations.length, 1); await assert.rejects(checkReply(body([{ ...web, quote: 'discarded bytes' }]), c), refusal('citation-unfetched')); c.web.allowed = false; await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched')); }); test('C2-06 file origin is the path commit and trailer, while last follows served changes', async t => { const r = repo(t), path = 'Book One/Characters/Mara Vell.md'; writeFileSync(join(r.repo, path), 'Agent fixture.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'Note\n\nMosaic-Agent: acme/scribe'); const file = git(r.repo, 'rev-parse', 'HEAD'); writeFileSync(join(r.repo, 'Inbox Note.md'), 'Unrelated.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'unrelated'); const cited = git(r.repo, 'rev-parse', 'HEAD'), reader = libraryReader(r.repo, cited, mapping()); assert.notEqual(cited, file); assert.deepEqual(await reader.origin(cited, path), { agent: 'acme/scribe', file }); assert.equal(await reader.last(path), file); writeFileSync(join(r.repo, path), 'Human fixture.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'human'); assert.notEqual(await libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping()).last(path), file); }); test('C2-06 origin uses literal paths even when a filename contains a glob', async t => { const path = 'Book One/Characters/Mara*.md'; const r = repo(t, { ...BASE, [path]: 'Literal fixture.\n' }), original = r.commit; writeFileSync(join(r.repo, 'Book One/Characters/Mara Vell.md'), 'Other fixture.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'Other path\n\nMosaic-Agent: acme/other'); const reader = libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping()); assert.deepEqual(await reader.origin(git(r.repo, 'rev-parse', 'HEAD'), path), { agent: null, file: original }); assert.equal(await reader.last(path), original); }); // C2-05a landed: compare the real lookup index with the guard's cited-commit reader. // A guard that admits unmapped files, or excludes a supporting file, breaks this fixture. test('C2-06 cited-commit guard reads exactly the C2-05a indexed file set with supporting scope', async t => { const r = repo(t), map = mapping(entry => { entry.universes.harbor.supporting = ['Book One/Playground/']; return entry; }); const read = await readLibrary({ repo: r.repo, commit: r.commit, mapping: map }); const index = await indexFor({ repo: r.repo, commit: r.commit, mapping: map }); const reader = libraryReader(r.repo, r.commit, map), admitted = []; for (const entry of await listTree(r.repo, r.commit)) { if (await reader.lines(r.commit, entry.path) !== null) admitted.push(entry.path); } assert.deepEqual(admitted.sort(), [...index.files.keys()].sort()); assert.deepEqual(admitted, read.files.map(f => f.path).sort()); assert.equal(index.files.get('Book One/Playground/What If.md').scope, 'supporting'); assert.equal(index.files.has('Inbox Note.md'), false); assert.equal(index.files.has('Book One/Board.canvas'), false); }); test('C2-06 reply limits fall back independently without coercion or inherited keys', () => { for (const value of ['true', 1, null, [true], {}]) { const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: value, citationsPerReply: 5 } } }); assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 5); assert.deepEqual(r.fallback, ['requireCitation']); } for (const value of [0, 1001, 2.5, true, '5']) { const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: true, citationsPerReply: value } } }); assert.equal(r.requireCitation, true); assert.equal(r.citationsPerReply, 20); assert.deepEqual(r.fallback, ['citationsPerReply']); } for (const value of [null, [], 1, 'settings', undefined]) { const r = resolveReplyLimits({ readable: true, document: { replyLimits: value } }); assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 20); } assert.equal(resolveReplyLimits({ readable: true, document: { replyLimits: Object.create({ requireCitation: true }) } }).requireCitation, false); for (const document of [undefined, null, [], 'invalid']) assert.equal(resolveReplyLimits({ readable: true, document }).requireCitation, true); assert.equal(resolveReplyLimits({ readable: false }).requireCitation, true); assert.deepEqual(checkReplyLimits({ requireCitation: true, citationsPerReply: 1000 }), { requireCitation: true, citationsPerReply: 1000 }); for (const value of [{ unknown: 1 }, { requireCitation: 'true' }, { citationsPerReply: 0 }, null, []]) assert.equal(checkReplyLimits(value), null); });