Skip to content

Commit 3335bfa

Browse files
authored
feat(dsn): infer project from directory name when DSN detection fails (#178)
## Summary Adds a fallback mechanism that uses the project root directory name to find matching Sentry projects when no DSN is found in the codebase. - Uses bidirectional word-boundary matching (`\b` regex) - Directory `cli` matches project `sentry-cli` or `cli-website` - Directory `sentry-docs` matches project `docs` - Results are cached using the existing DSN cache with `source: "inferred"` - Gracefully handles unauthenticated state ## Changes - **src/lib/api-client.ts**: Add `findProjectsByPattern()` function with `escapeRegex()` and `matchesWordBoundary()` helpers - **src/lib/dsn/types.ts**: Add `"inferred"` to `DsnSource` type - **src/lib/resolve-target.ts**: Add `inferFromDirectoryName()` with caching, integrate into `resolveAllTargets()` and `resolveOrgAndProject()` - **test/lib/resolve-target.test.ts**: Add unit tests for word boundary matching - **test/lib/word-boundary.property.test.ts**: Add property-based tests using fast-check ## Notes The implementation plan is available as a git note on the commit.
1 parent 65720eb commit 3335bfa

6 files changed

Lines changed: 547 additions & 88 deletions

File tree

src/lib/api-client.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,75 @@ export async function findProjectsBySlug(
616616
return searchResults.filter((r): r is ProjectWithOrg => r !== null);
617617
}
618618

619+
/**
620+
* Escape special regex characters in a string.
621+
* Uses native RegExp.escape if available (Node.js 23.6+, Bun), otherwise polyfills.
622+
*/
623+
const escapeRegex: (str: string) => string =
624+
typeof RegExp.escape === "function"
625+
? RegExp.escape
626+
: (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
627+
628+
/**
629+
* Check if two strings match with word-boundary semantics (bidirectional).
630+
*
631+
* Returns true if either:
632+
* - `a` appears in `b` at a word boundary
633+
* - `b` appears in `a` at a word boundary
634+
*
635+
* @example
636+
* matchesWordBoundary("cli", "cli-website") // true: "cli" in "cli-website"
637+
* matchesWordBoundary("sentry-docs", "docs") // true: "docs" in "sentry-docs"
638+
* matchesWordBoundary("cli", "eclipse") // false: no word boundary
639+
*
640+
* @internal Exported for testing
641+
*/
642+
export function matchesWordBoundary(a: string, b: string): boolean {
643+
const aInB = new RegExp(`\\b${escapeRegex(a)}\\b`, "i");
644+
const bInA = new RegExp(`\\b${escapeRegex(b)}\\b`, "i");
645+
return aInB.test(b) || bInA.test(a);
646+
}
647+
648+
/**
649+
* Find projects matching a pattern with bidirectional word-boundary matching.
650+
* Used for directory name inference when DSN detection fails.
651+
*
652+
* Uses `\b` regex word boundary, which matches:
653+
* - Start/end of string
654+
* - Between word char (`\w`) and non-word char (like "-")
655+
*
656+
* Matching is bidirectional:
657+
* - Directory name in project slug: dir "cli" matches project "cli-website"
658+
* - Project slug in directory name: project "docs" matches dir "sentry-docs"
659+
*
660+
* @param pattern - Directory name to match against project slugs
661+
* @returns Array of matching projects with their org context
662+
*/
663+
export async function findProjectsByPattern(
664+
pattern: string
665+
): Promise<ProjectWithOrg[]> {
666+
const orgs = await listOrganizations();
667+
668+
const searchResults = await Promise.all(
669+
orgs.map(async (org) => {
670+
try {
671+
const projects = await listProjects(org.slug);
672+
return projects
673+
.filter((p) => matchesWordBoundary(pattern, p.slug))
674+
.map((p) => ({ ...p, orgSlug: org.slug }));
675+
} catch (error) {
676+
if (error instanceof AuthError) {
677+
throw error;
678+
}
679+
// Skip orgs where user lacks access (permission errors, etc.)
680+
return [];
681+
}
682+
})
683+
);
684+
685+
return searchResults.flat();
686+
}
687+
619688
/**
620689
* Find a project by DSN public key.
621690
*

src/lib/db/dsn-cache.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ function rowToCachedDsnEntry(row: DsnCacheRow): CachedDsnEntry {
9191
};
9292
}
9393

94+
// Parse allResolved from all_dsns_json for inferred sources
95+
if (row.source === "inferred" && row.all_dsns_json) {
96+
try {
97+
entry.allResolved = JSON.parse(
98+
row.all_dsns_json
99+
) as CachedDsnEntry["allResolved"];
100+
} catch {
101+
// Ignore parse errors, allResolved will be undefined
102+
}
103+
}
104+
94105
return entry;
95106
}
96107

@@ -140,6 +151,10 @@ export async function setCachedDsn(
140151
resolved_org_name: entry.resolved?.orgName ?? null,
141152
resolved_project_slug: entry.resolved?.projectSlug ?? null,
142153
resolved_project_name: entry.resolved?.projectName ?? null,
154+
// Store allResolved in all_dsns_json for inferred sources
155+
all_dsns_json: entry.allResolved
156+
? JSON.stringify(entry.allResolved)
157+
: null,
143158
cached_at: now,
144159
last_accessed: now,
145160
},

src/lib/dsn/types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import { z } from "zod";
1313
* - env_file: .env file
1414
* - config: Language-specific config file (e.g., sentry.properties)
1515
* - code: Source code patterns (e.g., Sentry.init)
16+
* - inferred: Inferred from directory name matching project slugs
1617
*/
17-
export type DsnSource = "env" | "env_file" | "config" | "code";
18+
export type DsnSource = "env" | "env_file" | "config" | "code" | "inferred";
1819

1920
/**
2021
* Parsed DSN components
@@ -80,6 +81,8 @@ export type CachedDsnEntry = {
8081
sourcePath?: string;
8182
/** Resolved project info (avoids API call on cache hit) */
8283
resolved?: ResolvedProjectInfo;
84+
/** All resolved targets (for inferred source with multiple matches) */
85+
allResolved?: ResolvedProjectInfo[];
8386
/** Timestamp when this entry was cached */
8487
cachedAt: number;
8588
};
@@ -97,9 +100,10 @@ export const CachedDsnEntrySchema = z.object({
97100
dsn: z.string(),
98101
projectId: z.string(),
99102
orgId: z.string().optional(),
100-
source: z.enum(["env", "env_file", "config", "code"]),
103+
source: z.enum(["env", "env_file", "config", "code", "inferred"]),
101104
sourcePath: z.string().optional(),
102105
resolved: ResolvedProjectInfoSchema.optional(),
106+
allResolved: z.array(ResolvedProjectInfoSchema).optional(),
103107
cachedAt: z.number(),
104108
});
105109

src/lib/resolve-target.ts

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@
88
* 1. Explicit CLI flags
99
* 2. Config defaults
1010
* 3. DSN auto-detection (source code, .env files, environment variables)
11+
* 4. Directory name inference (matches project slugs with word boundaries)
1112
*/
1213

13-
import { findProjectByDsnKey, getProject } from "./api-client.js";
14+
import { basename } from "node:path";
15+
import {
16+
findProjectByDsnKey,
17+
findProjectsByPattern,
18+
getProject,
19+
} from "./api-client.js";
1420
import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js";
21+
import { getCachedDsn, setCachedDsn } from "./db/dsn-cache.js";
1522
import {
1623
getCachedProject,
1724
getCachedProjectByDsnKey,
@@ -22,6 +29,7 @@ import type { DetectedDsn } from "./dsn/index.js";
2229
import {
2330
detectAllDsns,
2431
detectDsn,
32+
findProjectRoot,
2533
formatMultipleProjectsFooter,
2634
getDsnSourceDescription,
2735
} from "./dsn/index.js";
@@ -323,6 +331,134 @@ async function resolveDsnToTarget(
323331
}
324332
}
325333

334+
/** Minimum directory name length for inference (avoids matching too broadly) */
335+
const MIN_DIR_NAME_LENGTH = 2;
336+
337+
/**
338+
* Check if a directory name is valid for project inference.
339+
* Rejects empty strings, hidden directories, and names that are too short.
340+
*
341+
* @internal Exported for testing
342+
*/
343+
export function isValidDirNameForInference(dirName: string): boolean {
344+
if (!dirName || dirName.length < MIN_DIR_NAME_LENGTH) {
345+
return false;
346+
}
347+
// Reject hidden directories (starting with .) - includes ".", "..", ".git", ".env"
348+
if (dirName.startsWith(".")) {
349+
return false;
350+
}
351+
return true;
352+
}
353+
354+
/**
355+
* Infer project(s) from directory name when DSN detection fails.
356+
* Uses word-boundary matching (`\b`) against all accessible projects.
357+
*
358+
* Caches results in dsn_cache with source: "inferred" for performance.
359+
* Cache is invalidated when directory mtime changes or after 24h TTL.
360+
*
361+
* @param cwd - Current working directory
362+
* @returns Resolved targets, or empty if no matches found
363+
*/
364+
async function inferFromDirectoryName(cwd: string): Promise<ResolvedTargets> {
365+
const { projectRoot } = await findProjectRoot(cwd);
366+
const dirName = basename(projectRoot);
367+
368+
// Skip inference for invalid directory names
369+
if (!isValidDirNameForInference(dirName)) {
370+
return { targets: [] };
371+
}
372+
373+
// Check cache first (reuse DSN cache with source: "inferred")
374+
const cached = await getCachedDsn(projectRoot);
375+
if (cached?.source === "inferred") {
376+
const detectedFrom = `directory name "${dirName}"`;
377+
378+
// Return all cached targets if available
379+
if (cached.allResolved && cached.allResolved.length > 0) {
380+
const targets = cached.allResolved.map((r) => ({
381+
org: r.orgSlug,
382+
project: r.projectSlug,
383+
orgDisplay: r.orgName,
384+
projectDisplay: r.projectName,
385+
detectedFrom,
386+
}));
387+
return {
388+
targets,
389+
footer:
390+
targets.length > 1
391+
? `Found ${targets.length} projects matching directory "${dirName}"`
392+
: undefined,
393+
};
394+
}
395+
396+
// Fallback to single resolved target (legacy cache entries)
397+
if (cached.resolved) {
398+
return {
399+
targets: [
400+
{
401+
org: cached.resolved.orgSlug,
402+
project: cached.resolved.projectSlug,
403+
orgDisplay: cached.resolved.orgName,
404+
projectDisplay: cached.resolved.projectName,
405+
detectedFrom,
406+
},
407+
],
408+
};
409+
}
410+
}
411+
412+
// Search for matching projects using word-boundary matching
413+
let matches: Awaited<ReturnType<typeof findProjectsByPattern>>;
414+
try {
415+
matches = await findProjectsByPattern(dirName);
416+
} catch {
417+
// If not authenticated or API fails, skip inference silently
418+
return { targets: [] };
419+
}
420+
421+
if (matches.length === 0) {
422+
return { targets: [] };
423+
}
424+
425+
// Cache all matches for faster subsequent lookups
426+
const [primary] = matches;
427+
if (primary) {
428+
const allResolved = matches.map((m) => ({
429+
orgSlug: m.orgSlug,
430+
orgName: m.organization?.name ?? m.orgSlug,
431+
projectSlug: m.slug,
432+
projectName: m.name,
433+
}));
434+
435+
await setCachedDsn(projectRoot, {
436+
dsn: "", // No DSN for inferred
437+
projectId: primary.id,
438+
source: "inferred",
439+
resolved: allResolved[0], // Primary for backwards compatibility
440+
allResolved,
441+
});
442+
}
443+
444+
const detectedFrom = `directory name "${dirName}"`;
445+
const targets: ResolvedTarget[] = matches.map((m) => ({
446+
org: m.orgSlug,
447+
project: m.slug,
448+
orgDisplay: m.organization?.name ?? m.orgSlug,
449+
projectDisplay: m.name,
450+
detectedFrom,
451+
}));
452+
453+
return {
454+
targets,
455+
footer:
456+
matches.length > 1
457+
? `Found ${matches.length} projects matching directory "${dirName}"`
458+
: undefined,
459+
};
460+
}
461+
326462
/**
327463
* Resolve all targets for monorepo-aware commands.
328464
*
@@ -333,6 +469,7 @@ async function resolveDsnToTarget(
333469
* 1. CLI flags (--org and --project) - returns single target
334470
* 2. Config defaults - returns single target
335471
* 3. DSN auto-detection - may return multiple targets
472+
* 4. Directory name inference - matches project slugs with word boundaries
336473
*
337474
* @param options - Resolution options with flags and cwd
338475
* @returns All resolved targets and optional footer message
@@ -385,7 +522,8 @@ export async function resolveAllTargets(
385522
const detection = await detectAllDsns(cwd);
386523

387524
if (detection.all.length === 0) {
388-
return { targets: [] };
525+
// 4. Fallback: infer from directory name
526+
return inferFromDirectoryName(cwd);
389527
}
390528

391529
// Resolve all DSNs in parallel
@@ -438,6 +576,7 @@ export async function resolveAllTargets(
438576
* 1. CLI flags (--org and --project) - both must be provided together
439577
* 2. Config defaults
440578
* 3. DSN auto-detection
579+
* 4. Directory name inference - matches project slugs with word boundaries
441580
*
442581
* @param options - Resolution options with flags and cwd
443582
* @returns Resolved target, or null if resolution failed
@@ -480,10 +619,31 @@ export async function resolveOrgAndProject(
480619

481620
// 3. DSN auto-detection
482621
try {
483-
return await resolveFromDsn(cwd);
622+
const dsnResult = await resolveFromDsn(cwd);
623+
if (dsnResult) {
624+
return dsnResult;
625+
}
484626
} catch {
485-
return null;
627+
// Fall through to directory inference
486628
}
629+
630+
// 4. Fallback: infer from directory name
631+
const inferred = await inferFromDirectoryName(cwd);
632+
if (inferred.targets.length > 0) {
633+
const [first] = inferred.targets;
634+
if (first) {
635+
// If multiple matches, note it in detectedFrom
636+
return {
637+
...first,
638+
detectedFrom:
639+
inferred.targets.length > 1
640+
? `${first.detectedFrom} (1 of ${inferred.targets.length} matches)`
641+
: first.detectedFrom,
642+
};
643+
}
644+
}
645+
646+
return null;
487647
}
488648

489649
/**

0 commit comments

Comments
 (0)