@linxiraos/pi-utils 1.1.15 → 1.1.16

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 (51) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/THIRD-PARTY-NOTICES.txt +54 -25
  3. package/dist/types/async.d.ts +18 -0
  4. package/dist/types/browsers.d.ts +2 -30
  5. package/dist/types/color.d.ts +2 -0
  6. package/dist/types/dirs.d.ts +18 -1
  7. package/dist/types/env.d.ts +12 -2
  8. package/dist/types/executable.d.ts +4 -0
  9. package/dist/types/fetch-retry.d.ts +8 -2
  10. package/dist/types/file-lock.d.ts +6 -0
  11. package/dist/types/format.d.ts +7 -0
  12. package/dist/types/index.d.ts +2 -1
  13. package/dist/types/mime.d.ts +1 -0
  14. package/dist/types/path.d.ts +6 -0
  15. package/dist/types/peek-file.d.ts +2 -3
  16. package/dist/types/postmortem.d.ts +26 -5
  17. package/dist/types/procmgr.d.ts +2 -4
  18. package/dist/types/snowflake.d.ts +1 -0
  19. package/dist/types/sqlite.d.ts +27 -7
  20. package/dist/types/stream.d.ts +76 -1
  21. package/dist/types/which.d.ts +8 -2
  22. package/dist/types/yaml-config.d.ts +2 -0
  23. package/package.json +2 -2
  24. package/src/acp/transport.ts +37 -3
  25. package/src/async.ts +31 -0
  26. package/src/browsers.ts +10 -169
  27. package/src/color.ts +1 -1
  28. package/src/dirs.ts +28 -6
  29. package/src/env.ts +81 -34
  30. package/src/executable.ts +17 -0
  31. package/src/fetch-retry.ts +72 -13
  32. package/src/file-lock.ts +28 -0
  33. package/src/format.ts +16 -0
  34. package/src/index.ts +2 -1
  35. package/src/json.ts +12 -1
  36. package/src/logger/rotating-file.ts +40 -3
  37. package/src/logger.ts +16 -4
  38. package/src/mime.ts +3 -7
  39. package/src/path.ts +14 -0
  40. package/src/peek-file.ts +17 -67
  41. package/src/postmortem.ts +95 -47
  42. package/src/procmgr.ts +2 -12
  43. package/src/ptree.ts +36 -5
  44. package/src/snowflake.ts +12 -1
  45. package/src/sqlite.ts +242 -7
  46. package/src/stream.ts +167 -35
  47. package/src/which.ts +38 -14
  48. package/src/xml.ts +16 -0
  49. package/src/yaml-config.ts +8 -0
  50. package/dist/types/glob.d.ts +0 -28
  51. package/src/glob.ts +0 -189
package/src/stream.ts CHANGED
@@ -4,7 +4,14 @@ import { abortableSource } from "./abortable";
4
4
  import { parseStreamingJson } from "./json-parse";
5
5
 
6
6
  const LF = 0x0a;
7
+ const CR = 0x0d;
7
8
 
9
+ /**
10
+ * Split a byte stream on LF boundaries.
11
+ *
12
+ * Every yielded line owns its bytes and remains unchanged after the generator
13
+ * advances or drains. Line terminators are excluded.
14
+ */
8
15
  export async function* readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array> {
9
16
  const buffer = new ConcatSink();
10
17
  const source = abortableSource(stream, signal);
@@ -56,13 +63,19 @@ export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?:
56
63
  }
57
64
  }
58
65
 
