geneseed 3.1.1 → 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.
Files changed (98) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/DESIGN.md +15 -15
  3. package/README.md +1 -1
  4. package/SETUP.md +1 -1
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +111 -83
  11. package/harness.config.json +1 -1
  12. package/js/README.md +203 -0
  13. package/js/build/bundle.mjs +231 -0
  14. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  15. package/js/build/emit-claude.mjs +287 -0
  16. package/js/build/emit-common.mjs +255 -0
  17. package/js/build/emit-opencode.mjs +214 -0
  18. package/js/{generate.mjs → build/generate.mjs} +27 -28
  19. package/js/{render.mjs → build/render.mjs} +13 -9
  20. package/js/{checkout.mjs → build/source.mjs} +9 -9
  21. package/js/build/stubs.mjs +256 -0
  22. package/js/{themes.mjs → build/themes.mjs} +24 -24
  23. package/js/build/version.mjs +155 -0
  24. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  25. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  26. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  27. package/js/{link.mjs → hosts/link.mjs} +29 -28
  28. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  29. package/js/{native.mjs → hosts/native.mjs} +24 -22
  30. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  31. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  32. package/js/inspect/checks-authoring.mjs +761 -0
  33. package/js/inspect/checks-build.mjs +231 -0
  34. package/js/inspect/checks-repo.mjs +308 -0
  35. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  36. package/js/inspect/doctor.mjs +286 -0
  37. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  38. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  39. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  40. package/js/inspect/scan.mjs +125 -0
  41. package/js/{status.mjs → inspect/status.mjs} +27 -25
  42. package/js/inspect/validate.mjs +199 -0
  43. package/js/lib/fs.mjs +37 -759
  44. package/js/lib/json.mjs +344 -0
  45. package/js/lib/paths.mjs +242 -0
  46. package/js/lib/proc.mjs +3 -3
  47. package/js/lib/text.mjs +181 -0
  48. package/js/lib/udiff.mjs +1 -1
  49. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  50. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  51. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  52. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  53. package/js/{update.mjs → maintain/update.mjs} +51 -51
  54. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  55. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  56. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  57. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  58. package/js/web/actions.mjs +84 -85
  59. package/js/web/activity.mjs +22 -21
  60. package/js/web/api.mjs +72 -50
  61. package/js/web/daemon.mjs +319 -0
  62. package/js/web/docs.mjs +24 -21
  63. package/js/web/graph.mjs +0 -0
  64. package/js/web/handler.mjs +442 -0
  65. package/js/web/jobs.mjs +21 -19
  66. package/js/web/routes.mjs +223 -0
  67. package/js/web/server.mjs +56 -987
  68. package/package.json +11 -3
  69. package/src/doctrines/README.md +1 -1
  70. package/themes/opencode/README.md +1 -1
  71. package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
  72. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
  73. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
  74. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
  75. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
  76. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
  77. package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
  78. package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
  79. package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
  80. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
  81. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
  82. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
  83. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
  84. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
  85. package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
  86. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
  87. package/web/dist/assets/index-BE226Gv6.css +1 -0
  88. package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
  89. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  90. package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
  91. package/web/dist/index.html +2 -2
  92. package/web/src/pages/Laws.jsx +1 -1
  93. package/docs/declined.md +0 -200
  94. package/docs/limits.md +0 -329
  95. package/js/doctor.mjs +0 -1750
  96. package/js/emit.mjs +0 -1411
  97. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  98. package/web/dist/assets/index-CH_Z725C.js +0 -12
package/js/lib/fs.mjs CHANGED
@@ -1,32 +1,34 @@
1
1
  /**
2
- * The primitives — how this tool reads, writes, quotes, compares and prints.
2
+ * Reading and writing — files on disk, and the two process streams.
3
3
  *
4
- * These are the definitions, not an adaptation of somebody else's. Every rule below was
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
- * The `py`-prefixed export names are kept on purpose. Each marks a function whose contract
12
- * is a language primitive's exact behaviour rather than a convenience wrapper, and that mark
13
- * is what stops a later reader "simplifying" one back to the Node default it deliberately is
14
- * not. For the same reason the per-function comments still name what was measured: that
15
- * provenance is the only surviving record of WHY a rule is the shape it is, and it is the
16
- * first thing to read before touching one.
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
- accessSync, appendFileSync, constants, writeFileSync, readFileSync, copyFileSync, statSync,
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 pyPrint = (s) => process.stdout.write(xlate(s));
513
- export const pyPrintErr = (s) => process.stderr.write(xlate(s));
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 `pyPrintErr`, whose writes it intercepts, when the hook became the
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/geneseed.mjs`'s
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 `pyPrint`. The generator
553
- * prints from ~25 sites across `bin/geneseed.mjs`, `js/emit.mjs`, `js/settings.mjs`,
554
- * `js/opencode.mjs`, `js/native.mjs` and `js/render.mjs`, and those modules are ALSO the
555
- * body of the `GENESEED_NO_JS` seam child, whose `main` buffers both streams and hands them
556
- * to a Python parent that re-prints them through `print()`. Converting those sites to
557
- * `pyPrint` would translate once in Node and again in Python — `\r\r\n` on every line, on the
558
- * path `tests/golden.py` drives 259 times. So the translation belongs to whichever process
559
- * OWNS the stream, and the seam child does not own it. This wraps the driver's `main`, and
560
- * only the driver's `main`.
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 withPyNewlines(fn) {
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
- }