Skip to content
Open
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/tall-eels-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': major
---

fix: duplicates --json output in scan
2 changes: 1 addition & 1 deletion docs/baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Baseline suppression supports the same categories produced by scan usage checks,

- missing variables
- unused variables
- duplicate keys (`.env` / `.env.example`)
- duplicate keys (in the file the scan compared against)
- framework warnings
- uppercase key warnings
- inconsistent naming warnings
Expand Down
47 changes: 32 additions & 15 deletions packages/cli/src/baseline/scanBaseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'fs';
import type {
BaselineEntry,
BaselineFile,
BaselineRule,
ScanResult,
} from '../config/types.js';
import { resolveFromCwd } from '../core/helpers/resolveFromCwd.js';
Expand All @@ -28,14 +29,38 @@ export function loadBaselineFile(cwd: string): BaselineFile | null {
'version' in parsed &&
Array.isArray((parsed as { entries?: unknown }).entries)
) {
return parsed as BaselineFile;
const file = parsed as BaselineFile;
return { ...file, entries: file.entries.map(migrateEntry) };
}
return null;
} catch {
return null;
}
}

/**
* Rule names that older versions wrote for warnings that have since been
* renamed. Migrating them here — at the single point where a baseline file
* enters the program — keeps the rest of the codebase on one name per rule,
* and means an upgrade never silently un-suppresses a warning.
*/
const RENAMED_RULES: Record<string, BaselineRule> = {
// <=3.4 attributed scan duplicates to an env/example pair that does not
// exist: a scan only ever reads one file.
'duplicate-env': 'duplicate',
'duplicate-example': 'duplicate',
};

/**
* Rewrites a baseline entry written by an older version to its current rule name.
* @param entry - The entry as read from disk
* @returns The entry with an up-to-date rule name
*/
function migrateEntry(entry: BaselineEntry): BaselineEntry {
const renamed = RENAMED_RULES[entry.rule];
return renamed ? { ...entry, rule: renamed } : entry;
}

/**
* Writes a baseline file to disk and returns the absolute path it was written to.
* @param cwd - Current working directory to resolve the baseline file from
Expand Down Expand Up @@ -104,12 +129,8 @@ export function collectBaselineEntries(
entries.push({ rule: 'example-secret', key: warning.key });
}

for (const dup of scanResult.duplicates.env ?? []) {
entries.push({ rule: 'duplicate-env', key: dup.key });
}

for (const dup of scanResult.duplicates.example ?? []) {
entries.push({ rule: 'duplicate-example', key: dup.key });
for (const dup of scanResult.duplicates.keys ?? []) {
entries.push({ rule: 'duplicate', key: dup.key });
}

// variable + file uniquely identifies a framework warning without line numbers
Expand Down Expand Up @@ -175,14 +196,10 @@ export function applyBaselineEntries(
(s) => !has('secret', fingerprint(`${s.file}:${s.snippet}`)),
),
duplicates: {
...(scanResult.duplicates.env != null && {
env: scanResult.duplicates.env.filter(
(d) => !has('duplicate-env', d.key),
),
}),
...(scanResult.duplicates.example != null && {
example: scanResult.duplicates.example.filter(
(d) => !has('duplicate-example', d.key),
...scanResult.duplicates,
...(scanResult.duplicates.keys != null && {
keys: scanResult.duplicates.keys.filter(
(d) => !has('duplicate', d.key),
),
}),
},
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/commands/scanUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,7 @@ function calculateStats(scanResult: ScanResult): void {
(scanResult.secrets?.length ?? 0) +
scanResult.missing.length +
scanResult.unused.length +
(scanResult.duplicates?.env?.length ?? 0) +
(scanResult.duplicates?.example?.length ?? 0);
(scanResult.duplicates?.keys?.length ?? 0);

scanResult.stats = {
filesScanned: scanResult.stats.filesScanned,
Expand Down
23 changes: 17 additions & 6 deletions packages/cli/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ export interface DuplicateResult {
dupsEx: Duplicate[];
}

/**
* Duplicate keys found by a scan.
*
* A scan only ever reads a single file, so — unlike `--compare`, which works on
* a real env/example pair — there is no second file to attribute duplicates to.
* `file` names the file the keys were actually read from, so the console header
* and the JSON output can never disagree about which file is meant.
*/
export interface ScanDuplicates {
/** Basename of the file the duplicates were found in. */
file?: string;
/** The duplicated keys in that file, with their occurrence counts. */
keys?: Duplicate[];
}

