Skip to content

Commit d0eca1f

Browse files
authored
fix: false unused with dynamic variables (#384)
* fix: false unused with dynamic variables * chore(docs): updated docs * chore: changeset * chore: removed export from pattern interface * chore: tests updated
1 parent 7cb7c0e commit d0eca1f

6 files changed

Lines changed: 179 additions & 2 deletions

File tree

.changeset/twelve-clouds-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'dotenv-diff': patch
3+
---
4+
5+
fixed warning on unsued dynamic sveltekit variables

docs/capabilities.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ env.MY_KEY
2828
const { MY_KEY } = env
2929
const { MY_KEY: alias, OTHER_KEY = "fallback" } = env
3030

31+
// SvelteKit – dynamic with aliased import
32+
import { env as privateEnv } from '$env/dynamic/private';
33+
import { env as publicEnv } from '$env/dynamic/public';
34+
privateEnv.MY_KEY
35+
const { MY_KEY } = privateEnv
36+
3137
// SvelteKit – static (named imports)
3238
import { MY_KEY } from '$env/static/private';
3339
import { MY_KEY } from '$env/static/public';

packages/cli/src/core/scan/patterns.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,38 @@ type Pattern = {
66
processor?: (match: RegExpExecArray) => string[];
77
};
88

9+
/**
10+
* Builds SvelteKit env patterns for an aliased import.
11+
* Handles: import { env as aliasName } from '$env/dynamic/private'
12+
* @param alias - The local alias used for the env object (e.g. "privateEnv")
13+
* @returns Patterns matching aliasName.VAR and { VAR } = aliasName
14+
*/
15+
export function buildSveltekitAliasPatterns(alias: string): Pattern[] {
16+
return [
17+
{
18+
name: 'sveltekit' as const,
19+
regex: new RegExp(`(?<![.\\w])${alias}\\.([A-Z_][A-Z0-9_]*)`, 'g'),
20+
},
21+
{
22+
name: 'sveltekit' as const,
23+
regex: new RegExp(`\\{([^}]*)\\}\\s*=\\s*${alias}\\b`, 'g'),
24+
processor: (match) => {
25+
const content = match[1];
26+
if (!content) return [];
27+
return content
28+
.split(',')
29+
.map((part) => part.trim())
30+
.filter(Boolean)
31+
.map((part) => {
32+
const [key] = part.split(/[:=]/);
33+
return key ? key.trim() : '';
34+
})
35+
.filter((key) => /^[A-Z_][A-Z0-9_]*$/.test(key));
36+
},
37+
},
38+
];
39+
}
40+
941
/**
1042
* Framework-specific regex patterns for detecting environment variable usage
1143
* across different runtimes and frameworks.

packages/cli/src/core/scan/scanFile.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import path from 'path';
22
import type { EnvUsage, ScanOptions } from '../../config/types.js';
3-
import { ENV_PATTERNS } from './patterns.js';
3+
import { ENV_PATTERNS, buildSveltekitAliasPatterns } from './patterns.js';
44
import { hasIgnoreComment } from '../security/secretDetectors.js';
55
import { normalizePath } from '../helpers/normalizePath.js';
66
import { isLikelyMinified } from '../helpers/isLikelyMinified.js';
@@ -35,7 +35,18 @@ export function scanFile(
3535
envImports.push(importMatch[1]!);
3636
}
3737

38-
for (const pattern of ENV_PATTERNS) {
38+
// Detect aliased $env imports: import { env as aliasName } from '$env/dynamic/private'
39+
const aliasImportRegex =
40+
/import\s*\{\s*env\s+as\s+(\w+)\s*\}\s*from\s*['"]\$env\/(?:static|dynamic)\/(?:private|public)['"]/g;
41+
42+
const allPatterns = [...ENV_PATTERNS];
43+
let aliasImportMatch: RegExpExecArray | null;
44+
45+
while ((aliasImportMatch = aliasImportRegex.exec(content)) !== null) {
46+
allPatterns.push(...buildSveltekitAliasPatterns(aliasImportMatch[1]!));
47+
}
48+
49+
for (const pattern of allPatterns) {
3950
let match: RegExpExecArray | null;
4051
const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
4152

packages/cli/test/unit/core/scan/patterns.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DEFAULT_INCLUDE_EXTENSIONS,
55
DEFAULT_EXCLUDE_PATTERNS,
66
ENV_PATTERNS,
7+
buildSveltekitAliasPatterns,
78
} from '../../../../src/core/scan/patterns';
89
import type { ScanOptions } from '../../../../src/config/types';
910

@@ -455,6 +456,70 @@ describe('scanFile - Pattern Detection', () => {
455456
});
456457
});
457458

459+
describe('buildSveltekitAliasPatterns', () => {
460+
function makeMatch(
461+
fullMatch: string,
462+
...groups: (string | undefined)[]
463+
): RegExpExecArray {
464+
return Object.assign([fullMatch, ...groups], {
465+
index: 0,
466+
input: fullMatch,
467+
}) as unknown as RegExpExecArray;
468+
}
469+
470+
it('returns two patterns for a given alias', () => {
471+
const patterns = buildSveltekitAliasPatterns('privateEnv');
472+
expect(patterns).toHaveLength(2);
473+
expect(patterns[0]?.regex).toBeInstanceOf(RegExp);
474+
expect(patterns[1]?.regex).toBeInstanceOf(RegExp);
475+
expect(patterns.every((p) => p.name === 'sveltekit')).toBe(true);
476+
});
477+
478+
it('dot notation pattern matches alias.VAR', () => {
479+
const [dotPattern] = buildSveltekitAliasPatterns('privateEnv');
480+
expect(dotPattern!.regex.test('privateEnv.MY_SECRET')).toBe(true);
481+
expect(dotPattern!.regex.test('env.MY_SECRET')).toBe(false);
482+
});
483+
484+
it('destructuring processor returns [] for empty content (if !content branch)', () => {
485+
const [, destructurePattern] = buildSveltekitAliasPatterns('privateEnv');
486+
const result = destructurePattern!.processor!(makeMatch('{}', ''));
487+
expect(result).toEqual([]);
488+
});
489+
490+
it('destructuring processor returns [] when content is undefined', () => {
491+
const [, destructurePattern] = buildSveltekitAliasPatterns('privateEnv');
492+
const result = destructurePattern!.processor!(makeMatch('{}', undefined));
493+
expect(result).toEqual([]);
494+
});
495+
496+
it('destructuring processor returns empty string for part starting with : (key falsy branch)', () => {
497+
// ':ALIAS' splits to key='' which is falsy → '' → filtered by uppercase check
498+
const code = `import { env as privateEnv } from '$env/dynamic/private';
499+
const { :ALIAS } = privateEnv;`;
500+
const result = scanFile('test.ts', code, baseOpts);
501+
expect(result).toHaveLength(0);
502+
});
503+
504+
it('destructuring processor extracts multiple vars from aliased import', () => {
505+
const code = `import { env as privateEnv } from '$env/dynamic/private';
506+
const { SECRET_KEY, API_TOKEN } = privateEnv;`;
507+
const result = scanFile('test.ts', code, baseOpts);
508+
expect(result.map((u) => u.variable).sort()).toEqual([
509+
'API_TOKEN',
510+
'SECRET_KEY',
511+
]);
512+
});
513+
514+
it('destructuring processor handles aliased keys: { VAR: alias } = privateEnv', () => {
515+
const code = `import { env as privateEnv } from '$env/dynamic/private';
516+
const { SECRET_KEY: secret } = privateEnv;`;
517+
const result = scanFile('test.ts', code, baseOpts);
518+
expect(result).toHaveLength(1);
519+
expect(result[0]?.variable).toBe('SECRET_KEY');
520+
});
521+
});
522+
458523
describe('SvelteKit env Object Access', () => {
459524
it('detects env.VARIABLE_NAME access', () => {
460525
const code = 'const token = env.KEYCLOAK_SECRET;';

packages/cli/test/unit/core/scan/scanFile.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,64 @@ import * as env from '$env/dynamic/private';`;
155155
expect(usages.length).toBeGreaterThanOrEqual(0);
156156
});
157157

158+
it('detects env variable accessed via aliased $env import (dot notation)', () => {
159+
const content = `import { env as privateEnv } from '$env/dynamic/private';
160+
const key = privateEnv.SUPABASE_SERVICE_ROLE_KEY;`;
161+
const usages = scanFile(
162+
'/test/project/src/lib/supabase.ts',
163+
content,
164+
baseOpts,
165+
);
166+
167+
expect(usages).toHaveLength(1);
168+
expect(usages[0]?.variable).toBe('SUPABASE_SERVICE_ROLE_KEY');
169+
expect(usages[0]?.pattern).toBe('sveltekit');
170+
});
171+
172+
it('detects env variable accessed via aliased $env/dynamic/public import', () => {
173+
const content = `import { env as publicEnv } from '$env/dynamic/public';
174+
const url = publicEnv.PUBLIC_SUPABASE_URL;`;
175+
const usages = scanFile(
176+
'/test/project/src/lib/supabase.ts',
177+
content,
178+
baseOpts,
179+
);
180+
181+
expect(usages).toHaveLength(1);
182+
expect(usages[0]?.variable).toBe('PUBLIC_SUPABASE_URL');
183+
});
184+
185+
it('detects multiple env variables via multiple aliased imports', () => {
186+
const content = `import { env as publicEnv } from '$env/dynamic/public';
187+
import { env as privateEnv } from '$env/dynamic/private';
188+
const url = publicEnv.PUBLIC_SUPABASE_URL;
189+
const key = privateEnv.SUPABASE_SERVICE_ROLE_KEY;`;
190+
const usages = scanFile(
191+
'/test/project/src/lib/supabase.ts',
192+
content,
193+
baseOpts,
194+
);
195+
196+
expect(usages).toHaveLength(2);
197+
expect(usages.map((u) => u.variable)).toContain('PUBLIC_SUPABASE_URL');
198+
expect(usages.map((u) => u.variable)).toContain(
199+
'SUPABASE_SERVICE_ROLE_KEY',
200+
);
201+
});
202+
203+
it('detects env variables via destructuring from aliased import', () => {
204+
const content = `import { env as privateEnv } from '$env/dynamic/private';
205+
const { SECRET_KEY, API_TOKEN } = privateEnv;`;
206+
const usages = scanFile(
207+
'/test/project/src/lib/server.ts',
208+
content,
209+
baseOpts,
210+
);
211+
212+
expect(usages.map((u) => u.variable)).toContain('SECRET_KEY');
213+
expect(usages.map((u) => u.variable)).toContain('API_TOKEN');
214+
});
215+
158216
it('returns empty array when no env variables found', () => {
159217
const content = 'const x = 1;\nconst y = 2;';
160218
const usages = scanFile('/test/project/src/app.js', content, baseOpts);

0 commit comments

Comments
 (0)