Skip to content

Commit 44280a7

Browse files
authored
fix: flagging config loader variables as unused (#482)
1 parent 8ca674f commit 44280a7

8 files changed

Lines changed: 220 additions & 3 deletions

File tree

.changeset/config-loader-unused.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+
Stop flagging config loader variables as unused.

packages/cli/src/config/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,12 @@ export interface ScanResult {
267267
used: EnvUsage[];
268268
missing: string[];
269269
unused: string[];
270+
/**
271+
* Keys declared by a central config loader (e.g. a Zod/envalid schema over the
272+
* whole `process.env`). Used only to suppress false "unused" findings — they do
273+
* not count as usages for missing detection, stats, or the health score.
274+
*/
275+
declaredKeys?: string[];
270276
stats: ScanStats;
271277
secrets: SecretFinding[];
272278
duplicates: {

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ export function compareWithEnvFiles(
5353
return files.some((file) => !isCovered(variable, file));
5454
});
5555
const missing = filterIgnoredKeys(missingUnfiltered, ignore, ignoreRegex);
56-
const unused = [...envKeys].filter((v) => !usedVariables.has(v));
56+
57+
// Keys declared by a central config loader (e.g. a Zod schema over the whole
58+
// process.env) are read indirectly, so they must not be reported as unused.
59+
const declaredKeys = new Set(scanResult.declaredKeys ?? []);
60+
const unused = [...envKeys].filter(
61+
(v) => !usedVariables.has(v) && !declaredKeys.has(v),
62+
);
5763

5864
// Cross-reference missing (used but undefined) keys against the defined keys
5965
// to surface likely typos, e.g. code uses DATABASE_URL but .env has DATABAS_URL.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Signals that a file consumes the entire `process.env` object rather than a
3+
* single `process.env.X` key — the hallmark of a central config loader
4+
* (Zod/envalid schemas, direct assignment, spread). When one of these is present
5+
* we treat the file's UPPER_SNAKE_CASE object keys as env declarations.
6+
*/
7+
const WHOLESALE_ENV_SIGNALS: RegExp[] = [
8+
/\.(?:safeParse|parse)\s*\(\s*process\.env\b/, // zod: schema.parse(process.env)
9+
/\bcleanEnv\s*\(\s*process\.env\b/, // envalid: cleanEnv(process.env, {...})
10+
/=\s*process\.env(?!\s*[.[])/, // const env = process.env
11+
/\{\s*\.\.\.process\.env\b/, // { ...process.env }
12+
];
13+
14+
/**
15+
* Matches UPPER_SNAKE_CASE object-literal keys, e.g. `CLIENT_SECRET_GITHUB:` or
16+
* `"CLIENT_SECRET_GITHUB":`, in a key position (start of line, or after `{`, `,`,
17+
* `(`, or whitespace).
18+
*/
19+
const OBJECT_KEY_PATTERN = /(?:^|[\s{,(])["']?([A-Z_][A-Z0-9_]*)["']?\s*:/gm;
20+
21+
/**
22+
* Extracts env-variable keys that a central config loader declares as object
23+
* keys while consuming the whole `process.env` object.
24+
*
25+
* This surfaces variables that are read indirectly — e.g. a Zod schema
26+
* `z.object({ CLIENT_SECRET_GITHUB: ... }).parse(process.env)` whose values are
27+
* later accessed as `appCfg.CLIENT_SECRET_GITHUB` — which the literal
28+
* `process.env.X` scanner never sees. The returned keys are used only to
29+
* suppress false "unused" findings; they do not count as usages for missing
30+
* detection, stats, or the health score.
31+
*
32+
* @param content - The full source of a file.
33+
* @returns UPPER_SNAKE_CASE keys declared in the file, or an empty array when the
34+
* file does not consume the whole `process.env` object.
35+
*/
36+
export function detectConfigSchemaKeys(content: string): string[] {
37+
const consumesWholeEnv = WHOLESALE_ENV_SIGNALS.some((rx) => rx.test(content));
38+
if (!consumesWholeEnv) return [];
39+
40+
const keys = new Set<string>();
41+
OBJECT_KEY_PATTERN.lastIndex = 0;
42+
let match: RegExpExecArray | null;
43+
while ((match = OBJECT_KEY_PATTERN.exec(content)) !== null) {
44+
keys.add(match[1]!);
45+
}
46+
47+
return [...keys];
48+
}

packages/cli/src/services/scanCodebase.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '../core/security/secretDetectors.js';
99
import { DEFAULT_EXCLUDE_PATTERNS } from '../core/scan/patterns.js';
1010
import { scanFile } from '../core/scan/scanFile.js';
11+
import { detectConfigSchemaKeys } from '../core/scan/detectConfigSchemaKeys.js';
1112
import { createConcurrencyLimit } from '../core/helpers/concurrencyLimit.js';
1213
import { findFiles } from './fileWalker.js';
1314
import { normalizePath } from '../core/helpers/normalizePath.js';
@@ -36,8 +37,9 @@ export async function scanCodebase(opts: ScanOptions): Promise<ScanResult> {
3637
const relativePath = normalizePath(path.relative(opts.cwd, filePath));
3738
const fileUsages = scanFile(filePath, content, opts);
3839
const secrets = safeDetectSecrets(relativePath, content, opts);
40+
const declaredKeys = detectConfigSchemaKeys(content);
3941

40-
return { fileUsages, relativePath, content, secrets };
42+
return { fileUsages, relativePath, content, secrets, declaredKeys };
4143
};
4244

4345
const results = await Promise.all(
@@ -46,15 +48,17 @@ export async function scanCodebase(opts: ScanOptions): Promise<ScanResult> {
4648

4749
const allUsages: EnvUsage[] = [];
4850
const allSecrets: SecretFinding[] = [];
51+
const allDeclaredKeys = new Set<string>();
4952
const fileContentMap = new Map<string, string>();
5053
let filesScanned = 0;
5154

5255
for (const result of results) {
5356
if (!result) continue;
54-
const { fileUsages, relativePath, content, secrets } = result;
57+
const { fileUsages, relativePath, content, secrets, declaredKeys } = result;
5558
allUsages.push(...fileUsages);
5659
fileContentMap.set(relativePath, content);
5760
if (secrets.length) allSecrets.push(...secrets);
61+
for (const key of declaredKeys) allDeclaredKeys.add(key);
5862
filesScanned++;
5963
}
6064

@@ -72,6 +76,7 @@ export async function scanCodebase(opts: ScanOptions): Promise<ScanResult> {
7276
used: filteredUsages,
7377
missing: [],
7478
unused: [],
79+
declaredKeys: [...allDeclaredKeys],
7580
secrets: allSecrets,
7681
stats: {
7782
filesScanned,

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,25 @@ describe('compareWithEnvFiles', () => {
6565
expect(result.unused).toEqual(['B']);
6666
});
6767

68+
it('does not report a config-schema declared key as unused', () => {
69+
const result = compareWithEnvFiles(
70+
makeScanResult({ declaredKeys: ['CLIENT_SECRET_GITHUB'] }),
71+
{ CLIENT_SECRET_GITHUB: 'x', REALLY_UNUSED: 'y' },
72+
);
73+
74+
// Declared via a loader schema → not unused; the truly unreferenced key stays.
75+
expect(result.unused).toEqual(['REALLY_UNUSED']);
76+
});
77+
78+
it('ignores declared keys that are not present in the env file', () => {
79+
const result = compareWithEnvFiles(
80+
makeScanResult({ declaredKeys: ['NOT_IN_ENV'] }),
81+
{ REALLY_UNUSED: 'y' },
82+
);
83+
84+
expect(result.unused).toEqual(['REALLY_UNUSED']);
85+
});
86+
6887
it('preserves other ScanResult fields', () => {
6988
const scanResult = makeScanResult({
7089
stats: {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { detectConfigSchemaKeys } from '../../../../src/core/scan/detectConfigSchemaKeys.js';
3+
4+
describe('detectConfigSchemaKeys', () => {
5+
it('extracts schema keys from a Zod .parse(process.env) loader', () => {
6+
const content = `const envVars = z.object({
7+
CLIENT_ID_GITHUB: z.string(),
8+
CLIENT_SECRET_GITHUB: z.string().optional(),
9+
}).parse(process.env);`;
10+
11+
expect(detectConfigSchemaKeys(content).sort()).toEqual([
12+
'CLIENT_ID_GITHUB',
13+
'CLIENT_SECRET_GITHUB',
14+
]);
15+
});
16+
17+
it('supports .safeParse(process.env)', () => {
18+
const content =
19+
'schema.safeParse(process.env);\nconst obj = { DATABASE_URL: 1 };';
20+
21+
expect(detectConfigSchemaKeys(content)).toEqual(['DATABASE_URL']);
22+
});
23+
24+
it('supports envalid cleanEnv(process.env, {...})', () => {
25+
const content =
26+
'const env = cleanEnv(process.env, { PORT: port(), API_KEY: str() });';
27+
28+
expect(detectConfigSchemaKeys(content).sort()).toEqual(['API_KEY', 'PORT']);
29+
});
30+
31+
it('supports direct whole-object assignment (= process.env)', () => {
32+
const content =
33+
'const env = process.env;\nconst cfg = { SECRET_TOKEN: env.SECRET_TOKEN };';
34+
35+
expect(detectConfigSchemaKeys(content)).toEqual(['SECRET_TOKEN']);
36+
});
37+
38+
it('supports spread of the whole env object ({ ...process.env })', () => {
39+
const content = 'const cfg = { ...process.env, EXTRA_FLAG: true };';
40+
41+
expect(detectConfigSchemaKeys(content)).toEqual(['EXTRA_FLAG']);
42+
});
43+
44+
it('recognises quoted keys', () => {
45+
const content =
46+
'z.object({ "CLIENT_SECRET_GITHUB": z.string() }).parse(process.env);';
47+
48+
expect(detectConfigSchemaKeys(content)).toEqual(['CLIENT_SECRET_GITHUB']);
49+
});
50+
51+
it('deduplicates repeated keys', () => {
52+
const content =
53+
'schema.parse(process.env);\nconst a = { API_KEY: 1 };\nconst b = { API_KEY: 2 };';
54+
55+
expect(detectConfigSchemaKeys(content)).toEqual(['API_KEY']);
56+
});
57+
58+
it('ignores non-UPPER_SNAKE keys', () => {
59+
const content =
60+
'z.object({ clientId: z.string(), someKey: z.string() }).parse(process.env);';
61+
62+
expect(detectConfigSchemaKeys(content)).toEqual([]);
63+
});
64+
65+
it('returns [] when the file only reads a single process.env.X key', () => {
66+
const content =
67+
'const key = process.env.API_KEY;\nconst obj = { OTHER_KEY: 1 };';
68+
69+
// `process.env.API_KEY` must NOT count as whole-object consumption.
70+
expect(detectConfigSchemaKeys(content)).toEqual([]);
71+
});
72+
73+
it('returns [] when the file does not touch process.env at all', () => {
74+
const content = 'const config = { DATABASE_URL: 1, API_KEY: 2 };';
75+
76+
expect(detectConfigSchemaKeys(content)).toEqual([]);
77+
});
78+
79+
it('returns [] for a whole-env loader with no UPPER_SNAKE object keys', () => {
80+
const content = 'const env = process.env;\nexport default env;';
81+
82+
expect(detectConfigSchemaKeys(content)).toEqual([]);
83+
});
84+
85+
it('detects the loader across newlines between .parse( and process.env', () => {
86+
const content =
87+
'const cfg = z\n .object({ API_KEY: z.string() })\n .parse(\n process.env,\n );';
88+
89+
expect(detectConfigSchemaKeys(content)).toEqual(['API_KEY']);
90+
});
91+
92+
it('extracts only object keys, not the accessor key, in a mixed file', () => {
93+
// The file both reads a single process.env.NODE_ENV and defines a schema.
94+
const content =
95+
'const runtime = process.env.NODE_ENV;\nconst cfg = schema.parse(process.env);\nconst obj = { DB_HOST: 1 };';
96+
97+
// NODE_ENV is an accessor (no `KEY:`), so only the object key DB_HOST is declared.
98+
expect(detectConfigSchemaKeys(content)).toEqual(['DB_HOST']);
99+
});
100+
101+
it('handles keys containing digits (e.g. OAUTH2_TOKEN, S3_BUCKET)', () => {
102+
const content =
103+
'cleanEnv(process.env, { OAUTH2_TOKEN: str(), S3_BUCKET: str() });';
104+
105+
expect(detectConfigSchemaKeys(content).sort()).toEqual([
106+
'OAUTH2_TOKEN',
107+
'S3_BUCKET',
108+
]);
109+
});
110+
});

packages/cli/test/unit/services/scanCodebase.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ describe('scanCodebase', () => {
9090
expect(result.stats.uniqueVariables).toBe(0);
9191
});
9292

93+
it('collects config-schema declared keys from a whole-process.env loader', async () => {
94+
const testFile = path.join(tmpDir, 'env.ts');
95+
fsSync.writeFileSync(
96+
testFile,
97+
'const cfg = z.object({ CLIENT_ID_GITHUB: z.string(), CLIENT_SECRET_GITHUB: z.string() }).parse(process.env);',
98+
);
99+
100+
vi.mocked(findFiles).mockResolvedValue([testFile]);
101+
vi.mocked(scanFile).mockReturnValue([]);
102+
103+
const result = await scanCodebase(defaultOpts);
104+
105+
expect(result.declaredKeys?.sort()).toEqual([
106+
'CLIENT_ID_GITHUB',
107+
'CLIENT_SECRET_GITHUB',
108+
]);
109+
});
110+
93111
it('handles files with no usages', async () => {
94112
const testFile = path.join(tmpDir, 'app.js');
95113
fsSync.writeFileSync(testFile, 'const x = 42;');

0 commit comments

Comments
 (0)