geneseed 3.1.0 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/DESIGN.md +31 -20
- package/README.md +2 -2
- package/SETUP.md +30 -3
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +113 -85
- package/docs/web/rules.md +2 -0
- package/docs/web/setup-choices.md +13 -1
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +45 -31
- package/js/{render.mjs → build/render.mjs} +72 -11
- package/js/{checkout.mjs → build/source.mjs} +52 -11
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +68 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +94 -69
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +49 -37
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +124 -89
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +88 -58
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +29 -21
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/AGENT.md.tmpl +1 -1
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/Laws-BK07NKw9.js +3 -0
- package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +135 -69
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1712
- package/js/emit.mjs +0 -1410
- package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
- package/web/dist/assets/index-1pPSz7Kl.css +0 -1
- package/web/dist/assets/index-C-gb-q-r.js +0 -12
package/js/lib/fs.mjs
CHANGED
|
@@ -1,32 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Reading and writing — files on disk, and the two process streams.
|
|
3
3
|
*
|
|
4
|
-
* These are
|
|
4
|
+
* These are definitions, not an adaptation of somebody else's. Every rule below was
|
|
5
5
|
* originally MEASURED against CPython, because the generator these functions serve had to
|
|
6
6
|
* emit bytes indistinguishable from a Python one; that second party is gone and the rules
|
|
7
7
|
* are not. What holds them now is the recorded corpora under `tests/__snapshots__/`, which
|
|
8
8
|
* compare this module's output byte for byte and can never be re-recorded — so a rule here
|
|
9
9
|
* is a frozen fact about what this tool emits, and changing one is a product change.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* is a
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* ⚠ NONE OF THESE IS THE NODE DEFAULT, AND THAT IS THE ONE THING TO KNOW BEFORE EDITING ONE.
|
|
12
|
+
* Each is a deliberate divergence from the obvious one-liner, and the obvious one-liner is
|
|
13
|
+
* wrong here in a way no test in this repository would tell you about pleasantly. These names
|
|
14
|
+
* carried a `py` prefix until the prefix was retired, precisely to say so; the warning now
|
|
15
|
+
* lives where it belongs, in the per-function comment naming WHAT WAS MEASURED and against
|
|
16
|
+
* what. That provenance is the only surviving record of why a rule has the shape it has, and
|
|
17
|
+
* it is the first thing to read before touching one.
|
|
18
|
+
*
|
|
19
|
+
* In this module the divergence is `os.linesep`: every write translates `\n` to the platform's
|
|
20
|
+
* separator, and `printOut`/`printErr` do the same on the way to the console.
|
|
21
|
+
*
|
|
22
|
+
* The rest of the old single `fs.mjs` lives beside this file: `json.mjs` (quoting and the
|
|
23
|
+
* parsed-value model), `paths.mjs` (comparing and locating), `text.mjs` (string primitives).
|
|
17
24
|
*
|
|
18
25
|
* Everything here is stdlib-only, ESM, and deliberately tiny — it is imported on hook paths,
|
|
19
26
|
* where load time is a per-tool-call cost.
|
|
20
27
|
*/
|
|
21
28
|
import {
|
|
22
|
-
|
|
23
|
-
utimesSync,
|
|
29
|
+
appendFileSync, writeFileSync, readFileSync, copyFileSync, statSync, utimesSync,
|
|
24
30
|
} from 'node:fs';
|
|
25
31
|
import { EOL } from 'node:os';
|
|
26
|
-
import path from 'node:path';
|
|
27
|
-
|
|
28
|
-
const { X_OK } = constants;
|
|
29
|
-
|
|
30
32
|
/**
|
|
31
33
|
* `Path.write_text(text, encoding="utf-8")`.
|
|
32
34
|
*
|
|
@@ -52,7 +54,7 @@ export function writeText(path, text) {
|
|
|
52
54
|
* `path.open("a", encoding="utf-8").write(text)` — the APPEND half of the rule above.
|
|
53
55
|
*
|
|
54
56
|
* Same translation, second mode. It lives here rather than inlined at its one call site
|
|
55
|
-
* (`js/update.mjs`'s install log) because the newline rule has exactly one owner in this
|
|
57
|
+
* (`js/maintain/update.mjs`'s install log) because the newline rule has exactly one owner in this
|
|
56
58
|
* port and a second copy of it is how the rule rots: an appended line written with
|
|
57
59
|
* `appendFileSync` alone is the only LF-terminated line in an otherwise CRLF file, which is
|
|
58
60
|
* invisible in an editor and a whole-file difference to the gate.
|
|
@@ -95,402 +97,6 @@ export function copyFile(src, dest) {
|
|
|
95
97
|
utimesSync(dest, st.atime, st.mtime);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
/**
|
|
99
|
-
* `json.dumps(s)` for a STRING, with Python's default `ensure_ascii=True`.
|
|
100
|
-
*
|
|
101
|
-
* Every emitted `description:` line is built this way, and 44-50 of them per theme carry
|
|
102
|
-
* an em dash today: Python writes `"a — b"`, `JSON.stringify` writes `"a — b"`. The
|
|
103
|
-
* two agree on everything below U+007F — including the control-character table
|
|
104
|
-
* (`\b \t \n \f \r`, then lowercase `\u00xx`) — so escaping the rest is the whole delta.
|
|
105
|
-
* Astral characters fall out correctly without a special case: JS strings are UTF-16, so
|
|
106
|
-
* the surrogate pair is escaped one code unit at a time, which is exactly what Python
|
|
107
|
-
* emits (`😀`).
|
|
108
|
-
*
|
|
109
|
-
* Strings only, and loudly so — a COMPACT container is the one shape that genuinely
|
|
110
|
-
* differs: Python's default separators are `(', ', ': ')`, so it writes `{"a": 1, "b": 2}`
|
|
111
|
-
* where `JSON.stringify` writes `{"a":1,"b":2}`. Indented containers do NOT have that
|
|
112
|
-
* problem (see `jsonDumpsIndent`), which is why the split is compact-vs-indented rather
|
|
113
|
-
* than string-vs-container.
|
|
114
|
-
*/
|
|
115
|
-
export function jsonDumps(s) {
|
|
116
|
-
if (typeof s !== 'string') {
|
|
117
|
-
throw new TypeError('jsonDumps takes a string; a COMPACT container differs from '
|
|
118
|
-
+ "json.dumps by Python's ', ' / ': ' separators too \u2014 use jsonDumpsIndent or "
|
|
119
|
-
+ 'jsonDumpsCompact');
|
|
120
|
-
}
|
|
121
|
-
return escapeNonAscii(JSON.stringify(s));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* `ensure_ascii=True`: escape everything Python's encoder does and JS's does not.
|
|
126
|
-
*
|
|
127
|
-
* Applied to the SERIALISED text, which is safe because a JSON document can only carry
|
|
128
|
-
* non-ASCII inside string literals. Spelled with \\u escapes, never literals: a JS source
|
|
129
|
-
* file carrying a raw U+2028 or U+2029 terminates the line it sits on, and the parse
|
|
130
|
-
* error points somewhere else entirely.
|
|
131
|
-
*/
|
|
132
|
-
function escapeNonAscii(text) {
|
|
133
|
-
return text.replace(/[\u007f-\uffff]/g,
|
|
134
|
-
(c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* `json.dumps(obj, indent=2)` \u2014 and with `{ ensureAscii: false }`, the
|
|
139
|
-
* `ensure_ascii=False` variant. Those are the only two container shapes the generator
|
|
140
|
-
* writes; measured across all 27 `json.dumps` call sites.
|
|
141
|
-
*
|
|
142
|
-
* Indentation is what makes this tractable: passing `indent` switches Python's
|
|
143
|
-
* separators from `(', ', ': ')` to `(',', ': ')`, which is exactly what
|
|
144
|
-
* `JSON.stringify(v, null, 2)` emits. Verified rather than argued \u2014
|
|
145
|
-
* `tests/test_opencode_extras_parity.py` compares both variants against Python over a
|
|
146
|
-
* corpus of container shapes.
|
|
147
|
-
*/
|
|
148
|
-
export function jsonDumpsIndent(value, { ensureAscii = true } = {}) {
|
|
149
|
-
const text = JSON.stringify(value, null, 2);
|
|
150
|
-
return ensureAscii ? escapeNonAscii(text) : text;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* `json.dumps(obj)` and `json.dumps(obj, sort_keys=True)` — the COMPACT container form.
|
|
155
|
-
*
|
|
156
|
-
* Deferred through three phases because guessing its signature would have been worse than
|
|
157
|
-
* not having it; `js/settings.mjs` is the code that finally needs it, and the shapes were
|
|
158
|
-
* counted with `ast` rather than assumed: four bare `json.dumps(container)` and two
|
|
159
|
-
* `sort_keys=True`, all inside `_build_settings.py`, none of them `ensure_ascii=False`.
|
|
160
|
-
*
|
|
161
|
-
* It cannot delegate to `JSON.stringify` the way `jsonDumpsIndent` does. Without an indent
|
|
162
|
-
* Python's separators are `(', ', ': ')` — `{"a": 1, "b": [1, 2]}` where `JSON.stringify`
|
|
163
|
-
* writes `{"a":1,"b":[1,2]}` — and patching the separators back into the serialised text
|
|
164
|
-
* would corrupt any string containing `,` or `:`. So the walk is written out.
|
|
165
|
-
*
|
|
166
|
-
* `sortKeys` sorts with JS's default comparator (UTF-16 code units) where Python sorts by
|
|
167
|
-
* code point; the two disagree only above the BMP, which no settings.json key reaches. A
|
|
168
|
-
* non-finite number throws rather than emitting `NaN`/`Infinity`: Python's encoder writes
|
|
169
|
-
* those bare tokens, which is not valid JSON, and no caller here can produce one.
|
|
170
|
-
*
|
|
171
|
-
* `bareInts` admits a BARE JS number, reading it as a Python `int`. `pyStr` refuses one by
|
|
172
|
-
* default and is right to: a value that came out of `json.loads` carries the int/float
|
|
173
|
-
* distinction and a bare double has lost it. But P6's caller is `_send_json`, whose values
|
|
174
|
-
* are COMPUTED here — counts, a port, a pid, a unix second — and Python types every one of
|
|
175
|
-
* them `int`, which both languages render identically. The flag is where that claim is
|
|
176
|
-
* stated; the byte gate over every endpoint body is what would catch a float. A
|
|
177
|
-
* non-integral value still renders through `repr(float)`, which is the honest reading of
|
|
178
|
-
* `0.5`; only an INTEGRAL float (`1.0` against JS's `1`) is unrepresentable, and it is
|
|
179
|
-
* unrepresentable whatever this flag says.
|
|
180
|
-
*/
|
|
181
|
-
export function jsonDumpsCompact(value, { sortKeys = false, ensureAscii = true,
|
|
182
|
-
bareInts = false } = {}) {
|
|
183
|
-
const text = compactImpl(value, sortKeys, bareInts);
|
|
184
|
-
return ensureAscii ? escapeNonAscii(text) : text;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function compactImpl(v, sortKeys, bareInts) {
|
|
188
|
-
if (v === null || v === undefined) return 'null';
|
|
189
|
-
if (v === true) return 'true';
|
|
190
|
-
if (v === false) return 'false';
|
|
191
|
-
if (typeof v === 'string') return JSON.stringify(v);
|
|
192
|
-
if (v instanceof PyNumber || typeof v === 'number') {
|
|
193
|
-
const n = v instanceof PyNumber ? v.value : v;
|
|
194
|
-
if (!Number.isFinite(n)) {
|
|
195
|
-
throw new TypeError(`jsonDumpsCompact got ${n}; Python's encoder writes a bare `
|
|
196
|
-
+ 'NaN/Infinity token there, which is not JSON, and nothing here can produce one');
|
|
197
|
-
}
|
|
198
|
-
if (bareInts && typeof v === 'number') {
|
|
199
|
-
return Number.isSafeInteger(n) ? String(n) : pyFloat(n);
|
|
200
|
-
}
|
|
201
|
-
return pyStr(v);
|
|
202
|
-
}
|
|
203
|
-
if (Array.isArray(v)) {
|
|
204
|
-
return `[${v.map((x) => compactImpl(x, sortKeys, bareInts)).join(', ')}]`;
|
|
205
|
-
}
|
|
206
|
-
if (typeof v === 'object') {
|
|
207
|
-
const keys = sortKeys ? Object.keys(v).sort() : Object.keys(v);
|
|
208
|
-
return `{${keys.map((k) => `${JSON.stringify(k)}: ${compactImpl(v[k], sortKeys, bareInts)}`)
|
|
209
|
-
.join(', ')}}`;
|
|
210
|
-
}
|
|
211
|
-
throw new TypeError(`jsonDumpsCompact has no rendering for ${typeof v}`);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Python's `==` for values that came out of `json.loads` \u2014 the operator behind
|
|
216
|
-
* `group in arr`, `rec in canon_flat` and `arr.remove(group)`.
|
|
217
|
-
*
|
|
218
|
-
* `===` is identity for containers, so a naive port of `if group in arr` never matches and
|
|
219
|
-
* `_merge_claude_settings` re-adds its own hook group on every emit \u2014 the exact defect the
|
|
220
|
-
* prior-claim pruning exists to prevent, reintroduced by a one-word translation.
|
|
221
|
-
*
|
|
222
|
-
* `PyNumber` compares by its VALUE, which is right: Python's `{"a": 1} == {"a": 1.0}` is
|
|
223
|
-
* True even though `repr` renders the two differently. The one gap left is Python's
|
|
224
|
-
* `1 == True`; a settings.json holding a bool where the manifest recorded a number is not
|
|
225
|
-
* a shape any writer here produces.
|
|
226
|
-
*/
|
|
227
|
-
export function pyEq(a, b) {
|
|
228
|
-
const x = a instanceof PyNumber ? a.value : a;
|
|
229
|
-
const y = b instanceof PyNumber ? b.value : b;
|
|
230
|
-
if (x === null || y === null || typeof x !== 'object' || typeof y !== 'object') {
|
|
231
|
-
return x === y;
|
|
232
|
-
}
|
|
233
|
-
if (Array.isArray(x) !== Array.isArray(y)) return false;
|
|
234
|
-
if (Array.isArray(x)) {
|
|
235
|
-
return x.length === y.length && x.every((v, i) => pyEq(v, y[i]));
|
|
236
|
-
}
|
|
237
|
-
const kx = Object.keys(x);
|
|
238
|
-
const ky = Object.keys(y);
|
|
239
|
-
return kx.length === ky.length
|
|
240
|
-
&& kx.every((k) => Object.prototype.hasOwnProperty.call(y, k) && pyEq(x[k], y[k]));
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** `value in list` and `list.index(value)` under `pyEq` \u2014 Python's `in`, not `includes`. */
|
|
244
|
-
export function indexOfEq(arr, value) {
|
|
245
|
-
return arr.findIndex((v) => pyEq(v, value));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* `PurePath._str_normcase` \u2014 Windows compares and sorts paths case-folded, POSIX does
|
|
250
|
-
* not. This is what makes `sorted(SRC.rglob('*'))` and `sorted(d.glob('*.json'))`
|
|
251
|
-
* platform-dependent, and the order is observable: the prompt emitter embeds items in
|
|
252
|
-
* order, and each emit's written list becomes the ownership manifest the prune diffs.
|
|
253
|
-
*
|
|
254
|
-
* Two residual differences, both unreachable with this repo's ASCII filenames: Python
|
|
255
|
-
* compares by code point where JS compares by UTF-16 code unit (differs only above the
|
|
256
|
-
* BMP), and `str.lower()` is not `toLowerCase()` for a handful of characters.
|
|
257
|
-
*
|
|
258
|
-
* THE SEPARATOR CONVERSION IS NOT OPTIONAL, and it was missing until the byte-bearing
|
|
259
|
-
* corpus in `tests/test_pure_function_parity.py` asked. `ntpath.normcase` is
|
|
260
|
-
* `s.replace('/', '\\').lower()`, and `PurePath._str_normcase` gets the same conversion for
|
|
261
|
-
* free because `Path` has already done it — so BOTH readings of the reference fold slashes
|
|
262
|
-
* and this did not. Every sort call site feeds it `path.join` output or a bare `readdir`
|
|
263
|
-
* name, which is why no emitted byte ever moved; `fnTranslate(normcase(pattern))` in
|
|
264
|
-
* `js/hooks.mjs` is where it was reachable, because an exclude pattern is USER-WRITTEN and
|
|
265
|
-
* `memory/*` never matched a native `memory\x` the way `fnmatch` does.
|
|
266
|
-
*/
|
|
267
|
-
export const normcase = process.platform === 'win32'
|
|
268
|
-
? (s) => s.replaceAll('/', '\\').toLowerCase()
|
|
269
|
-
: (s) => s;
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* `sorted()` over paths \u2014 one owner, so two call sites cannot drift apart.
|
|
273
|
-
*
|
|
274
|
-
* COMPONENT-WISE, and that was a flat string compare until P2 measured it. `PurePath.__lt__`
|
|
275
|
-
* compares `_parts_normcase` \u2014 a TUPLE of path components \u2014 so the separator never takes part
|
|
276
|
-
* in the comparison, and the two readings disagree in BOTH directions:
|
|
277
|
-
*
|
|
278
|
-
* sorted : skills/geneseed/SKILL.md < skills/geneseed-code-review/SKILL.md
|
|
279
|
-
* flat cmp : the reverse, because '-' (0x2d) sorts before the separator '\\' (0x5c)
|
|
280
|
-
* sorted : x/a/b.md < x/a.md (component 'a' < 'a.md')
|
|
281
|
-
* flat cmp : the reverse again
|
|
282
|
-
*
|
|
283
|
-
* Nothing in the emitted tree could see it: `js/render.mjs` sorts a list whose members share
|
|
284
|
-
* a directory depth, and `golden.py` compared CONTENT rather than order. It became visible
|
|
285
|
-
* the first time a full path list was PRINTED \u2014 `geneseed validate -v` \u2014 against a real
|
|
286
|
-
* skills tree that happens to contain both `skills/geneseed/` and `skills/geneseed-code-review/`.
|
|
287
|
-
* `tests/test_maintainer_tools_parity.py` found it and died with the reference it compared
|
|
288
|
-
* against. What HOLDS it now is `tests/unit/maintainer_tools.test.mjs`, and specifically its
|
|
289
|
-
* `theme files are visited in THIS platform's Path collation order` test: it re-derives Python's
|
|
290
|
-
* `sorted(Path)` order for the running platform instead of replaying a recording, so it cannot
|
|
291
|
-
* agree with a drift \u2014 and it is the measured killer of this function's mutation (M3).
|
|
292
|
-
*/
|
|
293
|
-
export function comparePaths(a, b) {
|
|
294
|
-
const A = normcase(a).split(/[\\/]/);
|
|
295
|
-
const B = normcase(b).split(/[\\/]/);
|
|
296
|
-
for (let i = 0; i < Math.min(A.length, B.length); i += 1) {
|
|
297
|
-
if (A[i] !== B[i]) return A[i] < B[i] ? -1 : 1;
|
|
298
|
-
}
|
|
299
|
-
return A.length - B.length;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* `_harness_core._within` \u2014 containment through `normcase`, segment-wise.
|
|
304
|
-
*
|
|
305
|
-
* MOVED HERE IN P6i, from `js/web/api.mjs` where P6b first needed it. The Python is a harness
|
|
306
|
-
* CORE primitive (`child.relative_to(parent)` in a try/except), and its second caller is
|
|
307
|
-
* `_install_move_list` in `js/uninstall.mjs` \u2014 a CLI-level module that must not import the
|
|
308
|
-
* web tree to reach it. Beside `normcase`, which is the thing it is actually about.
|
|
309
|
-
* `js/web/api.mjs` re-exports it so its existing importers are unchanged.
|
|
310
|
-
*/
|
|
311
|
-
export function within(child, parent) {
|
|
312
|
-
const c = normcase(child).split(/[\\/]/);
|
|
313
|
-
const p = normcase(parent).split(/[\\/]/);
|
|
314
|
-
return p.length <= c.length && p.every((seg, i) => c[i] === seg);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* A number as `json.loads` produced it, carrying the literal it was parsed from.
|
|
319
|
-
*
|
|
320
|
-
* `json.loads` yields an `int` for `20` and a `float` for `1.0`, and Python's `str()`
|
|
321
|
-
* renders those as `20` and `1.0`. `JSON.parse` collapses both to the IEEE double `1`,
|
|
322
|
-
* and `String(1.0)` is `"1"` — so an `agent-overrides.json` carrying `"temperature": 1.0`
|
|
323
|
-
* emits `temperature: 1.0` from Python and `temperature: 1` from Node.
|
|
324
|
-
*
|
|
325
|
-
* That divergence is invisible to the golden harness: the emit always writes an EMPTY
|
|
326
|
-
* overrides stub, so no golden cell has ever had an override to render. It is only
|
|
327
|
-
* reachable on a real user's machine, which is the worst place to find it.
|
|
328
|
-
*/
|
|
329
|
-
class PyNumber {
|
|
330
|
-
constructor(value, source) { this.value = value; this.source = source; }
|
|
331
|
-
valueOf() { return this.value; }
|
|
332
|
-
toString() { return pyStr(this); }
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Re-serialising parsed JSON must round-trip the way Python's does. Without this,
|
|
336
|
-
* `JSON.stringify` would walk the wrapper and emit `{"value":1,"source":"1.0"}`.
|
|
337
|
-
* `JSON.rawJSON` (Node >= 21) writes the text verbatim, and `pyStr` supplies the text
|
|
338
|
-
* Python would have \u2014 NOT the original literal, because `json.dumps(json.loads('1.50'))`
|
|
339
|
-
* is `1.5`: Python re-formats through repr rather than echoing the source.
|
|
340
|
-
*/
|
|
341
|
-
toJSON() { return JSON.rawJSON(pyStr(this)); }
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* `json.loads(text)`, preserving the int/float distinction (see `PyNumber`).
|
|
346
|
-
*
|
|
347
|
-
* Uses the reviver's `context.source` (Node >= 21). Where it is unavailable the numbers
|
|
348
|
-
* come back bare and `pyStr` throws rather than formatting them wrongly — a loud failure
|
|
349
|
-
* on an old runtime beats bytes that differ from Python's only for some values.
|
|
350
|
-
*/
|
|
351
|
-
export function parseJson(text) {
|
|
352
|
-
return JSON.parse(text, function (key, value, context) {
|
|
353
|
-
return typeof value === 'number' && context && typeof context.source === 'string'
|
|
354
|
-
? new PyNumber(value, context.source)
|
|
355
|
-
: value;
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/** `repr(float)` — the half of `pyStr` where the two languages disagree most. */
|
|
360
|
-
function pyFloat(n) {
|
|
361
|
-
if (Number.isNaN(n)) return 'nan';
|
|
362
|
-
if (!Number.isFinite(n)) return n > 0 ? 'inf' : '-inf';
|
|
363
|
-
const a = Math.abs(n);
|
|
364
|
-
if (a !== 0 && (a >= 1e16 || a < 1e-4)) {
|
|
365
|
-
// Python leaves positional notation outside [1e-4, 1e16); JS's own thresholds are
|
|
366
|
-
// 1e-6 and 1e21, and it writes a one-digit exponent where Python pads to two.
|
|
367
|
-
const [mantissa, exp] = n.toExponential().split('e');
|
|
368
|
-
return `${mantissa}e${exp[0] === '-' ? '-' : '+'}${exp.slice(1).padStart(2, '0')}`;
|
|
369
|
-
}
|
|
370
|
-
// Both engines print the shortest round-tripping decimal, but JS drops the fractional
|
|
371
|
-
// part of an integral value: `String(1.0)` is '1' where `repr(1.0)` is '1.0'. And
|
|
372
|
-
// `String(-0)` is '0' where `repr(-0.0)` keeps the sign.
|
|
373
|
-
const s = Object.is(n, -0) ? '-0' : String(n);
|
|
374
|
-
return s.includes('.') || s.includes('e') ? s : `${s}.0`;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* `str(value)` for a value that came out of `json.loads` — what an f-string interpolates.
|
|
379
|
-
*
|
|
380
|
-
* Only the types the generator actually renders are handled. A list, dict or bool reaching
|
|
381
|
-
* here would print as `[object Object]` / `true` where Python prints `[1, 2]` / `True`, so
|
|
382
|
-
* it throws instead: an override file with a value nobody has decided the semantics for is
|
|
383
|
-
* a question to answer, not a byte to guess at.
|
|
384
|
-
*/
|
|
385
|
-
/**
|
|
386
|
-
* Python truthiness — `if value:` — for a value that came out of `json.loads`.
|
|
387
|
-
*
|
|
388
|
-
* `Boolean()` is not it, and the gap is not academic: a `PyNumber` is an OBJECT, so
|
|
389
|
-
* `Boolean(PyNumber(0))` is `true` where Python's `if 0` is false. In the other direction
|
|
390
|
-
* `Number('0')` is `0` where Python's `if "0"` is true. Both spellings appear in the
|
|
391
|
-
* override tests, so neither shortcut is safe.
|
|
392
|
-
*/
|
|
393
|
-
export function pyTruthy(value) {
|
|
394
|
-
if (value === undefined || value === null || value === false) return false;
|
|
395
|
-
if (typeof value === 'string') return value !== '';
|
|
396
|
-
if (value instanceof PyNumber) return value.value !== 0;
|
|
397
|
-
if (Array.isArray(value)) return value.length > 0;
|
|
398
|
-
if (typeof value === 'object') return Object.keys(value).length > 0;
|
|
399
|
-
return Boolean(value);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
export function pyStr(value) {
|
|
403
|
-
if (typeof value === 'string') return value;
|
|
404
|
-
if (value instanceof PyNumber) {
|
|
405
|
-
// `json.decoder` picks parse_float for any literal carrying a fraction or an exponent
|
|
406
|
-
// and parse_int otherwise — so the literal, not the resulting double, is what decides.
|
|
407
|
-
// Ints are exact in Python at any width; BigInt keeps that (and normalises `-0`).
|
|
408
|
-
return /[.eE]/.test(value.source) ? pyFloat(value.value) : BigInt(value.source).toString();
|
|
409
|
-
}
|
|
410
|
-
if (typeof value === 'number') {
|
|
411
|
-
throw new TypeError(`pyStr got a bare number (${value}); parse JSON with parseJson so `
|
|
412
|
-
+ `int 1 and float 1.0 stay distinguishable, as they are in Python`);
|
|
413
|
-
}
|
|
414
|
-
throw new TypeError(`pyStr has no Python-agreeing rendering for ${typeof value}`);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* `repr(value)` for the values a theme or an override file can hold.
|
|
419
|
-
*
|
|
420
|
-
* Python quotes strings with `'`, switching to `"` only when the string contains a `'`
|
|
421
|
-
* and no `"`, and spells the three singletons with a capital. Anything else is a
|
|
422
|
-
* container, whose repr differs structurally; guessing at a rendering would be worse
|
|
423
|
-
* than failing, so it falls through to `pyStr`.
|
|
424
|
-
*
|
|
425
|
-
* Lives here rather than beside its first caller because `pyAscii` below is the same
|
|
426
|
-
* function with one more escaping rule, and two copies of the quote choice would be two
|
|
427
|
-
* things to keep in agreement.
|
|
428
|
-
*
|
|
429
|
-
* Containers ARE rendered, unlike in `pyStr`, and the difference is not an inconsistency.
|
|
430
|
-
* `pyStr` interpolates into an emitted file, where a shape nobody decided the semantics
|
|
431
|
-
* for must be a question rather than a guessed byte. `repr` renders a value into a WARNING
|
|
432
|
-
* about that very value — a list in `.geneseed-srcdirs.json` is precisely what the warning
|
|
433
|
-
* exists to report, and throwing there would turn "this file is corrupt" into a crash. The
|
|
434
|
-
* value always came from JSON, so the type set is closed and the rendering is exact.
|
|
435
|
-
*/
|
|
436
|
-
function pyReprImpl(v, ascii) {
|
|
437
|
-
if (typeof v === 'string') {
|
|
438
|
-
let body = '';
|
|
439
|
-
for (const ch of v) { // by CODE POINT: `\U########` is one escape
|
|
440
|
-
const cp = ch.codePointAt(0);
|
|
441
|
-
if (ch === '\\') body += '\\\\';
|
|
442
|
-
else if (ch === '\n') body += '\\n';
|
|
443
|
-
else if (ch === '\r') body += '\\r';
|
|
444
|
-
else if (ch === '\t') body += '\\t';
|
|
445
|
-
else if (cp < 0x20 || cp === 0x7f) body += `\\x${cp.toString(16).padStart(2, '0')}`;
|
|
446
|
-
else if (cp < 0x7f) body += ch;
|
|
447
|
-
else if (!ascii) body += ch; // repr() keeps printable non-ASCII verbatim
|
|
448
|
-
else if (cp <= 0xff) body += `\\x${cp.toString(16).padStart(2, '0')}`;
|
|
449
|
-
else if (cp <= 0xffff) body += `\\u${cp.toString(16).padStart(4, '0')}`;
|
|
450
|
-
else body += `\\U${cp.toString(16).padStart(8, '0')}`;
|
|
451
|
-
}
|
|
452
|
-
// The quote choice reads the ESCAPED body, which is safe: no escape sequence above
|
|
453
|
-
// introduces a quote character, so a `'` or `"` in it came from the input.
|
|
454
|
-
return body.includes("'") && !body.includes('"')
|
|
455
|
-
? `"${body}"`
|
|
456
|
-
: `'${body.replaceAll("'", "\\'")}'`;
|
|
457
|
-
}
|
|
458
|
-
if (v === null) return 'None';
|
|
459
|
-
if (v === true) return 'True';
|
|
460
|
-
if (v === false) return 'False';
|
|
461
|
-
if (Array.isArray(v)) return `[${v.map((x) => pyReprImpl(x, ascii)).join(', ')}]`;
|
|
462
|
-
if (typeof v === 'object' && !(v instanceof PyNumber)) {
|
|
463
|
-
// JSON object keys are always strings, so `repr(key)` is the string branch above.
|
|
464
|
-
return `{${Object.entries(v)
|
|
465
|
-
.map(([k, x]) => `${pyReprImpl(k, ascii)}: ${pyReprImpl(x, ascii)}`)
|
|
466
|
-
.join(', ')}}`;
|
|
467
|
-
}
|
|
468
|
-
return pyStr(v);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
export function pyRepr(v) {
|
|
472
|
-
return pyReprImpl(v, false);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* `ascii(value)` — `repr()` with every non-ASCII character escaped as well.
|
|
477
|
-
*
|
|
478
|
-
* Used where a warning quotes a value that came out of a file a user (or another tool)
|
|
479
|
-
* may have written: `build`'s prune guard names the suspicious dir name it refused to
|
|
480
|
-
* delete from `.geneseed-srcdirs.json`. Escaping is the point — a dir name carrying a
|
|
481
|
-
* newline or a right-to-left override must be readable in the warning, not act on the
|
|
482
|
-
* terminal, and Python already made that choice by spelling it `ascii()` rather than an
|
|
483
|
-
* f-string.
|
|
484
|
-
*
|
|
485
|
-
* The two share ONE implementation on purpose. The first draft gave `pyAscii` its own
|
|
486
|
-
* container and quote handling, and a mutation exposed the consequence immediately:
|
|
487
|
-
* `pyRepr`'s container branches became unreachable, so half of the code was dead and
|
|
488
|
-
* neither copy's escaping could drift without the other silently keeping the gate green.
|
|
489
|
-
*/
|
|
490
|
-
export function pyAscii(v) {
|
|
491
|
-
return pyReprImpl(v, true);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
100
|
/**
|
|
495
101
|
* `print()` — the two output funnels, with Python's newline translation reproduced.
|
|
496
102
|
*
|
|
@@ -505,27 +111,27 @@ export function pyAscii(v) {
|
|
|
505
111
|
* universal newlines on the way back, folding both shapes to `\n` before any cell compares
|
|
506
112
|
* them, the same shape as the shim's exclusion from `golden.py`. So the gate for it is
|
|
507
113
|
* `test_the_two_entry_points_agree_on_stdout_BYTES`, which captures raw bytes. Moved here
|
|
508
|
-
* from `js/hooks.mjs` in P5c, when `js/excludes.mjs` became the second caller: a translation
|
|
114
|
+
* from `js/hosts/hooks.mjs` in P5c, when `js/inspect/excludes.mjs` became the second caller: a translation
|
|
509
115
|
* that exists twice is a translation that can be fixed once.
|
|
510
116
|
*/
|
|
511
117
|
const xlate = (s) => (EOL === '\n' ? s : s.replaceAll('\n', EOL));
|
|
512
|
-
export const
|
|
513
|
-
export const
|
|
118
|
+
export const printOut = (s) => process.stdout.write(xlate(s));
|
|
119
|
+
export const printErr = (s) => process.stderr.write(xlate(s));
|
|
514
120
|
|
|
515
121
|
/**
|
|
516
122
|
* Hold `fn`'s stderr, and emit it only if `fn` RETURNS.
|
|
517
123
|
*
|
|
518
124
|
* The reproduction of `except SystemExit` for a port that writes its refusal at the raise
|
|
519
|
-
* site (`js/generate.mjs`'s `sysExit` docblock states the convention). `sys.exit(msg)`
|
|
125
|
+
* site (`js/build/generate.mjs`'s `sysExit` docblock states the convention). `sys.exit(msg)`
|
|
520
126
|
* attaches the message to the EXCEPTION and the interpreter prints it on the way out, so a
|
|
521
127
|
* Python caller that catches sees no output at all — which makes write-at-the-raise-site
|
|
522
128
|
* identical for every caller that lets the throw propagate, and wrong for every caller that
|
|
523
129
|
* catches.
|
|
524
130
|
*
|
|
525
|
-
* MOVED HERE beside `
|
|
526
|
-
* second and third such caller: `js/hosts.mjs`'s `expanduser` refuses a `~user` path by
|
|
527
|
-
* printing and throwing, and `js/hooks.mjs` catches it twice — once per `excludes.json`
|
|
528
|
-
* entry and once around `$GENESEED_ROOT`/`--root`. Its old docblock in `js/doctor.mjs`
|
|
131
|
+
* MOVED HERE beside `printErr`, whose writes it intercepts, when the hook became the
|
|
132
|
+
* second and third such caller: `js/hosts/hosts.mjs`'s `expanduser` refuses a `~user` path by
|
|
133
|
+
* printing and throwing, and `js/hosts/hooks.mjs` catches it twice — once per `excludes.json`
|
|
134
|
+
* entry and once around `$GENESEED_ROOT`/`--root`. Its old docblock in `js/inspect/doctor.mjs`
|
|
529
135
|
* warned that a general helper "would invite a second caller that wanted silence the
|
|
530
136
|
* noise". That warning stands and is the contract: this is for a message that belonged to
|
|
531
137
|
* an exception NOBODY LET ESCAPE, never for quieting output a call legitimately makes —
|
|
@@ -546,18 +152,19 @@ export function withDiscardableStderr(fn) {
|
|
|
546
152
|
}
|
|
547
153
|
|
|
548
154
|
/**
|
|
549
|
-
* The same rule applied to a whole CALL rather than to one string — `bin/
|
|
155
|
+
* The same rule applied to a whole CALL rather than to one string — `bin/build-driver.mjs`'s
|
|
550
156
|
* answer to the newline item P5e recorded and could not gate.
|
|
551
157
|
*
|
|
552
|
-
* WHY THE DRIVER NEEDS A FUNNEL WHERE EVERY OTHER CALLER NEEDS `
|
|
553
|
-
* prints from ~25 sites across `bin/
|
|
554
|
-
* `js/opencode.mjs`, `js/native.mjs` and `js/render.mjs`, and those modules are ALSO the
|
|
555
|
-
* body of
|
|
556
|
-
* to a Python parent that re-
|
|
557
|
-
* `
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
158
|
+
* WHY THE DRIVER NEEDS A FUNNEL WHERE EVERY OTHER CALLER NEEDS `printOut`. The generator
|
|
159
|
+
* prints from ~25 sites across `bin/build-driver.mjs`, `js/build/bundle.mjs`, `js/hosts/settings.mjs`,
|
|
160
|
+
* `js/hosts/opencode.mjs`, `js/hosts/native.mjs` and `js/build/render.mjs`, and those modules are ALSO the
|
|
161
|
+
* body of what used to be a seam child, whose `main` buffered both streams and handed them
|
|
162
|
+
* to a Python parent that re-printed them through `print()`. Converting those sites to
|
|
163
|
+
* `printOut` would have translated once in Node and again in Python — `\r\r\n` on every line.
|
|
164
|
+
* So the translation belongs to whichever process OWNS the stream, and that child did not.
|
|
165
|
+
* This wraps the driver's `main`, and equally the four render entry points the CLI can reach
|
|
166
|
+
* WITHOUT `main` on the stack: `parseDriverArgs`, `buildInto`, `emitProjectInto` and
|
|
167
|
+
* `emitGlobalInto`.
|
|
561
168
|
*
|
|
562
169
|
* The item it closes: a plain `--emit files` handed its caller 104 bytes where `python
|
|
563
170
|
* build.py` handed over 105, and `tests/golden.py`'s `text=True` capture folds both to the
|
|
@@ -569,7 +176,7 @@ export function withDiscardableStderr(fn) {
|
|
|
569
176
|
* path writes binary to a stream, so translating one would be corrupting data rather than
|
|
570
177
|
* reproducing Python.
|
|
571
178
|
*/
|
|
572
|
-
export function
|
|
179
|
+
export function withPlatformNewlines(fn) {
|
|
573
180
|
if (EOL === '\n') return fn();
|
|
574
181
|
const outW = process.stdout.write.bind(process.stdout);
|
|
575
182
|
const errW = process.stderr.write.bind(process.stderr);
|
|
@@ -586,332 +193,3 @@ export function withPyNewlines(fn) {
|
|
|
586
193
|
}
|
|
587
194
|
}
|
|
588
195
|
|
|
589
|
-
/**
|
|
590
|
-
* `len(s)` — CODE POINTS, where `String.length` counts UTF-16 units.
|
|
591
|
-
*
|
|
592
|
-
* They differ on every astral character: `len("𝔊")` is 1 and `"𝔊".length` is 2. Only
|
|
593
|
-
* matters where a length becomes layout, which is exactly what the status panel does with
|
|
594
|
-
* it — a theme name or a memory path outside the BMP would shear the frame by one column
|
|
595
|
-
* per character under `.length`.
|
|
596
|
-
*/
|
|
597
|
-
export const pyLen = (s) => [...s].length;
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* `str.ljust(width)` — pad with spaces to `width` CODE POINTS, never truncate.
|
|
601
|
-
*
|
|
602
|
-
* `String.padEnd` counts UTF-16 units, so it is `pyLen`'s difference again, one call later.
|
|
603
|
-
*/
|
|
604
|
-
export const pyLjust = (s, width) => s + ' '.repeat(Math.max(0, width - pyLen(s)));
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* `str(PurePath(s))` — separators normalised to the platform's, `.` components dropped,
|
|
608
|
-
* duplicate separators collapsed, a trailing one removed.
|
|
609
|
-
*
|
|
610
|
-
* `path.normalize` is NOT this: it also collapses `a/../b` to `b`, and `PurePath` keeps the
|
|
611
|
-
* `..` because a symlinked `a` makes those two different directories. The distinction is
|
|
612
|
-
* live in `js/excludes.mjs`, where a hand-edited `excludes.json` entry is compared against a
|
|
613
|
-
* RESOLVED repo path — Python never matches a `..` entry there, and a normalising port would
|
|
614
|
-
* match it and unwire a file the reference would have left alone.
|
|
615
|
-
*/
|
|
616
|
-
/**
|
|
617
|
-
* `shutil.which(cmd, path=None)` — the first executable named `cmd` on PATH, or null.
|
|
618
|
-
*
|
|
619
|
-
* `doctor` runs `node --check` over the OpenCode plugins and SKIPS the whole check when node
|
|
620
|
-
* is not on PATH. `process.execPath` is not that: it is always set, so a port that used it
|
|
621
|
-
* would run the check in a world where the reference silently declines to — the divergence a
|
|
622
|
-
* PATH-removal refutation is aimed at, and the reason this is a real lookup rather than one
|
|
623
|
-
* line of "we are node, we know where node is".
|
|
624
|
-
*
|
|
625
|
-
* The Windows half is the part with behaviour in it, and three of its four rules were
|
|
626
|
-
* MEASURED against CPython 3.13 rather than assumed. `PATHEXT` supplies the suffixes, and the
|
|
627
|
-
* returned path carries PATHEXT's OWN SPELLING of the extension rather than the file's
|
|
628
|
-
* (`zzcmd.CMD` for a `zzcmd.cmd` on disk). An extension already in that set means the command
|
|
629
|
-
* is spelled in full. An EMPTY PATH entry is not skipped either: `join('', cmd)` is a
|
|
630
|
-
* relative path, and Python answers with one.
|
|
631
|
-
*
|
|
632
|
-
* THE FOURTH RULE IS THE CURRENT DIRECTORY, and it was measured on a machine that could not
|
|
633
|
-
* see it. `shutil.which` prepends `os.curdir` to the search — even to a `path=` given
|
|
634
|
-
* explicitly — when `_winapi.NeedCurrentDirectoryForExePath` says so, and that Win32 call
|
|
635
|
-
* answers TRUE unless `NoDefaultCurrentDirectoryInExePath` is DEFINED in the environment.
|
|
636
|
-
* Git Bash and this repo's developer machine both define it, so the reference answered None
|
|
637
|
-
* there and the first draft's "the current directory is NOT prepended — it was until 3.12"
|
|
638
|
-
* described an environment variable it had mistaken for a version change. GitHub's Windows
|
|
639
|
-
* runner does not define it: the reference answered `.\\geneseed.CMD` for a `geneseed` looked
|
|
640
|
-
* up beside the repo root, this returned null, and the parity corpus said so on the first CI
|
|
641
|
-
* run. The variable is an INPUT, so it is read here rather than baked, and
|
|
642
|
-
* `test_the_curdir_rule_agrees_in_both_environment_states` runs the corpus with it both ways.
|
|
643
|
-
*
|
|
644
|
-
* Gated as a pure function over a seeded directory in `tests/test_pure_function_parity.py`:
|
|
645
|
-
* a cell can only ever observe the one answer this machine's PATH gives.
|
|
646
|
-
*/
|
|
647
|
-
export function pyWhich(cmd, searchPath = null) {
|
|
648
|
-
const win = process.platform === 'win32';
|
|
649
|
-
const isExec = (p) => {
|
|
650
|
-
try {
|
|
651
|
-
if (!statSync(p).isFile()) return false;
|
|
652
|
-
accessSync(p, X_OK);
|
|
653
|
-
return true;
|
|
654
|
-
} catch { return false; }
|
|
655
|
-
};
|
|
656
|
-
// A command with a directory separator is not looked up at all — Python checks it as given.
|
|
657
|
-
if (cmd.includes('/') || (win && cmd.includes('\\'))) return isExec(cmd) ? cmd : null;
|
|
658
|
-
const exts = win
|
|
659
|
-
? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
660
|
-
: [];
|
|
661
|
-
const spelled = win && exts.some((e) => cmd.toLowerCase().endsWith(e.toLowerCase()));
|
|
662
|
-
// `os.path.join`, which CONCATENATES where `path.join` NORMALISES. The difference shows the
|
|
663
|
-
// moment a PATH entry is spelled with the other separator — routine on Windows, where Git
|
|
664
|
-
// Bash and MSYS put forward-slash entries in it — and the answer is the string a caller
|
|
665
|
-
// then spawns. Measured: the corpus case with a posix-spelled directory was the one that
|
|
666
|
-
// differed, and `path.join` had rewritten the whole path rather than appending to it.
|
|
667
|
-
const joinRaw = (dir, name) => (
|
|
668
|
-
dir === '' || /[\\/]$/.test(dir) || (win && dir.endsWith(':'))
|
|
669
|
-
? dir + name : dir + path.sep + name);
|
|
670
|
-
const dirs = (searchPath ?? process.env.PATH ?? '').split(path.delimiter);
|
|
671
|
-
// `NeedCurrentDirectoryForExePath(cmd)`: TRUE — so `.` goes in FRONT — unless the variable
|
|
672
|
-
// is defined. Only reachable with a separator-free `cmd`, which is the only way execution
|
|
673
|
-
// gets this far. Inserted before the dedupe, exactly where `shutil.which` inserts it, so a
|
|
674
|
-
// PATH that already names `.` is searched once.
|
|
675
|
-
if (win && process.env.NoDefaultCurrentDirectoryInExePath === undefined) dirs.unshift('.');
|
|
676
|
-
const seen = new Set();
|
|
677
|
-
for (const dir of dirs) {
|
|
678
|
-
const key = win ? dir.toLowerCase() : dir;
|
|
679
|
-
if (seen.has(key)) continue;
|
|
680
|
-
seen.add(key);
|
|
681
|
-
for (const ext of (!win || spelled ? [''] : exts)) {
|
|
682
|
-
const candidate = joinRaw(dir, cmd + ext);
|
|
683
|
-
if (isExec(candidate)) return candidate;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
return null;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* `pathlib.Path(s).is_absolute()`, which is NOT `path.isAbsolute(s)` on Windows.
|
|
691
|
-
*
|
|
692
|
-
* MEASURED against CPython 3.13, and the two disagree on exactly one shape: a ROOTLESS
|
|
693
|
-
* absolute path. `/x/AGENT.md` and `\x\AGENT.md` are `True` to `path.isAbsolute` and `False`
|
|
694
|
-
* to Python, which requires a DRIVE (`C:/x`) or a UNC root (`//server/share/x`) before it
|
|
695
|
-
* calls a Windows path absolute. `C:x/AGENT.md` — a drive-RELATIVE path — is False to both.
|
|
696
|
-
* On POSIX the two rules coincide and this is `s.startsWith('/')`.
|
|
697
|
-
*
|
|
698
|
-
* Written in P5h for `installAgentEntry`, which filters an OpenCode config's `instructions`
|
|
699
|
-
* for the entries that are NOT absolute in order to tell a project install's relative
|
|
700
|
-
* `…/AGENT.md` from a global install's absolute one. A hand-edited config naming
|
|
701
|
-
* `/repo/AGENT.md` is the reachable input: Python keeps that entry and Node would have
|
|
702
|
-
* skipped it and fallen through to the literal `AGENT.md`, unwiring nothing.
|
|
703
|
-
*
|
|
704
|
-
* ONE OWNER, and the five pre-existing `path.isAbsolute` calls in `js/` were READ rather than
|
|
705
|
-
* assumed to be copies. Three cannot reach the divergence — `js/doctor.mjs` and
|
|
706
|
-
* `bin/geneseed.mjs` test the output of `path.relative`, which is never rootless-absolute,
|
|
707
|
-
* and `js/emit.mjs`'s `safePriorDirName` is `&&`-guarded by `basename(p) === p` immediately
|
|
708
|
-
* after. The two in `js/hooks.mjs` (a context entry's `path`) DO carry the same hazard and
|
|
709
|
-
* are deliberately not repointed here: that is the hook entry's shipped behaviour, no
|
|
710
|
-
* `context` cell varies it, and changing it in this phase would be an ungated edit to a verb
|
|
711
|
-
* that crossed three phases ago. Recorded in the spec's "Still owed" with this reproduction.
|
|
712
|
-
*
|
|
713
|
-
* Gated by a corpus in `tests/test_pure_function_parity.py`: no cell can vary the shape.
|
|
714
|
-
*/
|
|
715
|
-
export function pyIsAbsolute(s) {
|
|
716
|
-
if (process.platform !== 'win32') return s.startsWith('/');
|
|
717
|
-
// Python is absolute iff the path has a DRIVE (or UNC share) *and* a root — `C:x` has the
|
|
718
|
-
// drive and no root, `/x` has the root and no drive, and neither is absolute. In
|
|
719
|
-
// `path.parse` terms that is exactly "the root is longer than a bare separator AND ends in
|
|
720
|
-
// one": `C:` for `C:x`, `\` for `\x`, `C:\` for `C:\x`, `\\srv\share\` for a UNC path.
|
|
721
|
-
// The first draft tested `root !== '\\'`, which called `C:x` absolute; the corpus caught
|
|
722
|
-
// it on the two drive-relative cases and nothing else would have.
|
|
723
|
-
const root = path.parse(s).root;
|
|
724
|
-
return root.length > 1 && /[\\/]$/.test(root);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* `int(s)` for a base-10 string — the VALUE, or null where Python raises `ValueError`.
|
|
729
|
-
*
|
|
730
|
-
* Null rather than a throw because both call sites branch on the failure rather than
|
|
731
|
-
* propagate it: `askChoice` falls through to matching the answer against the option KEYS,
|
|
732
|
-
* and `javaMajorOk` treats an unparseable major as "not new enough". Python spells the same
|
|
733
|
-
* two branches as `except ValueError`.
|
|
734
|
-
*
|
|
735
|
-
* Three things separate this from `Number(s)` and each one changes an answer:
|
|
736
|
-
*
|
|
737
|
-
* * `Number('')` is 0 and `int('')` raises. So is `Number(' ')`, and `Number('0x10')` is
|
|
738
|
-
* 16 where `int('0x10')` raises — a wizard answer of `0x2` must fall through to the key
|
|
739
|
-
* match, not select option 16.
|
|
740
|
-
* * PEP 515 underscores: `int('1_0')` is 10, `Number('1_0')` is NaN. Leading, trailing and
|
|
741
|
-
* doubled ones still raise, so the rule is "between digits" and not "strip them".
|
|
742
|
-
* * `int()` accepts any Unicode decimal digit — `int('٣')` is 3 — and `Number('٣')` is NaN.
|
|
743
|
-
* Every `Nd` block is exactly ten contiguous code points, so a digit's value is its
|
|
744
|
-
* offset from the first `Nd` code point at or below it, which is what the walk finds.
|
|
745
|
-
*
|
|
746
|
-
* Surrounding whitespace is Python's to strip and both callers have already done it, so this
|
|
747
|
-
* does NOT strip: `int(' 1')` is 1, but reproducing that here would need Python's
|
|
748
|
-
* `str.isspace()` set, which is a separate standing item.
|
|
749
|
-
*
|
|
750
|
-
* Gated by a corpus in `tests/test_pure_function_parity.py` — a primitive reproduction gets
|
|
751
|
-
* one owner and a corpus, never a cell (P5c).
|
|
752
|
-
*/
|
|
753
|
-
export function pyInt(s) {
|
|
754
|
-
if (typeof s !== 'string' || s === '') return null;
|
|
755
|
-
let body = s;
|
|
756
|
-
let sign = 1;
|
|
757
|
-
if (body[0] === '+' || body[0] === '-') {
|
|
758
|
-
if (body[0] === '-') sign = -1;
|
|
759
|
-
body = body.slice(1);
|
|
760
|
-
}
|
|
761
|
-
const chars = [...body];
|
|
762
|
-
if (chars.length === 0) return null;
|
|
763
|
-
let value = 0n;
|
|
764
|
-
let prevWasDigit = false;
|
|
765
|
-
for (const ch of chars) {
|
|
766
|
-
if (ch === '_') {
|
|
767
|
-
// Only BETWEEN digits: a leading, trailing or doubled underscore is a ValueError.
|
|
768
|
-
if (!prevWasDigit) return null;
|
|
769
|
-
prevWasDigit = false;
|
|
770
|
-
continue;
|
|
771
|
-
}
|
|
772
|
-
if (!/^\p{Nd}$/u.test(ch)) return null;
|
|
773
|
-
const cp = ch.codePointAt(0);
|
|
774
|
-
let zero = cp;
|
|
775
|
-
while (zero > 0 && /^\p{Nd}$/u.test(String.fromCodePoint(zero - 1))) zero -= 1;
|
|
776
|
-
value = value * 10n + BigInt(cp - zero);
|
|
777
|
-
prevWasDigit = true;
|
|
778
|
-
}
|
|
779
|
-
if (!prevWasDigit) return null; // a trailing underscore
|
|
780
|
-
return Number(sign === -1 ? -value : value);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
/**
|
|
784
|
-
* `str(Path(x))` — and it is PLATFORM-SHAPED, because `Path` is.
|
|
785
|
-
*
|
|
786
|
-
* `Path` is `WindowsPath` on Windows and `PosixPath` everywhere else, and the two parsers
|
|
787
|
-
* disagree about the two things below. A body that is right on one host is therefore wrong
|
|
788
|
-
* on the other, which is exactly how this one passed a whole Windows suite and failed two
|
|
789
|
-
* cases on the first Linux run of `tests/test_pure_function_parity.py`:
|
|
790
|
-
*
|
|
791
|
-
* * SEPARATORS. `ntpath` treats `\` and `/` alike; `posixpath` treats `\` as an ordinary
|
|
792
|
-
* filename character. `str(PurePosixPath('C:\\x\\bin\\'))` is that string VERBATIM —
|
|
793
|
-
* one component, trailing backslash and all — where splitting on both answered
|
|
794
|
-
* `C:/x/bin`. A backslash is legal in a Linux filename, so this is not academic.
|
|
795
|
-
* * THE DOUBLE-SLASH ROOT. POSIX gives EXACTLY two leading slashes an
|
|
796
|
-
* implementation-defined meaning and `PurePosixPath` preserves them; three or more
|
|
797
|
-
* collapse to one. `path.posix.parse` reports a one-character root for every case, so
|
|
798
|
-
* `//server/share` came back as `/server/share`.
|
|
799
|
-
*
|
|
800
|
-
* NOT reproduced, and named here rather than left as a gap nobody wrote down: `//` and
|
|
801
|
-
* `///x` on WINDOWS, where `PureWindowsPath` answers `\\` and `\\\x` and this answers `\`
|
|
802
|
-
* and `\x`. Those are incomplete UNC prefixes and matching them needs ntpath's share-name
|
|
803
|
-
* parsing; both inputs are kept OUT of the corpus in `tests/test_pure_function_parity.py`
|
|
804
|
-
* instead of sitting in it wrong.
|
|
805
|
-
*
|
|
806
|
-
* The root's separators are still rewritten ONE AT A TIME, never `replace(/[\\/]+/g, sep)`:
|
|
807
|
-
* a UNC root's leading pair is part of it, so collapsing runs turns `//server/share/x` into
|
|
808
|
-
* `\server\share\x`.
|
|
809
|
-
*/
|
|
810
|
-
export function pyPathStr(s) {
|
|
811
|
-
const win = path.sep === '\\';
|
|
812
|
-
let raw = path.parse(s).root;
|
|
813
|
-
if (!win && raw && /^\/\/(?!\/)/.test(s)) raw = '//';
|
|
814
|
-
const root = raw.replace(/[\\/]/g, path.sep);
|
|
815
|
-
const seps = win ? /[\\/]+/ : /\/+/;
|
|
816
|
-
const parts = s.slice(raw.length).split(seps).filter((p) => p !== '' && p !== '.');
|
|
817
|
-
const tail = parts.join(path.sep);
|
|
818
|
-
if (!root) return tail || '.';
|
|
819
|
-
return root + tail;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/**
|
|
823
|
-
* `urllib.parse.unquote(s)` — percent-decoding as Python does it, which is NOT
|
|
824
|
-
* `decodeURIComponent`.
|
|
825
|
-
*
|
|
826
|
-
* The whole difference is what happens to a sequence that is not an escape.
|
|
827
|
-
* `decodeURIComponent('a%ZZ')` throws a `URIError`, and so does a trailing bare `%`; the
|
|
828
|
-
* web shell would answer both as a 500 where the reference answers a 404 naming the
|
|
829
|
-
* literal text. `unquote` leaves them alone: CPython builds a table of every two-hex-digit
|
|
830
|
-
* pair in both cases and treats a `KeyError` as "this was not an escape", emitting `%` and
|
|
831
|
-
* the rest of the fragment verbatim.
|
|
832
|
-
*
|
|
833
|
-
* The outer split on `([\x00-\x7f]+)` is Python's too, and it is not decoration: only the
|
|
834
|
-
* ASCII runs are candidates for decoding, so a literal non-ASCII character already in the
|
|
835
|
-
* path is passed through rather than mixed into the byte buffer. Consecutive escapes ARE
|
|
836
|
-
* accumulated before decoding, which is what makes `%C3%A9` one `é` and not two
|
|
837
|
-
* replacement characters.
|
|
838
|
-
*
|
|
839
|
-
* `Buffer.toString('utf-8')` substitutes U+FFFD for an undecodable sequence, which is
|
|
840
|
-
* `errors='replace'`. The two decoders can disagree on HOW MANY replacement characters a
|
|
841
|
-
* truncated multi-byte sequence yields — Python's maximal-subpart rule against WHATWG's —
|
|
842
|
-
* and the corpus in `tests/test_pure_function_parity.py` is where that is measured rather
|
|
843
|
-
* than assumed.
|
|
844
|
-
*/
|
|
845
|
-
const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
|
|
846
|
-
|
|
847
|
-
function unquoteToBytes(run) {
|
|
848
|
-
const bits = run.split('%');
|
|
849
|
-
if (bits.length === 1) return Buffer.from(run, 'latin1');
|
|
850
|
-
const out = [Buffer.from(bits[0], 'latin1')];
|
|
851
|
-
for (const item of bits.slice(1)) {
|
|
852
|
-
const pair = item.slice(0, 2);
|
|
853
|
-
if (HEX_PAIR.test(pair)) {
|
|
854
|
-
out.push(Buffer.from([parseInt(pair, 16)]), Buffer.from(item.slice(2), 'latin1'));
|
|
855
|
-
} else {
|
|
856
|
-
out.push(Buffer.from(`%${item}`, 'latin1'));
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
return Buffer.concat(out);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
export function pyUnquote(s) {
|
|
863
|
-
if (!s.includes('%')) return s;
|
|
864
|
-
// `re.split` with ONE capture group alternates [gap, group, gap, group, …], so the ASCII
|
|
865
|
-
// runs are the odd indices on both sides.
|
|
866
|
-
const bits = s.split(/([\x00-\x7f]+)/);
|
|
867
|
-
let res = bits[0];
|
|
868
|
-
for (let i = 1; i < bits.length; i += 2) {
|
|
869
|
-
res += unquoteToBytes(bits[i]).toString('utf-8');
|
|
870
|
-
res += bits[i + 1] ?? '';
|
|
871
|
-
}
|
|
872
|
-
return res;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
/**
|
|
876
|
-
* Python's `\s` for a `str` pattern, as a character-class BODY — measured, not recalled.
|
|
877
|
-
*
|
|
878
|
-
* The two languages' whitespace classes are nearly the same and the difference had been
|
|
879
|
-
* carried as a standing item reading "differing from JS `\s` only at U+FEFF". That was
|
|
880
|
-
* incomplete in both directions, and a corpus over `slugifyHeading` is what found it:
|
|
881
|
-
*
|
|
882
|
-
* python \s = js \s - U+FEFF + U+001C..U+001F + U+0085
|
|
883
|
-
*
|
|
884
|
-
* JavaScript counts the byte-order mark as whitespace and Python does not; Python counts
|
|
885
|
-
* the four C0 information separators and the NEL, and JavaScript does not. Neither shows
|
|
886
|
-
* up in a docs heading anyone has written — which is exactly why it took a corpus rather
|
|
887
|
-
* than a cell, and why it is spelled out here rather than approximated with `\s`.
|
|
888
|
-
*/
|
|
889
|
-
export const PY_SPACE = '\t\n\v\f\r \u001c-\u001f\u0085\u00a0\u1680'
|
|
890
|
-
+ '\u2000-\u200a\u2028\u2029\u202f\u205f\u3000';
|
|
891
|
-
|
|
892
|
-
const PY_STRIP_RE = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'g');
|
|
893
|
-
const PY_LSTRIP_RE = new RegExp(`^[${PY_SPACE}]+`);
|
|
894
|
-
const PY_RSTRIP_RE = new RegExp(`[${PY_SPACE}]+$`);
|
|
895
|
-
|
|
896
|
-
/** `str.strip()` — Python's whitespace set, which `String.trim()`'s is not. */
|
|
897
|
-
export function pyStripSpace(s) {
|
|
898
|
-
return s.replace(PY_STRIP_RE, '');
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
/**
|
|
902
|
-
* `str.lstrip()` / `str.rstrip()` — the ONE-SIDED halves, over the same `PY_SPACE`.
|
|
903
|
-
*
|
|
904
|
-
* P2 added them for `_build_render._insert_theme_keys`, which uses all three spellings within
|
|
905
|
-
* four lines (`lines[0].strip()`, `ln.lstrip().startswith('"')`,
|
|
906
|
-
* `lines[pred].rstrip().endswith(",")`). They are here rather than in `js/themes.mjs` for the
|
|
907
|
-
* reason this file exists at all: `js/settings.mjs` already carries a private second copy of
|
|
908
|
-
* the character class, and a THIRD one — in the module that decides where a comma goes in a
|
|
909
|
-
* committed theme file — is how the three drift apart. One owner of the set, three views of it.
|
|
910
|
-
*/
|
|
911
|
-
export function pyLStripSpace(s) {
|
|
912
|
-
return s.replace(PY_LSTRIP_RE, '');
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
export function pyRStripSpace(s) {
|
|
916
|
-
return s.replace(PY_RSTRIP_RE, '');
|
|
917
|
-
}
|