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.
Files changed (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -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 +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.js +0 -12
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Quoting and the parsed-value model — `json.dumps`, `json.loads`, and the `str`/`repr`
3
+ * rules that decide what a parsed number renders as.
4
+ *
5
+ * These are definitions, not an adaptation of somebody else's; see `fs.mjs` for why the
6
+ * measurements against CPython are recorded per function, and why NONE of these is the Node
7
+ * default — the warning the retired `py` prefix used to carry now lives in each docblock.
8
+ * A rule here is a frozen fact about what this tool emits — the corpora under
9
+ * `tests/__snapshots__/` compare it byte for byte and can never be re-recorded.
10
+ *
11
+ * `JsonNumber` is why this module holds both the parser and the renderers: the wrapper is
12
+ * created by `parseJson` and read by `formatValue`, `formatRepr`, `isTruthy` and `deepEquals`, so the
13
+ * four cannot be separated from it without a cycle.
14
+ */
15
+ /**
16
+ * A number as `json.loads` produced it, carrying the literal it was parsed from.
17
+ *
18
+ * `json.loads` yields an `int` for `20` and a `float` for `1.0`, and Python's `str()`
19
+ * renders those as `20` and `1.0`. `JSON.parse` collapses both to the IEEE double `1`,
20
+ * and `String(1.0)` is `"1"` — so an `agent-overrides.json` carrying `"temperature": 1.0`
21
+ * emits `temperature: 1.0` from Python and `temperature: 1` from Node.
22
+ *
23
+ * That divergence WAS invisible to the golden harness — the emit always writes an EMPTY
24
+ * overrides stub, so no golden cell ever had an override to render, and mutation M6 stood
25
+ * ungated for two phases because of it. What holds it now is
26
+ * `tests/unit/lib_primitives.test.mjs`, which round-trips `{"temperature": 1.0}` and requires
27
+ * the `1.0` back.
28
+ */
29
+ class JsonNumber {
30
+ constructor(value, source) { this.value = value; this.source = source; }
31
+ valueOf() { return this.value; }
32
+ toString() { return formatValue(this); }
33
+
34
+ /**
35
+ * Re-serialising parsed JSON must round-trip the way Python's does. Without this,
36
+ * `JSON.stringify` would walk the wrapper and emit `{"value":1,"source":"1.0"}`.
37
+ * `JSON.rawJSON` (Node >= 21) writes the text verbatim, and `formatValue` supplies the text
38
+ * Python would have \u2014 NOT the original literal, because `json.dumps(json.loads('1.50'))`
39
+ * is `1.5`: Python re-formats through repr rather than echoing the source.
40
+ */
41
+ toJSON() { return JSON.rawJSON(formatValue(this)); }
42
+ }
43
+
44
+ /**
45
+ * `json.loads(text)`, preserving the int/float distinction (see `JsonNumber`).
46
+ *
47
+ * Uses the reviver's `context.source` (Node >= 21). Where it is unavailable the numbers
48
+ * come back bare and `formatValue` throws rather than formatting them wrongly — a loud failure
49
+ * on an old runtime beats bytes that differ from Python's only for some values.
50
+ */
51
+ export function parseJson(text) {
52
+ return JSON.parse(text, function (key, value, context) {
53
+ return typeof value === 'number' && context && typeof context.source === 'string'
54
+ ? new JsonNumber(value, context.source)
55
+ : value;
56
+ });
57
+ }
58
+
59
+ /** `repr(float)` — the half of `formatValue` where the two languages disagree most. */
60
+ function formatFloat(n) {
61
+ if (Number.isNaN(n)) return 'nan';
62
+ if (!Number.isFinite(n)) return n > 0 ? 'inf' : '-inf';
63
+ const a = Math.abs(n);
64
+ if (a !== 0 && (a >= 1e16 || a < 1e-4)) {
65
+ // Python leaves positional notation outside [1e-4, 1e16); JS's own thresholds are
66
+ // 1e-6 and 1e21, and it writes a one-digit exponent where Python pads to two.
67
+ const [mantissa, exp] = n.toExponential().split('e');
68
+ return `${mantissa}e${exp[0] === '-' ? '-' : '+'}${exp.slice(1).padStart(2, '0')}`;
69
+ }
70
+ // Both engines print the shortest round-tripping decimal, but JS drops the fractional
71
+ // part of an integral value: `String(1.0)` is '1' where `repr(1.0)` is '1.0'. And
72
+ // `String(-0)` is '0' where `repr(-0.0)` keeps the sign.
73
+ const s = Object.is(n, -0) ? '-0' : String(n);
74
+ return s.includes('.') || s.includes('e') ? s : `${s}.0`;
75
+ }
76
+
77
+ /**
78
+ * `str(value)` for a value that came out of `json.loads` — what an f-string interpolates.
79
+ *
80
+ * Only the types the generator actually renders are handled. A list, dict or bool reaching
81
+ * here would print as `[object Object]` / `true` where Python prints `[1, 2]` / `True`, so
82
+ * it throws instead: an override file with a value nobody has decided the semantics for is
83
+ * a question to answer, not a byte to guess at.
84
+ */
85
+ export function formatValue(value) {
86
+ if (typeof value === 'string') return value;
87
+ if (value instanceof JsonNumber) {
88
+ // `json.decoder` picks parse_float for any literal carrying a fraction or an exponent
89
+ // and parse_int otherwise — so the literal, not the resulting double, is what decides.
90
+ // Ints are exact in Python at any width; BigInt keeps that (and normalises `-0`).
91
+ return /[.eE]/.test(value.source) ? formatFloat(value.value) : BigInt(value.source).toString();
92
+ }
93
+ if (typeof value === 'number') {
94
+ throw new TypeError(`formatValue got a bare number (${value}); parse JSON with parseJson so `
95
+ + `int 1 and float 1.0 stay distinguishable, as they are in Python`);
96
+ }
97
+ throw new TypeError(`formatValue has no Python-agreeing rendering for ${typeof value}`);
98
+ }
99
+
100
+ /**
101
+ * Python truthiness — `if value:` — for a value that came out of `json.loads`.
102
+ *
103
+ * `Boolean()` is not it, and the gap is not academic: a `JsonNumber` is an OBJECT, so
104
+ * `Boolean(JsonNumber(0))` is `true` where Python's `if 0` is false. In the other direction
105
+ * `Number('0')` is `0` where Python's `if "0"` is true. Both spellings appear in the
106
+ * override tests, so neither shortcut is safe.
107
+ */
108
+ export function isTruthy(value) {
109
+ if (value === undefined || value === null || value === false) return false;
110
+ if (typeof value === 'string') return value !== '';
111
+ if (value instanceof JsonNumber) return value.value !== 0;
112
+ if (Array.isArray(value)) return value.length > 0;
113
+ if (typeof value === 'object') return Object.keys(value).length > 0;
114
+ return Boolean(value);
115
+ }
116
+
117
+ /**
118
+ * `repr(value)` for the values a theme or an override file can hold.
119
+ *
120
+ * Python quotes strings with `'`, switching to `"` only when the string contains a `'`
121
+ * and no `"`, and spells the three singletons with a capital. Anything else is a
122
+ * container, whose repr differs structurally; guessing at a rendering would be worse
123
+ * than failing, so it falls through to `formatValue`.
124
+ *
125
+ * Lives here rather than beside its first caller because `formatReprAscii` below is the same
126
+ * function with one more escaping rule, and two copies of the quote choice would be two
127
+ * things to keep in agreement.
128
+ *
129
+ * Containers ARE rendered, unlike in `formatValue`, and the difference is not an inconsistency.
130
+ * `formatValue` interpolates into an emitted file, where a shape nobody decided the semantics
131
+ * for must be a question rather than a guessed byte. `repr` renders a value into a WARNING
132
+ * about that very value — a list in `.geneseed-srcdirs.json` is precisely what the warning
133
+ * exists to report, and throwing there would turn "this file is corrupt" into a crash. The
134
+ * value always came from JSON, so the type set is closed and the rendering is exact.
135
+ */
136
+ function formatReprImpl(v, ascii) {
137
+ if (typeof v === 'string') {
138
+ let body = '';
139
+ for (const ch of v) { // by CODE POINT: `\U########` is one escape
140
+ const cp = ch.codePointAt(0);
141
+ if (ch === '\\') body += '\\\\';
142
+ else if (ch === '\n') body += '\\n';
143
+ else if (ch === '\r') body += '\\r';
144
+ else if (ch === '\t') body += '\\t';
145
+ else if (cp < 0x20 || cp === 0x7f) body += `\\x${cp.toString(16).padStart(2, '0')}`;
146
+ else if (cp < 0x7f) body += ch;
147
+ else if (!ascii) body += ch; // repr() keeps printable non-ASCII verbatim
148
+ else if (cp <= 0xff) body += `\\x${cp.toString(16).padStart(2, '0')}`;
149
+ else if (cp <= 0xffff) body += `\\u${cp.toString(16).padStart(4, '0')}`;
150
+ else body += `\\U${cp.toString(16).padStart(8, '0')}`;
151
+ }
152
+ // The quote choice reads the ESCAPED body, which is safe: no escape sequence above
153
+ // introduces a quote character, so a `'` or `"` in it came from the input.
154
+ return body.includes("'") && !body.includes('"')
155
+ ? `"${body}"`
156
+ : `'${body.replaceAll("'", "\\'")}'`;
157
+ }
158
+ if (v === null) return 'None';
159
+ if (v === true) return 'True';
160
+ if (v === false) return 'False';
161
+ if (Array.isArray(v)) return `[${v.map((x) => formatReprImpl(x, ascii)).join(', ')}]`;
162
+ if (typeof v === 'object' && !(v instanceof JsonNumber)) {
163
+ // JSON object keys are always strings, so `repr(key)` is the string branch above.
164
+ return `{${Object.entries(v)
165
+ .map(([k, x]) => `${formatReprImpl(k, ascii)}: ${formatReprImpl(x, ascii)}`)
166
+ .join(', ')}}`;
167
+ }
168
+ return formatValue(v);
169
+ }
170
+
171
+ export function formatRepr(v) {
172
+ return formatReprImpl(v, false);
173
+ }
174
+
175
+ /**
176
+ * `ascii(value)` — `repr()` with every non-ASCII character escaped as well.
177
+ *
178
+ * Used where a warning quotes a value that came out of a file a user (or another tool)
179
+ * may have written: `build`'s prune guard names the suspicious dir name it refused to
180
+ * delete from `.geneseed-srcdirs.json`. Escaping is the point — a dir name carrying a
181
+ * newline or a right-to-left override must be readable in the warning, not act on the
182
+ * terminal, and Python already made that choice by spelling it `ascii()` rather than an
183
+ * f-string.
184
+ *
185
+ * The two share ONE implementation on purpose. The first draft gave `formatReprAscii` its own
186
+ * container and quote handling, and a mutation exposed the consequence immediately:
187
+ * `formatRepr`'s container branches became unreachable, so half of the code was dead and
188
+ * neither copy's escaping could drift without the other silently keeping the gate green.
189
+ */
190
+ export function formatReprAscii(v) {
191
+ return formatReprImpl(v, true);
192
+ }
193
+
194
+ /**
195
+ * Python's `==` for values that came out of `json.loads` \u2014 the operator behind
196
+ * `group in arr`, `rec in canon_flat` and `arr.remove(group)`.
197
+ *
198
+ * `===` is identity for containers, so a naive port of `if group in arr` never matches and
199
+ * `_merge_claude_settings` re-adds its own hook group on every emit \u2014 the exact defect the
200
+ * prior-claim pruning exists to prevent, reintroduced by a one-word translation.
201
+ *
202
+ * `JsonNumber` compares by its VALUE, which is right: Python's `{"a": 1} == {"a": 1.0}` is
203
+ * True even though `repr` renders the two differently. The one gap left is Python's
204
+ * `1 == True`; a settings.json holding a bool where the manifest recorded a number is not
205
+ * a shape any writer here produces.
206
+ */
207
+ export function deepEquals(a, b) {
208
+ const x = a instanceof JsonNumber ? a.value : a;
209
+ const y = b instanceof JsonNumber ? b.value : b;
210
+ if (x === null || y === null || typeof x !== 'object' || typeof y !== 'object') {
211
+ return x === y;
212
+ }
213
+ if (Array.isArray(x) !== Array.isArray(y)) return false;
214
+ if (Array.isArray(x)) {
215
+ return x.length === y.length && x.every((v, i) => deepEquals(v, y[i]));
216
+ }
217
+ const kx = Object.keys(x);
218
+ const ky = Object.keys(y);
219
+ return kx.length === ky.length
220
+ && kx.every((k) => Object.prototype.hasOwnProperty.call(y, k) && deepEquals(x[k], y[k]));
221
+ }
222
+
223
+ /** `value in list` and `list.index(value)` under `deepEquals` \u2014 Python's `in`, not `includes`. */
224
+ export function indexOfDeepEqual(arr, value) {
225
+ return arr.findIndex((v) => deepEquals(v, value));
226
+ }
227
+
228
+ /**
229
+ * `json.dumps(s)` for a STRING, with Python's default `ensure_ascii=True`.
230
+ *
231
+ * Every emitted `description:` line is built this way, and 44-50 of them per theme carry
232
+ * an em dash today: Python writes `"a — b"`, `JSON.stringify` writes `"a — b"`. The
233
+ * two agree on everything below U+007F — including the control-character table
234
+ * (`\b \t \n \f \r`, then lowercase `\u00xx`) — so escaping the rest is the whole delta.
235
+ * Astral characters fall out correctly without a special case: JS strings are UTF-16, so
236
+ * the surrogate pair is escaped one code unit at a time, which is exactly what Python
237
+ * emits (`😀`).
238
+ *
239
+ * Strings only, and loudly so — a COMPACT container is the one shape that genuinely
240
+ * differs: Python's default separators are `(', ', ': ')`, so it writes `{"a": 1, "b": 2}`
241
+ * where `JSON.stringify` writes `{"a":1,"b":2}`. Indented containers do NOT have that
242
+ * problem (see `jsonDumpsIndent`), which is why the split is compact-vs-indented rather
243
+ * than string-vs-container.
244
+ */
245
+ export function jsonDumps(s) {
246
+ if (typeof s !== 'string') {
247
+ throw new TypeError('jsonDumps takes a string; a COMPACT container differs from '
248
+ + "json.dumps by Python's ', ' / ': ' separators too \u2014 use jsonDumpsIndent or "
249
+ + 'jsonDumpsCompact');
250
+ }
251
+ return escapeNonAscii(JSON.stringify(s));
252
+ }
253
+
254
+ /**
255
+ * `ensure_ascii=True`: escape everything Python's encoder does and JS's does not.
256
+ *
257
+ * Applied to the SERIALISED text, which is safe because a JSON document can only carry
258
+ * non-ASCII inside string literals. Spelled with \\u escapes, never literals: a JS source
259
+ * file carrying a raw U+2028 or U+2029 terminates the line it sits on, and the parse
260
+ * error points somewhere else entirely.
261
+ */
262
+ function escapeNonAscii(text) {
263
+ return text.replace(/[\u007f-\uffff]/g,
264
+ (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
265
+ }
266
+
267
+ /**
268
+ * `json.dumps(obj, indent=2)` \u2014 and with `{ ensureAscii: false }`, the
269
+ * `ensure_ascii=False` variant. Those are the only two container shapes the generator
270
+ * writes; measured across all 27 `json.dumps` call sites.
271
+ *
272
+ * Indentation is what makes this tractable: passing `indent` switches Python's
273
+ * separators from `(', ', ': ')` to `(',', ': ')`, which is exactly what
274
+ * `JSON.stringify(v, null, 2)` emits. Verified rather than argued \u2014
275
+ * `tests/test_opencode_extras_parity.py` compares both variants against Python over a
276
+ * corpus of container shapes.
277
+ */
278
+ export function jsonDumpsIndent(value, { ensureAscii = true } = {}) {
279
+ const text = JSON.stringify(value, null, 2);
280
+ return ensureAscii ? escapeNonAscii(text) : text;
281
+ }
282
+
283
+ /**
284
+ * `json.dumps(obj)` and `json.dumps(obj, sort_keys=True)` — the COMPACT container form.
285
+ *
286
+ * Deferred through three phases because guessing its signature would have been worse than
287
+ * not having it; `js/hosts/settings.mjs` is the code that finally needs it, and the shapes were
288
+ * counted with `ast` rather than assumed: four bare `json.dumps(container)` and two
289
+ * `sort_keys=True`, all inside `_build_settings.py`, none of them `ensure_ascii=False`.
290
+ *
291
+ * It cannot delegate to `JSON.stringify` the way `jsonDumpsIndent` does. Without an indent
292
+ * Python's separators are `(', ', ': ')` — `{"a": 1, "b": [1, 2]}` where `JSON.stringify`
293
+ * writes `{"a":1,"b":[1,2]}` — and patching the separators back into the serialised text
294
+ * would corrupt any string containing `,` or `:`. So the walk is written out.
295
+ *
296
+ * `sortKeys` sorts with JS's default comparator (UTF-16 code units) where Python sorts by
297
+ * code point; the two disagree only above the BMP, which no settings.json key reaches. A
298
+ * non-finite number throws rather than emitting `NaN`/`Infinity`: Python's encoder writes
299
+ * those bare tokens, which is not valid JSON, and no caller here can produce one.
300
+ *
301
+ * `bareInts` admits a BARE JS number, reading it as a Python `int`. `formatValue` refuses one by
302
+ * default and is right to: a value that came out of `json.loads` carries the int/float
303
+ * distinction and a bare double has lost it. But the caller it was written for is `sendJson`
304
+ * in `js/web/handler.mjs`, whose values are COMPUTED — counts, a port, a pid, a unix second
305
+ * — every one of them an integer, which both languages render identically. The flag is where
306
+ * that claim is stated. It has two further callers now, and neither is an endpoint:
307
+ * `js/web/jobs.mjs` writes the job history and `js/web/activity.mjs` the activity record. A
308
+ * non-integral value still renders through `repr(float)`, which is the honest reading of
309
+ * `0.5`; only an INTEGRAL float (`1.0` against JS's `1`) is unrepresentable, and it is
310
+ * unrepresentable whatever this flag says.
311
+ */
312
+ export function jsonDumpsCompact(value, { sortKeys = false, ensureAscii = true,
313
+ bareInts = false } = {}) {
314
+ const text = compactImpl(value, sortKeys, bareInts);
315
+ return ensureAscii ? escapeNonAscii(text) : text;
316
+ }
317
+
318
+ function compactImpl(v, sortKeys, bareInts) {
319
+ if (v === null || v === undefined) return 'null';
320
+ if (v === true) return 'true';
321
+ if (v === false) return 'false';
322
+ if (typeof v === 'string') return JSON.stringify(v);
323
+ if (v instanceof JsonNumber || typeof v === 'number') {
324
+ const n = v instanceof JsonNumber ? v.value : v;
325
+ if (!Number.isFinite(n)) {
326
+ throw new TypeError(`jsonDumpsCompact got ${n}; Python's encoder writes a bare `
327
+ + 'NaN/Infinity token there, which is not JSON, and nothing here can produce one');
328
+ }
329
+ if (bareInts && typeof v === 'number') {
330
+ return Number.isSafeInteger(n) ? String(n) : formatFloat(n);
331
+ }
332
+ return formatValue(v);
333
+ }
334
+ if (Array.isArray(v)) {
335
+ return `[${v.map((x) => compactImpl(x, sortKeys, bareInts)).join(', ')}]`;
336
+ }
337
+ if (typeof v === 'object') {
338
+ const keys = sortKeys ? Object.keys(v).sort() : Object.keys(v);
339
+ return `{${keys.map((k) => `${JSON.stringify(k)}: ${compactImpl(v[k], sortKeys, bareInts)}`)
340
+ .join(', ')}}`;
341
+ }
342
+ throw new TypeError(`jsonDumpsCompact has no rendering for ${typeof v}`);
343
+ }
344
+
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Comparing and locating — how a path is case-folded, ordered, contained, spelled and found.
3
+ *
4
+ * These are definitions, not an adaptation of somebody else's; see `fs.mjs` for why the
5
+ * measurements against CPython are recorded per function, and why NONE of these is the Node
6
+ * default — the warning the retired `py` prefix used to carry now lives in each docblock.
7
+ * Path semantics are the most PLATFORM-SHAPED rules in the tool — several of these have a
8
+ * different right answer on Windows and on POSIX, and each says which.
9
+ */
10
+ import { accessSync, constants, statSync } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ const { X_OK } = constants;
14
+ /**
15
+ * `PurePath._str_normcase` \u2014 Windows compares and sorts paths case-folded, POSIX does
16
+ * not. This is what makes `sorted(SRC.rglob('*'))` and `sorted(d.glob('*.json'))`
17
+ * platform-dependent, and the order is observable: the prompt emitter embeds items in
18
+ * order, and each emit's written list becomes the ownership manifest the prune diffs.
19
+ *
20
+ * Two residual differences, both unreachable with this repo's ASCII filenames: Python
21
+ * compares by code point where JS compares by UTF-16 code unit (differs only above the
22
+ * BMP), and `str.lower()` is not `toLowerCase()` for a handful of characters.
23
+ *
24
+ * THE SEPARATOR CONVERSION IS NOT OPTIONAL, and it was missing until the byte-bearing
25
+ * corpus in `tests/test_pure_function_parity.py` asked. `ntpath.normcase` is
26
+ * `s.replace('/', '\\').lower()`, and `PurePath._str_normcase` gets the same conversion for
27
+ * free because `Path` has already done it — so BOTH readings of the reference fold slashes
28
+ * and this did not. Every sort call site feeds it `path.join` output or a bare `readdir`
29
+ * name, which is why no emitted byte ever moved; `fnTranslate(normcase(pattern))` in
30
+ * `js/hosts/hooks.mjs` is where it was reachable, because an exclude pattern is USER-WRITTEN and
31
+ * `memory/*` never matched a native `memory\x` the way `fnmatch` does.
32
+ */
33
+ export const normcase = process.platform === 'win32'
34
+ ? (s) => s.replaceAll('/', '\\').toLowerCase()
35
+ : (s) => s;
36
+
37
+ /**
38
+ * `sorted()` over paths \u2014 one owner, so two call sites cannot drift apart.
39
+ *
40
+ * COMPONENT-WISE, and that was a flat string compare until P2 measured it. `PurePath.__lt__`
41
+ * compares `_parts_normcase` \u2014 a TUPLE of path components \u2014 so the separator never takes part
42
+ * in the comparison, and the two readings disagree in BOTH directions:
43
+ *
44
+ * sorted : skills/geneseed/SKILL.md < skills/geneseed-code-review/SKILL.md
45
+ * flat cmp : the reverse, because '-' (0x2d) sorts before the separator '\\' (0x5c)
46
+ * sorted : x/a/b.md < x/a.md (component 'a' < 'a.md')
47
+ * flat cmp : the reverse again
48
+ *
49
+ * Nothing in the emitted tree could see it: `js/build/render.mjs` sorts a list whose members share
50
+ * a directory depth, and `golden.py` compared CONTENT rather than order. It became visible
51
+ * the first time a full path list was PRINTED \u2014 `geneseed validate -v` \u2014 against a real
52
+ * skills tree that happens to contain both `skills/geneseed/` and `skills/geneseed-code-review/`.
53
+ * `tests/test_maintainer_tools_parity.py` found it and died with the reference it compared
54
+ * against. What HOLDS it now is `tests/unit/maintainer_tools.test.mjs`, and specifically its
55
+ * `theme files are visited in THIS platform's Path collation order` test: it re-derives Python's
56
+ * `sorted(Path)` order for the running platform instead of replaying a recording, so it cannot
57
+ * agree with a drift \u2014 and it is the measured killer of this function's mutation (M3).
58
+ */
59
+ export function comparePaths(a, b) {
60
+ const A = normcase(a).split(/[\\/]/);
61
+ const B = normcase(b).split(/[\\/]/);
62
+ for (let i = 0; i < Math.min(A.length, B.length); i += 1) {
63
+ if (A[i] !== B[i]) return A[i] < B[i] ? -1 : 1;
64
+ }
65
+ return A.length - B.length;
66
+ }
67
+
68
+ /**
69
+ * `_harness_core._within` \u2014 containment through `normcase`, segment-wise.
70
+ *
71
+ * MOVED HERE IN P6i, from `js/web/api.mjs` where P6b first needed it. The Python is a harness
72
+ * CORE primitive (`child.relative_to(parent)` in a try/except), and its second caller is
73
+ * `_install_move_list` in `js/maintain/uninstall.mjs` \u2014 a CLI-level module that must not import the
74
+ * web tree to reach it. Beside `normcase`, which is the thing it is actually about.
75
+ * `js/web/api.mjs` re-exports it so its existing importers are unchanged.
76
+ */
77
+ export function within(child, parent) {
78
+ const c = normcase(child).split(/[\\/]/);
79
+ const p = normcase(parent).split(/[\\/]/);
80
+ return p.length <= c.length && p.every((seg, i) => c[i] === seg);
81
+ }
82
+
83
+ /**
84
+ * `str(PurePath(s))` — separators normalised to the platform's, `.` components dropped,
85
+ * duplicate separators collapsed, a trailing one removed.
86
+ *
87
+ * `path.normalize` is NOT this: it also collapses `a/../b` to `b`, and `PurePath` keeps the
88
+ * `..` because a symlinked `a` makes those two different directories. The distinction is
89
+ * live in `js/inspect/excludes.mjs`, where a hand-edited `excludes.json` entry is compared against a
90
+ * RESOLVED repo path — Python never matches a `..` entry there, and a normalising port would
91
+ * match it and unwire a file the reference would have left alone.
92
+ */
93
+ /**
94
+ * `str(Path(x))` — and it is PLATFORM-SHAPED, because `Path` is.
95
+ *
96
+ * `Path` is `WindowsPath` on Windows and `PosixPath` everywhere else, and the two parsers
97
+ * disagree about the two things below. A body that is right on one host is therefore wrong
98
+ * on the other, which is exactly how this one passed a whole Windows suite and failed two
99
+ * cases on the first Linux run of `tests/test_pure_function_parity.py`:
100
+ *
101
+ * * SEPARATORS. `ntpath` treats `\` and `/` alike; `posixpath` treats `\` as an ordinary
102
+ * filename character. `str(PurePosixPath('C:\\x\\bin\\'))` is that string VERBATIM —
103
+ * one component, trailing backslash and all — where splitting on both answered
104
+ * `C:/x/bin`. A backslash is legal in a Linux filename, so this is not academic.
105
+ * * THE DOUBLE-SLASH ROOT. POSIX gives EXACTLY two leading slashes an
106
+ * implementation-defined meaning and `PurePosixPath` preserves them; three or more
107
+ * collapse to one. `path.posix.parse` reports a one-character root for every case, so
108
+ * `//server/share` came back as `/server/share`.
109
+ *
110
+ * NOT reproduced, and named here rather than left as a gap nobody wrote down: `//` and
111
+ * `///x` on WINDOWS, where `PureWindowsPath` answers `\\` and `\\\x` and this answers `\`
112
+ * and `\x`. Those are incomplete UNC prefixes and matching them needs ntpath's share-name
113
+ * parsing; both inputs are kept OUT of the corpus in `tests/test_pure_function_parity.py`
114
+ * instead of sitting in it wrong.
115
+ *
116
+ * The root's separators are still rewritten ONE AT A TIME, never `replace(/[\\/]+/g, sep)`:
117
+ * a UNC root's leading pair is part of it, so collapsing runs turns `//server/share/x` into
118
+ * `\server\share\x`.
119
+ */
120
+ export function toPlatformPath(s) {
121
+ const win = path.sep === '\\';
122
+ let raw = path.parse(s).root;
123
+ if (!win && raw && /^\/\/(?!\/)/.test(s)) raw = '//';
124
+ const root = raw.replace(/[\\/]/g, path.sep);
125
+ const seps = win ? /[\\/]+/ : /\/+/;
126
+ const parts = s.slice(raw.length).split(seps).filter((p) => p !== '' && p !== '.');
127
+ const tail = parts.join(path.sep);
128
+ if (!root) return tail || '.';
129
+ return root + tail;
130
+ }
131
+
132
+ /**
133
+ * `pathlib.Path(s).is_absolute()`, which is NOT `path.isAbsolute(s)` on Windows.
134
+ *
135
+ * MEASURED against CPython 3.13, and the two disagree on exactly one shape: a ROOTLESS
136
+ * absolute path. `/x/AGENT.md` and `\x\AGENT.md` are `True` to `path.isAbsolute` and `False`
137
+ * to Python, which requires a DRIVE (`C:/x`) or a UNC root (`//server/share/x`) before it
138
+ * calls a Windows path absolute. `C:x/AGENT.md` — a drive-RELATIVE path — is False to both.
139
+ * On POSIX the two rules coincide and this is `s.startsWith('/')`.
140
+ *
141
+ * Written in P5h for `installAgentEntry`, which filters an OpenCode config's `instructions`
142
+ * for the entries that are NOT absolute in order to tell a project install's relative
143
+ * `…/AGENT.md` from a global install's absolute one. A hand-edited config naming
144
+ * `/repo/AGENT.md` is the reachable input: Python keeps that entry and Node would have
145
+ * skipped it and fallen through to the literal `AGENT.md`, unwiring nothing.
146
+ *
147
+ * ONE OWNER, and the five pre-existing `path.isAbsolute` calls in `js/` were READ rather than
148
+ * assumed to be copies. Three cannot reach the divergence — `js/inspect/doctor.mjs` and
149
+ * `bin/build-driver.mjs` test the output of `path.relative`, which is never rootless-absolute,
150
+ * and `js/build/bundle.mjs`'s `safePriorDirName` is `&&`-guarded by `basename(p) === p` immediately
151
+ * after. The two in `js/hosts/hooks.mjs` (a context entry's `path`) DO carry the same hazard and
152
+ * are deliberately not repointed here: that is the hook entry's shipped behaviour, no
153
+ * `context` cell varies it, and changing it in this phase would be an ungated edit to a verb
154
+ * that crossed three phases ago. Recorded in the spec's "Still owed" with this reproduction.
155
+ *
156
+ * Gated by a corpus in `tests/test_pure_function_parity.py`: no cell can vary the shape.
157
+ */
158
+ export function isAbsolutePath(s) {
159
+ if (process.platform !== 'win32') return s.startsWith('/');
160
+ // Python is absolute iff the path has a DRIVE (or UNC share) *and* a root — `C:x` has the
161
+ // drive and no root, `/x` has the root and no drive, and neither is absolute. In
162
+ // `path.parse` terms that is exactly "the root is longer than a bare separator AND ends in
163
+ // one": `C:` for `C:x`, `\` for `\x`, `C:\` for `C:\x`, `\\srv\share\` for a UNC path.
164
+ // The first draft tested `root !== '\\'`, which called `C:x` absolute; the corpus caught
165
+ // it on the two drive-relative cases and nothing else would have.
166
+ const root = path.parse(s).root;
167
+ return root.length > 1 && /[\\/]$/.test(root);
168
+ }
169
+
170
+ /**
171
+ * `shutil.which(cmd, path=None)` — the first executable named `cmd` on PATH, or null.
172
+ *
173
+ * `doctor` runs `node --check` over the OpenCode plugins and SKIPS the whole check when node
174
+ * is not on PATH. `process.execPath` is not that: it is always set, so a port that used it
175
+ * would run the check in a world where the reference silently declines to — the divergence a
176
+ * PATH-removal refutation is aimed at, and the reason this is a real lookup rather than one
177
+ * line of "we are node, we know where node is".
178
+ *
179
+ * The Windows half is the part with behaviour in it, and three of its four rules were
180
+ * MEASURED against CPython 3.13 rather than assumed. `PATHEXT` supplies the suffixes, and the
181
+ * returned path carries PATHEXT's OWN SPELLING of the extension rather than the file's
182
+ * (`zzcmd.CMD` for a `zzcmd.cmd` on disk). An extension already in that set means the command
183
+ * is spelled in full. An EMPTY PATH entry is not skipped either: `join('', cmd)` is a
184
+ * relative path, and Python answers with one.
185
+ *
186
+ * THE FOURTH RULE IS THE CURRENT DIRECTORY, and it was measured on a machine that could not
187
+ * see it. `shutil.which` prepends `os.curdir` to the search — even to a `path=` given
188
+ * explicitly — when `_winapi.NeedCurrentDirectoryForExePath` says so, and that Win32 call
189
+ * answers TRUE unless `NoDefaultCurrentDirectoryInExePath` is DEFINED in the environment.
190
+ * Git Bash and this repo's developer machine both define it, so the reference answered None
191
+ * there and the first draft's "the current directory is NOT prepended — it was until 3.12"
192
+ * described an environment variable it had mistaken for a version change. GitHub's Windows
193
+ * runner does not define it: the reference answered `.\\geneseed.CMD` for a `geneseed` looked
194
+ * up beside the repo root, this returned null, and the parity corpus said so on the first CI
195
+ * run. The variable is an INPUT, so it is read here rather than baked, and
196
+ * `test_the_curdir_rule_agrees_in_both_environment_states` runs the corpus with it both ways.
197
+ *
198
+ * Gated as a pure function over a seeded directory in `tests/test_pure_function_parity.py`:
199
+ * a cell can only ever observe the one answer this machine's PATH gives.
200
+ */
201
+ export function which(cmd, searchPath = null) {
202
+ const win = process.platform === 'win32';
203
+ const isExec = (p) => {
204
+ try {
205
+ if (!statSync(p).isFile()) return false;
206
+ accessSync(p, X_OK);
207
+ return true;
208
+ } catch { return false; }
209
+ };
210
+ // A command with a directory separator is not looked up at all — Python checks it as given.
211
+ if (cmd.includes('/') || (win && cmd.includes('\\'))) return isExec(cmd) ? cmd : null;
212
+ const exts = win
213
+ ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
214
+ : [];
215
+ const spelled = win && exts.some((e) => cmd.toLowerCase().endsWith(e.toLowerCase()));
216
+ // `os.path.join`, which CONCATENATES where `path.join` NORMALISES. The difference shows the
217
+ // moment a PATH entry is spelled with the other separator — routine on Windows, where Git
218
+ // Bash and MSYS put forward-slash entries in it — and the answer is the string a caller
219
+ // then spawns. Measured: the corpus case with a posix-spelled directory was the one that
220
+ // differed, and `path.join` had rewritten the whole path rather than appending to it.
221
+ const joinRaw = (dir, name) => (
222
+ dir === '' || /[\\/]$/.test(dir) || (win && dir.endsWith(':'))
223
+ ? dir + name : dir + path.sep + name);
224
+ const dirs = (searchPath ?? process.env.PATH ?? '').split(path.delimiter);
225
+ // `NeedCurrentDirectoryForExePath(cmd)`: TRUE — so `.` goes in FRONT — unless the variable
226
+ // is defined. Only reachable with a separator-free `cmd`, which is the only way execution
227
+ // gets this far. Inserted before the dedupe, exactly where `shutil.which` inserts it, so a
228
+ // PATH that already names `.` is searched once.
229
+ if (win && process.env.NoDefaultCurrentDirectoryInExePath === undefined) dirs.unshift('.');
230
+ const seen = new Set();
231
+ for (const dir of dirs) {
232
+ const key = win ? dir.toLowerCase() : dir;
233
+ if (seen.has(key)) continue;
234
+ seen.add(key);
235
+ for (const ext of (!win || spelled ? [''] : exts)) {
236
+ const candidate = joinRaw(dir, cmd + ext);
237
+ if (isExec(candidate)) return candidate;
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
package/js/lib/proc.mjs CHANGED
@@ -7,9 +7,9 @@
7
7
  * worst case, running `node --check` once per plugin, and it is what a user actually sees:
8
8
  * seven consoles flashing on a dashboard load.
9
9
  *
10
- * WHY IT LIVES HERE AND NOT IN `js/update.mjs`, WHICH DEFINED IT FIRST. It was private to
11
- * that module and six of its spawns used it, so the four spawn sites in `js/doctor.mjs`,
12
- * `js/hooks.mjs`, `js/setup.mjs` and `js/link.mjs` each had to remember the flag on their
10
+ * WHY IT LIVES HERE AND NOT IN `js/maintain/update.mjs`, WHICH DEFINED IT FIRST. It was private to
11
+ * that module and six of its spawns used it, so the four spawn sites in `js/inspect/checks-authoring.mjs`,
12
+ * `js/hosts/hooks.mjs`, `js/maintain/setup.mjs` and `js/hosts/link.mjs` each had to remember the flag on their
13
13
  * own. Two of them did not — and the miss is BYTE-INVISIBLE: a console window is not
14
14
  * something the CLI prints, so every recorded cell under `tests/__snapshots__/` stays green
15
15
  * while the windows pop. The property under test never reaches the comparison, which is why