Skip to content

Commit 94382ba

Browse files
authored
fix: add Glob.match() polyfill + improve auto-detect diagnostics (CLI-7T) (#487)
## Problem When users run `sentry issue list` or `sentry issues` without explicit org/project arguments, the auto-detection cascade fails with `ContextError: Organization and project are required.` — 185 events affecting 63 users ([CLI-7T](https://sentry.sentry.io/issues/7283798253/)). **100% of events** are from the Node.js/npm distribution (`cli.runtime: node`), and 46% use `--json` (AI agents/tool callers). ### Root cause The `BunGlobPolyfill` class in the Node.js polyfill was missing the `match()` method. When `anyGlobMatches()` in `project-root.ts` called `new Bun.Glob(pattern).match(name)`, it threw a `TypeError` that was silently swallowed by a bare `catch {}` block. This broke project root detection for .NET (`*.sln`, `*.csproj`), Haskell (`*.cabal`), OCaml (`*.opam`), and Nim (`*.nimble`) projects on the npm distribution. While this bug is real, the primary cause for most users is simply running the CLI from a directory without a Sentry-instrumented project and with no env vars or config defaults set. The diagnostics and error message improvements help these users find the right path forward. ## Fix 1. **Add `match()` to `BunGlobPolyfill`** — Uses `picomatch` (already bundled via `tinyglobby`) with `{dot: true}` to match `Bun.Glob` behavior. 2. **Add debug logging to `resolveAllTargets()`** — Each fallthrough step (env vars → config defaults → DSN detection → directory inference) now logs at debug level, visible with `--verbose`. 3. **Improve `ContextError` default alternatives** — Adds `sentry org list` and `sentry project list <org>/` hints so users who don't know their slugs have a clear next step. ## Tests - 5 new tests for `Glob.match()` covering all `LANGUAGE_MARKER_GLOBS` patterns, negative cases, directory path rejection, and consistency with native `Bun.Glob.match()`. - All existing tests pass. Typecheck and lint clean. Fixes CLI-7T
1 parent f8eb06b commit 94382ba

File tree

7 files changed

+184
-46
lines changed

7 files changed

+184
-46
lines changed

AGENTS.md

Lines changed: 42 additions & 38 deletions
Large diffs are not rendered by default.

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"marked": "^15",
3232
"openai": "^6.22.0",
3333
"p-limit": "^7.2.0",
34+
"picomatch": "^4.0.3",
3435
"pretty-ms": "^9.3.0",
3536
"qrcode-terminal": "^0.12.0",
3637
"semver": "^7.7.3",

script/node-polyfills.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { execSync, spawn as nodeSpawn } from "node:child_process";
55
import { access, readFile, writeFile } from "node:fs/promises";
66
import { DatabaseSync } from "node:sqlite";
77

8+
import picomatch from "picomatch";
89
import { compare as semverCompare } from "semver";
910
import { glob } from "tinyglobby";
1011
import { uuidv7 } from "uuidv7";
@@ -175,9 +176,25 @@ const BunPolyfill = {
175176

176177
Glob: class BunGlobPolyfill {
177178
private pattern: string;
179+
/** Compiled matcher — created once at construction, reused on every match() call. */
180+
private matcher: (input: string) => boolean;
181+
178182
constructor(pattern: string) {
179183
this.pattern = pattern;
184+
// Compile once with dot:true to match Bun.Glob behavior where
185+
// `*` matches dotfiles by default (unlike picomatch defaults).
186+
this.matcher = picomatch(pattern, { dot: true });
187+
}
188+
189+
/**
190+
* Synchronously test whether a string matches the glob pattern.
191+
* Mirrors Bun.Glob.match() used by project-root detection for
192+
* language marker globs (*.sln, *.csproj, etc.).
193+
*/
194+
match(input: string): boolean {
195+
return this.matcher(input);
180196
}
197+
181198
async *scan(opts?: { cwd?: string }): AsyncIterable<string> {
182199
const results = await glob(this.pattern, {
183200
cwd: opts?.cwd || process.cwd(),

src/lib/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ export class OutputError extends CliError {
153153
const DEFAULT_CONTEXT_ALTERNATIVES = [
154154
"Run from a directory with a Sentry-configured project",
155155
"Set SENTRY_ORG and SENTRY_PROJECT (or SENTRY_DSN) environment variables",
156+
"Run 'sentry org list' to find your organization slug",
157+
"Run 'sentry project list <org>/' to find project slugs",
156158
] as const;
157159

158160
/**

src/lib/resolve-target.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,8 @@ export async function resolveAllTargets(
694694
);
695695
}
696696

697+
log.debug("No explicit org/project flags provided, trying env vars");
698+
697699
// 2. SENTRY_ORG / SENTRY_PROJECT environment variables
698700
const envVars = resolveFromEnvVars();
699701
if (envVars?.project) {
@@ -710,6 +712,8 @@ export async function resolveAllTargets(
710712
};
711713
}
712714

715+
log.debug("No SENTRY_ORG/SENTRY_PROJECT env vars, trying config defaults");
716+
713717
// 3. Config defaults
714718
const defaultOrg = await getDefaultOrganization();
715719
const defaultProject = await getDefaultProject();
@@ -726,12 +730,23 @@ export async function resolveAllTargets(
726730
};
727731
}
728732

733+
log.debug("No config defaults set, trying DSN auto-detection");
734+
729735
// 4. DSN auto-detection (may find multiple in monorepos)
730736
const detection = await detectAllDsns(cwd);
731737

732738
if (detection.all.length === 0) {
739+
log.debug(
740+
"No DSNs found in source code or env files, trying directory name inference"
741+
);
733742
// 5. Fallback: infer from directory name
734-
return inferFromDirectoryName(cwd);
743+
const result = await inferFromDirectoryName(cwd);
744+
if (result.targets.length === 0) {
745+
log.debug(
746+
"Directory name inference found no matching projects — auto-detection failed"
747+
);
748+
}
749+
return result;
735750
}
736751

737752
return resolveDetectedDsns(detection);

test/script/node-polyfills.test.ts

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
/**
2-
* Node.js Polyfill Tests — Bun.spawn
2+
* Node.js Polyfill Tests — Bun.spawn and Bun.Glob
33
*
4-
* Tests the spawn logic used by the Node.js polyfill in script/node-polyfills.ts.
4+
* Tests the spawn and glob logic used by the Node.js polyfill in
5+
* script/node-polyfills.ts.
56
*
67
* We can't import the polyfill directly (it overwrites globalThis.Bun and has
7-
* side effects), so we reproduce the exact spawn implementation and verify its
8-
* contract: exited promise, stdin piping, env passthrough, and inherit stdio.
8+
* side effects), so we reproduce the exact implementation and verify its
9+
* contract.
910
*
10-
* Fixes CLI-68: the original polyfill returned no `exited` property, causing
11-
* `await proc.exited` to resolve to `undefined` and the upgrade command to
12-
* throw "Setup failed with exit code undefined".
11+
* Fixes CLI-68: spawn polyfill returned no `exited` property.
12+
* Fixes CLI-7T: Glob polyfill was missing `match()`, causing silent
13+
* failures in project-root detection for .NET/Haskell/OCaml/Nim projects
14+
* on the Node.js distribution.
1315
*/
1416

1517
import { describe, expect, test } from "bun:test";
@@ -128,3 +130,99 @@ describe("spawn polyfill", () => {
128130
expect(() => proc.unref()).not.toThrow();
129131
});
130132
});
133+
134+
/**
135+
* Reproduces the exact Glob.match() logic from script/node-polyfills.ts.
136+
* Kept in sync manually — if the polyfill changes, update this too.
137+
*/
138+
/**
139+
* Lazy-loaded picomatch — imported once and cached.
140+
* Uses require() to avoid top-level import of an untyped CJS module.
141+
*/
142+
let picomatch: any;
143+
function getPicomatch() {
144+
if (!picomatch) {
145+
picomatch = require("picomatch");
146+
}
147+
return picomatch;
148+
}
149+
150+
class PolyfillGlob {
151+
private readonly matcher: (input: string) => boolean;
152+
constructor(pattern: string) {
153+
this.matcher = getPicomatch()(pattern, { dot: true });
154+
}
155+
match(input: string): boolean {
156+
return this.matcher(input);
157+
}
158+
}
159+
160+
describe("Glob polyfill match()", () => {
161+
test("matches *.sln pattern", () => {
162+
const glob = new PolyfillGlob("*.sln");
163+
expect(glob.match("MyProject.sln")).toBe(true);
164+
expect(glob.match("foo.sln")).toBe(true);
165+
expect(glob.match("foo.txt")).toBe(false);
166+
expect(glob.match("sln")).toBe(false);
167+
expect(glob.match(".sln")).toBe(true);
168+
});
169+
170+
test("matches *.csproj pattern", () => {
171+
const glob = new PolyfillGlob("*.csproj");
172+
expect(glob.match("MyApp.csproj")).toBe(true);
173+
expect(glob.match("something.csproj")).toBe(true);
174+
expect(glob.match("MyApp.fsproj")).toBe(false);
175+
expect(glob.match("csproj")).toBe(false);
176+
});
177+
178+
test("matches all LANGUAGE_MARKER_GLOBS patterns", () => {
179+
// These are the glob patterns from src/lib/dsn/project-root.ts
180+
const patterns = [
181+
"*.sln",
182+
"*.csproj",
183+
"*.fsproj",
184+
"*.vbproj",
185+
"*.cabal",
186+
"*.opam",
187+
"*.nimble",
188+
];
189+
190+
const positives: Record<string, string> = {
191+
"*.sln": "MyApp.sln",
192+
"*.csproj": "Web.csproj",
193+
"*.fsproj": "Lib.fsproj",
194+
"*.vbproj": "Old.vbproj",
195+
"*.cabal": "mylib.cabal",
196+
"*.opam": "parser.opam",
197+
"*.nimble": "tool.nimble",
198+
};
199+
200+
for (const pattern of patterns) {
201+
const glob = new PolyfillGlob(pattern);
202+
const positive = positives[pattern];
203+
expect(glob.match(positive!)).toBe(true);
204+
// Should not match unrelated extensions
205+
expect(glob.match("file.txt")).toBe(false);
206+
expect(glob.match("file.js")).toBe(false);
207+
}
208+
});
209+
210+
test("does not match directory paths (glob is name-only)", () => {
211+
const glob = new PolyfillGlob("*.sln");
212+
// picomatch by default doesn't match path separators with *
213+
expect(glob.match("dir/MyApp.sln")).toBe(false);
214+
});
215+
216+
test("is consistent with Bun.Glob.match()", () => {
217+
const patterns = ["*.sln", "*.csproj", "*.cabal"];
218+
const inputs = ["App.sln", "foo.csproj", "bar.cabal", "nope.txt", ""];
219+
220+
for (const pattern of patterns) {
221+
const polyfill = new PolyfillGlob(pattern);
222+
const bunGlob = new Bun.Glob(pattern);
223+
for (const input of inputs) {
224+
expect(polyfill.match(input)).toBe(bunGlob.match(input));
225+
}
226+
}
227+
});
228+
});

0 commit comments

Comments
 (0)