bantamkit-mcp 0.29.0 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,25 @@ export interface ArgModel {
41
41
  readonly model: string;
42
42
  readonly fields: readonly FieldSpec[];
43
43
  }
44
+ /**
45
+ * `assets/tools/bantamkit_read.json` `offset.maximum` — `Number.MAX_SAFE_INTEGER`, the
46
+ * largest integer `JSON.parse` reads back unchanged (F1). The reference binds it as
47
+ * `Annotated[int, Field(le=OFFSET_MAXIMUM)]`, so above it the refusal is pydantic's
48
+ * `less_than_equal` frame and the handler never runs; this side reads the JSON with its
49
+ * own decoder, so `9007199254740993` arrives exact and is refused with the same text.
50
+ *
51
+ * EXPORTED so the number can be TIED to the asset rather than copied a fourth time.
52
+ * `assets/tools/bantamkit_read.json` is the published contract and this is the number the
53
+ * signature enforces; `test/document-cache-and-bounds.test.mjs` reads that file OFF DISK and
54
+ * compares it against this constant, against the schema the server actually serves, and
55
+ * against what the handler refuses — the port of `runtime-py`'s
56
+ * `tests/test_document_manifest_parity.py`. Register entries (c) and (l),
57
+ * `docs/roadmap-toolbox.md` row 8. It stays a test rather than a runtime read for the
58
+ * reference's reason: a module-level asset load would make importing this module fail
59
+ * wherever the asset pack is not on disk, which is a worse failure than a refusal to
60
+ * register a tool.
61
+ */
62
+ export declare const OFFSET_MAXIMUM = 9007199254740991n;
44
63
  /**
45
64
  * One model per handler, mirroring `build_server`'s closures signature for signature.
46
65
  *
@@ -41,8 +41,19 @@ const opt = (name, kind, bound = {}) => ({
41
41
  * `Annotated[int, Field(le=OFFSET_MAXIMUM)]`, so above it the refusal is pydantic's
42
42
  * `less_than_equal` frame and the handler never runs; this side reads the JSON with its
43
43
  * own decoder, so `9007199254740993` arrives exact and is refused with the same text.
44
+ *
45
+ * EXPORTED so the number can be TIED to the asset rather than copied a fourth time.
46
+ * `assets/tools/bantamkit_read.json` is the published contract and this is the number the
47
+ * signature enforces; `test/document-cache-and-bounds.test.mjs` reads that file OFF DISK and
48
+ * compares it against this constant, against the schema the server actually serves, and
49
+ * against what the handler refuses — the port of `runtime-py`'s
50
+ * `tests/test_document_manifest_parity.py`. Register entries (c) and (l),
51
+ * `docs/roadmap-toolbox.md` row 8. It stays a test rather than a runtime read for the
52
+ * reference's reason: a module-level asset load would make importing this module fail
53
+ * wherever the asset pack is not on disk, which is a worse failure than a refusal to
54
+ * register a tool.
44
55
  */
45
- const OFFSET_MAXIMUM = 9007199254740991n;
56
+ export const OFFSET_MAXIMUM = 9007199254740991n;
46
57
  /**
47
58
  * One model per handler, mirroring `build_server`'s closures signature for signature.
48
59
  *
@@ -22,7 +22,7 @@
22
22
  * indistinguishable from a typo: a manifest renamed or a surface dropped would silently
23
23
  * shrink the served set, and the field would be a comment.
24
24
  */
25
- import { existsSync, readFileSync } from 'node:fs';
25
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
26
26
  import { dirname, join } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -173,6 +173,87 @@ function recordRaise(log, tool, body) {
173
173
  throw e;
174
174
  }
175
175
  }
