diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 2f20509b..2a9293af 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -28,6 +28,31 @@ export interface FrameworkWarning { framework: SupportedFramework; } +/** + * Information about the file an environment variable usage was found in + */ +export interface FrameworkRuleContext { + /** The file path, normalized to forward slashes */ + file: string; + /** The full content of the file (if available) */ + content?: string | undefined; +} + +/** + * A supported framework: how to detect it and which rules apply to it + */ +export interface Framework { + /** Unique name of the framework */ + name: SupportedFramework; + /** npm package whose presence in package.json identifies the framework */ + packageName: string; + /** + * Checks a single environment variable usage against the framework rules + * @returns The reason for a warning, or undefined if the usage is fine + */ + check(usage: EnvUsage, context: FrameworkRuleContext): string | undefined; +} + /** * Type representing a duplicate entry */ diff --git a/packages/cli/src/core/frameworks/constants.ts b/packages/cli/src/core/frameworks/constants.ts deleted file mode 100644 index fa775895..00000000 --- a/packages/cli/src/core/frameworks/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Keywords that indicate a variable is sensitive and must not be exposed to - * the browser (e.g. a public-prefixed variable containing one of these). - */ -export const SENSITIVE_KEYWORDS_PATTERN = /SECRET|PRIVATE|PASSWORD/; diff --git a/packages/cli/src/core/frameworks/frameworkDetector.ts b/packages/cli/src/core/frameworks/frameworkDetector.ts index 3164a87a..d7c528dc 100644 --- a/packages/cli/src/core/frameworks/frameworkDetector.ts +++ b/packages/cli/src/core/frameworks/frameworkDetector.ts @@ -1,63 +1,55 @@ import fs from 'fs'; import path from 'path'; -import type { DetectedFramework } from '../../config/types.js'; +import type { Framework } from '../../config/types.js'; +import { FRAMEWORKS } from './registry.js'; /** - * Interface representing the detected framework and its version (if applicable) + * Interface representing the detected framework and its version */ interface FrameworkDetection { - /** The detected framework (e.g., 'sveltekit', 'nextjs', or 'unknown') */ - framework: DetectedFramework; - /** The version of the detected framework (if available) */ - version?: string; + /** The detected framework */ + framework: Framework; + /** The version range of the framework from package.json */ + version: string; } /** - * Detects the framework being used in the project - * by checking package.json and file structure + * Detects the framework being used in the project by checking package.json * @param cwd The current working directory of the project - * @returns Detected framework and its version (if applicable) + * @returns The detected framework and its version, or undefined if none is supported */ -export function detectFramework(cwd: string): FrameworkDetection { +export function detectFramework(cwd: string): FrameworkDetection | undefined { + const deps = readDependencies(cwd); + + for (const framework of FRAMEWORKS) { + const version = deps[framework.packageName]; + if (version) { + return { framework, version }; + } + } + + return undefined; +} + +/** + * Reads dependencies and devDependencies from package.json + * @param cwd The current working directory of the project + * @returns Map of package names to version ranges (empty if package.json is missing or invalid) + */ +function readDependencies(cwd: string): Record { try { const packageJsonPath = path.join(cwd, 'package.json'); if (!fs.existsSync(packageJsonPath)) { - return { framework: 'unknown' }; + return {}; } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); - const deps = { + return { ...packageJson.dependencies, ...packageJson.devDependencies, }; - - // Check for SvelteKit - if (deps['@sveltejs/kit']) { - return { - framework: 'sveltekit', - version: deps['@sveltejs/kit'], - }; - } - - // Check for Next.js - if (deps['next']) { - return { - framework: 'nextjs', - version: deps['next'], - }; - } - - // Check for Nuxt - if (deps['nuxt']) { - return { - framework: 'nuxt', - version: deps['nuxt'], - }; - } - - return { framework: 'unknown' }; } catch { - return { framework: 'unknown' }; + return {}; } } diff --git a/packages/cli/src/core/frameworks/frameworkValidator.ts b/packages/cli/src/core/frameworks/frameworkValidator.ts index 5e323a19..773e98a2 100644 --- a/packages/cli/src/core/frameworks/frameworkValidator.ts +++ b/packages/cli/src/core/frameworks/frameworkValidator.ts @@ -1,13 +1,13 @@ -import type { EnvUsage, FrameworkWarning } from '../../config/types.js'; +import type { + EnvUsage, + Framework, + FrameworkWarning, +} from '../../config/types.js'; +import { normalizePath } from '../helpers/normalizePath.js'; import { detectFramework } from './frameworkDetector.js'; -import { - applySvelteKitRules, - applyNextJsRules, - applyNuxtRules, -} from './index.js'; /** - * Validates environment variable usages against framework-specific rules + * Validates environment variable usages against the rules of the project's framework * @param usages - Array of environment variable usages * @param cwd - Current working directory to detect framework * @param fileContentMap - Map of file paths to their content for detecting client components @@ -17,14 +17,51 @@ export function frameworkValidator( usages: EnvUsage[], cwd: string, fileContentMap?: Map, +): FrameworkWarning[] { + const detection = detectFramework(cwd); + if (!detection) { + return []; + } + + return validateUsages(usages, detection.framework, fileContentMap); +} + +/** + * Validates environment variable usages against the rules of a given framework + * @param usages - Array of environment variable usages + * @param framework - The framework whose rules to apply + * @param fileContentMap - Map of file paths to their content for detecting client components + * @returns Array of framework-specific warnings + */ +export function validateUsages( + usages: EnvUsage[], + framework: Framework, + fileContentMap?: Map, ): FrameworkWarning[] { const warnings: FrameworkWarning[] = []; - const { framework } = detectFramework(cwd); - for (const u of usages) { - if (framework === 'sveltekit') applySvelteKitRules(u, warnings); - if (framework === 'nextjs') applyNextJsRules(u, warnings, fileContentMap); - if (framework === 'nuxt') applyNuxtRules(u, warnings); + for (const usage of usages) { + // Normalize path separators for cross-platform consistency + const file = normalizePath(usage.file); + + if (file.includes('/node_modules/')) { + continue; + } + + const reason = framework.check(usage, { + file, + content: fileContentMap?.get(usage.file), + }); + + if (reason) { + warnings.push({ + variable: usage.variable, + reason, + file, + line: usage.line, + framework: framework.name, + }); + } } return warnings; diff --git a/packages/cli/src/core/frameworks/index.ts b/packages/cli/src/core/frameworks/index.ts deleted file mode 100644 index 73594ba5..00000000 --- a/packages/cli/src/core/frameworks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { applySvelteKitRules } from './sveltekitRules.js'; -export { applyNextJsRules } from './nextJsRules.js'; -export { applyNuxtRules } from './nuxtRules.js'; diff --git a/packages/cli/src/core/frameworks/nextJsRules.ts b/packages/cli/src/core/frameworks/nextJsRules.ts deleted file mode 100644 index 84bf2b3e..00000000 --- a/packages/cli/src/core/frameworks/nextJsRules.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { EnvUsage, FrameworkWarning } from '../../config/types.js'; -import { normalizePath } from '../helpers/normalizePath.js'; -import { SENSITIVE_KEYWORDS_PATTERN } from './constants.js'; - -/** - * Next.js environment variable validation rules - * @param u - The environment variable usage information - * @param warnings - The array to push warnings into - * @param fileContentMap - Optional map of file paths to their content for detecting client components - */ -export function applyNextJsRules( - u: EnvUsage, - warnings: FrameworkWarning[], - fileContentMap?: Map, -): void { - // Normalize path separators for cross-platform consistency - const normalizedFile = normalizePath(u.file); - - // Ignore node_modules - if (normalizedFile.includes('/node_modules/')) { - return; - } - - // Detect client components by checking the full file content - let isClientComponent = false; - if (fileContentMap) { - const fileContent = fileContentMap.get(u.file); - if (fileContent) { - // Check first few lines for "use client" directive - const firstLines = fileContent.split('\n').slice(0, 10).join('\n'); - isClientComponent = - /['"]use client['"]/.test(firstLines) || - /^use client;?$/m.test(firstLines); - } - } - // Pages Router: all non-API files are client components - const isPagesRouterClientFile = - /(^|\/)pages\//.test(normalizedFile) && - !/(^|\/)pages\/api\//.test(normalizedFile); - - isClientComponent ||= isPagesRouterClientFile; - - // Client components MUST use NEXT_PUBLIC_ prefix - if (isClientComponent && !u.variable.startsWith('NEXT_PUBLIC_')) { - warnings.push({ - variable: u.variable, - reason: 'Server-only variable accessed from client code', - file: normalizedFile, - line: u.line, - framework: 'nextjs', - }); - return; // Stop processing other rules for this usage - } - - // Warn about Vite syntax in Next.js - if (u.pattern === 'import.meta.env') { - warnings.push({ - variable: u.variable, - reason: 'Next.js uses process.env, not import.meta.env (Vite syntax)', - file: normalizedFile, - line: u.line, - framework: 'nextjs', - }); - return; // Stop processing other rules for this usage - } - - // Warn if NEXT_PUBLIC_ contains sensitive keywords - if ( - u.variable.startsWith('NEXT_PUBLIC_') && - SENSITIVE_KEYWORDS_PATTERN.test(u.variable) - ) { - warnings.push({ - variable: u.variable, - reason: 'Potential sensitive environment variable exposed to the browser', - file: normalizedFile, - line: u.line, - framework: 'nextjs', - }); - return; // Stop processing other rules for this usage - } -} diff --git a/packages/cli/src/core/frameworks/nextjs.ts b/packages/cli/src/core/frameworks/nextjs.ts new file mode 100644 index 00000000..ad68e4ec --- /dev/null +++ b/packages/cli/src/core/frameworks/nextjs.ts @@ -0,0 +1,54 @@ +import type { Framework } from '../../config/types.js'; +import { EXPOSED_SECRET_REASON, isExposedSecret } from './shared.js'; + +/** + * Next.js environment variable rules. + * + * Only variables prefixed with `NEXT_PUBLIC_` are inlined into client code. + */ +export const nextjs: Framework = { + name: 'nextjs', + packageName: 'next', + + check(usage, { file, content }) { + if ( + isClientFile(file, content) && + !usage.variable.startsWith('NEXT_PUBLIC_') + ) { + return 'Server-only variable accessed from client code'; + } + + if (usage.pattern === 'import.meta.env') { + return 'Next.js uses process.env, not import.meta.env (Vite syntax)'; + } + + if (isExposedSecret(usage.variable, ['NEXT_PUBLIC_'])) { + return EXPOSED_SECRET_REASON; + } + + return undefined; + }, +}; + +/** + * Checks whether a file runs in the browser: + * - App Router: files with a "use client" directive + * - Pages Router: everything in `pages/` except `pages/api/` + */ +function isClientFile(file: string, content?: string): boolean { + return hasUseClientDirective(content) || isPagesRouterPage(file); +} + +function hasUseClientDirective(content?: string): boolean { + if (!content) return false; + + // The directive must appear at the top of the file + const firstLines = content.split('\n').slice(0, 10).join('\n'); + return ( + /['"]use client['"]/.test(firstLines) || /^use client;?$/m.test(firstLines) + ); +} + +function isPagesRouterPage(file: string): boolean { + return /(^|\/)pages\//.test(file) && !/(^|\/)pages\/api\//.test(file); +} diff --git a/packages/cli/src/core/frameworks/nuxt.ts b/packages/cli/src/core/frameworks/nuxt.ts new file mode 100644 index 00000000..1ccaace0 --- /dev/null +++ b/packages/cli/src/core/frameworks/nuxt.ts @@ -0,0 +1,53 @@ +import type { Framework } from '../../config/types.js'; +import { EXPOSED_SECRET_REASON, isExposedSecret } from './shared.js'; + +/** + * Nuxt environment variable rules. + * + * Background (Nuxt 3): + * - Runtime configuration lives in `runtimeConfig` (private, server-only) and + * `runtimeConfig.public` (exposed to the client) and is read via + * `useRuntimeConfig()`. + * - In production, `.env` files are NOT read at runtime, and `process.env` is + * not populated in the browser. Direct `process.env` access in + * client/universal code is therefore unreliable — `useRuntimeConfig()` + * should be used instead. + * - Environment variables only override runtime config when prefixed with + * `NUXT_` (private) or `NUXT_PUBLIC_` (public). + */ +export const nuxt: Framework = { + name: 'nuxt', + packageName: 'nuxt', + + check(usage, { file }) { + if ( + usage.pattern === 'process.env' && + !isServerFile(file) && + !isConfigFile(file) + ) { + return 'process.env is not available in the browser; use useRuntimeConfig() instead'; + } + + if (isExposedSecret(usage.variable, ['NUXT_PUBLIC_'])) { + return EXPOSED_SECRET_REASON; + } + + return undefined; + }, +}; + +/** + * nuxt.config.* is allowed to read process.env (it feeds runtimeConfig) + */ +function isConfigFile(file: string): boolean { + return /(^|\/)nuxt\.config\.(ts|js|mjs|cjs)$/.test(file); +} + +/** + * Server-only contexts in Nuxt: + * - the Nitro `server/` directory (api, routes, middleware, plugins, utils) + * - `.server.` suffixed files (e.g. plugins/foo.server.ts, Foo.server.vue) + */ +function isServerFile(file: string): boolean { + return /(^|\/)server\//.test(file) || /\.server\.(ts|js|mjs|vue)$/.test(file); +} diff --git a/packages/cli/src/core/frameworks/nuxtRules.ts b/packages/cli/src/core/frameworks/nuxtRules.ts deleted file mode 100644 index 08ae4e80..00000000 --- a/packages/cli/src/core/frameworks/nuxtRules.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { EnvUsage, FrameworkWarning } from '../../config/types.js'; -import { normalizePath } from '../helpers/normalizePath.js'; -import { SENSITIVE_KEYWORDS_PATTERN } from './constants.js'; - -/** - * Nuxt environment variable validation rules. - * - * Background (Nuxt 3): - * - Runtime configuration lives in `runtimeConfig` (private, server-only) and - * `runtimeConfig.public` (exposed to the client) and is read via - * `useRuntimeConfig()`. - * - In production, `.env` files are NOT read at runtime, and `process.env` is - * not populated in the browser. Direct `process.env` access in - * client/universal code is therefore unreliable — `useRuntimeConfig()` - * should be used instead. - * - Environment variables only override runtime config when prefixed with - * `NUXT_` (private) or `NUXT_PUBLIC_` (public). - * - * @param u - The environment variable usage information - * @param warnings - The array to push warnings into - */ -export function applyNuxtRules( - u: EnvUsage, - warnings: FrameworkWarning[], -): void { - // Normalize path separators for cross-platform consistency - const normalizedFile = normalizePath(u.file); - - // Ignore node_modules - if (normalizedFile.includes('/node_modules/')) { - return; - } - - // nuxt.config.* is allowed to read process.env (it feeds runtimeConfig) - const isConfigFile = /(^|\/)nuxt\.config\.(ts|js|mjs|cjs)$/.test( - normalizedFile, - ); - - // Server-only contexts in Nuxt: - // - the Nitro `server/` directory (api, routes, middleware, plugins, utils) - // - `.server.` suffixed files (e.g. plugins/foo.server.ts, Foo.server.vue) - const isServerFile = - /(^|\/)server\//.test(normalizedFile) || - /\.server\.(ts|js|mjs|vue)$/.test(normalizedFile); - - // process.env is unreliable in client/universal code: not available in the - // browser and .env files are not read at runtime in production. - if (u.pattern === 'process.env' && !isServerFile && !isConfigFile) { - warnings.push({ - variable: u.variable, - reason: - 'process.env is not available in the browser; use useRuntimeConfig() instead', - file: normalizedFile, - line: u.line, - framework: 'nuxt', - }); - return; // Stop processing other rules for this usage - } - - // Warn if NUXT_PUBLIC_ contains sensitive keywords (exposed to the browser) - if ( - u.variable.startsWith('NUXT_PUBLIC_') && - SENSITIVE_KEYWORDS_PATTERN.test(u.variable) - ) { - warnings.push({ - variable: u.variable, - reason: 'Potential sensitive environment variable exposed to the browser', - file: normalizedFile, - line: u.line, - framework: 'nuxt', - }); - return; // Stop processing other rules for this usage - } -} diff --git a/packages/cli/src/core/frameworks/registry.ts b/packages/cli/src/core/frameworks/registry.ts new file mode 100644 index 00000000..849fe6e7 --- /dev/null +++ b/packages/cli/src/core/frameworks/registry.ts @@ -0,0 +1,13 @@ +import type { Framework } from '../../config/types.js'; +import { sveltekit } from './sveltekit.js'; +import { nextjs } from './nextjs.js'; +import { nuxt } from './nuxt.js'; + +/** + * All supported frameworks. + * + * Order matters: detection picks the first framework whose package is installed. + * Specific frameworks must therefore come before generic ones (e.g. a plain + * Vite fallback), since many frameworks depend on Vite themselves. + */ +export const FRAMEWORKS: readonly Framework[] = [sveltekit, nextjs, nuxt]; diff --git a/packages/cli/src/core/frameworks/shared.ts b/packages/cli/src/core/frameworks/shared.ts new file mode 100644 index 00000000..2edf344f --- /dev/null +++ b/packages/cli/src/core/frameworks/shared.ts @@ -0,0 +1,25 @@ +/** + * Keywords that indicate a variable is sensitive and must not be exposed to + * the browser (e.g. a public-prefixed variable containing one of these). + */ +const SENSITIVE_KEYWORDS_PATTERN = /SECRET|PRIVATE|PASSWORD/; + +/** + * Warning reason shared by all frameworks when a public variable looks like a secret + */ +export const EXPOSED_SECRET_REASON = + 'Potential sensitive environment variable exposed to the browser'; + +/** + * Checks whether a variable is exposed to the browser and looks like a secret + * @param variable - The environment variable name + * @param publicPrefixes - Prefixes that make a variable available in the browser + * @returns True if the variable is public and contains a sensitive keyword + */ +export function isExposedSecret( + variable: string, + publicPrefixes: string[], +): boolean { + const isPublic = publicPrefixes.some((prefix) => variable.startsWith(prefix)); + return isPublic && SENSITIVE_KEYWORDS_PATTERN.test(variable); +} diff --git a/packages/cli/src/core/frameworks/sveltekit.ts b/packages/cli/src/core/frameworks/sveltekit.ts new file mode 100644 index 00000000..69c68f2d --- /dev/null +++ b/packages/cli/src/core/frameworks/sveltekit.ts @@ -0,0 +1,105 @@ +import type { Framework } from '../../config/types.js'; +import { EXPOSED_SECRET_REASON, isExposedSecret } from './shared.js'; + +/** + * SvelteKit environment variable rules. + * + * Private variables must stay on the server, and public variables must use the + * `PUBLIC_` prefix (or `VITE_` when read through `import.meta.env`). + */ +export const sveltekit: Framework = { + name: 'sveltekit', + packageName: '@sveltejs/kit', + + check(usage, { file }) { + const { variable, pattern } = usage; + const isPublic = variable.startsWith('PUBLIC_'); + const isClientCode = isSvelteComponent(file) || isClientFile(file); + const importsFrom = (module: string): boolean => + pattern === 'sveltekit' && usage.imports?.includes(module) === true; + + if (pattern === 'import.meta.env' && !variable.startsWith('VITE_')) { + return 'Variables accessed through import.meta.env must start with "VITE_"'; + } + + if ( + pattern === 'process.env' && + !isServerFile(file) && + !isConfigFile(file) + ) { + return 'process.env should only be used in server files'; + } + + if (importsFrom('$env/dynamic/private')) { + if (isClientCode) { + return '$env/dynamic/private cannot be used in client-side code'; + } + if (isPublic) { + return '$env/dynamic/private variables must not start with "PUBLIC_"'; + } + } + + if (importsFrom('$env/dynamic/public') && !isPublic) { + return '$env/dynamic/public variables must start with "PUBLIC_"'; + } + + if (importsFrom('$env/static/private')) { + if (isPublic) { + return '$env/static/private variables must not start with "PUBLIC_"'; + } + if (isClientCode) { + return '$env/static/private variables cannot be used in client-side code'; + } + } + + if (importsFrom('$env/static/public') && !isPublic) { + return '$env/static/public variables must start with "PUBLIC_"'; + } + + // SvelteKit 3 + if (importsFrom('$app/env/private') && isClientCode) { + return '$app/env/private cannot be used in client-side code'; + } + + if (isExposedSecret(variable, ['PUBLIC_', 'VITE_'])) { + return EXPOSED_SECRET_REASON; + } + + return undefined; + }, +}; + +/** + * Config files are allowed to use process.env + */ +function isConfigFile(file: string): boolean { + return /svelte\.config\.(ts|js)$/.test(file); +} + +function isServerFile(file: string): boolean { + return ( + // SvelteKit route server files + file.includes('/+server.') || + file.includes('.server.') || + // hooks.server.ts / handle.server.ts etc. + /\/(hooks|handle)\.server\.(ts|js)$/.test(file) || + // generic server.ts fallback + /\/server\.(ts|js)$/.test(file) || + // $lib/server (src/lib/server) — SvelteKit's enforced server-only directory, + // never bundled to the client, so process.env there is legitimate. + /(^|\/)lib\/server\//.test(file) + ); +} + +function isClientFile(file: string): boolean { + return ( + !file.includes('.server.') && + (file.includes('/hooks.client.') || + file.includes('/+page.') || + file.includes('/+layout.')) + ); +} + +function isSvelteComponent(file: string): boolean { + return /\.svelte$/.test(file); +} diff --git a/packages/cli/src/core/frameworks/sveltekitRules.ts b/packages/cli/src/core/frameworks/sveltekitRules.ts deleted file mode 100644 index 75abebc4..00000000 --- a/packages/cli/src/core/frameworks/sveltekitRules.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { EnvUsage, FrameworkWarning } from '../../config/types.js'; -import { normalizePath } from '../helpers/normalizePath.js'; -import { SENSITIVE_KEYWORDS_PATTERN } from './constants.js'; - -/** - * Applies SvelteKit specific rules to environment variable usage - * @param u - The environment variable usage information - * @param warnings - The array to push warnings into - */ -export function applySvelteKitRules( - u: EnvUsage, - warnings: FrameworkWarning[], -): void { - // Normalize path separators for cross-platform consistency - const normalizedFile = normalizePath(u.file); - - // Ignore node_modules - if (normalizedFile.includes('/node_modules/')) { - return; - } - - // Config files are allowed to use process.env - const isConfigFile = /svelte\.config\.(ts|js)$/.test(normalizedFile); - - const isServerFile = - // SvelteKit route server files - normalizedFile.includes('/+server.') || - normalizedFile.includes('.server.') || - // hooks.server.ts / handle.server.ts etc. - /\/(hooks|handle)\.server\.(ts|js)$/.test(normalizedFile) || - // generic server.ts fallback - /\/server\.(ts|js)$/.test(normalizedFile) || - // $lib/server (src/lib/server) — SvelteKit's enforced server-only directory, - // never bundled to the client, so process.env there is legitimate. - /(^|\/)lib\/server\//.test(normalizedFile); - - const isClientFile = - !normalizedFile.includes('.server.') && - (normalizedFile.includes('/hooks.client.') || - normalizedFile.includes('/+page.') || - normalizedFile.includes('/+layout.')); - - const isSvelteFile = /\.svelte$/.test(normalizedFile); - - // import.meta.env - if (u.pattern === 'import.meta.env' && !u.variable.startsWith('VITE_')) { - warnings.push({ - variable: u.variable, - reason: - 'Variables accessed through import.meta.env must start with "VITE_"', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; // Stop processing other rules for this usage - } - - // process.env - if (u.pattern === 'process.env') { - if (!isServerFile && !isConfigFile) { - warnings.push({ - variable: u.variable, - reason: 'process.env should only be used in server files', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; - } - } - - // $env/dynamic/private - if ( - u.pattern === 'sveltekit' && - u.imports?.includes('$env/dynamic/private') && - (isSvelteFile || isClientFile) - ) { - warnings.push({ - variable: u.variable, - reason: '$env/dynamic/private cannot be used in client-side code', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; - } - - if ( - u.pattern === 'sveltekit' && - u.imports?.includes('$env/dynamic/private') && - u.variable.startsWith('PUBLIC_') - ) { - warnings.push({ - variable: u.variable, - reason: '$env/dynamic/private variables must not start with "PUBLIC_"', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; - } - - //$env/dynamic/public - if ( - u.pattern === 'sveltekit' && - u.imports?.includes('$env/dynamic/public') && - !u.variable.startsWith('PUBLIC_') - ) { - warnings.push({ - variable: u.variable, - reason: '$env/dynamic/public variables must start with "PUBLIC_"', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; - } - - // $env/static/private - if (u.pattern === 'sveltekit' && u.imports?.includes('$env/static/private')) { - if (u.variable.startsWith('PUBLIC_')) { - warnings.push({ - variable: u.variable, - reason: '$env/static/private variables must not start with "PUBLIC_"', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; - } - - if (isSvelteFile || isClientFile) { - warnings.push({ - variable: u.variable, - reason: - '$env/static/private variables cannot be used in client-side code', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; - } - } - - // $env/static/public - if ( - u.pattern === 'sveltekit' && - u.imports?.includes('$env/static/public') && - !u.variable.startsWith('PUBLIC_') - ) { - warnings.push({ - variable: u.variable, - reason: '$env/static/public variables must start with "PUBLIC_"', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; - } - - // $app/env/private (SvelteKit 3) - if ( - u.pattern === 'sveltekit' && - u.imports?.includes('$app/env/private') && - (isSvelteFile || isClientFile) - ) { - warnings.push({ - variable: u.variable, - reason: '$app/env/private cannot be used in client-side code', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; - } - - // Warn if PUBLIC_ or VITE_ contains sensitive keywords - if ( - (u.variable.startsWith('PUBLIC_') || u.variable.startsWith('VITE_')) && - SENSITIVE_KEYWORDS_PATTERN.test(u.variable) - ) { - warnings.push({ - variable: u.variable, - reason: 'Potential sensitive environment variable exposed to the browser', - file: normalizedFile, - line: u.line, - framework: 'sveltekit', - }); - return; // Stop processing other rules for this usage - } -} diff --git a/packages/cli/test/e2e/frameworks/cli.nextJs.e2e.test.ts b/packages/cli/test/e2e/frameworks/cli.nextJs.e2e.test.ts index ca7fb889..d0b96e9b 100644 --- a/packages/cli/test/e2e/frameworks/cli.nextJs.e2e.test.ts +++ b/packages/cli/test/e2e/frameworks/cli.nextJs.e2e.test.ts @@ -28,7 +28,7 @@ function tmpDir() { } /** - * Minimal Next.js project so detectFramework() → "next" + * Minimal Next.js project so detectFramework() → "nextjs" */ function makeNextProject(cwd: string) { fs.writeFileSync( diff --git a/packages/cli/test/property/core/frameworks/frameworkRules.property.test.ts b/packages/cli/test/property/core/frameworks/frameworkRules.property.test.ts index 08db239a..dea085e3 100644 --- a/packages/cli/test/property/core/frameworks/frameworkRules.property.test.ts +++ b/packages/cli/test/property/core/frameworks/frameworkRules.property.test.ts @@ -1,18 +1,16 @@ import { describe, test, expect } from 'vitest'; import fc from 'fast-check'; -import type { - EnvUsage, - FrameworkWarning, -} from '../../../../src/config/types.js'; -import { applyNextJsRules } from '../../../../src/core/frameworks/nextJsRules.js'; -import { applyNuxtRules } from '../../../../src/core/frameworks/nuxtRules.js'; -import { applySvelteKitRules } from '../../../../src/core/frameworks/sveltekitRules.js'; +import type { EnvUsage, Framework } from '../../../../src/config/types.js'; +import { validateUsages } from '../../../../src/core/frameworks/frameworkValidator.js'; +import { FRAMEWORKS } from '../../../../src/core/frameworks/registry.js'; +import { nextjs } from '../../../../src/core/frameworks/nextjs.js'; +import { sveltekit } from '../../../../src/core/frameworks/sveltekit.js'; import { normalizePath } from '../../../../src/core/helpers/normalizePath.js'; /** * Property-based ("fuzz") tests for the per-framework validation rules. * - * Each `apply*Rules` function inspects one env-var usage and pushes at most one + * Each framework `check` inspects one env-var usage and yields at most one * warning. They are branchy (path regexes, prefix checks, a sensitive-keyword * matcher), so these throw thousands of random usages — odd file paths, unicode * variables, every access pattern, arbitrary `$env` imports — at all three to @@ -100,33 +98,17 @@ const usageArb: fc.Arbitrary = fc.record({ isLogged: fc.option(fc.boolean(), { nil: undefined }), }) as fc.Arbitrary; -type Rule = ( +const run = ( + framework: Framework, u: EnvUsage, - warnings: FrameworkWarning[], fileContentMap?: Map, -) => void; +) => validateUsages([u], framework, fileContentMap); -const RULES: Array<{ name: string; fn: Rule; framework: string }> = [ - { name: 'nextjs', fn: applyNextJsRules, framework: 'nextjs' }, - { name: 'nuxt', fn: applyNuxtRules as Rule, framework: 'nuxt' }, - { - name: 'sveltekit', - fn: applySvelteKitRules as Rule, - framework: 'sveltekit', - }, -]; - -const run = (fn: Rule, u: EnvUsage): FrameworkWarning[] => { - const w: FrameworkWarning[] = []; - fn(u, w); - return w; -}; - -describe.each(RULES)('$name rules (property-based)', ({ fn, framework }) => { +describe.each(FRAMEWORKS)('$name rules (property-based)', (framework) => { test('never throws on arbitrary usages', () => { fc.assert( fc.property(usageArb, (u) => { - run(fn, u); + run(framework, u); }), { numRuns: 4000 }, ); @@ -135,7 +117,7 @@ describe.each(RULES)('$name rules (property-based)', ({ fn, framework }) => { test('emits at most one warning per usage', () => { fc.assert( fc.property(usageArb, (u) => { - expect(run(fn, u).length).toBeLessThanOrEqual(1); + expect(run(framework, u).length).toBeLessThanOrEqual(1); }), { numRuns: 4000 }, ); @@ -144,11 +126,11 @@ describe.each(RULES)('$name rules (property-based)', ({ fn, framework }) => { test('every emitted warning is well-formed', () => { fc.assert( fc.property(usageArb, (u) => { - for (const w of run(fn, u)) { + for (const w of run(framework, u)) { expect(w.variable).toBe(u.variable); expect(w.line).toBe(u.line); expect(w.file).toBe(normalizePath(u.file)); - expect(w.framework).toBe(framework); + expect(w.framework).toBe(framework.name); expect(typeof w.reason).toBe('string'); expect(w.reason.length).toBeGreaterThan(0); } @@ -161,7 +143,7 @@ describe.each(RULES)('$name rules (property-based)', ({ fn, framework }) => { fc.assert( fc.property(usageArb, (u) => { if (normalizePath(u.file).includes('/node_modules/')) { - expect(run(fn, u)).toEqual([]); + expect(run(framework, u)).toEqual([]); } }), { numRuns: 4000 }, @@ -171,7 +153,7 @@ describe.each(RULES)('$name rules (property-based)', ({ fn, framework }) => { test('is deterministic', () => { fc.assert( fc.property(usageArb, (u) => { - expect(run(fn, u)).toEqual(run(fn, u)); + expect(run(framework, u)).toEqual(run(framework, u)); }), { numRuns: 3000 }, ); @@ -181,7 +163,7 @@ describe.each(RULES)('$name rules (property-based)', ({ fn, framework }) => { fc.assert( fc.property(usageArb, (u) => { const snapshot = JSON.stringify(u); - run(fn, u); + run(framework, u); expect(JSON.stringify(u)).toBe(snapshot); }), { numRuns: 2000 }, @@ -189,7 +171,7 @@ describe.each(RULES)('$name rules (property-based)', ({ fn, framework }) => { }); }); -describe('applySvelteKitRules — $lib/server is server context (property-based)', () => { +describe('sveltekit — $lib/server is server context (property-based)', () => { test('process.env in a $lib/server file never triggers the client-usage warning', () => { const serverPath = fc .array(fc.stringMatching(/^[a-z]{1,8}$/), { minLength: 0, maxLength: 3 }) @@ -204,8 +186,7 @@ describe('applySvelteKitRules — $lib/server is server context (property-based) pattern: 'process.env', context: '', } as EnvUsage; - const w: FrameworkWarning[] = []; - applySvelteKitRules(u, w); + const w = run(sveltekit, u); expect( w.some( (x) => @@ -218,7 +199,7 @@ describe('applySvelteKitRules — $lib/server is server context (property-based) }); }); -describe('applyNextJsRules — fileContentMap handling (property-based)', () => { +describe('nextjs — fileContentMap handling (property-based)', () => { test('a "use client" file forces the NEXT_PUBLIC_ requirement', () => { const nonPublicVar = fc .tuple(fc.constantFrom('', 'FOO_', 'API_'), fc.constantFrom('KEY', 'URL')) @@ -239,8 +220,7 @@ describe('applyNextJsRules — fileContentMap handling (property-based)', () => context: '', } as EnvUsage; const map = new Map([[file, '"use client"\nconst x = 1']]); - const w: FrameworkWarning[] = []; - applyNextJsRules(u, w, map); + const w = run(nextjs, u, map); expect(w).toHaveLength(1); expect(w[0]!.reason).toBe( 'Server-only variable accessed from client code', @@ -254,8 +234,7 @@ describe('applyNextJsRules — fileContentMap handling (property-based)', () => test('never throws with arbitrary file-content maps', () => { fc.assert( fc.property(usageArb, fc.string({ unit: 'binary' }), (u, content) => { - const w: FrameworkWarning[] = []; - applyNextJsRules(u, w, new Map([[u.file, content]])); + run(nextjs, u, new Map([[u.file, content]])); }), { numRuns: 2000 }, ); diff --git a/packages/cli/test/unit/core/frameworks/frameworkDetector.test.ts b/packages/cli/test/unit/core/frameworks/frameworkDetector.test.ts index 08774fa9..788905d9 100644 --- a/packages/cli/test/unit/core/frameworks/frameworkDetector.test.ts +++ b/packages/cli/test/unit/core/frameworks/frameworkDetector.test.ts @@ -15,16 +15,16 @@ describe('detectFramework', () => { const result = detectFramework('/project'); - expect(result.framework).toBe('sveltekit'); - expect(result.version).toBe('2.0.0'); + expect(result?.framework.name).toBe('sveltekit'); + expect(result?.version).toBe('2.0.0'); }); - it('returns unknown if no package.json', () => { + it('returns undefined if no package.json', () => { vi.spyOn(fs, 'existsSync').mockReturnValue(false); const result = detectFramework('/project'); - expect(result.framework).toBe('unknown'); + expect(result).toBeUndefined(); }); it('detects nextjs', () => { @@ -37,8 +37,8 @@ describe('detectFramework', () => { const result = detectFramework('/project'); - expect(result.framework).toBe('nextjs'); - expect(result.version).toBe('12.0.0'); + expect(result?.framework.name).toBe('nextjs'); + expect(result?.version).toBe('12.0.0'); }); it('detects nuxt', () => { @@ -51,11 +51,11 @@ describe('detectFramework', () => { const result = detectFramework('/project'); - expect(result.framework).toBe('nuxt'); - expect(result.version).toBe('3.10.0'); + expect(result?.framework.name).toBe('nuxt'); + expect(result?.version).toBe('3.10.0'); }); - it('returns unknown for unrecognized frameworks', () => { + it('returns undefined for unrecognized frameworks', () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs, 'readFileSync').mockReturnValue( JSON.stringify({ @@ -65,15 +65,15 @@ describe('detectFramework', () => { const result = detectFramework('/project'); - expect(result.framework).toBe('unknown'); + expect(result).toBeUndefined(); }); - it('returns unknown when package.json contains invalid JSON', () => { + it('returns undefined when package.json contains invalid JSON', () => { vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs, 'readFileSync').mockReturnValue('{ invalid json }'); const result = detectFramework('/project'); - expect(result).toEqual({ framework: 'unknown' }); + expect(result).toBeUndefined(); }); }); diff --git a/packages/cli/test/unit/core/frameworks/frameworkValidator.test.ts b/packages/cli/test/unit/core/frameworks/frameworkValidator.test.ts index 23cef317..f7738975 100644 --- a/packages/cli/test/unit/core/frameworks/frameworkValidator.test.ts +++ b/packages/cli/test/unit/core/frameworks/frameworkValidator.test.ts @@ -1,23 +1,15 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import type { EnvUsage } from '../../../../src/config/types.js'; +import type { EnvUsage, Framework } from '../../../../src/config/types.js'; vi.mock('../../../../src/core/frameworks/frameworkDetector.js', () => ({ detectFramework: vi.fn(), })); -vi.mock('../../../../src/core/frameworks/index.js', () => ({ - applySvelteKitRules: vi.fn(), - applyNextJsRules: vi.fn(), - applyNuxtRules: vi.fn(), -})); - -import { frameworkValidator } from '../../../../src/core/frameworks/frameworkValidator.js'; -import { detectFramework } from '../../../../src/core/frameworks/frameworkDetector.js'; import { - applySvelteKitRules, - applyNextJsRules, - applyNuxtRules, -} from '../../../../src/core/frameworks/index.js'; + frameworkValidator, + validateUsages, +} from '../../../../src/core/frameworks/frameworkValidator.js'; +import { detectFramework } from '../../../../src/core/frameworks/frameworkDetector.js'; afterEach(() => { vi.clearAllMocks(); @@ -32,64 +24,76 @@ const mockUsage: EnvUsage = { context: '', }; +/** Creates a fake framework whose check always returns the given reason */ +function fakeFramework(reason?: string): Framework { + return { + name: 'nextjs', + packageName: 'next', + check: vi.fn(() => reason), + }; +} + describe('frameworkValidator', () => { - it('applies SvelteKit rules when framework is sveltekit', () => { - vi.mocked(detectFramework).mockReturnValue({ framework: 'sveltekit' }); + it('applies the rules of the detected framework', () => { + const framework = fakeFramework('Something is wrong'); + vi.mocked(detectFramework).mockReturnValue({ framework, version: '1.0.0' }); const warnings = frameworkValidator([mockUsage], '/project'); - expect(applySvelteKitRules).toHaveBeenCalledOnce(); - expect(applySvelteKitRules).toHaveBeenCalledWith( - mockUsage, - expect.any(Array), - ); - expect(applyNextJsRules).not.toHaveBeenCalled(); - expect(warnings).toEqual([]); + expect(framework.check).toHaveBeenCalledOnce(); + expect(warnings).toHaveLength(1); }); - it('applies Next.js rules when framework is nextjs', () => { - vi.mocked(detectFramework).mockReturnValue({ framework: 'nextjs' }); + it('returns empty warnings when no framework is detected', () => { + vi.mocked(detectFramework).mockReturnValue(undefined); - const fileContentMap = new Map([ - ['src/index.ts', 'use client'], - ]); + const warnings = frameworkValidator([mockUsage], '/project'); - const warnings = frameworkValidator( - [mockUsage], - '/project', - fileContentMap, - ); - - expect(applyNextJsRules).toHaveBeenCalledOnce(); - expect(applyNextJsRules).toHaveBeenCalledWith( - mockUsage, - expect.any(Array), - fileContentMap, - ); - expect(applySvelteKitRules).not.toHaveBeenCalled(); expect(warnings).toEqual([]); }); +}); - it('applies Nuxt rules when framework is nuxt', () => { - vi.mocked(detectFramework).mockReturnValue({ framework: 'nuxt' }); +describe('validateUsages', () => { + it('builds a warning from the reason returned by the framework', () => { + const warnings = validateUsages([mockUsage], fakeFramework('Bad usage')); + + expect(warnings).toEqual([ + { + variable: 'API_KEY', + reason: 'Bad usage', + file: 'src/index.ts', + line: 1, + framework: 'nextjs', + }, + ]); + }); - const warnings = frameworkValidator([mockUsage], '/project'); + it('returns no warnings when the framework returns no reason', () => { + const warnings = validateUsages([mockUsage], fakeFramework(undefined)); - expect(applyNuxtRules).toHaveBeenCalledOnce(); - expect(applyNuxtRules).toHaveBeenCalledWith(mockUsage, expect.any(Array)); - expect(applySvelteKitRules).not.toHaveBeenCalled(); - expect(applyNextJsRules).not.toHaveBeenCalled(); expect(warnings).toEqual([]); }); - it('returns empty warnings when framework is unknown', () => { - vi.mocked(detectFramework).mockReturnValue({ framework: 'unknown' }); + it('passes the normalized file path and file content to the framework', () => { + const framework = fakeFramework(undefined); + const usage = { ...mockUsage, file: 'src\\app\\page.tsx' }; + const fileContentMap = new Map([[usage.file, '"use client"']]); - const warnings = frameworkValidator([mockUsage], '/project'); + validateUsages([usage], framework, fileContentMap); + + expect(framework.check).toHaveBeenCalledWith(usage, { + file: 'src/app/page.tsx', + content: '"use client"', + }); + }); + + it('skips files inside node_modules', () => { + const framework = fakeFramework('Bad usage'); + const usage = { ...mockUsage, file: 'src/node_modules/pkg/index.js' }; + + const warnings = validateUsages([usage], framework); - expect(applySvelteKitRules).not.toHaveBeenCalled(); - expect(applyNextJsRules).not.toHaveBeenCalled(); - expect(applyNuxtRules).not.toHaveBeenCalled(); + expect(framework.check).not.toHaveBeenCalled(); expect(warnings).toEqual([]); }); }); diff --git a/packages/cli/test/unit/core/frameworks/nextJsRules.test.ts b/packages/cli/test/unit/core/frameworks/nextjs.test.ts similarity index 69% rename from packages/cli/test/unit/core/frameworks/nextJsRules.test.ts rename to packages/cli/test/unit/core/frameworks/nextjs.test.ts index d31dcc1c..b442388f 100644 --- a/packages/cli/test/unit/core/frameworks/nextJsRules.test.ts +++ b/packages/cli/test/unit/core/frameworks/nextjs.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { applyNextJsRules } from '../../../../src/core/frameworks/nextJsRules.js'; +import { validateUsages } from '../../../../src/core/frameworks/frameworkValidator.js'; +import { nextjs } from '../../../../src/core/frameworks/nextjs.js'; import type { EnvUsage, FrameworkWarning, @@ -9,7 +10,11 @@ vi.mock('../../../../src/core/helpers/normalizePath.js', () => ({ normalizePath: vi.fn((p: string) => p.replace(/\\/g, '/')), })); -describe('applyNextJsRules', () => { +/** Runs the Next.js rules on a single usage */ +const validate = (usage: EnvUsage, fileContentMap?: Map) => + validateUsages([usage], nextjs, fileContentMap); + +describe('Next.js rules', () => { let warnings: FrameworkWarning[]; const baseUsage: EnvUsage = { @@ -29,13 +34,10 @@ describe('applyNextJsRules', () => { }); it('ignores files inside node_modules', () => { - applyNextJsRules( - { - ...baseUsage, - file: 'src/node_modules/pkg/index.js', - }, - warnings, - ); + warnings = validate({ + ...baseUsage, + file: 'src/node_modules/pkg/index.js', + }); expect(warnings).toHaveLength(0); }); @@ -44,7 +46,7 @@ describe('applyNextJsRules', () => { const map = new Map(); map.set(baseUsage.file, '"use client"\nconsole.log(\'test\')'); - applyNextJsRules(baseUsage, warnings, map); + warnings = validate(baseUsage, map); expect(warnings).toHaveLength(1); expect(warnings[0]!.framework).toBe('nextjs'); @@ -52,13 +54,13 @@ describe('applyNextJsRules', () => { }); it('detects pages router client file', () => { - applyNextJsRules({ ...baseUsage, file: 'pages/index.tsx' }, warnings); + warnings = validate({ ...baseUsage, file: 'pages/index.tsx' }); expect(warnings).toHaveLength(1); }); it('does not treat pages/api as client', () => { - applyNextJsRules({ ...baseUsage, file: 'pages/api/route.ts' }, warnings); + warnings = validate({ ...baseUsage, file: 'pages/api/route.ts' }); expect(warnings).toHaveLength(0); }); @@ -67,7 +69,7 @@ describe('applyNextJsRules', () => { const map = new Map(); map.set(baseUsage.file, 'use client;\nconst x = 1;'); - applyNextJsRules(baseUsage, warnings, map); + warnings = validate(baseUsage, map); expect(warnings).toHaveLength(1); }); @@ -75,26 +77,23 @@ describe('applyNextJsRules', () => { it('does nothing when fileContentMap exists but fileContent is undefined', () => { const map = new Map(); // empty folder - applyNextJsRules(baseUsage, warnings, map); + warnings = validate(baseUsage, map); expect(warnings).toHaveLength(0); }); it('warns about Vite syntax', () => { - applyNextJsRules({ ...baseUsage, pattern: 'import.meta.env' }, warnings); + warnings = validate({ ...baseUsage, pattern: 'import.meta.env' }); expect(warnings).toHaveLength(1); expect(warnings[0]!.reason).toMatch(/process\.env/); }); it('warns about sensitive NEXT_PUBLIC variable', () => { - applyNextJsRules( - { - ...baseUsage, - variable: 'NEXT_PUBLIC_SECRET_KEY', - }, - warnings, - ); + warnings = validate({ + ...baseUsage, + variable: 'NEXT_PUBLIC_SECRET_KEY', + }); expect(warnings).toHaveLength(1); expect(warnings[0]!.reason).toMatch(/sensitive/); @@ -102,14 +101,13 @@ describe('applyNextJsRules', () => { it('does not warn for valid NEXT_PUBLIC usage in client component', () => { const map = new Map(); - map.set(baseUsage.file, '\'use client\''); + map.set(baseUsage.file, "'use client'"); - applyNextJsRules( + warnings = validate( { ...baseUsage, variable: 'NEXT_PUBLIC_API_URL', }, - warnings, map, ); @@ -117,29 +115,25 @@ describe('applyNextJsRules', () => { }); it('does nothing when no rule matches', () => { - applyNextJsRules( - { - ...baseUsage, - variable: 'SERVER_SECRET', - file: 'server-only.ts', - }, - warnings, - ); + warnings = validate({ + ...baseUsage, + variable: 'SERVER_SECRET', + file: 'server-only.ts', + }); expect(warnings).toHaveLength(0); }); it('stops processing after first matching rule (early return)', () => { const map = new Map(); - map.set(baseUsage.file, '\'use client\''); + map.set(baseUsage.file, "'use client'"); - applyNextJsRules( + warnings = validate( { ...baseUsage, variable: 'MY_SECRET', pattern: 'import.meta.env', }, - warnings, map, ); diff --git a/packages/cli/test/unit/core/frameworks/nuxtRules.test.ts b/packages/cli/test/unit/core/frameworks/nuxt.test.ts similarity index 59% rename from packages/cli/test/unit/core/frameworks/nuxtRules.test.ts rename to packages/cli/test/unit/core/frameworks/nuxt.test.ts index a12edf23..57158d3b 100644 --- a/packages/cli/test/unit/core/frameworks/nuxtRules.test.ts +++ b/packages/cli/test/unit/core/frameworks/nuxt.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { applyNuxtRules } from '../../../../src/core/frameworks/nuxtRules.js'; +import { validateUsages } from '../../../../src/core/frameworks/frameworkValidator.js'; +import { nuxt } from '../../../../src/core/frameworks/nuxt.js'; import type { EnvUsage, FrameworkWarning, @@ -9,7 +10,11 @@ vi.mock('../../../../src/core/helpers/normalizePath.js', () => ({ normalizePath: vi.fn((p: string) => p.replace(/\\/g, '/')), })); -describe('applyNuxtRules', () => { +/** Runs the Nuxt rules on a single usage */ +const validate = (usage: EnvUsage, fileContentMap?: Map) => + validateUsages([usage], nuxt, fileContentMap); + +describe('Nuxt rules', () => { let warnings: FrameworkWarning[]; const baseUsage: EnvUsage = { @@ -29,16 +34,16 @@ describe('applyNuxtRules', () => { }); it('ignores files inside node_modules', () => { - applyNuxtRules( - { ...baseUsage, file: 'src/node_modules/pkg/index.js' }, - warnings, - ); + warnings = validate({ + ...baseUsage, + file: 'src/node_modules/pkg/index.js', + }); expect(warnings).toHaveLength(0); }); it('warns about process.env in client/universal code', () => { - applyNuxtRules(baseUsage, warnings); + warnings = validate(baseUsage); expect(warnings).toHaveLength(1); expect(warnings[0]!.framework).toBe('nuxt'); @@ -46,64 +51,60 @@ describe('applyNuxtRules', () => { }); it('allows process.env inside the server directory', () => { - applyNuxtRules({ ...baseUsage, file: 'server/api/data.ts' }, warnings); + warnings = validate({ ...baseUsage, file: 'server/api/data.ts' }); expect(warnings).toHaveLength(0); }); it('allows process.env in .server. suffixed files', () => { - applyNuxtRules({ ...baseUsage, file: 'plugins/auth.server.ts' }, warnings); + warnings = validate({ ...baseUsage, file: 'plugins/auth.server.ts' }); expect(warnings).toHaveLength(0); }); it('allows process.env inside nuxt.config', () => { - applyNuxtRules({ ...baseUsage, file: 'nuxt.config.ts' }, warnings); + warnings = validate({ ...baseUsage, file: 'nuxt.config.ts' }); expect(warnings).toHaveLength(0); }); it('warns about sensitive NUXT_PUBLIC_ variable', () => { - applyNuxtRules( - { - ...baseUsage, - variable: 'NUXT_PUBLIC_API_SECRET', - file: 'server/api/data.ts', - }, - warnings, - ); + warnings = validate({ + ...baseUsage, + variable: 'NUXT_PUBLIC_API_SECRET', + file: 'server/api/data.ts', + }); expect(warnings).toHaveLength(1); expect(warnings[0]!.reason).toMatch(/sensitive/); }); it('does not warn for a safe NUXT_PUBLIC_ variable', () => { - applyNuxtRules( - { - ...baseUsage, - variable: 'NUXT_PUBLIC_API_BASE', - file: 'server/api/data.ts', - }, - warnings, - ); + warnings = validate({ + ...baseUsage, + variable: 'NUXT_PUBLIC_API_BASE', + file: 'server/api/data.ts', + }); expect(warnings).toHaveLength(0); }); it('does nothing when no rule matches', () => { - applyNuxtRules( - { ...baseUsage, variable: 'API_SECRET', file: 'server/api/data.ts' }, - warnings, - ); + warnings = validate({ + ...baseUsage, + variable: 'API_SECRET', + file: 'server/api/data.ts', + }); expect(warnings).toHaveLength(0); }); it('does not warn for import.meta.env usage (supported by Nuxt/Vite)', () => { - applyNuxtRules( - { ...baseUsage, pattern: 'import.meta.env', variable: 'API_BASE' }, - warnings, - ); + warnings = validate({ + ...baseUsage, + pattern: 'import.meta.env', + variable: 'API_BASE', + }); expect(warnings).toHaveLength(0); }); diff --git a/packages/cli/test/unit/core/frameworks/sveltekit.test.ts b/packages/cli/test/unit/core/frameworks/sveltekit.test.ts new file mode 100644 index 00000000..2cce20aa --- /dev/null +++ b/packages/cli/test/unit/core/frameworks/sveltekit.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { validateUsages } from '../../../../src/core/frameworks/frameworkValidator.js'; +import { sveltekit } from '../../../../src/core/frameworks/sveltekit.js'; +import type { + EnvUsage, + FrameworkWarning, +} from '../../../../src/config/types.js'; + +vi.mock('../../../../src/core/helpers/normalizePath.js', () => ({ + normalizePath: vi.fn((p: string) => p.replace(/\\/g, '/')), +})); + +/** Runs the SvelteKit rules on a single usage */ +const validate = (usage: EnvUsage, fileContentMap?: Map) => + validateUsages([usage], sveltekit, fileContentMap); + +describe('SvelteKit rules', () => { + let warnings: FrameworkWarning[]; + + const baseUsage: EnvUsage = { + variable: 'MY_VAR', + file: 'src/routes/+page.ts', + line: 5, + column: 1, + pattern: 'process.env', + context: '', + imports: [], + }; + + beforeEach(() => { + warnings = []; + vi.clearAllMocks(); + }); + + // $app/env/private (SvelteKit 3) + it('warns when $app/env/private is used in client code', () => { + warnings = validate({ + ...baseUsage, + pattern: 'sveltekit', + variable: 'API_KEY', + file: 'src/routes/+page.svelte', + imports: ['$app/env/private'], + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]!.reason).toContain('$app/env/private'); + }); + + it('allows $app/env/private in server files and $app/env/public anywhere', () => { + warnings = validate({ + ...baseUsage, + pattern: 'sveltekit', + variable: 'API_KEY', + file: 'src/routes/+page.server.ts', + imports: ['$app/env/private'], + }); + warnings.push( + ...validate({ + ...baseUsage, + pattern: 'sveltekit', + variable: 'SENTRY_DSN', + file: 'src/routes/+page.svelte', + imports: ['$app/env/public'], + }), + ); + expect(warnings).toHaveLength(0); + }); + + it('ignores node_modules', () => { + warnings = validate({ + ...baseUsage, + file: 'src/node_modules/pkg/index.js', + }); + expect(warnings).toHaveLength(0); + }); + + // import.meta.env + it('warns when import.meta.env variable does not start with VITE_', () => { + warnings = validate({ + ...baseUsage, + pattern: 'import.meta.env', + variable: 'API_KEY', + }); + expect(warnings).toHaveLength(1); + }); + + it('allows import.meta.env with VITE_', () => { + warnings = validate({ + ...baseUsage, + pattern: 'import.meta.env', + variable: 'VITE_API', + }); + expect(warnings).toHaveLength(0); + }); + + // process.env + it('warns when process.env used in client file', () => { + warnings = validate(baseUsage); + expect(warnings).toHaveLength(1); + }); + + it('allows process.env in server file', () => { + warnings = validate({ ...baseUsage, file: 'src/routes/+server.ts' }); + expect(warnings).toHaveLength(0); + }); + + it('allows process.env in $lib/server directory', () => { + // Regression: src/lib/server is SvelteKit's enforced server-only directory, + // so process.env there must not be flagged as client-side usage. + warnings = validate({ ...baseUsage, file: 'src/lib/server/db.ts' }); + expect(warnings).toHaveLength(0); + }); + + it('allows process.env in a nested $lib/server file', () => { + warnings = validate({ + ...baseUsage, + file: 'src/lib/server/auth/tokens.ts', + }); + expect(warnings).toHaveLength(0); + }); + + it('still warns for process.env in a non-server $lib file', () => { + warnings = validate({ ...baseUsage, file: 'src/lib/utils.ts' }); + expect(warnings).toHaveLength(1); + expect(warnings[0]!.reason).toMatch(/process\.env should only be used/); + }); + + it('allows process.env in svelte.config.js', () => { + warnings = validate({ ...baseUsage, file: 'svelte.config.js' }); + expect(warnings).toHaveLength(0); + }); + + it('allows process.env in svelte.config.ts', () => { + warnings = validate({ ...baseUsage, file: 'svelte.config.ts' }); + expect(warnings).toHaveLength(0); + }); + + // dynamic private + it('warns dynamic/private in svelte file', () => { + warnings = validate({ + ...baseUsage, + file: 'src/routes/page.svelte', + pattern: 'sveltekit', + imports: ['$env/dynamic/private'], + }); + expect(warnings).toHaveLength(1); + }); + + it('warns dynamic/private with PUBLIC_ prefix', () => { + warnings = validate({ + ...baseUsage, + pattern: 'sveltekit', + variable: 'PUBLIC_SECRET', + imports: ['$env/dynamic/private'], + }); + expect(warnings).toHaveLength(1); + }); + + // dynamic public + it('warns dynamic/public without PUBLIC_', () => { + warnings = validate({ + ...baseUsage, + pattern: 'sveltekit', + imports: ['$env/dynamic/public'], + variable: 'SECRET', + }); + expect(warnings).toHaveLength(1); + }); + + it('allows dynamic/public with PUBLIC_', () => { + warnings = validate({ + ...baseUsage, + pattern: 'sveltekit', + imports: ['$env/dynamic/public'], + variable: 'PUBLIC_API', + }); + expect(warnings).toHaveLength(0); + }); + + // static private + it('warns static/private with PUBLIC_', () => { + warnings = validate({ + ...baseUsage, + pattern: 'sveltekit', + imports: ['$env/static/private'], + variable: 'PUBLIC_KEY', + }); + expect(warnings).toHaveLength(1); + }); + + it('warns static/private in client file', () => { + warnings = validate({ + ...baseUsage, + file: 'src/routes/+page.ts', + pattern: 'sveltekit', + imports: ['$env/static/private'], + }); + expect(warnings).toHaveLength(1); + }); + + it('allows static/private in server file', () => { + warnings = validate({ + ...baseUsage, + file: 'src/routes/+server.ts', + pattern: 'sveltekit', + imports: ['$env/static/private'], + }); + expect(warnings).toHaveLength(0); + }); + + // static public + it('warns static/public without PUBLIC_', () => { + warnings = validate({ + ...baseUsage, + pattern: 'sveltekit', + imports: ['$env/static/public'], + variable: 'SECRET', + }); + expect(warnings).toHaveLength(1); + }); + + it('allows static/public with PUBLIC_', () => { + warnings = validate({ + ...baseUsage, + pattern: 'sveltekit', + imports: ['$env/static/public'], + variable: 'PUBLIC_API', + }); + expect(warnings).toHaveLength(0); + }); + + // sensitive keywords + it('warns for sensitive PUBLIC_ variable', () => { + warnings = validate({ ...baseUsage, variable: 'PUBLIC_SECRET' }); + expect(warnings).toHaveLength(1); + }); + + it('warns for sensitive VITE_ variable', () => { + warnings = validate({ ...baseUsage, variable: 'VITE_PASSWORD' }); + expect(warnings).toHaveLength(1); + }); + + it('does nothing when no rule matches', () => { + warnings = validate({ + ...baseUsage, + file: 'src/server.ts', + pattern: 'sveltekit', + imports: [], + variable: 'SAFE_VAR', + }); + expect(warnings).toHaveLength(0); + }); + + it('warns dynamic/private variable starting with PUBLIC_ in server file', () => { + warnings = validate({ + ...baseUsage, + file: 'src/routes/+server.ts', // server file + pattern: 'sveltekit', + imports: ['$env/dynamic/private'], + variable: 'PUBLIC_SECRET_KEY', + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]!.reason).toMatch(/must not start with "PUBLIC_"/); + }); + + it('warns for sensitive PUBLIC_ variable exposed to browser', () => { + warnings = validate({ + ...baseUsage, + file: 'src/routes/+server.ts', // server file + pattern: 'process.env', + variable: 'PUBLIC_SECRET_TOKEN', + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]!.reason).toMatch(/Potential sensitive/); + }); +}); diff --git a/packages/cli/test/unit/core/frameworks/sveltekitRules.test.ts b/packages/cli/test/unit/core/frameworks/sveltekitRules.test.ts deleted file mode 100644 index 5eb08a68..00000000 --- a/packages/cli/test/unit/core/frameworks/sveltekitRules.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { applySvelteKitRules } from '../../../../src/core/frameworks/sveltekitRules.js'; -import type { - EnvUsage, - FrameworkWarning, -} from '../../../../src/config/types.js'; - -vi.mock('../../../../src/core/helpers/normalizePath.js', () => ({ - normalizePath: vi.fn((p: string) => p.replace(/\\/g, '/')), -})); - -describe('applySvelteKitRules', () => { - let warnings: FrameworkWarning[]; - - const baseUsage: EnvUsage = { - variable: 'MY_VAR', - file: 'src/routes/+page.ts', - line: 5, - column: 1, - pattern: 'process.env', - context: '', - imports: [], - }; - - beforeEach(() => { - warnings = []; - vi.clearAllMocks(); - }); - - // $app/env/private (SvelteKit 3) - it('warns when $app/env/private is used in client code', () => { - applySvelteKitRules( - { - ...baseUsage, - pattern: 'sveltekit', - variable: 'API_KEY', - file: 'src/routes/+page.svelte', - imports: ['$app/env/private'], - }, - warnings, - ); - expect(warnings).toHaveLength(1); - expect(warnings[0]!.reason).toContain('$app/env/private'); - }); - - it('allows $app/env/private in server files and $app/env/public anywhere', () => { - applySvelteKitRules( - { - ...baseUsage, - pattern: 'sveltekit', - variable: 'API_KEY', - file: 'src/routes/+page.server.ts', - imports: ['$app/env/private'], - }, - warnings, - ); - applySvelteKitRules( - { - ...baseUsage, - pattern: 'sveltekit', - variable: 'SENTRY_DSN', - file: 'src/routes/+page.svelte', - imports: ['$app/env/public'], - }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - it('ignores node_modules', () => { - applySvelteKitRules( - { ...baseUsage, file: 'src/node_modules/pkg/index.js' }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - // import.meta.env - it('warns when import.meta.env variable does not start with VITE_', () => { - applySvelteKitRules( - { ...baseUsage, pattern: 'import.meta.env', variable: 'API_KEY' }, - warnings, - ); - expect(warnings).toHaveLength(1); - }); - - it('allows import.meta.env with VITE_', () => { - applySvelteKitRules( - { ...baseUsage, pattern: 'import.meta.env', variable: 'VITE_API' }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - // process.env - it('warns when process.env used in client file', () => { - applySvelteKitRules(baseUsage, warnings); - expect(warnings).toHaveLength(1); - }); - - it('allows process.env in server file', () => { - applySvelteKitRules( - { ...baseUsage, file: 'src/routes/+server.ts' }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - it('allows process.env in $lib/server directory', () => { - // Regression: src/lib/server is SvelteKit's enforced server-only directory, - // so process.env there must not be flagged as client-side usage. - applySvelteKitRules( - { ...baseUsage, file: 'src/lib/server/db.ts' }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - it('allows process.env in a nested $lib/server file', () => { - applySvelteKitRules( - { ...baseUsage, file: 'src/lib/server/auth/tokens.ts' }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - it('still warns for process.env in a non-server $lib file', () => { - applySvelteKitRules({ ...baseUsage, file: 'src/lib/utils.ts' }, warnings); - expect(warnings).toHaveLength(1); - expect(warnings[0]!.reason).toMatch(/process\.env should only be used/); - }); - - it('allows process.env in svelte.config.js', () => { - applySvelteKitRules({ ...baseUsage, file: 'svelte.config.js' }, warnings); - expect(warnings).toHaveLength(0); - }); - - it('allows process.env in svelte.config.ts', () => { - applySvelteKitRules({ ...baseUsage, file: 'svelte.config.ts' }, warnings); - expect(warnings).toHaveLength(0); - }); - - // dynamic private - it('warns dynamic/private in svelte file', () => { - applySvelteKitRules( - { - ...baseUsage, - file: 'src/routes/page.svelte', - pattern: 'sveltekit', - imports: ['$env/dynamic/private'], - }, - warnings, - ); - expect(warnings).toHaveLength(1); - }); - - it('warns dynamic/private with PUBLIC_ prefix', () => { - applySvelteKitRules( - { - ...baseUsage, - pattern: 'sveltekit', - variable: 'PUBLIC_SECRET', - imports: ['$env/dynamic/private'], - }, - warnings, - ); - expect(warnings).toHaveLength(1); - }); - - // dynamic public - it('warns dynamic/public without PUBLIC_', () => { - applySvelteKitRules( - { - ...baseUsage, - pattern: 'sveltekit', - imports: ['$env/dynamic/public'], - variable: 'SECRET', - }, - warnings, - ); - expect(warnings).toHaveLength(1); - }); - - it('allows dynamic/public with PUBLIC_', () => { - applySvelteKitRules( - { - ...baseUsage, - pattern: 'sveltekit', - imports: ['$env/dynamic/public'], - variable: 'PUBLIC_API', - }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - // static private - it('warns static/private with PUBLIC_', () => { - applySvelteKitRules( - { - ...baseUsage, - pattern: 'sveltekit', - imports: ['$env/static/private'], - variable: 'PUBLIC_KEY', - }, - warnings, - ); - expect(warnings).toHaveLength(1); - }); - - it('warns static/private in client file', () => { - applySvelteKitRules( - { - ...baseUsage, - file: 'src/routes/+page.ts', - pattern: 'sveltekit', - imports: ['$env/static/private'], - }, - warnings, - ); - expect(warnings).toHaveLength(1); - }); - - it('allows static/private in server file', () => { - applySvelteKitRules( - { - ...baseUsage, - file: 'src/routes/+server.ts', - pattern: 'sveltekit', - imports: ['$env/static/private'], - }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - // static public - it('warns static/public without PUBLIC_', () => { - applySvelteKitRules( - { - ...baseUsage, - pattern: 'sveltekit', - imports: ['$env/static/public'], - variable: 'SECRET', - }, - warnings, - ); - expect(warnings).toHaveLength(1); - }); - - it('allows static/public with PUBLIC_', () => { - applySvelteKitRules( - { - ...baseUsage, - pattern: 'sveltekit', - imports: ['$env/static/public'], - variable: 'PUBLIC_API', - }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - // sensitive keywords - it('warns for sensitive PUBLIC_ variable', () => { - applySvelteKitRules({ ...baseUsage, variable: 'PUBLIC_SECRET' }, warnings); - expect(warnings).toHaveLength(1); - }); - - it('warns for sensitive VITE_ variable', () => { - applySvelteKitRules({ ...baseUsage, variable: 'VITE_PASSWORD' }, warnings); - expect(warnings).toHaveLength(1); - }); - - it('does nothing when no rule matches', () => { - applySvelteKitRules( - { - ...baseUsage, - file: 'src/server.ts', - pattern: 'sveltekit', - imports: [], - variable: 'SAFE_VAR', - }, - warnings, - ); - expect(warnings).toHaveLength(0); - }); - - it('warns dynamic/private variable starting with PUBLIC_ in server file', () => { - applySvelteKitRules( - { - ...baseUsage, - file: 'src/routes/+server.ts', // server file - pattern: 'sveltekit', - imports: ['$env/dynamic/private'], - variable: 'PUBLIC_SECRET_KEY', - }, - warnings, - ); - - expect(warnings).toHaveLength(1); - expect(warnings[0]!.reason).toMatch(/must not start with "PUBLIC_"/); - }); - - it('warns for sensitive PUBLIC_ variable exposed to browser', () => { - applySvelteKitRules( - { - ...baseUsage, - file: 'src/routes/+server.ts', // server file - pattern: 'process.env', - variable: 'PUBLIC_SECRET_TOKEN', - }, - warnings, - ); - - expect(warnings).toHaveLength(1); - expect(warnings[0]!.reason).toMatch(/Potential sensitive/); - }); -});