/**
* Type representing a single category for comparison
*/
Expand Down Expand Up @@ -283,10 +298,7 @@ export interface ScanResult {
declaredKeys?: string[];
stats: ScanStats;
secrets: SecretFinding[];
duplicates: {
env?: Duplicate[];
example?: Duplicate[];
};
duplicates: ScanDuplicates;
frameworkWarnings?: FrameworkWarning[];
exampleWarnings?: ExampleSecretWarning[];
logged: EnvUsage[];
Expand Down Expand Up @@ -526,8 +538,7 @@ export type BaselineRule =
| 'logged'
| 'secret'
| 'example-secret'
| 'duplicate-env'
| 'duplicate-example'
| 'duplicate'
| 'framework'
| 'uppercase'
| 'expire'
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/core/scan/computeExitDecision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,7 @@ function hasStrictViolation(
): boolean {
return (
scan.unused.length > 0 ||
(scan.duplicates?.env?.length ?? 0) > 0 ||
(scan.duplicates?.example?.length ?? 0) > 0 ||
(scan.duplicates?.keys?.length ?? 0) > 0 ||
(scan.secrets?.length ?? 0) > 0 ||
(scan.exampleWarnings?.length ?? 0) > 0 ||
(scan.frameworkWarnings?.length ?? 0) > 0 ||
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/core/scan/computeHealthScore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ export function computeHealthScore(scan: ScanResult): number {
score -= (scan.driftWarnings?.length ?? 0) * 2;

// === 10. Duplicate definitions ===
score -= (scan.duplicates?.env?.length ?? 0) * 10;
score -= (scan.duplicates?.example?.length ?? 0) * 10;
score -= (scan.duplicates?.keys?.length ?? 0) * 10;

// Never go below 0 or above 100
return Math.max(0, Math.min(100, score));
Expand Down
16 changes: 7 additions & 9 deletions packages/cli/src/services/printScanResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { printHeader } from '../ui/scan/printHeader.js';
import { printStats } from '../ui/scan/printStats.js';
import { printMissing } from '../ui/scan/printMissing.js';
import { printUnused } from '../ui/scan/printUnused.js';
import { printDuplicates } from '../ui/shared/printDuplicates.js';
import { printScanDuplicates } from '../ui/scan/printScanDuplicates.js';
import { printSecrets } from '../ui/scan/printSecrets.js';
import { printFixTips } from '../ui/shared/printFixTips.js';
import { printAutoFix } from '../ui/shared/printAutoFix.js';
Expand Down Expand Up @@ -87,12 +87,10 @@ export function printScanResult(
printUnused(scanResult.unused, comparedAgainst, opts.strict);
}

// Duplicates
printDuplicates(
comparedAgainst || DEFAULT_ENV_FILE,
'example file',
scanResult.duplicates?.env ?? [],
scanResult.duplicates?.example ?? [],
// Duplicates — always attributed to the one file the scan read
printScanDuplicates(
scanResult.duplicates?.file || comparedAgainst || DEFAULT_ENV_FILE,
scanResult.duplicates?.keys ?? [],
isJson,
opts.fix ?? false,
opts.strict,
Expand Down Expand Up @@ -156,8 +154,8 @@ export function printScanResult(
printFixTips(
{
missing: scanResult.missing,
duplicatesEnv: scanResult.duplicates?.env ?? [],
duplicatesEx: scanResult.duplicates?.example ?? [],
duplicatesEnv: scanResult.duplicates?.keys ?? [],
duplicatesEx: [],
gitignoreIssue: hasGitignoreIssue ? { reason: 'not-ignored' } : null,
},
hasGitignoreIssue,
Expand Down
69 changes: 21 additions & 48 deletions packages/cli/src/services/processComparisonFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { DEFAULT_EXAMPLE_FILE } from '../config/constants.js';
import type {
ScanUsageOptions,
ScanResult,
DuplicateResult,
UppercaseWarning,
Duplicate,
ComparisonFile,
Expand All @@ -39,10 +38,8 @@ export interface ProcessComparisonResult {
envVariables: Record<string, string | undefined>;
/** The file the comparison was made against */
comparedAgainst: string;
/** The duplicate environment variables found in the comparison file */
dupsEnv: Duplicate[];
/** The duplicate example variables found in the comparison file */
dupsEx: Duplicate[];
/** The duplicate keys found in the comparison file */
duplicates: Duplicate[];
/** The context of any fixes applied to the comparison file */
fix: FixContext;
/** The full contents of the example file, if it was found and read */
Expand Down Expand Up @@ -77,8 +74,7 @@ export function processComparisonFile(
): ProcessComparisonResult {
let envVariables: Record<string, string | undefined> = {};
let comparedAgainst = '';
let dupsEnv: Duplicate[] = [];
let dupsEx: Duplicate[] = [];
let duplicates: Duplicate[] = [];
let exampleFull: Record<string, string> | undefined = undefined;
let exampleFile: string | undefined = undefined;
let uppercaseWarnings: UppercaseWarning[] = [];
Expand Down Expand Up @@ -163,9 +159,7 @@ export function processComparisonFile(

// Find duplicates
if (!opts.allowDuplicates) {
const duplicateResults = checkDuplicates(compareFile, opts);
dupsEnv = duplicateResults.dupsEnv;
dupsEx = duplicateResults.dupsEx;
duplicates = checkDuplicates(compareFile, opts);
}

if (opts.expireWarnings) {
Expand Down Expand Up @@ -212,7 +206,7 @@ export function processComparisonFile(
const { changed, result } = applyFixes({
envPath: compareFile.path,
missingKeys: scanResult.missing,
duplicateKeys: dupsEnv.map((d) => d.key),
duplicateKeys: duplicates.map((d) => d.key),
ensureGitignore: true,
});

Expand All @@ -225,28 +219,23 @@ export function processComparisonFile(

// clear the issues that were fixed
scanResult.missing = [];
dupsEnv = [];
dupsEx = [];
duplicates = [];
}
}

// Keep duplicates for output if not fixed
if (
(dupsEnv.length > 0 || dupsEx.length > 0) &&
(!opts.fix || !fix.fixApplied)
) {
if (!scanResult.duplicates) scanResult.duplicates = {};
if (dupsEnv.length > 0) scanResult.duplicates.env = dupsEnv;
if (dupsEx.length > 0) scanResult.duplicates.example = dupsEx;
// Keep duplicates for output if not fixed. They are always reported against
// the file the scan actually read, which is not necessarily `.env` — with
// `--example` it is the example file itself.
if (duplicates.length > 0 && (!opts.fix || !fix.fixApplied)) {
scanResult.duplicates = { file: compareFile.name, keys: duplicates };
}
} catch (error) {
const errorMessage = `Could not read ${compareFile.name}: ${compareFile.path} - ${error}`;
return {
scanResult,
envVariables,
comparedAgainst,
dupsEnv,
dupsEx,
duplicates,
fix,
exampleFull,
exampleFile,
Expand All @@ -266,8 +255,7 @@ export function processComparisonFile(
scanResult,
envVariables,
comparedAgainst,
dupsEnv,
dupsEx,
duplicates,
fix,
exampleFull,
exampleFile,
Expand All @@ -280,38 +268,23 @@ export function processComparisonFile(
}

/**
* Check for duplicate keys in env and example files
* Check for duplicate keys in the file the scan is comparing against.
*
* A scan reads exactly one file, so there is only one place duplicates can come
* from. Attributing them to an env/example pair — the way `--compare` does — is
* what made the same finding show up under two different names.
* @param compareFile - The file to compare against
* @param opts - Scan options
* @returns Object containing duplicate keys in env and example files
* @returns Duplicate keys found in the comparison file
*/
function checkDuplicates(
compareFile: ComparisonFile,
opts: ScanUsageOptions,
): DuplicateResult {
): Duplicate[] {
const isIgnored = (key: string) =>
!opts.ignore.includes(key) && !opts.ignoreRegex.some((rx) => rx.test(key));

// Duplicates in main env file
const dupsEnv = findDuplicateKeys(compareFile.path).filter(({ key }) =>
return findDuplicateKeys(compareFile.path).filter(({ key }) =>
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),
);
}
}

return { dupsEnv, dupsEx } satisfies DuplicateResult;
}
5 changes: 1 addition & 4 deletions packages/cli/src/services/scanCodebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,7 @@ export async function scanCodebase(opts: ScanOptions): Promise<ScanResult> {
warningsCount: 0,
duration: 0,
},
duplicates: {
env: [],
example: [],
},
duplicates: { keys: [] },
logged: loggedVariables,
fileContentMap,
};
Expand Down
Loading