176
+ /**
177
+ * The last `docread.extract` result this server produced, and what it was OF.
178
+ *
179
+ * The port of `mcpserver._DocumentCache` (register entry (i), `docs/roadmap-toolbox.md` row
180
+ * 8). `bantamkit_read` re-parsed the whole document on EVERY call, so a caller paging a
181
+ * 12,001-row sheet in 200-row pages parsed the workbook once per page — paging was O(N^2) in
182
+ * the row window. MEASURED on this machine over a 1,553,944-byte 12,001-row xlsx driven
183
+ * through a real client with `docread.extract` counted by a loader hook: 68 tool calls, **68**
184
+ * parses, 4.128 s for the walk. After, over the same fixture and the same counter: **1**
185
+ * parse, 0.104 s (0.103 / 0.108 on two repeats). The committed, rerunnable form of the same
186
+ * count is `test/document-cache-and-bounds.test.mjs`, at 7 calls rather than 68.
187
+ *
188
+ * ONE entry, deliberately, and for the reference's reason. A single entry evicts whenever two
189
+ * callers alternate between two documents, and that case then costs exactly one parse per
190
+ * call — which is what the code this replaces cost for EVERY case, so the cache cannot make
191
+ * any caller slower than it was (`test/document-cache-and-bounds.test.mjs` counts the
192
+ * alternating walk and pins the number). What more entries would cost is resident memory: a
193
+ * `Document` holds its whole rendering as strings, bounded per document by
194
+ * `docread.TEXT_MAX_BYTES` and `docread.XLSX_MAX_TEXT_BYTES` (16 MiB each), and every extra
195
+ * entry multiplies that ceiling by one.
196
+ *
197
+ * **The key is (realpath, size, mtimeNs), and it has one hole — stated, not implied.** A
198
+ * rewrite that lands inside a single filesystem timestamp tick AND leaves the byte count
199
+ * unchanged is indistinguishable from no rewrite at all, and would be served from the stale
200
+ * parse. `mtimeNs` is nanosecond-SHAPED and not nanosecond-GRAINED: what it reports is
201
+ * whatever the filesystem stored, which on HFS+ is one second and on APFS/ext4 is finer but
202
+ * not unbounded. The alternative — hashing the bytes — would re-read the file this cache
203
+ * exists to avoid re-reading, which is the whole cost on the large documents that motivate
204
+ * it. So the hole stays, and the test that proves the key works changes the SIZE rather than
205
+ * racing the clock, because a test that raced it would be measuring the filesystem.
206
+ *
207
+ * THE CACHED VALUE IS THE RESULT, NEVER A READER. `docread.extract` hands its own `ZipReader`
208
+ * down to `extractXlsx`/`extractDocx` so an archive is read from disk once instead of twice,
209
+ * and that reader's lifetime ends with the `extract` call that opened it. This sits one layer
210
+ * above and holds a finished `Document`; no file handle outlives a call because of it.
211
+ */
212
+ class DocumentCache {
213
+ key = null;
214
+ doc = null;
215
+ /** The cached document for `key`, or `null` — a `null` key never matches. */
216
+ get(key) {
217
+ if (key === null || key !== this.key) {
218
+ return null;
219
+ }
220
+ return this.doc;
221
+ }
222
+ put(key, doc) {
223
+ if (key === null) {
224
+ return;
225
+ }
226
+ this.key = key;
227
+ this.doc = doc;
228
+ }
229
+ }
230
+ /**
231
+ * What identifies the bytes at `path` — or `null`, which means "do not cache".
232
+ *
233
+ * `statSync` FOLLOWS symlinks, which is the same file the reader is about to open, and
234
+ * `{bigint: true}` is not decoration: `Stats.mtimeMs` is a float and has already lost the
235
+ * sub-millisecond digits the reference's `st_mtime_ns` keeps, so a key built from it would
236
+ * have a coarser hole than the one documented above. `size` and `mtimeNs` are read from the
237
+ * bigint view for the same reason.
238
+ *
239
+ * Any error here returns `null` rather than refusing: this is a cache key, and the refusal a
240
+ * caller reads must be the one `docread.extract` raises in the reader's own words, not one
241
+ * this function invented from a different syscall's errno. That covers the one shape the
242
+ * reference does not have — `os.path.realpath` is non-strict in CPython and answers even for
243
+ * a path that does not resolve, while `realpathSync` throws. A path that stats but will not
244
+ * resolve is therefore uncached here and cached there; nothing a client can observe moves,
245
+ * because the reply is the reader's either way.
246
+ */
247
+ function documentKey(path) {
248
+ try {
249
+ const stat = statSync(path, { bigint: true });
250
+ // NUL cannot occur in a path on either platform, so the join is unambiguous.
251
+ return `${realpathSync(path)}\0${stat.size}\0${stat.mtimeNs}`;
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ }
176
257
  /**
177
258
  * Run a shiftwork handler and record its `result` — the register's OWN verdict.
178
259
  *
@@ -230,7 +311,7 @@ function recordResult(log, tool, call) {
230
311
  * byte-compares, and would make the log move when nothing the tool did moved. `noted` and
231
312
  * `notedDict` below therefore run last, on the way out.
232
313
  */
233
- function runTool(name, memory, args, version, log) {
314
+ function runTool(name, memory, args, version, log, documents) {
234
315
  /**
235
316
  * The footer for right now — recomputed per call, never cached.
236
317
  *
@@ -403,37 +484,46 @@ function runTool(name, memory, args, version, log) {
403
484
  // `document_error` like the encrypted one. Until job43 G2 this arm ran `extract`
404
485
  // outside `recordRaise` with a hand-copied catch.
405
486
  return recordRaise(log, 'bantamkit_read', () => {
406
- let doc;
407
- try {
408
- doc = docread.extract(path);
409
- }
410
- catch (e) {
411
- // `except (docread.DocumentReadError, OSError)`. A Node fs error is CPython's
412
- // `OSError` with the sentence rebuilt by `asPyOSError` — `[Errno 13] Permission
413
- // denied: '<path>'` for a file this process may not open — through the CRT arm,
414
- // because `open()` is the call the reference makes. A `BadZipFile` never reaches
415
- // this arm on either side: `docread.ts`'s `zipKind`/`openZip` catch it exactly
416
- // where `docread.py`'s `_zip_kind`/`_open` do, and what leaves them is the
417
- // sentence naming what the reader saw, never the zip module's.
418
- // A `PyOSError` the reader raised itself is already the reference's sentence —
419
- // `[Errno 22] Invalid argument` from a zip whose member offset is negative, with
420
- // NO filename, because the `seek` that fails there has none. Re-wrapping it would
421
- // append `: '<path>'`.
422
- // ONLY a filesystem rejection is an `OSError`: numeric `errno` AND the `syscall`
423
- // it came from. A string `code` alone is not one — `Z_DATA_ERROR`,
424
- // `ERR_INVALID_ARG_VALUE` and `ERR_STRING_TOO_LONG` all carry one, and until job43
425
- // G2 each was printed as a fabricated `[Errno 0] …` sentence where the reference
426
- // raises (the corrupt stream) or says `no such file` (the NUL byte, which
427
- // `docread.statPath` now answers before this arm is reached).
428
- const refusal = e instanceof docread.DocumentReadError || e instanceof PyOSError
429
- ? e
430
- : isFsError(e)
431
- ? asPyOSError(e, path, undefined, 'crt')
432
- : null;
433
- if (refusal === null)
434
- throw e;
435
- log.record('bantamkit_read', 'refused-unreadable');
436
- return { value: { t: 'str', v: noted(documentError(refusal)) }, wrapped: true };
487
+ // (i): the parse is cached on (realpath, size, mtimeNs), so a paging walk over one
488
+ // unchanged document parses it once. `documentKey` answering `null` means "do not
489
+ // cache" and leaves this arm exactly as it was.
490
+ const key = documentKey(path);
491
+ let doc = documents.get(key);
492
+ if (doc === null) {
493
+ try {
494
+ doc = docread.extract(path);
495
+ }
496
+ catch (e) {
497
+ // `except (docread.DocumentReadError, OSError)`. A Node fs error is CPython's
498
+ // `OSError` with the sentence rebuilt by `asPyOSError` — `[Errno 13] Permission
499
+ // denied: '<path>'` for a file this process may not open — through the CRT arm,
500
+ // because `open()` is the call the reference makes. A `BadZipFile` never reaches
501
+ // this arm on either side: `docread.ts`'s `zipKind`/`openZip` catch it exactly
502
+ // where `docread.py`'s `_zip_kind`/`_open` do, and what leaves them is the
503
+ // sentence naming what the reader saw, never the zip module's.
504
+ // A `PyOSError` the reader raised itself is already the reference's sentence —
505
+ // `[Errno 22] Invalid argument` from a zip whose member offset is negative, with
506
+ // NO filename, because the `seek` that fails there has none. Re-wrapping it would
507
+ // append `: '<path>'`.
508
+ // ONLY a filesystem rejection is an `OSError`: numeric `errno` AND the `syscall`
509
+ // it came from. A string `code` alone is not one — `Z_DATA_ERROR`,
510
+ // `ERR_INVALID_ARG_VALUE` and `ERR_STRING_TOO_LONG` all carry one, and until job43
511
+ // G2 each was printed as a fabricated `[Errno 0] …` sentence where the reference
512
+ // raises (the corrupt stream) or says `no such file` (the NUL byte, which
513
+ // `docread.statPath` now answers before this arm is reached).
514
+ const refusal = e instanceof docread.DocumentReadError || e instanceof PyOSError
515
+ ? e
516
+ : isFsError(e)
517
+ ? asPyOSError(e, path, undefined, 'crt')
518
+ : null;
519
+ if (refusal === null)
520
+ throw e;
521
+ log.record('bantamkit_read', 'refused-unreadable');
522
+ return { value: { t: 'str', v: noted(documentError(refusal)) }, wrapped: true };
523
+ }
524
+ // A refusal is NOT cached: the entry holds a parse, and a path that has no parse
525
+ // must reach the reader again next call so the reply stays the reader's own.
526
+ documents.put(key, doc);
437
527
  }
438
528
  const detail = { kind: doc.kind, parts: doc.parts.length };
439
529
  if (part === null) {
@@ -589,6 +679,10 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
589
679
  },
590
680
  instructions: loadSkill('memory'),
591
681
  });
682
+ // Per SERVER, not per process: two servers in one interpreter (the tests build several)
683
+ // must not answer each other's files, and the entry dies with the server rather than
684
+ // outliving it in a module global. The reference does the same, inside `build_server`.
685
+ const documents = new DocumentCache();
592
686
  // Startup, not call time: a tool without a manifest, or with the wrong surface, must stop
593
687
  // the process before a client ever sees a `tools/list`. The reference gets this from
594
688
  // `MCPServer(..., tools=[...])`; here it is one eager pass.
@@ -697,7 +791,7 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
697
791
  if (model === undefined)
698
792
  throw new BantamError(`Unknown tool: ${name}`);
699
793
  const bound = validateArguments(model, args);
700
- const { value, wrapped } = runTool(name, memory, bound, version, log);
794
+ const { value, wrapped } = runTool(name, memory, bound, version, log, documents);
701
795
  // `_convert_to_content` runs BEFORE the `{"result": ...}` wrap, so a str tool's text
702
796
  // block is the RAW STRING and a dict tool's is `to_json(..., indent=2)`.
703
797
  text = value.t === 'str' ? value.v : sdkJson(value, 2);
@@ -67,7 +67,7 @@
67
67
  import { BantamError } from '../errors.js';
68
68
  import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs, pyIntStrict, } from '../pyargparse.js';
69
69
  import { discoverProjectStore } from './layers.js';
70
- import { pyJoin } from './pyfs.js';
70
+ import { pyJoin, PyOSError } from './pyfs.js';
71
71
  import { DEFAULT_INDEX_BUDGET, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyText, } from './store.js';
72
72
  /** The Node operator's spelling of the reference's `python -m bantamkit.memory`. */
73
73
  const PROG = 'bantamkit-memory';
@@ -314,6 +314,40 @@ function cmdArchived(store) {
314
314
  out(` ${name}`);
315
315
  return 0;
316
316
  }
317
+ /**
318
+ * job44 (y): an uncaught OS exception used to reach the operator here as a raw stack trace.
319
+ * Measured 2026-09-05 on macOS, both entrances: `archive/` at 0o555 (the guards pass,
320
+ * `pyMkdirParents` is a no-op on a directory that is already there, and the MOVE is refused)
321
+ * and `index.md` as a directory (the move succeeds, `rebuildIndex` raises, the rollback
322
+ * restores the store correctly, and the exception then escapes `main` the same way). Both
323
+ * already exited 1 — Node's default for an uncaught exception — so only the TEXT differed:
324
+ * a `node:fs` / `pyfs.js` stack ending in `PyOSError` here, a CPython traceback ending in
325
+ * `PermissionError: [Errno 13]` on the reference. `PyOSError` does not extend `BantamError`,
326
+ * so it reached neither this catch nor the top-level `BantamError` arm below — it fell all
327
+ * the way to `throw error` and out of the process.
328
+ *
329
+ * THE SENTENCE DELIBERATELY DOES NOT INTERPOLATE THE UNDERLYING ERROR, the same reason
330
+ * `cmdLint`'s budget sentence is not the store's own message: `PyOSError`'s text is a
331
+ * translation of Node's `errno`/`code`, and `OSError`'s is CPython's `strerror` — the two
332
+ * are never going to read the same, so printing either would just move the divergence from
333
+ * a stack trace into a sentence. RECONCILED WITH U7's published wording (job44 handoff).
334
+ *
335
+ * "NOTHING UNDER `store.root` CHANGED" IS A CLAIM, NOT A FLOURISH, and it was FALSE on the
336
+ * reference until U7's own reconciliation: `restore`'s final `_rebuild_index()` used to sit
337
+ * OUTSIDE its only `try`, so the same `index.md`-is-a-directory fault this comment describes
338
+ * left a restored fact stuck in `facts/`, gone from `archive/`, with no rollback at all.
339
+ * MEASURED the same way on this side before printing this sentence, not reasoned from the
340
+ * code shape: driving that exact fault against the pre-reconciliation `restore` here left
341
+ * `facts/alpha.md` present and `archive/` empty afterwards — the same hole, open on both
342
+ * runtimes. `restore`'s two-step tail is now ONE `try` (`checkIndexBudget` and the closing
343
+ * `rebuildIndex` together) for exactly that reason, so this sentence is true when it prints.
344
+ * `archive` never had the hole — its one write step was inside its only `try` from the start
345
+ * — and is untouched by that fix.
346
+ */
347
+ function osErrorSentence(command, name, store) {
348
+ return (`${command} failed: a filesystem error stopped the move of '${name}'; ` +
349
+ `nothing under ${store.root} changed`);
350
+ }
317
351
  function cmdArchive(store, args) {
318
352
  try {
319
353
  store.archive(args.name);
@@ -323,6 +357,10 @@ function cmdArchive(store, args) {
323
357
  err(`archive failed: ${error.message}`);
324
358
  return 1;
325
359
  }
360
+ if (error instanceof PyOSError) {
361
+ err(osErrorSentence('archive', args.name, store));
362
+ return 1;
363
+ }
326
364
  throw error;
327
365
  }
328
366
  out(`archived '${args.name}' — index now ${size(store)}/${store.indexBudget} bytes`);
@@ -342,6 +380,10 @@ function cmdRestore(store, args) {
342
380
  err(`restore failed: ${error.message}`);
343
381
  return 1;
344
382
  }
383
+ if (error instanceof PyOSError) {
384
+ err(osErrorSentence('restore', args.name, store));
385
+ return 1;
386
+ }
345
387
  throw error;
346
388
  }
347
389
  out(`restored '${args.name}' — index now ${size(store)}/${store.indexBudget} bytes`);
@@ -14,6 +14,7 @@
14
14
  * `tools/conformance/suites/codec.mjs`; the shapes are pinned in `test/codec.test.mjs`.
15
15
  */
16
16
  import { PyScalar } from './pyyaml.js';
17
+ import { pyStrip } from '../pysem.js';
17
18
  export declare class FactParseError extends Error {
18
19
  }
19
20
  /**
@@ -38,7 +39,18 @@ export interface Fact {
38
39
  }
39
40
  /** The frontmatter as it comes off disk — exactly the keys the file carries. */
40
41
  export type FactMeta = Record<string, FactValue | FactValue[]>;
41
- export declare function pyStrip(text: string): string;
42
+ /**
43
+ * `str.strip()`, which is NOT `String.prototype.trim()` — `pysem.ts`'s, and no longer a second
44
+ * copy of it (`docs/roadmap-toolbox.md` row 8, entry (n)).
45
+ *
46
+ * Measured over all 0x110000 codepoints: Python strips 29, JS strips 25, and the two sets
47
+ * disagree on six. Python also strips U+001C..U+001F and U+0085; JS also strips U+FEFF.
48
+ * `_write_fact` ends with `fact.body.strip() + "\n"`, so each of those six is a byte of
49
+ * difference in a file the store then compares, hashes and diffs. `docread.ts` had derived
50
+ * the same set independently; the two were diffed over every codepoint and 200,000 random
51
+ * strings before they were merged, and the name stays exported from here.
52
+ */
53
+ export { pyStrip };
42
54
  /**
43
55
  * The bytes of a fact file as Python's `Path.read_text(encoding="utf-8")` sees them.
44
56
  *
@@ -14,32 +14,22 @@
14
14
  * `tools/conformance/suites/codec.mjs`; the shapes are pinned in `test/codec.test.mjs`.
15
15
  */
16
16
  import { constructPlain, PyScalar, safeDumpMapping } from './pyyaml.js';
17
+ import { pyStrip } from '../pysem.js';
17
18
  export class FactParseError extends Error {
18
19
  }
19
20
  // ------------------------------------------------------------------------ whitespace
20
21
  /**
21
- * `str.strip()`, which is NOT `String.prototype.trim()`.
22
+ * `str.strip()`, which is NOT `String.prototype.trim()` — `pysem.ts`'s, and no longer a second
23
+ * copy of it (`docs/roadmap-toolbox.md` row 8, entry (n)).
22
24
  *
23
25
  * Measured over all 0x110000 codepoints: Python strips 29, JS strips 25, and the two sets
24
26
  * disagree on six. Python also strips U+001C..U+001F and U+0085; JS also strips U+FEFF.
25
27
  * `_write_fact` ends with `fact.body.strip() + "\n"`, so each of those six is a byte of
26
- * difference in a file the store then compares, hashes and diffs.
28
+ * difference in a file the store then compares, hashes and diffs. `docread.ts` had derived
29
+ * the same set independently; the two were diffed over every codepoint and 200,000 random
30
+ * strings before they were merged, and the name stays exported from here.
27
31
  */
28
- const PY_SPACE = new Set([
29
- 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x85, 0xa0, 0x1680, 0x2000,
30
- 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028,
31
- 0x2029, 0x202f, 0x205f, 0x3000,
32
- ]);
33
- export function pyStrip(text) {
34
- const chars = [...text];
35
- let start = 0;
36
- let end = chars.length;
37
- while (start < end && PY_SPACE.has(chars[start].codePointAt(0)))
38
- start += 1;
39
- while (end > start && PY_SPACE.has(chars[end - 1].codePointAt(0)))
40
- end -= 1;
41
- return chars.slice(start, end).join('');
42
- }
32
+ export { pyStrip };
43
33
  /**
44
34
  * The bytes of a fact file as Python's `Path.read_text(encoding="utf-8")` sees them.
45
35
  *
@@ -1,3 +1,4 @@
1
+ import { cmpCodepoint, pyRepr } from '../pysem.js';
1
2
  /** The errno names this port claims to render in CPython's words. The suite reads it. */
2
3
  export declare const STRERROR_NAMES: readonly string[];
3
4
  /**
@@ -242,15 +243,20 @@ export declare function matchesMd(name: string): boolean;
242
243
  /** `os.path.normcase` — identity off Windows, `s.replace('/', '\\').lower()` on it. */
243
244
  export declare function normcase(name: string): string;
244
245
  /**
245
- * Python's `<` on `str`, which is CODEPOINT order. JS compares UTF-16 code units.
246
+ * Python's `<` on `str`, which is CODEPOINT order — `pysem.ts`'s, and no longer a second copy
247
+ * of it (`docs/roadmap-toolbox.md` row 8, entry (n)).
246
248
  *
247
- * They disagree for every astral character: `['\u{1F414}.md', '!.md'].sort()` puts the
248
- * chicken first because its lead surrogate is 0xD83D, and Python puts it last because
249
- * 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall` breaks score ties on the name, and the
250
- * index is written in that order — so a single astral fact name is enough to make two
251
- * `index.md` files that differ.
249
+ * JS compares UTF-16 code units, and the two disagree for every astral character:
250
+ * `['\u{1F414}.md', '!.md'].sort()` puts the chicken first because its lead surrogate is
251
+ * 0xD83D, and Python puts it last because 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall`
252
+ * breaks score ties on the name, and the index is written in that order — so a single astral
253
+ * fact name is enough to make two `index.md` files that differ.
254
+ *
255
+ * `docread.ts` carried its own, which agreed on the ORDER for every pair and disagreed on the
256
+ * numbers (it returned the codepoint difference, this returns -1/0/1). This spelling is the
257
+ * one that survived, to the digit, because every importer already sees these numbers.
252
258
  */
253
- export declare function cmpCodepoint(a: string, b: string): number;
259
+ export { cmpCodepoint };
254
260
  /**
255
261
  * `sorted(directory / name for name in names)` — sorting `Path`s, not strings.
256
262
  *
@@ -473,20 +479,22 @@ export declare function pyIsFile(path: string): boolean;
473
479
  */
474
480
  export declare function pyStatIsDir(path: string): boolean;
475
481
  /**
476
- * `repr(str)`, which `_pinned_store` interpolates with `{raw!r}` for a relative pin.
482
+ * `repr(str)`, which `_pinned_store` interpolates with `{raw!r}` for a relative pin — and
483
+ * which `pysem.ts` now spells once (`docs/roadmap-toolbox.md` row 8, entry (n)).
477
484
  *
478
485
  * Python quotes with `'` unless the value contains a `'` and no `"`; it escapes `\\`, the
479
486
  * quote, `\t`, `\n`, `\r`, and every codepoint that is not `str.isprintable()` — that is,
480
- * everything in a `C*` category and every separator except the space itself. JS spells the
481
- * same predicate `\p{C}` / `\p{Z}` under the `u` flag, so this is the rule rather than a
482
- * table. The escapes are `\xNN` below U+0100, `\uNNNN` below U+10000, `\UNNNNNNNN` above.
487
+ * everything in a `C*` category and every separator except the space itself. `docread.ts` had
488
+ * spelled the same predicate as an explicit `Cc|Cf|Cs|Co|Cn|Zl|Zp|Zs` class where this one
489
+ * spelled it `\p{C}|\p{Z}`; the two were compared on all 1,114,112 codepoints and answered
490
+ * byte-identically before they were merged.
483
491
  *
484
492
  * The one place it can drift is a codepoint whose category changed between CPython 3.12's
485
493
  * Unicode 15.0 and the ICU this Node was built against: a newly assigned character is `Cn`
486
494
  * (not printable, escaped) for Python and assigned (printable, raw) here. A pin path made of
487
495
  * brand-new codepoints is the only input that reaches it.
488
496
  */
489
- export declare function pyRepr(value: string): string;
497
+ export { pyRepr };
490
498
  /**
491
499
  * `repr(float)`.
492
500
  *
@@ -55,6 +55,7 @@
55
55
  */
56
56
  import { appendFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
57
57
  import { constants as osConstants, homedir, userInfo } from 'node:os';
58
+ import { cmpCodepoint, pyRepr } from '../pysem.js';
58
59
  // ------------------------------------------------------------------------------ errno
59
60
  /**
60
61
  * `os.strerror(errno)` for the errnos a directory scan, a `mkdir` or a file write can
@@ -750,26 +751,20 @@ export function normcase(name) {
750
751
  }
751
752
  // -------------------------------------------------------------------------- comparison
752
753
  /**
753
- * Python's `<` on `str`, which is CODEPOINT order. JS compares UTF-16 code units.
754
- *
755
- * They disagree for every astral character: `['\u{1F414}.md', '!.md'].sort()` puts the
756
- * chicken first because its lead surrogate is 0xD83D, and Python puts it last because
757
- * 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall` breaks score ties on the name, and the
758
- * index is written in that order — so a single astral fact name is enough to make two
759
- * `index.md` files that differ.
754
+ * Python's `<` on `str`, which is CODEPOINT order — `pysem.ts`'s, and no longer a second copy
755
+ * of it (`docs/roadmap-toolbox.md` row 8, entry (n)).
756
+ *
757
+ * JS compares UTF-16 code units, and the two disagree for every astral character:
758
+ * `['\u{1F414}.md', '!.md'].sort()` puts the chicken first because its lead surrogate is
759
+ * 0xD83D, and Python puts it last because 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall`
760
+ * breaks score ties on the name, and the index is written in that order — so a single astral
761
+ * fact name is enough to make two `index.md` files that differ.
762
+ *
763
+ * `docread.ts` carried its own, which agreed on the ORDER for every pair and disagreed on the
764
+ * numbers (it returned the codepoint difference, this returns -1/0/1). This spelling is the
765
+ * one that survived, to the digit, because every importer already sees these numbers.
760
766
  */
761
- export function cmpCodepoint(a, b) {
762
- const x = [...a];
763
- const y = [...b];
764
- const n = Math.min(x.length, y.length);
765
- for (let i = 0; i < n; i += 1) {
766
- const p = x[i].codePointAt(0);
767
- const q = y[i].codePointAt(0);
768
- if (p !== q)
769
- return p < q ? -1 : 1;
770
- }
771
- return x.length - y.length;
772
- }
767
+ export { cmpCodepoint };
773
768
  /**
774
769
  * `sorted(directory / name for name in names)` — sorting `Path`s, not strings.
775
770
  *
@@ -1458,47 +1453,22 @@ export function pyStatIsDir(path) {
1458
1453
  }
1459
1454
  // --------------------------------------------------------------------------------- repr
1460
1455
  /**
1461
- * `repr(str)`, which `_pinned_store` interpolates with `{raw!r}` for a relative pin.
1456
+ * `repr(str)`, which `_pinned_store` interpolates with `{raw!r}` for a relative pin — and
1457
+ * which `pysem.ts` now spells once (`docs/roadmap-toolbox.md` row 8, entry (n)).
1462
1458
  *
1463
1459
  * Python quotes with `'` unless the value contains a `'` and no `"`; it escapes `\\`, the
1464
1460
  * quote, `\t`, `\n`, `\r`, and every codepoint that is not `str.isprintable()` — that is,
1465
- * everything in a `C*` category and every separator except the space itself. JS spells the
1466
- * same predicate `\p{C}` / `\p{Z}` under the `u` flag, so this is the rule rather than a
1467
- * table. The escapes are `\xNN` below U+0100, `\uNNNN` below U+10000, `\UNNNNNNNN` above.
1461
+ * everything in a `C*` category and every separator except the space itself. `docread.ts` had
1462
+ * spelled the same predicate as an explicit `Cc|Cf|Cs|Co|Cn|Zl|Zp|Zs` class where this one
1463
+ * spelled it `\p{C}|\p{Z}`; the two were compared on all 1,114,112 codepoints and answered
1464
+ * byte-identically before they were merged.
1468
1465
  *
1469
1466
  * The one place it can drift is a codepoint whose category changed between CPython 3.12's
1470
1467
  * Unicode 15.0 and the ICU this Node was built against: a newly assigned character is `Cn`
1471
1468
  * (not printable, escaped) for Python and assigned (printable, raw) here. A pin path made of
1472
1469
  * brand-new codepoints is the only input that reaches it.
1473
1470
  */
1474
- export function pyRepr(value) {
1475
- const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
1476
- let out = quote;
1477
- for (const ch of value) {
1478
- if (ch === '\\')
1479
- out += '\\\\';
1480
- else if (ch === quote)
1481
- out += `\\${ch}`;
1482
- else if (ch === '\t')
1483
- out += '\\t';
1484
- else if (ch === '\n')
1485
- out += '\\n';
1486
- else if (ch === '\r')
1487
- out += '\\r';
1488
- else if (ch === ' ' || !/^(?:\p{C}|\p{Z})$/u.test(ch))
1489
- out += ch;
1490
- else {
1491
- const cp = ch.codePointAt(0);
1492
- if (cp < 0x100)
1493
- out += `\\x${cp.toString(16).padStart(2, '0')}`;
1494
- else if (cp < 0x10000)
1495
- out += `\\u${cp.toString(16).padStart(4, '0')}`;
1496
- else
1497
- out += `\\U${cp.toString(16).padStart(8, '0')}`;
1498
- }
1499
- }
1500
- return out + quote;
1501
- }
1471
+ export { pyRepr };
1502
1472
  /**
1503
1473
  * `repr(float)`.
1504
1474
  *
@@ -330,6 +330,49 @@ export declare class MemoryStore {
330
330
  * say, a directory at `archive/<name>.md`, which the guard above stats and refuses first.
331
331
  */
332
332
  archive(name: string): void;
333
+ /**
334
+ * `NAME_RE` ENFORCED BEFORE ANY SYSCALL, matching `archive` (job44, z). `restore` has
335
+ * built `facts/<name>.md` out of whatever it was handed since it was written; measured on
336
+ * macOS before this check, both runtimes: `restore ALPHA` against a live
337
+ * `archive/ALPHA.md` exited 0 and wrote `facts/ALPHA.md` — the case-insensitive
338
+ * filesystem matched the archived source, and nothing asked the store's own naming rule
339
+ * about the destination it was about to create.
340
+ *
341
+ * THE OCCUPIED-DESTINATION GUARD NOW ALSO CATCHES A DANGLING SYMLINK (job44, z). `reachable`
342
+ * is `pyExists`, `Path.exists()`'s equivalent, which FOLLOWS symlinks — so a dangling
343
+ * symlink at `facts/<name>.md` is an occupied directory entry the guard alone cannot see:
344
+ * measured, `pyLexists` true and `pyExists` false, and without the check below the guard
345
+ * passed and execution reached the pre-move `facts()` parse, which then raised a raw
346
+ * `FileNotFoundError` reading the dangling link it was about to parse as a fact. `pyLexists`
347
+ * is only consulted when `reachable` returned `false` WITHOUT raising — an EACCES on the
348
+ * same stat already escaped out of `reachable` first, so this never masks that distinction.
349
+ *
350
+ * TWO SENTENCES, NOT ONE, on job44's reconciliation with the reference. The first two
351
+ * answers tried here were both wrong: treating a dangling link the same as `reachable` said
352
+ * "already live", and a live fact is exactly what a dangling link is NOT; letting it fall
353
+ * through to `facts()`'s pre-read dressed an unrelated `FileNotFoundError` as a move
354
+ * failure when no move had been attempted (both runtimes made one of these two mistakes).
355
+ * The link occupies the path — refusing is still correct — but the REASON is that it
356
+ * cannot be read as a fact, not that a fact is already there, so the second branch below
357
+ * says that instead and never reaches `facts()` at all.
358
+ *
359
+ * THIS IS THE OPPOSITE OUTCOME FROM `archive`'s mirror of the same defect: `archive`'s
360
+ * forward move is `pyReplace`, which overwrites a dangling link identically on every
361
+ * platform, so the guard there is deliberately left as `reachable` alone (see the test
362
+ * pinning that). Closing the guard here removes the ONLY path that could reach an occupied
363
+ * destination, on both runtimes, rather than picking a side of what would happen there.
364
+ *
365
+ * AND THE FORWARD MOVE ITSELF NO LONGER DIVERGES — this sentence used to say it did, and
366
+ * was stale against its own commit. `restore`'s forward move is `pyReplace` on this side
367
+ * and `source.replace(destination)` on the reference (`store.py`, `restore`), changed in
368
+ * the SAME commit that added the guard above; at its parent `f484c70` the reference still
369
+ * called `source.rename(destination)`, which is what this comment described.
370
+ * `d239480`'s `rename`-versus-`pyReplace` difference is therefore REMOVED here, not merely
371
+ * made unreachable, and the same is true of `archive` (fixed by review round 5) and of
372
+ * `compact` (fixed by `d239480` itself). Every forward move in this store is `replace` on
373
+ * both sides; the only `rename` calls left on the reference are the two ROLLBACKS, which
374
+ * move back onto a path the forward move has just emptied — see `docs/porting.md`.
375
+ */
333
376
  restore(name: string): void;
334
377
  /**
335
378
  * The index as it should be on disk, derived from `facts/` and nothing else.