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
5 changes: 5 additions & 0 deletions .changeset/example-duplicates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': patch
---

check the example file for duplicate keys on scan
4 changes: 3 additions & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ Variables that are **defined in env files** but **never used** in the scanned co

### 3 Duplicate Keys

Duplicate variable definitions inside env files (both main env and example env, when available).
Duplicate variable definitions inside env files. Both the file being scanned and the example file documenting it are checked — the latter on every scan, against whichever example name applies (`.env.example`, `.env.sample`, `.env.template`, …), without needing `--example`.

`--fix` only rewrites the env file, so duplicates reported in the example file stay reported after a fix.

### 4 Secret Detection

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/scanUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ export async function scanUsage(opts: ScanUsageOptions): Promise<ExitResult> {
} else {
scanResult = result.scanResult;
comparedAgainst = result.comparedAgainst;
if (result.exampleFile) {
scanResult.exampleFile = result.exampleFile;
}
fixApplied = result.fix.fixApplied;
removedDuplicates = result.fix.removedDuplicates;
fixedKeys = result.fix.addedEnv;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ export interface ScanResult {
};
frameworkWarnings?: FrameworkWarning[];
exampleWarnings?: ExampleSecretWarning[];
exampleFile?: string;
logged: EnvUsage[];
uppercaseWarnings?: UppercaseWarning[];
expireWarnings?: ExpireWarning[];
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/services/printScanResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { printListAll } from '../ui/scan/printListAll.js';
* @param scanResult - The result of the scan.
* @param opts - The scan options.
* @param comparedAgainst - The file being compared against.
* @param fixContext - What `--fix` changed, when it ran.
* @returns An object indicating whether to exit with an error.
*/
export function printScanResult(
Expand Down Expand Up @@ -90,7 +91,7 @@ export function printScanResult(
// Duplicates
printDuplicates(
comparedAgainst || DEFAULT_ENV_FILE,
'example file',
scanResult.exampleFile || 'example file',
scanResult.duplicates?.env ?? [],
scanResult.duplicates?.example ?? [],
isJson,
Expand Down
52 changes: 28 additions & 24 deletions packages/cli/src/services/processComparisonFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,11 @@ export function processComparisonFile(

// Find duplicates
if (!opts.allowDuplicates) {
const duplicateResults = checkDuplicates(compareFile, opts);
const duplicateResults = checkDuplicates(
compareFile,
exampleFilePath,
opts,
);
dupsEnv = duplicateResults.dupsEnv;
dupsEx = duplicateResults.dupsEx;
}
Expand Down Expand Up @@ -223,18 +227,16 @@ export function processComparisonFile(
fix.addedEnv = result.addedEnv;
fix.gitignoreUpdated = result.gitignoreUpdated;

// clear the issues that were fixed
// clear the issues that were fixed. `dupsEx` is not among them: the fix
// only rewrites the env file, so duplicates in the example file survive
// it and must still be reported.
scanResult.missing = [];
dupsEnv = [];
dupsEx = [];
}
}

// Keep duplicates for output if not fixed
if (
(dupsEnv.length > 0 || dupsEx.length > 0) &&
(!opts.fix || !fix.fixApplied)
) {
// Keep the duplicates that were not fixed for output
if (dupsEnv.length > 0 || dupsEx.length > 0) {
if (!scanResult.duplicates) scanResult.duplicates = {};
if (dupsEnv.length > 0) scanResult.duplicates.env = dupsEnv;
if (dupsEx.length > 0) scanResult.duplicates.example = dupsEx;
Expand Down Expand Up @@ -280,13 +282,22 @@ export function processComparisonFile(
}

/**
* Check for duplicate keys in env and example files
* Check for duplicate keys in env and example files.
*
* The example file is taken from the caller rather than re-derived from
* `--example`, so it is checked whenever one exists beside the comparison file
* — the same reasoning as the drift check. Gating it on the flag meant the
* example was silently never checked in the common case of a plain
* `dotenv-diff scan`, even though a duplicated key there is exactly the kind of
* thing that misleads whoever copies the file.
* @param compareFile - The file to compare against
* @param examplePath - Absolute path of the example file, or null when none was found
* @param opts - Scan options
* @returns Object containing duplicate keys in env and example files
*/
function checkDuplicates(
compareFile: ComparisonFile,
examplePath: string | null,
opts: ScanUsageOptions,
): DuplicateResult {
const isIgnored = (key: string) =>
Expand All @@ -297,21 +308,14 @@ function checkDuplicates(
isIgnored(key),
);

// Duplicates in example file
let dupsEx: Duplicate[] = [];

if (opts.examplePath) {
const examplePath = resolveFromCwd(opts.cwd, opts.examplePath);

const exampleIsDifferentFile =
fs.existsSync(examplePath) && examplePath !== compareFile.path;

if (exampleIsDifferentFile) {
dupsEx = findDuplicateKeys(examplePath).filter(({ key }) =>
isIgnored(key),
);
}
}
// Duplicates in example file. Skipped when it *is* the comparison file — a
// scan with no `.env` falls through to `.env.example`, and its duplicates are
// already reported as `dupsEnv`. `findDuplicateKeys` handles a path that does
// not exist, which an explicit `--example` may still point at.
const dupsEx: Duplicate[] =
examplePath && examplePath !== compareFile.path
? findDuplicateKeys(examplePath).filter(({ key }) => isIgnored(key))
: [];

return { dupsEnv, dupsEx } satisfies DuplicateResult;
}
2 changes: 1 addition & 1 deletion packages/cli/src/ui/shared/printDuplicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function printDuplicates(
console.log(`${divider}`);
}

if (dEx.length) {
if (dEx.length && exampleName) {
console.log();
console.log(`${indicator} ${header(`Duplicate keys in ${exampleName}`)}`);
console.log(`${divider}`);
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/test/unit/commands/scanUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,67 @@ describe('scanUsage', () => {
);
});

it('attaches the resolved example file name onto the scan result', async () => {
// Carried through so findings about the example file can name it — a
// project may document itself with .env.sample rather than .env.example.
vi.mocked(determineComparisonFile).mockResolvedValue({
type: 'found',
file: { path: '/env/.env', name: '.env' },
});
vi.mocked(processComparisonFile).mockReturnValue({
scanResult: { ...baseScanResult },
comparedAgainst: '.env',
envVariables: {},
duplicatesFound: false,
dupsEnv: [],
dupsEx: [],
fix: {
fixApplied: false,
removedDuplicates: [],
addedEnv: [],
gitignoreUpdated: false,
},
exampleFile: '.env.sample',
} as ProcessComparisonResult);

await scanUsage({ ...baseOpts, json: false });

expect(printScanResult).toHaveBeenCalledWith(
expect.objectContaining({ exampleFile: '.env.sample' }),
expect.anything(),
expect.anything(),
expect.anything(),
);
});

it('leaves the example file name unset when no example file was resolved', async () => {
vi.mocked(determineComparisonFile).mockResolvedValue({
type: 'found',
file: { path: '/env/.env', name: '.env' },
});
vi.mocked(processComparisonFile).mockReturnValue({
scanResult: { ...baseScanResult },
comparedAgainst: '.env',
envVariables: {},
duplicatesFound: false,
dupsEnv: [],
dupsEx: [],
fix: {
fixApplied: false,
removedDuplicates: [],
addedEnv: [],
gitignoreUpdated: false,
},
exampleFile: undefined,
} as ProcessComparisonResult);

await scanUsage({ ...baseOpts, json: false });

expect(vi.mocked(printScanResult).mock.calls[0]?.[0]).not.toHaveProperty(
'exampleFile',
);
});

it('sets frameworkWarnings on scanResult when frameworkValidator returns results', async () => {
const { frameworkValidator } =
await import('../../../src/core/frameworks/frameworkValidator.js');
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/test/unit/services/printScanResult.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,28 @@ describe('printScanResult', () => {
);
});

it('names the example file in duplicates when the scan resolved one', () => {
printScanResult(
{
...baseScanResult,
duplicates: { example: [{ key: 'EX_KEY', count: 2 }] },
exampleFile: '.env.sample',
},
baseOpts,
'.env',
);

expect(printDuplicates).toHaveBeenCalledWith(
'.env',
'.env.sample',
[],
[{ key: 'EX_KEY', count: 2 }],
false,
false,
undefined,
);
});

it('does not print console log warning when logged is undefined', () => {
printScanResult(
{
Expand Down
54 changes: 45 additions & 9 deletions packages/cli/test/unit/services/processComparisonFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ vi.mock('../../../src/services/detectExampleDrift.js', () => ({
}));

import fs from 'fs';
import path from 'path';
import { processComparisonFile } from '../../../src/services/processComparisonFile.js';
import { applyFixes } from '../../../src/services/fixEnv.js';
import { parseEnvFile } from '../../../src/services/parseEnvFile.js';
Expand Down Expand Up @@ -254,15 +255,8 @@ describe('processComparisonFile', () => {
});

it('keeps duplicates when fix does not change anything', () => {
vi.mocked(applyFixes).mockReturnValueOnce({
changed: false,
result: {
removedDuplicates: [],
addedEnv: [],
gitignoreUpdated: false,
},
});

// `fix` is off, so applyFixes never runs — queueing a return value here
// would only leak into the next test that does enable it.
const result = processComparisonFile(baseScanResult, compareFile, {
...baseOpts,
allowDuplicates: false,
Expand Down Expand Up @@ -320,6 +314,48 @@ describe('processComparisonFile', () => {
expect(result.exampleFull).toEqual({ A: '1', bKey: '2' });
});

it('checks the example file for duplicates without the examplePath option', () => {
// Same reasoning as the drift check: the example beside the comparison file
// is checked whether or not --example was passed, so a duplicate there is
// not silently missed on a plain scan.
vi.mocked(findDuplicateKeys)
.mockReturnValueOnce([])
.mockReturnValueOnce([{ key: 'EX_KEY', count: 2 }]);

const result = processComparisonFile(
{ ...baseScanResult, duplicates: {} },
compareFile,
{ ...baseOpts, examplePath: undefined, allowDuplicates: false },
);

// `path.join` in resolveExampleFile, so the separator is platform-specific.
expect(findDuplicateKeys).toHaveBeenNthCalledWith(
2,
path.join('/env', '.env.example'),
);
expect(result.dupsEx).toEqual([{ key: 'EX_KEY', count: 2 }]);
});

it('keeps example duplicates after a fix, which only rewrites the env file', () => {
vi.mocked(findDuplicateKeys)
.mockReturnValueOnce([{ key: 'A', count: 2 }])
.mockReturnValueOnce([{ key: 'EX_KEY', count: 2 }]);

const result = processComparisonFile(
{ ...baseScanResult, duplicates: {} },
compareFile,
{ ...baseOpts, fix: true, allowDuplicates: false },
);

expect(result.fix.fixApplied).toBe(true);
// The env file was deduplicated, the example file was never touched.
expect(result.dupsEnv).toHaveLength(0);
expect(result.scanResult.duplicates?.env).toBeUndefined();
expect(result.scanResult.duplicates?.example).toEqual([
{ key: 'EX_KEY', count: 2 },
]);
});

it('skips example duplicate check when examplePath equals compareFile path', async () => {
// resolveFromCwd returns the compareFile path → same file → skip
const { resolveFromCwd } =
Expand Down
37 changes: 35 additions & 2 deletions packages/cli/test/unit/ui/shared/printDuplicates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('printDuplicates', () => {
});

it('does nothing when json is true', () => {
printDuplicates('.env', '.env.example', [], [], true);
printDuplicates('.env', '.env.example', [], [], true, false, false);
expect(logSpy).not.toHaveBeenCalled();
});

Expand All @@ -36,6 +36,7 @@ describe('printDuplicates', () => {
[],
false,
true,
false,
);

expect(
Expand All @@ -46,7 +47,7 @@ describe('printDuplicates', () => {
});

it('does not print env duplicates when none exist', () => {
printDuplicates('.env', '.env.example', [], [], false, false);
printDuplicates('.env', '.env.example', [], [], false, false, false);

expect(logSpy).not.toHaveBeenCalled();
});
Expand All @@ -58,11 +59,43 @@ describe('printDuplicates', () => {
[{ key: 'A', count: 2 }],
[{ key: 'B', count: 3 }],
false,
false,
false,
);

expect(logSpy).toHaveBeenCalled();
});

it('names the example file that duplicates were found in', () => {
printDuplicates(
'.env',
'.env.sample',
[],
[{ key: 'B', count: 3 }],
false,
false,
false,
);

expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('H(Duplicate keys in .env.sample)'),
);
});

it('skips example duplicates when no example file was resolved', () => {
printDuplicates(
'.env',
'',
[],
[{ key: 'B', count: 3 }],
false,
false,
false,
);

expect(logSpy).not.toHaveBeenCalled();
});

it('uses strict formatting when strict mode is enabled', () => {
printDuplicates(
'.env',
Expand Down