@oh-my-pi/pi-utils 18.12.2 → 18.12.4

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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.12.4] - 2026-10-20
6
+
7
+ ### Breaking Changes
8
+
9
+ - `readTarEntriesFromBuffer()` in `@oh-my-pi/pi-utils/ar/tar` now returns `Promise<ArchiveIndexEntry[]>` instead of `ArchiveIndexEntry[]`; `await` its result ([#16394](https://github.com/can1357/oh-my-pi/pull/16394) by [@will-bogusz](https://github.com/will-bogusz))
10
+
11
+ ### Added
12
+
13
+ - Added `maxStreamSize` to `ArchiveLimits` (default 4 GB): the most a large `.tar.gz` or `.tar.zst` may decompress in one pass, so a decompression bomb fails instead of running without bound ([#16394](https://github.com/can1357/oh-my-pi/pull/16394) by [@will-bogusz](https://github.com/will-bogusz))
14
+
15
+ ### Fixed
16
+
17
+ - Fixed `bun test` runs writing their logs into your real `~/.omp/logs`: test processes now log to a per-user `omp-test-logs-<uid>/` under the OS temp dir ([#16386](https://github.com/can1357/oh-my-pi/pull/16386) by [@will-bogusz](https://github.com/will-bogusz))
18
+ - Fixed `.tar.gz` and `.tar.zst` archives that decompress past 256 MB failing to list or read with a raw `Cannot create a Buffer larger than …` error; a bare `.gz` or `.zst` that large now names the archive size limit ([#16394](https://github.com/can1357/oh-my-pi/pull/16394) by [@will-bogusz](https://github.com/will-bogusz))
19
+ - Fixed frontmatter repair deleting HTML comments from document bodies and plain markdown; it now strips only leading comments ahead of the opening delimiter, so prompt bodies keep their comments ([#16481](https://github.com/can1357/oh-my-pi/pull/16481) by [@adi-IL](https://github.com/adi-IL)).
20
+
5
21
  ## [18.12.2] - 2026-10-18
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@ Shared utilities for [omp](https://github.com/can1357/oh-my-pi) packages. Zero c
6
6
 
7
7
  | Module | Purpose |
8
8
  | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
9
- | `logger` | Centralized logger writing to `~/.omp/logs/` with rotation (TUI-safe — never stdout) |
9
+ | `logger` | Centralized logger writing to `~/.omp/logs/` (`bun test`: temp dir), rotating, never stdout |
10
10
  | `prompt` | Handlebars-based prompt templating and formatting helpers |
11
11
  | `dirs` | Path helpers for omp config directories (`~/.omp`, XDG-aware on Linux) |
12
12
  | `stream` | `readStream` / `readLines` helpers over `ReadableStream` |
@@ -1,6 +1,9 @@
1
+ import { type ByteSource } from "../source.js";
1
2
  /** gzip magic: 0x1f 0x8b. */
2
3
  export declare function isGzip(bytes: Uint8Array): boolean;
3
4
  /** Decompress one gzip stream, bounded so a gzip bomb cannot inflate without limit. */
4
5
  export declare function gzipDecompress(bytes: Uint8Array, maxOutput: number): Promise<Uint8Array>;
6
+ /** Decompress a gzip source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
7
+ export declare function gzipDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array>;
5
8
  /** Compress bytes as one gzip stream (tar.gz writing). */
6
9
  export declare function gzipCompress(bytes: Uint8Array): Promise<Uint8Array>;
@@ -1,6 +1,9 @@
1
+ import { type ByteSource } from "../source.js";
1
2
  /** zstd frame magic: 28 b5 2f fd. */
2
3
  export declare function isZstd(bytes: Uint8Array): boolean;
3
4
  /** Decompress one zstd frame, bounded to `maxOutput` bytes. */
4
5
  export declare function zstdDecompress(bytes: Uint8Array, maxOutput: number): Promise<Uint8Array>;
6
+ /** Decompress a zstd source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
7
+ export declare function zstdDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array>;
5
8
  /** Compress bytes as one zstd frame (tar.zst writing). */
6
9
  export declare function zstdCompress(bytes: Uint8Array): Promise<Uint8Array>;
@@ -9,6 +9,8 @@ export interface ArchiveLimits {
9
9
  maxEntries: number;
10
10
  /** Max bytes for any archive fully materialized in memory (tar buffers, solid/decompressed streams). */
11
11
  maxInMemorySize: number;
12
+ /** Max decompressed bytes in one pass over a compressed tar too large for `maxInMemorySize`. */
13
+ maxStreamSize: number;
12
14
  /** Max bytes for archive metadata (ZIP central directory, ASAR JSON header, RAR/7z header areas). */
13
15
  maxIndexSize: number;
14
16
  /** Max declared bytes for a single extracted member. */
@@ -1,3 +1,4 @@
1
+ import { type Transform } from "node:stream";
1
2
  /**
2
3
  * A byte window into an archive — file-backed (lazy, ranged reads) or
3
4
  * in-memory. Format readers index through this so ZIP/ASAR/RAR payloads are
@@ -17,6 +18,14 @@ export declare function memoryByteSource(buffer: Uint8Array): ByteSource;
17
18
  export declare function fileByteSource(filePath: string): ByteSource;
18
19
  /** Materialize an entire {@link ByteSource}; use only under a limits check. */
19
20
  export declare function readAllBytes(source: ByteSource): Promise<Uint8Array>;
21
+ /** Chunk size for streamed decompression, on both the compressed and the decompressed side. */
22
+ export declare const STREAM_CHUNK_SIZE: number;
23
+ /**
24
+ * Decompress a {@link ByteSource} through `decoder` chunk by chunk, so neither
25
+ * the compressed nor the decompressed stream is ever held whole. Output past
26
+ * `maxOutput` bytes fails, so a decompression bomb cannot run without bound.
27
+ */
28
+ export declare function decodeByteSource(source: ByteSource, decoder: Transform, maxOutput: number): AsyncGenerator<Uint8Array>;
20
29
  /** Options for {@link httpByteSource}. */
21
30
  export interface HttpByteSourceOptions {
22
31
  /** Extra request headers (e.g. authorization). */
@@ -1,6 +1,11 @@
1
1
  import type { ArchiveIndexEntry, FormatReader, FormatReadOptions } from "./types.js";
2
- /** Index an already-decompressed tar buffer with bounded GNU, ustar, and PAX handling. */
3
- export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): ArchiveIndexEntry[];
2
+ /** Index an already-decompressed tar buffer; members are views into it. */
3
+ export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
4
+ /**
5
+ * Index a decompressed tar stream that is too large to hold in memory: one
6
+ * pass over `open()` walks the headers, and member reads open it again.
7
+ */
8
+ export declare function readTarEntriesFromStream(open: () => AsyncIterable<Uint8Array>, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
4
9
  /** Read and index a tar source after one bounded whole-stream read. */
5
10
  export declare const readTar: FormatReader;
6
11
  /** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
@@ -44,7 +44,14 @@ export declare function normalizeProfileName(profile: string | undefined): strin
44
44
  export declare function resolveProfileEnv(omp: string | undefined, pi: string | undefined): string | undefined;
45
45
  /** Profile-independent config root (~/.omp), shared by every omp profile. */
46
46
  export declare function getBaseConfigRoot(): string;
47
- export declare function resolveEquivalentPath(inputPath: string): string;
47
+ export interface EquivalentPathOptions {
48
+ /**
49
+ * Resolve a path that does not exist yet through its deepest existing ancestor, keeping the
50
+ * missing suffix, so a symlinked parent canonicalizes the same before and after the path appears.
51
+ */
52
+ throughMissing?: boolean;
53
+ }
54
+ export declare function resolveEquivalentPath(inputPath: string, options?: EquivalentPathOptions): string;
48
55
  export declare function normalizePathForComparison(inputPath: string): string;
49
56
  /**
50
57
  * Compare paths already normalized by {@link normalizePathForComparison}.
@@ -135,7 +142,12 @@ export declare function getAgentDir(): string;
135
142
  export declare function getProjectAgentDir(cwd?: string): string;
136
143
  /** Get the reports directory (~/.omp/reports). */
137
144
  export declare function getReportsDir(): string;
138
- /** Get the logs directory (~/.omp/logs). */
145
+ /**
146
+ * Get the logs directory (~/.omp/logs). `bun test` processes get
147
+ * `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
148
+ * developer's real logs; the uid suffix (as for the test session-owners dir)
149
+ * keeps users of a shared `/tmp` from owning each other's directory.
150
+ */
139
151
  export declare function getLogsDir(): string;
140
152
  /**
141
153
  * Local-timezone `YYYY-MM-DD` day key (zero-padded), formatted exactly like
@@ -1,3 +1,4 @@
1
+ export * from "./test-runtime.js";
1
2
  export * from "./worker-host.js";
2
3
  /**
3
4
  * Strict shell-identifier shape. Used for dotenv keys we accept into
@@ -82,8 +83,6 @@ export declare function $envExact(name: string, env?: Record<string, string | un
82
83
  * Empty, invalid, NaN, zero, or negative values return `defaultValue`.
83
84
  */
84
85
  export declare function $envpos(name: string, defaultValue: number): number;
85
- /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
86
- export declare function isBunTestRuntime(): boolean;
87
86
  /**
88
87
  * True when real-terminal side effects must be suppressed: stdout escape/frame
89
88
  * writes, stdin raw-mode + resume, CSI/OSC capability probes, SIGWINCH, window
@@ -0,0 +1,2 @@
1
+ /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
2
+ export declare function isBunTestRuntime(): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.12.2",
3
+ "version": "18.12.4",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.12.2"
60
+ "@oh-my-pi/pi-natives": "18.12.4"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
@@ -1,6 +1,7 @@
1
1
  import { promisify } from "node:util";
2
2
  import * as zlib from "node:zlib";
3
3
  import { ArchiveError } from "../error";
4
+ import { type ByteSource, decodeByteSource, STREAM_CHUNK_SIZE } from "../source";
4
5
 
5
6
  const gzipAsync = promisify(zlib.gzip);
6
7
  const gunzipAsync = promisify(zlib.gunzip);
@@ -19,6 +20,11 @@ export async function gzipDecompress(bytes: Uint8Array, maxOutput: number): Prom
19
20
  }
20
21
  }
21
22
 
23
+ /** Decompress a gzip source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
24
+ export function gzipDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array> {
25
+ return decodeByteSource(source, zlib.createGunzip({ chunkSize: STREAM_CHUNK_SIZE }), maxOutput);
26
+ }
27
+
22
28
  /** Compress bytes as one gzip stream (tar.gz writing). */
23
29
  export function gzipCompress(bytes: Uint8Array): Promise<Uint8Array> {
24
30
  return gzipAsync(bytes);
@@ -1,6 +1,7 @@
1
1
  import { promisify } from "node:util";
2
2
  import * as zlib from "node:zlib";
3
3
  import { ArchiveError } from "../error";
4
+ import { type ByteSource, decodeByteSource, STREAM_CHUNK_SIZE } from "../source";
4
5
 
5
6
  const zstdCompressAsync = promisify(zlib.zstdCompress);
6
7
  const zstdDecompressAsync = promisify(zlib.zstdDecompress);
@@ -19,6 +20,11 @@ export async function zstdDecompress(bytes: Uint8Array, maxOutput: number): Prom
19
20
  }
20
21
  }
21
22
 
23
+ /** Decompress a zstd source as a stream of chunks, bounded to `maxOutput` bytes, for output too large to hold at once. */
24
+ export function zstdDecompressStream(source: ByteSource, maxOutput: number): AsyncGenerator<Uint8Array> {
25
+ return decodeByteSource(source, zlib.createZstdDecompress({ chunkSize: STREAM_CHUNK_SIZE }), maxOutput);
26
+ }
27
+
22
28
  /** Compress bytes as one zstd frame (tar.zst writing). */
23
29
  export function zstdCompress(bytes: Uint8Array): Promise<Uint8Array> {
24
30
  return zstdCompressAsync(bytes);
package/src/ar/deb.ts CHANGED
@@ -135,7 +135,7 @@ async function readDebImpl(source: ByteSource, options: FormatReadOptions): Prom
135
135
  }
136
136
  const compressed = await readOuterMember(outerEntry);
137
137
  const tarBytes = await decompressDebTar(compressed, tar.compression, options);
138
- const innerEntries = readTarEntriesFromBuffer(tarBytes, options);
138
+ const innerEntries = await readTarEntriesFromBuffer(tarBytes, options);
139
139
  for (const innerEntry of innerEntries) {
140
140
  upsertArchiveEntry(result, tar.kind === "control" ? prefixControlEntry(innerEntry) : innerEntry);
141
141
  }
package/src/ar/limits.ts CHANGED
@@ -13,6 +13,8 @@ export interface ArchiveLimits {
13
13
  maxEntries: number;
14
14
  /** Max bytes for any archive fully materialized in memory (tar buffers, solid/decompressed streams). */
15
15
  maxInMemorySize: number;
16
+ /** Max decompressed bytes in one pass over a compressed tar too large for `maxInMemorySize`. */
17
+ maxStreamSize: number;
16
18
  /** Max bytes for archive metadata (ZIP central directory, ASAR JSON header, RAR/7z header areas). */
17
19
  maxIndexSize: number;
18
20
  /** Max declared bytes for a single extracted member. */
@@ -26,6 +28,7 @@ export interface ArchiveLimits {
26
28
  export const DEFAULT_ARCHIVE_LIMITS: ArchiveLimits = {
27
29
  maxEntries: 1_000_000,
28
30
  maxInMemorySize: 256 * 1024 * 1024,
31
+ maxStreamSize: 4 * 1024 * 1024 * 1024,
29
32
  maxIndexSize: 64 * 1024 * 1024,
30
33
  maxMemberSize: 64 * 1024 * 1024,
31
34
  maxPathBytes: 4096,
@@ -1,25 +1,27 @@
1
1
  import * as path from "node:path";
2
+ import { formatBytes } from "../format";
2
3
  import { readArj, sniffArj } from "./arj";
3
4
  import { readAsar, sniffAsar } from "./asar";
4
5
  import { readUInt32LE } from "./bytes";
5
6
  import { readCab, sniffCab } from "./cab";
6
7
  import { bzip2Decompress, isBzip2 } from "./codecs/bzip2";
7
- import { gzipDecompress, isGzip } from "./codecs/gzip";
8
+ import { gzipDecompressStream, isGzip } from "./codecs/gzip";
8
9
  import { lzmaAloneDecompress } from "./codecs/lzma";
9
10
  import { isCompressZ, lzwDecompress } from "./codecs/lzw";
10
11
  import { isXz, xzDecompress } from "./codecs/xz";
11
- import { isZstd, zstdDecompress } from "./codecs/zstd";
12
+ import { isZstd, zstdDecompressStream } from "./codecs/zstd";
12
13
  import { readCpio, sniffCpio } from "./cpio";
13
14
  import { readDeb, sniffDeb } from "./deb";
15
+ import { ArchiveError } from "./error";
14
16
  import { readIso, sniffIso } from "./iso";
15
17
  import { assertInMemorySize } from "./limits";
16
18
  import { readLzh, sniffLzh } from "./lzh";
17
19
  import { readRar, sniffRar } from "./rar";
18
20
  import { readRpm, sniffRpm } from "./rpm";
19
21
  import { readSevenZip, sniffSevenZip } from "./sevenzip";
20
- import { readAllBytes } from "./source";
21
- import { readTar, readTarEntriesFromBuffer, sniffTar } from "./tar";
22
- import type { ArchiveFormat, ArchiveIndexEntry, FormatReader, MemberSource } from "./types";
22
+ import { type ByteSource, readAllBytes } from "./source";
23
+ import { readTar, readTarEntriesFromBuffer, readTarEntriesFromStream, sniffTar } from "./tar";
24
+ import type { ArchiveFormat, ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
23
25
  import { readUnixAr, sniffUnixAr } from "./unix-ar";
24
26
  import { readZip, sniffZip } from "./zip";
25
27
 
@@ -100,6 +102,21 @@ class BufferMember implements MemberSource {
100
102
  }
101
103
  }
102
104
 
105
+ /** Index decompressed bytes as tar when they are one, else as a single stem-named member. */
106
+ async function readDecompressed(inner: Uint8Array, options: FormatReadOptions): Promise<ArchiveIndexEntry[]> {
107
+ assertInMemorySize(inner.byteLength, options.limits);
108
+ if (sniffTar(inner)) {
109
+ return readTarEntriesFromBuffer(inner, options);
110
+ }
111
+ const entry: ArchiveIndexEntry = {
112
+ path: stemMemberName(options.archivePath),
113
+ isDirectory: false,
114
+ size: inner.byteLength,
115
+ storage: { type: "member", source: new BufferMember(inner) },
116
+ };
117
+ return [entry];
118
+ }
119
+
103
120
  /**
104
121
  * Reader for one compressed stream: decompress bounded, then index the inner
105
122
  * bytes as tar when they are one, else surface a single stem-named member.
@@ -109,28 +126,50 @@ class BufferMember implements MemberSource {
109
126
  function compressedReader(decompress: (bytes: Uint8Array, maxOutput: number) => Promise<Uint8Array>): FormatReader {
110
127
  return async (source, options) => {
111
128
  assertInMemorySize(source.size, options.limits);
112
- const inner = await decompress(await readAllBytes(source), options.limits.maxInMemorySize);
113
- assertInMemorySize(inner.byteLength, options.limits);
114
- if (sniffTar(inner)) {
115
- return readTarEntriesFromBuffer(inner, options);
129
+ return readDecompressed(await decompress(await readAllBytes(source), options.limits.maxInMemorySize), options);
130
+ };
131
+ }
132
+
133
+ /**
134
+ * {@link compressedReader} for codecs that decompress as a stream. Output that
135
+ * fits `maxInMemorySize` is indexed the same way; a tar that inflates past it
136
+ * is indexed by a streaming header walk instead, its members decompressed
137
+ * again when read, so memory stays bounded by the limits either way. Each pass
138
+ * stops at `maxStreamSize`.
139
+ */
140
+ function streamedReader(
141
+ decompress: (source: ByteSource, maxOutput: number) => AsyncIterable<Uint8Array>,
142
+ ): FormatReader {
143
+ return async (source, options) => {
144
+ const { maxInMemorySize, maxStreamSize } = options.limits;
145
+ const chunks: Uint8Array[] = [];
146
+ let size = 0;
147
+ for await (const chunk of decompress(source, maxStreamSize)) {
148
+ chunks.push(chunk);
149
+ size += chunk.byteLength;
150
+ if (size > maxInMemorySize) break;
151
+ }
152
+ // Copy out what is needed, then drop the chunks so they are not held alongside it.
153
+ const kept = Buffer.concat(chunks, size <= maxInMemorySize ? size : Math.min(size, 512));
154
+ chunks.length = 0;
155
+ if (size <= maxInMemorySize) return readDecompressed(kept, options);
156
+ // Past the limit only a tar has headers to walk; its first 512-byte block tells.
157
+ if (!sniffTar(kept)) {
158
+ throw new ArchiveError(
159
+ `Archive is too large to read in memory (decompressed > ${formatBytes(maxInMemorySize)} limit)`,
160
+ );
116
161
  }
117
- const entry: ArchiveIndexEntry = {
118
- path: stemMemberName(options.archivePath),
119
- isDirectory: false,
120
- size: inner.byteLength,
121
- storage: { type: "member", source: new BufferMember(inner) },
122
- };
123
- return [entry];
162
+ return readTarEntriesFromStream(() => decompress(source, maxStreamSize), options);
124
163
  };
125
164
  }
126
165
 
127
166
  const READERS: Record<ArchiveFormat, FormatReader> = {
128
167
  zip: readZip,
129
168
  tar: readTar,
130
- "tar.gz": compressedReader(gzipDecompress),
169
+ "tar.gz": streamedReader(gzipDecompressStream),
131
170
  "tar.bz2": compressedReader(bzip2Decompress),
132
171
  "tar.xz": compressedReader(xzDecompress),
133
- "tar.zst": compressedReader(zstdDecompress),
172
+ "tar.zst": streamedReader(zstdDecompressStream),
134
173
  "tar.Z": compressedReader(lzwDecompress),
135
174
  asar: readAsar,
136
175
  rar: readRar,
@@ -143,10 +182,10 @@ const READERS: Record<ArchiveFormat, FormatReader> = {
143
182
  deb: readDeb,
144
183
  lzh: readLzh,
145
184
  arj: readArj,
146
- gz: compressedReader(gzipDecompress),
185
+ gz: streamedReader(gzipDecompressStream),
147
186
  bz2: compressedReader(bzip2Decompress),
148
187
  xz: compressedReader(xzDecompress),
149
- zst: compressedReader(zstdDecompress),
188
+ zst: streamedReader(zstdDecompressStream),
150
189
  Z: compressedReader(lzwDecompress),
151
190
  lzma: compressedReader(lzmaAloneDecompress),
152
191
  };
package/src/ar/source.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { Readable, type Transform } from "node:stream";
2
+ import { formatBytes } from "../format";
1
3
  import { LRUCache } from "../lru";
2
4
  import { ArchiveError } from "./error";
3
5
 
@@ -62,6 +64,44 @@ export async function readAllBytes(source: ByteSource): Promise<Uint8Array> {
62
64
  return source.read(0, source.size);
63
65
  }
64
66
 
67
+ /** Chunk size for streamed decompression, on both the compressed and the decompressed side. */
68
+ export const STREAM_CHUNK_SIZE = 1024 * 1024;
69
+
70
+ async function* readChunks(source: ByteSource): AsyncGenerator<Uint8Array> {
71
+ for (let start = 0; start < source.size; start += STREAM_CHUNK_SIZE) {
72
+ yield await source.read(start, Math.min(source.size, start + STREAM_CHUNK_SIZE));
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Decompress a {@link ByteSource} through `decoder` chunk by chunk, so neither
78
+ * the compressed nor the decompressed stream is ever held whole. Output past
79
+ * `maxOutput` bytes fails, so a decompression bomb cannot run without bound.
80
+ */
81
+ export async function* decodeByteSource(
82
+ source: ByteSource,
83
+ decoder: Transform,
84
+ maxOutput: number,
85
+ ): AsyncGenerator<Uint8Array> {
86
+ const input = Readable.from(readChunks(source));
87
+ input.once("error", error => decoder.destroy(error));
88
+ let output = 0;
89
+ try {
90
+ for await (const chunk of input.pipe(decoder)) {
91
+ output += chunk.byteLength;
92
+ if (output > maxOutput) {
93
+ throw new ArchiveError(`Archive decompresses past the ${formatBytes(maxOutput)} stream limit`);
94
+ }
95
+ yield chunk;
96
+ }
97
+ } catch (error) {
98
+ if (error instanceof ArchiveError) throw error;
99
+ throw new ArchiveError(error instanceof Error ? error.message : String(error));
100
+ } finally {
101
+ input.destroy();
102
+ }
103
+ }
104
+
65
105
  /** Options for {@link httpByteSource}. */
66
106
  export interface HttpByteSourceOptions {
67
107
  /** Extra request headers (e.g. authorization). */
package/src/ar/tar.ts CHANGED
@@ -53,13 +53,141 @@ interface PendingTarLink {
53
53
  targetPath: string;
54
54
  }
55
55
 
56
- class TarMemberSource implements MemberSource {
56
+ /** Forward-only reader over tar bytes that arrive in chunks: one buffer, or a decompressing stream. */
57
+ class TarByteStream {
58
+ readonly #chunks: AsyncIterator<Uint8Array> | Iterator<Uint8Array>;
59
+ #chunk: Uint8Array = new Uint8Array(0);
60
+ #position = 0;
61
+
62
+ constructor(chunks: AsyncIterable<Uint8Array> | Iterable<Uint8Array>) {
63
+ this.#chunks = Symbol.asyncIterator in chunks ? chunks[Symbol.asyncIterator]() : chunks[Symbol.iterator]();
64
+ }
65
+
66
+ /** Bytes consumed so far. */
67
+ get position(): number {
68
+ return this.#position;
69
+ }
70
+
71
+ /** The next `length` bytes; shorter only when the stream ends first. */
72
+ async read(length: number): Promise<Uint8Array> {
73
+ if (this.#chunk.byteLength >= length) {
74
+ const bytes = this.#chunk.subarray(0, length);
75
+ this.#chunk = this.#chunk.subarray(length);
76
+ this.#position += length;
77
+ return bytes;
78
+ }
79
+ const bytes = new Uint8Array(length);
80
+ let filled = 0;
81
+ while (filled < length && (this.#chunk.byteLength > 0 || (await this.#next()))) {
82
+ const take = Math.min(length - filled, this.#chunk.byteLength);
83
+ bytes.set(this.#chunk.subarray(0, take), filled);
84
+ this.#chunk = this.#chunk.subarray(take);
85
+ filled += take;
86
+ }
87
+ this.#position += filled;
88
+ return filled === length ? bytes : bytes.subarray(0, filled);
89
+ }
90
+
91
+ /** Step over `length` bytes; false when the stream ends first. */
92
+ async skip(length: number): Promise<boolean> {
93
+ let remaining = length;
94
+ while (remaining > 0) {
95
+ if (this.#chunk.byteLength === 0 && !(await this.#next())) return false;
96
+ const take = Math.min(remaining, this.#chunk.byteLength);
97
+ this.#chunk = this.#chunk.subarray(take);
98
+ this.#position += take;
99
+ remaining -= take;
100
+ }
101
+ return true;
102
+ }
103
+
104
+ /** Stop the underlying stream without draining it. */
105
+ async close(): Promise<void> {
106
+ await this.#chunks.return?.();
107
+ }
108
+
109
+ async #next(): Promise<boolean> {
110
+ const result = await this.#chunks.next();
111
+ if (result.done) return false;
112
+ this.#chunk = result.value;
113
+ return true;
114
+ }
115
+ }
116
+
117
+ /** Where an indexed tar's member bytes come from, by offset into the tar stream. */
118
+ interface TarPayload {
119
+ read(offset: number, size: number, memberPath: string): Promise<Uint8Array>;
120
+ }
121
+
122
+ class TarBufferPayload implements TarPayload {
57
123
  readonly #buffer: Uint8Array;
124
+
125
+ constructor(buffer: Uint8Array) {
126
+ this.#buffer = buffer;
127
+ }
128
+
129
+ async read(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
130
+ if (size > this.#buffer.byteLength - offset) {
131
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
132
+ }
133
+ const bytes = this.#buffer.subarray(offset, offset + size);
134
+ if (bytes.byteLength !== size) {
135
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' has an invalid size`);
136
+ }
137
+ return bytes;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Member bytes of a tar too large to hold decompressed: each read decompresses
143
+ * the stream again up to the member. Reads are serialized, and a read ahead of
144
+ * the previous one continues that pass, so reading every member in archive
145
+ * order decompresses the stream once.
146
+ */
147
+ class TarStreamPayload implements TarPayload {
148
+ readonly #open: () => AsyncIterable<Uint8Array>;
149
+ #stream: TarByteStream | undefined;
150
+ #pending: Promise<unknown> = Promise.resolve();
151
+
152
+ constructor(open: () => AsyncIterable<Uint8Array>) {
153
+ this.#open = open;
154
+ }
155
+
156
+ read(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
157
+ const bytes = this.#pending.then(() => this.#readAt(offset, size, memberPath));
158
+ this.#pending = bytes.catch(() => undefined);
159
+ return bytes;
160
+ }
161
+
162
+ async #readAt(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
163
+ let stream = this.#stream;
164
+ this.#stream = undefined;
165
+ if (!stream || stream.position > offset) {
166
+ await stream?.close();
167
+ stream = new TarByteStream(this.#open());
168
+ }
169
+ try {
170
+ const bytes = (await stream.skip(offset - stream.position)) ? await stream.read(size) : undefined;
171
+ if (bytes?.byteLength !== size) {
172
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
173
+ }
174
+ this.#stream = stream;
175
+ // A view into a decoder chunk would keep the whole chunk alive with the member.
176
+ return bytes.byteLength === bytes.buffer.byteLength ? bytes : new Uint8Array(bytes);
177
+ } catch (error) {
178
+ await stream.close();
179
+ throw error;
180
+ }
181
+ }
182
+ }
183
+
184
+ class TarMemberSource implements MemberSource {
185
+ readonly #payload: TarPayload;
58
186
  readonly #dataOffset: number;
59
187
  readonly #sparse: boolean;
60
188
 
61
- constructor(buffer: Uint8Array, dataOffset: number, sparse: boolean) {
62
- this.#buffer = buffer;
189
+ constructor(payload: TarPayload, dataOffset: number, sparse: boolean) {
190
+ this.#payload = payload;
63
191
  this.#dataOffset = dataOffset;
64
192
  this.#sparse = sparse;
65
193
  }
@@ -70,14 +198,7 @@ class TarMemberSource implements MemberSource {
70
198
  `Archive member '${formatArchivePathForError(memberPath)}' is a sparse file and cannot be read`,
71
199
  );
72
200
  }
73
- if (size > this.#buffer.byteLength - this.#dataOffset) {
74
- throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
75
- }
76
- const bytes = this.#buffer.subarray(this.#dataOffset, this.#dataOffset + size);
77
- if (bytes.byteLength !== size) {
78
- throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' has an invalid size`);
79
- }
80
- return bytes;
201
+ return this.#payload.read(this.#dataOffset, size, memberPath);
81
202
  }
82
203
  }
83
204
 
@@ -172,7 +293,6 @@ function checksumMatches(buffer: Uint8Array, offset: number): boolean {
172
293
  }
173
294
 
174
295
  function parsePaxRecords(data: Uint8Array, limits: ArchiveLimits): Map<string, string> {
175
- assertIndexSize(data.byteLength, limits, "tar PAX metadata");
176
296
  const attrs = new Map<string, string>();
177
297
  let pos = 0;
178
298
  while (pos < data.byteLength) {
@@ -295,7 +415,6 @@ function applyOldGnuNameRecords(
295
415
  pendingLinks: Map<ArchiveIndexEntry, PendingTarLink>,
296
416
  limits: ArchiveLimits,
297
417
  ): void {
298
- assertIndexSize(data.byteLength, limits, "old-GNU name metadata");
299
418
  const terminator = data.indexOf(0);
300
419
  const end = terminator === -1 ? data.byteLength : terminator;
301
420
  let start = 0;
@@ -412,10 +531,31 @@ function resolvePendingLinks(
412
531
  if (unresolved.size > 0) throw new ArchiveError("Archive contains cyclic or unsupported links");
413
532
  }
414
533
 
415
- /** Index an already-decompressed tar buffer with bounded GNU, ustar, and PAX handling. */
416
- export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatReadOptions): ArchiveIndexEntry[] {
534
+ /** Read one metadata record's payload, bounded by `maxIndexSize`, and step over its padding. */
535
+ async function readTarMetadata(
536
+ stream: TarByteStream,
537
+ size: number,
538
+ limits: ArchiveLimits,
539
+ what: string,
540
+ ): Promise<Uint8Array> {
541
+ assertIndexSize(size, limits, what);
542
+ const data = await stream.read(size);
543
+ if (data.byteLength !== size || !(await stream.skip(paddedSize(size) - size))) {
544
+ throw new ArchiveError("Archive member data is truncated");
545
+ }
546
+ return data;
547
+ }
548
+
549
+ /**
550
+ * Walk tar headers with bounded GNU, ustar, and PAX handling. Member data is
551
+ * stepped over, never held: entries point into `payload` by stream offset.
552
+ */
553
+ async function readTarEntries(
554
+ stream: TarByteStream,
555
+ payload: TarPayload,
556
+ options: FormatReadOptions,
557
+ ): Promise<ArchiveIndexEntry[]> {
417
558
  const { limits } = options;
418
- assertInMemorySize(buffer.byteLength, limits);
419
559
  const entries = new Map<string, ArchiveIndexEntry>();
420
560
  const pendingLinks = new Map<ArchiveIndexEntry, PendingTarLink>();
421
561
  const addEntry = (entry: ArchiveIndexEntry, pendingLink?: PendingTarLink): void => {
@@ -426,61 +566,58 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
426
566
  if (pendingLink) pendingLinks.set(indexed, pendingLink);
427
567
  assertEntryCount(entries.size, limits);
428
568
  };
429
- let offset = 0;
430
569
  let longName: string | undefined;
431
570
  let longLink: string | undefined;
432
571
  let localPax: Map<string, string> | undefined;
433
572
  const globalPax = new Map<string, string>();
434
573
  let sawTerminator = false;
435
574
 
436
- while (offset + BLOCK_SIZE <= buffer.byteLength) {
437
- if (isZeroBlock(buffer, offset)) {
575
+ for (
576
+ let header = await stream.read(BLOCK_SIZE);
577
+ header.byteLength === BLOCK_SIZE;
578
+ header = await stream.read(BLOCK_SIZE)
579
+ ) {
580
+ if (isZeroBlock(header, 0)) {
438
581
  sawTerminator = true;
439
582
  break;
440
583
  }
441
- if (!checksumMatches(buffer, offset)) throw new ArchiveError("Invalid or corrupt tar archive header");
442
- const headerOffset = offset;
443
- const typeFlag = String.fromCharCode(buffer[headerOffset + TYPEFLAG_OFFSET] || 0x30);
444
- let size = readTarSize(buffer, headerOffset + SIZE_OFFSET);
445
- let name = readTarString(buffer, headerOffset + NAME_OFFSET, NAME_LENGTH);
446
- if (isUstarHeader(buffer, headerOffset)) {
447
- const prefix = readTarString(buffer, headerOffset + PREFIX_OFFSET, PREFIX_LENGTH);
584
+ if (!checksumMatches(header, 0)) throw new ArchiveError("Invalid or corrupt tar archive header");
585
+ const typeFlag = String.fromCharCode(header[TYPEFLAG_OFFSET] || 0x30);
586
+ let size = readTarSize(header, SIZE_OFFSET);
587
+ let name = readTarString(header, NAME_OFFSET, NAME_LENGTH);
588
+ if (isUstarHeader(header, 0)) {
589
+ const prefix = readTarString(header, PREFIX_OFFSET, PREFIX_LENGTH);
448
590
  if (prefix) name = `${prefix}/${name}`;
449
591
  }
450
- let linkName = readTarString(buffer, headerOffset + LINKNAME_OFFSET, LINKNAME_LENGTH);
451
- const mtime = readTarNumeric(buffer, headerOffset + MTIME_OFFSET, MTIME_LENGTH);
452
- const rawMode = readTarNumeric(buffer, headerOffset + MODE_OFFSET, MODE_LENGTH);
592
+ let linkName = readTarString(header, LINKNAME_OFFSET, LINKNAME_LENGTH);
593
+ const mtime = readTarNumeric(header, MTIME_OFFSET, MTIME_LENGTH);
594
+ const rawMode = readTarNumeric(header, MODE_OFFSET, MODE_LENGTH);
453
595
  const mode = Number.isSafeInteger(rawMode) && rawMode >= 0 ? rawMode : undefined;
454
- offset += BLOCK_SIZE;
455
- const dataBlocks = paddedSize(size);
456
- if (dataBlocks > buffer.byteLength - offset) throw new ArchiveError("Archive member data is truncated");
457
- const data = buffer.subarray(offset, offset + size);
458
596
 
459
597
  if (typeFlag === "L") {
460
- assertIndexSize(data.byteLength, limits, "GNU long-name metadata");
598
+ const data = await readTarMetadata(stream, size, limits, "GNU long-name metadata");
461
599
  longName = readMetadataPath(data, "GNU long path", limits);
462
- offset += dataBlocks;
463
600
  continue;
464
601
  }
465
602
  if (typeFlag === "K") {
466
- assertIndexSize(data.byteLength, limits, "GNU long-link metadata");
603
+ const data = await readTarMetadata(stream, size, limits, "GNU long-link metadata");
467
604
  longLink = readMetadataPath(data, "GNU long link target", limits);
468
- offset += dataBlocks;
469
605
  continue;
470
606
  }
471
607
  if (typeFlag === "N") {
608
+ const data = await readTarMetadata(stream, size, limits, "old-GNU name metadata");
472
609
  applyOldGnuNameRecords(data, entries, pendingLinks, limits);
473
- offset += dataBlocks;
474
610
  continue;
475
611
  }
476
612
  if (typeFlag === "x" || typeFlag === "X") {
477
- localPax = parsePaxRecords(data, limits);
478
- offset += dataBlocks;
613
+ localPax = parsePaxRecords(await readTarMetadata(stream, size, limits, "tar PAX metadata"), limits);
479
614
  continue;
480
615
  }
481
616
  if (typeFlag === "g") {
482
- applyGlobalPax(globalPax, parsePaxRecords(data, limits));
483
- offset += dataBlocks;
617
+ applyGlobalPax(
618
+ globalPax,
619
+ parsePaxRecords(await readTarMetadata(stream, size, limits, "tar PAX metadata"), limits),
620
+ );
484
621
  continue;
485
622
  }
486
623
  if (typeFlag === "M") throw new ArchiveError("GNU multi-volume tar members are not supported");
@@ -499,22 +636,18 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
499
636
  const paxSparseRealSize = paxAttribute(globalPax, localPax, "GNU.sparse.realsize");
500
637
  if (paxSparseRealSize !== undefined) displaySize = parsePaxSize(paxSparseRealSize, "sparse real size");
501
638
  const sparse = typeFlag === "S" || paxDeclaresSparse(globalPax, localPax);
502
- if (typeFlag === "S" && buffer[headerOffset + GNU_SPARSE_ISEXTENDED_OFFSET] === 1) {
639
+ if (typeFlag === "S" && header[GNU_SPARSE_ISEXTENDED_OFFSET] === 1) {
503
640
  let extended = true;
504
641
  while (extended) {
505
- if (offset + BLOCK_SIZE > buffer.byteLength) {
506
- throw new ArchiveError("Archive sparse metadata is truncated");
507
- }
508
- extended = buffer[offset + GNU_SPARSE_CONT_ISEXTENDED_OFFSET] === 1;
509
- offset += BLOCK_SIZE;
642
+ const block = await stream.read(BLOCK_SIZE);
643
+ if (block.byteLength < BLOCK_SIZE) throw new ArchiveError("Archive sparse metadata is truncated");
644
+ extended = block[GNU_SPARSE_CONT_ISEXTENDED_OFFSET] === 1;
510
645
  }
511
646
  }
512
- const dataOffset = offset;
513
- const memberDataBlocks = paddedSize(size);
514
- if (memberDataBlocks > buffer.byteLength - dataOffset) {
647
+ const dataOffset = stream.position;
648
+ if (!(await stream.skip(paddedSize(size)))) {
515
649
  throw new ArchiveError(`Archive member '${formatArchivePathForError(name)}' is truncated`);
516
650
  }
517
- offset += memberDataBlocks;
518
651
  longName = undefined;
519
652
  longLink = undefined;
520
653
  localPax = undefined;
@@ -567,7 +700,7 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
567
700
  size: displaySize,
568
701
  mtimeMs,
569
702
  mode,
570
- storage: { type: "member", source: new TarMemberSource(buffer, dataOffset, sparse) },
703
+ storage: { type: "member", source: new TarMemberSource(payload, dataOffset, sparse) },
571
704
  });
572
705
  }
573
706
  if (!sawTerminator) throw new ArchiveError("Not a valid tar archive: missing terminating zero block");
@@ -575,6 +708,35 @@ export function readTarEntriesFromBuffer(buffer: Uint8Array, options: FormatRead
575
708
  return [...entries.values()];
576
709
  }
577
710
 
711
+ /** Index an already-decompressed tar buffer; members are views into it. */
712
+ export async function readTarEntriesFromBuffer(
713
+ buffer: Uint8Array,
714
+ options: FormatReadOptions,
715
+ ): Promise<ArchiveIndexEntry[]> {
716
+ assertInMemorySize(buffer.byteLength, options.limits);
717
+ return readTarEntries(new TarByteStream([buffer]), new TarBufferPayload(buffer), options);
718
+ }
719
+
720
+ /**
721
+ * Index a decompressed tar stream that is too large to hold in memory: one
722
+ * pass over `open()` walks the headers, and member reads open it again.
723
+ */
724
+ export async function readTarEntriesFromStream(
725
+ open: () => AsyncIterable<Uint8Array>,
726
+ options: FormatReadOptions,
727
+ ): Promise<ArchiveIndexEntry[]> {
728
+ const stream = new TarByteStream(open());
729
+ try {
730
+ const entries = await readTarEntries(stream, new TarStreamPayload(open), options);
731
+ // Decompress past the terminator to the end, so the codec still checks the
732
+ // stream's trailer (gzip CRC and length) as a whole-stream decompression does.
733
+ await stream.skip(Number.POSITIVE_INFINITY);
734
+ return entries;
735
+ } finally {
736
+ await stream.close();
737
+ }
738
+ }
739
+
578
740
  /** Read and index a tar source after one bounded whole-stream read. */
579
741
  export const readTar: FormatReader = async (source, options) => {
580
742
  assertInMemorySize(source.size, options.limits);
package/src/dirs.ts CHANGED
@@ -17,6 +17,7 @@ import * as path from "node:path";
17
17
  import { expandWindowsLongPath } from "@oh-my-pi/pi-natives/path";
18
18
  import { engines, version } from "../package.json" with { type: "json" };
19
19
  import { isEnoent, isEnotdir } from "./fs-error";
20
+ import { isBunTestRuntime } from "./test-runtime";
20
21
 
21
22
  /** App name (e.g. "omp") */
22
23
  export const APP_NAME: string = "omp";
@@ -160,12 +161,33 @@ function standardizeProjectPath(p: string): string {
160
161
  return process.platform === "win32" ? expandWindowsLongPath(p) : standardizeMacOSPath(p);
161
162
  }
162
163
 
163
- export function resolveEquivalentPath(inputPath: string): string {
164
+ export interface EquivalentPathOptions {
165
+ /**
166
+ * Resolve a path that does not exist yet through its deepest existing ancestor, keeping the
167
+ * missing suffix, so a symlinked parent canonicalizes the same before and after the path appears.
168
+ */
169
+ throughMissing?: boolean;
170
+ }
171
+
172
+ export function resolveEquivalentPath(inputPath: string, options?: EquivalentPathOptions): string {
164
173
  const resolvedPath = path.resolve(inputPath);
165
174
  try {
166
175
  return fs.realpathSync(resolvedPath);
167
176
  } catch {
168
- return resolvedPath;
177
+ if (!options?.throughMissing) return resolvedPath;
178
+ }
179
+ const missing: string[] = [];
180
+ let current = resolvedPath;
181
+ for (;;) {
182
+ const parent = path.dirname(current);
183
+ if (parent === current) return resolvedPath;
184
+ missing.unshift(path.basename(current));
185
+ current = parent;
186
+ try {
187
+ return path.join(fs.realpathSync(current), ...missing);
188
+ } catch {
189
+ // Keep walking: a higher ancestor may exist.
190
+ }
169
191
  }
170
192
  }
171
193
 
@@ -634,8 +656,17 @@ export function getReportsDir(): string {
634
656
  return dirs.rootSubdir("reports", "state");
635
657
  }
636
658
 
637
- /** Get the logs directory (~/.omp/logs). */
659
+ /**
660
+ * Get the logs directory (~/.omp/logs). `bun test` processes get
661
+ * `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
662
+ * developer's real logs; the uid suffix (as for the test session-owners dir)
663
+ * keeps users of a shared `/tmp` from owning each other's directory.
664
+ */
638
665
  export function getLogsDir(): string {
666
+ if (isBunTestRuntime()) {
667
+ const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().username;
668
+ return path.join(os.tmpdir(), `omp-test-logs-${uid}`);
669
+ }
639
670
  return dirs.rootSubdir("logs", "state");
640
671
  }
641
672
 
package/src/env.ts CHANGED
@@ -3,7 +3,9 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { parseEnv } from "node:util";
5
5
  import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
6
+ import { isBunTestRuntime } from "./test-runtime";
6
7
 
8
+ export * from "./test-runtime";
7
9
  export * from "./worker-host";
8
10
 
9
11
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -378,15 +380,6 @@ export function $envpos(name: string, defaultValue: number): number {
378
380
  return parsed;
379
381
  }
380
382
 
381
- const BUN_TEST_ENTRY_PATTERN = /[._](?:test|spec)\.[cm]?[jt]sx?$/;
382
-
383
- /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
384
- export function isBunTestRuntime(): boolean {
385
- if (Bun.env.PI_TEST_RUNTIME === "1") return true;
386
- const hasTestEnvironment = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
387
- return hasTestEnvironment && BUN_TEST_ENTRY_PATTERN.test(Bun.main);
388
- }
389
-
390
383
  let terminalHeadless = isBunTestRuntime();
391
384
 
392
385
  /**
@@ -2,8 +2,13 @@ import { YAML } from "bun";
2
2
  import { truncate } from "./format";
3
3
  import * as logger from "./logger";
4
4
 
5
- function stripHtmlComments(content: string): string {
6
- return content.replace(/<!--[\s\S]*?-->/g, "");
5
+ function stripLeadingHtmlComments(content: string): string {
6
+ const leading = /^(?:(?:\s*<!--[\s\S]*?-->)+)/.exec(content);
7
+ if (!leading) return content;
8
+ const rest = content.slice(leading[0].length);
9
+ // Only repair a comment that precedes frontmatter; leave a plain document
10
+ // (and every body) byte-identical so HTML comments inside it survive.
11
+ return /^\s*---/.test(rest) ? rest.replace(/^\s+/, "") : content;
7
12
  }
8
13
 
9
14
  /** Convert kebab-case to camelCase (e.g. "thinking-level" -> "thinkingLevel") */
@@ -142,7 +147,8 @@ export function parseFrontmatter(
142
147
  const frontmatter: Record<string, unknown> = { ...fallback };
143
148
 
144
149
  const newlineNormalized = normalize ? content.replace(/\r\n?/g, "\n") : content;
145
- const normalized = normalize && repair ? stripHtmlComments(newlineNormalized) : newlineNormalized;
150
+ const repairHtmlComments = normalize && repair;
151
+ const normalized = repairHtmlComments ? stripLeadingHtmlComments(newlineNormalized) : newlineNormalized;
146
152
  if (!normalized.startsWith("---")) {
147
153
  return { frontmatter, body: normalized };
148
154
  }
@@ -152,7 +158,8 @@ export function parseFrontmatter(
152
158
  return { frontmatter, body: normalized };
153
159
  }
154
160
 
155
- const metadata = normalized.slice(4, endIndex);
161
+ const rawMetadata = normalized.slice(4, endIndex);
162
+ const metadata = repairHtmlComments ? rawMetadata.replace(/<!--[\s\S]*?-->/g, "") : rawMetadata;
156
163
  const body = normalized.slice(endIndex + 4).trim();
157
164
 
158
165
  try {
@@ -0,0 +1,8 @@
1
+ const BUN_TEST_ENTRY_PATTERN = /[._](?:test|spec)\.[cm]?[jt]sx?$/;
2
+
3
+ /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
4
+ export function isBunTestRuntime(): boolean {
5
+ if (Bun.env.PI_TEST_RUNTIME === "1") return true;
6
+ const hasTestEnvironment = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
7
+ return hasTestEnvironment && BUN_TEST_ENTRY_PATTERN.test(Bun.main);
8
+ }