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.
- package/dist/docread.d.ts +82 -8
- package/dist/docread.js +348 -96
- package/dist/mcp/pyargs.d.ts +19 -0
- package/dist/mcp/pyargs.js +12 -1
- package/dist/mcp/server.js +128 -34
- package/dist/memory/cli.js +43 -1
- package/dist/memory/factfile.d.ts +13 -1
- package/dist/memory/factfile.js +7 -17
- package/dist/memory/pyfs.d.ts +20 -12
- package/dist/memory/pyfs.js +21 -51
- package/dist/memory/store.d.ts +43 -0
- package/dist/memory/store.js +61 -3
- package/dist/pysem.d.ts +81 -0
- package/dist/pysem.js +126 -0
- package/dist/skillaudit.js +31 -38
- package/package.json +2 -2
package/dist/mcp/pyargs.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/mcp/pyargs.js
CHANGED
|
@@ -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
|
*
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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);
|
package/dist/memory/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
package/dist/memory/factfile.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
package/dist/memory/pyfs.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
248
|
-
* chicken first because its lead surrogate is
|
|
249
|
-
* 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall`
|
|
250
|
-
* index is written in that order — so a single astral
|
|
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
|
|
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.
|
|
481
|
-
* same predicate
|
|
482
|
-
*
|
|
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
|
|
497
|
+
export { pyRepr };
|
|
490
498
|
/**
|
|
491
499
|
* `repr(float)`.
|
|
492
500
|
*
|
package/dist/memory/pyfs.js
CHANGED
|
@@ -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.
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
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
|
|
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.
|
|
1466
|
-
* same predicate
|
|
1467
|
-
*
|
|
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
|
|
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
|
*
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -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.
|