bantamkit-mcp 0.26.0
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/LICENSE +21 -0
- package/README.md +398 -0
- package/assets/contracts/default.yaml +90 -0
- package/assets/evals/devteam/manifest.yaml +351 -0
- package/assets/evals/devteam/repo/HISTORY.md +18 -0
- package/assets/evals/devteam/repo/README.md +12 -0
- package/assets/evals/devteam/repo/docs/architecture.md +17 -0
- package/assets/evals/devteam/repo/docs/runbook.md +10 -0
- package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- package/assets/evals/fixtures/.gitkeep +0 -0
- package/assets/evals/fixtures/catalog.json +6 -0
- package/assets/evals/perturbations/task-completion.yaml +576 -0
- package/assets/evals/tasks/.gitkeep +0 -0
- package/assets/evals/tasks/extract-contact.yaml +14 -0
- package/assets/evals/tasks/extract-invoice.yaml +14 -0
- package/assets/evals/tasks/extract-order.yaml +15 -0
- package/assets/evals/tasks/extract-schedule.yaml +14 -0
- package/assets/evals/tasks/extract-versions.yaml +17 -0
- package/assets/evals/tasks/nav-prod-port.yaml +84 -0
- package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- package/assets/evals/tasks/recall-db-port.yaml +17 -0
- package/assets/evals/tasks/recall-deploy.yaml +13 -0
- package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- package/assets/evals/tasks/recall-oncall.yaml +13 -0
- package/assets/evals/tasks/recall-org-quota.yaml +18 -0
- package/assets/evals/tasks/recall-owner.yaml +13 -0
- package/assets/evals/tasks/shop-basket-total.yaml +10 -0
- package/assets/evals/tasks/shop-cheapest.yaml +9 -0
- package/assets/evals/tasks/shop-compare.yaml +9 -0
- package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- package/assets/evals/tasks/shop-stock-total.yaml +9 -0
- package/assets/evals/tasks/shop-total.yaml +9 -0
- package/assets/profiles/default.yaml +31 -0
- package/assets/profiles/patient.yaml +31 -0
- package/assets/rubrics/.gitkeep +0 -0
- package/assets/rubrics/code-quality.yaml +20 -0
- package/assets/rubrics/grounded-completion.yaml +37 -0
- package/assets/rubrics/task-completion.yaml +28 -0
- package/assets/schemas/shiftwork-checkpoint.json +188 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/file-graph.md +7 -0
- package/assets/skills/memory.md +35 -0
- package/assets/tools/.gitkeep +0 -0
- package/assets/tools/bantamkit_read.json +48 -0
- package/assets/tools/bantamkit_status.json +25 -0
- package/assets/tools/build_identity.json +17 -0
- package/assets/tools/document_list.json +12 -0
- package/assets/tools/document_read.json +31 -0
- package/assets/tools/file_graph.json +12 -0
- package/assets/tools/memory_compact.json +31 -0
- package/assets/tools/memory_recall.json +38 -0
- package/assets/tools/memory_save.json +61 -0
- package/assets/tools/shiftwork_clock_in.json +25 -0
- package/assets/tools/shiftwork_clock_out.json +60 -0
- package/assets/tools/shiftwork_status.json +25 -0
- package/assets/tools/skill_audit.json +70 -0
- package/assets/tools/validate_json.json +31 -0
- package/dist/assets.d.ts +48 -0
- package/dist/assets.js +109 -0
- package/dist/charsets.d.ts +17 -0
- package/dist/charsets.js +580 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +280 -0
- package/dist/contract.d.ts +145 -0
- package/dist/contract.js +608 -0
- package/dist/docread.d.ts +357 -0
- package/dist/docread.js +3315 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +14 -0
- package/dist/eventlog.d.ts +137 -0
- package/dist/eventlog.js +294 -0
- package/dist/htmlentities.d.ts +2 -0
- package/dist/htmlentities.js +16 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +13 -0
- package/dist/mcp/identity.d.ts +25 -0
- package/dist/mcp/identity.js +275 -0
- package/dist/mcp/pyargs.d.ts +80 -0
- package/dist/mcp/pyargs.js +362 -0
- package/dist/mcp/sdkjson.d.ts +56 -0
- package/dist/mcp/sdkjson.js +91 -0
- package/dist/mcp/server.d.ts +45 -0
- package/dist/mcp/server.js +740 -0
- package/dist/mcp/status.d.ts +210 -0
- package/dist/mcp/status.js +357 -0
- package/dist/mcp/transport.d.ts +68 -0
- package/dist/mcp/transport.js +111 -0
- package/dist/mcpreport.d.ts +214 -0
- package/dist/mcpreport.js +758 -0
- package/dist/memory/cli.d.ts +2 -0
- package/dist/memory/cli.js +396 -0
- package/dist/memory/component.d.ts +256 -0
- package/dist/memory/component.js +497 -0
- package/dist/memory/factfile.d.ts +120 -0
- package/dist/memory/factfile.js +406 -0
- package/dist/memory/layers.d.ts +92 -0
- package/dist/memory/layers.js +410 -0
- package/dist/memory/pyfs.d.ts +521 -0
- package/dist/memory/pyfs.js +1608 -0
- package/dist/memory/pyyaml.d.ts +155 -0
- package/dist/memory/pyyaml.js +868 -0
- package/dist/memory/store.d.ts +478 -0
- package/dist/memory/store.js +1034 -0
- package/dist/pyargparse.d.ts +141 -0
- package/dist/pyargparse.js +813 -0
- package/dist/pyjson.d.ts +110 -0
- package/dist/pyjson.js +524 -0
- package/dist/pyjsonschema.d.ts +50 -0
- package/dist/pyjsonschema.js +951 -0
- package/dist/shiftwork.d.ts +60 -0
- package/dist/shiftwork.js +324 -0
- package/dist/skillaudit.d.ts +172 -0
- package/dist/skillaudit.js +914 -0
- package/dist/statusline.d.ts +106 -0
- package/dist/statusline.js +281 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem calls the memory store makes, with CPython's answers rather than libuv's.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS AS ITS OWN MODULE
|
|
5
|
+
* ---------------------------------
|
|
6
|
+
* `store._listing` is the code job37 spent five units closing, and the whole of that fix is
|
|
7
|
+
* a DECISION taken on the result of a failed directory scan: something at the path means
|
|
8
|
+
* "unreadable", nothing at the path means "empty". Reproducing the decision needs three
|
|
9
|
+
* things Node's `fs` does not hand you — a `strerror` in the C library's words rather than
|
|
10
|
+
* libuv's, an `lexists` that answers `false` for every error and not just ENOENT, and a
|
|
11
|
+
* `Path.exists()` that swallows exactly the errnos `pathlib` swallows. They are here rather
|
|
12
|
+
* than inside `store.ts` because `layers.count_facts` makes the SAME syscalls and a
|
|
13
|
+
* DELIBERATELY DIFFERENT decision on them, so the next unit needs the primitives without
|
|
14
|
+
* inheriting the store's ruling.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here invents a policy. Every function names the CPython call it stands in for.
|
|
17
|
+
*
|
|
18
|
+
* REGISTERED AND NOT DONE — N8's review found five things in this file and N10 fixed two of
|
|
19
|
+
* them. The two were WRONG ANSWERS: `pyDecodeUtf8` ate a UTF-8 BOM its own validator had
|
|
20
|
+
* just accepted, and `OSError.__str__`/the symlink-loop `RuntimeError` spelled `%r` as a
|
|
21
|
+
* hand-written pair of apostrophes. The other three are CONSOLIDATION — one question with
|
|
22
|
+
* two spellings, both of them right — and they are listed here rather than half-started:
|
|
23
|
+
*
|
|
24
|
+
* - FOUR TABLES KEYED ON THE SAME THING, now, not two: `STRERROR` (errno name -> the C
|
|
25
|
+
* library's sentence), `STRERROR_WINDOWS` (the four the UCRT spells differently),
|
|
26
|
+
* `OSERROR_SUBCLASS` (errno name -> the exception class CPython picks) and `WINERROR`
|
|
27
|
+
* (Win32 number -> the Win32 sentence). Three of the four are read back against the
|
|
28
|
+
* running Python — `os.strerror for every errno` covers the first two and
|
|
29
|
+
* `FormatMessage for every winerror` the fourth, both in
|
|
30
|
+
* `tools/conformance/suites/store.mjs` — and `OSERROR_SUBCLASS` is the one still
|
|
31
|
+
* uncovered: an errno added to `STRERROR` and not to it silently loses its exception
|
|
32
|
+
* class. Nothing measured is wrong today.
|
|
33
|
+
* - `normcase` AND `PyRuntimeError` ARE EXPORTED WITH NO CALLER OUTSIDE THIS FILE. Both
|
|
34
|
+
* are used inside it; the export is surface nobody asked for.
|
|
35
|
+
*
|
|
36
|
+
* FOUR PATH PARSERS became one, and N11 closed that one because it had stopped being a
|
|
37
|
+
* consolidation and started being wrong. `parsePath`, `lastSeparator` and `resolveWindows`
|
|
38
|
+
* each re-decided where a separator and a drive letter are; the Windows readings they agreed
|
|
39
|
+
* on were the POSIX ones. There is now a single `ntpath`/`PureWindowsPath` model —
|
|
40
|
+
* `ntSplitRoot`, `parseWindowsPath`, `ntJoin`, `winFormat` and the `win*` functions over
|
|
41
|
+
* them — and every Windows arm is a one-line dispatch to it.
|
|
42
|
+
*
|
|
43
|
+
* WHY THE WINDOWS ARMS ARE EXPORTED SEPARATELY FROM THE PLATFORM DISPATCH. They are pure
|
|
44
|
+
* path algebra, and CPython computes it the same way on every operating system, so exporting
|
|
45
|
+
* the flavour explicitly turns "measurable only on a GitHub runner" into "measurable on the
|
|
46
|
+
* laptop that wrote it". The `ntpath.splitroot and PureWindowsPath parsing` case in
|
|
47
|
+
* `tools/conformance/suites/store.mjs` drives them against a running CPython on every
|
|
48
|
+
* platform, and it caught a wrong answer (`///a` reported absolute) before a CI cycle was
|
|
49
|
+
* spent on it. What still cannot be measured off Windows is the DISPATCH — the
|
|
50
|
+
* `process.platform === 'win32'` line itself — and the calls that touch the real filesystem
|
|
51
|
+
* (`resolveWindows`, `scandirShape`).
|
|
52
|
+
*
|
|
53
|
+
* Also registered, in `store.ts` rather than here: the `ValueError` arms `_facts` catches
|
|
54
|
+
* and this port reaches by a different route. Owner: whichever unit consolidates `pyfs`.
|
|
55
|
+
*/
|
|
56
|
+
import { appendFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
57
|
+
import { constants as osConstants, homedir, userInfo } from 'node:os';
|
|
58
|
+
// ------------------------------------------------------------------------------ errno
|
|
59
|
+
/**
|
|
60
|
+
* `os.strerror(errno)` for the errnos a directory scan, a `mkdir` or a file write can
|
|
61
|
+
* actually produce. Keyed on the errno NAME, not the number: ELOOP is 62 on macOS and 40 on
|
|
62
|
+
* Linux, so a table keyed on the number would be wrong on one of the two CI platforms.
|
|
63
|
+
*
|
|
64
|
+
* Every wording here is glibc's AND BSD's — verified identical for this set by the
|
|
65
|
+
* `strerror table` case in `tools/conformance/suites/store.mjs`, which asks the running
|
|
66
|
+
* Python for `os.strerror` of each entry. Adding a key without adding it to that case is
|
|
67
|
+
* how this quietly becomes a guess.
|
|
68
|
+
*
|
|
69
|
+
* WINDOWS IS COVERED BY A SECOND TABLE, NOT BY THIS ONE. `os.scandir` on Windows raises an
|
|
70
|
+
* `OSError` whose `strerror` comes from the Win32 message table by way of `winerror`, not
|
|
71
|
+
* from the CRT — "The directory name is invalid" where POSIX says "Not a directory". That
|
|
72
|
+
* table is `WINERROR` below, and which of the two a given call uses is `OSErrorOrigin`:
|
|
73
|
+
* anything that goes through the Win32 API reads `WINERROR`, and `open()` — which goes
|
|
74
|
+
* through the C runtime — reads this one.
|
|
75
|
+
*/
|
|
76
|
+
const STRERROR = {
|
|
77
|
+
E2BIG: 'Argument list too long',
|
|
78
|
+
EACCES: 'Permission denied',
|
|
79
|
+
EBADF: 'Bad file descriptor',
|
|
80
|
+
EEXIST: 'File exists',
|
|
81
|
+
EFAULT: 'Bad address',
|
|
82
|
+
EINVAL: 'Invalid argument',
|
|
83
|
+
EIO: 'Input/output error',
|
|
84
|
+
EISDIR: 'Is a directory',
|
|
85
|
+
ELOOP: 'Too many levels of symbolic links',
|
|
86
|
+
EMFILE: 'Too many open files',
|
|
87
|
+
ENAMETOOLONG: 'File name too long',
|
|
88
|
+
ENFILE: 'Too many open files in system',
|
|
89
|
+
ENOENT: 'No such file or directory',
|
|
90
|
+
ENOMEM: 'Cannot allocate memory',
|
|
91
|
+
ENOSPC: 'No space left on device',
|
|
92
|
+
ENOTDIR: 'Not a directory',
|
|
93
|
+
ENOTEMPTY: 'Directory not empty',
|
|
94
|
+
EPERM: 'Operation not permitted',
|
|
95
|
+
EROFS: 'Read-only file system',
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* The four of those nineteen where the WINDOWS C runtime says something else.
|
|
99
|
+
*
|
|
100
|
+
* `os.strerror` is the CRT's `strerror`, and the UCRT's table is not glibc's. MEASURED
|
|
101
|
+
* against CPython 3.12 on windows-latest, run 32649940727, by the `os.strerror for every
|
|
102
|
+
* errno` case: `E2BIG` is "Arg list too long" there, `ENAMETOOLONG` is "Filename too long",
|
|
103
|
+
* `ENOMEM` is "Not enough space", and `ELOOP` — which the UCRT has no entry for at all —
|
|
104
|
+
* is the literal string "Unknown error". Everything not listed is byte-identical.
|
|
105
|
+
*
|
|
106
|
+
* None of the four is reachable from a store operation today; the table is compared in full
|
|
107
|
+
* because an entry nobody reaches is exactly the one that rots.
|
|
108
|
+
*/
|
|
109
|
+
const STRERROR_WINDOWS = {
|
|
110
|
+
E2BIG: 'Arg list too long',
|
|
111
|
+
ELOOP: 'Unknown error',
|
|
112
|
+
ENAMETOOLONG: 'Filename too long',
|
|
113
|
+
ENOMEM: 'Not enough space',
|
|
114
|
+
};
|
|
115
|
+
/** The errno names this port claims to render in CPython's words. The suite reads it. */
|
|
116
|
+
export const STRERROR_NAMES = Object.keys(STRERROR);
|
|
117
|
+
/**
|
|
118
|
+
* `os.strerror` for one errno NAME — the table alone, with no Windows arm in front of it.
|
|
119
|
+
*
|
|
120
|
+
* The conformance case that checks these wordings used to read them back through
|
|
121
|
+
* `asPyOSError`, which now answers with the WIN32 message on Windows for exactly the codes
|
|
122
|
+
* the table also carries. Reading the table directly keeps that case measuring the thing its
|
|
123
|
+
* name says, and leaves the winerror wordings to the `winerror table` case beside it.
|
|
124
|
+
*/
|
|
125
|
+
export function pyStrerror(code) {
|
|
126
|
+
if (process.platform === 'win32' && code in STRERROR_WINDOWS)
|
|
127
|
+
return STRERROR_WINDOWS[code];
|
|
128
|
+
return STRERROR[code];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* CPython's `errnomap`, for the errnos this module can raise.
|
|
132
|
+
*
|
|
133
|
+
* `OSError.__new__` picks a SUBCLASS from the errno, and the class name is what a caller
|
|
134
|
+
* sees and what `component.Memory` puts in front of a person. A single `PyOSError` name
|
|
135
|
+
* would be a difference with no reason behind it, so the name follows the errno the way
|
|
136
|
+
* CPython's does. Anything not listed stays `OSError`, which is also CPython's default.
|
|
137
|
+
*/
|
|
138
|
+
const OSERROR_SUBCLASS = {
|
|
139
|
+
EACCES: 'PermissionError',
|
|
140
|
+
EAGAIN: 'BlockingIOError',
|
|
141
|
+
ECHILD: 'ChildProcessError',
|
|
142
|
+
EEXIST: 'FileExistsError',
|
|
143
|
+
EINTR: 'InterruptedError',
|
|
144
|
+
EISDIR: 'IsADirectoryError',
|
|
145
|
+
ENOENT: 'FileNotFoundError',
|
|
146
|
+
ENOTDIR: 'NotADirectoryError',
|
|
147
|
+
EPERM: 'PermissionError',
|
|
148
|
+
EPIPE: 'BrokenPipeError',
|
|
149
|
+
ESRCH: 'ProcessLookupError',
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* `type(exc).__name__` for an `OSError` with this errno NAME — the table and its default.
|
|
153
|
+
*
|
|
154
|
+
* ONE reader of `OSERROR_SUBCLASS` for two callers, because the default is half the rule:
|
|
155
|
+
* an unlisted errno is `OSError` in CPython, and a second `?? 'OSError'` written somewhere
|
|
156
|
+
* else is a second place for the two halves to drift apart. `PyOSError`'s own constructor
|
|
157
|
+
* takes its `name` from here, and `eventlog.ts` asks the same question of an exception it
|
|
158
|
+
* is about to record — the port had already decided this question in the constructor and
|
|
159
|
+
* the event log answered it with the JavaScript constructor name instead, recording
|
|
160
|
+
* `PyOSError` where CPython records `NotADirectoryError`.
|
|
161
|
+
*/
|
|
162
|
+
export function osErrorClassName(code) {
|
|
163
|
+
return OSERROR_SUBCLASS[code] ?? 'OSError';
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* An `OSError` as Python prints one.
|
|
167
|
+
*
|
|
168
|
+
* `str(OSError)` is `[Errno {errno}] {strerror}: {filename!r}`, and that text is not
|
|
169
|
+
* decoration: `_ensure_dirs` lets a `FileExistsError` out of `save` unconverted, and
|
|
170
|
+
* `component.Memory.save` turns whatever comes out into the sentence a model reads.
|
|
171
|
+
* `errno` is the platform number, which is what Python prints and what libuv already
|
|
172
|
+
* gives us as the magnitude of `err.errno`.
|
|
173
|
+
*/
|
|
174
|
+
export class PyOSError extends Error {
|
|
175
|
+
errno;
|
|
176
|
+
code;
|
|
177
|
+
strerror;
|
|
178
|
+
filename;
|
|
179
|
+
/** `OSError.filename2` — set by the two-path syscalls, and PRINTED when it is. */
|
|
180
|
+
filename2;
|
|
181
|
+
/**
|
|
182
|
+
* `OSError.winerror`, which exists ONLY on Windows and CHANGES `__str__`.
|
|
183
|
+
*
|
|
184
|
+
* CPython carries both numbers for a call that went through the Win32 API: the translated
|
|
185
|
+
* POSIX `errno` and the raw Win32 code. `OSError.__str__` prefers the latter and prints
|
|
186
|
+
* `[WinError %d] <the Win32 message>`. `null` here means "this OSError has no winerror",
|
|
187
|
+
* which is every OSError on POSIX and every OSError CPython raises on Windows out of the
|
|
188
|
+
* C runtime rather than out of the Win32 API — see `asPyOSError`'s `origin`.
|
|
189
|
+
*/
|
|
190
|
+
winerror;
|
|
191
|
+
constructor(errno, code, strerror, filename, filename2 = null, winerror = null) {
|
|
192
|
+
// `OSError.__str__` is `"[Errno %S] %S: %R"`, or `"[Errno %S] %S: %R -> %R"` when there
|
|
193
|
+
// are two (`Objects/exceptions.c`). `%R` is `repr`, NOT a hand-written pair of
|
|
194
|
+
// apostrophes. WAS the latter, and N8 measured the difference: a path holding a `'`, a
|
|
195
|
+
// newline or a tab produced a sentence CPython does not print — `pyRepr` switches to
|
|
196
|
+
// double quotes for the first and escapes the other two. That sentence is quoted
|
|
197
|
+
// verbatim into what `component.Memory` and shiftwork's clock-out put before a model.
|
|
198
|
+
const where = filename === null
|
|
199
|
+
? ''
|
|
200
|
+
: filename2 === null
|
|
201
|
+
? `: ${pyRepr(filename)}`
|
|
202
|
+
: `: ${pyRepr(filename)} -> ${pyRepr(filename2)}`;
|
|
203
|
+
// `Objects/exceptions.c`, `OSError_str`: the winerror arm comes FIRST, and when it fires
|
|
204
|
+
// the number printed is the Win32 one and the sentence is the Win32 one. There is no
|
|
205
|
+
// spelling of `str(OSError)` that shows both.
|
|
206
|
+
const head = winerror === null ? `[Errno ${errno}]` : `[WinError ${winerror}]`;
|
|
207
|
+
super(`${head} ${strerror}${where}`);
|
|
208
|
+
this.name = osErrorClassName(code);
|
|
209
|
+
this.errno = errno;
|
|
210
|
+
this.code = code;
|
|
211
|
+
this.strerror = strerror;
|
|
212
|
+
this.filename = filename;
|
|
213
|
+
this.filename2 = filename2;
|
|
214
|
+
this.winerror = winerror;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The number CPython would print, which is NOT the number libuv hands over.
|
|
219
|
+
*
|
|
220
|
+
* `err.errno` is a libuv status, and on POSIX libuv reuses the platform's own errno so the
|
|
221
|
+
* magnitude is right by accident. On Windows it does not: libuv has its own space starting
|
|
222
|
+
* at -4096, so `ENOENT` arrives as **-4058** and `EISDIR` as **-4068** where CPython prints
|
|
223
|
+
* `[Errno 2]` and `[Errno 21]`. MEASURED on windows-latest, run 32644269451: five test
|
|
224
|
+
* nodes reported `[Errno 4058] No such file or directory` for a sentence the reference
|
|
225
|
+
* renders `[Errno 2] ...`, and that sentence is quoted verbatim into what shiftwork's
|
|
226
|
+
* clock-out and `component.Memory` put in front of a model.
|
|
227
|
+
*
|
|
228
|
+
* `os.constants.errno` is the platform's own `errno.h` table — 2/21/13 on Windows exactly
|
|
229
|
+
* as on POSIX — so keying on the CODE NAME gives CPython's number on every platform. The
|
|
230
|
+
* libuv magnitude stays as the fallback for a code the table does not carry, because a
|
|
231
|
+
* number that is merely wrong beats no number at all in a message a human has to search
|
|
232
|
+
* for. Cross-checked against the reference by the `oserror` cases in
|
|
233
|
+
* tools/conformance/suites/store.mjs.
|
|
234
|
+
*
|
|
235
|
+
* The `winerror` half of the same question is `WINERROR` and `winerrorFor` below.
|
|
236
|
+
*/
|
|
237
|
+
function pyErrno(e) {
|
|
238
|
+
const named = e?.code === undefined ? undefined : osConstants.errno[e.code];
|
|
239
|
+
return named ?? Math.abs(e?.errno ?? 0);
|
|
240
|
+
}
|
|
241
|
+
// ---------------------------------------------------------------------------- winerror
|
|
242
|
+
/**
|
|
243
|
+
* The Win32 message table, as `FormatMessageW` renders it and as CPython then TRIMS it.
|
|
244
|
+
*
|
|
245
|
+
* `PyErr_SetExcFromWindowsErrWithFilename` strips every trailing character that is `<= ' '`
|
|
246
|
+
* or `'.'`, so `The system cannot find the file specified.\r\n` reaches a reader without the
|
|
247
|
+
* period. ALL FIFTEEN ARE MEASURED, not four: the `FormatMessage for every winerror the
|
|
248
|
+
* port claims to render` case in `tools/conformance/suites/store.mjs` asks the running
|
|
249
|
+
* CPython — through `ctypes.FormatError`, with CPython's own trim — for every entry, and it
|
|
250
|
+
* passed on windows-latest from the first run that carried it (32649940727). Off Windows
|
|
251
|
+
* `ctypes.FormatError` has nothing to answer, so the case does not exist there and the
|
|
252
|
+
* suite's notes say which wordings are therefore unmeasured on the platform in hand.
|
|
253
|
+
*/
|
|
254
|
+
const WINERROR = {
|
|
255
|
+
1: 'Incorrect function',
|
|
256
|
+
2: 'The system cannot find the file specified',
|
|
257
|
+
3: 'The system cannot find the path specified',
|
|
258
|
+
5: 'Access is denied',
|
|
259
|
+
15: 'The system cannot find the drive specified',
|
|
260
|
+
32: 'The process cannot access the file because it is being used by another process',
|
|
261
|
+
80: 'The file exists',
|
|
262
|
+
87: 'The parameter is incorrect',
|
|
263
|
+
123: 'The filename, directory name, or volume label syntax is incorrect',
|
|
264
|
+
145: 'The directory is not empty',
|
|
265
|
+
183: 'Cannot create a file when that file already exists',
|
|
266
|
+
206: 'The filename or extension is too long',
|
|
267
|
+
267: 'The directory name is invalid',
|
|
268
|
+
1920: 'The file cannot be accessed by the system',
|
|
269
|
+
1921: 'The name of the file cannot be resolved by the system',
|
|
270
|
+
};
|
|
271
|
+
/** The winerrors this port claims to render in CPython's words. The suite reads it. */
|
|
272
|
+
export const WINERROR_NUMBERS = Object.keys(WINERROR).map(Number);
|
|
273
|
+
/** One Win32 wording, as CPython trims it. The `winerror table` case checks every entry. */
|
|
274
|
+
export function pyWinStrerror(winerror) {
|
|
275
|
+
return WINERROR[winerror];
|
|
276
|
+
}
|
|
277
|
+
/** `ERROR_CANT_RESOLVE_FILENAME` — pathlib's `_WINERROR_CANT_RESOLVE_FILENAME`. */
|
|
278
|
+
const WINERROR_CANT_RESOLVE_FILENAME = 1921;
|
|
279
|
+
/**
|
|
280
|
+
* libuv's code -> the Win32 error CPython would have carried, or `null` for "no winerror".
|
|
281
|
+
*
|
|
282
|
+
* THE MAPPING IS MANY-TO-ONE IN THE DIRECTION THAT LOSES INFORMATION, which is why this
|
|
283
|
+
* cannot be a plain table. libuv translates every Win32 error into one POSIX-shaped code, so
|
|
284
|
+
* `ERROR_FILE_NOT_FOUND` (2) and `ERROR_PATH_NOT_FOUND` (3) both arrive as `ENOENT` and the
|
|
285
|
+
* distinction Windows drew has to be RE-DERIVED. Win32 picks 3 whenever the component that
|
|
286
|
+
* failed was needed AS A DIRECTORY and 2 when only the final name was missing, so the probe
|
|
287
|
+
* is exactly that: which of the components is not there.
|
|
288
|
+
*
|
|
289
|
+
* Verified against the reference for both arms on run 32646521489 — `os.replace` onto a
|
|
290
|
+
* missing destination DIRECTORY is 3, `os.replace` of a missing source FILE beside an
|
|
291
|
+
* existing directory is 2. `exists` is injected so the derivation is a pure function with a
|
|
292
|
+
* node that goes red on a laptop.
|
|
293
|
+
*/
|
|
294
|
+
export function winerrorFor(code, origin, path, dest, exists) {
|
|
295
|
+
if (origin === 'crt')
|
|
296
|
+
return null;
|
|
297
|
+
if (code === 'ENOENT')
|
|
298
|
+
return notFoundWinerror(origin, path, dest, exists);
|
|
299
|
+
// libuv reports EINVAL for two unrelated things on Windows: a name the API refuses, and a
|
|
300
|
+
// parameter it refuses. Only the first belongs in the path walk; the second is
|
|
301
|
+
// `ERROR_INVALID_PARAMETER`, and routing both through the walk would print a not-found
|
|
302
|
+
// sentence for an argument error.
|
|
303
|
+
if (code === 'EINVAL') {
|
|
304
|
+
return ntNameIsInvalid(path) || ntNameIsInvalid(dest)
|
|
305
|
+
? notFoundWinerror(origin, path, dest, exists)
|
|
306
|
+
: 87;
|
|
307
|
+
}
|
|
308
|
+
switch (code) {
|
|
309
|
+
case 'ENOTDIR':
|
|
310
|
+
return 267;
|
|
311
|
+
case 'EEXIST':
|
|
312
|
+
return 183;
|
|
313
|
+
case 'EACCES':
|
|
314
|
+
case 'EPERM':
|
|
315
|
+
return 5;
|
|
316
|
+
case 'ENOTEMPTY':
|
|
317
|
+
return 145;
|
|
318
|
+
case 'ELOOP':
|
|
319
|
+
return WINERROR_CANT_RESOLVE_FILENAME;
|
|
320
|
+
case 'ENAMETOOLONG':
|
|
321
|
+
return 206;
|
|
322
|
+
case 'EBUSY':
|
|
323
|
+
return 32;
|
|
324
|
+
default:
|
|
325
|
+
// No claim rather than a fabricated one: an unmapped code keeps the `[Errno %d]` form,
|
|
326
|
+
// which is visibly a difference instead of a plausible-looking wrong sentence.
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* `winerror_to_errno` — CPython's translation, which is NOT the one libuv already applied.
|
|
332
|
+
*
|
|
333
|
+
* THE CLASS COMES OFF THE ERRNO, and the errno CPython carries is the one IT derived from
|
|
334
|
+
* the Win32 code, not the one libuv derived. MEASURED, run 32651049453: `os.unlink` of a
|
|
335
|
+
* name holding a newline is `ERROR_INVALID_NAME` (123), which CPython translates to EINVAL
|
|
336
|
+
* — and EINVAL is not in `errnomap`, so the exception is a plain `OSError`. libuv had folded
|
|
337
|
+
* the same failure into ENOENT, so this port raised `FileNotFoundError` and a caller's
|
|
338
|
+
* `except FileNotFoundError` would have swallowed an error the reference lets past.
|
|
339
|
+
*
|
|
340
|
+
* Every other arm here is an identity on the codes this port can reach (2/3 -> ENOENT,
|
|
341
|
+
* 183 -> EEXIST, 267 -> ENOTDIR), which is why only the 123 case was visible. The table is
|
|
342
|
+
* written out in full anyway: an arm that happens to be an identity today is exactly the one
|
|
343
|
+
* that stops being one without anybody noticing.
|
|
344
|
+
*/
|
|
345
|
+
export function winerrorToCode(winerror) {
|
|
346
|
+
switch (winerror) {
|
|
347
|
+
case 2:
|
|
348
|
+
case 3:
|
|
349
|
+
case 15:
|
|
350
|
+
return 'ENOENT';
|
|
351
|
+
case 5:
|
|
352
|
+
case 32:
|
|
353
|
+
case 1920:
|
|
354
|
+
return 'EACCES';
|
|
355
|
+
case 80:
|
|
356
|
+
case 183:
|
|
357
|
+
return 'EEXIST';
|
|
358
|
+
case 145:
|
|
359
|
+
return 'ENOTEMPTY';
|
|
360
|
+
case 206:
|
|
361
|
+
return 'ENAMETOOLONG';
|
|
362
|
+
case 267:
|
|
363
|
+
return 'ENOTDIR';
|
|
364
|
+
default:
|
|
365
|
+
// `ERROR_INVALID_FUNCTION`, `ERROR_INVALID_PARAMETER`, `ERROR_INVALID_NAME` and
|
|
366
|
+
// `ERROR_CANT_RESOLVE_FILENAME` are all outside CPython's errmap, and an unmapped
|
|
367
|
+
// Win32 error becomes EINVAL. pathlib's `check_eloop` testing `winerror == 1921`
|
|
368
|
+
// SEPARATELY from `errno == ELOOP` is that fact written down in the reference.
|
|
369
|
+
return 'EINVAL';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* WHICH not-found, in the ORDER Win32 arrives at one. Three answers, one path walk.
|
|
374
|
+
*
|
|
375
|
+
* Win32 resolves the directory components FIRST and only then looks at the final name, so
|
|
376
|
+
* the three outcomes are ordered and not alternatives:
|
|
377
|
+
*
|
|
378
|
+
* a missing DIRECTORY component -> ERROR_PATH_NOT_FOUND (3)
|
|
379
|
+
* a final name Win32 forbids -> ERROR_INVALID_NAME (123)
|
|
380
|
+
* a missing final component -> ERROR_FILE_NOT_FOUND (2)
|
|
381
|
+
*
|
|
382
|
+
* THE ORDER IS MEASURED AND IT WAS THE OTHER WAY ROUND FIRST. `unlink` of
|
|
383
|
+
* `<bed>\two\nlines.md` — parent present, name forbidden — is 123, and `mkdir` of
|
|
384
|
+
* `<bed>\missing\two\nlines.md` — the SAME forbidden name under a missing directory — is 3,
|
|
385
|
+
* not 123. Run 32649940727 established the first, run 32651670551 the second by refuting a
|
|
386
|
+
* validate-the-name-first rule that had got the first one right for the wrong reason.
|
|
387
|
+
*
|
|
388
|
+
* A two-name call walks the SOURCE to completion before it looks at the destination, which
|
|
389
|
+
* is why a missing source beside a missing destination DIRECTORY is 2 and not 3 (measured,
|
|
390
|
+
* run 32646521489, both ways round). A destination that is merely absent is not an error —
|
|
391
|
+
* that is the success case for `os.replace` — so only the source's absence answers.
|
|
392
|
+
*/
|
|
393
|
+
function notFoundWinerror(origin, path, dest, exists) {
|
|
394
|
+
// A directory-listing call uses the NAMED path as a directory, so a missing leaf is
|
|
395
|
+
// already a missing directory component there.
|
|
396
|
+
if (origin === 'scandir') {
|
|
397
|
+
if (path === null || !exists(path))
|
|
398
|
+
return 3;
|
|
399
|
+
return ntNameIsInvalid(path) ? 123 : 2;
|
|
400
|
+
}
|
|
401
|
+
for (const candidate of dest === null ? [path] : [path, dest]) {
|
|
402
|
+
if (candidate === null)
|
|
403
|
+
continue;
|
|
404
|
+
if (!exists(pyParent(candidate)))
|
|
405
|
+
return 3;
|
|
406
|
+
if (ntNameIsInvalid(candidate))
|
|
407
|
+
return 123;
|
|
408
|
+
// A missing SOURCE ends the walk: `os.replace` never reaches the destination, so a
|
|
409
|
+
// missing destination DIRECTORY behind it is never the error reported. Deleting this
|
|
410
|
+
// line turns the measured 2 into a 3. (Replacing its condition with a bare
|
|
411
|
+
// `!exists(candidate)` is an EQUIVALENT mutation and survives a sweep — the
|
|
412
|
+
// destination arm would answer 2 as well, which is what the fallthrough already says.)
|
|
413
|
+
if (candidate === path && !exists(candidate))
|
|
414
|
+
return 2;
|
|
415
|
+
}
|
|
416
|
+
return 2;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* The C runtime's answer where it is NOT libuv's, for the calls `open()` makes.
|
|
420
|
+
*
|
|
421
|
+
* One entry, and it is measured: opening a directory for writing is `EISDIR` to libuv and
|
|
422
|
+
* `EACCES` to the Windows CRT, so CPython prints `[Errno 13] Permission denied` for the
|
|
423
|
+
* `.tmp` that shiftwork's clock-out could not write. Everything else the CRT can raise here
|
|
424
|
+
* — ENOENT, EACCES, EEXIST, EINVAL — already agrees with libuv in both number and wording.
|
|
425
|
+
*/
|
|
426
|
+
export function crtCode(code, path = null) {
|
|
427
|
+
if (code === 'EISDIR')
|
|
428
|
+
return 'EACCES';
|
|
429
|
+
// The same name rule, through the other door: `open()` on a name Win32 rejects is
|
|
430
|
+
// `[Errno 22] Invalid argument`, not a not-found. MEASURED, run 32649940727, in
|
|
431
|
+
// `store/every validation refusal` — a save whose name reaches the file path.
|
|
432
|
+
if (code === 'ENOENT' && ntNameIsInvalid(path))
|
|
433
|
+
return 'EINVAL';
|
|
434
|
+
return code;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Does any component of this path hold a character Win32 forbids in a name?
|
|
438
|
+
*
|
|
439
|
+
* `< > : " | ? *` and every control character below U+0020. The separators are excluded
|
|
440
|
+
* because they SPLIT the name rather than sitting in one, and the drive's colon is excluded
|
|
441
|
+
* for the same reason — it is part of the anchor, not of a component. Windows also strips a
|
|
442
|
+
* trailing space or dot silently rather than refusing, so neither is treated as invalid.
|
|
443
|
+
*/
|
|
444
|
+
export function ntNameIsInvalid(path) {
|
|
445
|
+
if (path === null)
|
|
446
|
+
return false;
|
|
447
|
+
const { tail } = parseWindowsPath(path);
|
|
448
|
+
return tail.some((piece) => /[<>:"|?*\u0000-\u001f]/.test(piece));
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* A Node `fs` rejection, restated as the `OSError` CPython would have raised.
|
|
452
|
+
*
|
|
453
|
+
* An errno outside the table keeps libuv's own lowercase wording rather than a fabricated
|
|
454
|
+
* one — a wrong capital letter in a sentence a model reads is worse than a visibly
|
|
455
|
+
* different one, and the table is the thing the conformance suite can check.
|
|
456
|
+
*
|
|
457
|
+
* `origin` says which of CPython's two error paths the call would have taken on Windows;
|
|
458
|
+
* off Windows it changes nothing, because there is only one path there.
|
|
459
|
+
*/
|
|
460
|
+
export function asPyOSError(error, fallbackPath, fallbackDest, origin = 'win32') {
|
|
461
|
+
const e = error;
|
|
462
|
+
const windows = process.platform === 'win32';
|
|
463
|
+
const rawPath = e?.path ?? fallbackPath ?? null;
|
|
464
|
+
const code = windows && origin === 'crt' ? crtCode(e?.code ?? 'EUNKNOWN', rawPath) : (e?.code ?? 'EUNKNOWN');
|
|
465
|
+
const errno = pyErrno({ code, errno: e?.errno });
|
|
466
|
+
const filename = rawPath;
|
|
467
|
+
const filename2 = e?.dest ?? fallbackDest ?? null;
|
|
468
|
+
const winerror = windows ? winerrorFor(code, origin, filename, filename2, pyLexists) : null;
|
|
469
|
+
// Once a winerror is known it is the SOURCE of both the number and the class, because it
|
|
470
|
+
// is CPython's source for them: `PyErr_SetExcFromWindowsErr` translates the Win32 code and
|
|
471
|
+
// `OSError.__new__` picks the subclass off the result.
|
|
472
|
+
const finalCode = winerror === null ? code : winerrorToCode(winerror);
|
|
473
|
+
const finalErrno = winerror === null ? errno : pyErrno({ code: finalCode, errno: e?.errno });
|
|
474
|
+
const strerror = (winerror === null ? pyStrerror(finalCode) : WINERROR[winerror]) ??
|
|
475
|
+
pyStrerror(finalCode) ??
|
|
476
|
+
`${finalCode}: ${e?.message ?? 'unknown error'}`;
|
|
477
|
+
return new PyOSError(finalErrno, finalCode, strerror, filename, filename2, winerror);
|
|
478
|
+
}
|
|
479
|
+
// -------------------------------------------------------------------------- utf-8 text
|
|
480
|
+
/** `UnicodeDecodeError`, which is a `ValueError` in Python and is NOT an `OSError`. */
|
|
481
|
+
export class PyUnicodeDecodeError extends Error {
|
|
482
|
+
start;
|
|
483
|
+
end;
|
|
484
|
+
reason;
|
|
485
|
+
constructor(bytes, start, end, reason) {
|
|
486
|
+
const where = end - start === 1
|
|
487
|
+
? `byte 0x${bytes[start].toString(16).padStart(2, '0')} in position ${start}`
|
|
488
|
+
: `bytes in position ${start}-${end - 1}`;
|
|
489
|
+
super(`'utf-8' codec can't decode ${where}: ${reason}`);
|
|
490
|
+
this.name = 'UnicodeDecodeError';
|
|
491
|
+
this.start = start;
|
|
492
|
+
this.end = end;
|
|
493
|
+
this.reason = reason;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** How many continuation bytes follow a lead byte, and what the second one may be. */
|
|
497
|
+
function leadInfo(b) {
|
|
498
|
+
if (b < 0x80)
|
|
499
|
+
return { length: 1, lo: 0, hi: 0 };
|
|
500
|
+
if (b >= 0xc2 && b <= 0xdf)
|
|
501
|
+
return { length: 2, lo: 0x80, hi: 0xbf };
|
|
502
|
+
if (b === 0xe0)
|
|
503
|
+
return { length: 3, lo: 0xa0, hi: 0xbf };
|
|
504
|
+
if (b >= 0xe1 && b <= 0xec)
|
|
505
|
+
return { length: 3, lo: 0x80, hi: 0xbf };
|
|
506
|
+
if (b === 0xed)
|
|
507
|
+
return { length: 3, lo: 0x80, hi: 0x9f }; // no surrogates
|
|
508
|
+
if (b >= 0xee && b <= 0xef)
|
|
509
|
+
return { length: 3, lo: 0x80, hi: 0xbf };
|
|
510
|
+
if (b === 0xf0)
|
|
511
|
+
return { length: 4, lo: 0x90, hi: 0xbf }; // no overlongs
|
|
512
|
+
if (b >= 0xf1 && b <= 0xf3)
|
|
513
|
+
return { length: 4, lo: 0x80, hi: 0xbf };
|
|
514
|
+
if (b === 0xf4)
|
|
515
|
+
return { length: 4, lo: 0x80, hi: 0x8f }; // no > U+10FFFF
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* `bytes.decode("utf-8")` with `errors="strict"`, including the message.
|
|
520
|
+
*
|
|
521
|
+
* `TextDecoder` without `fatal` substitutes U+FFFD, which would let a corrupt fact file
|
|
522
|
+
* through and have `_stamp` write the substitution back over the original bytes — a
|
|
523
|
+
* silent, destructive divergence rather than a loud one. `fatal: true` raises, but with a
|
|
524
|
+
* `TypeError` whose text says nothing about where. `_facts` reads OUTSIDE its `try`, so
|
|
525
|
+
* this message is what a model sees for a binary file in `facts/`, verbatim.
|
|
526
|
+
*
|
|
527
|
+
* The three reasons and the reported span are CPython's, derived by running its decoder
|
|
528
|
+
* over 220 sequences and matched against all of them (the `utf-8 decode` cases in the
|
|
529
|
+
* conformance suite re-run that comparison).
|
|
530
|
+
*
|
|
531
|
+
* `ignoreBOM: true` IS THE DEFAULT SPELLING BACKWARDS, and it is the point. `TextDecoder`
|
|
532
|
+
* defaults `ignoreBOM` to FALSE, which means "treat a leading EF BB BF as a byte-order mark
|
|
533
|
+
* and DROP it". CPython's utf-8 codec has no such rule — `b'\xef\xbb\xbf'.decode('utf-8')`
|
|
534
|
+
* is `'\ufeff'`, one character, and `utf-8-sig` is the codec that strips it. The validator
|
|
535
|
+
* above already accepted EF BB BF as an ordinary three-byte sequence, so this module was
|
|
536
|
+
* checking one thing and returning another: N8 measured a BOM'd checkpoint that CPython
|
|
537
|
+
* REFUSES (`json.loads` answers `Unexpected UTF-8 BOM (decode using utf-8-sig)`) and that
|
|
538
|
+
* this port parsed and then WROTE. Every text read in the package now comes through here,
|
|
539
|
+
* `assets.ts` included, so there is one UTF-8 decode and not two with opposite answers.
|
|
540
|
+
*/
|
|
541
|
+
export function pyDecodeUtf8(bytes) {
|
|
542
|
+
let i = 0;
|
|
543
|
+
while (i < bytes.length) {
|
|
544
|
+
const b = bytes[i];
|
|
545
|
+
const info = leadInfo(b);
|
|
546
|
+
if (info === null)
|
|
547
|
+
throw new PyUnicodeDecodeError(bytes, i, i + 1, 'invalid start byte');
|
|
548
|
+
if (info.length === 1) {
|
|
549
|
+
i += 1;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
for (let j = 1; j < info.length; j += 1) {
|
|
553
|
+
const at = i + j;
|
|
554
|
+
if (at >= bytes.length) {
|
|
555
|
+
throw new PyUnicodeDecodeError(bytes, i, i + j, 'unexpected end of data');
|
|
556
|
+
}
|
|
557
|
+
const c = bytes[at];
|
|
558
|
+
const lo = j === 1 ? info.lo : 0x80;
|
|
559
|
+
const hi = j === 1 ? info.hi : 0xbf;
|
|
560
|
+
if (c < lo || c > hi) {
|
|
561
|
+
throw new PyUnicodeDecodeError(bytes, i, i + j, 'invalid continuation byte');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
i += info.length;
|
|
565
|
+
}
|
|
566
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* `Path.read_text(encoding="utf-8")`: strict UTF-8 AND universal-newline translation.
|
|
570
|
+
*
|
|
571
|
+
* The translation half is N2's `decodeFactBytes`, re-derived nowhere: this reads the bytes,
|
|
572
|
+
* decodes them strictly, and hands the string to the same fold. A CRLF fact file therefore
|
|
573
|
+
* parses in both runtimes, which is the property the codec suite already measures.
|
|
574
|
+
*/
|
|
575
|
+
export function pyReadText(path) {
|
|
576
|
+
let raw;
|
|
577
|
+
try {
|
|
578
|
+
raw = readFileSync(path);
|
|
579
|
+
}
|
|
580
|
+
catch (e) {
|
|
581
|
+
throw asPyOSError(e, path, undefined, 'crt');
|
|
582
|
+
}
|
|
583
|
+
return pyDecodeUtf8(raw).replace(/\r\n?/g, '\n');
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* `io.TextIOWrapper`'s outgoing newline translation, which is `newline=None`'s whole effect.
|
|
587
|
+
*
|
|
588
|
+
* THIS REVERSES N2's RULING, and the reason it was reversed is worth more than the ruling
|
|
589
|
+
* was. N2 wrote LF on every platform, arguing that CRLF "already disagrees with the store's
|
|
590
|
+
* own arithmetic" and that reproducing it "would make the same Fact emit different bytes on
|
|
591
|
+
* two machines". Both halves describe the REFERENCE, not a defect being avoided: CPython's
|
|
592
|
+
* `_check_index_budget` counts the LF string it just built while `write_text` puts CRLF on
|
|
593
|
+
* the disk, and CPython already emits different bytes for the same Fact on Windows and on
|
|
594
|
+
* macOS. A port that "fixes" that is not byte-compatible with the thing it stands beside —
|
|
595
|
+
* and byte-compatibility is the product, because the Python server and this one read and
|
|
596
|
+
* write the SAME store on the SAME machine. MEASURED, run 32646521489: 83 conformance cases
|
|
597
|
+
* differed by exactly this, one byte per line, in files both runtimes had just written.
|
|
598
|
+
*
|
|
599
|
+
* The arithmetic does not move. Python counts the in-memory LF text and so does this port;
|
|
600
|
+
* only the bytes leaving through `write` are translated, which is where CPython does it too.
|
|
601
|
+
*
|
|
602
|
+
* The translation is unconditional on `\n`, INCLUDING the `\n` of a `\r\n`: CPython emits
|
|
603
|
+
* `\r\r\n` for a string that already held a CRLF, and so does this.
|
|
604
|
+
*/
|
|
605
|
+
export function toCrlf(text) {
|
|
606
|
+
return text.replace(/\n/g, '\r\n');
|
|
607
|
+
}
|
|
608
|
+
/** The same translation, gated the way CPython gates it: `#ifdef MS_WINDOWS`. */
|
|
609
|
+
export function pyNewlineOut(text) {
|
|
610
|
+
return process.platform === 'win32' ? toCrlf(text) : text;
|
|
611
|
+
}
|
|
612
|
+
/** `os.linesep` as one string — the line terminator a text-mode write actually emits. */
|
|
613
|
+
export const PY_LINESEP = process.platform === 'win32' ? '\r\n' : '\n';
|
|
614
|
+
/** `Path.write_text(text, encoding="utf-8")`, newline translation included. */
|
|
615
|
+
export function pyWriteText(path, text) {
|
|
616
|
+
try {
|
|
617
|
+
writeFileSync(path, pyNewlineOut(text), 'utf8');
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
throw asPyOSError(e, path, undefined, 'crt');
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* `path.open("a", encoding="utf-8").write(text)` — the accounting log's append.
|
|
625
|
+
*
|
|
626
|
+
* Same newline translation as `pyWriteText`, for the same reason. One `appendFileSync` is
|
|
627
|
+
* one `open(O_APPEND)/write/close`, which is what CPython's `with` block does, so a line is
|
|
628
|
+
* never interleaved with another process's.
|
|
629
|
+
*/
|
|
630
|
+
export function pyAppendText(path, text) {
|
|
631
|
+
try {
|
|
632
|
+
appendFileSync(path, pyNewlineOut(text), 'utf8');
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
throw asPyOSError(e, path, undefined, 'crt');
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// ------------------------------------------------------------------------- path probes
|
|
639
|
+
/**
|
|
640
|
+
* `os.path.lexists(path)` — `lstat` without following, and FALSE for every error.
|
|
641
|
+
*
|
|
642
|
+
* Not `lstatSync(p, {throwIfNoEntry: false})`: that returns `undefined` for ENOENT but
|
|
643
|
+
* still throws for EACCES on the parent, and `lexists` catches `(OSError, ValueError)`
|
|
644
|
+
* wholesale. This is the middle arm of the `_listing` three-way, so getting the error
|
|
645
|
+
* behaviour wrong turns an unreadable store back into an empty one.
|
|
646
|
+
*/
|
|
647
|
+
export function pyLexists(path) {
|
|
648
|
+
try {
|
|
649
|
+
lstatSync(path);
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* `Path.exists()` — swallows exactly `pathlib._IGNORED_ERRNOS` and re-raises the rest.
|
|
658
|
+
*
|
|
659
|
+
* ENOENT, ENOTDIR, EBADF and ELOOP really do mean "nothing is there"; EACCES means "I was
|
|
660
|
+
* not allowed to look", and `save`'s `path.exists()` before `_write_fact` must raise on it
|
|
661
|
+
* rather than answer `False` and overwrite. `existsSync` answers `false` for all of them.
|
|
662
|
+
*/
|
|
663
|
+
const IGNORED = new Set(['ENOENT', 'ENOTDIR', 'EBADF', 'ELOOP']);
|
|
664
|
+
export function pyExists(path) {
|
|
665
|
+
try {
|
|
666
|
+
statSync(path);
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
catch (e) {
|
|
670
|
+
if (IGNORED.has(e?.code ?? ''))
|
|
671
|
+
return false;
|
|
672
|
+
throw asPyOSError(e, path);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// ------------------------------------------------------------------------- directories
|
|
676
|
+
/**
|
|
677
|
+
* `Path.mkdir(parents=True, exist_ok=True)`.
|
|
678
|
+
*
|
|
679
|
+
* `exist_ok` suppresses `FileExistsError` only when the path is a DIRECTORY: CPython
|
|
680
|
+
* re-raises when a regular file sits there, which is exactly how `save` fails on a store
|
|
681
|
+
* whose `facts/` is a file — before the listing, with a bare `FileExistsError`
|
|
682
|
+
* (`test_a_facts_path_that_is_not_a_directory_is_unreadable_not_empty` says so). Node's
|
|
683
|
+
* recursive `mkdirSync` raises EEXIST there too; this only restates the error.
|
|
684
|
+
*/
|
|
685
|
+
export function pyMkdirParents(path) {
|
|
686
|
+
try {
|
|
687
|
+
mkdirSync(path, { recursive: true });
|
|
688
|
+
}
|
|
689
|
+
catch (e) {
|
|
690
|
+
throw asPyOSError(e, path);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* `os.scandir(directory)` reduced to names, raising the way CPython does.
|
|
695
|
+
*
|
|
696
|
+
* The caller decides what a failure MEANS — that decision is `store._listing`'s and
|
|
697
|
+
* `layers.count_facts` makes a different one from the same syscall.
|
|
698
|
+
*/
|
|
699
|
+
export function pyScandirNames(directory) {
|
|
700
|
+
try {
|
|
701
|
+
return readdirSync(directory);
|
|
702
|
+
}
|
|
703
|
+
catch (e) {
|
|
704
|
+
throw asPyOSError(scandirShape(e, directory), directory, undefined, 'scandir');
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The one place libuv and CPython make DIFFERENT SYSCALLS, not just different sentences.
|
|
709
|
+
*
|
|
710
|
+
* `os.scandir(p)` on Windows is `FindFirstFileW(p + "\\*")`, so `p` is a DIRECTORY COMPONENT
|
|
711
|
+
* of the name being opened; a dangling FILE symlink there is `ERROR_DIRECTORY` (267) and
|
|
712
|
+
* CPython raises `NotADirectoryError`. libuv opens a handle on `p` itself instead, follows
|
|
713
|
+
* the reparse point, finds nothing and reports `ENOENT`. MEASURED, run 32646521489: for the
|
|
714
|
+
* same dangling `facts` symlink the reference says "The directory name is invalid" and this
|
|
715
|
+
* port said "No such file or directory (a path exists there)", and `count_facts` answered
|
|
716
|
+
* `0` where the reference raised. That is not a wording difference — an answer of 0 sends a
|
|
717
|
+
* caller down the success branch — so the SHAPE is restated here and the sentence follows.
|
|
718
|
+
*
|
|
719
|
+
* The probe is `lexists`, not `exists`: the whole point is a name that IS there while what
|
|
720
|
+
* it names is not. `reference-windows-dangling-symlink-two-shapes` is the same finding from
|
|
721
|
+
* the other side, and this is the second shape it predicted.
|
|
722
|
+
*/
|
|
723
|
+
function scandirShape(error, directory) {
|
|
724
|
+
const e = error;
|
|
725
|
+
if (process.platform !== 'win32')
|
|
726
|
+
return error;
|
|
727
|
+
if (!scandirIsNotADirectory(e?.code ?? '', () => pyLexists(directory)))
|
|
728
|
+
return error;
|
|
729
|
+
return { ...e, code: 'ENOTDIR', errno: undefined };
|
|
730
|
+
}
|
|
731
|
+
/** The decision inside `scandirShape`, without the platform gate, so a laptop can fail it. */
|
|
732
|
+
export function scandirIsNotADirectory(code, lexists) {
|
|
733
|
+
return code === 'ENOENT' && lexists();
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* `fnmatch.fnmatch(name, "*.md")`, which is the match `pathlib.glob` performs.
|
|
737
|
+
*
|
|
738
|
+
* Two things it is not: it is not "ends with .md" on Windows, where `os.path.normcase`
|
|
739
|
+
* lowercases first and `FACT.MD` is therefore a fact; and it has no dotfile or
|
|
740
|
+
* directory-entry special case, so `.hidden.md` counts and a DIRECTORY called `x.md`
|
|
741
|
+
* counts. `_listing`'s docstring makes not changing the counted set an explicit
|
|
742
|
+
* requirement, so this deliberately keeps every oddity.
|
|
743
|
+
*/
|
|
744
|
+
export function matchesMd(name) {
|
|
745
|
+
return normcase(name).endsWith('.md');
|
|
746
|
+
}
|
|
747
|
+
/** `os.path.normcase` — identity off Windows, `s.replace('/', '\\').lower()` on it. */
|
|
748
|
+
export function normcase(name) {
|
|
749
|
+
return process.platform === 'win32' ? name.replace(/\//g, '\\').toLowerCase() : name;
|
|
750
|
+
}
|
|
751
|
+
// -------------------------------------------------------------------------- comparison
|
|
752
|
+
/**
|
|
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.
|
|
760
|
+
*/
|
|
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
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* `sorted(directory / name for name in names)` — sorting `Path`s, not strings.
|
|
775
|
+
*
|
|
776
|
+
* `PurePath` compares `_parts_normcase` tuples. Every path here shares a parent, so the
|
|
777
|
+
* comparison reduces to the last component; what survives is the CASE FOLD, which
|
|
778
|
+
* `_parts_normcase` applies on Windows and nowhere else. Ties under that fold keep listing
|
|
779
|
+
* order, because both runtimes sort stably.
|
|
780
|
+
*/
|
|
781
|
+
export function sortedPathNames(names) {
|
|
782
|
+
return [...names].sort((a, b) => cmpCodepoint(normcase(a), normcase(b)));
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* `sorted(paths)` when the paths are NESTED — the case `sortedPathNames` above cannot cover.
|
|
786
|
+
*
|
|
787
|
+
* `PurePath.__lt__` compares the `_parts_normcase` TUPLE, and a tuple comparison stops at
|
|
788
|
+
* the first component that differs; it never sees the separator. Sorting the same paths as
|
|
789
|
+
* STRINGS does see it, and `-` (0x2D) sorts before `/` (0x2F), so the two orders are
|
|
790
|
+
* opposite for a pair like `a/b` and `a-b`:
|
|
791
|
+
*
|
|
792
|
+
* sorted([Path("assets/a/b"), Path("assets/a-b")]) -> ['assets/a/b', 'assets/a-b']
|
|
793
|
+
* sorted(["assets/a/b", "assets/a-b"]) -> ['assets/a-b', 'assets/a/b']
|
|
794
|
+
*
|
|
795
|
+
* N3 measured that this does not bite `_fact_paths`, whose paths are all siblings of one
|
|
796
|
+
* directory. It DOES bite `mcpserver._tree_digest`, which walks a nested tree and folds the
|
|
797
|
+
* order straight into `assets_digest` — a digest computed in string order over the shipped
|
|
798
|
+
* pack is a different digest, silently, and `build_identity` exists precisely to be
|
|
799
|
+
* trustworthy about that. A shorter tuple sorts first when it is a prefix of the longer one,
|
|
800
|
+
* which is `[] < ['b']` and needs no special case.
|
|
801
|
+
*/
|
|
802
|
+
export function sortedPathParts(paths) {
|
|
803
|
+
return paths
|
|
804
|
+
.map((p) => [...p])
|
|
805
|
+
.sort((a, b) => {
|
|
806
|
+
const n = Math.min(a.length, b.length);
|
|
807
|
+
for (let i = 0; i < n; i += 1) {
|
|
808
|
+
const order = cmpCodepoint(normcase(a[i]), normcase(b[i]));
|
|
809
|
+
if (order !== 0)
|
|
810
|
+
return order;
|
|
811
|
+
}
|
|
812
|
+
return a.length - b.length;
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
// ----------------------------------------------------------------------- path building
|
|
816
|
+
/**
|
|
817
|
+
* `PurePath.__truediv__` and `str(Path)`, for the shapes a store root can take.
|
|
818
|
+
*
|
|
819
|
+
* Not `path.join`: `join('/a/b/..', 'facts')` collapses to `/a/facts` while
|
|
820
|
+
* `str(Path('/a/b/..') / 'facts')` keeps `/a/b/../facts`. Both open the same file, but the
|
|
821
|
+
* store PRINTS this path in the sentence a model reads for an unreadable store, and a port
|
|
822
|
+
* whose error names a different path than the reference's is a difference nobody put in
|
|
823
|
+
* writing. Empty and `.` components are dropped, `..` is kept, and the anchor survives —
|
|
824
|
+
* which is `PurePath`'s parsing, not a normalization.
|
|
825
|
+
*
|
|
826
|
+
* On Windows the separator is `\` and `/` is also accepted as one; drive-relative paths
|
|
827
|
+
* (`C:foo`) and UNC roots are outside what a store root has ever been and are not modelled.
|
|
828
|
+
*/
|
|
829
|
+
export function pyJoin(...parts) {
|
|
830
|
+
if (process.platform === 'win32')
|
|
831
|
+
return winStr(ntJoin(...parts));
|
|
832
|
+
let anchor = '';
|
|
833
|
+
const out = [];
|
|
834
|
+
for (const part of parts) {
|
|
835
|
+
// A leading separator, or a drive: `parsePath` is that reading, shared with `pyParents`
|
|
836
|
+
// and `pyExpanduser` so the four cannot disagree about what a path is made of.
|
|
837
|
+
const parsed = parsePath(part);
|
|
838
|
+
// An ANCHORED later part replaces everything before it — `PurePath('/a', '/b')` is
|
|
839
|
+
// `/b`. `load_grants` is where it fires: `config.parent / entry` with an absolute
|
|
840
|
+
// `entry` is the entry, and joining them instead builds a path under the config that
|
|
841
|
+
// never existed. The store never passes an absolute second part, so this arm was
|
|
842
|
+
// unreachable until the binding layer arrived.
|
|
843
|
+
if (parsed.anchor !== '') {
|
|
844
|
+
anchor = parsed.anchor;
|
|
845
|
+
out.length = 0;
|
|
846
|
+
}
|
|
847
|
+
out.push(...parsed.parts);
|
|
848
|
+
}
|
|
849
|
+
return renderPath(anchor, out);
|
|
850
|
+
}
|
|
851
|
+
function parsePath(path) {
|
|
852
|
+
if (process.platform === 'win32') {
|
|
853
|
+
const { drive, root, tail } = parseWindowsPath(path);
|
|
854
|
+
return { anchor: drive + root, parts: tail };
|
|
855
|
+
}
|
|
856
|
+
const pieces = path.split('/');
|
|
857
|
+
let anchor = '';
|
|
858
|
+
// `''.split('/')` is `['']`, which is not a leading separator: `str(PurePath(''))` is `.`
|
|
859
|
+
// and has no anchor. Measured against CPython through `Path('').expanduser()`.
|
|
860
|
+
if (path !== '' && pieces[0] === '') {
|
|
861
|
+
let leading = 0;
|
|
862
|
+
while (pieces[leading] === '')
|
|
863
|
+
leading += 1;
|
|
864
|
+
anchor = leading === 2 && pieces.length > 2 ? '//' : '/';
|
|
865
|
+
pieces.splice(0, leading);
|
|
866
|
+
}
|
|
867
|
+
return { anchor, parts: pieces.filter((piece) => piece !== '' && piece !== '.') };
|
|
868
|
+
}
|
|
869
|
+
// --------------------------------------------------------------------- ntpath, on Windows
|
|
870
|
+
/**
|
|
871
|
+
* `ntpath.splitroot` — drive, root, and the rest, ported branch for branch.
|
|
872
|
+
*
|
|
873
|
+
* The old spelling of `parsePath` counted leading separators the way `PurePosixPath` does,
|
|
874
|
+
* and that reading is WRONG ON WINDOWS in the direction that changes an answer: `//a/b` is
|
|
875
|
+
* not "a root and two components", it is a UNC share whose whole text is the drive, with no
|
|
876
|
+
* parents at all and nothing for `_walk_to_store` to iterate. MEASURED against CPython 3.12
|
|
877
|
+
* on run 32646521489 — `PureWindowsPath('//a/b').parents` is `[]` and this port answered
|
|
878
|
+
* `['\\\\a', '\\\\']`, which is a walk over two directories that do not exist.
|
|
879
|
+
*
|
|
880
|
+
* The result is spelled with the CALLER's separators, not normalised ones, exactly as
|
|
881
|
+
* `ntpath.splitroot` returns slices of its argument.
|
|
882
|
+
*/
|
|
883
|
+
export function ntSplitRoot(path) {
|
|
884
|
+
const norm = path.replace(/\//g, '\\');
|
|
885
|
+
if (norm.startsWith('\\')) {
|
|
886
|
+
if (norm.startsWith('\\\\')) {
|
|
887
|
+
// `\\?\UNC\` is eight characters of prefix before the server name starts.
|
|
888
|
+
const start = norm.slice(0, 8).toUpperCase() === '\\\\?\\UNC\\' ? 8 : 2;
|
|
889
|
+
const index = norm.indexOf('\\', start);
|
|
890
|
+
if (index === -1)
|
|
891
|
+
return [path, '', ''];
|
|
892
|
+
const index2 = norm.indexOf('\\', index + 1);
|
|
893
|
+
if (index2 === -1)
|
|
894
|
+
return [path, '', ''];
|
|
895
|
+
return [path.slice(0, index2), path.slice(index2, index2 + 1), path.slice(index2 + 1)];
|
|
896
|
+
}
|
|
897
|
+
return ['', path.slice(0, 1), path.slice(1)];
|
|
898
|
+
}
|
|
899
|
+
if (norm.slice(1, 2) === ':') {
|
|
900
|
+
if (norm.slice(2, 3) === '\\')
|
|
901
|
+
return [path.slice(0, 2), path.slice(2, 3), path.slice(3)];
|
|
902
|
+
return [path.slice(0, 2), '', path.slice(2)];
|
|
903
|
+
}
|
|
904
|
+
return ['', '', path];
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* `PurePath._parse_path` under the Windows flavour: drive, root and the `_tail` components.
|
|
908
|
+
*
|
|
909
|
+
* The `drv_parts` arm is not decoration. `splitroot` hands back a bare `\\server\share` with
|
|
910
|
+
* an EMPTY root, and pathlib puts the root back on when the drive names a real share (four
|
|
911
|
+
* pieces) or a `\\?\UNC\server\share` (six) — which is why `//a/b` prints with a trailing
|
|
912
|
+
* separator and `//a` does not.
|
|
913
|
+
*/
|
|
914
|
+
export function parseWindowsPath(path) {
|
|
915
|
+
if (path === '')
|
|
916
|
+
return { drive: '', root: '', tail: [] };
|
|
917
|
+
const normalised = path.replace(/\//g, '\\');
|
|
918
|
+
const [drive, splitRoot, rest] = ntSplitRoot(normalised);
|
|
919
|
+
let root = splitRoot;
|
|
920
|
+
if (root === '' && drive.startsWith('\\') && !drive.endsWith('\\')) {
|
|
921
|
+
const pieces = drive.split('\\');
|
|
922
|
+
// `drv_parts[2] not in '?.'` is a SUBSTRING test in Python, and the empty string is a
|
|
923
|
+
// substring of everything: `///a` splits to a third piece of `''`, the test is therefore
|
|
924
|
+
// False, and the path keeps its empty root and stays RELATIVE. Spelling it as two
|
|
925
|
+
// character comparisons made `///a` absolute — caught on a laptop by the case below,
|
|
926
|
+
// which is the entire reason that case exists.
|
|
927
|
+
if (pieces.length === 4 && !'?.'.includes(pieces[2]))
|
|
928
|
+
root = '\\';
|
|
929
|
+
else if (pieces.length === 6)
|
|
930
|
+
root = '\\';
|
|
931
|
+
}
|
|
932
|
+
return { drive, root, tail: rest.split('\\').filter((piece) => piece !== '' && piece !== '.') };
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* `ntpath.join` — what `PurePath.__truediv__` actually feeds the parser.
|
|
936
|
+
*
|
|
937
|
+
* `PurePath('//a') / 'facts'` is `\\a\facts\`, not `\\a\facts`, because the join produces
|
|
938
|
+
* `//a\facts` and the RE-PARSE reads all of it as a share. Nothing else reproduces that.
|
|
939
|
+
*/
|
|
940
|
+
export function ntJoin(...parts) {
|
|
941
|
+
const separators = '\\/';
|
|
942
|
+
let [drive, root, tail] = ntSplitRoot(parts[0] ?? '');
|
|
943
|
+
for (const part of parts.slice(1)) {
|
|
944
|
+
const [partDrive, partRoot, partTail] = ntSplitRoot(part);
|
|
945
|
+
if (partRoot !== '') {
|
|
946
|
+
if (partDrive !== '' || drive === '')
|
|
947
|
+
drive = partDrive;
|
|
948
|
+
root = partRoot;
|
|
949
|
+
tail = partTail;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (partDrive !== '' && partDrive !== drive) {
|
|
953
|
+
if (partDrive.toLowerCase() !== drive.toLowerCase()) {
|
|
954
|
+
drive = partDrive;
|
|
955
|
+
root = partRoot;
|
|
956
|
+
tail = partTail;
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
drive = partDrive;
|
|
960
|
+
}
|
|
961
|
+
if (tail !== '' && !separators.includes(tail[tail.length - 1]))
|
|
962
|
+
tail += '\\';
|
|
963
|
+
tail += partTail;
|
|
964
|
+
}
|
|
965
|
+
// A separator has to appear between a bare UNC share and a relative remainder.
|
|
966
|
+
if (tail !== '' && root === '' && drive !== '' && !`:${separators}`.includes(drive[drive.length - 1])) {
|
|
967
|
+
return `${drive}\\${tail}`;
|
|
968
|
+
}
|
|
969
|
+
return drive + root + tail;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* `PurePath._format_parsed_parts` under the Windows flavour — `str(PureWindowsPath)`.
|
|
973
|
+
*
|
|
974
|
+
* Exported, and every Windows arm below goes through it, so the flavour has exactly ONE
|
|
975
|
+
* implementation and the conformance suite can drive it against `PureWindowsPath` from a
|
|
976
|
+
* laptop instead of from a runner.
|
|
977
|
+
*/
|
|
978
|
+
export function winFormat(drive, root, tail) {
|
|
979
|
+
if (drive !== '' || root !== '')
|
|
980
|
+
return drive + root + tail.join('\\');
|
|
981
|
+
// An anchorless path whose FIRST component would itself parse as a drive gets a `.` in
|
|
982
|
+
// front, so `PureWindowsPath('a', 'C:x')` does not print as a drive-relative path.
|
|
983
|
+
if (tail.length > 0 && ntSplitRoot(tail[0])[0] !== '')
|
|
984
|
+
return ['.', ...tail].join('\\');
|
|
985
|
+
return tail.join('\\') || '.';
|
|
986
|
+
}
|
|
987
|
+
/** `str(PureWindowsPath(path))`. */
|
|
988
|
+
export function winStr(path) {
|
|
989
|
+
const { drive, root, tail } = parseWindowsPath(path);
|
|
990
|
+
return winFormat(drive, root, tail);
|
|
991
|
+
}
|
|
992
|
+
/** `PureWindowsPath.parents`. `//a/b` is a share and has NONE. */
|
|
993
|
+
export function winParents(path) {
|
|
994
|
+
const { drive, root, tail } = parseWindowsPath(path);
|
|
995
|
+
const out = [];
|
|
996
|
+
for (let n = tail.length - 1; n >= 0; n -= 1)
|
|
997
|
+
out.push(winFormat(drive, root, tail.slice(0, n)));
|
|
998
|
+
return out;
|
|
999
|
+
}
|
|
1000
|
+
/** `PureWindowsPath.name`. */
|
|
1001
|
+
export function winName(path) {
|
|
1002
|
+
const { tail } = parseWindowsPath(path);
|
|
1003
|
+
return tail.length === 0 ? '' : tail[tail.length - 1];
|
|
1004
|
+
}
|
|
1005
|
+
/** `PureWindowsPath.is_absolute()` — `bool(drive and root)`, so a bare `//a` is NOT one. */
|
|
1006
|
+
export function winIsAbsolute(path) {
|
|
1007
|
+
const { drive, root } = parseWindowsPath(path);
|
|
1008
|
+
return drive !== '' && root !== '';
|
|
1009
|
+
}
|
|
1010
|
+
/** `PureWindowsPath.with_suffix`, which rebuilds the path in the flavour's own spelling. */
|
|
1011
|
+
export function winWithSuffix(path, suffix) {
|
|
1012
|
+
const { drive, root, tail } = parseWindowsPath(path);
|
|
1013
|
+
if (tail.length === 0)
|
|
1014
|
+
return winFormat(drive, root, tail);
|
|
1015
|
+
const name = tail[tail.length - 1];
|
|
1016
|
+
const dot = suffixDot(name);
|
|
1017
|
+
const stem = dot === -1 ? name : name.slice(0, dot);
|
|
1018
|
+
return winFormat(drive, root, [...tail.slice(0, -1), `${stem}${suffix}`]);
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* `str(PurePosixPath)` for an already-parsed path: the empty path prints as `.`.
|
|
1022
|
+
*
|
|
1023
|
+
* POSIX ONLY. Every Windows arm renders through `winFormat`; this one had a
|
|
1024
|
+
* `process.platform` separator once and it hid the fact that `pyExpanduser` was calling it
|
|
1025
|
+
* on both platforms with a Windows anchor and a POSIX join.
|
|
1026
|
+
*/
|
|
1027
|
+
function renderPath(anchor, parts) {
|
|
1028
|
+
if (parts.length === 0)
|
|
1029
|
+
return anchor === '' ? '.' : anchor;
|
|
1030
|
+
return anchor + parts.join('/');
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* `PurePath.parents` — `_walk_to_store` iterates `(base, *base.parents)` and stops at `/`.
|
|
1034
|
+
*
|
|
1035
|
+
* The tail is not the anchor: `/a/b` yields `/a` then `/` and stops, while `a/b` yields `a`
|
|
1036
|
+
* then `.` — a relative start walks to the CWD-relative dot and no further, which is why
|
|
1037
|
+
* `_resolved_base` resolves before walking. `/` and `.` have no parents at all.
|
|
1038
|
+
*/
|
|
1039
|
+
export function pyParents(path) {
|
|
1040
|
+
if (process.platform === 'win32')
|
|
1041
|
+
return winParents(path);
|
|
1042
|
+
const { anchor, parts } = parsePath(path);
|
|
1043
|
+
const out = [];
|
|
1044
|
+
for (let n = parts.length - 1; n >= 0; n -= 1)
|
|
1045
|
+
out.push(renderPath(anchor, parts.slice(0, n)));
|
|
1046
|
+
return out;
|
|
1047
|
+
}
|
|
1048
|
+
/** `PurePath.parent`. `/`'s parent is `/` and `.`'s is `.`, as in Python. */
|
|
1049
|
+
export function pyParent(path) {
|
|
1050
|
+
const parents = pyParents(path);
|
|
1051
|
+
if (parents.length > 0)
|
|
1052
|
+
return parents[0];
|
|
1053
|
+
if (process.platform === 'win32') {
|
|
1054
|
+
const { drive, root } = parseWindowsPath(path);
|
|
1055
|
+
return winFormat(drive, root, []);
|
|
1056
|
+
}
|
|
1057
|
+
return renderPath(parsePath(path).anchor, []);
|
|
1058
|
+
}
|
|
1059
|
+
/** `PurePath.name` — the last component, or `''` for a bare anchor. */
|
|
1060
|
+
export function pyName(path) {
|
|
1061
|
+
if (process.platform === 'win32')
|
|
1062
|
+
return winName(path);
|
|
1063
|
+
const { parts } = parsePath(path);
|
|
1064
|
+
return parts.length === 0 ? '' : parts[parts.length - 1];
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* `PurePath.is_absolute()`.
|
|
1068
|
+
*
|
|
1069
|
+
* On Windows a root without a drive is NOT absolute (`PureWindowsPath('/a')` is relative to
|
|
1070
|
+
* the current drive), which is why this is not "starts with a separator". `_pinned_store`
|
|
1071
|
+
* raises on a relative pin, so getting this wrong there would accept a pin the reference
|
|
1072
|
+
* refuses, or refuse one it accepts.
|
|
1073
|
+
*/
|
|
1074
|
+
export function pyIsAbsolute(path) {
|
|
1075
|
+
if (process.platform !== 'win32')
|
|
1076
|
+
return parsePath(path).anchor !== '';
|
|
1077
|
+
return winIsAbsolute(path);
|
|
1078
|
+
}
|
|
1079
|
+
// -------------------------------------------------------------------------- home and ~
|
|
1080
|
+
/**
|
|
1081
|
+
* `RuntimeError`, which `Path.expanduser()` and `Path.resolve()` raise. NOT an `OSError`, so
|
|
1082
|
+
* nothing that catches `OSError` catches it — `_pinned_store`'s `except OSError` included.
|
|
1083
|
+
*/
|
|
1084
|
+
export class PyRuntimeError extends Error {
|
|
1085
|
+
constructor(message) {
|
|
1086
|
+
super(message);
|
|
1087
|
+
this.name = 'RuntimeError';
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* `Path.home()`, which is `os.path.expanduser('~')`.
|
|
1092
|
+
*
|
|
1093
|
+
* `$HOME` FIRST, and only then the passwd database — verified by overriding it. That order
|
|
1094
|
+
* is what lets the tests and the conformance harness point the profile layer somewhere that
|
|
1095
|
+
* is not the operator's own facts; a port that read the uid's home directly would recall
|
|
1096
|
+
* against, and STAMP, the real store on every run.
|
|
1097
|
+
*
|
|
1098
|
+
* An empty `HOME` is still a set `HOME` in Python (the test is `'HOME' not in os.environ`),
|
|
1099
|
+
* and `''.rstrip('/') or '/'` makes it `/`. `os.homedir()` falls back to the passwd entry
|
|
1100
|
+
* there instead, so the rule is spelled out rather than delegated.
|
|
1101
|
+
*
|
|
1102
|
+
* On Windows Python reads `USERPROFILE`, then `HOMEDRIVE`+`HOMEPATH`; `os.homedir()` reads
|
|
1103
|
+
* `USERPROFILE` and then the API. The first arm is the same, and it is the one a host sets.
|
|
1104
|
+
*/
|
|
1105
|
+
export function pyHome() {
|
|
1106
|
+
if (process.platform === 'win32') {
|
|
1107
|
+
const profile = process.env['USERPROFILE'];
|
|
1108
|
+
const drive = process.env['HOMEDRIVE'];
|
|
1109
|
+
const tail = process.env['HOMEPATH'];
|
|
1110
|
+
const raw = profile ?? (drive !== undefined && tail !== undefined ? drive + tail : homedir());
|
|
1111
|
+
return raw.replace(/[\\/]+$/, '') || '\\';
|
|
1112
|
+
}
|
|
1113
|
+
const home = process.env['HOME'];
|
|
1114
|
+
const raw = home !== undefined ? home : homedir();
|
|
1115
|
+
return raw.replace(/\/+$/, '') || '/';
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* `Path(raw).expanduser()`.
|
|
1119
|
+
*
|
|
1120
|
+
* Only a FIRST component beginning with `~` expands, and only when the path has no anchor;
|
|
1121
|
+
* `/~/x` and `a/~` are left alone. An MCP host passes `env` verbatim with no shell, so the
|
|
1122
|
+
* tilde arrives literal and this is the only thing that expands it — see `_pinned_store`.
|
|
1123
|
+
*
|
|
1124
|
+
* RULING — `~someone-else`. Python asks the passwd database and resolves any account on the
|
|
1125
|
+
* machine (measured: `~root` -> `/var/root`). Node has no `getpwnam`, so this resolves `~`
|
|
1126
|
+
* and `~<the current user>` and raises Python's own `RuntimeError` for anything else, where
|
|
1127
|
+
* Python would have answered a path. Reproducing it means shelling out to `dscl`/`getent` at
|
|
1128
|
+
* runtime — a subprocess, in a pure-Node package, for a spelling of the pin nothing has ever
|
|
1129
|
+
* used. Carried as a must-differ case in `tools/conformance/suites/recall-strings.mjs`, so
|
|
1130
|
+
* it is re-measured on every run rather than remembered.
|
|
1131
|
+
*/
|
|
1132
|
+
export function pyExpanduser(path) {
|
|
1133
|
+
const { anchor, parts } = parsePath(path);
|
|
1134
|
+
const first = parts[0];
|
|
1135
|
+
if (anchor !== '' || first === undefined || !first.startsWith('~')) {
|
|
1136
|
+
// `str(PurePath(raw))` in the FLAVOUR, which is what `pyJoin` of a single path is.
|
|
1137
|
+
// `renderPath` is the POSIX renderer and joining a Windows tail with it produced
|
|
1138
|
+
// `C:\Users/runneradmin/...` — one backslash from the anchor and forward slashes for
|
|
1139
|
+
// everything after it. MEASURED on windows-latest, run 32649940727: three conformance
|
|
1140
|
+
// cases and two test nodes, all naming a path in that mixed spelling.
|
|
1141
|
+
return pyJoin(path);
|
|
1142
|
+
}
|
|
1143
|
+
let home = null;
|
|
1144
|
+
const user = first.slice(1);
|
|
1145
|
+
if (user === '')
|
|
1146
|
+
home = pyHome();
|
|
1147
|
+
else {
|
|
1148
|
+
try {
|
|
1149
|
+
const info = userInfo();
|
|
1150
|
+
if (info.username === user)
|
|
1151
|
+
home = info.homedir.replace(/\/+$/, '') || '/';
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
home = null; // no passwd entry at all; Python raises here too
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (home === null)
|
|
1158
|
+
throw new PyRuntimeError('Could not determine home directory.');
|
|
1159
|
+
return pyJoin(home, ...parts.slice(1));
|
|
1160
|
+
}
|
|
1161
|
+
/** `Path.cwd()`. */
|
|
1162
|
+
export function pyCwd() {
|
|
1163
|
+
return process.cwd();
|
|
1164
|
+
}
|
|
1165
|
+
// --------------------------------------------------------------------------- resolution
|
|
1166
|
+
/**
|
|
1167
|
+
* `Path.resolve()` with `strict=False`: `posixpath.realpath`, then pathlib's ELOOP check.
|
|
1168
|
+
*
|
|
1169
|
+
* WHY NOT `fs.realpathSync`: it is strict. `_resolved_base` resolves a START DIRECTORY that
|
|
1170
|
+
* may not exist, and `load_grants` resolves a granted path precisely in order to then ask
|
|
1171
|
+
* whether it exists. Python resolves as far as the filesystem goes and appends the rest
|
|
1172
|
+
* verbatim — measured, `<bed>/nope/x` resolves to itself, and so does `<0o000-dir>/x/y`.
|
|
1173
|
+
*
|
|
1174
|
+
* `..` is applied to the path resolved SO FAR, not lexically to the input, so through a
|
|
1175
|
+
* symlinked directory it lands in the target's parent. That is what makes a symlinked
|
|
1176
|
+
* worktree bind the store beside its real checkout.
|
|
1177
|
+
*
|
|
1178
|
+
* A symlink LOOP is the one failure that is not swallowed: non-strict `realpath` returns the
|
|
1179
|
+
* unresolved path, `Path.resolve` then stats it, gets ELOOP, and raises
|
|
1180
|
+
* `RuntimeError("Symlink loop from %r" % e.filename)`. The `%r` is `repr`, not a pair of
|
|
1181
|
+
* apostrophes — the second spelling of the `OSError.__str__` bug N8 found, and fixed the
|
|
1182
|
+
* same way. Measured against CPython here.
|
|
1183
|
+
*
|
|
1184
|
+
* WINDOWS IS AN APPROXIMATION AND SAYS SO. `ntpath.realpath` goes through
|
|
1185
|
+
* `GetFinalPathNameByHandle` and returns forms this cannot reproduce; there this resolves
|
|
1186
|
+
* what it can natively and falls back to the lexical form. NOT MEASURED — no Windows runner
|
|
1187
|
+
* has run this port.
|
|
1188
|
+
*/
|
|
1189
|
+
export function pyResolve(path) {
|
|
1190
|
+
if (process.platform === 'win32')
|
|
1191
|
+
return resolveWindows(path);
|
|
1192
|
+
const [resolved] = joinRealpath('', pyJoin(path), new Map());
|
|
1193
|
+
const absolute = posixAbspath(resolved);
|
|
1194
|
+
try {
|
|
1195
|
+
statSync(absolute);
|
|
1196
|
+
}
|
|
1197
|
+
catch (e) {
|
|
1198
|
+
// pathlib's `check_eloop`: only a symlink loop is promoted; every other error is ignored.
|
|
1199
|
+
if (e?.code === 'ELOOP') {
|
|
1200
|
+
throw new PyRuntimeError(`Symlink loop from ${pyRepr(e.path ?? absolute)}`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return absolute;
|
|
1204
|
+
}
|
|
1205
|
+
/** `posixpath._joinrealpath`, ported statement for statement including the `seen` protocol. */
|
|
1206
|
+
function joinRealpath(path, rest, seen) {
|
|
1207
|
+
let current = path;
|
|
1208
|
+
let remaining = rest;
|
|
1209
|
+
if (remaining.startsWith('/')) {
|
|
1210
|
+
remaining = remaining.slice(1);
|
|
1211
|
+
current = '/';
|
|
1212
|
+
}
|
|
1213
|
+
while (remaining !== '') {
|
|
1214
|
+
const at = remaining.indexOf('/');
|
|
1215
|
+
const name = at === -1 ? remaining : remaining.slice(0, at);
|
|
1216
|
+
remaining = at === -1 ? '' : remaining.slice(at + 1);
|
|
1217
|
+
if (name === '' || name === '.')
|
|
1218
|
+
continue;
|
|
1219
|
+
if (name === '..') {
|
|
1220
|
+
if (current !== '') {
|
|
1221
|
+
const [head, tail] = posixSplit(current);
|
|
1222
|
+
current = head;
|
|
1223
|
+
if (tail === '..')
|
|
1224
|
+
current = posixJoin(posixJoin(current, '..'), '..');
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
current = '..';
|
|
1228
|
+
}
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
const newpath = posixJoin(current, name);
|
|
1232
|
+
let isLink = false;
|
|
1233
|
+
try {
|
|
1234
|
+
isLink = lstatSync(newpath).isSymbolicLink();
|
|
1235
|
+
}
|
|
1236
|
+
catch {
|
|
1237
|
+
isLink = false; // non-strict: every OSError means "not a link", and the walk goes on
|
|
1238
|
+
}
|
|
1239
|
+
if (!isLink) {
|
|
1240
|
+
current = newpath;
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
if (seen.has(newpath)) {
|
|
1244
|
+
const cached = seen.get(newpath);
|
|
1245
|
+
if (cached !== null) {
|
|
1246
|
+
current = cached;
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
return [posixJoin(newpath, remaining), false]; // a loop; non-strict leaves it alone
|
|
1250
|
+
}
|
|
1251
|
+
seen.set(newpath, null);
|
|
1252
|
+
const [resolved, ok] = joinRealpath(current, readlinkSync(newpath), seen);
|
|
1253
|
+
if (!ok)
|
|
1254
|
+
return [posixJoin(resolved, remaining), false];
|
|
1255
|
+
current = resolved;
|
|
1256
|
+
seen.set(newpath, current);
|
|
1257
|
+
}
|
|
1258
|
+
return [current, true];
|
|
1259
|
+
}
|
|
1260
|
+
/** `posixpath.split`: the head keeps its trailing separator only when it is the root. */
|
|
1261
|
+
function posixSplit(path) {
|
|
1262
|
+
const cut = path.lastIndexOf('/');
|
|
1263
|
+
if (cut === -1)
|
|
1264
|
+
return ['', path];
|
|
1265
|
+
const head = path.slice(0, cut + 1);
|
|
1266
|
+
return [head === '/' ? head : head.replace(/\/+$/, ''), path.slice(cut + 1)];
|
|
1267
|
+
}
|
|
1268
|
+
/** `posixpath.join` for two components. */
|
|
1269
|
+
function posixJoin(a, b) {
|
|
1270
|
+
if (b.startsWith('/'))
|
|
1271
|
+
return b;
|
|
1272
|
+
if (a === '' || a.endsWith('/'))
|
|
1273
|
+
return a + b;
|
|
1274
|
+
return `${a}/${b}`;
|
|
1275
|
+
}
|
|
1276
|
+
/** `posixpath.abspath` = `normpath(join(getcwd(), path))`. Two leading slashes survive. */
|
|
1277
|
+
function posixAbspath(path) {
|
|
1278
|
+
const joined = path.startsWith('/') ? path : posixJoin(process.cwd(), path);
|
|
1279
|
+
const leading = /^\/\/(?!\/)/.test(joined) ? '//' : joined.startsWith('/') ? '/' : '';
|
|
1280
|
+
const out = [];
|
|
1281
|
+
for (const piece of joined.split('/')) {
|
|
1282
|
+
if (piece === '' || piece === '.')
|
|
1283
|
+
continue;
|
|
1284
|
+
if (piece === '..' && leading !== '') {
|
|
1285
|
+
if (out.length > 0 && out[out.length - 1] !== '..')
|
|
1286
|
+
out.pop();
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
if (piece === '..' && out.length > 0 && out[out.length - 1] !== '..') {
|
|
1290
|
+
out.pop();
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
out.push(piece);
|
|
1294
|
+
}
|
|
1295
|
+
return leading + out.join('/') || '.';
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* The Windows arm of `pyResolve` — `ntpath.realpath(strict=False)` and pathlib's ELOOP check.
|
|
1299
|
+
*
|
|
1300
|
+
* SWALLOWING THE FAILURE WAS THE DEFECT. This used to answer `realpathSync.native` or, on any
|
|
1301
|
+
* error at all, the lexical path — which is neither of the two things CPython does. MEASURED
|
|
1302
|
+
* on run 32646521489, both halves: a DANGLING symlink resolves to its TARGET on the reference
|
|
1303
|
+
* (`<bed>\dang` -> `<bed>\nowhere`) and this port answered `<bed>\dang`; and a self-referential
|
|
1304
|
+
* symlink raises `RuntimeError("Symlink loop from %r")` on the reference while this port
|
|
1305
|
+
* raised nothing and returned a path. A missing raise is worse than a wrong sentence: the
|
|
1306
|
+
* caller takes the success branch.
|
|
1307
|
+
*
|
|
1308
|
+
* Both come out of `ntpath._getfinalpathname_nonstrict`, which is ported below: when the OS
|
|
1309
|
+
* cannot resolve a name it FOLLOWS THE LINK ITSELF with `readlink` before giving up, and only
|
|
1310
|
+
* then walks up a component. The loop survives that walk — `_readlink_deep` stops when it
|
|
1311
|
+
* revisits a name — and is caught afterwards by the `stat` that `Path.resolve` runs precisely
|
|
1312
|
+
* so that non-strict resolution still raises on a cycle.
|
|
1313
|
+
*/
|
|
1314
|
+
function resolveWindows(path) {
|
|
1315
|
+
const absolute = ntIsAbs(path) ? path : ntJoin(process.cwd(), path);
|
|
1316
|
+
let resolved;
|
|
1317
|
+
try {
|
|
1318
|
+
resolved = realpathSync.native(absolute);
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
resolved = getFinalPathNameNonStrict(absolute);
|
|
1322
|
+
}
|
|
1323
|
+
resolved = pyJoin(resolved);
|
|
1324
|
+
try {
|
|
1325
|
+
statSync(resolved);
|
|
1326
|
+
}
|
|
1327
|
+
catch (e) {
|
|
1328
|
+
// `check_eloop`: `errno == ELOOP or winerror == 1921`. libuv folds
|
|
1329
|
+
// `ERROR_CANT_RESOLVE_FILENAME` into `UV_ELOOP`, so the one code covers both tests.
|
|
1330
|
+
if (e?.code === 'ELOOP') {
|
|
1331
|
+
throw new PyRuntimeError(`Symlink loop from ${pyRepr(e.path ?? resolved)}`);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return resolved;
|
|
1335
|
+
}
|
|
1336
|
+
/** `ntpath._readlink_deep` — follow the chain by hand, stopping the first time a name repeats. */
|
|
1337
|
+
function readlinkDeep(start) {
|
|
1338
|
+
const seen = new Set();
|
|
1339
|
+
let path = start;
|
|
1340
|
+
while (!seen.has(normcase(path))) {
|
|
1341
|
+
seen.add(normcase(path));
|
|
1342
|
+
let target;
|
|
1343
|
+
try {
|
|
1344
|
+
target = readlinkSync(path);
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
break; // not a link, or unreadable: what we have is the answer
|
|
1348
|
+
}
|
|
1349
|
+
path = ntIsAbs(target) ? target : ntJoin(ntDirname(path), target);
|
|
1350
|
+
}
|
|
1351
|
+
return path;
|
|
1352
|
+
}
|
|
1353
|
+
/** `ntpath._getfinalpathname_nonstrict` — as much of the target as the OS will resolve. */
|
|
1354
|
+
function getFinalPathNameNonStrict(start) {
|
|
1355
|
+
let path = start;
|
|
1356
|
+
let tail = '';
|
|
1357
|
+
while (path !== '') {
|
|
1358
|
+
try {
|
|
1359
|
+
const got = realpathSync.native(path);
|
|
1360
|
+
return tail === '' ? got : ntJoin(got, tail);
|
|
1361
|
+
}
|
|
1362
|
+
catch {
|
|
1363
|
+
/* every allowed winerror; the walk goes on */
|
|
1364
|
+
}
|
|
1365
|
+
const followed = readlinkDeep(path);
|
|
1366
|
+
if (followed !== path)
|
|
1367
|
+
return tail === '' ? followed : ntJoin(followed, tail);
|
|
1368
|
+
const [head, name] = ntSplit(path);
|
|
1369
|
+
if (head !== '' && name === '')
|
|
1370
|
+
return head + tail;
|
|
1371
|
+
tail = tail === '' ? name : ntJoin(name, tail);
|
|
1372
|
+
path = head;
|
|
1373
|
+
}
|
|
1374
|
+
return tail;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* `ntpath.split` — the head keeps its trailing separators only where they are the root.
|
|
1378
|
+
*
|
|
1379
|
+
* Read off `Lib/ntpath.py` at 3.12, which is the interpreter the reference side runs:
|
|
1380
|
+
* `return d + r + head.rstrip(seps), tail`, with no fallback to the unstripped head.
|
|
1381
|
+
*/
|
|
1382
|
+
export function ntSplit(path) {
|
|
1383
|
+
const [drive, root, rest] = ntSplitRoot(path);
|
|
1384
|
+
let cut = rest.length;
|
|
1385
|
+
while (cut > 0 && !'\\/'.includes(rest[cut - 1]))
|
|
1386
|
+
cut -= 1;
|
|
1387
|
+
const name = rest.slice(cut);
|
|
1388
|
+
const head = rest.slice(0, cut);
|
|
1389
|
+
return [drive + root + head.replace(/[\\/]+$/, ''), name];
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* `ntpath.isabs`, which is NOT `PureWindowsPath.is_absolute()`.
|
|
1393
|
+
*
|
|
1394
|
+
* `ntpath` calls `\a` absolute — a leading separator is enough — while pathlib calls it
|
|
1395
|
+
* relative, because a rooted path with no drive is relative to the CURRENT drive. CPython's
|
|
1396
|
+
* own source labels the difference a LEGACY BUG and keeps it. Both readings are right for
|
|
1397
|
+
* their own caller, and
|
|
1398
|
+
* `realpath` is `ntpath`'s caller: it prepends the cwd on `not isabs(path)`, so using
|
|
1399
|
+
* pathlib's stricter test there would join the cwd onto a path Windows already resolves
|
|
1400
|
+
* from the drive root.
|
|
1401
|
+
*/
|
|
1402
|
+
export function ntIsAbs(path) {
|
|
1403
|
+
// `Lib/ntpath.py`, verbatim: only the first THREE characters are looked at.
|
|
1404
|
+
const head = path.slice(0, 3).replace(/\//g, '\\');
|
|
1405
|
+
return head.startsWith('\\') || head.slice(1).startsWith(':\\');
|
|
1406
|
+
}
|
|
1407
|
+
/** `ntpath.dirname`. */
|
|
1408
|
+
function ntDirname(path) {
|
|
1409
|
+
return ntSplit(path)[0];
|
|
1410
|
+
}
|
|
1411
|
+
// -------------------------------------------------------------------------- stat probes
|
|
1412
|
+
/**
|
|
1413
|
+
* `Path.is_dir()` — and it RE-RAISES, which is the half that matters.
|
|
1414
|
+
*
|
|
1415
|
+
* `pathlib` swallows only `_IGNORED_ERRNOS`; `EACCES` is not one of them, so a candidate
|
|
1416
|
+
* inside a directory the process cannot traverse raises `PermissionError` out of the WALK
|
|
1417
|
+
* itself. MEASURED against CPython 3.12 here: `_walk_to_store` through a `0o000` ancestor
|
|
1418
|
+
* raises rather than skipping the candidate, which refutes the claim in `_pinned_store`'s
|
|
1419
|
+
* docstring that the walk "lives with" an unreadable candidate. `existsSync` and
|
|
1420
|
+
* `statSync(p, {throwIfNoEntry: false})` both answer `false` there and would bind a
|
|
1421
|
+
* different store in silence.
|
|
1422
|
+
*/
|
|
1423
|
+
export function pyIsDir(path) {
|
|
1424
|
+
try {
|
|
1425
|
+
return statSync(path).isDirectory();
|
|
1426
|
+
}
|
|
1427
|
+
catch (e) {
|
|
1428
|
+
if (IGNORED.has(e?.code ?? ''))
|
|
1429
|
+
return false;
|
|
1430
|
+
throw asPyOSError(e, path);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
/** `Path.is_file()`, with the same swallow set as `pyIsDir`. */
|
|
1434
|
+
export function pyIsFile(path) {
|
|
1435
|
+
try {
|
|
1436
|
+
return statSync(path).isFile();
|
|
1437
|
+
}
|
|
1438
|
+
catch (e) {
|
|
1439
|
+
if (IGNORED.has(e?.code ?? ''))
|
|
1440
|
+
return false;
|
|
1441
|
+
throw asPyOSError(e, path);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* `os.stat(p)` reduced to "is it a directory", unguarded.
|
|
1446
|
+
*
|
|
1447
|
+
* `_pinned_store` uses `os.stat` rather than `is_dir()` on purpose: a pin is a single path
|
|
1448
|
+
* the operator named out loud, so an unreadable one gets the accurate reason instead of
|
|
1449
|
+
* being reported as a typo.
|
|
1450
|
+
*/
|
|
1451
|
+
export function pyStatIsDir(path) {
|
|
1452
|
+
try {
|
|
1453
|
+
return statSync(path).isDirectory();
|
|
1454
|
+
}
|
|
1455
|
+
catch (e) {
|
|
1456
|
+
throw asPyOSError(e, path);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
// --------------------------------------------------------------------------------- repr
|
|
1460
|
+
/**
|
|
1461
|
+
* `repr(str)`, which `_pinned_store` interpolates with `{raw!r}` for a relative pin.
|
|
1462
|
+
*
|
|
1463
|
+
* Python quotes with `'` unless the value contains a `'` and no `"`; it escapes `\\`, the
|
|
1464
|
+
* 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.
|
|
1468
|
+
*
|
|
1469
|
+
* The one place it can drift is a codepoint whose category changed between CPython 3.12's
|
|
1470
|
+
* Unicode 15.0 and the ICU this Node was built against: a newly assigned character is `Cn`
|
|
1471
|
+
* (not printable, escaped) for Python and assigned (printable, raw) here. A pin path made of
|
|
1472
|
+
* brand-new codepoints is the only input that reaches it.
|
|
1473
|
+
*/
|
|
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
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* `repr(float)`.
|
|
1504
|
+
*
|
|
1505
|
+
* CPython formats with `PyOS_double_to_string(v, 'r', 0, Py_DTSF_ADD_DOT_0)`: the shortest
|
|
1506
|
+
* decimal that round-trips, rendered in scientific notation when the decimal point falls at
|
|
1507
|
+
* or left of position -4 or right of position 16, and with a forced `.0` otherwise. JS
|
|
1508
|
+
* agrees on the DIGITS (`toExponential()` with no argument is also shortest-round-trip) and
|
|
1509
|
+
* on nothing else: it switches to scientific at 1e21 and 1e-7, writes `10000000000000000`
|
|
1510
|
+
* where Python writes `1e+16`, and never pads the exponent to two digits.
|
|
1511
|
+
*/
|
|
1512
|
+
export function pyFloatRepr(x) {
|
|
1513
|
+
if (Number.isNaN(x))
|
|
1514
|
+
return 'nan';
|
|
1515
|
+
if (x === Infinity)
|
|
1516
|
+
return 'inf';
|
|
1517
|
+
if (x === -Infinity)
|
|
1518
|
+
return '-inf';
|
|
1519
|
+
const negative = x < 0 || Object.is(x, -0);
|
|
1520
|
+
const [mantissa, exponent] = Math.abs(x).toExponential().split('e');
|
|
1521
|
+
const digits = mantissa.replace('.', '');
|
|
1522
|
+
const decpt = Number(exponent) + 1; // digits[0] sits just left of position `decpt`
|
|
1523
|
+
const sign = negative ? '-' : '';
|
|
1524
|
+
if (decpt <= -4 || decpt > 16) {
|
|
1525
|
+
const head = digits.slice(0, 1);
|
|
1526
|
+
const tail = digits.slice(1).replace(/0+$/, '');
|
|
1527
|
+
const e = decpt - 1;
|
|
1528
|
+
const eSign = e < 0 ? '-' : '+';
|
|
1529
|
+
return `${sign}${head}${tail ? `.${tail}` : ''}e${eSign}${String(Math.abs(e)).padStart(2, '0')}`;
|
|
1530
|
+
}
|
|
1531
|
+
if (decpt <= 0)
|
|
1532
|
+
return `${sign}0.${'0'.repeat(-decpt)}${digits}`;
|
|
1533
|
+
if (decpt >= digits.length)
|
|
1534
|
+
return `${sign}${digits}${'0'.repeat(decpt - digits.length)}.0`;
|
|
1535
|
+
return `${sign}${digits.slice(0, decpt)}.${digits.slice(decpt)}`;
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* `PurePath.with_suffix(suffix)` on the LAST component.
|
|
1539
|
+
*
|
|
1540
|
+
* `_write_fact` writes `path.with_suffix(".md.tmp")`, and the rule is not "append": a name
|
|
1541
|
+
* whose last component already has a suffix has that suffix REPLACED, while a component
|
|
1542
|
+
* that is all leading dots has one appended. `facts/a.b.md` -> `facts/a.b.md.tmp`, and the
|
|
1543
|
+
* degenerate `facts/.md` (a fact whose frontmatter name is empty) -> `facts/.md.md.tmp`.
|
|
1544
|
+
*/
|
|
1545
|
+
export function pyWithSuffix(path, suffix) {
|
|
1546
|
+
// `with_suffix` rebuilds a `PurePath`, so the answer comes back in the flavour's own
|
|
1547
|
+
// spelling: `PureWindowsPath('/f/a.md').with_suffix('.md.tmp')` is `\f\a.md.tmp`, with the
|
|
1548
|
+
// separators the CALLER wrote replaced. MEASURED against CPython 3.12; slicing the input
|
|
1549
|
+
// string kept `/f/a.md.tmp`, which is a path the reference never prints.
|
|
1550
|
+
if (process.platform === 'win32')
|
|
1551
|
+
return winWithSuffix(path, suffix);
|
|
1552
|
+
const { anchor, parts } = parsePath(path);
|
|
1553
|
+
if (parts.length === 0)
|
|
1554
|
+
return renderPath(anchor, parts);
|
|
1555
|
+
const name = parts[parts.length - 1];
|
|
1556
|
+
const dot = suffixDot(name);
|
|
1557
|
+
const stem = dot === -1 ? name : name.slice(0, dot);
|
|
1558
|
+
return renderPath(anchor, [...parts.slice(0, -1), `${stem}${suffix}`]);
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* `PurePath.suffix`: the last dot counts only when it is neither the first character of the
|
|
1562
|
+
* name nor the last one, so `.md` has no suffix and `a.` has none either.
|
|
1563
|
+
*/
|
|
1564
|
+
function suffixDot(name) {
|
|
1565
|
+
const dot = name.lastIndexOf('.');
|
|
1566
|
+
return dot > 0 && dot < name.length - 1 ? dot : -1;
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* `PurePath.suffix` as a value, which `shiftwork.clock_out` needs on its own:
|
|
1570
|
+
* `path.with_suffix(path.suffix + ".tmp")` turns `cp.json` into `cp.json.tmp` and a
|
|
1571
|
+
* suffix-less `cp` into `cp.tmp`, and the two arms are only distinguishable by reading the
|
|
1572
|
+
* suffix first.
|
|
1573
|
+
*/
|
|
1574
|
+
export function pySuffix(path) {
|
|
1575
|
+
const dot = suffixDot(pyName(path));
|
|
1576
|
+
return dot === -1 ? '' : pyName(path).slice(dot);
|
|
1577
|
+
}
|
|
1578
|
+
/** `Path.unlink()` and `Path.replace(target)`, with CPython's error text. */
|
|
1579
|
+
export function pyUnlink(path) {
|
|
1580
|
+
try {
|
|
1581
|
+
unlinkSync(path);
|
|
1582
|
+
}
|
|
1583
|
+
catch (e) {
|
|
1584
|
+
throw asPyOSError(e, path);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
export function pyReplace(source, target) {
|
|
1588
|
+
try {
|
|
1589
|
+
renameSync(source, target);
|
|
1590
|
+
}
|
|
1591
|
+
catch (e) {
|
|
1592
|
+
// TWO filenames: `os.replace` sets `filename2`, so `str(e)` is `... : 'src' -> 'dst'`.
|
|
1593
|
+
throw asPyOSError(e, source, target);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/** `date.fromtimestamp(path.stat().st_mtime).isoformat()` — LOCAL, like `date.today()`. */
|
|
1597
|
+
export function pyMtimeDate(path) {
|
|
1598
|
+
let ms;
|
|
1599
|
+
try {
|
|
1600
|
+
ms = statSync(path).mtimeMs;
|
|
1601
|
+
}
|
|
1602
|
+
catch (e) {
|
|
1603
|
+
throw asPyOSError(e, path);
|
|
1604
|
+
}
|
|
1605
|
+
const when = new Date(ms);
|
|
1606
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
1607
|
+
return `${pad(when.getFullYear(), 4)}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;
|
|
1608
|
+
}
|