From d6b780918082d0545143cb46213cb1b66b30d290 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 3 Feb 2026 12:48:14 +0000 Subject: [PATCH 01/19] feat(dsn): add project root detection for automatic DSN discovery Walk up from CWD to find project root before scanning for DSNs: - Check .env files for SENTRY_DSN during walk-up (immediate return if found) - Stop at VCS/CI markers (.git, .github, etc.) as definitive repo root - Use language markers (package.json, pyproject.toml, etc.) as fallback - Limit scan depth to 2 levels from project root for performance This ensures DSN detection works regardless of which subdirectory the user runs the CLI from within a project. --- src/lib/dsn/detector.ts | 85 ++-- src/lib/dsn/index.ts | 14 + src/lib/dsn/languages/index.ts | 101 ++++- src/lib/dsn/project-root.ts | 675 ++++++++++++++++++++++++++++++ test/lib/dsn/detector.test.ts | 13 +- test/lib/dsn/project-root.test.ts | 342 +++++++++++++++ 6 files changed, 1181 insertions(+), 49 deletions(-) create mode 100644 src/lib/dsn/project-root.ts create mode 100644 test/lib/dsn/project-root.test.ts diff --git a/src/lib/dsn/detector.ts b/src/lib/dsn/detector.ts index 9eab7a01..ae41defa 100644 --- a/src/lib/dsn/detector.ts +++ b/src/lib/dsn/detector.ts @@ -1,18 +1,18 @@ /** * DSN Detector * - * Detects Sentry DSN with GitHub CLI-style caching. + * Detects Sentry DSN with GitHub CLI-style caching and project root detection. * - * Fast path (cache hit): ~5ms - verify single file - * Slow path (cache miss): ~2-5s - full scan + * Detection algorithm: + * 1. Find project root by walking up from cwd (checks .env for DSN at each level) + * 2. If DSN found during walk-up, return immediately (fast path) + * 3. Check cache for project root + * 4. Full scan from project root with depth limiting * - * Detection priority (explicit code DSN wins): - * 1. Source code (explicit DSN in Sentry.init, etc.) - * 2. .env files (.env.local, .env, etc.) - * 3. SENTRY_DSN environment variable + * Priority: .env with SENTRY_DSN > code > .env files > SENTRY_DSN env var */ -import { join } from "node:path"; +import { join, relative } from "node:path"; import { getCachedDsn, setCachedDsn } from "../db/dsn-cache.js"; import { detectFromEnv, SENTRY_DSN_ENV } from "./env.js"; import { @@ -26,6 +26,7 @@ import { getDetectorForFile, } from "./languages/index.js"; import { createDetectedDsn, parseDsn } from "./parser.js"; +import { findProjectRoot } from "./project-root.js"; import type { CachedDsnEntry, DetectedDsn, @@ -38,28 +39,49 @@ import type { // ───────────────────────────────────────────────────────────────────────────── /** - * Detect DSN with caching support + * Detect DSN with project root detection and caching support. * - * Fast path (cache hit): ~5ms - * Slow path (cache miss): ~2-5s + * Algorithm: + * 1. Find project root by walking up from cwd (checking .env for SENTRY_DSN at each level) + * 2. If DSN found during walk-up, return immediately (fastest path) + * 3. Check cache for project root (fast path) + * 4. Full scan from project root with depth limiting (slow path) * - * Priority: code > .env files > SENTRY_DSN env var - * - * @param cwd - Directory to search in + * @param cwd - Directory to start searching from * @returns Detected DSN with source info, or null if not found */ export async function detectDsn(cwd: string): Promise { - // 1. Check cache for this directory (fast path) - const cached = await getCachedDsn(cwd); + // 1. Find project root (may find DSN in .env along the way) + const { projectRoot, foundDsn } = await findProjectRoot(cwd); + + // 2. If DSN found during walk-up, cache and return immediately + if (foundDsn) { + // Make source path relative to project root for caching + const sourcePath = foundDsn.sourcePath + ? relative(projectRoot, join(cwd, foundDsn.sourcePath)) || + foundDsn.sourcePath + : foundDsn.sourcePath; + + await setCachedDsn(projectRoot, { + dsn: foundDsn.raw, + projectId: foundDsn.projectId, + orgId: foundDsn.orgId, + source: foundDsn.source, + sourcePath, + }); + return foundDsn; + } + + // 3. Check cache for project root (fast path) + const cached = await getCachedDsn(projectRoot); if (cached) { - // 2. Verify cached source file still has same DSN - const verified = await verifyCachedDsn(cwd, cached); + const verified = await verifyCachedDsn(projectRoot, cached); if (verified) { // Check if DSN changed if (verified.raw !== cached.dsn) { // DSN changed - update cache - await setCachedDsn(cwd, { + await setCachedDsn(projectRoot, { dsn: verified.raw, projectId: verified.projectId, orgId: verified.orgId, @@ -78,12 +100,12 @@ export async function detectDsn(cwd: string): Promise { // Cache invalid, fall through to full scan } - // 3. Full scan (cache miss): code → .env → env var - const detected = await fullScanFirst(cwd); + // 4. Full scan from project root (slow path) + const detected = await fullScanFirst(projectRoot); if (detected) { - // 4. Cache for next time (without resolved info yet) - await setCachedDsn(cwd, { + // Cache for next time (without resolved info yet) + await setCachedDsn(projectRoot, { dsn: detected.raw, projectId: detected.projectId, orgId: detected.orgId, @@ -99,6 +121,7 @@ export async function detectDsn(cwd: string): Promise { * Detect all DSNs in a directory (supports monorepos) * * Unlike detectDsn, this finds ALL DSNs from all sources. + * First finds project root, then scans from there with depth limiting. * Useful for monorepos with multiple Sentry projects. * * Collection order matches priority: code > .env files > env var @@ -107,6 +130,9 @@ export async function detectDsn(cwd: string): Promise { * @returns Detection result with all found DSNs and hasMultiple flag */ export async function detectAllDsns(cwd: string): Promise { + // Find project root first + const { projectRoot, foundDsn } = await findProjectRoot(cwd); + const allDsns: DetectedDsn[] = []; const seenRawDsns = new Set(); @@ -118,14 +144,19 @@ export async function detectAllDsns(cwd: string): Promise { } }; - // 1. Check all code files (highest priority) - const codeDsns = await detectAllFromCode(cwd); + // If DSN was found during walk-up, add it first (highest priority) + if (foundDsn) { + addDsn(foundDsn); + } + + // 1. Check all code files from project root + const codeDsns = await detectAllFromCode(projectRoot); for (const dsn of codeDsns) { addDsn(dsn); } - // 2. Check all .env files (includes monorepo packages/apps) - const envFileDsns = await detectFromAllEnvFiles(cwd); + // 2. Check all .env files from project root (includes monorepo packages/apps) + const envFileDsns = await detectFromAllEnvFiles(projectRoot); for (const dsn of envFileDsns) { addDsn(dsn); } diff --git a/src/lib/dsn/index.ts b/src/lib/dsn/index.ts index 1c8e81ae..d8f005c3 100644 --- a/src/lib/dsn/index.ts +++ b/src/lib/dsn/index.ts @@ -81,3 +81,17 @@ export { isValidDsn, parseDsn, } from "./parser.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Project Root Detection +// ───────────────────────────────────────────────────────────────────────────── + +export type { ProjectRootReason, ProjectRootResult } from "./project-root.js"; +export { + findProjectRoot, + getStopBoundary, + hasBuildSystemMarker, + hasLanguageMarker, + hasRepoRootMarker, + isProjectRoot, +} from "./project-root.js"; diff --git a/src/lib/dsn/languages/index.ts b/src/lib/dsn/languages/index.ts index 0230db80..e95d280b 100644 --- a/src/lib/dsn/languages/index.ts +++ b/src/lib/dsn/languages/index.ts @@ -4,6 +4,8 @@ * Unified scanner for detecting DSN from source code across all supported languages. * Uses a registry of language detectors to scan files by extension. * + * Scans with depth limiting (default 2 levels) to avoid deep recursion. + * * ## Adding a New Language * * 1. Create `{lang}.ts` implementing `LanguageDetector` @@ -23,6 +25,8 @@ */ import { extname, join } from "node:path"; +// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import +import * as Sentry from "@sentry/bun"; import { createDetectedDsn, inferPackagePath } from "../parser.js"; import type { DetectedDsn } from "../types.js"; import { goDetector } from "./go.js"; @@ -79,6 +83,14 @@ const allExtensions = languageDetectors.flatMap((d) => d.extensions); const globPattern = `**/*{${allExtensions.join(",")}}`; const codeGlob = new Bun.Glob(globPattern); +/** + * Maximum depth to scan from project root. + * Depth 0 = files in root directory + * Depth 1 = files in first-level subdirectories + * Depth 2 = files in second-level subdirectories + */ +const MAX_SCAN_DEPTH = 2; + // ───────────────────────────────────────────────────────────────────────────── // Public API // ───────────────────────────────────────────────────────────────────────────── @@ -129,13 +141,37 @@ export function detectAllFromCode(cwd: string): Promise { // Internal // ───────────────────────────────────────────────────────────────────────────── +/** + * Get the depth of a file path (number of directory levels). + * Files in the root have depth 0, first-level subdirs have depth 1, etc. + * + * @param filepath - Relative file path + * @returns Depth level (0 for root files) + */ +function getPathDepth(filepath: string): number { + const parts = filepath.split("/"); + // Depth is number of directory parts (excluding the filename) + return parts.length - 1; +} + /** * Check if a path should be skipped during scanning. * + * Skips paths that: + * - Exceed the maximum scan depth + * - Contain a known skip directory (node_modules, dist, etc.) + * * @param filepath - Relative file path to check - * @returns True if any path segment matches a skip directory + * @returns True if the path should be skipped */ function shouldSkipPath(filepath: string): boolean { + // Check depth first (fast reject) + const depth = getPathDepth(filepath); + if (depth > MAX_SCAN_DEPTH) { + return true; + } + + // Check for skip directories const parts = filepath.split("/"); return parts.some((part) => allSkipDirs.has(part)); } @@ -175,31 +211,64 @@ async function processCodeFile( } /** - * Scan code files for DSNs. + * Scan code files for DSNs with Sentry performance tracing. * * @param cwd - Directory to search in * @param stopOnFirst - Whether to stop after first match * @returns Array of detected DSNs */ -async function scanCodeFiles( +function scanCodeFiles( cwd: string, stopOnFirst: boolean ): Promise { - const results: DetectedDsn[] = []; + return Sentry.startSpan( + { + name: "scanCodeFiles", + op: "dsn.detect.code", + attributes: { + "dsn.scan_dir": cwd, + "dsn.stop_on_first": stopOnFirst, + "dsn.max_depth": MAX_SCAN_DEPTH, + }, + onlyIfParent: true, + }, + async (span) => { + const results: DetectedDsn[] = []; + let filesScanned = 0; + let filesSkipped = 0; - for await (const relativePath of codeGlob.scan({ cwd, onlyFiles: true })) { - if (shouldSkipPath(relativePath)) { - continue; - } + for await (const relativePath of codeGlob.scan({ + cwd, + onlyFiles: true, + })) { + if (shouldSkipPath(relativePath)) { + filesSkipped += 1; + continue; + } - const detected = await processCodeFile(cwd, relativePath); - if (detected) { - results.push(detected); - if (stopOnFirst) { - return results; + filesScanned += 1; + const detected = await processCodeFile(cwd, relativePath); + if (detected) { + results.push(detected); + if (stopOnFirst) { + span.setAttributes({ + "dsn.files_scanned": filesScanned, + "dsn.files_skipped": filesSkipped, + "dsn.dsns_found": results.length, + }); + span.setStatus({ code: 1 }); + return results; + } + } } - } - } - return results; + span.setAttributes({ + "dsn.files_scanned": filesScanned, + "dsn.files_skipped": filesSkipped, + "dsn.dsns_found": results.length, + }); + span.setStatus({ code: 1 }); + return results; + } + ); } diff --git a/src/lib/dsn/project-root.ts b/src/lib/dsn/project-root.ts new file mode 100644 index 00000000..2ecc8fe1 --- /dev/null +++ b/src/lib/dsn/project-root.ts @@ -0,0 +1,675 @@ +/** + * Project Root Detection + * + * Walks up from starting directory to find project root, optionally + * detecting DSN along the way for early exit. + * + * Priority: + * 1. .env with SENTRY_DSN → immediate return + * 2. VCS/CI markers → definitive repo root (stop walking) + * 3. Language markers → closest to cwd wins + * 4. Build system markers → last resort + * + * Stops at: home directory or filesystem root + */ + +import { stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import +import * as Sentry from "@sentry/bun"; +import { extractDsnFromEnvContent } from "./env-file.js"; +import { createDetectedDsn } from "./parser.js"; +import type { DetectedDsn } from "./types.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Why a directory was chosen as project root + */ +export type ProjectRootReason = + | "env_dsn" // Found .env with SENTRY_DSN + | "vcs" // Version control (.git, .hg, etc.) + | "ci" // CI/CD markers (.github, etc.) + | "editorconfig" // .editorconfig with root=true + | "language" // Language/package marker + | "build_system" // Build system marker + | "fallback"; // No markers found, using cwd + +/** + * Result of project root detection + */ +export type ProjectRootResult = { + /** The determined project root directory */ + projectRoot: string; + /** DSN found in .env while walking up (early exit) */ + foundDsn?: DetectedDsn; + /** Why this directory was chosen as root */ + reason: ProjectRootReason; + /** Number of directories traversed to find root */ + levelsTraversed: number; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Marker Definitions +// ───────────────────────────────────────────────────────────────────────────── + +/** VCS directories - definitive repo root */ +const VCS_MARKERS = [ + ".git", + ".hg", + ".svn", + ".bzr", + "_darcs", + ".fossil", + ".pijul", +] as const; + +/** CI/CD markers - definitive repo root */ +const CI_MARKERS = [ + ".github", + ".gitlab-ci.yml", + ".circleci", + "Jenkinsfile", + ".travis.yml", + "azure-pipelines.yml", + ".buildkite", + "bitbucket-pipelines.yml", + ".drone.yml", + ".woodpecker.yml", + ".forgejo", + ".gitea", +] as const; + +/** Language/package markers - strong project boundary */ +const LANGUAGE_MARKERS = [ + // JavaScript/Node ecosystem + "package.json", + "deno.json", + "deno.jsonc", + // Python + "pyproject.toml", + "setup.py", + "setup.cfg", + "Pipfile", + // Go + "go.mod", + "go.work", + // Rust + "Cargo.toml", + // Ruby + "Gemfile", + // PHP + "composer.json", + // Java/JVM + "pom.xml", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + "build.sbt", + // .NET + "global.json", + // Swift + "Package.swift", + // Elixir + "mix.exs", + // Dart/Flutter + "pubspec.yaml", + // Haskell + "stack.yaml", + "cabal.project", + // OCaml + "dune-project", + // Zig + "build.zig", + "build.zig.zon", + // Clojure + "project.clj", + "deps.edn", +] as const; + +/** Glob patterns for language markers that need wildcard matching */ +const LANGUAGE_MARKER_GLOBS = [ + "*.sln", + "*.csproj", + "*.fsproj", + "*.vbproj", + "*.cabal", + "*.opam", + "*.nimble", +] as const; + +/** Build system markers - last resort */ +const BUILD_SYSTEM_MARKERS = [ + "Makefile", + "GNUmakefile", + "makefile", + "CMakeLists.txt", + "BUILD", + "BUILD.bazel", + "WORKSPACE", + "WORKSPACE.bazel", + "MODULE.bazel", + "meson.build", + "Justfile", + "justfile", + "Taskfile.yml", + "Taskfile.yaml", + "SConstruct", + "xmake.lua", + "premake5.lua", + "premake4.lua", + "wscript", + "Earthfile", +] as const; + +/** .env files to check for SENTRY_DSN (in priority order) */ +const ENV_FILES = [ + ".env.local", + ".env.development.local", + ".env.production.local", + ".env", + ".env.development", + ".env.production", +] as const; + +/** Regex for detecting root=true in .editorconfig (top-level for performance) */ +const EDITORCONFIG_ROOT_REGEX = /^\s*root\s*=\s*true\s*$/im; + +// ───────────────────────────────────────────────────────────────────────────── +// Telemetry Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Wrap a file system operation with a span for tracing. + */ +function withFsSpan( + operation: string, + fn: () => T | Promise +): Promise { + return Sentry.startSpan( + { + name: operation, + op: "file", + onlyIfParent: true, + }, + async (span) => { + try { + const result = await fn(); + span.setStatus({ code: 1 }); // OK + return result; + } catch (error) { + span.setStatus({ code: 2 }); // Error + throw error; + } + } + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// File System Helpers (Parallelized) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Check if a path exists (file or directory) + */ +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +/** + * Check if a file exists (async) - only for regular files + */ +async function fileExists(path: string): Promise { + try { + return await Bun.file(path).exists(); + } catch { + return false; + } +} + +/** + * Check if any of the given paths exist in a directory (parallel) + * Works for both files and directories. + * + * @param dir - Directory to check + * @param names - Array of file/directory names to check + * @returns True if any path exists + */ +async function anyExists( + dir: string, + names: readonly string[] +): Promise { + const checks = names.map((name) => pathExists(join(dir, name))); + const results = await Promise.all(checks); + return results.some((exists) => exists); +} + +/** + * Check if any files matching glob patterns exist in a directory (parallel) + * + * @param dir - Directory to check + * @param patterns - Glob patterns to match + * @returns True if any matching file exists + */ +async function anyGlobMatches( + dir: string, + patterns: readonly string[] +): Promise { + const checks = patterns.map(async (pattern) => { + const glob = new Bun.Glob(pattern); + for await (const _match of glob.scan({ cwd: dir, onlyFiles: true })) { + return true; // Found at least one match + } + return false; + }); + + const results = await Promise.all(checks); + return results.some((found) => found); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Marker Detection Functions +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Check if .editorconfig exists and contains root=true + * + * @param dir - Directory to check + * @returns True if .editorconfig with root=true found + */ +async function checkEditorConfigRoot(dir: string): Promise { + const editorConfigPath = join(dir, ".editorconfig"); + try { + const file = Bun.file(editorConfigPath); + if (!(await file.exists())) { + return false; + } + const content = await file.text(); + return EDITORCONFIG_ROOT_REGEX.test(content); + } catch { + return false; + } +} + +/** + * Determine the type of repo root marker found + */ +function getRepoRootType( + hasVcs: boolean, + hasCi: boolean, + hasEditorConfigRoot: boolean +): "vcs" | "ci" | "editorconfig" | undefined { + if (hasVcs) { + return "vcs"; + } + if (hasCi) { + return "ci"; + } + if (hasEditorConfigRoot) { + return "editorconfig"; + } + return; +} + +/** + * Check if directory has VCS or CI/CD markers (definitive repo root) + * + * @param dir - Directory to check + * @returns Object with found status and marker type + */ +export function hasRepoRootMarker( + dir: string +): Promise<{ found: boolean; type?: "vcs" | "ci" | "editorconfig" }> { + return withFsSpan("hasRepoRootMarker", async () => { + // Check all marker types in parallel + const [hasVcs, hasCi, hasEditorConfigRoot] = await Promise.all([ + anyExists(dir, VCS_MARKERS), + anyExists(dir, CI_MARKERS), + checkEditorConfigRoot(dir), + ]); + + const type = getRepoRootType(hasVcs, hasCi, hasEditorConfigRoot); + return type ? { found: true, type } : { found: false }; + }); +} + +/** + * Check if directory has language/package markers + * + * @param dir - Directory to check + * @returns True if any language marker found + */ +export function hasLanguageMarker(dir: string): Promise { + return withFsSpan("hasLanguageMarker", async () => { + // Check exact filenames and glob patterns in parallel + const [hasExact, hasGlob] = await Promise.all([ + anyExists(dir, LANGUAGE_MARKERS), + anyGlobMatches(dir, LANGUAGE_MARKER_GLOBS), + ]); + return hasExact || hasGlob; + }); +} + +/** + * Check if directory has build system markers + * + * @param dir - Directory to check + * @returns True if any build system marker found + */ +export function hasBuildSystemMarker(dir: string): Promise { + return withFsSpan("hasBuildSystemMarker", async () => + anyExists(dir, BUILD_SYSTEM_MARKERS) + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DSN Detection in .env Files +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Check .env files in a directory for SENTRY_DSN + * + * Checks files in priority order and returns immediately on first match. + * + * @param dir - Directory to check + * @returns Detected DSN or null + */ +export function checkEnvForDsn(dir: string): Promise { + return withFsSpan("checkEnvForDsn", async () => { + // Check all env files in parallel for existence + const existenceChecks = ENV_FILES.map(async (filename) => { + const path = join(dir, filename); + const exists = await fileExists(path); + return { filename, path, exists }; + }); + + const results = await Promise.all(existenceChecks); + const existingFiles = results.filter((r) => r.exists); + + // Read existing files in parallel + const contentChecks = existingFiles.map(async ({ filename, path }) => { + try { + const content = await Bun.file(path).text(); + const dsn = extractDsnFromEnvContent(content); + return dsn ? { dsn, filename } : null; + } catch { + return null; + } + }); + + const dsnResults = await Promise.all(contentChecks); + + // Return first found DSN (respecting priority order) + for (const filename of ENV_FILES) { + const found = dsnResults.find( + (r) => r !== null && r.filename === filename + ); + if (found) { + return createDetectedDsn(found.dsn, "env_file", found.filename); + } + } + + return null; + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Get the stop boundary for project root search. + * + * Returns home directory if it exists, otherwise filesystem root. + */ +export function getStopBoundary(): string { + try { + return homedir(); + } catch { + return "/"; + } +} + +/** + * Process one directory level during the walk-up search + */ +async function processDirectoryLevel( + currentDir: string, + languageMarkerAt: string | null, + buildSystemAt: string | null +): Promise<{ + dsnResult: DetectedDsn | null; + repoRootResult: { found: boolean; type?: "vcs" | "ci" | "editorconfig" }; + hasLang: boolean; + hasBuild: boolean; +}> { + // Run all checks for this level in parallel + const [dsnResult, repoRootResult, hasLang, hasBuild] = await Promise.all([ + checkEnvForDsn(currentDir), + hasRepoRootMarker(currentDir), + languageMarkerAt ? Promise.resolve(false) : hasLanguageMarker(currentDir), + buildSystemAt ? Promise.resolve(false) : hasBuildSystemMarker(currentDir), + ]); + + return { dsnResult, repoRootResult, hasLang, hasBuild }; +} + +/** + * Convert repo root type to project root reason + */ +function repoRootTypeToReason( + type: "vcs" | "ci" | "editorconfig" | undefined +): ProjectRootReason { + switch (type) { + case "editorconfig": + return "editorconfig"; + case "ci": + return "ci"; + default: + return "vcs"; + } +} + +/** + * Determine the final project root from candidates + */ +function selectProjectRoot( + languageMarkerAt: string | null, + buildSystemAt: string | null, + fallback: string +): { projectRoot: string; reason: ProjectRootReason } { + if (languageMarkerAt) { + return { projectRoot: languageMarkerAt, reason: "language" }; + } + if (buildSystemAt) { + return { projectRoot: buildSystemAt, reason: "build_system" }; + } + return { projectRoot: fallback, reason: "fallback" }; +} + +/** + * State tracked during directory walk-up + */ +type WalkState = { + currentDir: string; + levelsTraversed: number; + languageMarkerAt: string | null; + buildSystemAt: string | null; +}; + +/** + * Create result when DSN is found in .env file + */ +function createDsnFoundResult( + currentDir: string, + dsnResult: DetectedDsn, + levelsTraversed: number +): ProjectRootResult { + return { + projectRoot: currentDir, + foundDsn: dsnResult, + reason: "env_dsn", + levelsTraversed, + }; +} + +/** + * Create result when repo root marker is found + */ +function createRepoRootResult( + currentDir: string, + markerType: "vcs" | "ci" | "editorconfig" | undefined, + levelsTraversed: number +): ProjectRootResult { + return { + projectRoot: currentDir, + reason: repoRootTypeToReason(markerType), + levelsTraversed, + }; +} + +/** + * Walk up directories searching for project root + */ +async function walkUpDirectories( + resolvedStart: string, + stopBoundary: string +): Promise { + const state: WalkState = { + currentDir: resolvedStart, + levelsTraversed: 0, + languageMarkerAt: null, + buildSystemAt: null, + }; + + while (state.currentDir !== "/" && state.currentDir !== stopBoundary) { + state.levelsTraversed += 1; + + const { dsnResult, repoRootResult, hasLang, hasBuild } = + await processDirectoryLevel( + state.currentDir, + state.languageMarkerAt, + state.buildSystemAt + ); + + // 1. Check for DSN in .env files - immediate return + if (dsnResult) { + return createDsnFoundResult( + state.currentDir, + dsnResult, + state.levelsTraversed + ); + } + + // 2. Check for VCS/CI markers - definitive root, stop walking + if (repoRootResult.found) { + return createRepoRootResult( + state.currentDir, + repoRootResult.type, + state.levelsTraversed + ); + } + + // 3. Remember language marker (closest to cwd wins) + if (!state.languageMarkerAt && hasLang) { + state.languageMarkerAt = state.currentDir; + } + + // 4. Remember build system marker (last resort) + if (!state.buildSystemAt && hasBuild) { + state.buildSystemAt = state.currentDir; + } + + // Move to parent directory + const parentDir = dirname(state.currentDir); + if (parentDir === state.currentDir) { + break; // Reached filesystem root + } + state.currentDir = parentDir; + } + + // Determine project root from candidates (priority order) + const selected = selectProjectRoot( + state.languageMarkerAt, + state.buildSystemAt, + resolvedStart + ); + + return { + projectRoot: selected.projectRoot, + reason: selected.reason, + levelsTraversed: state.levelsTraversed, + }; +} + +/** + * Find project root by walking up from starting directory. + * + * Checks for DSN in .env files at each level (early exit if found). + * Stops at VCS/CI markers (definitive repo root). + * Falls back to language markers, then build system markers. + * + * @param startDir - Directory to start searching from + * @returns Project root result with optional DSN + */ +export function findProjectRoot(startDir: string): Promise { + return Sentry.startSpan( + { + name: "findProjectRoot", + op: "dsn.detect", + attributes: { + "dsn.start_dir": startDir, + }, + onlyIfParent: true, + }, + async (span) => { + const resolvedStart = resolve(startDir); + const stopBoundary = getStopBoundary(); + + const result = await walkUpDirectories(resolvedStart, stopBoundary); + + span.setAttributes({ + "dsn.found": result.foundDsn !== undefined, + "dsn.reason": result.reason, + "dsn.levels_traversed": result.levelsTraversed, + "dsn.project_root": result.projectRoot, + }); + span.setStatus({ code: 1 }); + + return result; + } + ); +} + +/** + * Check if a directory is a project root. + * + * A directory is considered a project root if it has any of: + * - VCS markers (.git, .hg, etc.) + * - CI/CD markers (.github, etc.) + * - .editorconfig with root=true + * - Language/package markers + * - Build system markers + * + * @param dir - Directory to check + * @returns True if directory appears to be a project root + */ +export async function isProjectRoot(dir: string): Promise { + // Check all marker types in parallel + const [hasRepo, hasLang, hasBuild] = await Promise.all([ + hasRepoRootMarker(dir), + hasLanguageMarker(dir), + hasBuildSystemMarker(dir), + ]); + + return hasRepo.found || hasLang || hasBuild; +} diff --git a/test/lib/dsn/detector.test.ts b/test/lib/dsn/detector.test.ts index a0b83a02..c7706ee0 100644 --- a/test/lib/dsn/detector.test.ts +++ b/test/lib/dsn/detector.test.ts @@ -99,7 +99,7 @@ describe("DSN Detector (New Module)", () => { expect(cached?.dsn).toBe(dsn2); }); - test("code DSN takes priority over env file", async () => { + test("env file DSN found during walk-up takes priority over code", async () => { const envFileDsn = "https://file@o111.ingest.sentry.io/111"; const codeDsn = "https://code@o222.ingest.sentry.io/222"; @@ -111,10 +111,11 @@ describe("DSN Detector (New Module)", () => { `Sentry.init({ dsn: "${codeDsn}" })` ); - // Should return code DSN (highest priority) + // With project root detection, .env files are checked during walk-up + // and return immediately if SENTRY_DSN is found (fastest path) const result = await detectDsn(testDir); - expect(result?.raw).toBe(codeDsn); - expect(result?.source).toBe("code"); + expect(result?.raw).toBe(envFileDsn); + expect(result?.source).toBe("env_file"); }); test("code DSN takes priority over env var", async () => { @@ -245,8 +246,8 @@ describe("DSN Detector (New Module)", () => { const result = await detectAllDsns(testDir); expect(result.hasMultiple).toBe(true); - // Code DSN has higher priority, so it's first - expect(result.primary?.raw).toBe(codeDsn); + // With project root detection, env file DSN found during walk-up is first + expect(result.primary?.raw).toBe(envDsn); expect(result.all).toHaveLength(2); }); diff --git a/test/lib/dsn/project-root.test.ts b/test/lib/dsn/project-root.test.ts new file mode 100644 index 00000000..e4477207 --- /dev/null +++ b/test/lib/dsn/project-root.test.ts @@ -0,0 +1,342 @@ +/** + * Project Root Detection Tests + * + * Tests for finding project root by walking up from a starting directory. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + findProjectRoot, + getStopBoundary, + hasBuildSystemMarker, + hasLanguageMarker, + hasRepoRootMarker, + isProjectRoot, +} from "../../../src/lib/dsn/project-root.js"; + +// Test directory structure helper +function createDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +function createFile(path: string, content = ""): void { + writeFileSync(path, content); +} + +describe("project-root", () => { + let testDir: string; + + beforeEach(() => { + // Create a unique temp directory for each test + testDir = join( + tmpdir(), + `sentry-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + createDir(testDir); + }); + + afterEach(() => { + // Clean up test directory + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("getStopBoundary", () => { + test("returns home directory", () => { + const boundary = getStopBoundary(); + expect(boundary).toBe(homedir()); + }); + }); + + describe("hasRepoRootMarker", () => { + test("detects .git directory", async () => { + createDir(join(testDir, ".git")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("vcs"); + }); + + test("detects .hg directory", async () => { + createDir(join(testDir, ".hg")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("vcs"); + }); + + test("detects .github directory", async () => { + createDir(join(testDir, ".github")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("ci"); + }); + + test("detects .gitlab-ci.yml file", async () => { + createFile(join(testDir, ".gitlab-ci.yml")); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("ci"); + }); + + test("detects .editorconfig with root=true", async () => { + createFile( + join(testDir, ".editorconfig"), + "root = true\n[*]\nindent_style = space" + ); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(true); + expect(result.type).toBe("editorconfig"); + }); + + test("ignores .editorconfig without root=true", async () => { + createFile(join(testDir, ".editorconfig"), "[*]\nindent_style = space"); + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(false); + }); + + test("returns found=false when no markers", async () => { + const result = await hasRepoRootMarker(testDir); + expect(result.found).toBe(false); + }); + }); + + describe("hasLanguageMarker", () => { + test("detects package.json", async () => { + createFile(join(testDir, "package.json"), "{}"); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects pyproject.toml", async () => { + createFile(join(testDir, "pyproject.toml"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects go.mod", async () => { + createFile(join(testDir, "go.mod"), "module example.com/test"); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects Cargo.toml", async () => { + createFile(join(testDir, "Cargo.toml"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects .sln file (glob pattern)", async () => { + createFile(join(testDir, "MyProject.sln"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("detects .csproj file (glob pattern)", async () => { + createFile(join(testDir, "MyProject.csproj"), ""); + expect(await hasLanguageMarker(testDir)).toBe(true); + }); + + test("returns false when no markers", async () => { + expect(await hasLanguageMarker(testDir)).toBe(false); + }); + }); + + describe("hasBuildSystemMarker", () => { + test("detects Makefile", async () => { + createFile(join(testDir, "Makefile"), ""); + expect(await hasBuildSystemMarker(testDir)).toBe(true); + }); + + test("detects CMakeLists.txt", async () => { + createFile(join(testDir, "CMakeLists.txt"), ""); + expect(await hasBuildSystemMarker(testDir)).toBe(true); + }); + + test("detects BUILD.bazel", async () => { + createFile(join(testDir, "BUILD.bazel"), ""); + expect(await hasBuildSystemMarker(testDir)).toBe(true); + }); + + test("returns false when no markers", async () => { + expect(await hasBuildSystemMarker(testDir)).toBe(false); + }); + }); + + describe("isProjectRoot", () => { + test("returns true for directory with .git", async () => { + createDir(join(testDir, ".git")); + expect(await isProjectRoot(testDir)).toBe(true); + }); + + test("returns true for directory with package.json", async () => { + createFile(join(testDir, "package.json"), "{}"); + expect(await isProjectRoot(testDir)).toBe(true); + }); + + test("returns true for directory with Makefile", async () => { + createFile(join(testDir, "Makefile"), ""); + expect(await isProjectRoot(testDir)).toBe(true); + }); + + test("returns false for empty directory", async () => { + expect(await isProjectRoot(testDir)).toBe(false); + }); + }); + + describe("findProjectRoot", () => { + describe("DSN detection in .env files", () => { + test("finds DSN in .env file and returns immediately", async () => { + const dsn = "https://abc123@o123.ingest.sentry.io/456"; + createFile(join(testDir, ".env"), `SENTRY_DSN=${dsn}`); + createDir(join(testDir, "src", "lib")); + + const result = await findProjectRoot(join(testDir, "src", "lib")); + + expect(result.foundDsn).toBeDefined(); + expect(result.foundDsn?.raw).toBe(dsn); + expect(result.reason).toBe("env_dsn"); + expect(result.projectRoot).toBe(testDir); + }); + + test("finds DSN in .env.local (higher priority)", async () => { + const dsnLocal = "https://local@o123.ingest.sentry.io/456"; + const dsnBase = "https://base@o123.ingest.sentry.io/789"; + createFile(join(testDir, ".env.local"), `SENTRY_DSN=${dsnLocal}`); + createFile(join(testDir, ".env"), `SENTRY_DSN=${dsnBase}`); + + const result = await findProjectRoot(testDir); + + expect(result.foundDsn?.raw).toBe(dsnLocal); + }); + + test("finds DSN at intermediate level during walk-up", async () => { + const dsn = "https://abc123@o123.ingest.sentry.io/456"; + // Create nested structure: testDir/packages/app/src + const packagesDir = join(testDir, "packages"); + const appDir = join(packagesDir, "app"); + const srcDir = join(appDir, "src"); + createDir(srcDir); + + // Put DSN in app directory + createFile(join(appDir, ".env"), `SENTRY_DSN=${dsn}`); + + // Put .git at root + createDir(join(testDir, ".git")); + + const result = await findProjectRoot(srcDir); + + // Should find DSN at app level (immediate return) + expect(result.foundDsn).toBeDefined(); + expect(result.foundDsn?.raw).toBe(dsn); + expect(result.reason).toBe("env_dsn"); + }); + }); + + describe("VCS marker detection", () => { + test("stops at .git directory", async () => { + createDir(join(testDir, ".git")); + createDir(join(testDir, "src", "lib", "utils")); + + const result = await findProjectRoot( + join(testDir, "src", "lib", "utils") + ); + + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("vcs"); + // Levels: utils(1) -> lib(2) -> src(3) -> testDir(4, found .git) + expect(result.levelsTraversed).toBe(4); + }); + + test("stops at .github directory", async () => { + createDir(join(testDir, ".github")); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("ci"); + }); + }); + + describe("language marker detection", () => { + test("uses closest language marker to cwd", async () => { + // Root has package.json + createFile(join(testDir, "package.json"), "{}"); + + // Nested package also has package.json + const nestedDir = join(testDir, "packages", "frontend"); + createDir(nestedDir); + createFile(join(nestedDir, "package.json"), "{}"); + + // Start from nested/src + const srcDir = join(nestedDir, "src"); + createDir(srcDir); + + const result = await findProjectRoot(srcDir); + + // Should use the closest package.json (in packages/frontend) + expect(result.projectRoot).toBe(nestedDir); + expect(result.reason).toBe("language"); + }); + + test("VCS marker takes precedence over language marker", async () => { + createDir(join(testDir, ".git")); + createFile(join(testDir, "package.json"), "{}"); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + // Should stop at .git even though package.json was also found + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("vcs"); + }); + }); + + describe("build system marker detection", () => { + test("uses build system marker as last resort", async () => { + createFile(join(testDir, "Makefile"), ""); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + expect(result.projectRoot).toBe(testDir); + expect(result.reason).toBe("build_system"); + }); + + test("language marker takes precedence over build system", async () => { + createFile(join(testDir, "Makefile"), ""); + createFile(join(testDir, "package.json"), "{}"); + createDir(join(testDir, "src")); + + const result = await findProjectRoot(join(testDir, "src")); + + expect(result.reason).toBe("language"); + }); + }); + + describe("fallback behavior", () => { + test("returns cwd when no markers found", async () => { + const deepDir = join(testDir, "a", "b", "c"); + createDir(deepDir); + + const result = await findProjectRoot(deepDir); + + // Should fall back to the starting directory + expect(result.projectRoot).toBe(deepDir); + expect(result.reason).toBe("fallback"); + }); + }); + + describe("levels traversed tracking", () => { + test("tracks correct number of levels", async () => { + createDir(join(testDir, ".git")); + createDir(join(testDir, "a", "b", "c", "d")); + + const result = await findProjectRoot(join(testDir, "a", "b", "c", "d")); + + // Levels: d(1) -> c(2) -> b(3) -> a(4) -> testDir(5, found .git) + expect(result.levelsTraversed).toBe(5); + }); + }); + }); +}); From aff89c6bc389802f4b243c9d1322236c6db22948 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 3 Feb 2026 14:22:57 +0000 Subject: [PATCH 02/19] refactor(dsn): replace language-specific detectors with grep-based scanner - Add language-agnostic code-scanner.ts that greps for DSN URL patterns - Filter commented lines using common prefixes (// # -- + + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores C-style block comment lines starting with /*", () => { + const content = ` + /* const DSN = "https://abc123@o123.ingest.sentry.io/456"; */ + const REAL_DSN = "https://real@o456.ingest.sentry.io/789"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores JSDoc/multi-line comment continuation lines starting with *", () => { + const content = ` + /** + * DSN: "https://abc123@o123.ingest.sentry.io/456" + */ + const REAL_DSN = "https://real@o456.ingest.sentry.io/789"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores SQL comments with --", () => { + const content = ` + -- INSERT INTO config VALUES ('dsn', 'https://abc123@o123.ingest.sentry.io/456'); + INSERT INTO config VALUES ('dsn', 'https://real@o456.ingest.sentry.io/789'); + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("ignores Python triple-quote docstrings", () => { + const content = ` + '''https://abc123@o123.ingest.sentry.io/456''' + DSN = "https://real@o456.ingest.sentry.io/789" + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://real@o456.ingest.sentry.io/789"]); + }); + + test("returns empty array for content without DSNs", () => { + const content = ` + const config = { debug: true }; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual([]); + }); + + test("only accepts *.sentry.io hosts for SaaS", () => { + const content = ` + const REAL = "https://abc@o123.ingest.sentry.io/456"; + const FAKE = "https://abc@fake.example.com/456"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc@o123.ingest.sentry.io/456"]); + }); + + test("accepts self-hosted DSNs when SENTRY_URL is set", () => { + process.env.SENTRY_URL = "https://sentry.mycompany.com:9000"; + const content = ` + const DSN = "https://abc@sentry.mycompany.com:9000/123"; + `; + const dsns = extractDsnsFromContent(content); + expect(dsns).toEqual(["https://abc@sentry.mycompany.com:9000/123"]); + }); + }); + + describe("extractFirstDsnFromContent", () => { + test("returns first DSN", () => { + const content = ` + const DSN1 = "https://first@o123.ingest.sentry.io/111"; + const DSN2 = "https://second@o456.ingest.sentry.io/222"; + `; + const dsn = extractFirstDsnFromContent(content); + expect(dsn).toBe("https://first@o123.ingest.sentry.io/111"); + }); + + test("returns null when no DSN found", () => { + const dsn = extractFirstDsnFromContent("no dsn here"); + expect(dsn).toBeNull(); + }); + }); + + describe("scanCodeForFirstDsn", () => { + test("finds DSN in root file", async () => { + writeFileSync( + join(testDir, "config.ts"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://abc@o123.ingest.sentry.io/456"); + expect(result?.source).toBe("code"); + expect(result?.sourcePath).toBe("config.ts"); + }); + + test("finds DSN in subdirectory", async () => { + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/sentry.ts"), + 'Sentry.init({ dsn: "https://abc@o123.ingest.sentry.io/456" });' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://abc@o123.ingest.sentry.io/456"); + expect(result?.sourcePath).toBe("src/sentry.ts"); + }); + + test("returns null when no DSN found", async () => { + writeFileSync(join(testDir, "index.ts"), "console.log('hello');"); + + const result = await scanCodeForFirstDsn(testDir); + expect(result).toBeNull(); + }); + + test("skips node_modules directory", async () => { + mkdirSync(join(testDir, "node_modules/some-package"), { + recursive: true, + }); + writeFileSync( + join(testDir, "node_modules/some-package/index.js"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result).toBeNull(); + }); + + test("respects gitignore", async () => { + writeFileSync(join(testDir, ".gitignore"), "ignored/"); + mkdirSync(join(testDir, "ignored"), { recursive: true }); + writeFileSync( + join(testDir, "ignored/config.ts"), + 'const DSN = "https://ignored@o123.ingest.sentry.io/456";' + ); + writeFileSync( + join(testDir, "real.ts"), + 'const DSN = "https://real@o456.ingest.sentry.io/789";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://real@o456.ingest.sentry.io/789"); + }); + + test("infers packagePath for monorepo structure", async () => { + // Use depth 2 (packages/frontend/sentry.ts) to stay within MAX_SCAN_DEPTH + mkdirSync(join(testDir, "packages/frontend"), { recursive: true }); + writeFileSync( + join(testDir, "packages/frontend/sentry.ts"), + 'const DSN = "https://abc@o123.ingest.sentry.io/456";' + ); + + const result = await scanCodeForFirstDsn(testDir); + expect(result?.packagePath).toBe("packages/frontend"); + }); + + test("scans various file types", async () => { + // Test Python + writeFileSync( + join(testDir, "app.py"), + 'sentry_sdk.init(dsn="https://py@o123.ingest.sentry.io/1")' + ); + + let result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://py@o123.ingest.sentry.io/1"); + + // Clean and test Go + rmSync(join(testDir, "app.py")); + writeFileSync( + join(testDir, "main.go"), + 'sentry.Init(sentry.ClientOptions{Dsn: "https://go@o123.ingest.sentry.io/2"})' + ); + + result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://go@o123.ingest.sentry.io/2"); + + // Clean and test Ruby + rmSync(join(testDir, "main.go")); + writeFileSync( + join(testDir, "config.rb"), + 'Sentry.init do |config|\n config.dsn = "https://rb@o123.ingest.sentry.io/3"\nend' + ); + + result = await scanCodeForFirstDsn(testDir); + expect(result?.raw).toBe("https://rb@o123.ingest.sentry.io/3"); + }); + }); + + describe("scanCodeForDsns", () => { + test("finds all DSNs across multiple files", async () => { + mkdirSync(join(testDir, "src"), { recursive: true }); + writeFileSync( + join(testDir, "src/frontend.ts"), + 'const DSN = "https://frontend@o123.ingest.sentry.io/111";' + ); + writeFileSync( + join(testDir, "src/backend.ts"), + 'const DSN = "https://backend@o456.ingest.sentry.io/222";' + ); + + const results = await scanCodeForDsns(testDir); + expect(results).toHaveLength(2); + + const dsns = results.map((r) => r.raw); + expect(dsns).toContain("https://frontend@o123.ingest.sentry.io/111"); + expect(dsns).toContain("https://backend@o456.ingest.sentry.io/222"); + }); + + test("deduplicates same DSN from multiple files", async () => { + writeFileSync( + join(testDir, "a.ts"), + 'const DSN = "https://same@o123.ingest.sentry.io/456";' + ); + writeFileSync( + join(testDir, "b.ts"), + 'const DSN = "https://same@o123.ingest.sentry.io/456";' + ); + + const results = await scanCodeForDsns(testDir); + expect(results).toHaveLength(1); + }); + + test("returns empty array when no DSNs found", async () => { + writeFileSync(join(testDir, "index.ts"), "console.log('hello');"); + + const results = await scanCodeForDsns(testDir); + expect(results).toEqual([]); + }); + }); +}); diff --git a/test/lib/dsn/languages/go.test.ts b/test/lib/dsn/languages/go.test.ts deleted file mode 100644 index 22b8ac06..00000000 --- a/test/lib/dsn/languages/go.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Go DSN Detector Tests - * - * Consolidated tests for extracting DSN from Go source code. - * Tests cover: ClientOptions struct, variable assignment, raw string, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromGo, - goDetector, -} from "../../../../src/lib/dsn/languages/go.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("Go DSN Detector", () => { - test("extracts DSN from sentry.Init with ClientOptions", () => { - const code = ` -package main - -import "github.com/getsentry/sentry-go" - -func main() { - err := sentry.Init(sentry.ClientOptions{ - Dsn: "${TEST_DSN}", - Environment: "production", - TracesSampleRate: 1.0, - }) -} -`; - expect(extractDsnFromGo(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from variable assignment", () => { - const code = ` -dsn := "${TEST_DSN}" -sentry.Init(sentry.ClientOptions{Dsn: dsn}) -`; - expect(extractDsnFromGo(code)).toBe(TEST_DSN); - }); - - test("extracts DSN with backtick raw string literal", () => { - const code = ` -dsn := \`${TEST_DSN}\` -`; - expect(extractDsnFromGo(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from os.Getenv", () => { - const code = ` -sentry.Init(sentry.ClientOptions{ - Dsn: os.Getenv("SENTRY_DSN"), -}) -`; - expect(extractDsnFromGo(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(goDetector.name).toBe("Go"); - expect(goDetector.extensions).toContain(".go"); - expect(goDetector.skipDirs).toContain("vendor"); - expect(goDetector.extractDsn).toBe(extractDsnFromGo); - }); -}); diff --git a/test/lib/dsn/languages/java.test.ts b/test/lib/dsn/languages/java.test.ts deleted file mode 100644 index 5d7812a8..00000000 --- a/test/lib/dsn/languages/java.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Java/Kotlin DSN Detector Tests - * - * Consolidated tests for extracting DSN from Java/Kotlin source code and properties files. - * Tests cover: Sentry.init, properties file, Kotlin pattern, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromJava, - javaDetector, -} from "../../../../src/lib/dsn/languages/java.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("Java DSN Detector", () => { - test("extracts DSN from Sentry.init with setDsn", () => { - const code = ` -import io.sentry.Sentry; - -public class SentryConfig { - public static void init() { - Sentry.init(options -> { - options.setDsn("${TEST_DSN}"); - options.setEnvironment("production"); - }); - } -} -`; - expect(extractDsnFromJava(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from sentry.properties file", () => { - const content = ` -# Sentry configuration -dsn=${TEST_DSN} -environment=production -`; - expect(extractDsnFromJava(content)).toBe(TEST_DSN); - }); - - test("extracts DSN from Kotlin companion object", () => { - const code = ` -companion object { - const val dsn = "${TEST_DSN}" -} -`; - expect(extractDsnFromJava(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from System.getenv", () => { - const code = ` -Sentry.init(options -> { - options.setDsn(System.getenv("SENTRY_DSN")); -}); -`; - expect(extractDsnFromJava(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(javaDetector.name).toBe("Java"); - expect(javaDetector.extensions).toContain(".java"); - expect(javaDetector.extensions).toContain(".kt"); - expect(javaDetector.extensions).toContain(".properties"); - expect(javaDetector.skipDirs).toContain("target"); - expect(javaDetector.skipDirs).toContain("build"); - expect(javaDetector.extractDsn).toBe(extractDsnFromJava); - }); -}); diff --git a/test/lib/dsn/languages/javascript.test.ts b/test/lib/dsn/languages/javascript.test.ts deleted file mode 100644 index d4175315..00000000 --- a/test/lib/dsn/languages/javascript.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * JavaScript DSN Detector Tests - * - * Consolidated tests for extracting DSN from JavaScript/TypeScript source code. - * Tests cover: basic init, config object, multiple DSNs, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromCode, - javascriptDetector, -} from "../../../../src/lib/dsn/languages/javascript.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("JavaScript DSN Detector", () => { - test("extracts DSN from basic Sentry.init", () => { - const code = ` - import * as Sentry from "@sentry/react"; - - Sentry.init({ - dsn: "${TEST_DSN}", - tracesSampleRate: 1.0, - }); - `; - expect(extractDsnFromCode(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from config object", () => { - const code = ` - export const sentryConfig = { - dsn: "${TEST_DSN}", - enabled: true, - }; - `; - expect(extractDsnFromCode(code)).toBe(TEST_DSN); - }); - - test("extracts first DSN when multiple exist", () => { - const dsn2 = "https://xyz@o999.ingest.sentry.io/111"; - const code = ` - Sentry.init({ dsn: "${TEST_DSN}" }); - const backup = { dsn: "${dsn2}" }; - `; - expect(extractDsnFromCode(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from env variable", () => { - const code = ` - Sentry.init({ - dsn: process.env.SENTRY_DSN, - }); - `; - expect(extractDsnFromCode(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(javascriptDetector.name).toBe("JavaScript"); - expect(javascriptDetector.extensions).toContain(".ts"); - expect(javascriptDetector.extensions).toContain(".js"); - expect(javascriptDetector.skipDirs).toContain("node_modules"); - expect(javascriptDetector.extractDsn).toBe(extractDsnFromCode); - }); -}); diff --git a/test/lib/dsn/languages/php.test.ts b/test/lib/dsn/languages/php.test.ts deleted file mode 100644 index d2126fa5..00000000 --- a/test/lib/dsn/languages/php.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * PHP DSN Detector Tests - * - * Consolidated tests for extracting DSN from PHP source code. - * Tests cover: Sentry\init, Laravel config, multiline init, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromPhp, - phpDetector, -} from "../../../../src/lib/dsn/languages/php.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("PHP DSN Detector", () => { - test("extracts DSN from Sentry\\init", () => { - const code = ` - '${TEST_DSN}', - 'environment' => 'production', -]); -`; - expect(extractDsnFromPhp(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from Laravel config style", () => { - const code = ` - [ - 'dsn' => '${TEST_DSN}', - ], -]; -`; - expect(extractDsnFromPhp(code)).toBe(TEST_DSN); - }); - - test("extracts DSN with double quotes", () => { - const code = ` - "${TEST_DSN}"]); -`; - expect(extractDsnFromPhp(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from env function", () => { - const code = ` - env('SENTRY_DSN')]); -`; - expect(extractDsnFromPhp(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(phpDetector.name).toBe("PHP"); - expect(phpDetector.extensions).toContain(".php"); - expect(phpDetector.skipDirs).toContain("vendor"); - expect(phpDetector.extractDsn).toBe(extractDsnFromPhp); - }); -}); diff --git a/test/lib/dsn/languages/python.test.ts b/test/lib/dsn/languages/python.test.ts deleted file mode 100644 index 1a27128d..00000000 --- a/test/lib/dsn/languages/python.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Python DSN Detector Tests - * - * Consolidated tests for extracting DSN from Python source code. - * Tests cover: basic init, dict config, Django settings, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromPython, - pythonDetector, -} from "../../../../src/lib/dsn/languages/python.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("Python DSN Detector", () => { - test("extracts DSN from basic sentry_sdk.init", () => { - const code = ` -import sentry_sdk - -sentry_sdk.init( - dsn="${TEST_DSN}", - traces_sample_rate=1.0, -) -`; - expect(extractDsnFromPython(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from dict config", () => { - const code = ` -SENTRY_CONFIG = { - "dsn": "${TEST_DSN}", - "environment": "production", -} -`; - expect(extractDsnFromPython(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from Django settings style", () => { - const code = ` -SENTRY_DSN = "${TEST_DSN}" - -LOGGING = { - "handlers": { - "sentry": { - "dsn": "${TEST_DSN}", - } - } -} -`; - expect(extractDsnFromPython(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from env variable", () => { - const code = ` -import os -sentry_sdk.init(dsn=os.environ.get("SENTRY_DSN")) -`; - expect(extractDsnFromPython(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(pythonDetector.name).toBe("Python"); - expect(pythonDetector.extensions).toContain(".py"); - expect(pythonDetector.skipDirs).toContain("venv"); - expect(pythonDetector.skipDirs).toContain("__pycache__"); - expect(pythonDetector.extractDsn).toBe(extractDsnFromPython); - }); -}); diff --git a/test/lib/dsn/languages/ruby.test.ts b/test/lib/dsn/languages/ruby.test.ts deleted file mode 100644 index 643c4d31..00000000 --- a/test/lib/dsn/languages/ruby.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Ruby DSN Detector Tests - * - * Consolidated tests for extracting DSN from Ruby source code. - * Tests cover: Sentry.init block, Rails initializer, hash patterns, env filtering, detector config. - */ - -import { describe, expect, test } from "bun:test"; -import { - extractDsnFromRuby, - rubyDetector, -} from "../../../../src/lib/dsn/languages/ruby.js"; - -const TEST_DSN = "https://abc123@o456.ingest.sentry.io/789"; - -describe("Ruby DSN Detector", () => { - test("extracts DSN from Sentry.init block", () => { - const code = ` -Sentry.init do |config| - config.dsn = '${TEST_DSN}' - config.traces_sample_rate = 1.0 -end -`; - expect(extractDsnFromRuby(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from Rails initializer style", () => { - const code = ` -Sentry.init do |config| - config.dsn = '${TEST_DSN}' - config.breadcrumbs_logger = [:active_support_logger] - config.traces_sample_rate = 0.5 -end -`; - expect(extractDsnFromRuby(code)).toBe(TEST_DSN); - }); - - test("extracts DSN from symbol key hash", () => { - const code = ` -sentry_config = { - dsn: '${TEST_DSN}', - environment: 'production' -} -`; - expect(extractDsnFromRuby(code)).toBe(TEST_DSN); - }); - - test("returns null for DSN from ENV", () => { - const code = ` -Sentry.init do |config| - config.dsn = ENV['SENTRY_DSN'] -end -`; - expect(extractDsnFromRuby(code)).toBeNull(); - }); - - test("detector has correct configuration", () => { - expect(rubyDetector.name).toBe("Ruby"); - expect(rubyDetector.extensions).toContain(".rb"); - expect(rubyDetector.skipDirs).toContain("vendor/bundle"); - expect(rubyDetector.extractDsn).toBe(extractDsnFromRuby); - }); -}); From 4c5d2f4598cba3a0557a4b0be7d7713b530ea4e2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 3 Feb 2026 14:31:19 +0000 Subject: [PATCH 03/19] style: remove ASCII art section dividers from code-scanner --- src/lib/dsn/code-scanner.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/lib/dsn/code-scanner.ts b/src/lib/dsn/code-scanner.ts index 4d0fed18..ee98beec 100644 --- a/src/lib/dsn/code-scanner.ts +++ b/src/lib/dsn/code-scanner.ts @@ -21,10 +21,6 @@ import pLimit from "p-limit"; import { createDetectedDsn, inferPackagePath, parseDsn } from "./parser.js"; import type { DetectedDsn } from "./types.js"; -// ───────────────────────────────────────────────────────────────────────────── -// Constants -// ───────────────────────────────────────────────────────────────────────────── - /** * Maximum file size to scan (256KB). * Files larger than this are skipped as they're unlikely to be source files @@ -162,10 +158,6 @@ const COMMENT_PREFIXES = ["//", "#", "--", "