Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
996c79524f | ||
|
|
5b08b5d31d | ||
|
|
5acc9e89df | ||
|
|
19467323b7 | ||
|
|
e4da5d4e31 | ||
|
|
e553e02a75 | ||
|
|
d95be10ec4 | ||
|
|
3e39689c10 | ||
|
|
dca5f10cca | ||
|
|
ecb7d435e5 |
No files matched your search
@@ -5816,3 +5816,5 @@ Before: agents can read the library; supporting-path and push-credential depende
|
||||
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
|
||||
|
||||
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
|
||||
|
||||
2026-11-02T19:04:01Z Lead sub-agent (Claude Sonnet 5.5) ROW 173 R3 #1671 (Seen-intent test fix on lead/landing-batch1, base 09a847eda): BEFORE `MOSAIC_REQUIRE_EDITOR_BUILD=1 node --test packages/webui/tests/board-held.test.mjs` gave 4 tests, 3 pass, 1 fail ("a Seen or Unsee click that waited out a Board read keeps its meaning"), timing out at board-held.test.mjs:207 on the sessions table and held rows before any click. CAUSE: the row 173 test called setup(false), which starts the page with Show machine sessions off, so table.sessions never renders; its sibling at :168 uses setup(true). Not base drift: the same single test fails identically on a detached checkout of dewey/row173-r2 tip 6d15eb5fc20a (based on 78b69e1e6). The test had never run (BUILD.md: "no runs yet"). FIX: board-held.test.mjs:198 setup(false) to setup(true), one token; app.js unchanged. AFTER the test passes (1/1); whole board-held file 4 tests, 4 pass, 0 fail; whole webui package `node --test tests/*.test.mjs` 491 tests, 491 pass, 0 fail, 0 skipped. Teeth check: with app.js reverted to the pre-row-173 behavior (no intent check, seen: !r.seen) the test fails at the announce wait; app.js restored byte for byte. Hash-pin grep (packages/*/tests/*, 33 hex lines, positive control account-migration pin found): no pin names board-held.test.mjs or app.js. Comparison worktree and links removed. Not run: scripts/test-*.sh (lead gate). Corrects the r2 BUILD.md claim of no runs; no earlier entry edited.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Row 211 (#1709, Vikunja P19 #426): a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY
|
||||
|
||||
Darkwing, 2026-10-22. Brief: `docs/plans/2026-10-20_force-push-guard.md` (blob 3a8c2cf13). Moved from Rocko
|
||||
to me by sage-2801 (Q220, mos-1301). Base: 78b69e1e6 (r2; r1 was aca16404c). Branch: `darkwing/row211-r2`
|
||||
(r1 `darkwing/row211` 6db25b1f0 stays as pushed). Reviewer: Dewey. Gate owner: Sage.
|
||||
|
||||
## What ships
|
||||
|
||||
- `scripts/git-hooks/pre-push` (new, `/bin/sh`). For each ref line on stdin it refuses:
|
||||
- a delete (local sha all zeros);
|
||||
- a remote tip that isn't in this repository (`git cat-file -e <remote>^{commit}`), with "fetch first,
|
||||
then push again";
|
||||
- a remote tip that isn't an ancestor of the local tip (`git merge-base --is-ancestor`), naming the ref
|
||||
and both shas;
|
||||
- an ancestor check that fails for any other reason (exit above 1);
|
||||
- a short or overlong ref line, or a non-hex object name.
|
||||
|
||||
A new ref (remote sha all zeros) passes, and so does an empty push. It fetches nothing. On any refusal
|
||||
it prints one line per refused ref and a closing line, and exits 1, so git pushes no ref at all.
|
||||
- `scripts/install-git-hooks.sh` (new). Installs the hook as `pre-push` in the common `.git/hooks`
|
||||
(`git rev-parse --git-common-dir`), so a `--repo` naming a worktree lands in the shared directory.
|
||||
It refuses (exit 2):
|
||||
- a set `core.hooksPath` at any scope;
|
||||
- a different installed `pre-push`, unless `--force-install` is given;
|
||||
- a symlinked `pre-push`, even with `--force-install`;
|
||||
- any of `GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY` or
|
||||
`GIT_ALTERNATE_OBJECT_DIRECTORIES` in the environment;
|
||||
- a directory that isn't a git checkout.
|
||||
|
||||
It never writes the installed file in place. A new hook goes in by `link()` from a temporary copy, so
|
||||
a hook that appeared meanwhile is kept and the install refuses. A replacement goes in by `rename()`.
|
||||
Then it compares the bytes, checks the mode, and runs a canary through `git hook run` (git 2.55 here):
|
||||
an empty push must pass and a delete line must be refused with the guard's message.
|
||||
- `scripts/slot-busy.sh` (new). Exit 0 CLEAR, 1 BUSY, 2 its own error, one `BUSY <pid> <rule> cwd=<cwd>
|
||||
<command>` line per match. Rules: `node ... --test`, `*gate*.sh` (after a space, `/`, `_` or `-`), a
|
||||
`mutant` substring, `test-*.sh`, `docker compose` and `docker-compose`. It skips its own pid and its
|
||||
ancestors, empty command lines (kernel threads) and shell-snapshot wrappers.
|
||||
- `scripts/test-git-guard.sh` (new), 61 cases, and three Maintenance rows plus a short section in
|
||||
`docs/TOOLS.md`.
|
||||
|
||||
## Choices
|
||||
|
||||
1. **A separate `scripts/install-git-hooks.sh`, not `queue-commit.sh --install-push-hook`.** The brief
|
||||
left this open. `queue-commit.sh --install-hook` is the queue guard's installer. It is tied to the
|
||||
canonical checkout and to the lead (jason/sage) and runs the queue guard's canary. The push guard has
|
||||
neither tie: it must also be installable in a scratch clone for the tests, and every seat worktree
|
||||
shares it. Putting it in `queue-commit.sh` would widen that script's reach for no gain. The cost is a
|
||||
second installer with a similar shape. The install pattern (copy beside, link or rename, verify,
|
||||
canary) follows `queue-commit.sh`.
|
||||
2. **No privilege check in the installer.** The brief makes the canonical install a separate step with
|
||||
Sage after landing, and the tests need to install into scratch clones as the suite's user. So the
|
||||
script installs wherever it is told. The guard is advisory on the client in any case (`--no-verify`).
|
||||
3. **The hook source is the working file next to the installer** (`$ROOT/scripts/git-hooks/pre-push`),
|
||||
not a blob from a ref. That lets the mutant runner install a mutated copy from its own tree, and
|
||||
matches how `queue-commit.sh` reads its guard.
|
||||
4. **A new `scripts/test-git-guard.sh`, not more cases in `test-land-work.sh`.** The brief allowed either.
|
||||
Land-work's suite is about `land-work.sh`, and the gate runs every `scripts/test-*.sh`, so a new file
|
||||
gets gated with no wiring.
|
||||
5. **`--proc-root DIR` is a test seam in `slot-busy.sh`.** On a live host the real `/proc` may hold
|
||||
other seats' runs at any time, so the suite can't demand exit 0 there. Exit 0 and every rule are
|
||||
tested against a fake `/proc` with pids above `pid_max` (4194304), so they can't collide with the
|
||||
ancestor set, which always comes from the real `/proc`. The real `/proc` is tested with a stand-in:
|
||||
a `/bin/sh` script named `node`, started as `node --test standin.test.mjs` in its own cwd. The suite
|
||||
asserts its BUSY line with that cwd, exit 1, that `slot-busy > /dev/null && echo ran` prints nothing,
|
||||
that the suite itself (an ancestor whose command line matches `test-*.sh`) has no BUSY line, and that
|
||||
after the stand-in exits its pid is gone. The brief's "0 without one" is the fake-`/proc` case.
|
||||
6. **Ancestors are skipped**, not only its own pid as the brief says. A gate script that runs the busy
|
||||
check matches its own rules (`*gate*.sh`, `test-*.sh`, `mutant`), so checking only `$$` would make
|
||||
every such caller see itself as BUSY.
|
||||
7. **The gate rule is broader than `gate.sh`**: `[ /_-]gate[^ /]*\.sh `, so `sage-gate.sh` and
|
||||
`gate-253.sh` match too. A false BUSY costs a wait; a false CLEAR costs a broken run.
|
||||
8. **Shell-snapshot wrappers are skipped.** An agent harness runs each command as `bash -c 'source
|
||||
.../shell-snapshots/snapshot-*.sh && eval <command text>'`, so the wrapper's command line holds the
|
||||
text of whatever the agent typed, including a busy check that names `test-*.sh`. The real child
|
||||
processes the wrapper starts are still matched.
|
||||
|
||||
## Tests (`scripts/test-git-guard.sh`)
|
||||
|
||||
Scratch bare remote, a scratch clone with the guard installed, and a worktree of that clone. Git runs
|
||||
with `GIT_CONFIG_NOSYSTEM=1` and an empty `GIT_CONFIG_GLOBAL`, as in `test-land-work.sh`. Nothing touches
|
||||
origin, `~/.mosaic`, `~/.mosaic-dev` or `~/.config/mosaic-dev`.
|
||||
|
||||
- Install: exit 0, the canary passed, bytes and mode, no temporary file left, and the worktree's git
|
||||
resolves `hooks/pre-push` to the common file.
|
||||
- From the worktree: a new branch and a fast-forward pass. A `--force` push of a rewritten branch, a
|
||||
`+refspec` push and a `--force-with-lease` push are refused by the guard's message, naming the ref and
|
||||
both shas, and the remote ref is unchanged. `:ref` and `--delete` are refused and the branch stays.
|
||||
- A second clone moves the remote; a `--force` push from the worktree is then refused with "fetch
|
||||
first", the remote stays where the other clone put it, and the guard fetched nothing.
|
||||
- The hook fed by hand: a short line, a non-hex name and an unknown local object are refused; an empty
|
||||
push passes.
|
||||
- The installer: `core.hooksPath` set refuses with that message and writes no hook (in `.git/hooks` or
|
||||
the hooks path). A different hook refuses and stays byte-identical. `--force-install` replaces it, and
|
||||
a hard link to the old file keeps the old bytes, so the replacement was a rename. A second install of
|
||||
the same bytes exits 0. A symlinked hook refuses even with `--force-install`. A `--repo` naming a
|
||||
worktree lands in the common directory. `GIT_DIR` set, a plain directory and an unknown argument
|
||||
refuse.
|
||||
- `slot-busy.sh`, fake `/proc`: CLEAR with exit 0 when nothing matches (a server, a `grep gate`, a
|
||||
snapshot wrapper whose text holds `node --test`, an empty command line). Exit 1 and the BUSY line for
|
||||
`node --test`, with no CLEAR in the output, and `> /dev/null && echo ran` prints nothing. Each rule
|
||||
once more (7 cases). Exit 2 for a missing root, an empty root and an unknown argument.
|
||||
- `slot-busy.sh`, real `/proc`: the stand-in cases in choice 5.
|
||||
|
||||
## Mutants (`agents/darkwing/work/queue-211/mutants.sh`)
|
||||
|
||||
Each mutant copies the four scripts into its own tree, edits one site there (exactly one match), and runs
|
||||
that tree's suite under `timeout -k 10 180`. The worktree's files are never edited. The unmutated suite
|
||||
runs first and a red base stops the run. The brief's five come first:
|
||||
|
||||
| Mutant | Site |
|
||||
|---|---|
|
||||
| ancestor-dropped | pre-push: `rc=0` in place of the `merge-base --is-ancestor` call |
|
||||
| unknown-object-passes | pre-push: the missing-remote-tip branch skips the ref without refusing |
|
||||
| delete-passes | pre-push: the delete branch skips the ref without refusing |
|
||||
| install-over-hookspath | installer: the `core.hooksPath` refusal is `true \|\| die` |
|
||||
| busy-exits-0 | slot-busy: `exit 0` when BUSY |
|
||||
| differing-hook-replaced | installer: a different hook is replaced without `--force-install` |
|
||||
| replace-in-place | installer: the replacement writes into the installed file (`cat >`) instead of renaming |
|
||||
| symlink-followed | installer: no symlink refusal |
|
||||
| ancestor-error-passes | pre-push: an ancestor check exiting above 1 doesn't refuse |
|
||||
| snapshot-matched | slot-busy: no shell-snapshot skip |
|
||||
| ancestors-matched | slot-busy: no ancestor skip |
|
||||
| no-node-rule | slot-busy: the `node --test` rule removed |
|
||||
| no-compose-rule | slot-busy: the `docker compose` rule removed |
|
||||
|
||||
`install-over-hookspath` still exits 2, because its canary then fails (git looks for the hook in the hooks
|
||||
path). The suite kills it on the message and on "no hook was written", not on the exit code.
|
||||
|
||||
## Rebase r2 (sage-4362, 2026-10-24)
|
||||
|
||||
r1 6db25b1f0 sat on aca16404c, 555 commits behind refactor, and overlapped it in `docs/TOOLS.md`. r2
|
||||
cherry-picks it onto 78b69e1e6 as a new commit on a new branch; no force-push. The TOOLS.md hunk merged
|
||||
without a conflict, `git patch-id --stable` is the same for both commits (db451e11), and the four scripts,
|
||||
`mutants.sh` and this file's other text are byte-identical to r1, so no assertion or reviewed line
|
||||
changed. On the new base none of the six paths existed before, `scripts/git-hooks/` holds only the queue
|
||||
guard's `pre-commit` (untouched here), and no test reads or pins `docs/TOOLS.md`.
|
||||
|
||||
## Runs
|
||||
|
||||
Pending: nothing has run yet. Gander held the gate slot (row 253 run 5, from 16:32Z) while this was
|
||||
written, so only `bash -n`, `sh -n` and a `DRY=1` mutant pass (every site matched once, no suite run)
|
||||
happened. The suite, the mutants and every other `scripts/test-*.sh` run in a CLEAR Sage announces, with
|
||||
`TMPDIR` under `/mnt/storage/scratch/tmp`. Results and the measured suite time go here before review.
|
||||
|
||||
## Known limits
|
||||
|
||||
- `git push --no-verify` skips the hook. Gitea branch protection (ops-01, through Mos) is the guard that
|
||||
holds.
|
||||
- The hook doesn't check tags differently from branches: a moved tag is refused like a rewritten branch.
|
||||
- `slot-busy.sh` can call a process BUSY that isn't a run, for example an editor with a `test-*.sh` open.
|
||||
It never reads files under a seat's directory, so it can't tell.
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 211 (#426) mutants. Run from the candidate worktree's root:
|
||||
# TMPDIR=<scratch dir> LOG=<dir> bash agents/darkwing/work/queue-211/mutants.sh > mutants.txt
|
||||
# Each mutant copies the four row-211 scripts into its own tree, edits one
|
||||
# site there (exactly one match, else NO MATCH), and runs that tree's
|
||||
# scripts/test-git-guard.sh under a 180 s timeout. The worktree's files are
|
||||
# never edited. "killed" means the suite failed; "survived" means it passed.
|
||||
# The unmutated suite runs first, and a red base stops the run (exit 3).
|
||||
set -u
|
||||
LOG=${LOG:-.}
|
||||
FILES="scripts/git-hooks/pre-push scripts/install-git-hooks.sh scripts/slot-busy.sh scripts/test-git-guard.sh"
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/r211-mutants.XXXXXX") || exit 2
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
tree() { # $1: name. Prints the copy's root.
|
||||
local t="$WORK/$1" f
|
||||
for f in $FILES; do mkdir -p "$t/$(dirname "$f")" && cp -p -- "$f" "$t/$f" || return 1; done
|
||||
echo "$t"
|
||||
}
|
||||
suite() { # $1: tree, $2: log
|
||||
timeout -k 10 180 bash "$1/scripts/test-git-guard.sh" > "$2" 2>&1
|
||||
}
|
||||
edit() { # $1: file, $2: from, $3: to. Exit 3 unless $2 occurs exactly once.
|
||||
python3 -B - "$1" "$2" "$3" <<'PY'
|
||||
import sys
|
||||
path, old, new = sys.argv[1:4]
|
||||
text = open(path).read()
|
||||
if text.count(old) != 1:
|
||||
sys.exit(3)
|
||||
open(path, "w").write(text.replace(old, new))
|
||||
PY
|
||||
}
|
||||
|
||||
base=$(tree base) || exit 2
|
||||
if [ -n "${DRY:-}" ]; then
|
||||
echo "base: not run (DRY)"
|
||||
elif suite "$base" "$LOG/mut-base.log"; then
|
||||
echo "base: green ($(tail -1 "$LOG/mut-base.log"))"
|
||||
else
|
||||
echo "base: RED, stopping ($(tail -1 "$LOG/mut-base.log"))"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
run() { # $1: name, $2: file, $3: from, $4: to
|
||||
local name=$1 file=$2 t log
|
||||
t=$(tree "$name") || { echo "$name: COPY FAILED"; return; }
|
||||
if ! edit "$t/$file" "$3" "$4"; then echo "$name: NO MATCH"; return; fi
|
||||
if [ -n "${DRY:-}" ]; then echo "$name: match"; return; fi
|
||||
log="$LOG/mut-$name.log"
|
||||
if suite "$t" "$log"; then
|
||||
echo "$name: survived"
|
||||
else
|
||||
echo "$name: killed (exit $?, $(grep -c '^FAIL' "$log") FAIL; first: $(grep -m1 '^FAIL' "$log" | cut -c6-100))"
|
||||
fi
|
||||
}
|
||||
H=scripts/git-hooks/pre-push
|
||||
I=scripts/install-git-hooks.sh
|
||||
S=scripts/slot-busy.sh
|
||||
|
||||
# The brief's five.
|
||||
run ancestor-dropped $H $' git merge-base --is-ancestor "$rsha" "$lsha" 2>/dev/null\n rc=$?' ' rc=0'
|
||||
run unknown-object-passes $H $' refuse "$rref: the remote tip $rsha isn\'t in this repository; fetch first, then push again"\n continue' ' continue'
|
||||
run delete-passes $H $' refuse "$rref: a delete (remote $rsha); seat branches are never deleted by push"\n continue' ' continue'
|
||||
run install-over-hookspath $I '[ -z "$out" ] || die 2 "refused: core.hooksPath' '[ -z "$out" ] || true || die 2 "refused: core.hooksPath'
|
||||
run busy-exits-0 $S 'if [ "$busy" = 1 ]; then exit 1; fi' 'if [ "$busy" = 1 ]; then exit 0; fi'
|
||||
# Further sites the suite pins.
|
||||
run differing-hook-replaced $I '[ "$FORCE" = 1 ] || die 2 "refused: a different' 'true || die 2 "refused: a different'
|
||||
run replace-in-place $I 'mv -f -- "$tmp" "$HOOK" ||' 'cat -- "$tmp" > "$HOOK" && rm -f -- "$tmp" ||'
|
||||
run symlink-followed $I 'if [ -L "$HOOK" ]; then die 2' 'if false; then die 2'
|
||||
run ancestor-error-passes $H ' *) refuse "$rref: the ancestor check failed' ' *) : "$rref: the ancestor check failed'
|
||||
run snapshot-matched $S $' if [[ "$c" == *"/shell-snapshots/snapshot-"* ]]; then return 1; fi\n' ''
|
||||
run ancestors-matched $S ' [ -z "${SKIP[$pid]:-}" ] || continue' ' :'
|
||||
run no-node-rule $S 'if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi' ':'
|
||||
run no-compose-rule $S 'if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi' ':'
|
||||
@@ -0,0 +1,94 @@
|
||||
# Row 173 (#1671): a Seen click that waited out a Board read keeps the meaning it was clicked with
|
||||
|
||||
Brief: docs/plans/2026-10-19_seen-click-intent.md (blob 225ac823b). Branch dewey/row173 off aca16404c.
|
||||
Queue reviewer: Darkwing. Gate (queue row): Darkwing approves on #1671 naming the candidate manifest; webui and
|
||||
every scripts/test-*.sh green on Sage's gate rerun.
|
||||
|
||||
## Design
|
||||
|
||||
- Intent. `seenButton` draws `data-intent="seen"` on a Seen button and `data-intent="unsee"` on an Unsee
|
||||
button, from the same `r.seen` that picks the label. The click handler reads it into `mark` before
|
||||
`writing = true` and before the wait, so the fresh row can't change it. I chose a `data-` value over reading the
|
||||
label text so a later relabel can't change what a click posts.
|
||||
- The fresh row already holds the intent (`r.seen === mark`). This happens when another viewer marked or unmarked
|
||||
the row while the read was in flight. Nothing is posted, the button is re-enabled, and the page announces
|
||||
`<agent> already seen` or `<agent> already waiting`.
|
||||
- Why post nothing. The route takes a repeat without harm: `markSeen` with `seen:true` rewrites the same mark,
|
||||
and `seen:false` deletes a key that is already gone (control-board/src/scan.mjs:255-256). So posting the same
|
||||
value would also be correct. It would cost a write and a rescan, though, and the read that just finished has
|
||||
already drawn the row. The brief allows either.
|
||||
- An unseen row can still hold an old mark for an earlier lastActivity. Posting nothing on Unsee leaves that
|
||||
mark, which is harmless because `scanAgent` only counts a mark equal to the row's lastActivity (scan.mjs:340).
|
||||
- Otherwise the click posts `seen: mark` and announces from `mark`, not from the fresh row.
|
||||
- Unchanged: the row 172 wait, the single-click guard (`writing`), the no-row branch, and the `/api/seen` route
|
||||
(out of scope).
|
||||
- `seen.disabled = false` in the new branch matches the no-row branch. After the read, `accept` redraws the
|
||||
inspector and the cards, so `seen` is usually a detached button and the line has no visible effect. No test
|
||||
can tell it apart; see M5.
|
||||
|
||||
## Test
|
||||
|
||||
packages/webui/tests/board-held.test.mjs gains one test: "a Seen or Unsee click that waited out a Board read
|
||||
keeps its meaning: another viewer's mark in that read posts nothing". `setup` returns `boardURL: f.boardURL` so
|
||||
the test can post to the board as the other viewer.
|
||||
- Steps, for each of Seen and Unsee:
|
||||
- it holds `/api/board` with `window.__hold` and clicks Refresh;
|
||||
- while the read is held, it posts the other viewer's mark straight to the board;
|
||||
- it clicks the inspector's button and releases the read.
|
||||
- Checks: the announcement (`agent1 already seen`, then `agent1 already waiting`), `#seen-count` (1, then 0), and
|
||||
that the page sent no `/api/seen` at all.
|
||||
- The held fetch hasn't reached the board when the other viewer posts (the stub awaits the hold before calling
|
||||
the real fetch), so the released answer carries the other viewer's mark.
|
||||
|
||||
Hash pins and readers checked (git grep, with a positive control for 64-hex): nothing pins app.js. shell.test
|
||||
counts existing app.js strings only. browser.test, sso-browser.test and the other board-held tests click
|
||||
`[data-seen]` buttons the page draws, so they get `data-intent` with them.
|
||||
|
||||
## Mutants (run on CLEAR only; each one edit to app.js, then board-held.test.mjs whole)
|
||||
|
||||
| id | edit | expected kill |
|
||||
|----|------|---------------|
|
||||
| M1 | post `seen: !r.seen` and drop the `r.seen === mark` branch (the old toggle) | new test (a post, and "returned to waiting") |
|
||||
| M2 | drop the `r.seen === mark` branch only (post the repeat) | new test (a post, and "marked seen") |
|
||||
| M3 | `seenButton` swaps the intent (`r.seen ? 'seen' : 'unsee'`) | row 172 test (`#seen-count` never 1) and the new test |
|
||||
| M4 | the new branch returns without `announce` | new test (announcement wait times out) |
|
||||
| M5 | drop `seen.disabled = false` in the new branch | equivalent: the button is redrawn by `accept`; named, not run |
|
||||
|
||||
Each run: `timeout -k 10 180 node --test --test-force-exit packages/webui/tests/board-held.test.mjs`; a kill
|
||||
counts only as an assertion or a named wait failing in the named test.
|
||||
|
||||
## Runs
|
||||
|
||||
None yet. Gander holds the gate slot (row 253 run 5) from 16:32Z. The claim, the push and every run wait for
|
||||
Sage's CLEAR.
|
||||
|
||||
## Round 2: rebase onto 78b69e1e6 (sage-4361)
|
||||
|
||||
- Branch dewey/row173-r2 off refactor 78b69e1e6. The round 1 commit 25ff7cd3f (on aca16404c, 555 commits
|
||||
behind) was cherry-picked as a new commit; dewey/row173 is untouched. No force-push.
|
||||
- The pick merged on its own. `git range-diff aca16404c..25ff7cd3f 78b69e1e6..<pick>` reports the commit equal.
|
||||
- Overlap with refactor: one app.js line, the `/api/config` handler at the end of the file (federation render).
|
||||
It is far from both row 173 hunks and changes no assertion or reviewed line. board-held.test.mjs and the brief
|
||||
(blob 225ac823b) are unchanged on refactor.
|
||||
- No test pins app.js by hash (grepped tests for app.js plus 64-hex).
|
||||
|
||||
### Run list
|
||||
|
||||
app.js is a page script, loaded by index.html, not imported by any module. Its test readers:
|
||||
- by name: board-held, browser, granted-user, serve, shell, sso-browser, user-option-swap;
|
||||
- by page load: every webui test that opens the Console through tests/browser.mjs (31 test files).
|
||||
No test outside packages/webui reads or serves app.js (`git grep` over packages/*/tests and scripts: none).
|
||||
|
||||
Order, each run with `TMPDIR` from `mktemp -d /mnt/storage/scratch/tmp/d173XXXXXX` and absolute paths:
|
||||
1. M1-M4 below, each with board-held.test.mjs whole.
|
||||
2. board-held.test.mjs whole on the restored tree.
|
||||
3. packages/webui whole (all 70 test files), which covers both reader groups.
|
||||
case1-accept.test.mjs is not in the list (mos-1612). Runs need Sage's CLEAR.
|
||||
|
||||
### Mutant procedure
|
||||
|
||||
The M1-M5 table above stands. M5 stays equivalent: the new branch is reached only after the waited-out Board
|
||||
read's `accept` (refresh, app.js:372) has redrawn the row, so `seen` is the detached old button. Each mutant is one
|
||||
edit to app.js. Before the next mutant, the restored app.js must match the candidate's sha256. Each run is in its
|
||||
own process group so a timeout kill takes its browser with it. Afterwards I sweep for orphaned node/chromium
|
||||
processes in my run directory.
|
||||
@@ -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.
|
||||
@@ -1879,3 +1879,5 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
|
||||
|
||||
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
|
||||
|
||||
2026-11-02 | Lead sub-agent (Claude Sonnet 5.5) | row173 R3 (#1671) Seen-intent test fix | Cause: test called setup(false) (machine sessions off) and waited for table.sessions; sibling uses setup(true); not base drift (tip 6d15eb5fc20a fails the same). Fixed board-held.test.mjs:198 only; app.js unchanged. Test 1/1, board-held 4/4, whole webui 491/491; mutant (old intent behavior) fails the test. Local commit on lead/landing-batch1, no push.
|
||||
@@ -166,8 +166,37 @@ resolved through the CLI.
|
||||
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
|
||||
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
|
||||
| `scripts/test-land-work.sh` | Land-work selftests (sandboxed, fake Docker) | 84 cases |
|
||||
| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 61 cases |
|
||||
| `scripts/install-git-hooks.sh [--repo DIR] [--force-install]` | Install the push guard as `pre-push` in the common `.git/hooks` | Refuses a set `core.hooksPath` or a different installed hook; exit 0 ok · 1 failed · 2 refused · 4 usage |
|
||||
| `scripts/slot-busy.sh` | Busy check: one `BUSY` line per gate or test process, or `CLEAR` | Exit 0 CLEAR · 1 BUSY · 2 error; chain on the exit code |
|
||||
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper; reads the JSON credential file or a raw per-seat token file | Token never on argv/stdout |
|
||||
|
||||
### Push guard and busy check (row 211, #426)
|
||||
|
||||
`scripts/git-hooks/pre-push` refuses a push that deletes a branch, or that
|
||||
moves a branch to a commit that doesn't descend from the remote tip (a
|
||||
`--force`, `--force-with-lease` or `+refspec` push of a rewritten branch). A
|
||||
new branch and a fast-forward pass. The hook fetches nothing: when the remote
|
||||
tip isn't in the local repository it refuses and says to fetch first.
|
||||
`git push --no-verify` skips it, so Gitea's branch protection (ops-01) is the
|
||||
guard that holds. `scripts/install-git-hooks.sh` copies it into the common
|
||||
`.git/hooks`, which every worktree of the checkout shares, then runs a canary
|
||||
through `git hook run`. Installing it in the canonical checkout is a step for
|
||||
the lead after the row lands.
|
||||
|
||||
Chain the busy check on its exit code, and set `pipefail` in any script that
|
||||
pipes it:
|
||||
|
||||
```bash
|
||||
bash scripts/slot-busy.sh > "$LOG" && gate
|
||||
```
|
||||
|
||||
Never `bash scripts/slot-busy.sh | tail -1 && gate`: `tail` exits 0, so the
|
||||
gate runs while BUSY. It matches `node ... --test`, a `*gate*.sh`, `mutant`, a
|
||||
`test-*.sh` and `docker compose` command lines, skips itself, its ancestors
|
||||
and agent shell-snapshot wrappers, and reads only `/proc/<pid>/cmdline` and
|
||||
the cwd link.
|
||||
|
||||
## Seat launch with registration (`scripts/mosaic`)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# PRD: Mosaic Stack
|
||||
|
||||
- Status: approved, version 1.17. Jason approved draft 0.4 unchanged as
|
||||
- Status: approved, version 1.18. Jason approved draft 0.4 unchanged as
|
||||
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
|
||||
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
|
||||
on 2026-10-14. They approved version 1.2, the chief of staff, the
|
||||
@@ -54,7 +54,17 @@
|
||||
adds REQ-LIB-9, the Console's DOCX editor, from Jason's rulings Q195,
|
||||
Q226 and Q234. It changes what Case 2 requirements allow, so Mos puts
|
||||
the text to Jason as written, and it lands only after his yes (mos-1366,
|
||||
Vikunja 570).
|
||||
Vikunja 570). Version 1.18 corrects Mos's reading in REQ-INST-2 to match
|
||||
the approved row 255 design. The stack prints the providers entry and
|
||||
never writes config.json. What the requirement allows or forbids is
|
||||
unchanged (mos-1671, sage-4418). Doc-only, so it takes the 1.11 path:
|
||||
one seat drafts, a second reviews, Sage lands.
|
||||
- Version 1.18 source: Mos's ruling mos-1671 on 2026-10-25, answering
|
||||
sage-4418. REQ-INST-2's second bullet said the stack installs the
|
||||
bundled Pocket ID "and writes it as the Console's one providers entry."
|
||||
It now says the stack "prints the Console's one providers entry for the
|
||||
user to add to config.json. The stack never writes config.json (canon
|
||||
invariant 2)."
|
||||
- Version 1.17 source: Jason's Q235, answering Q36: 'How about we see a
|
||||
markdown file, we convert the markdown file to docx, then archive or
|
||||
mark the markdown file as converted. The new standard for files in the
|
||||
@@ -1517,8 +1527,10 @@ Added in version 1.4 (decision 1ea053cc).
|
||||
- The installer offers two choices: point the Console at one or more
|
||||
existing OIDC providers, or deploy a bundled Pocket ID.
|
||||
- The stack installs that Pocket ID as an install component under
|
||||
REQ-INST-1 and writes it as the Console's one providers entry. No
|
||||
Pocket ID source goes in the repository, and the Console gets no
|
||||
REQ-INST-1 and prints the Console's one providers entry for the user
|
||||
to add to config.json. The stack never writes config.json (canon
|
||||
invariant 2). No Pocket ID source goes in the repository, and the
|
||||
Console gets no
|
||||
sign-in path apart from its own OIDC sign-in (REQ-WEB-2).
|
||||
- Pocket ID is passkey-only for people. Agents keep per-role service
|
||||
tokens.
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"prd": "docs/prd/mosaic-stack.md",
|
||||
"version": "1.17",
|
||||
"version": "1.18",
|
||||
"requirements": [
|
||||
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
|
||||
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per assistant session; the chief of staff may end a run, others need a decision" },
|
||||
|
||||
@@ -21,7 +21,7 @@ test("the list holds the PRD's requirement ids, in its order, at its version", (
|
||||
assert.equal(ids.length, 87);
|
||||
assert.deepEqual(list.requirements.map((r) => r.id), ids);
|
||||
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.17']);
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.18']);
|
||||
});
|
||||
|
||||
test('the committed list loads frozen', () => {
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
$('mode').onchange = e => { mode = e.target.value; theme(); };
|
||||
function seenButton(r) {
|
||||
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
|
||||
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
|
||||
return `<button type="button" class="btn" data-seen="${esc(key(r))}" data-intent="${r.seen ? 'unsee' : 'seen'}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
|
||||
}
|
||||
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
|
||||
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
|
||||
@@ -415,12 +415,16 @@
|
||||
if (!seen || writing) return;
|
||||
// A Board read in flight (reading) holds busy. The click waits for that read and posts on the row it
|
||||
// drew; dropping it left the click doing nothing (row 172). A second click while one waits or posts is dropped.
|
||||
// The click means what the button said when clicked (Seen marks, Unsee returns the row to waiting), read before
|
||||
// the wait. If the fresh row already holds that, another viewer got there first: nothing is posted (row 173).
|
||||
const mark = seen.dataset.intent === 'seen';
|
||||
writing = true; seen.disabled = true;
|
||||
try {
|
||||
while (reading) await reading;
|
||||
const r = row(seen.dataset.seen); if (!r) { seen.disabled = false; return; }
|
||||
if (r.seen === mark) { seen.disabled = false; announce(`${r.agent} already ${mark ? 'seen' : 'waiting'}`); return; }
|
||||
busy = true;
|
||||
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: !r.seen })); announce(`${r.agent} ${r.seen ? 'returned to waiting' : 'marked seen'}`); }
|
||||
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: mark })); announce(`${r.agent} ${mark ? 'marked seen' : 'returned to waiting'}`); }
|
||||
catch (err) { error(err); seen.disabled = false; }
|
||||
finally { busy = false; schedule(); }
|
||||
} finally { writing = false; }
|
||||
|
||||
@@ -82,7 +82,7 @@ async function setup(machine, fast = false) {
|
||||
const polls = () => b.evaluate('window.__polls');
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, base, wait, text, shown, reqs, polls, settle, done };
|
||||
return { b, base, boardURL: f.boardURL, wait, text, shown, reqs, polls, settle, done };
|
||||
}
|
||||
|
||||
const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2';
|
||||
@@ -191,6 +191,37 @@ test('a Seen click while a Board read is in flight waits for that read, then pos
|
||||
} finally { await done(); }
|
||||
});
|
||||
|
||||
// Row 173 (#1671): the click took its meaning from the fresh row, so another viewer's mark landing in the read
|
||||
// it waited for turned "Seen" into an unsee. The click means what its button said; a fresh row that already
|
||||
// holds that posts nothing and says so. The other viewer posts straight to the board while the read is held.
|
||||
test('a Seen or Unsee click that waited out a Board read keeps its meaning: another viewer\'s mark in that read posts nothing', { timeout: 120000 }, async () => {
|
||||
const { b, base, boardURL, wait, text, reqs, done } = await setup(true);
|
||||
const posts = async () => (await reqs()).filter(r => r.path === '/api/seen').length;
|
||||
const other = async seen => {
|
||||
const r = (await (await fetch(`${boardURL}/api/board`)).json()).sessions.find(s => s.project === 'proj' && s.agent === 'agent1');
|
||||
const res = await fetch(`${boardURL}/api/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ project: 'proj', agent: 'agent1', lastActivity: r.lastActivity, seen }) });
|
||||
assert.equal(res.status, 200);
|
||||
};
|
||||
try {
|
||||
await b.navigate(base);
|
||||
await wait(`${SESSIONS} && ${HELD}`);
|
||||
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
|
||||
await wait('document.querySelector("#inspection [data-seen]")');
|
||||
for (const [label, seen, count, said] of [['Seen', true, '1', 'agent1 already seen'], ['Unsee', false, '0', 'agent1 already waiting']]) {
|
||||
assert.equal(await text('#inspection [data-seen]'), label);
|
||||
await b.evaluate(`window.__hold = '/api/board', document.querySelector('#refresh').click(), true`);
|
||||
await wait('window.__held.length === 1');
|
||||
// The held fetch has not reached the board yet, so its answer carries the other viewer's mark.
|
||||
await other(seen);
|
||||
await b.evaluate(`document.querySelector('#inspection [data-seen]').click(), true`);
|
||||
await b.evaluate('window.__held.splice(0).forEach(r => r()), true');
|
||||
await wait(`document.querySelector("#announce").textContent === ${JSON.stringify(said)}`);
|
||||
assert.equal(await text('#seen-count'), count);
|
||||
assert.equal(await posts(), 0);
|
||||
}
|
||||
} finally { await done(); }
|
||||
});
|
||||
|
||||
test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
|
||||
// The poll timers fire after 150 ms here, so the 600 ms settle below spans several ticks.
|
||||
const { b, base, wait, text, shown, reqs, polls, settle, done } = await setup(true, true);
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/sh
|
||||
# Mosaic push guard (row 211, #426). Installed as pre-push in the common
|
||||
# .git/hooks by `scripts/install-git-hooks.sh`; never edit the installed copy.
|
||||
# For each ref git is about to push, it refuses a delete and any update whose
|
||||
# remote tip isn't an ancestor of the local tip: a force-push or a +refspec.
|
||||
# A new ref passes. It fetches nothing, so a remote tip that isn't in this
|
||||
# repository refuses. Any failure of the check refuses too.
|
||||
# `git push --no-verify` skips this hook; Gitea's branch protection is the
|
||||
# guard that holds (ops-01). Canon: never force-push.
|
||||
refused=0
|
||||
refuse() {
|
||||
echo "mosaic push guard: refused: $*" >&2
|
||||
refused=1
|
||||
}
|
||||
zero() {
|
||||
case "$1" in *[!0]*) return 1 ;; esac
|
||||
return 0
|
||||
}
|
||||
while read -r lref lsha rref rsha extra || [ -n "${lref:-}" ]; do
|
||||
if [ -z "${rsha:-}" ] || [ -n "${extra:-}" ]; then
|
||||
refuse "unreadable ref line from git: $lref ${lsha:-} ${rref:-} ${rsha:-} ${extra:-}"
|
||||
continue
|
||||
fi
|
||||
case "$lsha$rsha" in
|
||||
*[!0-9a-f]*) refuse "$rref: a ref line with a non-hex object name (local $lsha, remote $rsha)"; continue ;;
|
||||
esac
|
||||
if zero "$lsha"; then
|
||||
refuse "$rref: a delete (remote $rsha); seat branches are never deleted by push"
|
||||
continue
|
||||
fi
|
||||
if zero "$rsha"; then
|
||||
continue
|
||||
fi
|
||||
if ! git cat-file -e "$rsha^{commit}" 2>/dev/null; then
|
||||
refuse "$rref: the remote tip $rsha isn't in this repository; fetch first, then push again"
|
||||
continue
|
||||
fi
|
||||
git merge-base --is-ancestor "$rsha" "$lsha" 2>/dev/null
|
||||
rc=$?
|
||||
case $rc in
|
||||
0) ;;
|
||||
1) refuse "$rref: not a fast-forward; remote $rsha isn't an ancestor of local $lsha (a force-push or a rewritten branch)" ;;
|
||||
*) refuse "$rref: the ancestor check failed (exit $rc) for remote $rsha and local $lsha" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$refused" != 0 ]; then
|
||||
echo "mosaic push guard: nothing was pushed; a pushed branch only moves forward (canon: never force-push)" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# install-git-hooks.sh: install the push guard (row 211, #426) as pre-push in
|
||||
# a repository's common .git/hooks, which every linked worktree shares.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/install-git-hooks.sh [--repo DIR] [--force-install]
|
||||
#
|
||||
# DIR defaults to this script's own checkout. The hook's bytes are this
|
||||
# checkout's scripts/git-hooks/pre-push. It refuses when core.hooksPath is set
|
||||
# (git would not run the hook), and when a different pre-push is installed,
|
||||
# unless --force-install is given. It never edits an installed hook in place:
|
||||
# a new hook is linked in, a replacement is renamed over the old one. A canary
|
||||
# then runs the installed hook through git and needs a delete refused.
|
||||
#
|
||||
# Exit codes: 0 installed (or already the same bytes); 1 failed; 2 refused;
|
||||
# 4 usage. Installing in the canonical checkout is its own step with the lead.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
SRC="$ROOT/scripts/git-hooks/pre-push"
|
||||
TMPD=""
|
||||
|
||||
say() { printf 'install-git-hooks: %s\n' "$*" >&2; }
|
||||
die() { local code=$1; shift; say "$*"; exit "$code"; }
|
||||
cleanup() { if [ -n "$TMPD" ]; then rm -rf -- "$TMPD"; fi; }
|
||||
trap cleanup EXIT
|
||||
|
||||
usage() {
|
||||
sed -n '5,6p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2
|
||||
exit 4
|
||||
}
|
||||
|
||||
REPO=$ROOT
|
||||
FORCE=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--repo) [ $# -ge 2 ] || usage; REPO=$2; shift 2 ;;
|
||||
--force-install) FORCE=1; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) say "unknown argument: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for v in GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES; do
|
||||
if [ -n "${!v+x}" ]; then die 2 "refused: $v is set; run with the normal git environment"; fi
|
||||
done
|
||||
[ -f "$SRC" ] && [ ! -L "$SRC" ] || die 2 "refused: $SRC is missing or not a regular file"
|
||||
|
||||
g() { git -C "$REPO" "$@"; }
|
||||
top=$(g rev-parse --show-toplevel 2>/dev/null) || die 2 "refused: $REPO is not a git checkout"
|
||||
common=$(g rev-parse --path-format=absolute --git-common-dir) || die 1 "cannot read $REPO's git common directory"
|
||||
HOOKS="$common/hooks"
|
||||
HOOK="$HOOKS/pre-push"
|
||||
|
||||
out=$(g config --show-scope --get-all core.hooksPath 2>/dev/null)
|
||||
[ -z "$out" ] || die 2 "refused: core.hooksPath is set ($(printf '%s' "$out" | tr '\n\t' '; ')), so git would not run the push guard; unset it first"
|
||||
|
||||
if [ -L "$HOOK" ]; then die 2 "refused: $HOOK is a symlink; remove it by hand if it is not wanted"; fi
|
||||
mkdir -p "$HOOKS" || die 1 "cannot create $HOOKS"
|
||||
tmp="$HOOKS/.pre-push.install-git-hooks.$$"
|
||||
if [ -e "$HOOK" ]; then
|
||||
[ -f "$HOOK" ] || die 2 "refused: $HOOK is not a regular file"
|
||||
if cmp -s "$SRC" "$HOOK"; then
|
||||
chmod 0755 "$HOOK" || die 1 "chmod failed"
|
||||
note="the same bytes were already there"
|
||||
else
|
||||
[ "$FORCE" = 1 ] || die 2 "refused: a different pre-push hook is installed at $HOOK; compare it with $SRC, and pass --force-install to replace it"
|
||||
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
|
||||
# rename() swaps the whole file; the old hook's bytes are never edited.
|
||||
mv -f -- "$tmp" "$HOOK" || { rm -f -- "$tmp"; die 1 "cannot replace $HOOK"; }
|
||||
note="replaced a different hook (--force-install)"
|
||||
fi
|
||||
else
|
||||
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
|
||||
# link() never replaces, so a hook that appeared meanwhile is kept.
|
||||
if ! ln -- "$tmp" "$HOOK" 2>/dev/null; then rm -f -- "$tmp"; die 2 "refused: a pre-push hook appeared at $HOOK while installing"; fi
|
||||
rm -f -- "$tmp"
|
||||
note="new"
|
||||
fi
|
||||
|
||||
cmp -s "$SRC" "$HOOK" || die 1 "$HOOK differs from $SRC after install"
|
||||
[ -x "$HOOK" ] || die 1 "$HOOK is not executable after install"
|
||||
|
||||
# The canary: git runs the hook it would run for a push. A delete line needs
|
||||
# no objects; the guard must refuse it, and pass an empty push.
|
||||
TMPD=$(mktemp -d "${TMPDIR:-/tmp}/install-git-hooks.XXXXXX") || die 1 "mktemp failed"
|
||||
z=0000000000000000000000000000000000000000
|
||||
printf '(delete) %s refs/heads/install-canary 1%s\n' "$z" "${z#0}" > "$TMPD/delete"
|
||||
: > "$TMPD/empty"
|
||||
if ! out=$(cd "$top" && git hook run --to-stdin="$TMPD/empty" pre-push -- canary canary 2>&1); then
|
||||
die 2 "refused: the canary's empty push failed, so git is not running the push guard as installed: $out"
|
||||
fi
|
||||
out=$(cd "$top" && git hook run --to-stdin="$TMPD/delete" pre-push -- canary canary 2>&1); rc=$?
|
||||
[ "$rc" -ne 0 ] && [[ "$out" == *"mosaic push guard: refused"* ]] \
|
||||
|| die 2 "refused: the canary's delete was not refused by the push guard (exit $rc)"
|
||||
|
||||
printf 'ok installed the push guard at %s (%s, sha256 %s, mode 0755); canary passed\n' \
|
||||
"$HOOK" "$note" "$(sha256sum < "$HOOK" | cut -d' ' -f1)"
|
||||
exit 0
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# slot-busy.sh: the shared busy check (row 211, #426). Is a gate or a test run
|
||||
# going on this host?
|
||||
#
|
||||
# Usage:
|
||||
# scripts/slot-busy.sh [--proc-root DIR]
|
||||
#
|
||||
# It prints one BUSY line per matching process, or one CLEAR line. Exit codes:
|
||||
# 0 CLEAR, 1 BUSY, 2 its own error. Chain it on the exit code, never on a
|
||||
# filter of its output:
|
||||
# bash scripts/slot-busy.sh > LOG && gate (set -o pipefail if piped)
|
||||
# never `bash scripts/slot-busy.sh | tail -1 && gate`, which runs while BUSY.
|
||||
#
|
||||
# A process matches when its command line holds `node ... --test`, a
|
||||
# `*gate*.sh`, `mutant`, a `test-*.sh` or `docker compose`/`docker-compose`.
|
||||
# It skips itself and its ancestors (a gate script may run the check), and an
|
||||
# agent harness's shell-snapshot wrapper, whose command line is the agent's
|
||||
# command text; that shell's real children are still matched. It reads only
|
||||
# /proc/<pid>/cmdline and the cwd link, never a file under a seat's directory.
|
||||
# --proc-root names a stand-in for /proc in the selftests.
|
||||
set -uo pipefail
|
||||
|
||||
PROC=/proc
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--proc-root) [ $# -ge 2 ] || { echo "slot-busy: error: --proc-root needs a directory" >&2; exit 2; }; PROC=$2; shift 2 ;;
|
||||
-h|--help) sed -n '5,6p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2 ;;
|
||||
*) echo "slot-busy: error: unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ -d "$PROC" ] || { echo "slot-busy: error: $PROC is not a directory" >&2; exit 2; }
|
||||
|
||||
# This process and its ancestors, from the real /proc.
|
||||
declare -A SKIP=()
|
||||
p=$$
|
||||
while [ -n "$p" ] && [ "$p" != 0 ]; do
|
||||
SKIP[$p]=1
|
||||
p=$(awk '/^PPid:/{print $2; exit}' "/proc/$p/status" 2>/dev/null) || p=""
|
||||
done
|
||||
[ "${#SKIP[@]}" -ge 1 ] && [ -n "${SKIP[$$]:-}" ] || { echo "slot-busy: error: cannot read its own process" >&2; exit 2; }
|
||||
|
||||
RE_NODE='[ /]node[^ ]* (.* )?--test '
|
||||
RE_GATE='[ /_-]gate[^ /]*\.sh '
|
||||
RE_TEST='[ /]test-[^ /]*\.sh '
|
||||
RE_COMPOSE='[ /]docker( compose|-compose) '
|
||||
match() { # $1: the command line, NULs as spaces. Prints the rule that matched.
|
||||
local c=" $1 "
|
||||
if [[ "$c" == *"/shell-snapshots/snapshot-"* ]]; then return 1; fi
|
||||
if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi
|
||||
if [[ "$c" =~ $RE_GATE ]]; then echo "gate"; return 0; fi
|
||||
if [[ "$c" == *mutant* ]]; then echo "mutant"; return 0; fi
|
||||
if [[ "$c" =~ $RE_TEST ]]; then echo "test script"; return 0; fi
|
||||
if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
seen=0
|
||||
busy=0
|
||||
for d in "$PROC"/[0-9]*; do
|
||||
[ -d "$d" ] || continue
|
||||
pid=${d##*/}
|
||||
[[ "$pid" =~ ^[0-9]+$ ]] || continue
|
||||
seen=$((seen + 1))
|
||||
[ -z "${SKIP[$pid]:-}" ] || continue
|
||||
# A process can exit between the listing and the read; that's not an error.
|
||||
cmd=$(tr '\0' ' ' < "$d/cmdline" 2>/dev/null) || continue
|
||||
cmd=${cmd% }
|
||||
[ -n "$cmd" ] || continue
|
||||
rule=$(match "$cmd") || continue
|
||||
cwd=$(readlink "$d/cwd" 2>/dev/null) || cwd="?"
|
||||
printf 'BUSY %s %s cwd=%s %s\n' "$pid" "$rule" "$cwd" "${cmd:0:200}"
|
||||
busy=1
|
||||
done
|
||||
[ "$seen" -ge 1 ] || { echo "slot-busy: error: no processes under $PROC" >&2; exit 2; }
|
||||
if [ "$busy" = 1 ]; then exit 1; fi
|
||||
echo "CLEAR"
|
||||
exit 0
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sandboxed selftests for the push guard, its installer and the busy check
|
||||
# (row 211, #426): scripts/git-hooks/pre-push, scripts/install-git-hooks.sh
|
||||
# and scripts/slot-busy.sh.
|
||||
#
|
||||
# Pushes go from a scratch clone and its worktree to a scratch bare remote,
|
||||
# never to origin. Git sees no user or system config. The busy check runs
|
||||
# against a fake /proc (pids above pid_max) for the exit codes and rules, and
|
||||
# against the real /proc with a sleeping stand-in whose argv is `node --test`.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO="$(pwd)"
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
|
||||
|
||||
SANDBOX="$(cd "$(mktemp -d)" && pwd -P)"
|
||||
STANDIN=""
|
||||
cleanup() {
|
||||
if [ -n "$STANDIN" ]; then kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null; fi
|
||||
rm -rf "$SANDBOX"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
|
||||
else
|
||||
C_OK=""; C_FAIL=""; C_RESET=""
|
||||
fi
|
||||
check() {
|
||||
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
|
||||
}
|
||||
has() { [[ "$1" == *"$2"* ]]; }
|
||||
|
||||
# Git sees no user or system config: no hooks path, no signing, a fixed identity.
|
||||
: > "$SANDBOX/gitconfig"
|
||||
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$SANDBOX/gitconfig"
|
||||
export GIT_AUTHOR_NAME=suite GIT_AUTHOR_EMAIL=suite@local GIT_COMMITTER_NAME=suite GIT_COMMITTER_EMAIL=suite@local
|
||||
|
||||
HOOK_SRC="$REPO/scripts/git-hooks/pre-push"
|
||||
INSTALL="$REPO/scripts/install-git-hooks.sh"
|
||||
BUSY="$REPO/scripts/slot-busy.sh"
|
||||
|
||||
B="$SANDBOX/remote.git"
|
||||
C="$SANDBOX/clone"
|
||||
W="$SANDBOX/wt"
|
||||
O="$SANDBOX/other"
|
||||
commit() { # $1: repo, $2: file and message
|
||||
echo "$2" > "$1/$2" && git -C "$1" add "$2" && git -C "$1" commit -q -m "$2"
|
||||
}
|
||||
rtip() { git -C "$B" rev-parse -q --verify "refs/heads/$1"; }
|
||||
|
||||
# ---- the remote, a clone with the guard, a worktree ----
|
||||
git init -q --bare -b main "$B"
|
||||
git init -q -b main "$SANDBOX/seed"
|
||||
commit "$SANDBOX/seed" base
|
||||
git -C "$SANDBOX/seed" push -q "$B" main
|
||||
git clone -q "$B" "$C"
|
||||
|
||||
echo "== install =="
|
||||
out=$(bash "$INSTALL" --repo "$C" 2>&1); rc=$?
|
||||
check "the install into a clean clone exits 0" "$rc"
|
||||
check "it reports the install and a passed canary" "$(has "$out" "ok installed the push guard" && has "$out" "canary passed"; echo $?)"
|
||||
check "the hook is in the common .git/hooks, with the repository's bytes, executable" \
|
||||
"$(cmp -s "$HOOK_SRC" "$C/.git/hooks/pre-push" && [ -x "$C/.git/hooks/pre-push" ]; echo $?)"
|
||||
check "no temporary file is left in .git/hooks" \
|
||||
"$(ls -A "$C/.git/hooks" | grep -q '^\.pre-push\.install'; [ $? = 1 ]; echo $?)"
|
||||
git -C "$C" worktree add -q -b seat/w "$W" origin/main
|
||||
check "the worktree's git runs the common hook" \
|
||||
"$([ "$(git -C "$W" rev-parse --path-format=absolute --git-path hooks/pre-push)" = "$C/.git/hooks/pre-push" ]; echo $?)"
|
||||
|
||||
echo "== pushes from the worktree =="
|
||||
commit "$W" one
|
||||
out=$(git -C "$W" push origin seat/w 2>&1); rc=$?
|
||||
check "a new branch passes" "$rc"
|
||||
check "the remote branch is at the pushed commit" "$([ "$(rtip seat/w)" = "$(git -C "$W" rev-parse HEAD)" ]; echo $?)"
|
||||
|
||||
commit "$W" two
|
||||
out=$(git -C "$W" push origin seat/w 2>&1); rc=$?
|
||||
check "a fast-forward passes" "$rc"
|
||||
check "the remote branch moved forward" "$([ "$(rtip seat/w)" = "$(git -C "$W" rev-parse HEAD)" ]; echo $?)"
|
||||
|
||||
before=$(rtip seat/w)
|
||||
git -C "$W" reset -q --hard HEAD~1
|
||||
commit "$W" two-rewritten
|
||||
after=$(git -C "$W" rev-parse HEAD)
|
||||
out=$(git -C "$W" push --force origin seat/w 2>&1); rc=$?
|
||||
check "push --force of a rewritten branch is refused" "$([ "$rc" -ne 0 ]; echo $?)"
|
||||
check "the refusal is the guard's, and names the ref and both shas" \
|
||||
"$(has "$out" "mosaic push guard: refused: refs/heads/seat/w: not a fast-forward" && has "$out" "$before" && has "$out" "$after"; echo $?)"
|
||||
check "the remote ref is unchanged after the refused --force" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
|
||||
|
||||
out=$(git -C "$W" push origin +seat/w:seat/w 2>&1); rc=$?
|
||||
check "a +refspec push is refused" "$([ "$rc" -ne 0 ] && has "$out" "not a fast-forward"; echo $?)"
|
||||
check "the remote ref is unchanged after the refused +refspec" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
|
||||
|
||||
out=$(git -C "$W" push --force-with-lease origin seat/w 2>&1); rc=$?
|
||||
check "push --force-with-lease is refused too" "$([ "$rc" -ne 0 ] && has "$out" "not a fast-forward"; echo $?)"
|
||||
|
||||
out=$(git -C "$W" push origin :seat/w 2>&1); rc=$?
|
||||
check "a delete (:ref) is refused" "$([ "$rc" -ne 0 ] && has "$out" "mosaic push guard: refused: refs/heads/seat/w: a delete"; echo $?)"
|
||||
out=$(git -C "$W" push --delete origin seat/w 2>&1); rc=$?
|
||||
check "a delete (--delete) is refused" "$([ "$rc" -ne 0 ] && has "$out" "a delete"; echo $?)"
|
||||
check "the remote branch still exists after the refused deletes" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
|
||||
|
||||
# Another clone moves the remote; this clone never fetches it.
|
||||
git clone -q -b seat/w "$B" "$O"
|
||||
commit "$O" from-other
|
||||
git -C "$O" push -q origin seat/w
|
||||
moved=$(rtip seat/w)
|
||||
check "the other clone moved the remote, and this clone lacks that commit" \
|
||||
"$([ "$moved" != "$before" ] && ! git -C "$C" cat-file -e "$moved^{commit}" 2>/dev/null; echo $?)"
|
||||
out=$(git -C "$W" push --force origin seat/w 2>&1); rc=$?
|
||||
check "a push over a remote tip missing locally is refused, saying fetch first" \
|
||||
"$([ "$rc" -ne 0 ] && has "$out" "isn't in this repository; fetch first"; echo $?)"
|
||||
check "the remote ref is unchanged after that refusal" "$([ "$(rtip seat/w)" = "$moved" ]; echo $?)"
|
||||
check "the guard fetched nothing" "$(! git -C "$C" cat-file -e "$moved^{commit}" 2>/dev/null; echo $?)"
|
||||
|
||||
echo "== the hook, fed by hand =="
|
||||
z=0000000000000000000000000000000000000000
|
||||
hookrun() { (cd "$C" && printf '%s\n' "$1" | sh "$C/.git/hooks/pre-push" origin "$B" 2>&1); }
|
||||
out=$(hookrun "refs/heads/x $(git -C "$C" rev-parse HEAD) refs/heads/x"); rc=$?
|
||||
check "a short ref line is refused" "$([ "$rc" -ne 0 ] && has "$out" "unreadable ref line"; echo $?)"
|
||||
out=$(hookrun "refs/heads/x HEAD refs/heads/x $z"); rc=$?
|
||||
check "a non-hex object name is refused" "$([ "$rc" -ne 0 ] && has "$out" "non-hex"; echo $?)"
|
||||
out=$(hookrun "refs/heads/x 1234567890123456789012345678901234567890 refs/heads/x $(git -C "$C" rev-parse HEAD)"); rc=$?
|
||||
check "a failed ancestor check (an unknown local object) is refused" \
|
||||
"$([ "$rc" -ne 0 ] && has "$out" "the ancestor check failed"; echo $?)"
|
||||
out=$(printf '' | (cd "$C" && sh "$C/.git/hooks/pre-push" origin "$B" 2>&1)); rc=$?
|
||||
check "an empty push passes" "$rc"
|
||||
|
||||
echo "== installer refusals =="
|
||||
fresh() { rm -rf "$1"; git clone -q "$B" "$1"; }
|
||||
fresh "$SANDBOX/hp"
|
||||
git -C "$SANDBOX/hp" config core.hooksPath .husky
|
||||
out=$(bash "$INSTALL" --repo "$SANDBOX/hp" 2>&1); rc=$?
|
||||
check "the install refuses with core.hooksPath set (exit 2)" "$([ "$rc" = 2 ]; echo $?)"
|
||||
check "it says core.hooksPath is set" "$(has "$out" "core.hooksPath is set"; echo $?)"
|
||||
check "no hook was written under the hooksPath refusal" \
|
||||
"$([ ! -e "$SANDBOX/hp/.git/hooks/pre-push" ] && [ ! -e "$SANDBOX/hp/.husky/pre-push" ]; echo $?)"
|
||||
|
||||
fresh "$SANDBOX/df"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$SANDBOX/df/.git/hooks/pre-push"
|
||||
chmod 0755 "$SANDBOX/df/.git/hooks/pre-push"
|
||||
ln "$SANDBOX/df/.git/hooks/pre-push" "$SANDBOX/df-old"
|
||||
out=$(bash "$INSTALL" --repo "$SANDBOX/df" 2>&1); rc=$?
|
||||
check "the install refuses a different installed pre-push (exit 2)" \
|
||||
"$([ "$rc" = 2 ] && has "$out" "a different pre-push hook is installed"; echo $?)"
|
||||
check "the different hook is untouched" \
|
||||
"$([ "$(cat "$SANDBOX/df/.git/hooks/pre-push")" = "$(printf '#!/bin/sh\nexit 0')" ]; echo $?)"
|
||||
out=$(bash "$INSTALL" --repo "$SANDBOX/df" --force-install 2>&1); rc=$?
|
||||
check "--force-install replaces it" \
|
||||
"$([ "$rc" = 0 ] && has "$out" "replaced a different hook" && cmp -s "$HOOK_SRC" "$SANDBOX/df/.git/hooks/pre-push"; echo $?)"
|
||||
check "the replacement was a rename: the old file keeps its bytes" \
|
||||
"$([ "$(cat "$SANDBOX/df-old")" = "$(printf '#!/bin/sh\nexit 0')" ]; echo $?)"
|
||||
out=$(bash "$INSTALL" --repo "$SANDBOX/df" 2>&1); rc=$?
|
||||
check "a second install over the same bytes exits 0" "$([ "$rc" = 0 ] && has "$out" "the same bytes"; echo $?)"
|
||||
|
||||
fresh "$SANDBOX/ln"
|
||||
ln -s /dev/null "$SANDBOX/ln/.git/hooks/pre-push"
|
||||
out=$(bash "$INSTALL" --repo "$SANDBOX/ln" --force-install 2>&1); rc=$?
|
||||
check "the install refuses a symlinked pre-push, even with --force-install" "$([ "$rc" = 2 ] && has "$out" "is a symlink"; echo $?)"
|
||||
|
||||
fresh "$SANDBOX/wd"
|
||||
git -C "$SANDBOX/wd" worktree add -q -b seat/x "$SANDBOX/wd-wt" origin/main
|
||||
out=$(bash "$INSTALL" --repo "$SANDBOX/wd-wt" 2>&1); rc=$?
|
||||
check "an install named by a worktree path lands in the common .git/hooks" \
|
||||
"$([ "$rc" = 0 ] && cmp -s "$HOOK_SRC" "$SANDBOX/wd/.git/hooks/pre-push"; echo $?)"
|
||||
|
||||
out=$(GIT_DIR="$SANDBOX/wd/.git" bash "$INSTALL" --repo "$SANDBOX/wd" 2>&1); rc=$?
|
||||
check "the install refuses with GIT_DIR set" "$([ "$rc" = 2 ] && has "$out" "GIT_DIR is set"; echo $?)"
|
||||
mkdir -p "$SANDBOX/plain"
|
||||
out=$(bash "$INSTALL" --repo "$SANDBOX/plain" 2>&1); rc=$?
|
||||
check "the install refuses a directory that isn't a checkout" "$([ "$rc" = 2 ]; echo $?)"
|
||||
out=$(bash "$INSTALL" --nope 2>&1); rc=$?
|
||||
check "an unknown argument exits 4" "$([ "$rc" = 4 ]; echo $?)"
|
||||
|
||||
echo "== slot-busy, fake /proc =="
|
||||
P="$SANDBOX/proc"
|
||||
fakeproc() { # $1: pid, $2: cwd, rest: argv
|
||||
local pid=$1 cwd=$2; shift 2
|
||||
mkdir -p "$P/$pid"
|
||||
printf '%s\0' "$@" > "$P/$pid/cmdline"
|
||||
ln -sfn "$cwd" "$P/$pid/cwd"
|
||||
}
|
||||
busyrun() { bash "$BUSY" --proc-root "$P" 2>&1; }
|
||||
fakeproc 4194401 /srv node server.mjs
|
||||
fakeproc 4194402 /srv grep -r gate .
|
||||
fakeproc 4194403 /srv /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval 'node --test x.test.mjs'"
|
||||
mkdir -p "$P/4194404"; : > "$P/4194404/cmdline"
|
||||
out=$(busyrun); rc=$?
|
||||
check "no matching process: exit 0 and CLEAR" "$([ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
|
||||
check "an agent's shell-snapshot wrapper isn't a match" "$(! has "$out" "4194403"; echo $?)"
|
||||
|
||||
fakeproc 4194410 /seat/a node --test packages/x/tests/a.test.mjs
|
||||
out=$(busyrun); rc=$?
|
||||
check "a node --test process: exit 1" "$([ "$rc" = 1 ]; echo $?)"
|
||||
check "its BUSY line names pid, rule and cwd" "$(has "$out" "BUSY 4194410 node --test cwd=/seat/a node --test"; echo $?)"
|
||||
check "BUSY output never says CLEAR" "$(! has "$out" "CLEAR"; echo $?)"
|
||||
ran=$(bash "$BUSY" --proc-root "$P" >/dev/null && echo ran)
|
||||
check "\`slot-busy > LOG && step\` doesn't run the step while BUSY" "$([ -z "$ran" ]; echo $?)"
|
||||
rm -rf "${P:?}/4194410"
|
||||
|
||||
rule() { # $1: expected rule, rest: argv
|
||||
local want=$1; shift
|
||||
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b "$@"
|
||||
out=$(busyrun); rc=$?
|
||||
check "busy on: $* ($want)" "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 $want "; echo $?)"
|
||||
}
|
||||
rule "node --test" /usr/bin/node --experimental-strip-types --test a.test.ts
|
||||
rule "gate" bash /mnt/scratch/sage-gate.sh 253
|
||||
rule "gate" bash ./gate.sh
|
||||
rule "mutant" bash agents/x/work/queue-1/mutants.sh M3
|
||||
rule "test script" bash scripts/test-land-work.sh
|
||||
rule "docker compose" docker compose -p x up -d
|
||||
rule "docker compose" /usr/bin/docker-compose up
|
||||
rm -rf "${P:?}/4194420"
|
||||
|
||||
out=$(bash "$BUSY" --proc-root "$SANDBOX/no-such-proc" 2>&1); rc=$?
|
||||
check "a missing proc root: exit 2" "$([ "$rc" = 2 ]; echo $?)"
|
||||
mkdir -p "$SANDBOX/empty-proc"
|
||||
out=$(bash "$BUSY" --proc-root "$SANDBOX/empty-proc" 2>&1); rc=$?
|
||||
check "a proc root with no processes: exit 2" "$([ "$rc" = 2 ]; echo $?)"
|
||||
out=$(bash "$BUSY" --nope 2>&1); rc=$?
|
||||
check "an unknown argument: exit 2" "$([ "$rc" = 2 ]; echo $?)"
|
||||
|
||||
echo "== slot-busy, real /proc =="
|
||||
mkdir -p "$SANDBOX/bin" "$SANDBOX/standin"
|
||||
cat > "$SANDBOX/bin/node" <<'EOF'
|
||||
#!/bin/sh
|
||||
trap 'exit 0' TERM
|
||||
while :; do sleep 0.2; done
|
||||
EOF
|
||||
chmod +x "$SANDBOX/bin/node"
|
||||
(cd "$SANDBOX/standin" && exec "$SANDBOX/bin/node" --test standin.test.mjs) &
|
||||
STANDIN=$!
|
||||
for _ in $(seq 50); do
|
||||
has "$(tr '\0' ' ' < "/proc/$STANDIN/cmdline" 2>/dev/null)" "--test standin" && break
|
||||
sleep 0.1
|
||||
done
|
||||
out=$(bash "$BUSY" 2>&1); rc=$?
|
||||
check "with a stand-in node --test running: exit 1" "$([ "$rc" = 1 ]; echo $?)"
|
||||
check "the stand-in has a BUSY line with its cwd" \
|
||||
"$(printf '%s\n' "$out" | grep -q "^BUSY $STANDIN node --test cwd=$SANDBOX/standin "; echo $?)"
|
||||
check "this suite, an ancestor, has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $$ "; echo $?)"
|
||||
ran=$(bash "$BUSY" >/dev/null && echo ran)
|
||||
check "\`slot-busy > LOG && step\` doesn't run the step with the stand-in up" "$([ -z "$ran" ]; echo $?)"
|
||||
kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null
|
||||
gone=$STANDIN; STANDIN=""
|
||||
out=$(bash "$BUSY" 2>&1); rc=$?
|
||||
check "after the stand-in exits, it has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $gone "; echo $?)"
|
||||
check "the real-/proc run exits 0 or 1, never 2" "$([ "$rc" = 0 ] || [ "$rc" = 1 ]; echo $?)"
|
||||
|
||||
echo "---"
|
||||
echo "git-guard selftests: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in new issue
Block a user