Skip to content

Commit ab2e073

Browse files
authored
fix(polyfill): add missing Bun.file().stat() shim for npm distribution (#838)
## Summary - Fixes **CLI-1EA** (14 events) and **CLI-1EB** (420 events, escalating) — both share a single root cause. - Commit 9cb0d33 ("refactor(dsn): use Bun stat in regular-file guard", released in **0.29.0**) swapped `stat` from `node:fs/promises` for `Bun.file(path).stat()` in `isRegularFile()`. The Node polyfill in `script/node-polyfills.ts` (injected at bundle time for the npm distribution) never implemented `.stat()`, so every DSN auto-detection call throws `TypeError: Bun.file(...).stat is not a function` on Node. - The error is caught by `isRegularFile`'s `try/catch` but then captured to Sentry (not in `isIgnorableFileError`'s allow-list), so npm users on 0.29.0 silently lose `.env` DSN detection + `.editorconfig` project-root detection while spamming our Sentry project with handled TypeErrors. ## Fix Extend `BunPolyfill.file(path)` with `stat: stat.bind(null, path)` — aliases `stat` from `node:fs/promises` with the path pre-bound. No wrapper closure needed; matches `Bun.file().stat()`'s zero-arg signature exactly. `fs.stat` (not `lstat`) follows symlinks, matching Bun semantics — critical for detecting 1Password `symlink → FIFO` chains. Minimal one-line addition to the polyfill + a new `stat` in the existing top-level `node:fs/promises` import. ## Tests - Extended `test/script/node-polyfills.test.ts` (the existing `polyfillFile()` reproduction) with 5 new `.stat()` cases: regular file, directory, non-existent path (ENOENT), symlink following, and parity with native `Bun.file().stat()`. - Added `test/lib/node-polyfills-file-stat.test.ts` — a mirror that lives under the `bun run test:unit` glob so CI actually runs it (the `test/script/` directory is not in any CI test script per AGENTS.md). All 39 polyfill tests pass; full `bun test test/lib` is green (4192 pass, 0 fail). ## Release Intended as the content of a **0.29.1 patch release** (will cut after merge). ## Verification - [x] `bun run typecheck` — clean - [x] `bun run lint` — only pre-existing warning in `src/lib/formatters/markdown.ts` (not touched) - [x] `bun test test/lib` — 4192 pass, 0 fail - [x] `bun test test/script/node-polyfills.test.ts test/lib/node-polyfills-file-stat.test.ts` — 39 pass
1 parent 1cd5c76 commit ab2e073

3 files changed

Lines changed: 182 additions & 1 deletion

File tree

‎script/node-polyfills.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
spawnSync as nodeSpawnSync,
88
} from "node:child_process";
99
import { statSync } from "node:fs";
10-
import { access, readFile, writeFile } from "node:fs/promises";
10+
import { access, readFile, stat, writeFile } from "node:fs/promises";
1111
// node:sqlite is imported lazily inside NodeDatabasePolyfill to avoid
1212
// crashing on Node.js versions without node:sqlite support when the
1313
// bundle is loaded as a library (the consumer may never use SQLite).
@@ -127,6 +127,8 @@ const BunPolyfill = {
127127
return false;
128128
}
129129
},
130+
// Follows symlinks (stat, not lstat) — matches Bun.file().stat() semantics.
131+
stat: stat.bind(null, path),
130132
text(): Promise<string> {
131133
return readFile(path, "utf-8");
132134
},
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* CLI-1EA / CLI-1EB regression: the npm distribution's Bun.file() polyfill
3+
* was missing `.stat()`, causing every DSN auto-detection call on Node to
4+
* throw `TypeError: Bun.file(...).stat is not a function`.
5+
*
6+
* This file lives under `test/lib/` so it's picked up by `bun run test:unit`
7+
* (the primary `test/script/node-polyfills.test.ts` is outside the CI
8+
* globs — see the "test:unit glob" gotcha in AGENTS.md).
9+
*
10+
* We reproduce the minimal shape of the polyfill inline to mirror the test
11+
* pattern in `test/script/node-polyfills.test.ts`. If the polyfill's
12+
* `.stat()` shim changes shape in `script/node-polyfills.ts`, update this
13+
* reproduction too.
14+
*/
15+
16+
import { describe, expect, test } from "bun:test";
17+
import { execSync } from "node:child_process";
18+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19+
import { stat } from "node:fs/promises";
20+
import { tmpdir } from "node:os";
21+
import { join } from "node:path";
22+
23+
/**
24+
* Mirrors the `.stat` member of the object returned by
25+
* `BunPolyfill.file(path)` in `script/node-polyfills.ts`.
26+
*/
27+
function polyfillFileStat(
28+
path: string
29+
): () => Promise<import("node:fs").Stats> {
30+
// Follows symlinks (stat, not lstat) — matches Bun.file().stat() semantics.
31+
return stat.bind(null, path);
32+
}
33+
34+
describe("node polyfill Bun.file().stat() (CLI-1EA, CLI-1EB)", () => {
35+
test("regular file resolves with isFile()=true", async () => {
36+
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-stat-"));
37+
const filePath = join(tmpDir, "regular.txt");
38+
try {
39+
writeFileSync(filePath, "hello");
40+
const stats = await polyfillFileStat(filePath)();
41+
expect(stats.isFile()).toBe(true);
42+
expect(stats.isDirectory()).toBe(false);
43+
expect(stats.size).toBe(5);
44+
} finally {
45+
rmSync(tmpDir, { recursive: true });
46+
}
47+
});
48+
49+
test("directory resolves with isDirectory()=true, isFile()=false", async () => {
50+
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-stat-"));
51+
try {
52+
const stats = await polyfillFileStat(tmpDir)();
53+
expect(stats.isDirectory()).toBe(true);
54+
expect(stats.isFile()).toBe(false);
55+
} finally {
56+
rmSync(tmpDir, { recursive: true });
57+
}
58+
});
59+
60+
test("non-existent path rejects with ENOENT", async () => {
61+
const statFn = polyfillFileStat("/tmp/__nonexistent_cli_1ea_test__");
62+
try {
63+
await statFn();
64+
throw new Error("expected stat to reject");
65+
} catch (err) {
66+
expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
67+
}
68+
});
69+
70+
test("follows symlinks (returns target type, matches Bun.file().stat())", async () => {
71+
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-stat-"));
72+
const targetPath = join(tmpDir, "target.txt");
73+
const linkPath = join(tmpDir, "link.txt");
74+
try {
75+
writeFileSync(targetPath, "data");
76+
execSync(
77+
`ln -s ${JSON.stringify(targetPath)} ${JSON.stringify(linkPath)}`
78+
);
79+
const stats = await polyfillFileStat(linkPath)();
80+
// stat (not lstat) follows the symlink; we must see the regular file.
81+
expect(stats.isFile()).toBe(true);
82+
expect(stats.isSymbolicLink()).toBe(false);
83+
} finally {
84+
rmSync(tmpDir, { recursive: true });
85+
}
86+
});
87+
88+
test("parity with native Bun.file().stat()", async () => {
89+
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-stat-"));
90+
const filePath = join(tmpDir, "compare.txt");
91+
try {
92+
writeFileSync(filePath, "compare");
93+
const polyfillStats = await polyfillFileStat(filePath)();
94+
const bunStats = await Bun.file(filePath).stat();
95+
expect(polyfillStats.isFile()).toBe(bunStats.isFile());
96+
expect(polyfillStats.isDirectory()).toBe(bunStats.isDirectory());
97+
expect(polyfillStats.size).toBe(bunStats.size);
98+
} finally {
99+
rmSync(tmpDir, { recursive: true });
100+
}
101+
});
102+
});

