Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec7fa046c1 | ||
|
|
367fd0d240 | ||
|
|
6ac8a1ecd5 | ||
|
|
d60d29aa7a |
No files matched your search
@@ -1,628 +0,0 @@
|
||||
# Row 198 r5: assertion diff
|
||||
|
||||
Status: uncommitted working-tree review file. Nothing here has been committed or pushed, and no test has been run (CLEAR pending).
|
||||
|
||||
- Base: `78b69e1e6` (refactor). Rebase paused on pick 1 (`4288b7b16`). Commit 1 is staged; later edits are in the working tree.
|
||||
- Groups: A = tests that existed on refactor before row 198 (Mos reads these, mos-1272). B = tests row 198 adds (Sage rules).
|
||||
- Hunks: 24 group A, 3 group B. Changed assert lines: 230 removed, 61 added (hunk sums; the file-level diff includes non-assert lines).
|
||||
- Retired test names: 36 (list below).
|
||||
|
||||
Labels are from line-range attribution. A removed block can span several old tests, so the enclosing-test label is the first match only; the reason and the diff text are authoritative.
|
||||
|
||||
## Group A (existed on refactor before row 198)
|
||||
|
||||
### A1: control-board/tests/discord.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): server rescans connector discovery and refuses HTTP reply without transport
|
||||
|
||||
Reason: Port: the HTTP /api/board fetch becomes board.index(); the 409 is the status board.reply returns in-process. Kept: state, connector.alive, registered null, forged task not used.
|
||||
|
||||
```diff
|
||||
- assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
|
||||
- assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
|
||||
- assert.equal(response.status, 409);
|
||||
+ assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
|
||||
+ assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
|
||||
+ assert.equal(status, 409);
|
||||
```
|
||||
|
||||
### A2: control-board/tests/discord.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): server rescans connector discovery and refuses HTTP reply without transport
|
||||
|
||||
Reason: Port: the HTTP /api/board fetch becomes board.index(); zero-sessions assert unchanged.
|
||||
|
||||
```diff
|
||||
- assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
|
||||
+ assert.equal((await board.index()).sessions.length, 0);
|
||||
```
|
||||
|
||||
### A4: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): isLoopbackHost: rejects non-loopback hosts
|
||||
|
||||
Reason: Retired writer HTTP asserts: page content-type and cache-control, healthz body, 404/405 and the 500 envelope. The writer opens no listener now. index.json and per-agent write asserts are not re-asserted here (gap).
|
||||
|
||||
```diff
|
||||
- assert.ok(caught instanceof ConfigError, "expected a ConfigError");
|
||||
- assert.equal(res.status, 200, path);
|
||||
- assert.equal(res.headers.get("content-type"), "text/html; charset=utf-8", path);
|
||||
- assert.equal(res.headers.get("cache-control"), "no-store", path);
|
||||
- assert.equal(await res.text(), page, path);
|
||||
- assert.equal(health.status, 200);
|
||||
- assert.deepEqual(await health.json(), { ok: true });
|
||||
- assert.equal(first.status, 200);
|
||||
- assert.equal(first.headers.get("content-type"), "application/json");
|
||||
- assert.equal(first.headers.get("cache-control"), "no-store");
|
||||
- assert.equal(firstBody.sessions[0].state, "idle");
|
||||
- assert.ok(!firstBody.waitingOnYou.includes("proj/agent1"));
|
||||
- assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
|
||||
- assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
|
||||
- assert.equal(second.status, 200);
|
||||
- assert.equal(secondBody.sessions[0].state, "working");
|
||||
- assert.equal(missing.status, 404);
|
||||
- assert.equal(posted.status, 405);
|
||||
- assert.equal(res.status, 200);
|
||||
- assert.equal(body.sessions[0].taskSource, "registration");
|
||||
- assert.equal(body.sessions[0].task, "registered task");
|
||||
- assert.deepEqual(body.registered, ["proj/agent1"]);
|
||||
- assert.deepEqual(body.registrationErrors, []);
|
||||
- assert.equal(res.status, 500);
|
||||
- assert.equal(res.headers.get("content-type"), "application/json");
|
||||
- assert.equal(typeof body.error, "string");
|
||||
- assert.ok(body.error.length > 0);
|
||||
```
|
||||
|
||||
### A5: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): CLI: serve refuses a non-loopback host with exit 2 and a refused: message
|
||||
|
||||
Reason: Retired with serve: CLI refusal now asserted by cli.mjs:14 via board.test.mjs (group B hunk 0).
|
||||
|
||||
```diff
|
||||
- assert.equal(r.status, 2);
|
||||
- assert.match(r.stderr, /^refused:/);
|
||||
```
|
||||
|
||||
### A6: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): CLI: live serve prints its URL and answers /healthz
|
||||
|
||||
Reason: Retired page.html source checks (esc, seen POST, healthz) and HTTP seen round-trip. page.html is deleted; markSeen order is in board.test.mjs. The esc() asserts have no in-process target (open gap).
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 200);
|
||||
- assert.deepEqual(await res.json(), { ok: true });
|
||||
- assert.ok(m, "esc() must exist in page.html");
|
||||
- assert.equal(esc('<script>alert("x")</script>&\''), "<script>alert("x")</script>&'");
|
||||
- assert.equal(esc(null), "");
|
||||
- assert.equal(esc(undefined), "");
|
||||
- assert.equal(esc(42), "42");
|
||||
- assert.deepEqual(rawJoins, [], `API values concatenated into HTML without esc(): ${rawJoins.join(" | ")}`);
|
||||
- assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`);
|
||||
- assert.equal(postRes.status, 200);
|
||||
- assert.ok(postBody.seen.includes("proj/agent1"));
|
||||
- assert.ok(!postBody.waitingOnYou.includes("proj/agent1"));
|
||||
- assert.equal(boardRes.status, 200);
|
||||
- assert.ok(boardBody.seen.includes("proj/agent1"));
|
||||
- assert.ok(!boardBody.waitingOnYou.includes("proj/agent1"));
|
||||
- assert.equal(clearRes.status, 200);
|
||||
- assert.ok(!clearBody.seen.includes("proj/agent1"));
|
||||
- assert.ok(clearBody.waitingOnYou.includes("proj/agent1"));
|
||||
- assert.equal(res.status, 400);
|
||||
- assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written on a rejected content-type");
|
||||
- assert.equal(res.status, 400);
|
||||
- assert.ok(!existsSync(join(boardDir, "seen.json")));
|
||||
- if (status !== null) assert.equal(status, 400);
|
||||
- assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written for an oversized body");
|
||||
- assert.equal(res.status, 400);
|
||||
- assert.ok(!existsSync(join(boardDir, "seen.json")));
|
||||
- assert.equal(board.status, 405);
|
||||
- assert.equal(put.status, 405);
|
||||
```
|
||||
|
||||
### A7: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)'
|
||||
|
||||
Reason: Retired page.html source checks only (seenControl, Seen section, Hide seen, sourceTag escapes). No verified Console equivalent in this pass (open gap).
|
||||
|
||||
```diff
|
||||
- assert.ok(m, "seenControl() must exist in page.html");
|
||||
- assert.match(body, /esc\(rec\.project\)/);
|
||||
- assert.match(body, /esc\(rec\.agent\)/);
|
||||
- assert.match(body, /esc\(rec\.lastActivity\)/);
|
||||
- assert.match(html, /headers:\s*\{\s*"content-type":\s*"application\/json"\s*\}/, "the seen POST must send content-type: application/json");
|
||||
- assert.match(html, /<details id="seenDetails">(?![^>]*\sopen)/, "the Seen section must start collapsed");
|
||||
- assert.match(html, /<h2 id="seen-h">Seen<\/h2>/);
|
||||
- assert.ok(m, "renderSeen() must exist in page.html");
|
||||
- assert.match(m[0], /filter\(function \(r\) \{ return r\.seen; \}\)/, "the Seen section lists exactly the rows the API marks seen");
|
||||
- assert.match(m[0], /buildRowPair\(r, true\)/, "seen rows reuse the escaped row builder, project column included");
|
||||
- assert.match(html, /renderWaiting\(lastData\);\n\s*renderSeen\(lastData\);/, "renderAll must render the Seen section on every refresh");
|
||||
- assert.ok(m, "renderProjects() must exist in page.html");
|
||||
- assert.match(body, /if \(!\(project in hideSeenState\)\) hideSeenState\[project\] = true/, "Hide seen defaults to on and the choice survives re-render");
|
||||
- assert.match(body, /class="hide-seen-toggle" data-project="' \+ esc\(project\)/, "the checkbox carries the escaped project name");
|
||||
- assert.match(body, /hideSeen && r\.seen/, "seen rows are filtered when the box is ticked");
|
||||
- assert.match(body, /" seen hidden"/, "the note reports how many seen rows are hidden");
|
||||
- assert.match(html, /closest\("\.hide-offline-toggle, \.hide-seen-toggle"\)/, "one change handler serves both checkboxes");
|
||||
- assert.ok(m, "renderProjects() must exist in page.html");
|
||||
- assert.match(
|
||||
- assert.match(body, /<span>\(' \+ headCount \+ "\)<\/span>/, "the header uses headCount, not the raw group length");
|
||||
- assert.doesNotMatch(body, /<span>\(' \+ group\.length/, "the raw group length must no longer be rendered in the header");
|
||||
- assert.ok(m, "buildRowPair() must exist in page.html");
|
||||
- assert.match(body, /rec\.task\s*\?[\s\S]*esc\(rec\.task\)[\s\S]*:\s*'<span class="unknown">unknown<\/span>'/, "task is escaped, or reads unknown when null");
|
||||
- assert.match(body, /rec\.activeProject\s*\?[\s\S]*esc\(rec\.activeProject\)[\s\S]*unknown/, "active project is escaped, or reads unknown when null");
|
||||
- assert.match(body, /<dt>Task<\/dt>/, "detail lists the task");
|
||||
- assert.match(body, /<dt>Active project<\/dt>/, "detail lists the active project");
|
||||
- assert.match(body, /<dt>Workspace<\/dt><dd>" \+ \(rec\.workspace \? esc\(rec\.workspace\) : "unknown"\)/, "detail lists the workspace, escaped, or unknown");
|
||||
- assert.match(body, /var span = showProject \? 7 : 6/, "the detail row spans the two new columns");
|
||||
- assert.equal(heads.length, 3, "all three tables carry the two new headers");
|
||||
- assert.match(html, /<td colspan="6" class="empty">No agents\.<\/td>/, "the empty project row spans every column");
|
||||
- assert.ok(rowPair, "buildRowPair() must exist in page.html");
|
||||
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\)/, "the task cell shows its source");
|
||||
- assert.match(rowPair[0], /sourceTag\(rec\.activeProjectSource\)/, "the active project cell shows its source");
|
||||
- assert.match(
|
||||
- assert.match(rowPair[0], /esc\(rec\.agent\) \+ "<\/button>" \+ modelTag\(rec\)/, "the agent cell shows the model under the name");
|
||||
- assert.match(rowPair[0], /<dt>Model<\/dt><dd>" \+ modelText\(rec\) \+ "<\/dd>/, "the detail list has a Model row");
|
||||
- assert.ok(line, `${name}() must exist in page.html`);
|
||||
- assert.match(line, /esc\(rec\.model\)/, `${name}() escapes the model`);
|
||||
- if (name === "modelText") assert.match(line, /esc\(rec\.provider\)/, "modelText() escapes the provider");
|
||||
- assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html");
|
||||
- assert.equal(sourceLabel.registration, "registered", 'SOURCE_LABEL must map "registration" to "registered"');
|
||||
- assert.ok(sourceTagLine, "sourceTag() must exist in page.html");
|
||||
- assert.match(sourceTagLine, /esc\(source\)/, "sourceTag() escapes the raw source value (used in the title)");
|
||||
- assert.match(sourceTagLine, /esc\(sourceLabel\(source\)\)/, "sourceTag() escapes the label text it displays");
|
||||
- assert.ok(fromSourceLine, "fromSource() must exist in page.html");
|
||||
- assert.match(fromSourceLine, /esc\(sourceLabel\(source\)\)/, "fromSource() escapes the label text it displays");
|
||||
- assert.ok(registeredTextFn, "registeredText() must exist in page.html");
|
||||
- assert.match(line, /esc\(/, `every dynamic value read off reg must be escaped: ${line.trim()}`);
|
||||
```
|
||||
|
||||
### A8: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): every refusal code the reader can raise has an HTTP status
|
||||
|
||||
Reason: Moved in, not weakened: REFUSAL_STATUS table and code-count asserts from the retired conversation test (hunk 15), plus the DEFAULT_AGENT_SEND path asserts (hunk 12).
|
||||
|
||||
```diff
|
||||
+ assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
|
||||
+ assert.ok(existsSync(DEFAULT_AGENT_SEND));
|
||||
+ assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
|
||||
+ assert.ok(codes.size >= 15, [...codes].join(" "));
|
||||
+ assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
|
||||
+ assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
|
||||
+ assert.deepEqual(REFUSAL_STATUS, {
|
||||
```
|
||||
|
||||
### A9: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): POST /api/reply: a registration with a tmux socket adds -L <socket>
|
||||
|
||||
Reason: Re-indent only: the same -L socket asserts move into the board.reply closure (identical text after trim).
|
||||
|
||||
```diff
|
||||
- assert.equal(status, 200);
|
||||
- assert.equal(body.socket, "mosaic-fleet");
|
||||
- assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
|
||||
+ assert.equal(status, 200);
|
||||
+ assert.equal(body.socket, "mosaic-fleet");
|
||||
+ assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
|
||||
```
|
||||
|
||||
### A10: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called
|
||||
|
||||
Reason: Re-indent only: the refusal asserts move into the ported reply test; the tool-not-run check is unchanged.
|
||||
|
||||
```diff
|
||||
- assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
|
||||
- assert.match(body.error, c.error);
|
||||
- assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
|
||||
+ assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
|
||||
+ assert.match(body.error, c.error);
|
||||
+ assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
|
||||
```
|
||||
|
||||
### A11: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash
|
||||
|
||||
Reason: Re-indent only for the 500 and path asserts. The healthz status line is dropped with the HTTP server.
|
||||
|
||||
```diff
|
||||
- assert.equal(status, 500);
|
||||
- assert.match(body.error, /missing-agent-send\.sh/);
|
||||
- assert.equal(health.status, 200);
|
||||
+ assert.equal(status, 500);
|
||||
+ assert.match(body.error, /missing-agent-send\.sh/);
|
||||
```
|
||||
|
||||
### A12: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable
|
||||
|
||||
Reason: Moved, not weakened: DEFAULT_AGENT_SEND asserts move to the refusal-table test (hunk 8).
|
||||
|
||||
```diff
|
||||
- assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
|
||||
- assert.ok(existsSync(DEFAULT_AGENT_SEND));
|
||||
- assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
|
||||
```
|
||||
|
||||
### A13: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped
|
||||
|
||||
Reason: Retired page.html source checks for the reply box (canReply, replyControl, postReply, receipts). Reply gate behaviour is covered in the Console by browser-edge.test (reply needs a registered seat, line 61; in-flight reply).
|
||||
|
||||
```diff
|
||||
- assert.ok(canReplyFn, "canReply() must exist");
|
||||
- assert.match(canReplyFn[0], /reg\.alive !== false/, "a stale registration gets no box");
|
||||
- assert.match(canReplyFn[0], /reg\.tmux && reg\.tmux\.session/, "a registration without a tmux session gets no box");
|
||||
- assert.ok(replyControlFn, "replyControl() must exist");
|
||||
- assert.match(replyControlFn[0], /if \(!canReply\(rec\)\) return '<span class="unknown">reply needs a registered seat<\/span>'/);
|
||||
- assert.match(replyControlFn[0], /class="reply-form" data-id="' \+ esc\(id\)/);
|
||||
- assert.match(replyControlFn[0], /value="' \+ esc\(replyDrafts\[id\] \|\| ""\)/, "an unsent draft is put back after a refresh");
|
||||
- assert.match(rowPair[0], /<dt>Reply<\/dt><dd>" \+ replyControl\(rec\) \+ "<\/dd>/);
|
||||
- assert.ok(receiptFn, "replyReceipt() must exist");
|
||||
- assert.match(line, /esc\(/, `every receipt value must be escaped: ${line.trim()}`);
|
||||
- assert.match(receiptFn[0], /esc\(r\.stderr \|\| r\.error/, "a failure shows the tool's stderr");
|
||||
- assert.ok(postReplyFn, "postReply() must exist");
|
||||
- assert.match(postReplyFn[0], /fetch\("\/api\/reply", \{\s*method: "POST",\s*headers: \{ "content-type": "application\/json" \}/);
|
||||
- assert.match(postReplyFn[0], /JSON\.stringify\(\{ agent: id, text: text \}\)/);
|
||||
- assert.match(postReplyFn[0], /replyReceipts\[id\] = body/);
|
||||
- assert.ok(lines.some((l) => l.includes('main.addEventListener("submit"')), "the form submits through one delegated handler");
|
||||
- assert.ok(lines.some((l) => l.includes('main.addEventListener("input"')), "drafts are captured as they are typed");
|
||||
- assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
|
||||
- assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
|
||||
+ // Ported from the writer's HTTP test (refactor 78b69e1e6): the same assertions over board.index().
|
||||
```
|
||||
|
||||
### A14: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): startServer: /api/board carries taskSetBy from a live registration and null for the derived rows
|
||||
|
||||
Reason: Ported: byAgent taskSource, taskSetBy and registrationErrors asserts kept on board.index() (test renamed 'index: carries taskSetBy...'). Retired: page.html tag and detail checks and the HTTP Host/Origin fences (no listener).
|
||||
|
||||
```diff
|
||||
- assert.equal(byAgent.agent1.taskSource, "registration");
|
||||
- assert.equal(byAgent.agent1.taskSetBy, "darkwing");
|
||||
- assert.equal(byAgent.agent2.taskSource, "first-user-message");
|
||||
- assert.equal(byAgent.agent2.taskSetBy, null);
|
||||
- assert.deepEqual(body.registrationErrors, []);
|
||||
- assert.ok(rowPair);
|
||||
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
|
||||
- assert.match(rowPair[0], /<dt>Task set by<\/dt><dd>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
|
||||
- assert.ok(tagLine);
|
||||
- assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
|
||||
- assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
|
||||
- assert.match(tagLine, /not verified/);
|
||||
- assert.ok(textLine);
|
||||
- assert.match(textLine, /esc\(rec\.taskSetBy\)/);
|
||||
- assert.match(textLine, /not verified/);
|
||||
- assert.ok(canReplyFn);
|
||||
- assert.equal(canReplyFn[0].includes("taskSetBy"), false);
|
||||
- assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
|
||||
- assert.equal(board.status, 403, `GET /api/board, ${label}`);
|
||||
- assert.deepEqual(JSON.parse(board.text), { error }, `GET /api/board, ${label}`);
|
||||
- assert.equal(board.headers["access-control-allow-origin"], undefined);
|
||||
- assert.equal(posted.status, 403, `POST /api/reply, ${label}`);
|
||||
- assert.deepEqual(JSON.parse(posted.text), { error }, `POST /api/reply, ${label}`);
|
||||
- assert.equal(posted.headers["access-control-allow-origin"], undefined);
|
||||
- assert.equal((await rawRequest(port, {})).status, 400, "GET /api/board, missing Host");
|
||||
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/reply", headers: { "content-type": "application/json" }, body: reply })).status, 400, "POST /api/reply, missing Host");
|
||||
- assert.equal(existsSync(f.capture), false, "agent-send was never run");
|
||||
- assert.equal(existsSync(join(f.boardDir, "index.json")), false, "no refused request rescanned the board");
|
||||
- assert.equal((await rawRequest(port, { path, headers: { host: `rebind.example:${port}` } })).status, 403, path);
|
||||
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/seen", headers: { host: `rebind.example:${port}`, "content-type": "application/json" }, body: "{}" })).status, 403);
|
||||
- assert.equal((await rawRequest(port, { headers: { host } })).status, 200, host);
|
||||
- assert.equal((await rawRequest(port, { headers: { host, origin: `http://${host}` } })).status, 200, `${host} with its own Origin`);
|
||||
- assert.equal(posted.status, 200);
|
||||
- assert.equal(JSON.parse(posted.text).delivered, true);
|
||||
- assert.equal((await fetch(`http://${own}/api/board`)).status, 200, "fetch without Origin");
|
||||
- assert.equal(r.status, 403, `${path}, ${label}`);
|
||||
- assert.deepEqual(JSON.parse(r.text), { error }, `${path}, ${label}`);
|
||||
- for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith("access-control-"), `${path}, ${label}: ${h}`);
|
||||
- assert.deepEqual(calls, [], "the reader never ran for a refused request");
|
||||
- assert.equal((await rawRequest(port, { path: "/api/conversations", headers: { host: own, origin: `http://${own}` } })).status, 200, "same-origin passes");
|
||||
- assert.deepEqual(calls, ["catalogue"]);
|
||||
+ assert.equal(byAgent.agent1.taskSource, "registration");
|
||||
+ assert.equal(byAgent.agent1.taskSetBy, "darkwing");
|
||||
+ assert.equal(byAgent.agent2.taskSource, "first-user-message");
|
||||
+ assert.equal(byAgent.agent2.taskSetBy, null);
|
||||
+ assert.deepEqual(body.registrationErrors, []);
|
||||
```
|
||||
|
||||
### A15: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): conversation routes: a reader listing that refuses gets its refusal status and body, not a 200
|
||||
|
||||
Reason: Ported: status and refusal body kept on board.conversation (renamed 'conversation listing...'). Retired: HTTP content-type, nosniff, no-store and no-CORS asserts, now in the Console test (group B hunk 25). Retired: pi and claude catalogue and paging asserts, HTTP-only (gap).
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 503);
|
||||
- assert.deepEqual(await res.json(), { error: refusal.message, refusal: { code: refusal.code, reconcile: refusal.reconcile } });
|
||||
- assert.ok(codes.size >= 15, [...codes].join(" "));
|
||||
- assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
|
||||
- assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
|
||||
- assert.deepEqual(REFUSAL_STATUS, {
|
||||
- assert.equal(res.headers.get("content-type"), "application/json", path);
|
||||
- assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
|
||||
- assert.equal(res.headers.get("cache-control"), "no-store", path);
|
||||
- assert.equal(res.headers.get("access-control-allow-origin"), null, path);
|
||||
- assert.equal(cat.status, 200);
|
||||
- assert.deepEqual([pi.seat, pi.title, pi.readOnly], ["agent1", "m0", true]);
|
||||
- assert.deepEqual([link.refusal, claude.seat, claude.unsupportedReason], ["unsafe-path", "rocko", "unsupported-harness"]);
|
||||
- assert.equal(first.status, 200);
|
||||
- assert.deepEqual([first.body.page.kind, first.body.page.entries.length, first.body.page.hasMore, first.body.page.branch], ["page", 100, true, "main"]);
|
||||
- assert.equal(second.status, 200);
|
||||
- assert.deepEqual([second.body.page.entries.length, second.body.page.hasMore], [20, false]);
|
||||
- assert.equal(second.body.page.entries.at(-1).content[0].text, "m119");
|
||||
- assert.deepEqual([follow.status, follow.body.page.entries.length], [200, 0]);
|
||||
- assert.equal(branch.status, 200);
|
||||
- assert.equal(r.status, status, path);
|
||||
- assert.deepEqual(r.body.refusal, { code, reconcile }, path);
|
||||
- assert.equal(typeof r.body.error, "string");
|
||||
- assert.equal(r.status, 400, path);
|
||||
- assert.deepEqual(treePrint(f.sessionsDir), before, "the sessions directory is unchanged");
|
||||
- assert.equal(existsSync(join(f.proj, ".pi", "state", "rocko")), false, "the Claude seat's directory was never created");
|
||||
+ assert.equal(out.status, 503);
|
||||
+ assert.deepEqual(out.body, { error: refusal.message, refusal: { code: refusal.code, reconcile: refusal.reconcile } });
|
||||
```
|
||||
|
||||
### A16: webui/tests/browser.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures
|
||||
|
||||
Reason: CHANGED ASSERT: the error text must contain 'in the Console' instead of the board URL, since there is no board URL in-process. Not run; verify on CLEAR.
|
||||
|
||||
```diff
|
||||
- assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
|
||||
+ assert.ok(error.includes('in the Console')); assert.match(error, /Showing last known data/);
|
||||
```
|
||||
|
||||
### A17: webui/tests/preview.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): loopback requests are unchanged in preview mode, and the bus views work with the board down
|
||||
|
||||
Reason: CHANGED ASSERT: a dead board read now returns error 'invalid-response' at 502 (consoleRead, webui/src/serve.mjs:230-234). Status kept; the board echo field is gone.
|
||||
|
||||
```diff
|
||||
- assert.equal(board.status, 502); assert.equal(board.body.board, dead);
|
||||
+ assert.equal(board.status, 502); assert.equal(board.body.error, 'invalid-response');
|
||||
```
|
||||
|
||||
### A18: webui/tests/reads.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): settings: release, board, loopback address, notifier binding name only
|
||||
|
||||
Reason: CHANGED ASSERT: settings.board is null, since the board is in-process (webui/src/serve.mjs:874, boardLabel).
|
||||
|
||||
```diff
|
||||
- assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, launch: undefined, at: undefined });
|
||||
+ assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: null, address: `${base}/`, launch: undefined, at: undefined });
|
||||
```
|
||||
|
||||
### A19: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): loopback host and board origin fail closed
|
||||
|
||||
Reason: Retired: boardURL() is removed with the HTTP board address, so its asserts go with it.
|
||||
|
||||
```diff
|
||||
- for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
|
||||
- assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
|
||||
- assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
|
||||
```
|
||||
|
||||
### A20: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
|
||||
|
||||
Reason: BEHAVIOUR CHANGE, not a re-expression: the refactor forwarded this request and returned the board's 409. The Console now answers 403 when send is off or the seat is unnamed (webui/src/serve.mjs ~843-846). The oversized-body 400 is also dropped here; no Console test covers the 4096 limit (open gap).
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
|
||||
- assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
|
||||
- try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
|
||||
+ assert.equal(res.status, 403, path); assert.equal((await res.json()).sent, false);
|
||||
```
|
||||
|
||||
### A21: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
|
||||
|
||||
Reason: Dropped: text/plain 400 (Console body() at serve.mjs:58 refuses non-JSON, mapped to 400 at :191; no test, open gap), the request-count checks (no HTTP hop) and the board-down 502 (board is in-process). Added: 405 for GET /api/reply and OPTIONS /api/board; 'the board never ran a write' check on f.captures.
|
||||
|
||||
```diff
|
||||
- assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
|
||||
- assert.equal(requests.length, 2);
|
||||
- assert.equal((await fetch(base + '/api/board')).status, 502);
|
||||
- assert.equal(requests.length, 3);
|
||||
+ assert.equal(f.captures.length, 0, 'the board never ran a write');
|
||||
+ assert.equal((await fetch(base + '/api/reply')).status, 405);
|
||||
+ assert.equal((await fetch(base + '/api/board', { method: 'OPTIONS' })).status, 405);
|
||||
```
|
||||
|
||||
### A22: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
|
||||
|
||||
Reason: CHANGED ASSERT: the exact 404 body and refusal object become a want.status and want.body table. The table must match the same body (check in the file, not run).
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 404, path);
|
||||
- assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
|
||||
- assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
|
||||
+ assert.equal(res.status, want.status, path);
|
||||
+ assert.deepEqual(await res.json(), want.body, path);
|
||||
```
|
||||
|
||||
### A23: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
|
||||
|
||||
Reason: WEAKENED: the exact 'refused before the board' check (request count unchanged, last url /api/board) becomes a status >= 400 check. This drops the proof the board was not reached. Flagged for Mos.
|
||||
|
||||
```diff
|
||||
- assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
|
||||
- assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
|
||||
- assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
- assert.equal(requests.length, before, 'refused before the board');
|
||||
- assert.equal(requests.at(-1).url, '/api/board');
|
||||
+ assert.ok((await fetch(base + '/api/board')).status >= 400);
|
||||
```
|
||||
|
||||
### A24: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): unreachable board reports URL; CLI rejects unsupported options
|
||||
|
||||
Reason: CHANGED ASSERT: the 502 with the board URL becomes 503 not-configured for /api/board and /api/conversations. No board URL appears in any response now.
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
|
||||
+ assert.equal((await fetch(base + '/api/board')).status, 503);
|
||||
+ assert.equal((await fetch(base + '/api/conversations')).status, 503);
|
||||
```
|
||||
|
||||
### A26: webui/tests/service.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): render carries every serve flag, the preview set included
|
||||
|
||||
Reason: CHANGED ASSERT: the expected ExecStart drops the --board flag (in-process board; webui/src/cli.mjs and scripts/webui-service.sh changed in commit 1).
|
||||
|
||||
```diff
|
||||
- assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
|
||||
+ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
|
||||
```
|
||||
|
||||
## Group B (added by row 198; Sage rules)
|
||||
|
||||
### B0: control-board/tests/board.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): cli serve refuses and points to the Console
|
||||
|
||||
Reason: New in row 198: cli serve refusal (cli.mjs:14) and the 'serve' retirement message, plus the markSeen order and answer asserts.
|
||||
|
||||
```diff
|
||||
+ import assert from "node:assert/strict";
|
||||
+ assert.deepEqual(events, ["write", "bump", "scan"]);
|
||||
+ assert.deepEqual(before.seen, []);
|
||||
+ assert.deepEqual(index.seen, ["proj/agent1"]);
|
||||
+ const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") });
|
||||
+ assert.equal(out.status, 409);
|
||||
+ assert.equal(events.includes("send"), false);
|
||||
+ assert.equal(events.includes("bump"), false);
|
||||
+ assert.equal(r.status, 2);
|
||||
+ assert.match(r.stderr, /retired on 7331/);
|
||||
+ assert.match(r.stderr, /webui/);
|
||||
```
|
||||
|
||||
### B3: control-board/tests/relaunch.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): without the pid seam, a null-pid registration is undecided: kept, alive null, no relaunch
|
||||
|
||||
Reason: New in row 198: null-pid relaunch test. Correction: a null pid makes registeredAlive undecided (scan.mjs:372, 377, 381), not dead. The seam only affects relaunch; the default stays () => true.
|
||||
|
||||
```diff
|
||||
+ assert.notEqual(row.registered, null);
|
||||
+ assert.equal(row.registered.alive, null);
|
||||
+ assert.equal(row.relaunchedAt, null);
|
||||
```
|
||||
|
||||
### B25: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): conversation routes: nosniff, no-store, no CORS header, and every board refusal keeps its status
|
||||
|
||||
Reason: New in row 198: Console conversation headers test (nosniff, no-store, no CORS header) and the REFUSAL_STATUS table through the Console route.
|
||||
|
||||
```diff
|
||||
+ assert.equal(hostile, 403);
|
||||
+ assert.equal((await fetch(f.base + path, { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
+ assert.equal(res.status, status, `${code} ${path}`);
|
||||
+ assert.deepEqual(await res.json(), { error: code }, `${code} ${path}`);
|
||||
+ assert.equal(res.headers.get('x-content-type-options'), 'nosniff', path);
|
||||
+ assert.equal(res.headers.get('cache-control'), 'no-store', path);
|
||||
+ assert.equal(res.headers.get('access-control-allow-origin'), null, path);
|
||||
```
|
||||
|
||||
## Retired, not ported
|
||||
|
||||
One line per test name that exists on refactor and not in the current tree. Name-based check: `comm` on test names in the three files.
|
||||
|
||||
- `control-board/tests/serve.test.mjs`: startServer: refuses a non-loopback host with ConfigError, never opens a socket — Listener refusal. No listener remains in control-board.
|
||||
- `control-board/tests/serve.test.mjs`: startServer: serves page, healthz, and a rescanning /api/board — HTTP-only page, healthz and /api/board. No listener remains; page content is not re-asserted (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: startServer: a seatsDir registration overrides the row and index.registered reflects it — HTTP-only seatsDir override check. Not re-asserted in this port (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: startServer: /api/board returns 500 JSON with an error field when scan throws — HTTP error envelope. A throwing board read is covered by the preview dead-board test (502 invalid-response, hunk 17); the 500 shape is not re-asserted.
|
||||
- `control-board/tests/serve.test.mjs`: CLI: serve refuses a non-loopback host with exit 2 and a refused: message — serve is retired; the refusal is asserted by board.test.mjs 'cli serve refuses and points to the Console' (group B hunk 0).
|
||||
- `control-board/tests/serve.test.mjs`: CLI: live serve prints its URL and answers /healthz — serve is retired (cli.mjs:14). The writer's HTTP healthz and page are gone; no listener remains (see Board listeners).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: esc() escapes every HTML-significant character — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it — Seen POST HTTP test removed, no port (no hunk, no phrase). markSeen write, bump, scan order is covered by board.test.mjs. Body-parsing gap below.
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen without a JSON content-type returns 400 and does not write a mark — Body-parsing gap. Console body() refuses non-JSON content-type (webui/src/serve.mjs:58) then 400 at :191. No Console test (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen with invalid JSON returns 400 — Body-parsing gap. Console answers 400 (webui/src/serve.mjs:191). No Console test (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark — Body-parsing gap. Console body() default max 4096 (webui/src/serve.mjs:56) answers 400 via :191. No Console test (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen with a missing agent returns 400 — Body-parsing gap. Console answers 400 with the error message (webui/src/serve.mjs:197). No Console test (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/board returns 405; PUT /api/seen returns 405 — Writer HTTP method refusal. Console 405s are asserted by group A hunk 21 (GET /api/reply, OPTIONS /api/board).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: has a collapsed Seen section that lists seen rows with the shared row builder — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: a project header reads \ — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: runs agent-send.sh with -s from the registration, -S <host>:control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams — Renamed; the phrase is still present in the current tree (rename only, no assert hunk).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: a registration with a tmux socket adds -L <socket> — Name not carried over; the -L socket asserts stay (hunk 9, re-indent only).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim — Renamed; the phrase is still present in the current tree (rename only, no assert hunk).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called — Name not carried over; the refusal table stays (hunk 10, re-indent only).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash — Name not carried over; the 500 and path asserts stay in the ported reply test (hunk 11).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: startServer: /api/board carries taskSetBy from a live registration and null for the derived rows — Renamed 'index: carries taskSetBy...'; asserts ported (hunk 14).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers — Writer HTTP fence. control-board opens no listener (see Board listeners). Console fences at webui/src/serve.mjs:713-717 and :726 covered by the F16 test.
|
||||
- `control-board/tests/serve.test.mjs`: Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin — Writer HTTP fence; same reason as the other Host/Origin guard test.
|
||||
- `control-board/tests/serve.test.mjs`: conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers — Writer HTTP fence. Replaced by the Console F16 test (webui serve.test.mjs, 'conversation route: a non-local Host or a foreign Origin is refused 403').
|
||||
- `control-board/tests/serve.test.mjs`: conversation routes: a reader listing that refuses gets its refusal status and body, not a 200 — Renamed 'conversation listing: ...' and ported to board.conversation (hunk 15). HTTP header asserts moved to the Console test (group B hunk 25).
|
||||
- `control-board/tests/serve.test.mjs`: conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written — HTTP-only catalogue and paging. No in-process port; page-content asserts are not re-asserted (open gap).
|
||||
- `control-board/tests/discord.test.mjs`: server rescans connector discovery and refuses HTTP reply without transport — Renamed in place to 'board rescans connector discovery and refuses reply without transport'; HTTP fetch and 409 ported to board.index()/board.reply() (hunks 1-2).
|
||||
- `webui/tests/serve.test.mjs`: loopback host and board origin fail closed — boardURL() removed with the HTTP board address (hunk 19).
|
||||
- `webui/tests/serve.test.mjs`: proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect — Writer HTTP proxy test; the Console has no proxy hop in-process. Changed and weakened asserts in hunks 20-23 (see Weakened).
|
||||
- `webui/tests/serve.test.mjs`: unreachable board reports URL; CLI rejects unsupported options — No board URL to report in-process; replaced by 503 not-configured asserts (hunk 24).
|
||||
|
||||
## Weakened and changed asserts (for Mos)
|
||||
|
||||
- Hunk 20: 409 becomes 403 (behaviour change, see below); the oversized-body 400 is dropped.
|
||||
- Hunk 21: the text/plain 400 and the request-count and board-502 checks are dropped.
|
||||
- Hunk 22: exact 404 body text becomes a table comparison.
|
||||
- Hunk 23: exact 'refused before the board' check becomes status >= 400.
|
||||
|
||||
## Behaviour changes to report
|
||||
|
||||
1. POST /api/reply in the Console: 403 when send is off or the seat is unnamed (webui/src/serve.mjs ~843-846). The refactor forwarded the request unconditionally (hunk 20).
|
||||
2. /api/settings reports `board: null` in-process (hunk 18).
|
||||
3. A dead board read returns 502 `invalid-response` (hunk 17).
|
||||
4. Null-pid correction: a null pid is undecided (scan.mjs:372, 377, 381), not dead. The default `() => true` only matters for relaunch, and the new relaunch test (group B hunk 3) covers that.
|
||||
5. creative-hidden.test.mjs: ported from `startServer` to `createBoard` and `board.conversation`. Assertions unchanged. `startServer` is no longer exported by control-board/src/serve.mjs.
|
||||
|
||||
## Board listeners after 198
|
||||
|
||||
Command: `grep -rn "createServer\|\.listen(\|startServer\|startBoard" packages/control-board/src`
|
||||
|
||||
Result: no matches (exit 1). control-board/src opens no listener.
|
||||
|
||||
CLI entry: packages/control-board/src/cli.mjs:14 refuses `serve` with `ConfigError` (RETIRED message, line 11). No listener.
|
||||
|
||||
Not in the board's code, for completeness: the Console (packages/webui/src/serve.mjs:1 imports `createServer`; :684 creates the server; :909 listens) hosts the board in-process. packages/webui/src/tester-socket.mjs:24 listens on a separate tester socket, not the board.
|
||||
|
||||
So the Host/Origin tests in control-board are retired on this basis. The Console's Host and Origin fences are covered by the F16 test in webui/tests/serve.test.mjs.
|
||||
|
||||
## Open items (not closed in this pass)
|
||||
|
||||
- page.html source checks are retired with no verified Console equivalent in app.js (open gap).
|
||||
- Seen POST body parsing (400 for invalid JSON, missing agent, 4096 limit, non-JSON content-type) has no board test and no Console test. Gap recorded; no new test added (sage-4373).
|
||||
- Catalogue and paging over HTTP, seatsDir registration override, and the 500 shape are not re-asserted (see retire list).
|
||||
- Not run: chat-replies-browser (line 71 `startServer({ port: 0, board: f.board })`) and the shell 'board unavailable' text (ReadError message path, serve.mjs:230-234). Verify on CLEAR.
|
||||
- The webui F16 test (serve.test.mjs:93) is a new test; the hunk map does not isolate it as its own hunk. Confirm its add lines are in group B hunk 25's range.
|
||||
- webui/tests/discord.test.mjs needs no isPidAlive seam: its registration uses pid `process.pid`.
|
||||
- conversation/tests/flows.test.mjs imports replyToRow from control-board/src/serve.mjs; the export still exists. Not run.
|
||||
- Syntax: `node --check` passes for all touched JS files (no tests run).
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Row 267 (C2-15, #1772): hybrid universes, exclusions, pins and the crossover. Build notes
|
||||
|
||||
Morgana, 2026-10-25. Brief: `docs/plans/2026-10-20_case2-rows.md` "C2-15 hybrid universes: exclusions,
|
||||
pins and the end-to-end crossover" (blob 85e6fe2e). Reviewer Filbert, gate owner Sage. Claimed at rev 4087.
|
||||
Branch `morgana/row267` from refactor 78b69e1e6. Design 1.4 is `agents/gosalyn/work/case2/DESIGN.md`.
|
||||
|
||||
Status: build committed under sage-4389 and sage-4407. Mos approved the survey.test.mjs:75 change (mos-1667,
|
||||
read against 5b2da2203). Nothing is run. No runs before Sage's CLEAR.
|
||||
|
||||
## 1. What the code does today (78b69e1e6)
|
||||
|
||||
- `mapping.mjs:119-134` checks only the shape of a constraint. `exclude` is any folder path, and
|
||||
`pin.entity` is any folder path with a scalar value. Nothing checks either against the sources.
|
||||
- `query.mjs:52-57` `sees()` reads `exclude` as a path prefix only (`inside(c.exclude, file.path)`). The
|
||||
type form `second/Characters` (`mapping.test.mjs:203`) passes the validator but hides nothing, because
|
||||
no file path starts with `second/`.
|
||||
- `reader.mjs:206-210` gives R6 only for a canon link into a universe that is neither the file's own nor
|
||||
one of its sources. A hybrid canon file that links into an excluded source path gets no problem.
|
||||
- `guard.mjs:91-97` lets any mix of one hybrid and its sources pass. It doesn't check exclusions.
|
||||
- `problems.mjs` has R1-R7 and no R8.
|
||||
|
||||
## 2. Design
|
||||
|
||||
### 2.1 Validator (`mapping.mjs`)
|
||||
|
||||
The validator runs after every universe is read, alongside the existing sources check at `:141-146`,
|
||||
because it needs each source's books, playground and supporting lists. For each constraint of a hybrid:
|
||||
|
||||
- **Type form:** the first segment is one of the hybrid's `sources`, and the rest is exactly one key of
|
||||
`types`.
|
||||
- **Path form:** the path equals, or lies inside, a book folder of one of the sources.
|
||||
- Both forms fit: refuse `library-mapping-invalid` ("... reads as both a source/type and a source path").
|
||||
- Neither form fits: refuse `library-mapping-invalid` ("... lies inside no book of the hybrid's
|
||||
sources"). This covers a path in the hybrid's own books, in a non-source universe, or unmapped.
|
||||
- **Path form under a playground:** a path equal to or inside one of a source's playground prefixes
|
||||
refuses ("... lies under a playground of universes.X, which the hybrid never reads"). "Under" means the
|
||||
path equals the prefix without its trailing `/`, or starts with the prefix.
|
||||
- **`pin.entity`:** the same path-form rules apply, with no type form. An entity is one file.
|
||||
|
||||
The stored shape stays `{ exclude }` / `{ pin }`. I don't add a `form` field. `library_universe`
|
||||
returns `constraints` as they are now, and the index cache digest doesn't change for existing mappings.
|
||||
`excluded()` works out the form again each time. It's pure and cheap.
|
||||
|
||||
### 2.2 `excluded(mapping, hybrid, path)` (`scope.mjs`)
|
||||
|
||||
This returns true when any `exclude` of universe `hybrid` covers `path`:
|
||||
|
||||
- **Type form `S/T`:** `path` starts with `${book}/${T}/` for some book of source S.
|
||||
- **Path form P:** `path === P` or `path` starts with `${P}/`.
|
||||
|
||||
It returns false when `hybrid` isn't a hybrid. It reads only the path (D2). It has three callers:
|
||||
|
||||
- `query.mjs` `sees()` replaces its own `inside(c.exclude, ...)` reading with this helper.
|
||||
- `reader.mjs` uses it for the excluded-link R6.
|
||||
- `guard.mjs` uses it for the excluded cite.
|
||||
|
||||
`query-fixture.mjs` `EXCLUSION` (`Book One/Research`) is path form and still fits, so the existing
|
||||
C2-05a query tests keep their constants.
|
||||
|
||||
### 2.3 Reader: excluded-link R6 and R8
|
||||
|
||||
- **Excluded-link R6:** in the canon branch at `:206`, a hybrid file's link to a target where
|
||||
`excluded(mapping, file.universe, to.path)` is true gives `problem('R6', file.path, link.line)`, with
|
||||
the default message. A custom message isn't possible: `mirror.mjs:196-201` `validProblem` accepts a
|
||||
sync-state problem only when `message === RULES[code]`, so a new message would make every later sync
|
||||
state read refuse `library-state-invalid`. The link stays in `file.links`, as the other R-rules leave
|
||||
it. `sees()` already drops it from the hybrid's `links_out`. A link that is both foreign and excluded
|
||||
can't occur, because an excluded path always lies in a source, so each link gets at most one R6.
|
||||
- **R8:** a new `RULES.R8` entry reads "pinned value changed". The check runs once per commit, after the
|
||||
link pass, for each pin of each hybrid:
|
||||
- **Entity missing** from `byPath`: R8 at `entity`, with no line. This covers a file that is absent,
|
||||
R1, R2 or R7.
|
||||
- **Entity present:** R8 at `entity`, on the `field` key's line where there is one, unless the parsed
|
||||
front matter holds `field` as a scalar (string, number or boolean) and `String(parsed) ===
|
||||
String(value)`. A list, null, a missing key and unparsed (R3) front matter all count as changed. Under
|
||||
a plain `String()` comparison, `['grey']` would equal `'grey'`, so this rule closes that hole.
|
||||
- **Hybrid versions:** each canon file of the hybrid whose `type` (from `classify`) equals the entity's
|
||||
type, and whose name matches the entity's, is a version of it. Names are the trimmed front matter
|
||||
`name` on both sides. When either file has no text `name`, the file names (stems) compare instead.
|
||||
A version that holds `field` with another value gets R8 at its own path, on that key's line. A version
|
||||
without `field` gets nothing, and neither does any other file that only links to the entity.
|
||||
- The entity's type and name come from `classify(mapping, entity)` and the entity's parsed file. When the
|
||||
entity is missing, its stem stands in for the name.
|
||||
- An entity outside any type folder (a book root file, a manuscript file, or a folder that isn't a type)
|
||||
has type null or `manuscript`. Its versions are then the hybrid canon files with that same type and a
|
||||
matching name or stem. The brief doesn't cover this case, and no special rule is added for it (Filbert N2).
|
||||
- R8 entries are deduplicated by path and line, so two pins or two hybrids on one missing or changed
|
||||
entity give one entry (Filbert N1). Survey counts and the sync state count each path and line once.
|
||||
|
||||
### 2.4 Guard (`guard.mjs`)
|
||||
|
||||
After the existing span check at `:91-97`, the guard looks for a hybrid among the cited universes. If
|
||||
there is one, and some cite lies in one of its sources while `excluded(mapping, hybrid, c.path)` is true,
|
||||
the guard refuses `citation-cross-universe` ("citation N (path) is excluded from hybrid H"). A reply that
|
||||
cites only source paths, excluded ones included, has no hybrid among its universes, so it's unchanged.
|
||||
|
||||
### 2.5 Setup sheet
|
||||
|
||||
`docs/case2/SETUP-SHEET.md` gets one section after "Supporting folders", headed "Making a crossover
|
||||
(hybrid) universe". It covers `kind`, `name`, `sources`, `books`, `playground`, `constraints`
|
||||
(`exclude` in both forms, `pin`) and `notes`, and says that the continuity role reads and cites the notes.
|
||||
It also says that Jason edits this entry by hand and agents can't change it (C2-04 rule M9), as the
|
||||
sheet already says for the rest of the entry.
|
||||
|
||||
## 3. Tests and mutants
|
||||
|
||||
New files and cases, all on scratch repos under TMPDIR (never /tmp):
|
||||
|
||||
- **`packages/library/tests/hybrid.test.mjs` (new):**
|
||||
- A business-file-shaped mapping (fixture `mapping.json` plus a `cross` hybrid) on a repo in her
|
||||
layout's shape.
|
||||
- `find` and `search` in `cross` return its own files plus the sources' canon, each carrying its source
|
||||
universe. They never return a source playground or an excluded path, and this runs once for each
|
||||
exclude form.
|
||||
- `get` keeps `links_out` into the sources and drops excluded targets.
|
||||
- A source universe's lookups never return a `cross` file.
|
||||
- `universeInfo` returns the sources, constraints and notes.
|
||||
- `excluded()` gets unit cases for both forms and for a non-hybrid.
|
||||
- **`mapping.test.mjs`:** one case per new refusal: both forms fit, neither fits, the hybrid's own book,
|
||||
an exclude under a source playground, a pin under a source playground, and a `pin.entity` that doesn't
|
||||
end in `.md` (Q5). `second/Characters` at `:203` still checks.
|
||||
- **`reader.test.mjs`:**
|
||||
- R8 cases: entity missing, two pins on a missing entity give one entry (N1), entity changed, a same-name hybrid version with no link, and a list value.
|
||||
- Non-R8 cases: a hybrid place file with `age: 300` that links the entity, and a different hybrid
|
||||
character that links it.
|
||||
- The excluded-link R6, once for each exclude form.
|
||||
- **`guard.test.mjs`:** a hybrid cite with source canon passes, and a hybrid cite beside an excluded path
|
||||
refuses in both forms. A cite of only an excluded source path passes as that universe's item.
|
||||
- **`packages/bus/tests/citation-guard.test.mjs`:** through `citationWorld` with `mapChange` adding the
|
||||
hybrid, the excluded-beside-hybrid send refuses and the `messages` count stays the same.
|
||||
- **Sync state after an excluded-link R6 (sage-4389 finding a):** one case that syncs a commit holding a
|
||||
hybrid file's excluded link, then reads the sync state back with `readSyncState` and finds the R6 with
|
||||
the default message. This shows `validProblem` (`mirror.mjs:196-201`) still accepts the state.
|
||||
- **`packages/bus/tests/library-lookups.test.mjs`:** one broker case, where `library.find` and
|
||||
`library.universe` in the hybrid return what the library tests above assert.
|
||||
- **C2-04 M9 rule (agents can't write `projects`), not a mutant:** a case in `packages/business/tests/library-projects-protected.test.mjs` where an organization
|
||||
write that adds a new hybrid universe refuses `organization-projects-protected`. This is outside the
|
||||
brief's "tests in library and bus" list. Sage ruled it in (Q1), so the business suite is on the run list.
|
||||
- **`docs/case2/SURVEY-RUN.md:98`** names the problems line as `R1 .., ..., R8 ..`. No test reads that file.
|
||||
- **One existing assertion changes:** `survey.test.mjs:75` gains `, R8 0`. The before and after lines are
|
||||
in `assertion-diff.md`, which goes to Mos before the commit (mos-1272).
|
||||
|
||||
Mutants. Each runs only its own named tests, and the tree is restored after each one.
|
||||
|
||||
| # | Mutation | Killed by |
|
||||
|---|---|---|
|
||||
| X1 | `excluded()` ignores the type form | hybrid.test "C2-15 excluded(): ..." and "C2-15 type form: find and search ..." |
|
||||
| X2 | `excluded()` ignores the path form | hybrid.test "C2-15 excluded(): ..." and "C2-15 path form: find and search ..." |
|
||||
| X3 | `sees()` keeps its old `inside()` reading | hybrid.test "C2-15 type form: find and search ..." |
|
||||
| X4 | the validator accepts "both forms fit" | mapping.test "C2-15: each exclude and pin is checked against the hybrid's sources" |
|
||||
| X5 | the validator skips the playground check | mapping.test "C2-15: each exclude and pin is checked against the hybrid's sources" |
|
||||
| X6 | R8 compares with plain `String()` (list equals scalar) | reader.test "C2-15 R8: the pinned entity reads clean ..." (list case) |
|
||||
| X7 | a version is matched by link instead of name | reader.test "C2-15 R8: a hybrid version of the entity ..." |
|
||||
| X8 | the reader drops the excluded-link R6 | reader.test "C2-15 R6: a hybrid file that links into an excluded source path ..."; sync.test "C2-15 sage-4389 (a) ..." also fails |
|
||||
| X9 | the guard skips the excluded check | guard.test "C2-15 a hybrid cite beside an excluded source path refuses citation-cross-universe, both forms" (the mutant run); bus citation-guard "C2-15 excluded cite beside a hybrid refuses and inserts nothing" also fails |
|
||||
|
||||
## 4. Rulings (sage-4389)
|
||||
|
||||
- **Q1 yes.** The C2-04 M9 case is new and goes only in `packages/business/tests/library-projects-protected.test.mjs`.
|
||||
No existing assertion there changes. The business suite is on the run list.
|
||||
- **Q2 dropped.** The condition was a file:line showing that the hybrid's index, reader and guard never
|
||||
read a source's supporting prefix. Two of the three do read it:
|
||||
- Index: `query.mjs:55` `sees()` returns false for a source file whose scope isn't `canon`, so the
|
||||
hybrid's lookups never show a source's supporting file. This one holds.
|
||||
- Reader: `reader.mjs:206-209` checks R5 for playground targets only, and R6 skips any target in a
|
||||
source universe. A hybrid canon file that links into a source's supporting file gets no problem and
|
||||
keeps the link (`:211`).
|
||||
- Guard: `guard.mjs:91-97` checks the cited universes and never their scope. A reply that cites a
|
||||
hybrid file beside a source's supporting file passes, and so does one beside a source's playground
|
||||
file (`guard.mjs:87-90` only stops a playground cite marked `as: 'canon'`).
|
||||
Sage's ranges in sage-4399 (`reader.mjs:206-211`, `guard.mjs:87-97`) cover the same lines. Sage files
|
||||
a DEFERRED entry for the guard and reader gap at landing.
|
||||
So the validator does not refuse an exclude under a source's supporting prefix. The guard observation
|
||||
is outside this row's guard scope ("the excluded-path cite only"); I report it to Sage as a possible
|
||||
follow-up and don't change it here.
|
||||
- **Q3:** R8 reaches the survey and the sync state as described. `survey.test.mjs:75` goes through
|
||||
`assertion-diff.md`. Finding (a) adds the sync-state readback test in §3. `packages/library/README.md`
|
||||
lists R1-R7 in its problems table, so it gains an R8 row; no test reads that file.
|
||||
- **Q4 as written:** versions count only in the hybrid's canon.
|
||||
- **Q5 yes:** a `pin.entity` that doesn't end in `.md` refuses `library-mapping-invalid`, with its own test.
|
||||
- **Finding (a):** R6 keeps the default message, with the readback test above.
|
||||
|
||||
## 5. Gate
|
||||
|
||||
Filbert approves the manifest. The library and bus suites, plus business if Q1 stands, run green on
|
||||
Sage's gate along with every `scripts/test-*.sh`. Hash pins on the changed files are grepped before
|
||||
in-review.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Row 267: one existing assertion changes (mos-1272)
|
||||
|
||||
File: `packages/library/tests/survey.test.mjs:75`, in the test that checks the survey's last three lines
|
||||
with `--mapping`.
|
||||
|
||||
Before:
|
||||
|
||||
```
|
||||
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0']);
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```
|
||||
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0, R8 0']);
|
||||
```
|
||||
|
||||
Why: R8 ("pinned value changed") is a new rule in `problems.mjs` `RULES`, and the survey line lists a
|
||||
count for every `RULES` key (`survey.mjs:70`), so it gains `R8 0`. The fixture has no hybrid and no pin,
|
||||
so the count is 0 and no other count moves.
|
||||
|
||||
No other existing assertion in the row changes.
|
||||
@@ -65,6 +65,37 @@ Tell Jason which folders you want to mark. He adds them to the `supporting` list
|
||||
entry of your business file. He edits that entry by hand, and you can ask him to add or change a folder
|
||||
later.
|
||||
|
||||
## Making a crossover (hybrid) universe
|
||||
|
||||
A crossover is its own universe, called a hybrid. It reads the canon of the universes it draws on, its
|
||||
sources, and has its own books. Agents only cross worlds inside one. Like the rest of the `projects`
|
||||
entry, Jason adds it by hand and the agents can't change it. Tell him what you want, and he writes an
|
||||
entry with these keys:
|
||||
- `kind`: `"hybrid"`.
|
||||
- `name`: the name you call it by.
|
||||
- `sources`: two or more universes it reads.
|
||||
- `books`: its own book folders. It may start with none.
|
||||
- `playground` and `supporting`: its own folders, the same as for any universe.
|
||||
- `constraints`: the rules below, if you want any.
|
||||
- `notes`: text on how the worlds fit together, up to 4000 characters. The continuity agent reads the
|
||||
notes and cites them.
|
||||
|
||||
A hybrid never reads a source's playground.
|
||||
|
||||
There are two kinds of constraint:
|
||||
- `exclude` hides part of a source. It takes two forms. A source and a type folder, for example
|
||||
`second/Characters`, hides that type folder in each of that source's books. A path, for example
|
||||
`Book One/Research`, hides that folder or note, which must sit in a book of one of the sources. Agents
|
||||
in the hybrid don't see what is hidden, can't cite it beside a hybrid note, and a hybrid note that links
|
||||
to it gets a link warning.
|
||||
- `pin` holds one fact fixed: a source note (`entity`, a `.md` path), a property (`field`) and its value
|
||||
(`value`). If the note goes missing or the property changes, Mosaic warns. It also warns when a hybrid
|
||||
note of the same type and name sets that property to something else. Linking to the note doesn't count:
|
||||
a hybrid character who links `[[Mara]]` is someone else.
|
||||
|
||||
Mosaic refuses an entry whose `exclude` or `pin` points outside the sources' books, or into a source's
|
||||
playground.
|
||||
|
||||
## Saves and agent notes
|
||||
|
||||
When you save a file in the Console, Mosaic makes a commit under the name and email you give Jason for
|
||||
|
||||
@@ -95,7 +95,7 @@ With `--mapping` three lines follow:
|
||||
|---|---|
|
||||
| `scopes canon .., playground .., unmapped ..; indexed ..` | tree entries per scope, and notes indexed |
|
||||
| `resolved root .., vault-prefix .., relative .., shortest .., unresolved ..` | links per resolver step. `unresolved` is the R4 count |
|
||||
| `problems R1 .., ..., R7 ..` | problems per rule |
|
||||
| `problems R1 .., ..., R8 ..` | problems per rule |
|
||||
|
||||
Folder paths and short property names are the only words from her repo in the output. In a folder
|
||||
name, tab, newline, carriage return, backspace and form feed print as `\t`, `\n`, `\r`, `\b` and `\f`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { connect } from 'node:net';
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
@@ -68,6 +68,31 @@ test('C2-06 supporting file stores supporting scope and its real agent trailer',
|
||||
assert.deepEqual(JSON.parse(event.body).check, { citations: [{ scope: 'supporting', agent: 'acme/scribe', file }] });
|
||||
});
|
||||
|
||||
// C2-15: a hybrid over harbor and second that excludes harbor's Places folder (path form).
|
||||
const crossMapping = entry => {
|
||||
entry.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'],
|
||||
constraints: [{ exclude: 'Book One/Places' }] };
|
||||
return entry;
|
||||
};
|
||||
|
||||
test('C2-15 excluded cite beside a hybrid refuses and inserts nothing', async t => {
|
||||
const f = citationWorld(t, { mapChange: crossMapping }); f.call(f.agent, 'role.claim');
|
||||
mkdirSync(join(f.repo, 'Crossover/Chapters'), { recursive: true });
|
||||
writeFileSync(join(f.repo, 'Crossover/Chapters/01.md'), 'A hybrid fixture.\n');
|
||||
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'hybrid fixture');
|
||||
const commit = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(commit);
|
||||
const hybrid = fixtureCite(commit, { path: 'Crossover/Chapters/01.md', lines: [1, 1], quote: 'hybrid fixture' });
|
||||
const hidden = fixtureCite(commit, { path: 'Book One/Places/Harbor Town.md', lines: [4, 4], quote: 'Salt.' });
|
||||
const count = f.store.get('SELECT count(*) n FROM messages').n;
|
||||
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: citedBody([hybrid, hidden]) }),
|
||||
e => e.code === 'citation-cross-universe' && e.reason === 'citation 2 (Book One/Places/Harbor Town.md) is excluded from hybrid cross');
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, count);
|
||||
assert.equal(f.store.all("SELECT * FROM events WHERE json_extract(body,'$.action')='message.checked'").length, 0);
|
||||
// The hybrid beside source canon it can see goes through.
|
||||
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([hybrid, fixtureCite(commit)]) });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, count + 1);
|
||||
});
|
||||
|
||||
test('C2-06 check evidence and insert are atomic when storing evidence fails', async t => {
|
||||
const f = citationWorld(t); f.call(f.agent, 'role.claim');
|
||||
f.store.run(`CREATE TRIGGER fixture_check_refused BEFORE INSERT ON events
|
||||
|
||||
@@ -251,3 +251,18 @@ test('C2-05c broker forwards supporting scope and returns the served blob withou
|
||||
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' });
|
||||
});
|
||||
|
||||
test('C2-15 broker: library.find and library.universe in a hybrid see its own files and source canon, minus the type-form exclude', async t => {
|
||||
const projects = { books: mappingEntry() };
|
||||
projects.books.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'],
|
||||
constraints: [{ exclude: 'second/Characters' }], notes: 'Fixture crossover.' };
|
||||
const f = await world(t, { projects, files: { ...TEXTS, 'Crossover/Chapters/01.md': '# Both\n[[Tomas]] and [[Elsewhere]].\n' } });
|
||||
const found = await f.call('library.find', { universe: 'cross', query: '' });
|
||||
assert.deepEqual(found.results.map(x => [x.path, x.universe]), [
|
||||
['Book One/Chapters/01.md', 'harbor'], ['Book One/Characters/Tomas.md', 'harbor'], ['Crossover/Chapters/01.md', 'cross']]);
|
||||
assert.deepEqual((await f.call('library.find', { universe: 'cross', query: '', scope: 'playground' })).results, []);
|
||||
assert.ok(!(await f.call('library.find', { universe: 'harbor', query: '' })).results.some(x => x.path.startsWith('Crossover/')));
|
||||
const universe = await f.call('library.universe', { universe: 'cross' });
|
||||
assert.deepEqual([universe.kind, universe.sources, universe.constraints, universe.notes],
|
||||
['hybrid', ['harbor', 'second'], [{ exclude: 'second/Characters' }], 'Fixture crossover.']);
|
||||
});
|
||||
@@ -67,3 +67,12 @@ test('C2-04 M9: a serialization hook cannot change projects after the canonical
|
||||
Object.defineProperty(next.projects, 'toJSON', { value: () => changed });
|
||||
});
|
||||
});
|
||||
|
||||
test('C2-15: an organization write that adds a hybrid universe with exclude and pin constraints is protected', t => {
|
||||
const f = fixture(t);
|
||||
protectedWrite(f, next => {
|
||||
next.projects.books.universes.cross = { kind: 'hybrid', name: 'An invented crossover', sources: ['harbor', 'second'],
|
||||
books: ['Crossover'], playground: [], notes: 'Fixture only',
|
||||
constraints: [{ exclude: 'second/Characters' }, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'name', value: 'Mara Vell' } }] };
|
||||
});
|
||||
});
|
||||
@@ -119,8 +119,9 @@ Problems carry `{ code, path, line, message }`:
|
||||
| R3 | front matter not fully parsed, `line` is where parsing stopped | yes, front matter kept raw |
|
||||
| R4 | link resolves to no file or to more than one | yes, link dropped |
|
||||
| R5 | canon file links into the playground | yes |
|
||||
| R6 | canon file links into another universe, a hybrid's sources excepted | yes |
|
||||
| R6 | canon file links into another universe, a hybrid's sources excepted, or a hybrid's file links into a path its `exclude` covers | yes |
|
||||
| R7 | two paths differ only in case | no, neither |
|
||||
| R8 | pinned value changed: a hybrid's pinned entity is missing or its `field` differs from `value`, or a hybrid version of it sets `field` to another value | yes |
|
||||
|
||||
The front matter parser takes scalars, quoted strings, flow lists, block lists and wikilinks. Nested
|
||||
maps, anchors, block scalars and duplicate keys stop it with R3.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// only the citations, never what the text says. Codes are design 5.2's table.
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { git } from '../../project/src/git.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { classify, excluded } from './scope.mjs';
|
||||
import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs';
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { mirrorPath, readSyncState } from './mirror.mjs';
|
||||
@@ -94,6 +94,11 @@ export async function checkReply(body, { limits, mapping, library, web }) {
|
||||
const sources = hybrid ? mapping.universes[hybrid].sources : [];
|
||||
if (!hybrid || !universes.every(u => u === hybrid || sources.includes(u)))
|
||||
refuse('citation-cross-universe', `the citations span universes ${universes.join(', ')}`);
|
||||
// C2-15: beside a hybrid cite, a source path the hybrid excludes is outside the hybrid's world.
|
||||
citations.forEach((c, i) => {
|
||||
if (found[i] && found[i].universe !== hybrid && excluded(mapping, hybrid, c.path))
|
||||
refuse('citation-cross-universe', `citation ${i + 1} (${c.path}) is excluded from hybrid ${hybrid}`);
|
||||
});
|
||||
}
|
||||
citations.forEach((c, i) => {
|
||||
if (!Object.hasOwn(c, 'url')) return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// packages/library: her writing repo read by a mapping (Case 2, design 1 and 7.4). See README.md.
|
||||
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
|
||||
export { checkMapping, TYPES } from './mapping.mjs';
|
||||
export { classify } from './scope.mjs';
|
||||
export { classify, excluded } from './scope.mjs';
|
||||
export { supportingPath } from './supporting.mjs';
|
||||
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
|
||||
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// 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 { typeForm } from './scope.mjs';
|
||||
|
||||
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
@@ -144,6 +145,32 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a plain universe`);
|
||||
}
|
||||
}
|
||||
// C2-15 (design 1.4): each constraint against the hybrid's sources, now that every source is read. An
|
||||
// exclude reads as the type form `source/Type` or as a path in a source's book, never both. A path, or
|
||||
// a pinned entity, under a source's playground is one the hybrid never reads (REQ-LIB-3). Whether the
|
||||
// path exists at a commit is the reader's to check.
|
||||
for (const [id, universe] of Object.entries(universes)) {
|
||||
if (universe.kind !== 'hybrid') continue;
|
||||
const sources = universe.sources.map(source => [source, universes[source]]);
|
||||
const inSourceBook = path => sources.some(([, s]) => s.books.some(book => path === book || holds(book, path)));
|
||||
const underPlayground = path => sources.find(([, s]) => s.playground.some(p => path === p.slice(0, -1) || path.startsWith(p)))?.[0];
|
||||
const sourcePath = (path, at) => {
|
||||
if (!inSourceBook(path)) bad(`${at} ${JSON.stringify(path)} lies inside no book of the hybrid's sources`);
|
||||
const owner = underPlayground(path);
|
||||
if (owner) bad(`${at} ${JSON.stringify(path)} lies under a playground of universes.${owner}, which the hybrid never reads`);
|
||||
};
|
||||
universe.constraints.forEach((constraint, i) => {
|
||||
const at = `universes.${id}.constraints[${i}]`;
|
||||
if (constraint.pin) {
|
||||
if (!constraint.pin.entity.endsWith('.md')) bad(`${at}.pin.entity ${JSON.stringify(constraint.pin.entity)} is not a .md file`);
|
||||
return sourcePath(constraint.pin.entity, `${at}.pin.entity`);
|
||||
}
|
||||
const type = typeForm(entry, universe, constraint.exclude);
|
||||
if (type && inSourceBook(constraint.exclude))
|
||||
bad(`${at}.exclude ${JSON.stringify(constraint.exclude)} reads as both a source/type and a source path`);
|
||||
if (!type) sourcePath(constraint.exclude, `${at}.exclude`);
|
||||
});
|
||||
}
|
||||
const books = [...bookOwner.keys()];
|
||||
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
|
||||
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
|
||||
|
||||
@@ -13,6 +13,7 @@ export const RULES = Object.freeze({
|
||||
R5: 'canon file refers into the playground',
|
||||
R6: 'canon file refers into another universe',
|
||||
R7: 'paths differ only in case',
|
||||
R8: 'pinned value changed',
|
||||
});
|
||||
|
||||
export function problem(code, path, line, message = RULES[code]) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { raise } from './problems.mjs';
|
||||
import { TYPES } from './mapping.mjs';
|
||||
import { indexFor } from './index-build.mjs';
|
||||
import { listTree } from './reader.mjs';
|
||||
import { excluded } from './scope.mjs';
|
||||
|
||||
// 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;
|
||||
@@ -19,7 +20,6 @@ const SCOPE_RANK = Object.freeze({ supporting: 0, playground: 1, canon: 2 });
|
||||
|
||||
const bytes = value => JSON.stringify(value).length;
|
||||
const invalid = message => raise('query-argument-invalid', message);
|
||||
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
||||
|
||||
function textArg(value, name) {
|
||||
if (typeof value !== 'string') invalid(`${name} must be text`);
|
||||
@@ -47,13 +47,13 @@ function universeArg(mapping, value) {
|
||||
}
|
||||
|
||||
// Whether universe U may see a file. Its own files, in either scope. A hybrid also sees the canon of
|
||||
// its declared sources, minus the folders its exclude constraints name. A hybrid never sees a source's
|
||||
// playground. Pin constraints do not hide anything; they are reported by universeInfo only.
|
||||
// its declared sources, minus what its exclude constraints cover (scope.mjs excluded(), C2-15). A hybrid
|
||||
// never sees a source's playground. Pin constraints hide nothing; the reader reports them as R8.
|
||||
export function sees(mapping, universe, file) {
|
||||
if (file.universe === universe) return true;
|
||||
const entry = mapping.universes[universe];
|
||||
if (entry.kind !== 'hybrid' || !entry.sources.includes(file.universe) || file.scope !== 'canon') return false;
|
||||
return !entry.constraints.some(c => c.exclude !== undefined && inside(c.exclude, file.path));
|
||||
return !excluded(mapping, universe, file.path);
|
||||
}
|
||||
|
||||
// The files one universe may see in one scope, with optional book and type filters, in path order.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// way. Nothing in the repo is checked out or run (D7). Each R-rule is a problem, never a refusal
|
||||
// of the commit; the affected file or link is left out of the index.
|
||||
import { git, gitBytes } from '../../project/src/git.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { classify, excluded } from './scope.mjs';
|
||||
import { extractLinks, isAttachment, parseWikilink, resolver } from './links.mjs';
|
||||
import { LibraryError, problem, raise, sortProblems } from './problems.mjs';
|
||||
|
||||
@@ -169,7 +169,7 @@ function frontMatterLine(lines, bodyLine, field) {
|
||||
}
|
||||
|
||||
// Read a commit under a checked mapping (mapping.mjs). Returns the indexed files and every
|
||||
// R1-R7 problem: { commit, files: [...], problems: [...] }, frozen, sorted by path.
|
||||
// R1-R8 problem: { commit, files: [...], problems: [...] }, frozen, sorted by path.
|
||||
export async function readLibrary({ repo, commit, mapping }) {
|
||||
if (!mapping || mapping.kind !== 'library') throw new LibraryError('library-mapping-invalid', 'expected a checked library mapping');
|
||||
const entries = await listTree(repo, commit);
|
||||
@@ -206,17 +206,64 @@ export async function readLibrary({ repo, commit, mapping }) {
|
||||
if (file.scope === 'canon') {
|
||||
if (to.scope === 'playground') problems.push(problem('R5', file.path, link.line));
|
||||
const sources = mapping.universes[file.universe].sources ?? [];
|
||||
if (to.universe !== file.universe && !sources.includes(to.universe)) problems.push(problem('R6', file.path, link.line));
|
||||
// A hybrid's link into a path its exclude covers is R6 too (C2-15). An excluded path is always in a
|
||||
// source, so a link gets at most one R6.
|
||||
if ((to.universe !== file.universe && !sources.includes(to.universe)) || excluded(mapping, file.universe, to.path))
|
||||
problems.push(problem('R6', file.path, link.line));
|
||||
}
|
||||
kept.push({ ...link, path: target.path, step: target.step });
|
||||
}
|
||||
file.links = kept;
|
||||
delete file.stop;
|
||||
}
|
||||
problems.push(...pinProblems(mapping, files, byPath));
|
||||
files.sort((a, b) => a.path < b.path ? -1 : 1);
|
||||
return deepFreeze({ commit, files, problems: sortProblems(problems) });
|
||||
}
|
||||
|
||||
// R8 (C2-15, design 1.4): each pin of each hybrid. The pinned entity gets R8 when it isn't indexed (absent,
|
||||
// R1, R2 or R7) or its front matter doesn't hold `field` as a scalar whose text equals String(value); a
|
||||
// list, null, a missing key and R3 front matter all count as changed. A hybrid canon file is a version of
|
||||
// the entity when classify gives it the entity's type and its name matches: the trimmed front matter
|
||||
// `name` on both sides, or the file names when either has no text name. A version that sets `field` to
|
||||
// another value gets R8 on that key's line. A link alone never makes a version.
|
||||
function pinProblems(mapping, files, byPath) {
|
||||
const out = [];
|
||||
for (const [hybrid, entry] of Object.entries(mapping.universes)) {
|
||||
if (entry.kind !== 'hybrid') continue;
|
||||
for (const { pin } of entry.constraints.filter(c => c.pin)) {
|
||||
const entity = byPath.get(pin.entity);
|
||||
if (!entity) out.push(problem('R8', pin.entity));
|
||||
else if (!pinned(entity, pin)) out.push(problem('R8', pin.entity, keyLine(entity, pin.field)));
|
||||
const type = classify(mapping, pin.entity).type;
|
||||
const stem = pin.entity.split('/').pop().replace(/\.md$/, '');
|
||||
const name = entity ? textName(entity) : null;
|
||||
for (const file of files) {
|
||||
if (file.universe !== hybrid || file.scope !== 'canon' || file.type !== type) continue;
|
||||
const own = textName(file);
|
||||
if ((name !== null && own !== null ? own !== name : file.name !== stem)) continue;
|
||||
if (file.frontmatter && Object.hasOwn(file.frontmatter, pin.field) && !pinned(file, pin))
|
||||
out.push(problem('R8', file.path, keyLine(file, pin.field)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Two pins (or two hybrids) on one missing or changed entity give one entry per path and line (Filbert N1).
|
||||
const seen = new Set();
|
||||
return out.filter(p => { const key = `${p.path}\0${p.line ?? ''}`; return !seen.has(key) && seen.add(key); });
|
||||
}
|
||||
const textName = file => typeof file.frontmatter?.name === 'string' && file.frontmatter.name.trim() ? file.frontmatter.name.trim() : null;
|
||||
function pinned(file, { field, value }) {
|
||||
const held = file.frontmatter && Object.hasOwn(file.frontmatter, field) ? file.frontmatter[field] : undefined;
|
||||
return ['string', 'number', 'boolean'].includes(typeof held) && String(held) === String(value);
|
||||
}
|
||||
// The front matter line of `field` (bare or quoted key), or undefined.
|
||||
function keyLine(file, field) {
|
||||
if (file.bodyLine < 3) return undefined;
|
||||
const key = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const at = file.lines.slice(1, file.bodyLine - 2).find(([, t]) => new RegExp(`^["']?${key}["']?\\s*:`).test(t));
|
||||
return at?.[0];
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); for (const v of Object.values(value)) deepFreeze(v); }
|
||||
return value;
|
||||
|
||||
@@ -36,3 +36,26 @@ function place(mapping, path, under, where) {
|
||||
: mapping.manuscript.includes(folder) ? 'manuscript' : null;
|
||||
return { ...where, type, folder };
|
||||
}
|
||||
|
||||
// C2-15 (design 1.4): whether an `exclude` constraint of universe `hybrid` covers `path`. The type form
|
||||
// `source/Type` (first segment one of the hybrid's sources, the rest one key of `types`) covers that type
|
||||
// folder in each of the source's books. Otherwise the exclude is a path and covers itself and what lies
|
||||
// inside it. checkMapping keeps the two readings from both fitting. False for a universe that isn't a
|
||||
// hybrid. Reads only the path (D2).
|
||||
export function excluded(mapping, hybrid, path) {
|
||||
const entry = mapping.universes[hybrid];
|
||||
if (entry?.kind !== 'hybrid' || typeof path !== 'string') return false;
|
||||
return entry.constraints.some(c => {
|
||||
if (c.exclude === undefined) return false;
|
||||
const type = typeForm(mapping, entry, c.exclude);
|
||||
if (type) return mapping.universes[type.source].books.some(book => path.startsWith(`${book}/${type.folder}/`));
|
||||
return path === c.exclude || path.startsWith(`${c.exclude}/`);
|
||||
});
|
||||
}
|
||||
|
||||
// { source, folder } when `exclude` reads as the type form for this hybrid entry, else null.
|
||||
export function typeForm(mapping, entry, exclude) {
|
||||
const [source, ...rest] = exclude.split('/');
|
||||
const folder = rest.join('/');
|
||||
return entry.sources.includes(source) && rest.length === 1 && Object.hasOwn(mapping.types, folder) ? { source, folder } : null;
|
||||
}
|
||||
@@ -73,6 +73,25 @@ test('C2-06 independent universes refuse; a hybrid and only its sources pass', a
|
||||
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'));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// C2-15 (row 267): a hybrid universe end to end. A business-file-shaped mapping (the fixture mapping plus a
|
||||
// `cross` hybrid over harbor and second) on a repo in her layout's shape, read through the C2-05a queries,
|
||||
// once for each exclude form of design 1.4. Bodies are made up; the repos are built under the scratch area.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { excluded } from '../src/scope.mjs';
|
||||
import { find, get, search, universeInfo } from '../src/query.mjs';
|
||||
import { mapping, MARKER } from './helpers.mjs';
|
||||
import { scratchRepo, note } from './query-fixture.mjs';
|
||||
|
||||
const FILES = {
|
||||
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', `Grey eyes. ${MARKER}`),
|
||||
'Book One/Research/Ferry Logs.md': note('research', 'Ferry Logs', `Timetable. ${MARKER}`),
|
||||
'Book One/Playground/What If.md': note('summary', 'What If', `${MARKER} [[Mara Vell]]`),
|
||||
'Second World/Characters/Ilse.md': note('character', 'Ilse', `${MARKER} Ilse.`),
|
||||
'Second World/Places/Gate.md': note('place', 'Gate', `${MARKER} Gate.`),
|
||||
'Crossover/Chapters/01.md': `# Meeting\n${MARKER} [[Mara Vell]] meets [[Ilse]] at [[Gate]].\nShe reads [[Ferry Logs]].\n`,
|
||||
'Crossover/Places/Bridge.md': note('place', 'Bridge', `${MARKER} Bridge.`),
|
||||
};
|
||||
|
||||
// The two forms of one rule: the type form hides second's Characters folder; the path form hides harbor's
|
||||
// Research folder.
|
||||
const FORMS = {
|
||||
type: { exclude: 'second/Characters', hidden: 'Second World/Characters/Ilse.md' },
|
||||
path: { exclude: 'Book One/Research', hidden: 'Book One/Research/Ferry Logs.md' },
|
||||
};
|
||||
const NOTES = 'Ilse crosses over after book one.';
|
||||
const withCross = exclude => mapping(e => {
|
||||
e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'],
|
||||
constraints: [{ exclude }, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'name', value: 'Mara Vell' } }],
|
||||
notes: NOTES };
|
||||
return e;
|
||||
});
|
||||
const src = (ref, map) => ({ repo: ref.repo, commit: ref.commit, mapping: map });
|
||||
const ALL_CANON = ['Book One/Characters/Mara Vell.md', 'Book One/Research/Ferry Logs.md', 'Crossover/Chapters/01.md',
|
||||
'Crossover/Places/Bridge.md', 'Second World/Characters/Ilse.md', 'Second World/Places/Gate.md'];
|
||||
|
||||
test('C2-15 excluded(): the type form covers the type folder in each source book, the path form its own subtree', () => {
|
||||
const type = withCross('second/Characters'), path = withCross('Book One/Research');
|
||||
assert.equal(excluded(type, 'cross', 'Second World/Characters/Ilse.md'), true);
|
||||
assert.equal(excluded(type, 'cross', 'Second World/Characters/Deep/Ilse.md'), true);
|
||||
assert.equal(excluded(type, 'cross', 'Second World/Places/Gate.md'), false);
|
||||
assert.equal(excluded(type, 'cross', 'Book One/Characters/Mara Vell.md'), false);
|
||||
assert.equal(excluded(type, 'cross', 'second/Characters/Ilse.md'), false);
|
||||
assert.equal(excluded(path, 'cross', 'Book One/Research/Ferry Logs.md'), true);
|
||||
assert.equal(excluded(path, 'cross', 'Book One/Research'), true);
|
||||
assert.equal(excluded(path, 'cross', 'Book One/Research Notes/Other.md'), false);
|
||||
assert.equal(excluded(path, 'cross', 'Second World/Characters/Ilse.md'), false);
|
||||
// Not a hybrid, or no such universe: nothing is excluded.
|
||||
assert.equal(excluded(path, 'harbor', 'Book One/Research/Ferry Logs.md'), false);
|
||||
assert.equal(excluded(path, 'nowhere', 'Book One/Research/Ferry Logs.md'), false);
|
||||
});
|
||||
|
||||
for (const [form, { exclude, hidden }] of Object.entries(FORMS)) {
|
||||
test(`C2-15 ${form} form: find and search in the hybrid return its own files and the sources' canon, never a source playground or the excluded path`, async t => {
|
||||
const s = src(scratchRepo(t, FILES), withCross(exclude));
|
||||
const found = (await find(s, { universe: 'cross', query: '' })).results;
|
||||
assert.deepEqual(found.map(r => r.path), ALL_CANON.filter(p => p !== hidden));
|
||||
for (const r of found) assert.equal(r.universe, r.path.startsWith('Crossover/') ? 'cross' : r.path.startsWith('Book One/') ? 'harbor' : 'second');
|
||||
assert.deepEqual((await find(s, { universe: 'cross', query: '', scope: 'playground' })).results, []);
|
||||
const hits = (await search(s, { universe: 'cross', text: MARKER })).results.map(r => r.path);
|
||||
assert.ok(!hits.includes(hidden) && !hits.includes('Book One/Playground/What If.md'));
|
||||
assert.ok(hits.includes('Crossover/Places/Bridge.md'));
|
||||
assert.ok(hits.includes(form === 'type' ? 'Book One/Research/Ferry Logs.md' : 'Second World/Characters/Ilse.md'));
|
||||
assert.equal(await get(s, { universe: 'cross', path: hidden }), null);
|
||||
});
|
||||
|
||||
test(`C2-15 ${form} form: get keeps the hybrid's links into its sources and drops the excluded target`, async t => {
|
||||
const s = src(scratchRepo(t, FILES), withCross(exclude));
|
||||
const chapter = await get(s, { universe: 'cross', path: 'Crossover/Chapters/01.md' });
|
||||
const out = chapter.links_out.map(l => l.path);
|
||||
assert.ok(out.includes('Book One/Characters/Mara Vell.md'));
|
||||
assert.ok(out.includes('Second World/Places/Gate.md'));
|
||||
assert.ok(!out.includes(hidden));
|
||||
});
|
||||
}
|
||||
|
||||
test('C2-15 a source universe never sees a hybrid file', async t => {
|
||||
const s = src(scratchRepo(t, FILES), withCross('Book One/Research'));
|
||||
for (const universe of ['harbor', 'second']) {
|
||||
assert.ok(!(await find(s, { universe, query: '' })).results.some(r => r.path.startsWith('Crossover/')), universe);
|
||||
assert.ok(!(await search(s, { universe, text: MARKER })).results.some(r => r.path.startsWith('Crossover/')), universe);
|
||||
assert.equal(await get(s, { universe, path: 'Crossover/Chapters/01.md' }), null);
|
||||
}
|
||||
const mara = await get(s, { universe: 'harbor', path: 'Book One/Characters/Mara Vell.md' });
|
||||
assert.ok(!mara.links_in.some(l => l.path.startsWith('Crossover/')));
|
||||
});
|
||||
|
||||
test('C2-15 universeInfo returns the hybrid\'s sources, constraints and notes', async t => {
|
||||
const s = src(scratchRepo(t, FILES), withCross('second/Characters'));
|
||||
const info = await universeInfo(s, { universe: 'cross' });
|
||||
assert.equal(info.kind, 'hybrid');
|
||||
assert.deepEqual(info.sources, ['harbor', 'second']);
|
||||
assert.deepEqual(info.constraints, [{ exclude: 'second/Characters' },
|
||||
{ pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'name', value: 'Mara Vell' } }]);
|
||||
assert.equal(info.notes, NOTES);
|
||||
});
|
||||
@@ -212,6 +212,34 @@ test('a hybrid universe is a shape the query code can take (1.4), with sources t
|
||||
refuses(e => { hybrid(e); e.universes.cross.constraints = [{ exclude: 'x', pin: {} }]; return e; }, 'library-mapping-invalid', /exactly one/);
|
||||
});
|
||||
|
||||
test('C2-15: each exclude and pin is checked against the hybrid\'s sources', () => {
|
||||
const cross = (...constraints) => e => {
|
||||
e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'], constraints };
|
||||
return e;
|
||||
};
|
||||
const pin = entity => ({ pin: { entity, field: 'eyes', value: 'grey' } });
|
||||
// Both forms, a whole source book and a pin in a source book check.
|
||||
assert.equal(mapping(cross({ exclude: 'second/Characters' }, { exclude: 'Book One/Research' }, { exclude: 'Second World' },
|
||||
pin('Second World/Characters/Ilse.md'))).universes.cross.constraints.length, 4);
|
||||
// Both readings fit: second's book folder is also named "second".
|
||||
refuses(e => { cross({ exclude: 'second/Characters' })(e); e.universes.second.books = ['second']; return e; },
|
||||
'library-mapping-invalid', /constraints\[0\]\.exclude "second\/Characters" reads as both a source\/type and a source path/);
|
||||
// Neither fits: an unmapped path, the hybrid's own book, a type that isn't a key, a non-source universe.
|
||||
for (const exclude of ['Elsewhere/Characters', 'Crossover/Characters', 'second/Ghosts', 'harbor', 'Book'])
|
||||
refuses(cross({ exclude }), 'library-mapping-invalid', /\.exclude ".*" lies inside no book of the hybrid's sources/);
|
||||
refuses(e => { cross({ exclude: 'third/Characters' })(e); e.universes.third = { name: 'Third', books: ['Third World'] }; return e; },
|
||||
'library-mapping-invalid', /lies inside no book of the hybrid's sources/);
|
||||
// Under a source's playground, as the prefix itself or inside it.
|
||||
for (const exclude of ['Book One/Playground', 'Book One/Playground/Ideas'])
|
||||
refuses(cross({ exclude }), 'library-mapping-invalid', /\.exclude ".*" lies under a playground of universes\.harbor, which the hybrid never reads/);
|
||||
refuses(cross(pin('Book One/Playground/What If.md')), 'library-mapping-invalid',
|
||||
/\.pin\.entity "Book One\/Playground\/What If\.md" lies under a playground of universes\.harbor/);
|
||||
refuses(cross(pin('Crossover/Characters/Mara Vell.md')), 'library-mapping-invalid', /\.pin\.entity .* lies inside no book of the hybrid's sources/);
|
||||
// Q5: a pinned entity is one .md note.
|
||||
for (const entity of ['Book One/Characters/Mara Vell', 'Book One/Characters', 'Book One/Board.canvas'])
|
||||
refuses(cross(pin(entity)), 'library-mapping-invalid', /\.pin\.entity ".*" is not a \.md file/);
|
||||
});
|
||||
|
||||
test('a caller-supplied fail receives the code, as business file loading will pass it (C2-04)', () => {
|
||||
const seen = [];
|
||||
const fail = (code, message) => { seen.push(code); throw new Error(message); };
|
||||
|
||||
@@ -3,7 +3,7 @@ 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, mapping, repo } from './helpers.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);
|
||||
@@ -150,6 +150,81 @@ test('R6: a canon file that refers into another universe is reported; a hybrid m
|
||||
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');
|
||||
|
||||
@@ -72,7 +72,7 @@ test('with --mapping the survey adds scope, resolver step and problem counts', a
|
||||
assert.deepEqual(tail, [
|
||||
'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',
|
||||
'resolved root 2, vault-prefix 1, relative 2, shortest 3, unresolved 0',
|
||||
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0']);
|
||||
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0, R8 0']);
|
||||
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
|
||||
});
|
||||
|
||||
|
||||
@@ -527,3 +527,18 @@ test('C2-03b T5 helper: the named seat\'s user and file, never a link, 0644, ano
|
||||
assert.ok(statSync(marker).isFile(), 'the swap ran');
|
||||
assert.equal(readFileSync(file, 'utf8'), 'fixture-token-other-0047\n', 'the slot path names the other file now');
|
||||
});
|
||||
|
||||
test('C2-15 sage-4389 (a): an excluded-link R6 is written to the sync state with the default message and reads back', async t => {
|
||||
const w = await world(t);
|
||||
// context.business.projects.books is this same entry, so the run checks the mapping with the hybrid in it.
|
||||
w.entry.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'],
|
||||
constraints: [{ exclude: 'Book One/Places' }] };
|
||||
const next = w.push({ 'Crossover/Chapters/A.md': 'See [[Harbor Town]].\n' });
|
||||
const { state, code } = await w.run();
|
||||
assert.equal(code, null);
|
||||
assert.equal(state.served, next);
|
||||
const read = await w.state();
|
||||
assert.equal(read.served, next);
|
||||
assert.deepEqual(read.problems.filter(p => p.path === 'Crossover/Chapters/A.md'),
|
||||
[{ code: 'R6', path: 'Crossover/Chapters/A.md', line: 1, message: 'canon file refers into another universe' }]);
|
||||
});
|
||||
Reference in new issue
Block a user