@oh-my-pi/pi-utils 16.3.0 → 16.3.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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.1] - 2026-07-02
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `parseJsonWithRepair` failing tool calls whose streamed arguments contain an unquoted string value (e.g. `{"paths": packages/foo/*, "i": "…"}`). Final parsing now recovers such barewords in object/array value position as strings, terminating at `,` / `}` / `]` / newline. Recovery deliberately refuses anything that could mask real structure or bad data — truncated values, tokens containing `"` / `{` / `[` or a key-like `:` (URL `://` and Windows `:\` colons stay literal), and non-finite atoms (`NaN`, `Infinity`, `undefined`) — and streaming partial parses still roll back unfinished barewords instead of committing them.
10
+
5
11
  ## [16.3.0] - 2026-07-02
6
12
 
7
13
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "16.3.0",
4
+ "version": "16.3.2",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "16.3.0",
34
+ "@oh-my-pi/pi-natives": "16.3.2",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
package/src/json-parse.ts CHANGED
@@ -49,6 +49,18 @@ const KEYWORDS: readonly (readonly [string, unknown])[] = [
49
49
  ["None", null],
50
50
  ];
51
51
 
52
+ /**
53
+ * JS-only atoms never recovered as bareword strings — a tool must not execute
54
+ * with a non-finite or undefined argument masquerading as a string.
55
+ */
56
+ const NON_RECOVERABLE_BAREWORDS: Record<string, true> = {
57
+ NaN: true,
58
+ Infinity: true,
59
+ "-Infinity": true,
60
+ "+Infinity": true,
61
+ undefined: true,
62
+ };
63
+
52
64
  /**
53
65
  * Sentinel returned by partial-mode value parsing when an atomic value
54
66
  * (number / keyword) is incomplete at the streaming edge, so the enclosing
@@ -157,7 +169,10 @@ export function repairJson(json: string): string {
157
169
  * - Python literals `True` / `False` / `None` and JS `NaN` / `Infinity`;
158
170
  * - raw control characters and invalid `\x` escapes inside strings (kept literally);
159
171
  * - unescaped quotes inside strings — a quote only closes a string when followed
160
- * by a value terminator, recovering apostrophes such as `'it's'`.
172
+ * by a value terminator, recovering apostrophes such as `'it's'`;
173
+ * - unquoted string values in object/array value position (strict mode only) —
174
+ * an unrecognized bareword such as `{"paths": packages/foo/*}` is recovered as
175
+ * a string up to the next `,` / `}` / `]` / newline.
161
176
  *
162
177
  * In `partial` mode an unterminated string/object/array (or a value cut off at
163
178
  * end-of-input) is auto-closed with whatever was parsed so far — for streaming.
@@ -182,7 +197,7 @@ class RelaxedJson {
182
197
  if (this.#partial) return undefined;
183
198
  throw new SyntaxError("Unexpected end of JSON input");
184
199
  }
185
- const value = this.#value();
200
+ const value = this.#value(false);
186
201
  if (value === INCOMPLETE) return undefined;
187
202
  this.#ws();
188
203
  if (!this.#partial && this.#i < this.#n) {
@@ -218,7 +233,7 @@ class RelaxedJson {
218
233
  }
219
234
  }
220
235
 
221
- #value(): unknown {
236
+ #value(allowBareword: boolean): unknown {
222
237
  const s = this.#s;
223
238
  const c = s[this.#i];
224
239
  if (c === "{") return this.#object();
@@ -231,7 +246,7 @@ class RelaxedJson {
231
246
  // NaN guard (strict throw / partial rollback) like other bad tokens.
232
247
  return this.#number();
233
248
  }
234
- return this.#keyword();
249
+ return this.#keyword(allowBareword);
235
250
  }
236
251
 
237
252
  #object(): Record<string, unknown> {
@@ -267,7 +282,7 @@ class RelaxedJson {
267
282
  if (this.#partial) return out;
268
283
  throw new SyntaxError("Expected value after ':'");
269
284
  }
270
- const value = this.#value();
285
+ const value = this.#value(true);
271
286
  if (value === INCOMPLETE) return out;
272
287
  out[key] = value;
273
288
  this.#ws();
@@ -303,7 +318,7 @@ class RelaxedJson {
303
318
  this.#i++;
304
319
  continue;
305
320
  }
306
- const value = this.#value();
321
+ const value = this.#value(true);
307
322
  if (value === INCOMPLETE) return out;
308
323
  out.push(value);
309
324
  this.#ws();
@@ -468,7 +483,7 @@ class RelaxedJson {
468
483
  return num;
469
484
  }
470
485
 
471
- #keyword(): unknown {
486
+ #keyword(allowBareword: boolean): unknown {
472
487
  const s = this.#s;
473
488
  const i = this.#i;
474
489
  for (const [word, value] of KEYWORDS) {
@@ -485,8 +500,47 @@ class RelaxedJson {
485
500
  this.#i = this.#n;
486
501
  return INCOMPLETE;
487
502
  }
503
+ if (allowBareword) return this.#bareword();
488
504
  throw new SyntaxError(`Unexpected token at position ${this.#i}`);
489
505
  }
506
+
507
+ /**
508
+ * Strict-mode recovery of an unquoted string value, e.g.
509
+ * `{"paths": packages/foo/*}`: consume until `,` / `}` / `]` / newline and
510
+ * trim trailing whitespace. Recovery still throws — so a final parse never
511
+ * accepts a half-formed or non-finite argument — when the token:
512
+ * - hits end-of-input before a delimiter (truncated value);
513
+ * - contains a `"`, `{`, `[`, or a key-like `:` — this parser accepts
514
+ * unquoted keys, so a missed comma (`{"a": foo "b": 1}`, `{a: foo b: 1}`)
515
+ * would otherwise silently swallow the following field. A colon followed
516
+ * by `/` or `\` stays literal so URL and Windows-path values recover;
517
+ * - is a non-finite atom ({@link NON_RECOVERABLE_BAREWORDS}).
518
+ */
519
+ #bareword(): string {
520
+ const s = this.#s;
521
+ const start = this.#i;
522
+ let i = start;
523
+ while (i < this.#n) {
524
+ const cc = s.charCodeAt(i);
525
+ if (cc === 0x2c /* , */ || cc === 0x7d /* } */ || cc === 0x5d /* ] */ || cc === 0x0a || cc === 0x0d) break;
526
+ if (
527
+ cc === QUOTE ||
528
+ cc === 0x7b /* { */ ||
529
+ cc === 0x5b /* [ */ ||
530
+ (cc === 0x3a /* : */ && s.charCodeAt(i + 1) !== 0x2f /* / */ && s.charCodeAt(i + 1) !== 0x5c) /* \ */
531
+ ) {
532
+ throw new SyntaxError(`Unexpected token at position ${start}`);
533
+ }
534
+ i++;
535
+ }
536
+ if (i >= this.#n) throw new SyntaxError(`Unexpected token at position ${start}`);
537
+ let end = i;
538
+ while (end > start && isWhitespace(s.charCodeAt(end - 1))) end--;
539
+ const word = s.slice(start, end);
540
+ if (NON_RECOVERABLE_BAREWORDS[word]) throw new SyntaxError(`Unexpected token at position ${start}`);
541
+ this.#i = i;
542
+ return word;
543
+ }
490
544
  }
491
545
 
492
546
  /**