‎test/script/node-polyfills.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
spawnSync as nodeSpawnSync,
2121
} from "node:child_process";
2222
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
23+
import { stat } from "node:fs/promises";
2324
import { tmpdir } from "node:os";
2425
import { join } from "node:path";
2526

@@ -339,6 +340,8 @@ function polyfillFile(path: string) {
339340
return 0;
340341
}
341342
},
343+
// Follows symlinks (stat, not lstat) — matches Bun.file().stat() semantics.
344+
stat: stat.bind(null, path),
342345
};
343346
}
344347

@@ -415,6 +418,80 @@ describe("file polyfill size and lastModified", () => {
415418
});
416419
});
417420

421+
describe("file polyfill stat() (CLI-1EA, CLI-1EB regression)", () => {
422+
test("stat() resolves to a Stats object with isFile()=true for a regular file", async () => {
423+
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-file-stat-"));
424+
const filePath = join(tmpDir, "regular.txt");
425+
try {
426+
writeFileSync(filePath, "hello");
427+
const pf = polyfillFile(filePath);
428+
const stats = await pf.stat();
429+
expect(stats.isFile()).toBe(true);
430+
expect(stats.isDirectory()).toBe(false);
431+
} finally {
432+
rmSync(tmpDir, { recursive: true });
433+
}
434+
});
435+
436+
test("stat() reports a directory as !isFile()", async () => {
437+
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-file-stat-"));
438+
try {
439+
const pf = polyfillFile(tmpDir);
440+
const stats = await pf.stat();
441+
expect(stats.isFile()).toBe(false);
442+
expect(stats.isDirectory()).toBe(true);
443+
} finally {
444+
rmSync(tmpDir, { recursive: true });
445+
}
446+
});
447+
448+
test("stat() throws ENOENT for a non-existent path", async () => {
449+
const pf = polyfillFile("/tmp/__nonexistent_polyfill_stat_test__");
450+
try {
451+
await pf.stat();
452+
throw new Error("expected stat() to throw");
453+
} catch (err) {
454+
expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
455+
}
456+
});
457+
458+
test("stat() follows symlinks (returns target type, not lstat)", async () => {
459+
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-file-stat-"));
460+
const targetPath = join(tmpDir, "target.txt");
461+
const linkPath = join(tmpDir, "link.txt");
462+
try {
463+
writeFileSync(targetPath, "data");
464+
// Create a symlink link.txt → target.txt
465+
execSync(
466+
`ln -s ${JSON.stringify(targetPath)} ${JSON.stringify(linkPath)}`
467+
);
468+
const pf = polyfillFile(linkPath);
469+
const stats = await pf.stat();
470+
// stat (not lstat) follows the symlink to the regular file target.
471+
expect(stats.isFile()).toBe(true);
472+
expect(stats.isSymbolicLink()).toBe(false);
473+
} finally {
474+
rmSync(tmpDir, { recursive: true });
475+
}
476+
});
477+
478+
test("stat() is consistent with Bun.file().stat()", async () => {
479+
const tmpDir = mkdtempSync(join(tmpdir(), "polyfill-file-stat-"));
480+
const filePath = join(tmpDir, "compare.txt");
481+
try {
482+
writeFileSync(filePath, "compare");
483+
const pf = polyfillFile(filePath);
484+
const polyfillStats = await pf.stat();
485+
const bunStats = await Bun.file(filePath).stat();
486+
expect(polyfillStats.isFile()).toBe(bunStats.isFile());
487+
expect(polyfillStats.isDirectory()).toBe(bunStats.isDirectory());
488+
expect(polyfillStats.size).toBe(bunStats.size);
489+
} finally {
490+
rmSync(tmpDir, { recursive: true });
491+
}
492+
});
493+
});
494+
418495
/**
419496
* Reproduces the exact which() polyfill logic from script/node-polyfills.ts
420497
* with PATH option support.

0 commit comments

Comments
 (0)