59
- // =============================================================================
60
- // SSE (Server-Sent Events)
61
- // =============================================================================
62
-
63
- class ConcatSink {
66
+ /**
67
+ * Amortized byte accumulator for chunked stream readers.
68
+ *
69
+ * Holds the unconsumed tail of a stream in a single growing `Buffer` so that
70
+ * appending N chunks costs O(total bytes) instead of re-copying the whole
71
+ * prefix per chunk. Backs {@link readLines}, {@link readJsonl} and
72
+ * {@link readSseEvents}; also usable directly when a reader needs its own
73
+ * framing loop (see `consume` and `flush`).
74
+ */
75
+ export class ConcatSink {
64
76
  #space?: Buffer;
65
77
  #length = 0;
78
+ #skipLeadingLf = false;
66
79
 
67
80
  #ensureCapacity(size: number): Buffer {
68
81
  const space = this.#space;
@@ -100,16 +113,37 @@ class ConcatSink {
100
113
  return this.#length === 0;
101
114
  }
102
115
 
116
+ /**
117
+ * The buffered bytes as a live view — invalidated by the next `append`,
118
+ * `reset` or `consume`.
119
+ */
103
120
  flush(): Uint8Array | undefined {
104
121
  if (!this.#length) return undefined;
105
122
  return this.#space!.subarray(0, this.#length);
106
123
  }
107
124
 
125
+ /** Drop the first `count` buffered bytes, keeping the remainder. */
126
+ consume(count: number) {
127
+ if (count <= 0) return;
128
+ if (count >= this.#length) {
129
+ this.#length = 0;
130
+ return;
131
+ }
132
+ this.#space!.copyWithin(0, count, this.#length);
133
+ this.#length -= count;
134
+ }
135
+
108
136
  clear() {
109
137
  this.#length = 0;
110
138
  }
111
139
 
112
- *appendAndFlushLines(chunk: Uint8Array) {
140
+ /**
141
+ * Append a chunk and yield each complete LF-delimited line.
142
+ *
143
+ * Yielded lines are owned snapshots. Unlike {@link flush}, they remain
144
+ * valid after this sink or the input chunk is mutated.
145
+ */
146
+ *appendAndFlushLines(chunk: Uint8Array): Generator<Uint8Array> {
113
147
  let pos = 0;
114
148
  while (pos < chunk.length) {
115
149
  const nl = chunk.indexOf(LF, pos);
@@ -120,12 +154,12 @@ class ConcatSink {
120
154
  const suffix = chunk.subarray(pos, nl);
121
155
  pos = nl + 1;
122
156
  if (this.isEmpty) {
123
- yield suffix;
157
+ yield new Uint8Array(suffix);
124
158
  } else {
125
159
  this.append(suffix);
126
160
  const payload = this.flush();
127
161
  if (payload) {
128
- yield payload;
162
+ yield new Uint8Array(payload);
129
163
  this.clear();
130
164
  }
131
165
  }
@@ -133,19 +167,27 @@ class ConcatSink {
133
167
  }
134
168
 
135
169
  appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined {
136
- const lastNewline = chunk.lastIndexOf(LF);
137
- if (lastNewline === -1) {
138
- this.append(chunk);
170
+ let start = 0;
171
+ if (this.#skipLeadingLf) {
172
+ if (chunk.length === 0) return undefined;
173
+ this.#skipLeadingLf = false;
174
+ if (chunk[0] === LF) start = 1;
175
+ }
176
+
177
+ const lastLineEnd = Math.max(chunk.lastIndexOf(LF), chunk.lastIndexOf(CR));
178
+ if (lastLineEnd < start) {
179
+ if (start < chunk.length) this.append(chunk.subarray(start));
139
180
  return undefined;
140
181
  }
141
182
 
142
- const completeEnd = lastNewline + 1;
183
+ const completeEnd = lastLineEnd + 1;
184
+ this.#skipLeadingLf = chunk[lastLineEnd] === CR && completeEnd === chunk.length;
143
185
  let text: string;
144
186
  if (this.isEmpty) {
145
- const complete = completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd);
187
+ const complete = start === 0 && completeEnd === chunk.length ? chunk : chunk.subarray(start, completeEnd);
146
188
  text = decoder.decode(complete);
147
189
  } else {
148
- this.append(completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd));
190
+ this.append(chunk.subarray(start, completeEnd));
149
191
  text = decoder.decode(this.flush());
150
192
  this.clear();
151
193
  }
@@ -197,6 +239,10 @@ class ConcatSink {
197
239
  }
198
240
  }
199
241
 
242
+ // =============================================================================
243
+ // SSE (Server-Sent Events)
244
+ // =============================================================================
245
+
200
246
  /**
201
247
  * Stream parsed JSON objects from SSE `data:` lines.
202
248
  *
@@ -236,12 +282,27 @@ function isRecoverableTrailingJson(data: string): boolean {
236
282
  return typeof recovered === "object" && recovered !== null;
237
283
  }
238
284
 
239
- export async function* readSseJson<T>(
285
+ /**
286
+ * One dispatched `data:` frame from {@link readSseFrames}: either the parsed JSON
287
+ * value, or the text of a frame `JSON.parse` rejected together with the
288
+ * `SyntaxError` it raised (so the strict reader can rethrow it unchanged).
289
+ */
290
+ type SseFrame<T> = { ok: true; value: T } | { ok: false; raw: string; error: SyntaxError };
291
+
292
+ /**
293
+ * Shared `data:`-line framing for {@link readSseJson} and
294
+ * {@link readSseJsonOrText}: skips empty events, stops at the OpenAI `[DONE]`
295
+ * sentinel, notifies the diagnostic observer, and treats a container-shaped
296
+ * stream tail as a clean end of iteration.
297
+ */
298
+ async function* readSseFrames<T>(
240
299
  stream: ReadableStream<Uint8Array>,
241
300
  signal?: AbortSignal,
242
301
  onEvent?: SseEventObserver,
243
- ): AsyncGenerator<T> {
244
- for await (const sse of readSseEvents(stream, signal)) {
302
+ ): AsyncGenerator<SseFrame<T>> {
303
+ // The diagnostic observer is the only reader of `raw`; capture it exactly
304
+ // when one is attached so the hot path stays allocation-free.
305
+ for await (const sse of readSseEvents(stream, signal, onEvent ? { captureRaw: true } : undefined)) {
245
306
  const isTrailing = trailingEvents.has(sse);
246
307
  notifySseEventObserver(onEvent, sse);
247
308
  const data = sse.data;
@@ -250,16 +311,60 @@ export async function* readSseJson<T>(
250
311
  continue;
251
312
  }
252
313
  try {
253
- yield JSON.parse(data) as T;
314
+ yield { ok: true, value: JSON.parse(data) as T };
254
315
  } catch (err) {
255
316
  if (err instanceof SyntaxError && isTrailing && isRecoverableTrailingJson(data)) {
256
317
  return;
257
318
  }
319
+ if (err instanceof SyntaxError) {
320
+ yield { ok: false, raw: data, error: err };
321
+ continue;
322
+ }
258
323
  throw err;
259
324
  }
260
325
  }
261
326
  }
262
327
 
328
+ export async function* readSseJson<T>(
329
+ stream: ReadableStream<Uint8Array>,
330
+ signal?: AbortSignal,
331
+ onEvent?: SseEventObserver,
332
+ ): AsyncGenerator<T> {
333
+ for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
334
+ if (!frame.ok) throw frame.error;
335
+ yield frame.value;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Like {@link readSseJson}, but a `data:` frame that is not valid JSON is yielded
341
+ * as its raw text instead of raising a `SyntaxError`. Cut-off container-shaped
342
+ * stream tails stay recoverable, exactly as they are in {@link readSseJson}.
343
+ *
344
+ * Consumers that only understand objects must treat a `string` yield as a
345
+ * transport-level failure (for example a `429 Too Many Requests` or an HTML
346
+ * throttle page from a reverse proxy that already committed to the stream). This
347
+ * exists because `readSseJson`'s baseline consumers span unrelated transports
348
+ * whose error handling a text yield would subtly change; new call sites opt in.
349
+ *
350
+ * Note that the text lane is only the frames `JSON.parse` *rejected*: a frame
351
+ * carrying a JSON-encoded string (`data: "429 Too Many Requests"`) parses, so it
352
+ * is yielded as that string and is indistinguishable from a rejected frame by
353
+ * type alone. Consumers branching on `typeof === "string"` therefore see both,
354
+ * which is the safe direction — each is classified as text rather than trusted as
355
+ * an event object.
356
+ */
357
+ export async function* readSseJsonOrText<T>(
358
+ stream: ReadableStream<Uint8Array>,
359
+ signal?: AbortSignal,
360
+ onEvent?: SseEventObserver,
361
+ ): AsyncGenerator<T | string> {
362
+ for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
363
+ if (!frame.ok) yield frame.raw;
364
+ else yield frame.value;
365
+ }
366
+ }
367
+
263
368
  /**
264
369
  * A single Server-Sent Event dispatched on a blank-line boundary.
265
370
  *
@@ -276,6 +381,15 @@ export async function* readSseJson<T>(
276
381
  export interface ServerSentEvent {
277
382
  event: string | null;
278
383
  data: string;
384
+ /**
385
+ * Decoded wire lines for this event (`event:`/`data:`/etc.), for the
386
+ * diagnostic pipeline. Populated only when the reader opts in via
387
+ * {@link ReadSseEventsOptions.captureRaw} (or attaches an `onSseEvent`
388
+ * observer to the JSON readers, which opt in automatically); otherwise
389
+ * `[]`. Direct `readSseEvents` callers that need wire text must pass
390
+ * `{ captureRaw: true }` — the field is allocation-free by default so
391
+ * the token path pays no per-frame array/slice cost.
392
+ */
279
393
  raw: string[];
280
394
  id?: string;
281
395
  retry?: number;
@@ -288,50 +402,48 @@ interface SseEventState {
288
402
  // of buffering an array and joining at flush. `null` means "no data: field
289
403
  // seen yet" (distinct from a `data:` field with an empty value).
290
404
  data: string | null;
291
- raw: string[];
405
+ // Diagnostic wire lines, captured only when a reader asked for them (see
406
+ // `readSseEventsOptions.captureRaw`): per-frame array+slice allocation on
407
+ // the token path otherwise. `null` means capture is off.
408
+ raw: string[] | null;
292
409
  id?: string;
293
410
  retry?: number;
294
411
  }
295
412
 
296
413
  // Complete lines are decoded in one batch per source chunk. Each batch ends on
297
- // LF, which cannot split a multi-byte UTF-8 sequence.
414
+ // an ASCII line-ending byte, which cannot split a multi-byte UTF-8 sequence.
298
415
  const SSE_DECODER = new TextDecoder("utf-8");
299
416
 
300
417
  function flushSseEvent(state: SseEventState): ServerSentEvent | null {
301
418
  if (state.event === null && state.data === null && state.id === undefined && state.retry === undefined) {
302
- state.raw = [];
419
+ if (state.raw !== null) state.raw = [];
303
420
  return null;
304
421
  }
305
422
  const event: ServerSentEvent = {
306
423
  event: state.event,
307
424
  data: state.data ?? "",
308
- raw: state.raw,
425
+ raw: state.raw ?? [],
309
426
  };
310
427
  if (state.id !== undefined) event.id = state.id;
311
428
  if (state.retry !== undefined) event.retry = state.retry;
312
429
  state.event = null;
313
430
  state.data = null;
314
- state.raw = [];
431
+ if (state.raw !== null) state.raw = [];
315
432
  state.id = undefined;
316
433
  state.retry = undefined;
317
434
  return event;
318
435
  }
319
436
 
320
437
  function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null {
321
- // Complete-line batches split on LF only; strip a trailing CR so CRLF sources
322
- // don't leak `\r` into field values.
323
- if (line.charCodeAt(line.length - 1) === 0x0d /* '\r' */) {
324
- line = line.slice(0, -1);
325
- }
326
438
  if (line.length === 0) return flushSseEvent(state);
327
439
 
328
440
  // Comment line: keep in `raw` for diagnostic context, skip parsing.
329
441
  if (line.charCodeAt(0) === 0x3a /* ':' */) {
330
- state.raw.push(line);
442
+ state.raw?.push(line);
331
443
  return null;
332
444
  }
333
445
 
334
- state.raw.push(line);
446
+ state.raw?.push(line);
335
447
 
336
448
  const colon = line.indexOf(":");
337
449
  const fieldName = colon === -1 ? line : line.slice(0, colon);
@@ -385,12 +497,24 @@ function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null
385
497
  * }
386
498
  * ```
387
499
  */
500
+ export interface ReadSseEventsOptions {
501
+ /**
502
+ * Capture per-line wire text into `event.raw` for the diagnostic
503
+ * pipeline (`onSseEvent` observers, raw-SSE viewer). Off by default:
504
+ * every frame otherwise pays an array allocation plus one string slice
505
+ * per line on the token path.
506
+ */
507
+ captureRaw?: boolean;
508
+ }
509
+
388
510
  export async function* readSseEvents(
389
511
  stream: ReadableStream<Uint8Array>,
390
512
  signal?: AbortSignal,
513
+ options?: ReadSseEventsOptions,
391
514
  ): AsyncGenerator<ServerSentEvent> {
392
515
  const lineBuffer = new ConcatSink();
393
- const state: SseEventState = { event: null, data: null, raw: [] };
516
+ const captureRaw = options?.captureRaw === true;
517
+ const state: SseEventState = { event: null, data: null, raw: captureRaw ? [] : null };
394
518
  const source = abortableSource(stream, signal);
395
519
  try {
396
520
  for await (const chunk of source) {
@@ -398,13 +522,21 @@ export async function* readSseEvents(
398
522
  if (text === undefined) continue;
399
523
  let start = 0;
400
524
  while (start < text.length) {
401
- const newline = text.indexOf("\n", start);
402
- const event = pushSseLine(text.slice(start, newline), state);
525
+ let lineEnd = start;
526
+ while (lineEnd < text.length) {
527
+ const code = text.charCodeAt(lineEnd);
528
+ if (code === LF || code === CR) break;
529
+ lineEnd++;
530
+ }
531
+ const event = pushSseLine(text.slice(start, lineEnd), state);
403
532
  if (event) yield event;
404
- start = newline + 1;
533
+ if (text.charCodeAt(lineEnd) === CR && text.charCodeAt(lineEnd + 1) === LF) {
534
+ lineEnd++;
535
+ }
536
+ start = lineEnd + 1;
405
537
  }
406
538
  }
407
- // Treat any trailing partial line (no terminating LF) as a complete line.
539
+ // Treat any trailing partial line (no terminating line ending) as complete.
408
540
  if (!lineBuffer.isEmpty) {
409
541
  const tail = lineBuffer.flush();
410
542
  if (tail) {
package/src/which.ts CHANGED
@@ -11,8 +11,7 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
-
15
- type CacheKey = string | bigint | number;
14
+ import { isFullyQualifiedPath } from "./path";
16
15
 
17
16
  // Tools shipped by Xcode / Command Line Tools that callers actually look up.
18
17
  // Keeps the set small so darwinWhich can fast-reject non-Xcode commands without
@@ -148,12 +147,12 @@ function getMacosToolPaths(): Map<string, string> {
148
147
  }
149
148
 
150
149
  // Map: cache key -> resolved binary path or null (not found)
151
- const toolCache = new Map<CacheKey, string | null>();
150
+ const toolCache = new Map<string, string | null>();
152
151
 
153
152
  /**
154
153
  * Cache policy for which lookups.
155
154
  */
156
- export const enum WhichCachePolicy {
155
+ export enum WhichCachePolicy {
157
156
  /**
158
157
  * Use cached result if available.
159
158
  */
@@ -176,9 +175,15 @@ export const enum WhichCachePolicy {
176
175
  export interface WhichOptions extends Bun.WhichOptions {
177
176
  /**
178
177
  * Cache policy for the lookup.
179
- * Defaults to `WhichCachePolicy.Fresh`.
178
+ * Defaults to `WhichCachePolicy.Cached`.
180
179
  */
181
180
  cache?: WhichCachePolicy;
181
+ /**
182
+ * Only search absolute directory entries in PATH, ignoring relative entries
183
+ * (e.g. `.` or `./bin`) and empty components to prevent resolving against
184
+ * an untrusted working directory.
185
+ */
186
+ requireAbsolutePaths?: boolean;
182
187
  }
183
188
 
184
189
  // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH.
@@ -192,6 +197,15 @@ function darwinWhich(command: string, options?: Bun.WhichOptions): string | null
192
197
  return null;
193
198
  }
194
199
 
200
+ function filterAbsoluteSearchPath(rawPath: string | undefined): string | null {
201
+ if (!rawPath) return null;
202
+ const safePath = rawPath
203
+ .split(path.delimiter)
204
+ .filter(dir => dir.length > 0 && isFullyQualifiedPath(dir))
205
+ .join(path.delimiter);
206
+ return safePath || null;
207
+ }
208
+
195
209
  // Which function that incorporates Darwin Xcode logic if platform reports as 'darwin'.
196
210
  // Look `Bun.which` up per call rather than capturing it at import, so a `Bun.which`
197
211
  // stub installed later (the per-test seam) is honoured on every platform.
@@ -200,14 +214,14 @@ export const whichFresh =
200
214
  ? darwinWhich
201
215
  : (command: string, options?: Bun.WhichOptions): string | null => Bun.which(command, options);
202
216
 
203
- // Derive stable cache key from command and lookup options
204
- function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
217
+ // Length-prefixed (command, cwd, PATH) tuple: exact, unlike the 64-bit hash
218
+ // chain it replaced, and the length prefixes keep `("ab", "c")` and
219
+ // `("a", "bc")` distinct without a separator that cwd/PATH could contain.
220
+ function cacheKey(command: string, options?: Bun.WhichOptions): string {
205
221
  if (!options) return command;
206
- if (!options.cwd && !options.PATH) return command;
207
- let h = Bun.hash(command);
208
- if (options.cwd) h = Bun.hash(options.cwd, h);
209
- if (options.PATH) h = Bun.hash(options.PATH, h);
210
- return h;
222
+ const cwd = options?.cwd ?? "";
223
+ const binPath = options?.PATH ?? "";
224
+ return `${command.length}:${command}${cwd.length}:${cwd}${binPath.length}:${binPath}`;
211
225
  }
212
226
 
213
227
  /**
@@ -219,9 +233,16 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
219
233
  */
220
234
  export function $which(command: string, options?: WhichOptions): string | null {
221
235
  const cachePolicy = options?.cache ?? WhichCachePolicy.Cached;
222
- const lookupOptions =
236
+ let lookupOptions =
223
237
  options?.PATH !== undefined || process.env.PATH === undefined ? options : { ...options, PATH: process.env.PATH };
224
- let key: CacheKey | undefined;
238
+
239
+ if (options?.requireAbsolutePaths) {
240
+ const safePath = filterAbsoluteSearchPath(lookupOptions?.PATH);
241
+ if (!safePath) return null;
242
+ lookupOptions = { ...lookupOptions, PATH: safePath };
243
+ }
244
+
245
+ let key: string | undefined;
225
246
 
226
247
  if (cachePolicy !== WhichCachePolicy.Bypass) {
227
248
  key = cacheKey(command, lookupOptions);
@@ -232,6 +253,9 @@ export function $which(command: string, options?: WhichOptions): string | null {
232
253
  }
233
254
 
234
255
  const result = whichFresh(command, lookupOptions);
256
+ if (result && options?.requireAbsolutePaths && !isFullyQualifiedPath(result)) {
257
+ return null;
258
+ }
235
259
  if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) {
236
260
  toolCache.set(key, result);
237
261
  }
package/src/xml.ts CHANGED
@@ -104,6 +104,12 @@ class XmlReader {
104
104
  this.#readProcessingInstruction(document);
105
105
  continue;
106
106
  }
107
+ if (this.#xml.startsWith("</", this.#position)) {
108
+ // Stray end tag with no element open: skip it instead of parsing it as a new element,
109
+ // whose empty name would leave #readElement stuck on the `/`.
110
+ this.#skipThrough(">");
111
+ continue;
112
+ }
107
113
  if (this.#xml[this.#position] === "<") {
108
114
  const element = this.#readElement("");
109
115
  this.#addValue(document, element.name, element.name, element.value, element.leaf, null);
@@ -138,6 +144,11 @@ class XmlReader {
138
144
  this.#position++;
139
145
  this.#skipWhitespace();
140
146
  value = this.#readAttributeValue();
147
+ } else if (attributeName === "") {
148
+ // Markup #readName cannot consume (the stray `/` in `<a / >`): skip the character
149
+ // so the loop always makes progress.
150
+ this.#position++;
151
+ continue;
141
152
  }
142
153
  attributes.push([attributeName, value]);
143
154
  }
@@ -153,6 +164,11 @@ class XmlReader {
153
164
  this.#position = close < 0 ? this.#xml.length : close + 1;
154
165
  break;
155
166
  }
167
+ if (this.#xml.startsWith("</", this.#position)) {
168
+ // End tag for an ancestor (an unclosed `<br>` inside `<p>`, or plain mismatched
169
+ // markup): close this element implicitly and leave the tag to its owner.
170
+ break;
171
+ }
156
172
  if (this.#xml.startsWith("<![CDATA[", this.#position)) {
157
173
  const end = this.#xml.indexOf("]]>", this.#position + 9);
158
174
  const raw = this.#xml.slice(this.#position + 9, end < 0 ? this.#xml.length : end);
@@ -0,0 +1,8 @@
1
+ import { YAML } from "bun";
2
+
3
+ const YAML_MAPPING_HEADER_TRAILING_SPACE = /: +$/gm;
4
+
5
+ /** Serialize config YAML without Bun's trailing space on block mapping headers. */
6
+ export function stringifyYamlConfig(value: unknown): string {
7
+ return YAML.stringify(value, null, 2).replace(YAML_MAPPING_HEADER_TRAILING_SPACE, ":");
8
+ }
@@ -1,28 +0,0 @@
1
- export interface GlobPathsOptions {
2
- /** Base directory for glob patterns. Defaults to getProjectDir(). */
3
- cwd?: string;
4
- /** Glob exclusion patterns. */
5
- exclude?: string[];
6
- /** Abort signal to cancel the glob. */
7
- signal?: AbortSignal;
8
- /** Timeout in milliseconds for the glob operation. */
9
- timeoutMs?: number;
10
- /** Include dotfiles when true. */
11
- dot?: boolean;
12
- /** Only return files (skip directories). Default: true. */
13
- onlyFiles?: boolean;
14
- /** Respect .gitignore files when true. Walks up directory tree to find all applicable .gitignore files. */
15
- gitignore?: boolean;
16
- }
17
- /**
18
- * Load .gitignore patterns from a directory and its parents.
19
- * Walks up the directory tree to find all applicable .gitignore files.
20
- * Returns glob-compatible exclude patterns.
21
- */
22
- export declare function loadGitignorePatterns(baseDir: string): Promise<string[]>;
23
- /**
24
- * Resolve filesystem paths matching glob patterns with optional exclude filters.
25
- * Returns paths relative to the provided cwd (or getProjectDir()).
26
- * Errors and abort/timeouts are surfaced to the caller.
27
- */
28
- export declare function globPaths(patterns: string | string[], options?: GlobPathsOptions): Promise<string[]>;