Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/cli/src/core/scan/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ type Pattern = {
sourceModule?: string;
};

/**
* Matches SvelteKit $env imports to collect import sources per file.
* Example: import { env } from '$env/dynamic/private'
* Example: import { PUBLIC_URL } from '$env/static/public'
*/
export const SVELTEKIT_IMPORT_REGEX =
/import\s+(?:\{[^}]*\}|\w+)\s+from\s+['"](?<module>\$env\/(?:static|dynamic)\/(?:private|public))['"]/g;

/**
* Matches aliased SvelteKit env imports.
* Example: import { env as privateEnv } from '$env/dynamic/private'
*/
export const SVELTEKIT_ALIAS_IMPORT_REGEX =
/import\s*\{\s*env\s+as\s+(?<alias>\w+)\s*\}\s*from\s+['"](?<source>\$env\/(?:static|dynamic)\/(?:private|public))['"]/g;

/**
* Builds SvelteKit env patterns for an aliased import.
* Handles: import { env as aliasName } from '$env/dynamic/private'
Expand Down
27 changes: 14 additions & 13 deletions packages/cli/src/core/scan/scanFile.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import path from 'path';
import type { EnvUsage, ScanOptions } from '../../config/types.js';
import { ENV_PATTERNS, buildSveltekitAliasPatterns } from './patterns.js';
import {
ENV_PATTERNS,
buildSveltekitAliasPatterns,
SVELTEKIT_IMPORT_REGEX,
SVELTEKIT_ALIAS_IMPORT_REGEX,
} from './patterns.js';
import { hasIgnoreComment } from '../security/secretDetectors.js';
import { normalizePath } from '../helpers/normalizePath.js';
import { isLikelyMinified } from '../helpers/isLikelyMinified.js';
Expand Down Expand Up @@ -36,24 +41,20 @@ export function scanFile(
// Collect all $env imports used in this file
const envImports: string[] = [];

const importRegex =
/import\s+(?:\{[^}]*\}|\w+)\s+from\s+['"](\$env\/(?:static|dynamic)\/(?:private|public))['"]/g;

SVELTEKIT_IMPORT_REGEX.lastIndex = 0;
let importMatch: RegExpExecArray | null;

while ((importMatch = importRegex.exec(content)) !== null) {
while ((importMatch = SVELTEKIT_IMPORT_REGEX.exec(content)) !== null) {
envImports.push(importMatch[1]!);
}

// Detect aliased $env imports: import { env as aliasName } from '$env/dynamic/private'
// Capture both the alias name (group 1) and the source module (group 2)
const aliasImportRegex =
/import\s*\{\s*env\s+as\s+(\w+)\s*\}\s*from\s*['"](\$env\/(?:static|dynamic)\/(?:private|public))['"]/g;

// Detect aliased $env imports and build dynamic patterns for them
const allPatterns = [...ENV_PATTERNS];
let aliasImportMatch: RegExpExecArray | null;

while ((aliasImportMatch = aliasImportRegex.exec(content)) !== null) {
SVELTEKIT_ALIAS_IMPORT_REGEX.lastIndex = 0;
let aliasImportMatch: RegExpExecArray | null;
while (
(aliasImportMatch = SVELTEKIT_ALIAS_IMPORT_REGEX.exec(content)) !== null
) {
allPatterns.push(
...buildSveltekitAliasPatterns(
aliasImportMatch[1]!,
Expand Down
101 changes: 101 additions & 0 deletions packages/cli/test/unit/core/scan/patterns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
DEFAULT_EXCLUDE_PATTERNS,
ENV_PATTERNS,
buildSveltekitAliasPatterns,
SVELTEKIT_IMPORT_REGEX,
SVELTEKIT_ALIAS_IMPORT_REGEX,
} from '../../../../src/core/scan/patterns';
import type { ScanOptions } from '../../../../src/config/types';

Expand Down Expand Up @@ -569,6 +571,105 @@ const { SECRET_KEY: secret } = privateEnv;`;
});
});

describe('SVELTEKIT_IMPORT_REGEX', () => {
function exec(content: string) {
const re = new RegExp(
SVELTEKIT_IMPORT_REGEX.source,
SVELTEKIT_IMPORT_REGEX.flags,
);
return re.exec(content);
}

it('matches import from $env/static/private', () => {
const match = exec("import { env } from '$env/static/private';");
expect(match).not.toBeNull();
expect(match![1]).toBe('$env/static/private');
});

it('matches import from $env/dynamic/public', () => {
const match = exec("import { PUBLIC_URL } from '$env/dynamic/public';");
expect(match).not.toBeNull();
expect(match![1]).toBe('$env/dynamic/public');
});

it('captures the module path in named group', () => {
const match = exec("import { env } from '$env/dynamic/private';");
expect(match?.groups?.['module']).toBe('$env/dynamic/private');
});

it('does not match regular (non-$env) imports', () => {
const match = exec("import { something } from 'some-package';");
expect(match).toBeNull();
});

it('collects multiple $env imports', () => {
const content = `import { KEY1 } from '$env/static/private';
import { KEY2 } from '$env/dynamic/public';`;
const re = new RegExp(
SVELTEKIT_IMPORT_REGEX.source,
SVELTEKIT_IMPORT_REGEX.flags,
);
const modules: string[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) modules.push(m[1]!);
expect(modules).toEqual(['$env/static/private', '$env/dynamic/public']);
});
});

describe('SVELTEKIT_ALIAS_IMPORT_REGEX', () => {
function exec(content: string) {
const re = new RegExp(
SVELTEKIT_ALIAS_IMPORT_REGEX.source,
SVELTEKIT_ALIAS_IMPORT_REGEX.flags,
);
return re.exec(content);
}

it('matches aliased import and captures alias and source', () => {
const match = exec(
"import { env as privateEnv } from '$env/dynamic/private';",
);
expect(match).not.toBeNull();
expect(match![1]).toBe('privateEnv');
expect(match![2]).toBe('$env/dynamic/private');
});

it('captures alias and source via named groups', () => {
const match = exec(
"import { env as publicEnv } from '$env/dynamic/public';",
);
expect(match?.groups?.['alias']).toBe('publicEnv');
expect(match?.groups?.['source']).toBe('$env/dynamic/public');
});

it('does not match non-aliased env import', () => {
const match = exec("import { env } from '$env/dynamic/private';");
expect(match).toBeNull();
});

it('does not match regular imports', () => {
const match = exec("import { something as alias } from 'some-package';");
expect(match).toBeNull();
});

it('collects multiple aliased imports', () => {
const content = `import { env as publicEnv } from '$env/dynamic/public';
import { env as privateEnv } from '$env/dynamic/private';`;
const re = new RegExp(
SVELTEKIT_ALIAS_IMPORT_REGEX.source,
SVELTEKIT_ALIAS_IMPORT_REGEX.flags,
);
const found: Array<{ alias: string; source: string }> = [];
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null)
found.push({ alias: m[1]!, source: m[2]! });
expect(found).toEqual([
{ alias: 'publicEnv', source: '$env/dynamic/public' },
{ alias: 'privateEnv', source: '$env/dynamic/private' },
]);
});
});

describe('SvelteKit env Object Access', () => {
it('detects env.VARIABLE_NAME access', () => {
const code = 'const token = env.KEYCLOAK_SECRET;';
Expand Down
30 changes: 18 additions & 12 deletions packages/cli/test/unit/core/scan/scanFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,18 +341,24 @@ describe('scanFile – line 48 false variable guard', () => {
// to exercise the true branch of the guard.
it('skips falsy (empty-string) variable emitted by a processor (line 48 true branch)', async () => {
vi.resetModules();
vi.doMock('../../../../src/core/scan/patterns.js', () => ({
ENV_PATTERNS: [
{
name: 'process.env',
// Regex matches process.env.KEY and captures group 1
regex: /process\.env\.([A-Z_][A-Z0-9_]*)/g,
// Returns ['', 'API_KEY']: empty string is falsy → line 48 skips it,
// 'API_KEY' is truthy → pushed to usages.
processor: (match: RegExpExecArray) => ['' as string, match[1]!],
},
],
}));
vi.doMock('../../../../src/core/scan/patterns.js', async () => {
const actual = await vi.importActual<
typeof import('../../../../src/core/scan/patterns.js')
>('../../../../src/core/scan/patterns.js');
return {
...actual,
ENV_PATTERNS: [
{
name: 'process.env',
// Regex matches process.env.KEY and captures group 1
regex: /process\.env\.([A-Z_][A-Z0-9_]*)/g,
// Returns ['', 'API_KEY']: empty string is falsy → line 48 skips it,
// 'API_KEY' is truthy → pushed to usages.
processor: (match: RegExpExecArray) => ['' as string, match[1]!],
},
],
};
});

const { scanFile: scanFileFresh } =
await import('../../../../src/core/scan/scanFile.js');
Expand Down