row284: supporting lookup scope and served blob IDs (unrun)
This commit is contained in:
1 parent
0ed4fbab9b
commit
47db04eba8
8 files changed
+239
-21
No files matched your search
@@ -103,3 +103,74 @@ Dewey manifest approval, whole library/bus/harness suites and every
|
|||||||
scripts/test-*.sh on Sage gate. Before review grep all package tests for enum
|
scripts/test-*.sh on Sage gate. Before review grep all package tests for enum
|
||||||
pins and canon/playground pairs. Keep shared logs out; send receipts to Sage.
|
pins and canon/playground pairs. Keep shared logs out; send receipts to Sage.
|
||||||
No test or mutant evidence claimed. Manifest/digest follows complete code.
|
No test or mutant evidence claimed. Manifest/digest follows complete code.
|
||||||
|
|
||||||
|
## Implementation head (UNTESTED, sage-3658)
|
||||||
|
|
||||||
|
- query scope accepts supporting for find/search/get. get defaults canon,
|
||||||
|
mismatch null. Manuscript passes a separate two-value scope allowlist.
|
||||||
|
- get returns blob from listTree(source.repo,index.commit) after scope/universe
|
||||||
|
filtering. The same index.commit supplies the text; source HEAD is irrelevant.
|
||||||
|
- Full outgoing and incoming link lists are stably sorted by supporting,
|
||||||
|
playground,canon before combining/paging. All visible cross-scope links stay;
|
||||||
|
universe/hybrid visibility still applies. Scope metadata isn't added to links.
|
||||||
|
- Broker only adds optional scope to library.get's strict fields. No new verbs.
|
||||||
|
- Typed scope expands find/search/get only; manuscript retains its own two-value
|
||||||
|
enum. Names/M3 counts remain the row224 six/eight, no write tools added.
|
||||||
|
- New tests: book/universe/draft supporting find/search/get; defaults/mismatch;
|
||||||
|
absent supporting prefix; source HEAD advances but each scope's blob equals
|
||||||
|
served:path; 300 outgoing plus300 incoming links ordered across bounded pages,
|
||||||
|
retaining cross-scope links and dropping other-universe links; hybrid source
|
||||||
|
stays canon-only; no all/array scope; manuscript rejects supporting; broker
|
||||||
|
scope/blob forwarding; typed enums and unchanged names. All unrun.
|
||||||
|
|
||||||
|
## Additional old assertion pin (brief Gate)
|
||||||
|
|
||||||
|
harness/tests/library-tools.test.mjs:49 at23816cf28 (line49, unchanged in this head):
|
||||||
|
OLD find scope enum ['canon','playground']; NEW ['canon','playground','supporting'].
|
||||||
|
This is the exact scope-enum pin the brief directs updating. Existing six/eight
|
||||||
|
name snapshots and TYPES equality assertion are unchanged. The new manuscript
|
||||||
|
pin intentionally remains ['canon','playground']. No other two-scope test pin
|
||||||
|
was found by rg over packages/*/tests at this build's prerequisite merge.
|
||||||
|
|
||||||
|
## Proposed mutation evidence (reviewer confirms final set)
|
||||||
|
|
||||||
|
All unrun. Require assertion failures with whole-file runs and pristine restore:
|
||||||
|
M1 remove supporting from query SCOPES -> supporting lookup cases.
|
||||||
|
M2 drop get's requested-scope comparison -> default/mismatch null cases.
|
||||||
|
M3 use source HEAD for blob lookup -> served-tree/advanced-HEAD equality case.
|
||||||
|
M4 remove link rank sort -> multi-page outgoing/incoming expected order.
|
||||||
|
M5 restrict links to requested scope -> cross-scope link counts/order case.
|
||||||
|
M6 drop sees() from visible/link filtering -> second-universe/hybrid cases.
|
||||||
|
M7 allow supporting in manuscript validator -> manuscript refusal case.
|
||||||
|
M8 omit get scope from broker fields -> real broker supporting get case.
|
||||||
|
M9 widen typed manuscript enum -> typed-scope separation case.
|
||||||
|
|
||||||
|
## Inspection and pending gate
|
||||||
|
|
||||||
|
No production imports change in broker/tools, so no new sandbox closure file
|
||||||
|
or mount. query.mjs is host library code and adds existing reader.listTree;
|
||||||
|
no new package/export/copy-path file. rg found the one old find scope pin plus
|
||||||
|
the intentional new manuscript two-scope pin. Whole library/bus/harness and
|
||||||
|
shell gates remain required. CLI integration/merge context is prerequisite224/
|
||||||
|
219, not this row's source delta; any changed gate expectation goes to Sage.
|
||||||
|
Node checks, tests, mutants and elapsed-time evidence remain absent pending
|
||||||
|
CLEAR. The first test-migration commit and this implementation are distinct.
|
||||||
|
|
||||||
|
Sage sweep receipt: Della row284 builds on28346bad7ea3 plus intact224 merge;
|
||||||
|
mos1330 old-test migration separate, supporting scope/blob implementation and
|
||||||
|
fixtures authored, completely UNTESTED. No shared-log/queue write or live action.
|
||||||
|
|
||||||
|
## Current assertion anchors after implementation imports
|
||||||
|
|
||||||
|
The separate migration commit0ed4fbab9 retains its complete OLD/NEW text
|
||||||
|
above. Adding only the fs/path imports in the implementation shifts anchors:
|
||||||
|
|
||||||
|
query.test.mjs:130: `const blendCh2 = await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path: 'Book One/Chapters/Chapter 02.md' });`
|
||||||
|
query.test.mjs:350: `const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });`
|
||||||
|
query.test.mjs:351: `assert.equal(rin.scope, 'playground');`
|
||||||
|
query.test.mjs:352: `assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);`
|
||||||
|
query.test.mjs:368: `assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);`
|
||||||
|
query.test.mjs:369: `const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });`
|
||||||
|
query.test.mjs:370: `assert.equal(own.scope, 'playground');`
|
||||||
|
|
||||||
|
No result/null assertion changed; scope arguments carry the explicit opt-in.
|
||||||
@@ -100,7 +100,7 @@ export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file'
|
|||||||
// here also makes server.mjs await their host effects instead of serializing a Promise as {}.
|
// here also makes server.mjs await their host effects instead of serializing a Promise as {}.
|
||||||
const LIBRARY_LOOKUPS = Object.freeze({
|
const LIBRARY_LOOKUPS = Object.freeze({
|
||||||
'library.find': { method: 'find', fields: ['universe', 'query', 'type', 'book', 'scope', 'offset'], required: ['universe', 'query'] },
|
'library.find': { method: 'find', fields: ['universe', 'query', 'type', 'book', 'scope', 'offset'], required: ['universe', 'query'] },
|
||||||
'library.get': { method: 'get', fields: ['universe', 'path', 'from', 'links_from'], required: ['universe', 'path'] },
|
'library.get': { method: 'get', fields: ['universe', 'path', 'scope', 'from', 'links_from'], required: ['universe', 'path'] },
|
||||||
'library.search': { method: 'search', fields: ['universe', 'text', 'book', 'scope', 'offset'], required: ['universe', 'text'] },
|
'library.search': { method: 'search', fields: ['universe', 'text', 'book', 'scope', 'offset'], required: ['universe', 'text'] },
|
||||||
'library.manuscript': { method: 'manuscript', fields: ['universe', 'book', 'scope', 'chapter', 'from', 'to'], required: ['universe', 'book'] },
|
'library.manuscript': { method: 'manuscript', fields: ['universe', 'book', 'scope', 'chapter', 'from', 'to'], required: ['universe', 'book'] },
|
||||||
'library.universe': { method: 'universeInfo', fields: ['universe'], required: ['universe'] },
|
'library.universe': { method: 'universeInfo', fields: ['universe'], required: ['universe'] },
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ const TEXTS = {
|
|||||||
'Second World/Characters/Elsewhere.md': note('character', 'Elsewhere', 'Other ferry.'),
|
'Second World/Characters/Elsewhere.md': note('character', 'Elsewhere', 'Other ferry.'),
|
||||||
};
|
};
|
||||||
|
|
||||||
async function world(t, { projects, adapter, claimed = true, now = () => NOW } = {}) {
|
async function world(t, { projects, adapter, claimed = true, now = () => NOW, files = TEXTS } = {}) {
|
||||||
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'data');
|
const f = scratchRepo(t, files), dataRoot = join(f.root, 'data');
|
||||||
mkdirSync(dataRoot);
|
mkdirSync(dataRoot);
|
||||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
|
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
|
||||||
@@ -233,3 +233,19 @@ test('runtime actually injects the lookup adapter, without exposing human librar
|
|||||||
await assert.rejects(runtime.broker.request(cap, { verb: 'library.file', args: { project: 'books', path: GET.path } }),
|
await assert.rejects(runtime.broker.request(cap, { verb: 'library.file', args: { project: 'books', path: GET.path } }),
|
||||||
{ code: 'human-required' });
|
{ code: 'human-required' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('C2-05c broker forwards supporting scope and returns the served blob without adding write verbs', async t => {
|
||||||
|
const projects = { books: mappingEntry() };
|
||||||
|
projects.books.universes.harbor.supporting = ['Book One/Support/'];
|
||||||
|
const path = 'Book One/Support/Helper.md';
|
||||||
|
const f = await world(t, { projects, files: { ...TEXTS, [path]: note('summary', 'Helper', 'Supporting fixture.') } });
|
||||||
|
assert.deepEqual((await f.call('library.find', { universe: 'harbor', query: '', scope: 'supporting' })).results.map(x => x.path), [path]);
|
||||||
|
assert.deepEqual((await f.call('library.search', { universe: 'harbor', text: 'Supporting fixture', scope: 'supporting' })).results.map(x => x.path), [path]);
|
||||||
|
assert.equal(await f.call('library.get', { universe: 'harbor', path }), null);
|
||||||
|
const result = await f.call('library.get', { universe: 'harbor', path, scope: 'supporting' });
|
||||||
|
assert.equal(result.scope, 'supporting');
|
||||||
|
assert.equal(result.commit, f.commit);
|
||||||
|
assert.equal(result.blob, git(f.mirror, 'rev-parse', `${f.commit}:${path}`));
|
||||||
|
assert.equal(await f.call('library.get', { universe: 'second', path, scope: 'supporting' }), null);
|
||||||
|
await assert.rejects(f.call('library.manuscript', { universe: 'harbor', book: 'Book One', scope: 'supporting' }), { code: 'query-argument-invalid' });
|
||||||
|
});
|
||||||
@@ -228,24 +228,25 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
|||||||
if (library) {
|
if (library) {
|
||||||
const universe = str("Universe ID in the configured library", { minLength: 1 });
|
const universe = str("Universe ID in the configured library", { minLength: 1 });
|
||||||
const project = str("Library project ID; omit only when the business has one library");
|
const project = str("Library project ID; omit only when the business has one library");
|
||||||
const scope = str("canon by default; playground is opt-in", { enum: ["canon", "playground"] });
|
const scope = str("canon by default; playground and supporting are opt-in", { enum: ["canon", "playground", "supporting"] });
|
||||||
|
const manuscriptScope = str("canon by default; playground is opt-in", { enum: ["canon", "playground"] });
|
||||||
const book = str("Book folder from library_universe");
|
const book = str("Book folder from library_universe");
|
||||||
const offset = { type: "integer", minimum: 0, description: "next from the previous page" };
|
const offset = { type: "integer", minimum: 0, description: "next from the previous page" };
|
||||||
const line = { type: "integer", minimum: 1, description: "One-based line number" };
|
const line = { type: "integer", minimum: 1, description: "One-based line number" };
|
||||||
const base = { universe, project };
|
const base = { universe, project };
|
||||||
add("library_find", "library.find", null, "Find names in one universe, at the served commit. At most 50 results; continue with next as offset.", obj({
|
add("library_find", "library.find", null, "Find names in one universe, at the served commit. At most 50 results; continue with next as offset. Playground and supporting are opt-in.", obj({
|
||||||
...base, query: str("Case-insensitive name or filename substring; empty matches all"),
|
...base, query: str("Case-insensitive name or filename substring; empty matches all"),
|
||||||
type: str("Mapped type", { enum: ["character", "place", "research", "tracker", "summary"] }),
|
type: str("Mapped type", { enum: ["character", "place", "research", "tracker", "summary"] }),
|
||||||
book, scope, offset,
|
book, scope, offset,
|
||||||
}, ["universe", "query"]));
|
}, ["universe", "query"]));
|
||||||
add("library_get", "library.get", null, "Read a mapped note and visible links in one universe at the served commit. next pages lines; links_next pages links. An explicit own-universe playground path is readable.", obj({
|
add("library_get", "library.get", null, "Read a note and its blob at the served commit, canon by default. Playground/supporting paths need matching scope; mismatch returns null. next pages lines; links_next pages visible links in supporting/playground/canon order.", obj({
|
||||||
...base, path: str("Mapped note path relative to the library"), from: line, links_from: offset,
|
...base, path: str("Mapped note path relative to the library"), scope, from: line, links_from: offset,
|
||||||
}, ["universe", "path"]));
|
}, ["universe", "path"]));
|
||||||
add("library_search", "library.search", null, "Search text in one universe at the served commit. At most 50 hits; use next as offset. Playground is opt-in.", obj({
|
add("library_search", "library.search", null, "Search text in one universe at the served commit. At most 50 hits; use next as offset. Playground and supporting are opt-in.", obj({
|
||||||
...base, text: str("Nonempty case-insensitive substring", { minLength: 1 }), book, scope, offset,
|
...base, text: str("Nonempty case-insensitive substring", { minLength: 1 }), book, scope, offset,
|
||||||
}, ["universe", "text"]));
|
}, ["universe", "text"]));
|
||||||
add("library_manuscript", "library.manuscript", null, "Read one chapter of a book at the served commit, at most 400 lines. Omit chapter for the first; continue with next or next_chapter.", obj({
|
add("library_manuscript", "library.manuscript", null, "Read one chapter of a book at the served commit, at most 400 lines. Omit chapter for the first; continue with next or next_chapter.", obj({
|
||||||
...base, book, scope, chapter: str("Chapter filename without extension"), from: line, to: line,
|
...base, book, scope: manuscriptScope, chapter: str("Chapter filename without extension"), from: line, to: line,
|
||||||
}, ["universe", "book"]));
|
}, ["universe", "book"]));
|
||||||
add("library_universe", "library.universe", null, "Read a universe's mapping at the served commit: books, types, playground prefixes, and hybrid sources/constraints/notes or draft metadata.", obj(base, ["universe"]));
|
add("library_universe", "library.universe", null, "Read a universe's mapping at the served commit: books, types, playground prefixes, and hybrid sources/constraints/notes or draft metadata.", obj(base, ["universe"]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ test('lookup schemas require a universe even for get, include paging, and cannot
|
|||||||
for (const key of ['repo', 'commit', 'business', 'accept']) assert.equal(Object.hasOwn(tool.parameters.properties, key), false);
|
for (const key of ['repo', 'commit', 'business', 'accept']) assert.equal(Object.hasOwn(tool.parameters.properties, key), false);
|
||||||
}
|
}
|
||||||
const byName = Object.fromEntries(tools.map(t => [t.name, t]));
|
const byName = Object.fromEntries(tools.map(t => [t.name, t]));
|
||||||
assert.deepEqual(byName.library_find.parameters.properties.scope.enum, ['canon', 'playground']);
|
assert.deepEqual(byName.library_find.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||||
assert.deepEqual(byName.library_find.parameters.properties.type.enum, [...TYPES]);
|
assert.deepEqual(byName.library_find.parameters.properties.type.enum, [...TYPES]);
|
||||||
assert.equal(byName.library_find.parameters.properties.offset.minimum, 0);
|
assert.equal(byName.library_find.parameters.properties.offset.minimum, 0);
|
||||||
assert.equal(byName.library_get.parameters.properties.from.minimum, 1);
|
assert.equal(byName.library_get.parameters.properties.from.minimum, 1);
|
||||||
@@ -69,3 +69,14 @@ test('bundle flag reaches both harness tool manifests and creative prompt withou
|
|||||||
assert.doesNotMatch(prompt, /Bus tools: [^\n]*(read_spec|list_agents|inbox|trail|write_record|raise_decision|list_tasks)/);
|
assert.doesNotMatch(prompt, /Bus tools: [^\n]*(read_spec|list_agents|inbox|trail|write_record|raise_decision|list_tasks)/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => {
|
||||||
|
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||||
|
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS]);
|
||||||
|
for (const name of ['library_find', 'library_search', 'library_get']) {
|
||||||
|
const tool = tools.find(t => t.name === name);
|
||||||
|
assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||||
|
assert.ok(!tool.parameters.required.includes('scope'));
|
||||||
|
}
|
||||||
|
assert.deepEqual(tools.find(t => t.name === 'library_manuscript').parameters.properties.scope.enum, ['canon', 'playground']);
|
||||||
|
});
|
||||||
@@ -7,12 +7,15 @@
|
|||||||
import { raise } from './problems.mjs';
|
import { raise } from './problems.mjs';
|
||||||
import { TYPES } from './mapping.mjs';
|
import { TYPES } from './mapping.mjs';
|
||||||
import { indexFor } from './index-build.mjs';
|
import { indexFor } from './index-build.mjs';
|
||||||
|
import { listTree } from './reader.mjs';
|
||||||
|
|
||||||
// The same cap as packages/harness/src/tools.mjs REPLY_CAP. The query tests assert the two stay equal.
|
// The same cap as packages/harness/src/tools.mjs REPLY_CAP. The query tests assert the two stay equal.
|
||||||
export const QUERY_REPLY_CAP = 40_000;
|
export const QUERY_REPLY_CAP = 40_000;
|
||||||
export const LOOKUP_LIMIT = 50;
|
export const LOOKUP_LIMIT = 50;
|
||||||
export const MANUSCRIPT_LINES = 400;
|
export const MANUSCRIPT_LINES = 400;
|
||||||
const SCOPES = Object.freeze(['canon', 'playground']);
|
const SCOPES = Object.freeze(['canon', 'playground', 'supporting']);
|
||||||
|
const MANUSCRIPT_SCOPES = Object.freeze(['canon', 'playground']);
|
||||||
|
const SCOPE_RANK = Object.freeze({ supporting: 0, playground: 1, canon: 2 });
|
||||||
|
|
||||||
const bytes = value => JSON.stringify(value).length;
|
const bytes = value => JSON.stringify(value).length;
|
||||||
const invalid = message => raise('query-argument-invalid', message);
|
const invalid = message => raise('query-argument-invalid', message);
|
||||||
@@ -22,9 +25,9 @@ function textArg(value, name) {
|
|||||||
if (typeof value !== 'string') invalid(`${name} must be text`);
|
if (typeof value !== 'string') invalid(`${name} must be text`);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
function scopeArg(value) {
|
function scopeArg(value, allowed = SCOPES) {
|
||||||
if (value === undefined) return 'canon';
|
if (value === undefined) return 'canon';
|
||||||
if (!SCOPES.includes(value)) invalid('scope must be "canon" or "playground"');
|
if (!allowed.includes(value)) invalid(`scope must be one of ${allowed.join(", ")}`);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
function positionArg(value, name, fallback) {
|
function positionArg(value, name, fallback) {
|
||||||
@@ -109,7 +112,8 @@ export async function find(source, { universe: name, query, type, book, scope, o
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// get: one visible file, its front matter, its lines from `from`, and its visible links both ways.
|
// get: one file in the requested scope (canon default), its served-tree blob, front matter,
|
||||||
|
// lines and visible links both ways. Links keep all visible scopes, ordered supporting/playground/canon.
|
||||||
// Returns null when the file is missing or not visible, so a caller cannot tell the two apart. Lines page
|
// Returns null when the file is missing or not visible, so a caller cannot tell the two apart. Lines page
|
||||||
// by `from` (reply `next`), links page together by `links_from` (reply `links_next`); `links_out` come
|
// by `from` (reply `next`), links page together by `links_from` (reply `links_next`); `links_out` come
|
||||||
// before `links_in`. Every page carries the front matter. Links take at most half the cap, so a hub note
|
// before `links_in`. Every page carries the front matter. Links take at most half the cap, so a hub note
|
||||||
@@ -117,27 +121,33 @@ export async function find(source, { universe: name, query, type, book, scope, o
|
|||||||
// least one line, so links shrink when a line needs the room; read on with `from: next` and
|
// least one line, so links shrink when a line needs the room; read on with `from: next` and
|
||||||
// `links_from: links_next`. A `from` past the last line returns links alone. Only front matter, or a
|
// `links_from: links_next`. A `from` past the last line returns links alone. Only front matter, or a
|
||||||
// line that cannot fit with no links beside it, is refused.
|
// line that cannot fit with no links beside it, is refused.
|
||||||
export async function get(source, { universe: name, path, from, links_from: linksFrom } = {}) {
|
export async function get(source, { universe: name, path, scope, from, links_from: linksFrom } = {}) {
|
||||||
const universe = universeArg(source.mapping, name);
|
const universe = universeArg(source.mapping, name);
|
||||||
|
const requestedScope = scopeArg(scope);
|
||||||
const startLine = positionArg(from, 'from', 1);
|
const startLine = positionArg(from, 'from', 1);
|
||||||
const linkStart = offsetArg(linksFrom);
|
const linkStart = offsetArg(linksFrom);
|
||||||
if (typeof path !== 'string') invalid('path must be text');
|
if (typeof path !== 'string') invalid('path must be text');
|
||||||
const index = await indexFor(source);
|
const index = await indexFor(source);
|
||||||
const file = index.files.get(path);
|
const file = index.files.get(path);
|
||||||
if (!file || !sees(index.mapping, universe, file)) return null;
|
if (!file || file.scope !== requestedScope || !sees(index.mapping, universe, file)) return null;
|
||||||
const reachable = links => links.filter(link => {
|
const reachable = links => links.filter(link => {
|
||||||
const other = index.files.get(link.path);
|
const other = index.files.get(link.path);
|
||||||
return other !== undefined && sees(index.mapping, universe, other);
|
return other !== undefined && sees(index.mapping, universe, other);
|
||||||
});
|
}).sort((a, b) => SCOPE_RANK[index.files.get(a.path).scope] - SCOPE_RANK[index.files.get(b.path).scope]);
|
||||||
const outs = reachable(index.linksOut.get(file.path) ?? []);
|
const outs = reachable(index.linksOut.get(file.path) ?? []);
|
||||||
const ins = reachable(index.linksIn.get(file.path) ?? []);
|
const ins = reachable(index.linksIn.get(file.path) ?? []);
|
||||||
const links = [...outs.map(link => ({ out: true, link })), ...ins.map(link => ({ out: false, link }))];
|
const links = [...outs.map(link => ({ out: true, link })), ...ins.map(link => ({ out: false, link }))];
|
||||||
|
// The reader/index does not keep tree oids. Resolve only in this immutable served
|
||||||
|
// snapshot, never HEAD; listTree already checks/bounds the tree and its object IDs.
|
||||||
|
const entry = (await listTree(source.repo, index.commit)).find(entry => entry.path === file.path);
|
||||||
|
if (!entry || entry.mode !== '100644' || entry.type !== 'blob') raise('library-commit', 'indexed file is absent from the served tree');
|
||||||
|
const blob = entry.oid;
|
||||||
const lines = file.lines.slice(startLine - 1);
|
const lines = file.lines.slice(startLine - 1);
|
||||||
const linkRest = Math.max(0, links.length - linkStart);
|
const linkRest = Math.max(0, links.length - linkStart);
|
||||||
const page = (nLines, nLinks) => {
|
const page = (nLines, nLinks) => {
|
||||||
const taken = links.slice(linkStart, linkStart + nLinks);
|
const taken = links.slice(linkStart, linkStart + nLinks);
|
||||||
return {
|
return {
|
||||||
path: file.path, commit: index.commit, scope: file.scope, frontmatter: file.frontmatter,
|
path: file.path, commit: index.commit, blob, scope: file.scope, frontmatter: file.frontmatter,
|
||||||
lines: lines.slice(0, nLines),
|
lines: lines.slice(0, nLines),
|
||||||
links_out: taken.filter(item => item.out).map(item => item.link),
|
links_out: taken.filter(item => item.out).map(item => item.link),
|
||||||
links_in: taken.filter(item => !item.out).map(item => item.link),
|
links_in: taken.filter(item => !item.out).map(item => item.link),
|
||||||
@@ -201,7 +211,7 @@ export async function manuscript(source, { universe: name, book, scope, chapter,
|
|||||||
const start = positionArg(from, 'from', 1);
|
const start = positionArg(from, 'from', 1);
|
||||||
const end = positionArg(to, 'to', Infinity);
|
const end = positionArg(to, 'to', Infinity);
|
||||||
if (end < start) invalid('to must not be before from');
|
if (end < start) invalid('to must not be before from');
|
||||||
const { index, files } = await visible(source, universe, { scope: scopeArg(scope), book: bookName, type: 'manuscript' });
|
const { index, files } = await visible(source, universe, { scope: scopeArg(scope, MANUSCRIPT_SCOPES), book: bookName, type: 'manuscript' });
|
||||||
let at = 0;
|
let at = 0;
|
||||||
if (chapterName !== undefined) {
|
if (chapterName !== undefined) {
|
||||||
const matches = files.filter(file => file.stem === chapterName);
|
const matches = files.filter(file => file.stem === chapterName);
|
||||||
|
|||||||
@@ -29,3 +29,22 @@ export const note = (type, name, body = '', extra = []) => ['---', `type: ${type
|
|||||||
// `exclude` (a path prefix today); swapping these two constants is the whole change to the tests.
|
// `exclude` (a path prefix today); swapping these two constants is the whole change to the tests.
|
||||||
export const EXCLUSION = { exclude: 'Book One/Research' };
|
export const EXCLUSION = { exclude: 'Book One/Research' };
|
||||||
export const EXCLUDED_PATHS = ['Book One/Research/Ferry Logs.md'];
|
export const EXCLUDED_PATHS = ['Book One/Research/Ferry Logs.md'];
|
||||||
|
|
||||||
|
// C2-05c: synthetic scope fixture, including same-prefix precedence and a draft.
|
||||||
|
export const SUPPORT_FILES = {
|
||||||
|
'Book One/Characters/Canon.md': note('character', 'Canon', 'Scope fixture canon.'),
|
||||||
|
'Book One/Playground/Plan.md': note('summary', 'Plan', 'Scope fixture playground.'),
|
||||||
|
'Book One/Support/Notes/Helper.md': note('summary', 'Helper', 'Scope fixture supporting.'),
|
||||||
|
'Notes/HarborSupport/Loose.md': note('summary', 'Loose', 'Scope fixture supporting loose.'),
|
||||||
|
'Drafts/Idea/Support/Seed.md': note('summary', 'Seed', 'Scope fixture supporting draft.'),
|
||||||
|
};
|
||||||
|
export const withSupporting = entry => ({
|
||||||
|
...entry,
|
||||||
|
universes: {
|
||||||
|
...entry.universes,
|
||||||
|
harbor: { ...entry.universes.harbor,
|
||||||
|
playground: [...entry.universes.harbor.playground, 'Book One/Support/'],
|
||||||
|
supporting: ['Book One/Support/', 'Notes/HarborSupport/'] },
|
||||||
|
idea: { kind: 'draft', name: 'Idea', playground: ['Drafts/Idea/'], supporting: ['Drafts/Idea/Support/'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
// Query tests for design 3 (C2-05a): universe isolation, scope labels, unmapped files, hybrids, paging
|
// Query tests for design 3 (C2-05a): universe isolation, scope labels, unmapped files, hybrids, paging
|
||||||
// under the reply cap, refusals and manuscript paging. Fixtures are made up and built under the scratch area.
|
// under the reply cap, refusals and manuscript paging. Fixtures are made up and built under the scratch area.
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { REPLY_CAP } from '../../harness/src/tools.mjs';
|
import { REPLY_CAP } from '../../harness/src/tools.mjs';
|
||||||
import { find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from '../src/query.mjs';
|
import { find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from '../src/query.mjs';
|
||||||
import { mapping, MARKER } from './helpers.mjs';
|
import { mapping, MARKER, git } from './helpers.mjs';
|
||||||
import { scratchRepo, note, EXCLUSION, EXCLUDED_PATHS } from './query-fixture.mjs';
|
import { scratchRepo, note, EXCLUSION, EXCLUDED_PATHS, SUPPORT_FILES, withSupporting } from './query-fixture.mjs';
|
||||||
|
|
||||||
// A hybrid over harbor and second. Its one constraint is EXCLUSION (query-fixture.mjs) and it declares no
|
// A hybrid over harbor and second. Its one constraint is EXCLUSION (query-fixture.mjs) and it declares no
|
||||||
// books or playground.
|
// books or playground.
|
||||||
@@ -373,3 +375,91 @@ test('C2-05c get without scope returns null for a playground path', async t => {
|
|||||||
const s = src(scratchRepo(t, FILES));
|
const s = src(scratchRepo(t, FILES));
|
||||||
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Playground/What If.md' }), null);
|
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Playground/What If.md' }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row284: scope is explicit for get as well as lists; blob and lines share a served snapshot.
|
||||||
|
test('C2-05c supporting find/search/get are opt-in at book, universe and draft level', async t => {
|
||||||
|
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(withSupporting));
|
||||||
|
const expected = ['Book One/Support/Notes/Helper.md', 'Notes/HarborSupport/Loose.md'];
|
||||||
|
assert.deepEqual(paths(await find(s, { universe: 'harbor', query: '', scope: 'supporting' })), expected);
|
||||||
|
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture', scope: 'supporting' })).results.map(x => x.path), expected);
|
||||||
|
assert.deepEqual(paths(await find(s, { universe: 'harbor', query: '' })), ['Book One/Characters/Canon.md']);
|
||||||
|
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture' })).results.map(x => x.path), ['Book One/Characters/Canon.md']);
|
||||||
|
for (const path of expected) {
|
||||||
|
assert.equal(await get(s, { universe: 'harbor', path }), null);
|
||||||
|
assert.equal(await get(s, { universe: 'harbor', path, scope: 'playground' }), null);
|
||||||
|
assert.equal((await get(s, { universe: 'harbor', path, scope: 'supporting' })).scope, 'supporting');
|
||||||
|
assert.equal(await get(s, { universe: 'second', path, scope: 'supporting' }), null);
|
||||||
|
}
|
||||||
|
const draftPath = 'Drafts/Idea/Support/Seed.md';
|
||||||
|
assert.deepEqual(paths(await find(s, { universe: 'idea', query: '', scope: 'supporting' })), [draftPath]);
|
||||||
|
assert.equal((await get(s, { universe: 'idea', path: draftPath, scope: 'supporting' })).scope, 'supporting');
|
||||||
|
assert.equal(await get(s, { universe: 'idea', path: draftPath }), null);
|
||||||
|
assert.equal((await find(s, { universe: 'harbor', query: '', book: 'Book One', scope: 'supporting' })).results.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-05c an absent supporting prefix returns nothing without refusal', async t => {
|
||||||
|
const s = src(scratchRepo(t, SUPPORT_FILES));
|
||||||
|
assert.deepEqual((await find(s, { universe: 'harbor', query: '', scope: 'supporting' })).results, []);
|
||||||
|
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture', scope: 'supporting' })).results, []);
|
||||||
|
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Characters/Canon.md', scope: 'supporting' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-05c each get blob equals its served tree object, even after source HEAD advances', async t => {
|
||||||
|
const ref = scratchRepo(t, SUPPORT_FILES), s = src(ref, mapping(withSupporting));
|
||||||
|
const canon = 'Book One/Characters/Canon.md';
|
||||||
|
const priorBlob = git(ref.repo, 'rev-parse', `${ref.commit}:${canon}`);
|
||||||
|
writeFileSync(join(ref.repo, canon), note('character', 'Canon changed', 'New HEAD must not be read.'));
|
||||||
|
git(ref.repo, 'add', '-A'); git(ref.repo, 'commit', '-qm', 'advance head fixture');
|
||||||
|
assert.notEqual(git(ref.repo, 'rev-parse', `HEAD:${canon}`), priorBlob);
|
||||||
|
for (const [scope, path] of [['canon', canon], ['playground', 'Book One/Playground/Plan.md'], ['supporting', 'Book One/Support/Notes/Helper.md']]) {
|
||||||
|
const result = await get(s, { universe: 'harbor', path, scope });
|
||||||
|
assert.equal(result.commit, ref.commit);
|
||||||
|
assert.equal(result.blob, git(ref.repo, 'rev-parse', `${ref.commit}:${path}`));
|
||||||
|
assert.ok(!JSON.stringify(result.lines).includes('New HEAD must not be read'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-05c full outgoing and incoming links stay in scope order across pages without losing cross-scope links', async t => {
|
||||||
|
const files = {}, groups = { supporting: [], playground: [], canon: [] };
|
||||||
|
for (const [scope, folder] of [['canon', 'Book One/Characters'], ['playground', 'Book One/Playground'], ['supporting', 'Book One/Support']]) {
|
||||||
|
for (let n = 0; n < 100; n++) {
|
||||||
|
const path = `${folder}/${scope}-${String(n).padStart(3, '0')}-${'x'.repeat(80)}.md`;
|
||||||
|
groups[scope].push(path);
|
||||||
|
files[path] = `[[Book One/Characters/Hub]]\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const all = [...groups.canon, ...groups.playground, ...groups.supporting];
|
||||||
|
files['Book One/Characters/Hub.md'] = all.map(path => `[[${path.slice(0, -3)}]]`).join('\n');
|
||||||
|
// Other-universe links are never returned, regardless of rank.
|
||||||
|
files['Second World/Other.md'] = '[[Book One/Characters/Hub]]\n';
|
||||||
|
const s = src(scratchRepo(t, files), mapping(withSupporting));
|
||||||
|
const outs = [], ins = [];
|
||||||
|
let offset = 0, pages = 0;
|
||||||
|
for (; pages < 20; pages++) {
|
||||||
|
const page = await get(s, { universe: 'harbor', path: 'Book One/Characters/Hub.md', from: 999_999, links_from: offset });
|
||||||
|
assert.ok(JSON.stringify(page).length <= QUERY_REPLY_CAP);
|
||||||
|
outs.push(...page.links_out.map(x => x.path)); ins.push(...page.links_in.map(x => x.path));
|
||||||
|
if (page.links_next === null) break;
|
||||||
|
offset = page.links_next;
|
||||||
|
}
|
||||||
|
const expected = [...groups.supporting, ...groups.playground, ...groups.canon];
|
||||||
|
assert.ok(pages > 0 && pages < 20, 'links occupy multiple bounded pages');
|
||||||
|
assert.deepEqual(outs, expected);
|
||||||
|
assert.deepEqual(ins, expected);
|
||||||
|
assert.equal(new Set(outs).size, 300);
|
||||||
|
assert.equal(new Set(ins).size, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-05c manuscript still refuses supporting rather than silently widening its scope', async t => {
|
||||||
|
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(withSupporting));
|
||||||
|
await assert.rejects(manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'supporting' }), { code: 'query-argument-invalid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C2-05c scope expansion keeps hybrid sources canon-only and refuses all/array scopes', async t => {
|
||||||
|
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(entry => withSupporting(WITH_BLEND(entry))));
|
||||||
|
assert.deepEqual((await find(s, { universe: 'blend', query: '', scope: 'supporting' })).results, []);
|
||||||
|
assert.equal(await get(s, { universe: 'blend', path: 'Book One/Support/Notes/Helper.md', scope: 'supporting' }), null);
|
||||||
|
assert.equal((await get(s, { universe: 'blend', path: 'Book One/Characters/Canon.md' })).scope, 'canon');
|
||||||
|
for (const scope of ['all', ['canon', 'supporting']])
|
||||||
|
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Characters/Canon.md', scope }), { code: 'query-argument-invalid' });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user