Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e25ef0373c |
No files matched your search
@@ -1,276 +0,0 @@
|
||||
# Row 283 (#1788) C2-01d build notes (Honker)
|
||||
|
||||
Append-only. Design: DESIGN.md d2 16e809457, APPROVE webby-0172. Gate owner Sage, reviewer Webby.
|
||||
|
||||
## 2026-10-23 build on 45dca69f6 (sage-3577)
|
||||
|
||||
Branch honker/row283-build from origin/refactor 45dca69f6e4bce52834587f66f2ff33ba839ab8a. The old branch
|
||||
honker/row283 (16e809457) held only the design, so its two commits were cherry-picked as 398314cf4
|
||||
(d1) and 4f66dccde (d2). The DESIGN.md blob equals 16e809457's.
|
||||
|
||||
Commits:
|
||||
- 331653b38: code, README and tests T1-T8.
|
||||
- 28918824c: the survey.test:73 re-pin, alone (mos-1330 item 4).
|
||||
- this file.
|
||||
|
||||
Nothing has run. No node, test or mutant ran during the build: Darkwing held the slot. Every count
|
||||
below is a plan until Sage's CLEAR.
|
||||
|
||||
### Drift check (sage-3577)
|
||||
|
||||
`git diff --stat 94c33a43c 45dca69f6` (design base to build base) over the paths the tests read or
|
||||
hash-pin:
|
||||
- No change in packages/library, packages/business/src/business.mjs or
|
||||
packages/business/tests/business.test.mjs.
|
||||
- No test hash-pins a library file. bus library.test and library-sync.test import index.mjs, and
|
||||
stack-copy.test only checks that index.mjs exists.
|
||||
- webui library-cli.test changed one line and doesn't read scope. The `classify` in webui reads.mjs is
|
||||
the business one, not the library's.
|
||||
|
||||
I redo the drift check on BASE..refactor before the in-review move.
|
||||
|
||||
### N3 (webby-0169): deepEquals of the mapping output outside packages/library
|
||||
|
||||
`git grep -n playground -- 'packages/*/tests/*' ':!packages/library/*'` and the files naming
|
||||
`universes` give two files:
|
||||
- business.test.mjs:20 compares `validateBusinessDocument(...).projects.books` with
|
||||
`checkMapping(mappingEntry())`. That is output against output, so `supporting: []` shows up on both
|
||||
sides. :27 compares only `.playground`.
|
||||
- library-projects-protected.test.mjs compares file bytes (:31, :32) and change paths (:58), never the
|
||||
mapping output.
|
||||
|
||||
No re-pin is needed outside library. Inside library, only the draft deepEqual at mapping.test:48 gains
|
||||
`supporting: []`, as the design says.
|
||||
|
||||
### Q1 departure (sage-2923) and the :73 re-pin (mos-1330 item 4)
|
||||
|
||||
survey.mjs:65 counts `supporting: 0` and formatSurvey prints it. T8 is the only added test
|
||||
(survey.test "C2-01d: with --mapping the survey counts supporting files").
|
||||
|
||||
mos-1330 item 4, packages/library/tests/survey.test.mjs:73, commit 28918824c:
|
||||
- old: `'scopes canon 5, playground 1, unmapped 3; indexed 6',`
|
||||
- new: `'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',`
|
||||
|
||||
N2: the pin gains only `supporting 0`. No other text on the line or in that test moved.
|
||||
|
||||
### Q2 tradeoff (webby-0169)
|
||||
|
||||
A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. That goes past
|
||||
the brief, but it is the fail-closed reading, and it uses an existing code: classify checks supporting
|
||||
first, so that playground prefix could never match. The cost is that she can't nest a playground under
|
||||
a supporting folder, though she can nest the other way (a playground holding a supporting folder).
|
||||
|
||||
### Handoffs
|
||||
|
||||
- Q4 (webby-0169, sage-2930): the M12 delete rule ("A delete also needs the path to exist at the tip as
|
||||
a 100644 blob", case2 DESIGN.md :308) is not in supportingPath. It binds row 288 (C2-02) via Gitea
|
||||
#1793 comment 30738, with tests for a missing path and a non-100644 entry.
|
||||
- A save with no `editor` refusing is C2-02's (DESIGN.md section 1).
|
||||
|
||||
### Choices the design left open
|
||||
|
||||
- The book loop is now `againstBooks(owners, list, code)` in mapping.mjs, called once per list. The
|
||||
playground's messages and codes are byte-equal to before.
|
||||
- A non-string path refuses `library-write-story-path` ("the path must be a string") as part of
|
||||
check 1. A non-array `tree` is a TypeError (DESIGN.md section 3).
|
||||
- Check 8 builds the tree's names as every path and every folder above it, keyed by
|
||||
`normalize('NFC').toLowerCase()`. A name on the way refuses if its key holds any name other than
|
||||
itself, so a tree that already holds case twins refuses a write to either (R7).
|
||||
|
||||
### Mutants (exact strings)
|
||||
|
||||
Each applies one replacement to the build tip. Run one at a time, and restore with `git checkout --`
|
||||
on the one file before the next.
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M1 | src/scope.mjs | `const prefix = entry.supporting.find(p => inside(p, path));` | `const prefix = entry.supporting.find(p => inside(p, path) && !entry.playground.some(q => inside(q, path)));` | scope.test "supporting comes before playground", supporting.test "passes at book level" (both-lists path) |
|
||||
| M2 | src/mapping.mjs | `if ((inside \|\| holding) && owner !== id)` | `if ((inside \|\| holding) && owner !== id && list !== 'supporting')` | mapping.test "each supporting prefix rule", business.test C2-04 |
|
||||
| M3 | src/supporting.mjs | `for (const p of own) if (LINKS.has(modes.get(p)))` | `for (const p of own) if (false && LINKS.has(modes.get(p)))` | supporting.test "symlink or gitlink" |
|
||||
| M4 | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.normalize('NFC');` | supporting.test "case or Unicode form" |
|
||||
| M4b | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.toLowerCase();` | supporting.test "case or Unicode form" (NFD case) |
|
||||
| M5 | src/supporting.mjs | `Buffer.byteLength(path, 'utf8') > MAX_BYTES` | `path.length > MAX_BYTES` | supporting.test "1025 bytes" |
|
||||
| M6 | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[<>]/;` | mapping.test "editor is optional" |
|
||||
| M6b | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f]/;` | mapping.test "editor is optional" |
|
||||
| M7 | src/supporting.mjs | `if (place.scope !== 'supporting' \|\| place.universe !== universe)` | `if (place.scope !== 'supporting')` | supporting.test "outside the universe's supporting folders" |
|
||||
| M8 | src/mapping.mjs | `if (sid === pid && s !== p && holds(s, p))` | `if (false && sid === pid && s !== p && holds(s, p))` | mapping.test "each supporting prefix rule" (Workshop case) |
|
||||
|
||||
(The `\|` in the table is an escaped `|` in the source text.) Each mutant runs the one test file named,
|
||||
plus business.test for M2, under `timeout -k 10 600`. A mutant passes the gate only when its run is red
|
||||
on the named test and the restored file's sha256 equals the build tip's.
|
||||
|
||||
### Gate plan (on Sage's CLEAR)
|
||||
|
||||
1. The touched files whole, each under `timeout -k 10 600`: library mapping, scope, supporting and
|
||||
survey tests, and business.test.mjs. Counts go below.
|
||||
2. M1-M8 as above.
|
||||
3. The full gate, under mos-1275 timeouts: `node --test packages/library/tests/` and
|
||||
`node --test packages/business/tests/` each under `timeout -k 10 1800`, and every scripts/test-*.sh
|
||||
under `timeout -k 10 300`. pipefail and tee, with skips named (test-task Docker groups).
|
||||
4. Before in-review: grep packages/*/tests for a 64-hex pin on any changed file and for the changed
|
||||
message text, then the drift check on BASE..refactor.
|
||||
|
||||
## 2026-10-23 Webby early look (webby-0227, static, not a verdict)
|
||||
|
||||
- E1: business.test.mjs is an existing test file with a 4-line addition after the playground refusal.
|
||||
The in-review request lists it next to survey.test:73.
|
||||
- E3 fixed: supportingPath now runs the string check before `JSON.stringify`, so a BigInt (or any
|
||||
non-string) path refuses `library-write-story-path` instead of throwing a TypeError.
|
||||
supporting.test's string case now covers 7, null, undefined, 1n and an array.
|
||||
- E2 open: `editor` takes surrounding whitespace, and git trims it at commit, so the saved ident and
|
||||
the commit ident can differ. DESIGN.md section 1 says "non-empty after trim". Refusing
|
||||
`value.trim() !== value` would change that approved rule, so it went to Sage instead of into the
|
||||
build.
|
||||
|
||||
## 2026-10-23 E2 departure (sage-3601)
|
||||
|
||||
A departure from DESIGN.md section 1 (which stays unedited). The design says each `editor` value is
|
||||
"non-empty after trim". Sage ruled that surrounding whitespace refuses too, because git trims an
|
||||
ident at commit, so a padded value would save one name and commit another. mapping.mjs now adds
|
||||
`if (value.trim() !== value) bad(`editor.${key} must not start or end with whitespace`)` after the
|
||||
IDENT_BAD check. `trim()` covers Unicode whitespace, so U+00A0 and U+3000 refuse as well. Inner
|
||||
whitespace is kept ("Her Middle Name" passes).
|
||||
|
||||
T3 gains a case per key for leading, trailing and both-ends spaces, U+00A0 and U+3000, plus one inner
|
||||
whitespace pass.
|
||||
|
||||
M6 grows a third part:
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M6c | src/mapping.mjs | `if (value.trim() !== value) bad(` | `if (false && value.trim() !== value) bad(` | mapping.test "editor is optional" (padded cases) |
|
||||
|
||||
Fixture grep: `git grep -nE "\beditor\b|\bauthor\b" -- 'packages/library/tests/*' 'packages/business/tests/*'`,
|
||||
leaving out mapping.test.mjs, finds nothing. packages/business/tests has no fixtures directory. The
|
||||
library fixture mapping.json has no `editor`. The positive control (`vaultPrefix` in mapping.json)
|
||||
matches once. No fixture maps an editor or author with surrounding whitespace.
|
||||
|
||||
## 2026-10-23 E4 (webby-0230, sage-3606), same departure as E2
|
||||
|
||||
Claimed: `queue move 283 in-progress`, op honker-283-start, rev 3642, 19:10:49Z (briefed→in-progress).
|
||||
|
||||
Git's ident.c `crud()` strips any byte <= 0x20 and `, : ; < > " \ '` from both ends of an ident. I
|
||||
checked that with git 2.55.0: `Jane,` `"Jane"` `'Jane'` `Jane;` `Jane:` `Jane\` and `,Jane` all
|
||||
commit as `Jane`, while `Jane.` and `Ja,ne` are kept. mapping.mjs now takes the set from crud()
|
||||
rather than from that list: `IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/`.
|
||||
The E2 check became `if (value.trim() !== value || IDENT_CRUD_END.test(value))`, which refuses
|
||||
library-mapping-invalid with "editor.<key> must not start or end with whitespace or any of , : ; " ' \".
|
||||
`trim()` still catches Unicode whitespace (U+00A0, U+3000), which crud() leaves alone.
|
||||
|
||||
Inner characters: IDENT_BAD (`/[\u0000-\u001f\u007f-\u009f<>]/`) already refuses `<`, `>`, newline,
|
||||
tab and every other control anywhere in the value. T3 now pins that at the start, the end and the
|
||||
middle. Inner `, : ; " ' \` are kept, since git keeps them, and T3 pins that too.
|
||||
|
||||
Tests:
|
||||
- T3 has one case per character in [space, U+00A0, U+3000, `,`, `:`, `;`, `"`, `'`, `\`], at the start
|
||||
and at the end, for each key. `<`, `>`, newline and tab are pinned at the start, end and middle.
|
||||
Four accepted names have inner crud characters.
|
||||
- New round-trip test, "an accepted editor round-trips through a real git commit unchanged". It
|
||||
commits in a scratch repo with editor `Doe, Jane Q.` / `[email protected].` as author and
|
||||
committer, with GIT_AUTHOR_* and GIT_COMMITTER_* stripped from the env, and asserts `git log`
|
||||
shows exactly the saved values. I preflighted that commit with git 2.55.0 by hand: both values came
|
||||
back unchanged.
|
||||
|
||||
M6c now replaces the whole end check (this supersedes the earlier M6c row):
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M6c | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (false)` | mapping.test "editor is optional" (end cases) |
|
||||
| M6d | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (value.trim() !== value)` | mapping.test "editor is optional" (`,` `:` `;` `"` `'` `\` cases) |
|
||||
|
||||
## 2026-10-23 E5 (webby-0236, not blocking), fixed
|
||||
|
||||
The round-trip test stripped only GIT_AUTHOR_* and GIT_COMMITTER_*. With GIT_DIR, GIT_WORK_TREE or
|
||||
GIT_INDEX_FILE set (inside a hook, say), GIT_DIR beats `-C root` and the commit would land in the
|
||||
outer repo, and the test read global and system config. It now deletes GIT_DIR, GIT_WORK_TREE,
|
||||
GIT_COMMON_DIR and GIT_INDEX_FILE (as queue/tests/helpers.mjs gitEnv does) and sets
|
||||
GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 (as ledger.test.mjs does). The `-c user.*`
|
||||
ident still applies. Test only; no mutant changes.
|
||||
|
||||
## 2026-10-23 sage-2925: optional editor.user (sage-3649)
|
||||
|
||||
sage-2925 (recorded in ludwig/row286-r2 agents/ludwig/work/queue-286/BUILD.md, Q4) ruled that row 283
|
||||
adds an optional `editor.user`, her Gitea login for the `library-human` slot, which row 288 (C2-02)
|
||||
passes to `humanCredential(context, {user})`. It reached this row after the DESIGN.md d2 approval
|
||||
(ludwig-0068, bubba-0009, sage-3649), so it departs from DESIGN.md section 1 ("exactly the keys
|
||||
`name` and `email`"). DESIGN.md stays unedited.
|
||||
|
||||
- mapping.mjs: `only(entry.editor, ['name', 'email', 'user'], 'editor')`. When present, `user` must be
|
||||
a string matching USER (`/^[A-Za-z0-9_.-]{1,100}$/`, the entry `user` check), else
|
||||
library-mapping-invalid "editor.user must be a plain account name". The output carries `user` only
|
||||
when given. An absent `editor.user` refusing library-human-missing is 288's (bubba-0009).
|
||||
- README: the `editor` line names `user` and the end-character rule from E2/E4.
|
||||
- Tests: T3 (mapping.test "editor is optional") gains one accepted case (`her.login-1`, kept in the
|
||||
output) and refused cases for '', an inner space, '@', 101 characters, 7 and null. They go in
|
||||
mapping.test with the other editor cases, not in the new supporting.test, which tests write paths.
|
||||
- No test outside mapping.test reads `editor` or pins mapping.mjs or README (git grep).
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M6e | src/mapping.mjs | `if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' \|\| !USER.test(entry.editor.user)))` | `if (false)` | mapping.test "editor is optional" (editor.user refusals) |
|
||||
| M6f | src/mapping.mjs | `...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {})` | `...({})` | mapping.test "editor is optional" (`her.login-1` kept) |
|
||||
|
||||
## 2026-10-23 T2 run (sage-3695 CLEAR, lane A, library and business only)
|
||||
|
||||
Node v26.8.1. refactor was e2b873390 (226 landed); `git diff --stat 45dca69f6 e2b873390 -- packages/library
|
||||
packages/business` is empty. TMPDIR was a mktemp -d under /mnt/storage/scratch/tmp, with pipefail.
|
||||
Singles and mutants ran under `timeout -k 10 600`, suites under `timeout -k 10 1800`.
|
||||
|
||||
First-run red (mos-1272, placed): the first base run on 46bad7ea3 at 20:01:09Z was red in
|
||||
supporting.test "malformed paths refuse by class, in order". 5 of 6 passed, with `TypeError: Do not know
|
||||
how to serialize a BigInt`. The cause was the test helper, not the product: `refuses()` labelled each
|
||||
assertion with `JSON.stringify(path)`, and the 1n case added for E3 can't be stringified. In c18ad3e12
|
||||
the label uses `util.inspect(path)`, a 2-line test-only change. The product's 1n refusal was never in
|
||||
question: supportingPath type-checks before it stringifies (E3). The whole run below is on c18ad3e12.
|
||||
|
||||
Base, the touched files whole (20:01:43Z), all rc=0:
|
||||
- library mapping.test 16/16, scope.test 6/6, supporting.test 6/6, survey.test 7/7;
|
||||
- business business.test 21/21;
|
||||
- 0 skipped, 0 cancelled, 0 todo.
|
||||
|
||||
Mutants (20:01:44Z). Each was applied by exact string with count 1, then restored with `git checkout --`.
|
||||
Every restored sha256 equals HEAD's blob, and the tree was clean after.
|
||||
|
||||
| Mutant | Run | Result | Failing test |
|
||||
|---|---|---|---|
|
||||
| M1 | scope.test | rc=1, 5/6 | supporting comes before playground; a folder in both lists is supporting… |
|
||||
| M1 | supporting.test | rc=1, 5/6 | a path passes at book level, universe level, in a draft and in a folder in both lists |
|
||||
| M2 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code |
|
||||
| M2 | business.test | rc=1, 20/21 | C2-04: library mappings load without roots, remain frozen, and retain mapping refusals |
|
||||
| M3 | supporting.test | rc=1, 5/6 | a symlink or gitlink on the way, or at the path itself, refuses |
|
||||
| M4 | supporting.test | rc=1, 5/6 | a name that differs from a tree name only in case or Unicode form refuses… |
|
||||
| M4b | supporting.test | rc=1, 5/6 | the same case/Unicode test |
|
||||
| M5 | supporting.test | rc=1, 5/6 | a path of exactly 1024 bytes passes, and 1025 bytes… refuses |
|
||||
| M6 | mapping.test | rc=1, 15/16 | editor is optional, kept when given, and refuses anything but a plain name and email |
|
||||
| M6b | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6c | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6d | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6e | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6f | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M7 | supporting.test | rc=1, 5/6 | a path outside the universe's supporting folders refuses |
|
||||
| M8 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code |
|
||||
|
||||
14 of 14 mutants killed, each on its named test only.
|
||||
|
||||
Suites (20:01:47Z):
|
||||
- `packages/library`: `node --test tests/*.test.mjs` rc=0, 98/98, 0 skipped.
|
||||
- `packages/business`: `node --test tests/` rc=0, 231/231, 0 skipped.
|
||||
|
||||
Not run here: every scripts/test-*.sh and the other packages. Those are Sage's 283 gate, after Webby's
|
||||
APPROVE.
|
||||
|
||||
## 2026-10-23 G1: mos-1528 OK for the mapping.test draft deepEqual (sage-3713)
|
||||
|
||||
This entry completes the N3 line above (BUILD.md:42, "only the draft deepEqual at mapping.test:48 gains
|
||||
`supporting: []`"). BUILD.md is append-only, so :42 stays as written. Mos OK'd the change of that old
|
||||
assertion in mos-1528. It is now at packages/library/tests/mapping.test.mjs:52, and commit 331653b38
|
||||
changed it. Everything else in that commit's hunk is new test lines, not changed old ones.
|
||||
|
||||
- Old, mapping.test.mjs:48 at 45dca69f6:
|
||||
`assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });`
|
||||
- New, mapping.test.mjs:52 at b2a1f4092:
|
||||
`assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });`
|
||||
|
||||
The line gains only `, supporting: []`; nothing else in it moved. The business.test and scope.test hunks
|
||||
add tests only (sage-3713: no OK needed). The other old-test change in this row is survey.test:73
|
||||
(mos-1330 item 4, 28918824c). Doc only.
|
||||
@@ -1,191 +0,0 @@
|
||||
# Row 283 (#1788) C2-01d design, draft 2 (Honker)
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md "C2-01d" at refactor 94c33a43c. Sources: the design at
|
||||
agents/gosalyn/work/case2/DESIGN.md b68d79663, the ruling after r11 (:207), the M12 paragraph (:300),
|
||||
1.1 (:755) and 1.2 (:817). Base 94c33a43c. Reviewer Webby, gate owner Sage. Nothing is built yet. The
|
||||
build waits for 282 to land (sage-2906).
|
||||
|
||||
## 1. Mapping (packages/library/src/mapping.mjs)
|
||||
|
||||
`supporting`:
|
||||
- Every universe kind takes an optional `supporting` list: plain `['name','books','playground','supporting']`,
|
||||
hybrid adds it to its seven keys, and a draft gets `['kind','name','playground','supporting']`.
|
||||
- Each value is checked as a playground prefix is: a string ending in `/` whose body passes `folder()`.
|
||||
- The output carries `supporting` on every universe, `[]` when absent, the way a draft carries
|
||||
`books: []`. classify then needs no `?? []`. The business writer writes the raw document
|
||||
(organization.mjs:195), not this output, so no business file changes shape.
|
||||
- The prefix rules, across every entry (ruling after r11):
|
||||
- A supporting prefix holding another supporting prefix, or one listed twice, refuses
|
||||
`library-mapping-invalid`.
|
||||
- A prefix equal to one of its own entry's book folders, or holding one, refuses `library-mapping-invalid`.
|
||||
- A prefix inside or holding another entry's book folder refuses `library-supporting-other-universe`.
|
||||
- Level comes from where the prefix sits, as for the playground: inside one of its own entry's books
|
||||
is book level, and outside every book is universe level. A draft's prefixes are all universe level.
|
||||
- Between the two lists (my choice, Q2):
|
||||
- A supporting prefix may equal a playground prefix of the same entry. That folder is writable.
|
||||
- A playground prefix may hold a supporting prefix of the same entry. The inner folder is supporting
|
||||
and the rest stays playground.
|
||||
- A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. classify
|
||||
checks supporting first, so that playground prefix could never match.
|
||||
- A supporting prefix equal to, holding or inside a playground prefix of another entry refuses
|
||||
`library-mapping-invalid`. Otherwise one path would have two universes.
|
||||
- The book loop at mapping.mjs:128-140 becomes a helper that both lists call with their own code and
|
||||
label. The playground's messages and codes stay byte-equal, so no existing test text moves.
|
||||
|
||||
`editor`:
|
||||
- The entry takes an optional `editor`. When present, it is an object with exactly the keys `name` and
|
||||
`email` (via `only()`). Each value is a string that is non-empty after trim and has no control
|
||||
character (`[\u0000-\u001f\u007f-\u009f]`), `<` or `>` (webby-0169 R1: git drops them from an ident
|
||||
and commits under a different name, so C2-02 would never see a failure). Anything else refuses
|
||||
`library-mapping-invalid`.
|
||||
- The output carries `editor: { name, email }` only when present, as `user` and `vaultPrefix` do. A save
|
||||
with no `editor` refusing is C2-02's job.
|
||||
|
||||
## 2. Scope (scope.mjs)
|
||||
|
||||
- `classify` gets a supporting loop before the playground loop (design 1.2). It returns `place(...)`
|
||||
with `{ scope: 'supporting', universe, book }`. The book is the book folder holding the prefix, or
|
||||
null at universe level and in a draft. The type and folder come from the first folder under the
|
||||
prefix, as for the playground.
|
||||
- The `.md` check stays first, so a `.canvas` or `.base` under a supporting prefix stays unmapped.
|
||||
- The comment above `classify` names the three prefix scopes and the order.
|
||||
|
||||
## 3. supportingPath (new supporting.mjs)
|
||||
|
||||
`supportingPath(mapping, universe, path, tree)` returns `path` or throws
|
||||
`LibraryError('library-write-story-path', ...)`. It is pure and reads no git.
|
||||
|
||||
- `tree` is the array `listTree` returns (reader.mjs:26, `{ mode, type, oid, size, path }`), so the
|
||||
C2-02 broker passes its listing as it is (Q3). A non-array `tree` throws a TypeError, because that is
|
||||
a caller bug, not a path class.
|
||||
- The checks run in this order, each with its own message naming the class:
|
||||
1. The path must be a string of at most 1,024 UTF-8 bytes (`Buffer.byteLength`).
|
||||
2. It must have no control character, using the same control class as `editor` (not `<` or `>`).
|
||||
3. It must have no backslash.
|
||||
4. It must not be absolute (a leading `/`).
|
||||
5. It must have no empty, `.` or `..` segment. That also catches `//` and a trailing `/`.
|
||||
6. `classify(mapping, path)` must give scope `supporting` and that same `universe`. This one check
|
||||
covers an unknown universe, an unmapped path, a `.canvas`, `.base` or attachment, a canon or
|
||||
story path, a playground path that isn't also supporting, and another universe's prefix. The scope
|
||||
rule then has one source, and M1 below hits both.
|
||||
7. Every proper prefix of the path (`a`, `a/b`, ...) and the path itself must not be in `tree` with
|
||||
mode `120000` (symlink) or `160000` (gitlink).
|
||||
8. No prefix of the path, and not the path itself, may equal a tree path or a tree directory
|
||||
(derived from the entries' paths) after `normalize('NFC').toLowerCase()` on both sides, unless
|
||||
it equals that tree path exactly. NFC as well as case, because APFS treats NFC and NFD forms of
|
||||
a name as one file (webby-0169 N1).
|
||||
Directories are checked too, because the folders under a supporting prefix are hers to name. A
|
||||
path in a tree that holds both `a.md` and `A.md` refuses, which matches R7.
|
||||
- The refusal messages quote the path with JSON, except for the control-character class, whose message
|
||||
leaves the path out.
|
||||
- The delete rule ("A delete also needs the path to exist at the tip as a 100644 blob", M12) isn't in
|
||||
the brief's supportingPath list. It stays with C2-02's delete (Q4).
|
||||
The C2-02 brief doesn't name it (only design :308), so BUILD.md carries it to C2-02 under Handoffs
|
||||
(webby-0169 Q4).
|
||||
- index.mjs exports `supportingPath`.
|
||||
|
||||
## 4. README (packages/library/README.md)
|
||||
|
||||
- Mapping: `supporting` and its rules, the cross-list rules, and `editor`.
|
||||
- Refusals: a `library-supporting-other-universe` row, and the new cases in the
|
||||
`library-mapping-invalid` row.
|
||||
- Scope: supporting first, then playground, then canon.
|
||||
- A new section, "Agent write paths", for supportingPath and its classes.
|
||||
|
||||
## 5. Callers of classify outside files owned (Q1 for Sage)
|
||||
|
||||
- survey.mjs:65 builds `{ canon: 0, playground: 0, unmapped: 0 }` and increments
|
||||
`scopes[classify(...).scope]`. A supporting file adds `supporting: NaN`, and formatSurvey (:92) never
|
||||
prints it. I propose a departure: add `supporting: 0` and print it, so the line becomes
|
||||
`scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6`. That re-pins survey.test:73 and
|
||||
adds a count test. Without it, the survey reports garbage once she marks a folder. The departure
|
||||
covers two files, packages/library/src/survey.mjs and packages/library/tests/survey.test.mjs.
|
||||
- No change in the rest, each for a reason:
|
||||
- reader.mjs:183 skips only unmapped, so supporting files are indexed like playground files.
|
||||
- R5 (:207) flags only canon to playground, as design 1.3 R5 says. A canon link to a supporting file
|
||||
raises nothing.
|
||||
- render.mjs:23 gives supporting a null badge. The supporting badge belongs to C2-10b (design :1415).
|
||||
- mirror.mjs:246 refuses only unmapped. Reading supporting files by scope is C2-05c's.
|
||||
- No bus or webui source filters on scope (git grep at 94c33a43c).
|
||||
- M9 already protects the whole `projects` key from agent edits (library-projects-protected.test). No
|
||||
test is added there.
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- mapping.test:
|
||||
- T1: `supporting` at book level, at universe level, in a hybrid and in a draft. The output carries
|
||||
it, with `[]` when absent. The draft `deepEqual` at :48 gains `supporting: []`.
|
||||
- T2: one test per prefix rule.
|
||||
- These refuse `library-mapping-invalid`: no trailing `/`, a bad folder, one holding another, one
|
||||
listed twice, one equal to its own book, one holding its own book, one holding a playground
|
||||
prefix, and one overlapping another entry's playground.
|
||||
- These refuse `library-supporting-other-universe`: one inside another universe's book, and one
|
||||
holding another universe's book.
|
||||
- An equal same-entry playground passes.
|
||||
- T3: `editor` passes, and absent passes. It refuses `library-mapping-invalid` for a non-object, an
|
||||
array, an extra key, a missing key, an empty or blank value, a non-string, and a control character,
|
||||
`<` or `>` in either value.
|
||||
- scope.test, T4:
|
||||
- Supporting at book level, universe level and in a draft each give their universe and book.
|
||||
- A folder in both lists is `supporting`, and a playground-only folder is `playground`.
|
||||
- A supporting `.canvas` or `.base` is unmapped.
|
||||
- supporting.test (new):
|
||||
- T5 passes at book level, universe level, in a draft, in a both-lists folder, and at exactly 1,024 bytes.
|
||||
- T6 has one refusal per class against a fixture tree with a symlink, a gitlink and a case variant.
|
||||
The classes are:
|
||||
- unmapped, `.canvas`, `.base`, an attachment, canon, playground-only, another universe's
|
||||
supporting prefix, and an unknown universe;
|
||||
- absolute, `..`, `.`, `//`, a trailing `/`, a backslash, a C0 control and DEL;
|
||||
- a symlink folder, a gitlink folder, and a symlink at the path itself;
|
||||
- a case-variant file, a case-variant folder, and an NFD-form variant of an NFC tree name;
|
||||
- 1,025 bytes made with a multi-byte character, so bytes and characters differ.
|
||||
- Each asserts the code `library-write-story-path`.
|
||||
- business.test: T7 adds one assertion that `validateBusinessDocument` refuses
|
||||
`library-supporting-other-universe`.
|
||||
- survey.test (only if Q1 is approved): T8 counts one supporting file, and :73 is re-pinned.
|
||||
|
||||
## 7. Mutants (exact strings go in BUILD.md)
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
|---|---|---|
|
||||
| M1 | classify checks playground before supporting (the brief's mutant) | T4 both-lists, T5 both-lists |
|
||||
| M2 | the other-universe fail for supporting is dropped | T2 |
|
||||
| M3 | the symlink and gitlink check is dropped | T6 |
|
||||
| M4 | the case check is dropped | T6 |
|
||||
| M4b | check 8 drops `normalize('NFC')` | T6 NFD variant |
|
||||
| M5 | the byte length uses `path.length` | T6 1,025 bytes |
|
||||
| M6 | the `editor` control-character check is dropped | T3 |
|
||||
| M6b | the `editor` `<` and `>` check is dropped | T3 |
|
||||
| M7 | supportingPath skips the universe match | T6 other universe |
|
||||
| M8 | the supporting-holds-playground check is dropped | T2 |
|
||||
|
||||
## 8. Gate
|
||||
|
||||
- Webby approves the manifest.
|
||||
- The library and business suites and every scripts/test-*.sh are green on Sage's gate.
|
||||
- Timeouts (mos-1275): each package `timeout -k 10 1800`, each script `timeout -k 10 300`, each single
|
||||
file and mutant `timeout -k 10 600`. These come from my row 256 gate at 10:34Z.
|
||||
- Before in-review, grep packages/*/tests for 64-hex pins on the changed files and for the changed
|
||||
message text, then drift-check BASE..refactor.
|
||||
|
||||
## 9. Questions
|
||||
|
||||
- Q1 (Sage): approve the survey.mjs and survey.test.mjs departure in section 5?
|
||||
- Q2 (Webby): the cross-list rules in section 1?
|
||||
- Q3 (Webby): `tree` as the `listTree` array?
|
||||
- Q4 (Webby): the delete's 100644 check stays with C2-02?
|
||||
- Q5 (Webby): `editor` refuses only blank values and control characters, as the brief says. `<` and `>`
|
||||
would break a git ident. I'd leave them to C2-02's commit step unless you want them refused here.
|
||||
|
||||
## 10. Draft 2 (webby-0169)
|
||||
|
||||
- R1 folded: `editor` refuses `<` and `>` too, with T3 cases and mutant M6b.
|
||||
- Q2 accepted. BUILD.md records the tradeoff: refusing a supporting prefix that holds a playground
|
||||
prefix goes past the brief, but it is the fail-closed reading and uses an existing code. The cost
|
||||
is that she can't nest a playground under a supporting folder, though she can nest the other way.
|
||||
- Q3 accepted. Q4: carried to C2-02 under Handoffs in BUILD.md, and Sage was asked to add it to C2-02's brief.
|
||||
- N1 folded into check 8 (NFC and case), with an NFD case in T6 and mutant M4b.
|
||||
- N2: the survey.test:73 pin gains only `supporting`, and the count test is the only addition
|
||||
(mos-1330, its own commit).
|
||||
- N3: before the build, re-grep tests outside packages/library for a `deepEqual` of the mapping output
|
||||
on the build base.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Row 217 (Case 2 C2-06): build notes
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md, section C2-06, at base 45dca69f6.
|
||||
Builder: Launchpad until 2026-10-23 19:09Z. Then Mos moved coding to Codex seats (mos-1500, Q245) and
|
||||
the row went to Beakley on beakley/row217.
|
||||
|
||||
## State at handoff
|
||||
|
||||
All code in this commit is UNTESTED. No node, no test and no mutant has run on it. No test file is written.
|
||||
`packages/harness/src/runner.mjs` is not started.
|
||||
|
||||
Done, untested:
|
||||
- `packages/library/src/limits.mjs` (new, pure): `REPLY_LIMITS`, `resolveReplyLimits(read)` gives
|
||||
`{requireCitation, citationsPerReply, fallback, unreadable}`, `checkReplyLimits(value)` gives a clone or null.
|
||||
An unreadable business file forces `requireCitation` true. A key falls back to its default when
|
||||
`replyLimits` is not an object or the key holds an invalid value.
|
||||
- `packages/library/src/guard.mjs` (new): `GUARD_CODES` in design 5.2's table order, `FIXED_TEXTS`,
|
||||
`WEB_CITE_DEFINITIONS`, `REASON_MAX` (300), `GuardError`, `parseReply`, `checkReply`, `libraryReader`,
|
||||
`lastCommits`, `guardReply`.
|
||||
- `packages/library/src/index.mjs`: exports for both.
|
||||
- `packages/business/src/business.mjs`: `replyLimits` is a known top key. The validated output doesn't carry it.
|
||||
- `packages/business/src/organization.mjs`: the M11 fence `replyLimitsFence(old, document, actor)` compares
|
||||
against `old.human`, refuses the tester and any `<definition>.<instance>` of `old.roles`, and is called for
|
||||
both `document` and `written`. `setReplyLimits` and `readRaw` are exported.
|
||||
- `packages/bus/src/broker.mjs`: the guard call, the page store, the check event, `library.limits.set`,
|
||||
`library.status` gains `replyLimits`, `role.claim` gains `library`, `thread.messages` gains `check` and `last`.
|
||||
- `packages/bus/src/server.mjs`, `client.mjs`: `BusError.reason` (sage-3599).
|
||||
- `packages/bus/src/runtime.mjs`: guard deps and the `library.limits.set` dispatch.
|
||||
- `packages/cli/src/limits.mjs`: `limits replies --file LIMITS`.
|
||||
|
||||
Left:
|
||||
- `packages/harness/src/runner.mjs`: the C2-07 runner work (see "Runner plan" below).
|
||||
- `packages/cli/README.md`: a line for `limits replies`.
|
||||
- Every test in the brief and the rulings below, then the mutants.
|
||||
- The rebases onto rows 283, 216 and 285, with the drift checks below.
|
||||
- This file's gate results and the manifest.
|
||||
|
||||
## Rulings in force
|
||||
|
||||
sage-3595:
|
||||
1. Supporting scope: a stub classify test now. Before in-review, rebase onto row 283's landing
|
||||
(honker/row283-build bd2c1c4ea when ruled) and add two real-fixture tests: a supporting cite with
|
||||
`as: "canon"` refuses `playground-cited-as-canon`, and the stored check result names `supporting`.
|
||||
2. runtime.mjs is approved for the guard wiring and the one `library.limits.set` branch. List its hunks here,
|
||||
name it in the manifest, and drift-check it after the 216 and 285 rebases.
|
||||
3. The check result is an `action.allowed` event with `action: "message.checked"`. The events kind CHECK at
|
||||
`packages/bus/src/migrations/3b.sql:9` has no `message.checked`, so a new kind would be a schema step.
|
||||
The event and the message INSERT share one transaction: a mutant that splits them must fail.
|
||||
4. readLibrary at the cited commit stands in for the C2-05a index. On the 216 rebase, compare 216's index
|
||||
set with readLibrary's for the fixture and record the result here.
|
||||
5. The retry is for a library business only. Tests: a JSON body between 32,768 characters and 64 KiB gets
|
||||
the retry; outside a library business nothing changes.
|
||||
6. Old `role.claim` pins stay as they are.
|
||||
7. `render.mjs:6` and `render.test.mjs:58` belong to row 225. Leave them.
|
||||
8. `thread.messages` rows that carry `check` get `last` per path cite: the file's last commit at the served
|
||||
tip, one git call per distinct path per read. Test with a fixture file committed after the cite.
|
||||
|
||||
sage-3599: `BusError` gets an optional `reason`. Only the citation guard's refusals set it. The envelope
|
||||
carries `reason` only when it is set. It is the guard's own text, never a quote, file text, url or a path
|
||||
other than the cited one, at most 300 characters. Tests: a guard refusal reaches the runner with code and
|
||||
reason; a refusal without a reason has an envelope byte-identical to today's, checked against an existing
|
||||
pin; a mutant that drops the client.mjs pass-through fails.
|
||||
|
||||
sage-3602: store `file` per path cite from the same git log call as the trailer. Test with a cited commit
|
||||
that didn't touch the file, so `file` differs from the cited commit, and `last` equals `file`.
|
||||
|
||||
## The check-result contract with row 225 (launchpad-0118, launchpad-0120, morgana-0200, sage-3596)
|
||||
|
||||
- Stored on the `message.checked` event: `{citations: [...]}` in body order.
|
||||
- A path cite: `{scope, agent, file}`. `scope` is the classify scope. `agent` is the cite's file's last
|
||||
commit's `Mosaic-Agent` trailer at the cited commit, or null. `file` is that commit's 40-hex id, or null.
|
||||
- A url cite: `{scope: "web", agent: null}`.
|
||||
- On `thread.messages`, each path cite gains `last`: the file's last commit at the served tip (40-hex or null).
|
||||
The Console compares `last` with `file`.
|
||||
- Rows without `check` are unchanged. No `changed` boolean. The webui passes rows through unchanged.
|
||||
|
||||
## Hunks outside the brief's file list
|
||||
|
||||
- `packages/bus/src/runtime.mjs`: imports `setReplyLimits`; the library deps gain `guard`, `replyLimits`
|
||||
(organizationWriter `readRaw`, or `{readable: false}` with no organization), `last` and `GuardError`;
|
||||
the organization dispatch gains `library.limits.set`.
|
||||
- `packages/bus/src/server.mjs`: a `refusal(e, other)` helper above serve's doc comment builds every
|
||||
BusError envelope and adds `reason` only when set. The synchronous request path accepts a Promise result
|
||||
(the guard is async), with a 120 s timeout, and releases `own` when it settles.
|
||||
- `packages/bus/src/client.mjs`: `new BusError(r.error, reason)` with `reason` a string cut at 300.
|
||||
|
||||
## Decisions and their tradeoffs
|
||||
|
||||
- The guard runs async outside the SQL transaction, then re-enters request() through `#guardPass`, so
|
||||
message.send's insert and the check event stay one synchronous transaction.
|
||||
- The guard runs before message.send's own checks, so a guard code can come before an authority code.
|
||||
- The check event id is derived from the message id (a UUID-shaped sha256), so `thread.messages` finds it
|
||||
through the unique index on `events.id`.
|
||||
- With no organization configured, `replyLimits` reads as unreadable, so it fails closed.
|
||||
- With more than one library project, the guard uses the first in the business file's order.
|
||||
- A library business without guard deps refuses guarded sends with `project-not-configured`.
|
||||
- The page store is per cap: 20 pages of 200 KB, kept only in a library business, dropped at session
|
||||
delete or disconnect and at run end.
|
||||
- The CLI `replies` verb reads the revision itself from `limits`.
|
||||
- Guard reasons never carry a url or a quote (sage-3599).
|
||||
|
||||
## Runner plan (not built)
|
||||
|
||||
`role.claim`'s reply is discarded at `runner.mjs:462`; keep it and read `library === true`. In a library
|
||||
business, at the reply site (`runner.mjs:582-587`):
|
||||
- Fixed texts `(the session gave no answer)` and `(the turn failed)`; the reason stays in the run log.
|
||||
A fixed text goes with no note, and `unslop.sent()` is not called, so the note waits.
|
||||
- A body that parses as a JSON object with a string `text` gets `unslop.note()` appended to `text`, is
|
||||
serialized again and is never clipped. A plain body keeps `clip(body) + unslop.note()`.
|
||||
- On a refusal whose code is in `GUARD_CODES`, or `request-too-large` from the client, or the broker's
|
||||
`invalid-request` from `string(a.body, 32768)` for a body over 32,768 characters (ruling 5; the runner
|
||||
can tell it by the body length it sent), run one new turn in the same
|
||||
session naming the code and `err.reason`, or the 64 KiB limit. Bracket it with usage like other turns.
|
||||
- After the retry: a table code sends `(no answer passed the citation check)`, too large sends
|
||||
`(the answer was too long to send)`. One retry per answer, whatever the cause.
|
||||
- `runTurn`'s output buffer holds up to 4 * BODY_MAX characters, so a reply over 64 KiB can reach the send.
|
||||
- Outside a library business nothing changes.
|
||||
|
||||
## Mutants planned
|
||||
|
||||
- The M11 writer check removed.
|
||||
- `document.human` compared instead of `old.human`.
|
||||
- The check event split from the insert transaction.
|
||||
- The client.mjs reason pass-through dropped.
|
||||
+134
-7
@@ -203,15 +203,26 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
constructor(code) {
|
||||
// C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
if (reason !== undefined) this.reason = reason;
|
||||
}
|
||||
}
|
||||
const fail = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
// C2-06 (design 5.2, B7): the page store keeps a session's last 20 broker fetches, 200 KB of text each.
|
||||
const PAGE_COUNT = 20;
|
||||
const PAGE_BYTES = 200 * 1024;
|
||||
// C2-06: the guard's check event takes an id derived from the message's, so a thread read finds it
|
||||
// through the unique index on events.id (there is none on subject).
|
||||
const checkEventId = (mid) => {
|
||||
const h = createHash('sha256').update('message.checked:' + mid).digest('hex');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
||||
};
|
||||
const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
|
||||
const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
|
||||
function keys(x, allowed, required = []) {
|
||||
@@ -389,6 +400,11 @@ export class Broker {
|
||||
#credentialVerbs;
|
||||
#project;
|
||||
#library;
|
||||
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
|
||||
// In memory, dropped with the session.
|
||||
#pages = new Map();
|
||||
// C2-06: { s, check } while a guarded message.send or a thread.messages re-enters request().
|
||||
#guardPass = null;
|
||||
#usage;
|
||||
#limits;
|
||||
#harnessOn;
|
||||
@@ -1403,7 +1419,10 @@ export class Broker {
|
||||
this.#launcherReleased.delete(key);
|
||||
this.#accessRevoked.delete(key);
|
||||
for (const [cap, s] of this.#sessions)
|
||||
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
|
||||
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) {
|
||||
this.#sessions.delete(cap);
|
||||
this.#pages.delete(cap);
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
const h = this.#own(session);
|
||||
const released = h?.op === 'claim' && h.holder_run === session.run;
|
||||
@@ -1485,6 +1504,7 @@ export class Broker {
|
||||
}
|
||||
disconnect(cap) {
|
||||
this.#sessions.delete(cap);
|
||||
this.#pages.delete(cap);
|
||||
}
|
||||
identity(cap) {
|
||||
const s = this.#session(cap);
|
||||
@@ -4666,10 +4686,19 @@ export class Broker {
|
||||
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (LIBRARY_VERBS.includes(request.verb))
|
||||
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
|
||||
// C2-06 (design 5.2): an agent's message to the human in a library business passes the citation
|
||||
// guard first, outside any transaction, and thread.messages adds each path cite's `last`. Both
|
||||
// then re-enter request() for the synchronous path below.
|
||||
if (this.#guardPass?.s !== s) {
|
||||
if (request.verb === 'message.send' && this.#guarded(s, args.to))
|
||||
return this.#guardedSend(cap, s, request, structuredClone(args));
|
||||
if (request.verb === 'thread.messages' && this.#libraryProject(s.business) && this.#library?.last)
|
||||
return this.#withLast(cap, s, request);
|
||||
}
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
||||
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
// logs the same receipt; only the actor differs: the role definition and instance.
|
||||
@@ -4715,6 +4744,12 @@ export class Broker {
|
||||
keys(args, VETO_KEYS, VETO_KEYS);
|
||||
this.#human(s);
|
||||
this.#mayHuman(s, "veto.set", args.name);
|
||||
}
|
||||
// C2-06 M11: the human's replyLimits verb. The tester is refused by the gate above and an agent
|
||||
// session by human-required; the writer's fence refuses any other actor.
|
||||
else if (request.verb === "library.limits.set") {
|
||||
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
|
||||
this.#human(s);
|
||||
} else {
|
||||
keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
|
||||
@@ -5138,7 +5173,14 @@ export class Broker {
|
||||
const context = { business: this.#business(s.business), project: args.project };
|
||||
let result;
|
||||
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
|
||||
else if (verb === 'library.status') result = await this.#library.status(context);
|
||||
else if (verb === 'library.status') {
|
||||
result = await this.#library.status(context);
|
||||
// C2-06: the reply limits in force, read from the file now, and the keys that fell back.
|
||||
if (this.#library.replyLimits) {
|
||||
const { requireCitation, citationsPerReply, fallback, unreadable } = this.#library.replyLimits(s.business);
|
||||
result = { ...result, replyLimits: { requireCitation, citationsPerReply, fallback, problem: unreadable ? 'reply-limits-unreadable' : null } };
|
||||
}
|
||||
}
|
||||
else {
|
||||
// A fetch that did not move served still answers with the code, so the refusal is recorded.
|
||||
const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null });
|
||||
@@ -5152,6 +5194,78 @@ export class Broker {
|
||||
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
|
||||
}
|
||||
}
|
||||
// C2-06: the business's library project, the first in file order, or null.
|
||||
#libraryProject(business) {
|
||||
return Object.entries(this.#business(business).projects).find(([, p]) => p.kind === 'library')?.[0] ?? null;
|
||||
}
|
||||
// C2-06 (design 5.2): every agent session's message to the human in a business with a library project
|
||||
// is guarded, whatever the sender's definition.
|
||||
#guarded(s, to) {
|
||||
return to === 'human' && !s.human && !s.tester && !s.user && !s.reader && !s.console && this.#libraryProject(s.business) !== null;
|
||||
}
|
||||
#reenter(cap, s, request, check) {
|
||||
this.#guardPass = { s, check };
|
||||
try {
|
||||
return this.request(cap, request);
|
||||
} finally {
|
||||
this.#guardPass = null;
|
||||
}
|
||||
}
|
||||
// As #requestProject: the git reads run outside any SQL transaction. A guard refusal is recorded with
|
||||
// its code and reaches the caller with the guard's reason; the send then runs, and stores the check
|
||||
// result in its own transaction.
|
||||
async #guardedSend(cap, s, request, args) {
|
||||
let check;
|
||||
try {
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
if (!this.#library?.guard || !this.#library?.replyLimits) fail('project-not-configured');
|
||||
if (typeof args.body !== 'string') fail('invalid-request');
|
||||
const business = this.#business(s.business);
|
||||
check = await this.#library.guard({ business, project: this.#libraryProject(s.business) }, args.body, {
|
||||
limits: this.#library.replyLimits(s.business),
|
||||
definition: business.roles[s.role]?.definition ?? null,
|
||||
pages: this.#pages.get(cap) ?? [],
|
||||
});
|
||||
} catch (e) {
|
||||
const Guard = this.#library?.GuardError;
|
||||
throw this.#refused(s, Guard && e instanceof Guard ? new BusError(e.code, e.reason) : e, request);
|
||||
}
|
||||
return this.#reenter(cap, s, request, check);
|
||||
}
|
||||
// C2-06 (sage-3595, morgana-0200): each path cite of a checked row gains `last`, the file's last commit
|
||||
// at the served tip, one git call per distinct path. Rows without `check` are unchanged.
|
||||
async #withLast(cap, s, request) {
|
||||
const rows = this.#reenter(cap, s, request, null);
|
||||
if (!rows.some((m) => m.check)) return rows;
|
||||
const cites = (m) => {
|
||||
try {
|
||||
const c = JSON.parse(m.body).citations;
|
||||
return Array.isArray(c) ? c : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
let last = new Map();
|
||||
try {
|
||||
const paths = rows.filter((m) => m.check).flatMap((m) => cites(m).filter((c) => typeof c?.path === 'string').map((c) => c.path));
|
||||
last = await this.#library.last({ business: this.#business(s.business), project: this.#libraryProject(s.business) }, paths);
|
||||
} catch {
|
||||
last = new Map();
|
||||
}
|
||||
return rows.map((m) => {
|
||||
if (!m.check) return m;
|
||||
const body = cites(m);
|
||||
return { ...m, check: { ...m.check, citations: m.check.citations.map((c, i) =>
|
||||
c.scope === 'web' ? c : { ...c, last: last.get(body[i]?.path) ?? null }) } };
|
||||
});
|
||||
}
|
||||
// C2-06 (B7): a fetch's text, kept for url cites in a library business only.
|
||||
#keepPage(cap, s, url, page) {
|
||||
if (!this.#sessions.has(cap) || !this.#libraryProject(s.business) || typeof page?.text !== 'string') return;
|
||||
const text = Buffer.from(page.text).subarray(0, PAGE_BYTES).toString();
|
||||
const kept = { url, finalUrl: typeof page.finalUrl === 'string' ? page.finalUrl : null, text };
|
||||
this.#pages.set(cap, [...(this.#pages.get(cap) ?? []), kept].slice(-PAGE_COUNT));
|
||||
}
|
||||
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
|
||||
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
|
||||
async requestTask(cap, request, handler) {
|
||||
@@ -5235,6 +5349,7 @@ export class Broker {
|
||||
throw refuse(new BusError('web-failed'));
|
||||
}
|
||||
complete(out.summary);
|
||||
if (verb === 'web.fetch') this.#keepPage(cap, s, args.url, out.result);
|
||||
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
|
||||
}
|
||||
// Row 210 (#1707, design D5): host.run, one sudo command on a registered host over ssh, for a
|
||||
@@ -5361,7 +5476,7 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
this.#routeSession(s.session);
|
||||
return { role: s.role, run: s.run, thread: s.thread };
|
||||
return { role: s.role, run: s.run, thread: s.thread, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
|
||||
}
|
||||
if (
|
||||
this.#store.get(
|
||||
@@ -5385,7 +5500,8 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
this.#routeWaiting(s.business, s.role);
|
||||
return { role: s.role, run: s.run };
|
||||
// C2-06 (C2-07): the runner's switch to the library texts and retry. Absent elsewhere.
|
||||
return { role: s.role, run: s.run, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
|
||||
}
|
||||
case 'role.release': {
|
||||
keys(a, []);
|
||||
@@ -5749,6 +5865,10 @@ export class Broker {
|
||||
if (this.#userThread(s) && to === 'human') fail('user-thread-human');
|
||||
this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to);
|
||||
string(a.body, 32768);
|
||||
// C2-06: a guarded send is stored only through #guardedSend's pass, with its check result.
|
||||
const guarded = this.#guarded(s, to);
|
||||
if (guarded && this.#guardPass?.s !== s) fail('storage-refused');
|
||||
const check = guarded ? this.#guardPass.check : null;
|
||||
if (
|
||||
a.class !== undefined &&
|
||||
![
|
||||
@@ -5795,6 +5915,9 @@ export class Broker {
|
||||
a.corrects ?? null,
|
||||
a.body,
|
||||
);
|
||||
// C2-06: the stored check result shares the message's transaction (sage-3595 item 3). No new
|
||||
// event kind, which would be a schema step: the events kind CHECK is migrations/3b.sql:9.
|
||||
if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));
|
||||
const cancelled = this.#route(s.business, mid, to, thread);
|
||||
const request = s.human
|
||||
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
|
||||
@@ -6414,11 +6537,15 @@ export class Broker {
|
||||
)?.seq;
|
||||
if (after === undefined) fail('message-not-found');
|
||||
}
|
||||
// C2-06 (row 225, morgana-0198): a row the citation guard checked carries the stored result.
|
||||
return this.#store.all(
|
||||
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
|
||||
session,
|
||||
after,
|
||||
);
|
||||
).map((m) => {
|
||||
const e = this.#store.get("SELECT body FROM events WHERE id=? AND kind='action.allowed' AND subject=?", checkEventId(m.id), m.id);
|
||||
return e ? { ...m, check: JSON.parse(e.body).check } : m;
|
||||
});
|
||||
}
|
||||
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
|
||||
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
|
||||
|
||||
@@ -41,7 +41,8 @@ export class Client {
|
||||
const r = JSON.parse(input.toString('utf8'));
|
||||
if (r.ok === true) end(null, r.result);
|
||||
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
|
||||
end(new BusError(r.error));
|
||||
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
|
||||
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
|
||||
else end(new BusError('invalid-response'));
|
||||
} catch {
|
||||
end(new BusError('invalid-response'));
|
||||
|
||||
@@ -14,7 +14,7 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { loadRole } from '../../business/src/role.mjs';
|
||||
@@ -305,6 +305,13 @@ export async function startBroker({
|
||||
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
|
||||
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
|
||||
LibraryError: libraryModule.LibraryError,
|
||||
// C2-06: the citation guard, the reply limits read from the business file at each check (none
|
||||
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
|
||||
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
|
||||
replyLimits: id => libraryModule.resolveReplyLimits(organization
|
||||
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
|
||||
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
|
||||
GuardError: libraryModule.GuardError,
|
||||
},
|
||||
project: {
|
||||
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
|
||||
@@ -334,6 +341,8 @@ export async function startBroker({
|
||||
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
|
||||
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
|
||||
else if(verb==='veto.set')result=instances.veto(id,args,actor);
|
||||
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
|
||||
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
|
||||
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
|
||||
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
|
||||
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
|
||||
|
||||
@@ -7,6 +7,11 @@ const LIMIT = 65536;
|
||||
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
|
||||
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
|
||||
const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
|
||||
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
|
||||
const refusal = (e, other) => e instanceof BusError
|
||||
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
|
||||
: { ok: false, error: other };
|
||||
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
|
||||
@@ -15,6 +20,7 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
|
||||
// host-not-configured.
|
||||
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
|
||||
// (row 252) proof.
|
||||
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
@@ -96,7 +102,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestTask(cap, { verb: r.verb, args: r.args ?? {} }, tasks.handle)
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
|
||||
(e) => finish(refusal(e, 'adapter-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -114,7 +120,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestWeb(cap, { verb: r.verb, args: r.args ?? {} }, service, { onAdmit: () => socket.setTimeout(service.timeout) })
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
|
||||
(e) => finish(refusal(e, 'web-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -130,7 +136,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestHost(cap, { verb: r.verb, args: r.args ?? {} }, host, { onAdmit: (ms) => socket.setTimeout(ms + 10000) })
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
|
||||
(e) => finish(refusal(e, 'host-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -153,14 +159,26 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
socket.setTimeout(120_000);
|
||||
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
|
||||
result => { if (!socket.destroyed) finish({ ok: true, result }); },
|
||||
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
|
||||
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
|
||||
).finally(() => { if (own) release(); });
|
||||
return;
|
||||
}
|
||||
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
|
||||
// C2-06: a guarded message.send and a library business's thread.messages read Git first, so
|
||||
// the broker answers them with a promise. Every other verb stays synchronous.
|
||||
if (result instanceof Promise) {
|
||||
const own = transient;
|
||||
transient = null;
|
||||
socket.setTimeout(120_000);
|
||||
result.then(
|
||||
value => { if (!socket.destroyed) finish({ ok: true, result: value }); },
|
||||
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
|
||||
).finally(() => { if (own) release(); });
|
||||
return;
|
||||
}
|
||||
finish({ ok: true, result });
|
||||
} catch (e) {
|
||||
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
|
||||
finish(refusal(e, 'invalid-envelope'));
|
||||
} finally {
|
||||
if (transient) release();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { join, dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
|
||||
import { requireId, sha256, canonicalJson } from "./util.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -80,6 +81,33 @@ function changes(old, next, path = "") {
|
||||
}];
|
||||
}
|
||||
|
||||
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
|
||||
// the business's human as the file stood before the write, and no agent or the tester under that
|
||||
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
|
||||
function replyLimitsFence(old, document, actor) {
|
||||
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
|
||||
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
|
||||
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
|
||||
// The reason names each changed key and its value in force (the brief's M11).
|
||||
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const before = old.replyLimits, after = document?.replyLimits;
|
||||
const named = plain(before) && plain(after)
|
||||
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
|
||||
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
|
||||
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
|
||||
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
|
||||
}
|
||||
|
||||
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
|
||||
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
|
||||
// M11 fence refuses any actor but the human.
|
||||
export function setReplyLimits(writer, id, args, actor) {
|
||||
const replyLimits = checkReplyLimits(args?.replyLimits);
|
||||
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
|
||||
const { document } = writer.read(id);
|
||||
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const evidence = join(dir, "businesses", ".history");
|
||||
const logFile = id => join(evidence, id, "changes.jsonl");
|
||||
@@ -93,6 +121,19 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
return { document, revision: sha256(bytes) };
|
||||
}
|
||||
|
||||
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
|
||||
// file that fails validation still yields its limits. { readable: false } when the file is missing,
|
||||
// unreadable, not JSON or not an object.
|
||||
function readRaw(id) {
|
||||
try {
|
||||
const document = JSON.parse(regular(businessFilePath(id, dir)));
|
||||
return document !== null && typeof document === "object" && !Array.isArray(document)
|
||||
? { readable: true, document } : { readable: false };
|
||||
} catch {
|
||||
return { readable: false };
|
||||
}
|
||||
}
|
||||
|
||||
function withLock(id, fn) {
|
||||
const parent = join(dir, "businesses");
|
||||
directory(evidence);
|
||||
@@ -191,6 +232,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
// mapping, including library playgrounds and hybrid universes.
|
||||
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
replyLimitsFence(old, document, actor);
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
@@ -198,6 +240,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const written = JSON.parse(bytes);
|
||||
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
replyLimitsFence(old, written, actor);
|
||||
validateBusinessDocument(written, file, { rolesDir });
|
||||
const after = sha256(bytes);
|
||||
const transaction = randomUUID();
|
||||
@@ -245,5 +288,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -27,10 +27,6 @@ test('C2-04: library mappings load without roots, remain frozen, and retain mapp
|
||||
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
|
||||
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
|
||||
// C2-01d: a supporting prefix inside another universe's book refuses with its own code.
|
||||
doc.projects.books = mappingEntry();
|
||||
doc.projects.books.universes.harbor.supporting = ['Second World/Notes/'];
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-supporting-other-universe/);
|
||||
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
|
||||
});
|
||||
|
||||
@@ -7,9 +7,10 @@ import { CliError } from "./errors.mjs";
|
||||
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
|
||||
|
||||
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
|
||||
// C2-06 M11: `replies --file LIMITS` replaces replyLimits with the file's object, at the current revision.
|
||||
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " +
|
||||
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants";
|
||||
"reset PATH... [--business ID] | replies --file LIMITS [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants; LIMITS keys: requireCitation, citationsPerReply";
|
||||
|
||||
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
|
||||
|
||||
@@ -67,8 +68,9 @@ export async function limitsCommand(args, io, callFor) {
|
||||
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
||||
(verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) ||
|
||||
const fileVerb = verb === "set" || verb === "replies";
|
||||
if (!["view", "set", "reset", "replies"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
||||
(!fileVerb && values["--file"]) || (fileVerb && !values["--file"]) ||
|
||||
(verb === "reset" && parsed.positional.length < 2)) {
|
||||
throw new CliError(LIMITS_USAGE, 4);
|
||||
}
|
||||
@@ -78,6 +80,11 @@ export async function limitsCommand(args, io, callFor) {
|
||||
// result's `tokens` lists each token limit: counted, not-enforced or check-failed.
|
||||
let result;
|
||||
if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")));
|
||||
else if (verb === "replies") {
|
||||
const replyLimits = JSON.parse(readFileSync(values["--file"], "utf8"));
|
||||
const { revision } = await call("limits", {});
|
||||
result = await call("library.limits.set", { revision, replyLimits });
|
||||
}
|
||||
else if (verb === "reset") {
|
||||
const { revision } = await call("limits", {});
|
||||
result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] });
|
||||
|
||||
@@ -31,72 +31,33 @@ deep-frozen copy. `where` names the entry in messages. `fail(code, message)` def
|
||||
- `user` is the bot account name from design step 3, required for sync. It is optional in the
|
||||
mapping and checked with row 199's account name pattern, `[A-Za-z0-9_.-]`, 1 to 100 characters.
|
||||
- `vaultPrefix` is optional, one folder name.
|
||||
- `editor` is optional, `{ "name", "email" }` plus an optional `"user"`: the ident her Console saves commit under
|
||||
(C2-01d). Each value is text that is non-empty after trim, with no control character
|
||||
(`U+0000`-`U+001F`, `U+007F`-`U+009F`), `<` or `>`, and no whitespace or git-stripped character at
|
||||
either end. `user` is her Gitea login for the `library-human` slot (sage-2925), checked as the
|
||||
entry's `user` is. `editor` and its `user` come back only when given; a save without them refuses
|
||||
in C2-02.
|
||||
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
|
||||
`manuscript` lists folder names and shares none with `types`.
|
||||
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
|
||||
plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or
|
||||
`{ "pin": { "entity", "field", "value" } }`) and `notes`, and may have `books: []`. A draft
|
||||
universe has only `kind: "draft"`, `name` and one or more `playground` prefixes, and comes back as
|
||||
`{ kind: "draft", name, books: [], playground, supporting }`. `kind` is absent, `"hybrid"` or `"draft"`.
|
||||
`{ kind: "draft", name, books: [], playground }`. `kind` is absent, `"hybrid"` or `"draft"`.
|
||||
- A playground prefix ends in `/` and has one of three levels (C2-01c). Book level lies inside one of
|
||||
its own entry's book folders. Universe level, in a plain universe or a hybrid, lies outside every
|
||||
book folder. A draft's prefixes all lie outside every book folder. No prefix holds another or is
|
||||
listed twice, across every entry.
|
||||
- Every universe kind may also have `supporting`, a list of prefixes where agents may write (C2-01d,
|
||||
M12). It comes back on every universe, `[]` when absent. A supporting prefix follows the playground's
|
||||
rules: it ends in `/`, has the same three levels, holds no other supporting prefix and is listed
|
||||
once, across every entry.
|
||||
- Between the two lists: a supporting prefix may equal a playground prefix of its own entry, and a
|
||||
playground prefix may hold one of its own entry's supporting prefixes. A supporting prefix may not
|
||||
hold a playground prefix, which scope would then never reach, and may not equal, hold or lie inside
|
||||
another entry's playground prefix.
|
||||
|
||||
Refusals:
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `library-playground-other-universe` | a playground prefix lies inside, or holds, a book folder of another entry |
|
||||
| `library-supporting-other-universe` | a supporting prefix lies inside, or holds, a book folder of another entry |
|
||||
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground or supporting prefix that holds another of its list or is listed twice, a prefix equal to a book folder or holding its own entry's book, a supporting prefix that holds a playground prefix or overlaps another entry's, a bad `editor`, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
|
||||
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground prefix that holds another or is listed twice, a prefix equal to a book folder or holding its own entry's book, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
|
||||
|
||||
## Scope
|
||||
|
||||
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
|
||||
mapped. It checks prefixes first, supporting before playground. A path under any entry's supporting
|
||||
prefix is `supporting`, and otherwise one under a playground prefix is `playground`. Either way that
|
||||
entry is `universe` and the book folder holding the prefix is `book`, or `null` at universe level and
|
||||
mapped. It checks prefixes first. A path under any entry's playground prefix is `playground`, with that
|
||||
entry as `universe` and the book folder holding the prefix as `book`, or `null` at universe level and
|
||||
in a draft. Any other path under a book folder is `canon`. Everything else is `unmapped`. The type is
|
||||
the first folder after the prefix, or after the book: a `types` name, `manuscript`, or `null`.
|
||||
|
||||
## Agent write paths
|
||||
|
||||
`supportingPath(mapping, universe, path, tree)` returns `path` when an agent may write it in that
|
||||
universe, else it refuses `library-write-story-path` (C2-01d, M12). `tree` is the array `listTree`
|
||||
returns for the commit the write lands on; anything else is a TypeError. It reads no git. The checks
|
||||
run in this order, so a path with two faults always names the same one:
|
||||
|
||||
1. a string of at most 1,024 UTF-8 bytes;
|
||||
2. no control character (the `editor` class), and the message leaves the path out;
|
||||
3. no backslash;
|
||||
4. not absolute;
|
||||
5. no empty, `.` or `..` segment, which also refuses `//` and a trailing `/`;
|
||||
6. `classify` gives `supporting` in that same universe, which refuses unmapped, `.canvas`, `.base`
|
||||
and attachment paths, canon, a playground-only folder and another universe's prefix;
|
||||
7. no folder on the way, and not the path itself, is a symlink (`120000`) or gitlink (`160000`) in
|
||||
`tree`;
|
||||
8. no folder on the way, and not the path itself, matches a tree path or tree folder after
|
||||
`normalize('NFC').toLowerCase()` unless it is that exact name. APFS folds case and Unicode form,
|
||||
so her checkout would put the file somewhere else. A tree that already holds case twins refuses a
|
||||
write to either (R7).
|
||||
|
||||
A delete's further rule, that the path exists at the tip as a `100644` blob, belongs to the C2-02
|
||||
broker.
|
||||
the first folder after the playground prefix, or after the book: a `types` name, `manuscript`, or
|
||||
`null`.
|
||||
|
||||
## Reader
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// C2-06 (design 5.2, rulings after r11 Q222, Q223 and Q226): the citation guard. The broker runs it
|
||||
// before it stores an agent's message to the human in a business with a library project. It reads
|
||||
// only the citations, never what the text says. Codes are design 5.2's table.
|
||||
import { git } from '../../project/src/git.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs';
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { mirrorPath, readSyncState } from './mirror.mjs';
|
||||
import { plain } from './limits.mjs';
|
||||
|
||||
export const GUARD_CODES = Object.freeze(['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid',
|
||||
'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']);
|
||||
|
||||
// The runner's four fixed texts in a library business. The guard accepts exactly these bodies when
|
||||
// requireCitation is true; an agent that sends one gains nothing it couldn't say anyway.
|
||||
export const FIXED_TEXTS = Object.freeze({
|
||||
empty: '(the session gave no answer)',
|
||||
failed: '(the turn failed)',
|
||||
uncited: '(no answer passed the citation check)',
|
||||
tooLong: '(the answer was too long to send)',
|
||||
});
|
||||
|
||||
// Web cites are for these definitions only (design 5.2).
|
||||
export const WEB_CITE_DEFINITIONS = Object.freeze(['research', 'companion']);
|
||||
|
||||
// The reason reaches the runner (sage-3599): the guard's own text naming the check, the cite and the
|
||||
// limit, never a quote or file text, and at most REASON_MAX characters.
|
||||
export const REASON_MAX = 300;
|
||||
export class GuardError extends LibraryError {
|
||||
constructor(code, reason) { reason = reason.slice(0, REASON_MAX); super(code, reason); this.reason = reason; }
|
||||
}
|
||||
const refuse = (code, reason) => { throw new GuardError(code, reason); };
|
||||
|
||||
const SHA = /^[a-f0-9]{40}$/;
|
||||
|
||||
const LIBRARY_CITE = ['path', 'commit', 'lines', 'quote'];
|
||||
const WEB_CITE = ['url', 'quote'];
|
||||
const only = (value, required, optional = []) => plain(value)
|
||||
&& required.every(k => Object.hasOwn(value, k)) && Object.keys(value).every(k => required.includes(k) || optional.includes(k));
|
||||
const text = value => typeof value === 'string' && value.length > 0 && !value.includes('\0');
|
||||
|
||||
function libraryCite(c) {
|
||||
return only(c, LIBRARY_CITE, ['as']) && text(c.path) && c.path.length <= 4096 && typeof c.commit === 'string' && SHA.test(c.commit)
|
||||
&& Array.isArray(c.lines) && c.lines.length === 2 && c.lines.every(n => Number.isSafeInteger(n) && n >= 1) && c.lines[0] <= c.lines[1]
|
||||
&& text(c.quote) && (!Object.hasOwn(c, 'as') || c.as === 'canon');
|
||||
}
|
||||
const webCite = c => only(c, WEB_CITE) && text(c.url) && c.url.length <= 4096 && text(c.quote);
|
||||
|
||||
// A body is free text, or a JSON object {text, citations} that must fit the schema. Returns
|
||||
// { text, citations, cited } where `cited` says the body was the JSON form.
|
||||
export function parseReply(body) {
|
||||
let value;
|
||||
try { value = JSON.parse(body); } catch { return { text: body, citations: [], cited: false }; }
|
||||
if (!plain(value)) return { text: body, citations: [], cited: false };
|
||||
if (!only(value, ['text', 'citations']) || typeof value.text !== 'string' || !Array.isArray(value.citations))
|
||||
refuse('reply-invalid', 'a JSON object reply holds exactly "text", a string, and "citations", a list');
|
||||
value.citations.forEach((c, i) => {
|
||||
if (!libraryCite(c) && !webCite(c))
|
||||
refuse('reply-invalid', `citation ${i + 1} is neither {path, commit, lines, quote, as?} nor {url, quote}`);
|
||||
});
|
||||
return { text: value.text, citations: value.citations, cited: true };
|
||||
}
|
||||
|
||||
const squash = value => value.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Design 5.2's checks, in the table's order. `limits` comes from resolveReplyLimits. `library` reads
|
||||
// git for a library cite (libraryReader below). `web` is { allowed, pages } for this sender: whether
|
||||
// its definition may cite a url, and the session's kept fetches. Returns the stored check result,
|
||||
// { citations: [{ scope, agent, file }] } in body order (agreed with row 225, morgana-0198 and
|
||||
// morgana-0200; a url cite is { scope: 'web', agent: null }), or null for a
|
||||
// plain body. Throws GuardError.
|
||||
export async function checkReply(body, { limits, mapping, library, web }) {
|
||||
if (Object.values(FIXED_TEXTS).includes(body)) return null;
|
||||
const reply = parseReply(body);
|
||||
const { citations } = reply;
|
||||
if (limits.requireCitation && citations.length === 0)
|
||||
refuse('citation-missing', 'replyLimits.requireCitation is true, so a reply carries at least one citation');
|
||||
if (citations.length > limits.citationsPerReply)
|
||||
refuse('citations-over-limit', `replyLimits.citationsPerReply is ${limits.citationsPerReply}; the reply carries ${citations.length}`);
|
||||
if (!reply.cited) return null;
|
||||
const found = [];
|
||||
for (const [i, c] of citations.entries()) {
|
||||
if (!Object.hasOwn(c, 'path')) { found.push(null); continue; }
|
||||
const at = `citation ${i + 1} (${c.path})`;
|
||||
const where = classify(mapping, c.path);
|
||||
if (where.scope === 'unmapped') refuse('citation-invalid', `${at} names a path that isn't mapped`);
|
||||
const lines = await library.lines(c.commit, c.path);
|
||||
if (lines === null) refuse('citation-invalid', `${at} isn't an indexed file at a commit reachable from the served commit`);
|
||||
if (c.lines[1] > lines.length) refuse('citation-invalid', `${at} cites lines past the file's ${lines.length}`);
|
||||
const quote = squash(c.quote);
|
||||
if (!quote || !squash(lines.slice(c.lines[0] - 1, c.lines[1]).join('\n')).includes(quote))
|
||||
refuse('citation-invalid', `${at} quote isn't in lines ${c.lines[0]}-${c.lines[1]}`);
|
||||
found.push(where);
|
||||
}
|
||||
citations.forEach((c, i) => {
|
||||
if (c.as === 'canon' && found[i].scope !== 'canon')
|
||||
refuse('playground-cited-as-canon', `citation ${i + 1} (${c.path}) is ${found[i].scope}, not canon`);
|
||||
});
|
||||
const universes = [...new Set(found.filter(Boolean).map(w => w.universe))];
|
||||
if (universes.length > 1) {
|
||||
const hybrid = universes.find(u => mapping.universes[u]?.kind === 'hybrid');
|
||||
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(', ')}`);
|
||||
}
|
||||
citations.forEach((c, i) => {
|
||||
if (!Object.hasOwn(c, 'url')) return;
|
||||
if (!web.allowed) refuse('citation-unfetched', `citation ${i + 1}: only research and companion cite a url`);
|
||||
const quote = squash(c.quote);
|
||||
const page = web.pages.find(p => p.url === c.url || p.finalUrl === c.url);
|
||||
if (!page) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`);
|
||||
if (!quote || !squash(page.text).includes(quote)) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`);
|
||||
});
|
||||
const out = [];
|
||||
for (const [i, c] of citations.entries())
|
||||
out.push(found[i] ? { scope: found[i].scope, ...await library.origin(c.commit, c.path) } : { scope: 'web', agent: null });
|
||||
return { citations: out };
|
||||
}
|
||||
|
||||
// Git reads for one check against the bare mirror. `lines` is the file's lines at `commit` when the
|
||||
// commit is reachable from the served commit and readLibrary indexes the path there (sage-3595 item 4:
|
||||
// readLibrary at the cited commit stands in for the C2-05a index), else null. Reads are cached per
|
||||
// commit for the one check.
|
||||
export function libraryReader(mirror, served, mapping) {
|
||||
const indexed = new Map(), reach = new Map();
|
||||
const once = (cache, key, read) => {
|
||||
if (!cache.has(key)) cache.set(key, read().catch(() => null));
|
||||
return cache.get(key);
|
||||
};
|
||||
const reachable = commit => once(reach, commit, async () => {
|
||||
if (!mirror || !served) return false;
|
||||
if (await git(mirror, ['cat-file', '-t', commit]) !== 'commit') return false;
|
||||
await git(mirror, ['merge-base', '--is-ancestor', commit, served]);
|
||||
return true;
|
||||
});
|
||||
const index = commit => once(indexed, commit, async () => {
|
||||
const [read, entries] = await Promise.all([readLibrary({ repo: mirror, commit, mapping }), listTree(mirror, commit)]);
|
||||
return { files: new Set(read.files.map(file => file.path)), entries };
|
||||
});
|
||||
return {
|
||||
async lines(commit, path) {
|
||||
if (!await reachable(commit)) return null;
|
||||
const at = await index(commit);
|
||||
if (!at?.files.has(path)) return null;
|
||||
const texts = await readTexts(mirror, [at.entries.find(entry => entry.path === path)]).catch(() => new Map());
|
||||
return texts.has(path) ? splitLines(texts.get(path)) : null;
|
||||
},
|
||||
// One git call: the file's last commit at `commit` (morgana-0200: the Console compares it with the
|
||||
// served tip's, as the cited library commit needn't touch the file) and that commit's trailer.
|
||||
async origin(commit, path) {
|
||||
if (!mirror) return { agent: null, file: null };
|
||||
try {
|
||||
const [file, trailer = ''] = (await git(mirror, ['log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0');
|
||||
return { agent: trailer.trim() || null, file: SHA.test(file) ? file : null };
|
||||
} catch { return { agent: null, file: null }; }
|
||||
},
|
||||
// The file's last commit at the served tip, for thread.messages (sage-3595), or null.
|
||||
async last(path) {
|
||||
if (!mirror || !served) return null;
|
||||
try {
|
||||
const value = await git(mirror, ['log', '-1', '--format=%H', served, '--', path]);
|
||||
return SHA.test(value) ? value : null;
|
||||
} catch { return null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// thread.messages' `last` (sage-3595): each path's last commit at the served tip, one git call per
|
||||
// distinct path. A path maps to null when there is no served commit or the read fails.
|
||||
export async function lastCommits(context, paths) {
|
||||
const { mirror, served } = await servedMirror(context);
|
||||
const reader = libraryReader(mirror, served, null);
|
||||
const out = new Map();
|
||||
for (const path of new Set(paths)) out.set(path, await reader.last(path));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The library project's bare mirror and its served commit. Either is null when there is none yet, so
|
||||
// a plain reply still passes and any library cite refuses citation-invalid.
|
||||
async function servedMirror(context) {
|
||||
const mapping = context.business.projects[context.project];
|
||||
let mirror = null, served = null;
|
||||
try {
|
||||
mirror = mirrorPath(context);
|
||||
served = (await readSyncState(mirror, { branch: mapping.branch })).served ?? null;
|
||||
} catch { served = null; }
|
||||
return { mapping, mirror, served };
|
||||
}
|
||||
|
||||
// The broker's entry: the library project's mirror at its served commit, then checkReply. `context`
|
||||
// is { business, project, dataRoot }. `definition` is the sender's role definition, which decides only
|
||||
// whether a url cite may pass; `pages` is the broker's page store for the sender's session.
|
||||
export async function guardReply(context, body, { limits, definition, pages }) {
|
||||
const { mapping, mirror, served } = await servedMirror(context);
|
||||
const web = { allowed: WEB_CITE_DEFINITIONS.includes(definition), pages };
|
||||
return checkReply(body, { limits, mapping, library: libraryReader(mirror, served, mapping), web });
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
|
||||
export { checkMapping, TYPES } from './mapping.mjs';
|
||||
export { classify } 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';
|
||||
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
||||
export { survey, formatSurvey, surveyMain } from './survey.mjs';
|
||||
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
|
||||
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
|
||||
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
|
||||
export { GUARD_CODES, FIXED_TEXTS, WEB_CITE_DEFINITIONS, REASON_MAX, GuardError, parseReply, checkReply, libraryReader, guardReply, lastCommits } from './guard.mjs';
|
||||
@@ -0,0 +1,41 @@
|
||||
// C2-06 (design 5.2): the business file's replyLimits, read by the citation guard and written only
|
||||
// by the human's library.limits.set. Pure, so the business package's organization writer imports it.
|
||||
|
||||
export const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
// The replyLimits keys, their defaults and their valid values. A key is read only when Object.hasOwn
|
||||
// finds it and its value passes `valid`; anything else is the default (webby-0144 R3).
|
||||
export const REPLY_LIMITS = Object.freeze({
|
||||
requireCitation: { default: false, valid: v => typeof v === 'boolean' },
|
||||
citationsPerReply: { default: 20, valid: v => Number.isInteger(v) && v >= 1 && v <= 1000 },
|
||||
});
|
||||
|
||||
// `read` is { readable: false } when the business file is missing, unreadable, not JSON or not an
|
||||
// object at check time, else { readable: true, document }. An unreadable file checks at the defaults
|
||||
// except requireCitation, which is true so a setting she turned on doesn't fail open (webby-0151 R2).
|
||||
export function resolveReplyLimits(read) {
|
||||
const limits = {}, fallback = [];
|
||||
const unreadable = !read?.readable || !plain(read.document);
|
||||
const raw = unreadable ? undefined : read.document.replyLimits;
|
||||
for (const [key, { default: value, valid }] of Object.entries(REPLY_LIMITS)) {
|
||||
if (plain(raw) && Object.hasOwn(raw, key) && valid(raw[key])) limits[key] = raw[key];
|
||||
else {
|
||||
limits[key] = value;
|
||||
// A key fell back when replyLimits is there but isn't an object, or holds the key with a value
|
||||
// outside the table. A key left out is simply its default.
|
||||
if (raw !== undefined && (!plain(raw) || Object.hasOwn(raw, key))) fallback.push(key);
|
||||
}
|
||||
}
|
||||
if (unreadable) limits.requireCitation = true;
|
||||
return { ...limits, fallback, unreadable };
|
||||
}
|
||||
|
||||
// library.limits.set's check: the whole object is replaced, a key left out means its default, and an
|
||||
// unknown key or a value outside the table refuses. Returns the object to write.
|
||||
export function checkReplyLimits(value) {
|
||||
if (!plain(value)) return null;
|
||||
for (const [key, v] of Object.entries(value))
|
||||
if (!Object.hasOwn(REPLY_LIMITS, key) || !REPLY_LIMITS[key].valid(v)) return null;
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
|
||||
// books, type folders, manuscript folders and playground prefixes at three levels (ruling after r11,
|
||||
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. Supporting
|
||||
// prefixes (C2-01d) follow the same rules and are where agents may write (M12). A pure validator.
|
||||
// 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).
|
||||
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. A pure validator. 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';
|
||||
|
||||
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
|
||||
@@ -13,12 +12,6 @@ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
|
||||
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
|
||||
const CONSTRAINT_KEYS = ['exclude', 'pin'];
|
||||
const KINDS = ['hybrid', 'draft'];
|
||||
// C2-01d (F41): the editor ident her Console saves commit under. Git drops `<` and `>` from an ident
|
||||
// and commits under a different name, so they refuse here (webby-0169 R1).
|
||||
const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;
|
||||
// Git's ident.c crud() strips any byte <= 0x20 and , : ; < > " \ ' from both ends of an ident, so a
|
||||
// value that starts or ends with one would save one name and commit another (sage-3601, sage-3606).
|
||||
const IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/;
|
||||
|
||||
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
|
||||
// and dot names are not.
|
||||
@@ -47,7 +40,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
};
|
||||
|
||||
object(entry, '');
|
||||
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'editor', 'universes', 'types', 'manuscript'], 'entry');
|
||||
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'universes', 'types', 'manuscript'], 'entry');
|
||||
if (entry.kind !== 'library') bad('kind must be "library"');
|
||||
let url;
|
||||
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
|
||||
@@ -59,20 +52,6 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user)))
|
||||
bad('user must be a plain account name');
|
||||
if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
|
||||
if (Object.hasOwn(entry, 'editor')) {
|
||||
object(entry.editor, 'editor');
|
||||
only(entry.editor, ['name', 'email', 'user'], 'editor');
|
||||
// Her Gitea login for the library-human slot, which C2-02 passes in (sage-2925). Optional here.
|
||||
if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' || !USER.test(entry.editor.user)))
|
||||
bad('editor.user must be a plain account name');
|
||||
for (const key of ['name', 'email']) {
|
||||
const value = entry.editor[key];
|
||||
if (typeof value !== 'string' || !value.trim() || IDENT_BAD.test(value))
|
||||
bad(`editor.${key} must be non-empty text with no control character, < or >`);
|
||||
if (value.trim() !== value || IDENT_CRUD_END.test(value))
|
||||
bad(`editor.${key} must not start or end with whitespace or any of , : ; " ' \\`);
|
||||
}
|
||||
}
|
||||
|
||||
object(entry.types, 'types');
|
||||
for (const [name, type] of Object.entries(entry.types)) {
|
||||
@@ -85,7 +64,6 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
object(entry.universes, 'universes');
|
||||
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
|
||||
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
|
||||
const supports = [], supportOwner = new Map();
|
||||
for (const [id, universe] of Object.entries(entry.universes)) {
|
||||
const name = `universes.${id}`;
|
||||
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
|
||||
@@ -93,8 +71,8 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`);
|
||||
const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft';
|
||||
if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`);
|
||||
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'supporting', 'constraints', 'notes']
|
||||
: draft ? ['kind', 'name', 'playground', 'supporting'] : ['name', 'books', 'playground', 'supporting'], name);
|
||||
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes']
|
||||
: draft ? ['kind', 'name', 'playground'] : ['name', 'books', 'playground'], name);
|
||||
if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name))
|
||||
bad(`${name}.name must be a short display name`);
|
||||
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
|
||||
@@ -103,14 +81,12 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
|
||||
bookOwner.set(book, id);
|
||||
}
|
||||
const prefix = value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1));
|
||||
const playground = list(universe.playground ?? [], `${name}.playground`, prefix, 'a folder path ending in /');
|
||||
const playground = list(universe.playground ?? [], `${name}.playground`,
|
||||
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
|
||||
if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`);
|
||||
for (const p of playground) { prefixes.push(p); prefixOwner.set(p, id); }
|
||||
const supporting = list(universe.supporting ?? [], `${name}.supporting`, prefix, 'a folder path ending in /');
|
||||
for (const p of supporting) { supports.push(p); supportOwner.set(p, id); }
|
||||
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground, supporting }; continue; }
|
||||
const out = { name: universe.name, books, playground, supporting };
|
||||
for (const prefix of playground) { prefixes.push(prefix); prefixOwner.set(prefix, id); }
|
||||
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground }; continue; }
|
||||
const out = { name: universe.name, books, playground };
|
||||
if (hybrid) {
|
||||
out.kind = 'hybrid';
|
||||
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
|
||||
@@ -148,36 +124,18 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
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)}`);
|
||||
if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice');
|
||||
for (const a of supports) for (const b of supports) if (a !== b && holds(a, b)) bad(`supporting ${JSON.stringify(a)} holds supporting ${JSON.stringify(b)}`);
|
||||
if (new Set(supports).size !== supports.length) bad('a supporting prefix is listed twice');
|
||||
// A prefix's level comes from where it sits (ruling after r11). Inside one of its own entry's books
|
||||
// is book level, outside every book is universe level, and a draft's prefixes are all outside. Scope
|
||||
// checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book
|
||||
// into playground or supporting.
|
||||
const againstBooks = (owners, list, code) => {
|
||||
for (const [prefix, id] of owners) {
|
||||
for (const [book, owner] of bookOwner) {
|
||||
const at = `universes.${id}.${list} ${JSON.stringify(prefix)}`;
|
||||
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
|
||||
const inside = holds(book, prefix), holding = holds(prefix, book);
|
||||
if ((inside || holding) && owner !== id)
|
||||
fail(code, `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
|
||||
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
againstBooks(prefixOwner, 'playground', 'library-playground-other-universe');
|
||||
againstBooks(supportOwner, 'supporting', 'library-supporting-other-universe');
|
||||
// C2-01d, between the lists. A supporting prefix may equal a playground prefix of its own entry, and
|
||||
// a playground may hold one of its own entry's supporting prefixes. A supporting prefix that holds a
|
||||
// playground would hide it, since scope checks supporting first. Any overlap across entries would
|
||||
// give one path two universes.
|
||||
for (const [s, sid] of supportOwner) {
|
||||
for (const [p, pid] of prefixOwner) {
|
||||
const at = `universes.${sid}.supporting ${JSON.stringify(s)}`;
|
||||
if (sid !== pid && (s === p || holds(s, p) || holds(p, s)))
|
||||
bad(`${at} overlaps playground ${JSON.stringify(p)} of universes.${pid}`);
|
||||
if (sid === pid && s !== p && holds(s, p)) bad(`${at} holds playground ${JSON.stringify(p)}`);
|
||||
// into playground.
|
||||
for (const [prefix, id] of prefixOwner) {
|
||||
for (const [book, owner] of bookOwner) {
|
||||
const at = `universes.${id}.playground ${JSON.stringify(prefix)}`;
|
||||
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
|
||||
const inside = holds(book, prefix), holding = holds(prefix, book);
|
||||
if ((inside || holding) && owner !== id)
|
||||
fail('library-playground-other-universe', `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
|
||||
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +143,6 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
kind: 'library', remote: entry.remote, branch: entry.branch,
|
||||
...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}),
|
||||
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
|
||||
...(Object.hasOwn(entry, 'editor') ? { editor: { name: entry.editor.name, email: entry.editor.email,
|
||||
...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {}) } } : {}),
|
||||
universes, types: { ...entry.types }, manuscript,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,22 +2,16 @@
|
||||
// Paths are repo-relative and compared exactly, case included (R7 handles case-only twins).
|
||||
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
||||
|
||||
// { scope: 'supporting' | 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
|
||||
// Prefixes come first (ruling after r11, C2-01c), supporting before playground (C2-01d, design 1.2):
|
||||
// a `.md` file under any entry's supporting prefix is supporting, and otherwise one under a playground
|
||||
// prefix is playground. Either way `universe` is that entry and `book` is the book folder that holds
|
||||
// the prefix, or null at universe level and in a draft. Otherwise a `.md` file in a book folder is
|
||||
// canon. `type` is the mapped type of the first folder under the prefix or under the book (canon),
|
||||
// { scope: 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
|
||||
// Prefixes come first (ruling after r11, C2-01c): a `.md` file under any entry's playground prefix is
|
||||
// playground, with that entry as `universe` and the book folder that holds the prefix as `book`, or
|
||||
// null at universe level and in a draft. Otherwise a `.md` file in a book folder is canon. `type` is
|
||||
// the mapped type of the first folder under the prefix (playground) or under the book (canon),
|
||||
// 'manuscript' for a manuscript folder, else null. `folder` is that folder name. checkMapping keeps
|
||||
// prefixes of one list from overlapping, and keeps a supporting prefix from holding a playground
|
||||
// prefix, so the first match is the only one that counts.
|
||||
// prefixes from overlapping, so at most one matches.
|
||||
export function classify(mapping, path) {
|
||||
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
|
||||
const entries = Object.entries(mapping.universes);
|
||||
for (const [universe, entry] of entries) {
|
||||
const prefix = entry.supporting.find(p => inside(p, path));
|
||||
if (prefix) return place(mapping, path, prefix, { scope: 'supporting', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
|
||||
}
|
||||
for (const [universe, entry] of entries) {
|
||||
const prefix = entry.playground.find(p => inside(p, path));
|
||||
if (prefix) return place(mapping, path, prefix, { scope: 'playground', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// C2-01d (M12): the paths an agent may write. A path passes only under a supporting prefix of the
|
||||
// named universe, and only if nothing in the tree could turn the write into something else: a
|
||||
// symlink or gitlink on the way, or a name that differs from a tree name only in case or Unicode
|
||||
// form (APFS folds both, so her checkout would put the file somewhere else). Pure; reads no git.
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
|
||||
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
||||
const MAX_BYTES = 1024;
|
||||
const LINKS = new Set(['120000', '160000']);
|
||||
const fold = name => name.normalize('NFC').toLowerCase();
|
||||
|
||||
// `tree` is the array listTree returns ({ mode, type, oid, size, path }). Returns `path` or throws
|
||||
// library-write-story-path naming the class. Checks run in a fixed order, so a path with two faults
|
||||
// always names the same one.
|
||||
export function supportingPath(mapping, universe, path, tree) {
|
||||
if (!Array.isArray(tree)) throw new TypeError('supportingPath needs the listTree array');
|
||||
const refuse = message => { throw new LibraryError('library-write-story-path', message); };
|
||||
if (typeof path !== 'string') refuse('the path must be a string');
|
||||
const at = JSON.stringify(path);
|
||||
if (Buffer.byteLength(path, 'utf8') > MAX_BYTES) refuse(`${at} is longer than ${MAX_BYTES} bytes`);
|
||||
if (CONTROL.test(path)) refuse('the path holds a control character');
|
||||
if (path.includes('\\')) refuse(`${at} holds a backslash`);
|
||||
if (path.startsWith('/')) refuse(`${at} is absolute`);
|
||||
const parts = path.split('/');
|
||||
if (parts.some(part => part === '' || part === '.' || part === '..')) refuse(`${at} has an empty, . or .. segment`);
|
||||
const place = classify(mapping, path);
|
||||
if (place.scope !== 'supporting' || place.universe !== universe)
|
||||
refuse(`${at} is not under a supporting folder of universe ${JSON.stringify(universe)}`);
|
||||
const own = parts.map((_, i) => parts.slice(0, i + 1).join('/'));
|
||||
const modes = new Map(tree.map(entry => [entry.path, entry.mode]));
|
||||
for (const p of own) if (LINKS.has(modes.get(p))) refuse(`${at} goes through the symlink or gitlink ${JSON.stringify(p)}`);
|
||||
const names = new Map();
|
||||
for (const entry of tree) {
|
||||
const segs = entry.path.split('/');
|
||||
for (let i = 1; i <= segs.length; i++) {
|
||||
const name = segs.slice(0, i).join('/');
|
||||
const key = fold(name);
|
||||
if (!names.has(key)) names.set(key, new Set());
|
||||
names.get(key).add(name);
|
||||
}
|
||||
}
|
||||
for (const p of own) {
|
||||
const seen = names.get(fold(p));
|
||||
if (seen && [...seen].some(name => name !== p))
|
||||
refuse(`${at} differs only in case or Unicode form from ${JSON.stringify([...seen].find(name => name !== p))}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export async function survey({ repo, commit, mapping }) {
|
||||
forms, attachments, fields,
|
||||
};
|
||||
if (mapping) {
|
||||
const scopes = { canon: 0, playground: 0, supporting: 0, unmapped: 0 };
|
||||
const scopes = { canon: 0, playground: 0, unmapped: 0 };
|
||||
for (const entry of entries) scopes[classify(mapping, entry.path).scope]++;
|
||||
const library = await readLibrary({ repo, commit: head, mapping });
|
||||
const steps = Object.fromEntries(STEPS.map(s => [s, 0]));
|
||||
@@ -89,7 +89,7 @@ export function formatSurvey(s) {
|
||||
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}; attachment ${s.attachments}`);
|
||||
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
|
||||
if (s.scopes) {
|
||||
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, supporting ${s.scopes.supporting}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
|
||||
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
|
||||
out.push(`resolved ${STEPS.map(st => `${st} ${s.steps[st]}`).join(', ')}, unresolved ${s.steps.unresolved}`);
|
||||
out.push(`problems ${Object.entries(s.problems).map(([code, n]) => `${code} ${n}`).join(', ')}`);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { checkMapping, classify, LibraryError } from '../src/index.mjs';
|
||||
import { mapping, mappingEntry } from './helpers.mjs';
|
||||
|
||||
@@ -49,7 +45,7 @@ test('C2-01c: universe-level prefixes lie outside every book, in a plain univers
|
||||
|
||||
test('C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []', () => {
|
||||
const m = mapping(e => { e.universes.idea = draft(); return e; });
|
||||
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });
|
||||
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });
|
||||
assert.ok(Object.isFrozen(m.universes.idea) && Object.isFrozen(m.universes.idea.books) && Object.isFrozen(m.universes.idea.playground));
|
||||
refuses(e => { e.universes.idea = { ...draft(), books: ['New World'] }; return e; }, 'library-mapping-invalid', /universes.idea is a draft and takes no books/);
|
||||
refuses(e => { e.universes.idea = { ...draft(), books: [] }; return e; }, 'library-mapping-invalid', /takes no books/);
|
||||
@@ -60,107 +56,6 @@ test('C2-01c: a draft takes kind, name and playground only, and comes back froze
|
||||
refuses(e => { e.universes.harbor.kind = kind; return e; }, 'library-mapping-invalid', /universes.harbor.kind must be "hybrid" or "draft"/);
|
||||
});
|
||||
|
||||
test('C2-01d: supporting prefixes at book level, universe level, in a hybrid and in a draft, [] when absent', () => {
|
||||
const m = mapping(e => {
|
||||
e.universes.harbor.supporting = ['Book One/Notes/', 'Harbor Notes/'];
|
||||
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], supporting: ['Crossover Notes/'] };
|
||||
e.universes.idea = { ...draft(), supporting: ['Drafts/New World Notes/'] };
|
||||
return e;
|
||||
});
|
||||
assert.deepEqual(m.universes.harbor.supporting, ['Book One/Notes/', 'Harbor Notes/']);
|
||||
assert.deepEqual(m.universes.cross.supporting, ['Crossover Notes/']);
|
||||
assert.deepEqual(m.universes.idea.supporting, ['Drafts/New World Notes/']);
|
||||
assert.deepEqual(m.universes.second.supporting, []);
|
||||
assert.ok(Object.isFrozen(m.universes.harbor.supporting) && Object.isFrozen(m.universes.second.supporting));
|
||||
assert.deepEqual(mapping().universes.harbor.supporting, []);
|
||||
});
|
||||
|
||||
test('C2-01d: each supporting prefix rule refuses with its code', () => {
|
||||
const invalid = (change, pattern) => refuses(change, 'library-mapping-invalid', pattern);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes']; return e; }, /universes.harbor.supporting holds "Book One\/Notes", which is not a folder path ending in \//);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/../Notes/']; return e; }, /which is not a folder path ending in \//);
|
||||
invalid(e => { e.universes.harbor.supporting = 'Book One/Notes/'; return e; }, /universes.harbor.supporting must be a list/);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes/', 'Book One/Notes/Deep/']; return e; },
|
||||
/supporting "Book One\/Notes\/" holds supporting "Book One\/Notes\/Deep\/"/);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Ideas/']; e.universes.idea = { ...draft(), supporting: ['Ideas/'] }; return e; },
|
||||
/a supporting prefix is listed twice/);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/']; return e; }, /universes.harbor.supporting "Book One\/" is the whole book folder "Book One"/);
|
||||
invalid(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = []; e.universes.harbor.supporting = ['Shelf/']; return e; },
|
||||
/universes.harbor.supporting "Shelf\/" holds its own book folder "Shelf\/Book One"/);
|
||||
// Supporting is checked first in scope, so a supporting prefix holding a playground would hide it.
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/Workshop/']; e.universes.harbor.playground = ['Book One/Workshop/Playground/']; return e; },
|
||||
/universes.harbor.supporting "Book One\/Workshop\/" holds playground "Book One\/Workshop\/Playground\/"/);
|
||||
// Across entries any overlap would give one path two universes.
|
||||
for (const [supporting, playground] of [['Ideas/', 'Ideas/'], ['Ideas/Deep/', 'Ideas/'], ['Ideas/', 'Ideas/Deep/']])
|
||||
invalid(e => { e.universes.harbor.supporting = [supporting]; e.universes.idea = draft([playground]); return e; },
|
||||
new RegExp(`universes.harbor.supporting "${supporting}" overlaps playground "${playground}" of universes.idea`));
|
||||
refuses(e => { e.universes.harbor.supporting = ['Second World/Notes/']; return e; },
|
||||
'library-supporting-other-universe', /universes.harbor.supporting "Second World\/Notes\/" lies inside book folder "Second World" of universes.second/);
|
||||
refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.supporting = ['Worlds/']; return e; },
|
||||
'library-supporting-other-universe', /universes.harbor.supporting "Worlds\/" holds book folder "Worlds\/Second" of universes.second/);
|
||||
refuses(e => { e.universes.idea = { ...draft(), supporting: ['Book One/Idea Notes/'] }; return e; },
|
||||
'library-supporting-other-universe', /universes.idea.supporting "Book One\/Idea Notes\/" lies inside book folder "Book One" of universes.harbor/);
|
||||
});
|
||||
|
||||
test('C2-01d: between the lists, a supporting prefix may equal its own playground, and a playground may hold its own supporting', () => {
|
||||
const same = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/']; return e; });
|
||||
assert.deepEqual([same.universes.harbor.playground, same.universes.harbor.supporting], [['Book One/Playground/'], ['Book One/Playground/']]);
|
||||
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
|
||||
assert.deepEqual(nested.universes.harbor.supporting, ['Book One/Playground/Notes/']);
|
||||
});
|
||||
|
||||
test('C2-01d: editor is optional, kept when given, and refuses anything but a plain name and email', () => {
|
||||
assert.equal(Object.hasOwn(mapping(), 'editor'), false);
|
||||
const editor = { name: 'Her Name', email: '[email protected]' };
|
||||
const m = mapping(e => ({ ...e, editor }));
|
||||
assert.deepEqual(m.editor, editor);
|
||||
assert.notEqual(m.editor, editor);
|
||||
assert.ok(Object.isFrozen(m.editor));
|
||||
const invalid = (value, pattern) => refuses(e => ({ ...e, editor: value }), 'library-mapping-invalid', pattern);
|
||||
for (const value of [null, 'Her Name', 7, ['Her Name', '[email protected]']]) invalid(value, /editor must be an object/);
|
||||
invalid({ ...editor, signingKey: 'x' }, /editor has unknown key "signingKey"/);
|
||||
// sage-2925: an optional Gitea login for her save, checked as the entry's user is.
|
||||
assert.deepEqual(mapping(e => ({ ...e, editor: { ...editor, user: 'her.login-1' } })).editor, { ...editor, user: 'her.login-1' });
|
||||
for (const user of ['', 'her login', 'her@host', 'a'.repeat(101), 7, null])
|
||||
invalid({ ...editor, user }, /editor.user must be a plain account name/);
|
||||
invalid({ name: 'Her Name' }, /editor.email must be non-empty text with no control character, < or >/);
|
||||
invalid({ email: '[email protected]' }, /editor.name must be non-empty text/);
|
||||
for (const bad of ['', ' ', 7, null, 'a\u0000b', 'a\nb', 'a\u001fb', 'a\u007fb', 'a\u0085b', 'a\u009fb', '<her>', 'a<b', 'a>b']) {
|
||||
invalid({ ...editor, name: bad }, /editor.name must be non-empty text with no control character, < or >/);
|
||||
invalid({ ...editor, email: bad }, /editor.email must be non-empty text with no control character, < or >/);
|
||||
}
|
||||
// sage-3601, sage-3606: git's crud() strips whitespace and , : ; < > " \ ' from both ends of an
|
||||
// ident, so a value starting or ending with one refuses. Inner < > and controls refuse above.
|
||||
const ends = [' ', '\u00a0', '\u3000', ',', ':', ';', '"', "'", '\\'];
|
||||
for (const c of ends) for (const padded of [`${c}Her Name`, `Her Name${c}`]) {
|
||||
invalid({ ...editor, name: padded }, /editor.name must not start or end with whitespace or any of , : ; " ' \\/);
|
||||
invalid({ ...editor, email: padded }, /editor.email must not start or end with whitespace or any of , : ; " ' \\/);
|
||||
}
|
||||
for (const c of ['<', '>', '\n', '\t']) for (const padded of [`${c}Her Name`, `Her Name${c}`, `Her${c}Name`])
|
||||
invalid({ ...editor, name: padded }, /editor.name must be non-empty text with no control character, < or >/);
|
||||
assert.equal(mapping(e => ({ ...e, editor: { name: 'Her Middle Name', email: '[email protected]' } })).editor.name, 'Her Middle Name');
|
||||
for (const name of ['Doe, Jane Q.', 'Jane: "Q" Doe', "O'Neil", 'Back\\slash Name'])
|
||||
assert.equal(mapping(e => ({ ...e, editor: { name, email: '[email protected]' } })).editor.name, name);
|
||||
});
|
||||
|
||||
// sage-3606: an accepted editor commits under exactly the saved ident, as author and committer. A
|
||||
// future git that strips more from an ident fails here.
|
||||
test('C2-01d: an accepted editor round-trips through a real git commit unchanged', t => {
|
||||
const editor = mapping(e => ({ ...e, editor: { name: 'Doe, Jane Q.', email: '[email protected].' } })).editor;
|
||||
const root = mkdtempSync(join(tmpdir(), 'library-ident-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
// An ident in the environment would win over -c, so the commit runs without one. GIT_DIR and its
|
||||
// family would beat -C (inside a hook, say), and git reads no global or system config (webby-0236 E5).
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k)));
|
||||
for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[k];
|
||||
Object.assign(env, { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' });
|
||||
const git = (...args) => execFileSync('git', ['-c', `user.name=${editor.name}`, '-c', `user.email=${editor.email}`,
|
||||
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { encoding: 'utf8', env });
|
||||
git('init', '-q');
|
||||
git('commit', '-q', '--allow-empty', '-m', 'ident');
|
||||
assert.equal(git('log', '-1', '--format=%an%n%ae%n%cn%n%ce'), `${editor.name}\n${editor.email}\n${editor.name}\n${editor.email}\n`);
|
||||
});
|
||||
|
||||
test('no playground prefix holds another, and no book folder holds another', () => {
|
||||
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
|
||||
'library-mapping-invalid', /holds playground/);
|
||||
|
||||
@@ -53,44 +53,3 @@ test('C2-01c: a canon file linking into a draft gives R6, and into its own unive
|
||||
assert.deepEqual(codes(path), [], path);
|
||||
}
|
||||
});
|
||||
|
||||
// Supporting prefixes at three levels (C2-01d). Harbor's book-level prefix sits in both lists, and a
|
||||
// second book-level prefix is supporting only.
|
||||
const supporting = () => mapping(e => {
|
||||
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
|
||||
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
|
||||
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
|
||||
return e;
|
||||
});
|
||||
|
||||
test('C2-01d: each supporting level gives its universe, and its book or null', () => {
|
||||
const m = supporting();
|
||||
assert.deepEqual(classify(m, 'Book One/Notes/Characters/Mara.md'),
|
||||
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
|
||||
assert.deepEqual(classify(m, 'Harbor Notes/Places/Island.md'),
|
||||
{ scope: 'supporting', universe: 'harbor', book: null, type: 'place', folder: 'Places' });
|
||||
assert.deepEqual(classify(m, 'Harbor Notes/Loose.md'), { scope: 'supporting', universe: 'harbor', book: null, type: null, folder: null });
|
||||
assert.deepEqual(classify(m, 'Drafts/New World Notes/Chapters/Open.md'),
|
||||
{ scope: 'supporting', universe: 'idea', book: null, type: 'manuscript', folder: 'Chapters' });
|
||||
assert.deepEqual(classify(m, 'Drafts/New World/Ana.md'), { scope: 'playground', universe: 'idea', book: null, type: null, folder: null });
|
||||
assert.deepEqual(classify(m, 'Book One/Places/Dock.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: 'place', folder: 'Places' });
|
||||
});
|
||||
|
||||
test('C2-01d: supporting comes before playground; a folder in both lists is supporting, one in the playground only stays playground', () => {
|
||||
const m = supporting();
|
||||
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
|
||||
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: null, folder: null });
|
||||
assert.deepEqual(classify(m, 'Book One/Sketches/Research/Tides.md'),
|
||||
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'research', folder: 'Research' });
|
||||
// A playground may hold a supporting prefix of its own entry: the inner folder is supporting.
|
||||
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
|
||||
assert.equal(classify(nested, 'Book One/Playground/Notes/A.md').scope, 'supporting');
|
||||
assert.equal(classify(nested, 'Book One/Playground/A.md').scope, 'playground');
|
||||
});
|
||||
|
||||
test('C2-01d: only .md files are supporting; a .canvas or .base under a supporting prefix is unmapped', () => {
|
||||
const m = supporting();
|
||||
for (const path of ['Book One/Notes/Board.canvas', 'Harbor Notes/Table.base', 'Drafts/New World Notes/Map.canvas',
|
||||
'Harbor Notes/cover.png', 'Harbor Notesx/a.md'])
|
||||
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { inspect } from 'node:util';
|
||||
import { LibraryError, supportingPath } from '../src/index.mjs';
|
||||
import { mapping } from './helpers.mjs';
|
||||
|
||||
// C2-01d (M12): the path checks an agent write passes. The tree is a listTree array built by hand,
|
||||
// with a symlink, a gitlink, case twins and an NFC name, so no git is needed.
|
||||
const m = mapping(e => {
|
||||
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
|
||||
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
|
||||
e.universes.second.supporting = ['Second Notes/'];
|
||||
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
|
||||
return e;
|
||||
});
|
||||
const blob = (path, mode = '100644') => ({ mode, type: mode === '160000' ? 'commit' : 'blob', oid: '0'.repeat(40), size: 1, path });
|
||||
const tree = [
|
||||
blob('Book One/Characters/Mara Vell.md'),
|
||||
blob('Book One/Notes/Existing.md'),
|
||||
blob('Book One/Notes/Linked', '120000'),
|
||||
blob('Book One/Notes/Vendor', '160000'),
|
||||
blob('Book One/Notes/Pointer.md', '120000'),
|
||||
blob('Book One/Notes/Case/Twin.md'),
|
||||
blob('Book One/Notes/Café.md'),
|
||||
blob('Harbor Notes/Places/Island.md'),
|
||||
];
|
||||
const refuses = (universe, path, pattern) => assert.throws(() => supportingPath(m, universe, path, tree),
|
||||
e => e instanceof LibraryError && e.code === 'library-write-story-path' && pattern.test(e.message), inspect(path));
|
||||
|
||||
test('C2-01d: a path passes at book level, universe level, in a draft and in a folder in both lists', () => {
|
||||
for (const [universe, path] of [['harbor', 'Book One/Notes/Characters/New.md'], ['harbor', 'Book One/Notes/Existing.md'],
|
||||
['harbor', 'Harbor Notes/Places/Island.md'], ['harbor', 'Harbor Notes/Loose.md'], ['idea', 'Drafts/New World Notes/Ana.md'],
|
||||
['harbor', 'Book One/Playground/What If.md'], ['second', 'Second Notes/A.md'], ['harbor', 'Book One/Notes/Café.md']])
|
||||
assert.equal(supportingPath(m, universe, path, tree), path);
|
||||
assert.equal(supportingPath(m, 'harbor', 'Book One/Notes/x.md', []), 'Book One/Notes/x.md');
|
||||
});
|
||||
|
||||
test('C2-01d: a path of exactly 1024 bytes passes, and 1025 bytes made of multi-byte characters refuses', () => {
|
||||
const prefix = 'Harbor Notes/';
|
||||
const exact = `${prefix}${'a'.repeat(1024 - prefix.length - 3)}.md`;
|
||||
assert.equal(Buffer.byteLength(exact), 1024);
|
||||
assert.equal(supportingPath(m, 'harbor', exact, tree), exact);
|
||||
// é is two bytes: 1025 bytes but far fewer characters.
|
||||
const name = 'é'.repeat((1025 - prefix.length - 3 - 1) / 2);
|
||||
const long = `${prefix}${name}a.md`;
|
||||
assert.equal(Buffer.byteLength(long), 1025);
|
||||
assert.ok(long.length < 1024);
|
||||
refuses('harbor', long, /is longer than 1024 bytes/);
|
||||
});
|
||||
|
||||
test('C2-01d: a path outside the universe\'s supporting folders refuses', () => {
|
||||
const outside = /is not under a supporting folder of universe/;
|
||||
for (const path of ['Inbox Note.md', 'Book One/Notes/Board.canvas', 'Book One/Notes/Table.base', 'Book One/Notes/cover.png',
|
||||
'Book One/Characters/Mara Vell.md', 'Book One/Sketches/Idea.md', 'Drafts/New World/Ana.md', 'Second Notes/A.md', 'Book One/Notes.md'])
|
||||
refuses('harbor', path, outside);
|
||||
refuses('idea', 'Book One/Notes/A.md', /universe "idea"/);
|
||||
refuses('nowhere', 'Book One/Notes/A.md', /universe "nowhere"/);
|
||||
});
|
||||
|
||||
test('C2-01d: malformed paths refuse by class, in order', () => {
|
||||
refuses('harbor', '/Book One/Notes/A.md', /"\/Book One\/Notes\/A.md" is absolute/);
|
||||
for (const path of ['Book One/Notes/../Notes/A.md', 'Book One/Notes/./A.md', 'Book One//Notes/A.md', 'Book One/Notes/A/', 'Book One/Notes/..'])
|
||||
refuses('harbor', path, /has an empty, \. or \.\. segment/);
|
||||
refuses('harbor', 'Book One/Notes/A\\B.md', /holds a backslash/);
|
||||
for (const c of ['\u0000', '\u0001', '\n', '\u001f', '\u007f', '\u0085', '\u009f'])
|
||||
assert.throws(() => supportingPath(m, 'harbor', `Book One/Notes/A${c}.md`, tree),
|
||||
e => e.code === 'library-write-story-path' && e.message === 'library-write-story-path: the path holds a control character');
|
||||
for (const path of [7, null, undefined, 1n, ['Book One/Notes/A.md']]) refuses('harbor', path, /the path must be a string/);
|
||||
// A backslash in a path that is also absolute names the backslash: the order is fixed.
|
||||
refuses('harbor', '/a\\b.md', /holds a backslash/);
|
||||
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md', { 'Book One/Notes/A.md': '100644' }), TypeError);
|
||||
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md'), TypeError);
|
||||
});
|
||||
|
||||
test('C2-01d: a symlink or gitlink on the way, or at the path itself, refuses', () => {
|
||||
refuses('harbor', 'Book One/Notes/Linked/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Linked"/);
|
||||
refuses('harbor', 'Book One/Notes/Vendor/Deep/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Vendor"/);
|
||||
refuses('harbor', 'Book One/Notes/Pointer.md', /goes through the symlink or gitlink "Book One\/Notes\/Pointer.md"/);
|
||||
});
|
||||
|
||||
test('C2-01d: a name that differs from a tree name only in case or Unicode form refuses, at the file or any folder', () => {
|
||||
refuses('harbor', 'Book One/Notes/existing.md', /differs only in case or Unicode form from "Book One\/Notes\/Existing.md"/);
|
||||
refuses('harbor', 'Book One/Notes/case/New.md', /differs only in case or Unicode form from "Book One\/Notes\/Case"/);
|
||||
refuses('harbor', 'Book One/Notes/CASE/Twin.md', /from "Book One\/Notes\/Case"/);
|
||||
refuses('harbor', 'Harbor notes/A.md', /is not under a supporting folder/);
|
||||
// NFD e + combining acute against the NFC name in the tree: one file on APFS.
|
||||
refuses('harbor', 'Book One/Notes/Café.md', /differs only in case or Unicode form from "Book One\/Notes\/Café.md"/);
|
||||
// A tree that already holds case twins refuses a write to either (R7).
|
||||
const twins = [...tree, blob('Book One/Notes/existing.md')];
|
||||
for (const path of ['Book One/Notes/Existing.md', 'Book One/Notes/existing.md'])
|
||||
assert.throws(() => supportingPath(m, 'harbor', path, twins), e => e.code === 'library-write-story-path' && /differs only in case/.test(e.message));
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -70,23 +70,12 @@ test('with --mapping the survey adds scope, resolver step and problem counts', a
|
||||
assert.equal(out.code, 0, out.stderr);
|
||||
const tail = out.stdout.trimEnd().split('\n').slice(-3);
|
||||
assert.deepEqual(tail, [
|
||||
'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',
|
||||
'scopes canon 5, playground 1, 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']);
|
||||
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
|
||||
});
|
||||
|
||||
test('C2-01d: with --mapping the survey counts supporting files', async t => {
|
||||
const r = repo(t);
|
||||
const entry = JSON.parse(readFileSync(join(FIXTURES, 'mapping.json'), 'utf8'));
|
||||
entry.universes.harbor.supporting = ['Book One/Places/'];
|
||||
const file = join(r.root, 'supporting.json');
|
||||
writeFileSync(file, JSON.stringify(entry));
|
||||
const out = await run(['survey', r.repo, '--mapping', file]);
|
||||
assert.equal(out.code, 0, out.stderr);
|
||||
assert.match(out.stdout, /^scopes canon 4, playground 1, supporting 1, unmapped 3; indexed 6$/m);
|
||||
});
|
||||
|
||||
test('survey exit codes: usage 4, bad mapping 3, unreadable repo 2', async t => {
|
||||
const r = repo(t);
|
||||
for (const args of [[], ['scan', r.repo], ['survey'], ['survey', r.repo, '--mapping'], ['survey', r.repo, '--other'], ['survey', r.repo, r.repo]])
|
||||
|
||||
Reference in new issue
Block a user