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.
- package/CHANGELOG.md +68 -1
- package/DESIGN.md +15 -15
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +111 -83
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +27 -28
- package/js/{render.mjs → build/render.mjs} +13 -9
- package/js/{checkout.mjs → build/source.mjs} +9 -9
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +18 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +63 -55
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +34 -32
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +84 -85
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +72 -50
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +21 -19
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
- package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +1 -1
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1750
- package/js/emit.mjs +0 -1411
- package/web/dist/assets/index-B-mXquBZ.css +0 -1
- package/web/dist/assets/index-CH_Z725C.js +0 -12
package/js/lib/json.mjs
ADDED
|
@@ -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
|
+
|
package/js/lib/paths.mjs
ADDED
|
@@ -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/
|
|
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
|