Skip to content
5 changes: 5 additions & 0 deletions .changeset/metal-houses-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': minor
---

add --baseline flag to suppress known issues
91 changes: 91 additions & 0 deletions docs/baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Baseline Workflow

The `--baseline` flag helps you adopt `dotenv-diff` in projects that already contain known warnings.

It records the current warning state into a baseline file, so future runs can focus on newly introduced issues.

## What `--baseline` does

When you run:

```bash
dotenv-diff --baseline
```

dotenv-diff will:

- scan your codebase as normal
- collect the warnings from the current scan result
- write a `dotenv-diff.baseline.json` file in the working directory
- exit cleanly (`exit code 0`) after writing the file

On later runs (without `--baseline`), dotenv-diff automatically loads this file and suppresses matching warnings.

## Baseline file location

The baseline file is written in your current working directory:

```text
dotenv-diff.baseline.json
```

In monorepos, this means each app/package can keep its own baseline by running dotenv-diff from that folder.

## Supported warning categories

Baseline suppression supports the same categories produced by scan usage checks, including:

- missing variables
- unused variables
- duplicate keys (`.env` / `.env.example`)
- framework warnings
- uppercase key warnings
- inconsistent naming warnings
- expiration warnings
- secret findings (stored as fingerprints)
- `.env.example` secret warnings
- logged variable usages (`console.log` of env variables)

## JSON mode

You can combine baseline with JSON output:

```bash
dotenv-diff --baseline --json
```

Success output includes:

- `file`
- `warningsStored`

If writing fails, the process will exit with an exit code of `1`.

## Recommended workflow

1. Create a baseline once for the current state:

```bash
dotenv-diff --baseline
```

2. Commit `dotenv-diff.baseline.json`.

3. Run dotenv-diff normally in local development and CI.

4. Fix issues incrementally and remove stale baseline entries over time.

5. Recreate the baseline only when you intentionally accept a new known warning set.

## Best practices

- Review baseline changes in pull requests like any other code change.
- Keep the baseline file small by removing entries after fixes.
- Prefer fixing warnings over growing the baseline indefinitely.
- Avoid regenerating baseline automatically in CI; treat it as a reviewed artifact.

## Related docs

- [Configuration and Flags](./configuration_and_flags.md#--baseline)
- [Git Hooks and CI/CD](./git_hooks_ci.md)
- [Monorepo Support](./monorepo_support.md)
32 changes: 32 additions & 0 deletions docs/configuration_and_flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ CLI flags always take precedence over configuration file values.
- [--ignore-regex](#--ignore-regex-patterns)
- [--fix](#--fix)
- [--json](#--json)
- [--baseline](#--baseline)
- [--color](#--color)
- [--no-color](#--no-color)
- [--ci](#--ci)
Expand Down Expand Up @@ -255,6 +256,37 @@ Usage in the configuration file:
}
```

### `--baseline`

Save the current warning state as a baseline file and exit cleanly.

When this flag is used, dotenv-diff scans as usual, then writes a `dotenv-diff.baseline.json` file in the current working directory.
Future runs automatically load this file and suppress matching existing warnings, so new issues become easier to spot.

`--baseline` is useful when introducing dotenv-diff to an existing codebase with many known warnings.

Example usage:

```bash
dotenv-diff --baseline
```

Use with JSON output:

```bash
dotenv-diff --baseline --json
```

Usage in the configuration file:

```json
{
"baseline": true
}
```

See [Baseline Workflow](./baseline.md) for recommendations on when to create, refresh, and review baseline entries.

### `--color`

Enables colored output in the terminal (enabled by default).
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ npx dotenv-diff
|---|---|
| [Capabilities](./capabilities.md) | What the scanner checks for and how it works |and rules |
| [Configuration and Flags](./configuration_and_flags.md) | Full CLI/config reference for options and behavior |
| [Baseline Workflow](./baseline.md) | Set a warning baseline and suppress already-known findings safely |
| [Comparing Files](./compare.md) | How to compare two `.env` files to detect differences |
| [Expiration Warnings](./expiration_warnings.md) | How `@expire` annotations work and strict mode integration |
| [Ignore Comments](./ignore_comments.md) | Suppress false positives with inline/block ignore markers |
Expand Down
229 changes: 229 additions & 0 deletions packages/cli/src/baseline/scanBaseline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import crypto from 'crypto';
import fs from 'fs';
import type {
BaselineEntry,
BaselineFile,
ScanResult,
} from '../config/types.js';
import { resolveFromCwd } from '../core/helpers/resolveFromCwd.js';

export const BASELINE_FILE = 'dotenv-diff.baseline.json';
const BASELINE_VERSION = 1;

/**
* Loads the baseline file from disk. Returns null if the file does not exist
* or cannot be parsed into a valid shape.
* @param cwd - Current working directory to resolve the baseline file from
* @returns The parsed baseline file or null if not found/invalid
*/
export function loadBaselineFile(cwd: string): BaselineFile | null {
const filePath = resolveFromCwd(cwd, BASELINE_FILE);
if (!fs.existsSync(filePath)) return null;
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (
typeof parsed === 'object' &&
parsed !== null &&
'version' in parsed &&
Array.isArray((parsed as { entries?: unknown }).entries)
) {
return parsed as BaselineFile;
}
return null;
} catch {
return null;
}
}

/**
* 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
* @param entries - List of baseline entries to write
* @returns Absolute file path of the written baseline file
* @throws If writing the file fails (e.g. due to permissions or disk issues)
*/
export async function writeBaselineFile(
cwd: string,
entries: BaselineEntry[],
): Promise<string> {
const filePath = resolveFromCwd(cwd, BASELINE_FILE);
const payload: BaselineFile = {
version: BASELINE_VERSION,
createdAt: new Date().toISOString(),
entries,
};
await fs.promises.writeFile(
filePath,
`${JSON.stringify(payload, null, 2)}\n`,
'utf8',
);
return filePath;
}

/**
* Converts a ScanResult into a deterministic list of baseline entries.
*
* Identifiers are chosen to be stable across runs — volatile fields like line
* numbers and snippet text are excluded. Secrets are stored as a fingerprint
* (SHA-256 truncated to 12 hex chars) of `file:snippet` so no secret value is
* ever written to the baseline file.
* @param scanResult The full scan result to convert into baseline entries
* @returns A sorted list of baseline entries representing the scan result
*/
export function collectBaselineEntries(
scanResult: ScanResult,
): BaselineEntry[] {
const entries: BaselineEntry[] = [];

for (const key of scanResult.missing) {
entries.push({ rule: 'missing', key });
}

for (const key of scanResult.unused) {
entries.push({ rule: 'unused', key });
}

for (const usage of scanResult.logged) {
entries.push({
rule: 'logged',
key: usage.variable,
file: usage.file,
});
}

for (const secret of scanResult.secrets) {
entries.push({
rule: 'secret',
key: fingerprint(`${secret.file}:${secret.snippet}`),
file: secret.file,
});
}

for (const warning of scanResult.exampleWarnings ?? []) {
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 });
}

// variable + file uniquely identifies a framework warning without line numbers
for (const warning of scanResult.frameworkWarnings ?? []) {
entries.push({
rule: 'framework',
key: warning.variable,
file: warning.file,
});
}

for (const warning of scanResult.uppercaseWarnings ?? []) {
entries.push({ rule: 'uppercase', key: warning.key });
}

for (const warning of scanResult.expireWarnings ?? []) {
entries.push({ rule: 'expire', key: warning.key });
}

// Sort the key pair so the entry is identical regardless of scanner order
for (const warning of scanResult.inconsistentNamingWarnings ?? []) {
const pair = [warning.key1, warning.key2].sort().join('|');
entries.push({ rule: 'inconsistent-naming', key: pair });
}

return sortEntries(entries);
}

/**
* Returns a new ScanResult with every warning that is covered by a baseline
* entry removed. The matching logic is the mirror image of
* {@link collectBaselineEntries} so every entry written suppresses the
* correct warning.
* @param scanResult The full scan result to apply baseline filtering to
* @param entries The list of baseline entries to apply
* @returns A new ScanResult with baseline-covered warnings removed
*/
export function applyBaselineEntries(
scanResult: ScanResult,
entries: BaselineEntry[],
): ScanResult {
const has = (rule: string, key: string, file?: string): boolean =>
entries.some(
(e) =>
e.rule === rule && e.key === key && (file == null || e.file === file),
);

return {
...scanResult,
missing: scanResult.missing.filter((k) => !has('missing', k)),
unused: scanResult.unused.filter((k) => !has('unused', k)),
logged: scanResult.logged.filter((u) => !has('logged', u.variable, u.file)),
secrets: scanResult.secrets.filter(
(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.exampleWarnings != null && {
exampleWarnings: scanResult.exampleWarnings.filter(
(w) => !has('example-secret', w.key),
),
}),
...(scanResult.frameworkWarnings != null && {
frameworkWarnings: scanResult.frameworkWarnings.filter(
(w) => !has('framework', w.variable, w.file),
),
}),
...(scanResult.uppercaseWarnings != null && {
uppercaseWarnings: scanResult.uppercaseWarnings.filter(
(w) => !has('uppercase', w.key),
),
}),
...(scanResult.expireWarnings != null && {
expireWarnings: scanResult.expireWarnings.filter(
(w) => !has('expire', w.key),
),
}),
...(scanResult.inconsistentNamingWarnings != null && {
inconsistentNamingWarnings: scanResult.inconsistentNamingWarnings.filter(
(w) => {
const pair = [w.key1, w.key2].sort().join('|');
return !has('inconsistent-naming', pair);
},
),
}),
};
}

/**
* SHA-256 fingerprint truncated to 12 hex chars. Stable across runs; used for
* secrets so no secret value is ever committed to the baseline file.
* @param input The string to fingerprint (e.g. `file:snippet` for a secret)
* @returns A 12-character hex string representing the fingerprint
*/
function fingerprint(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
}

function sortEntries(entries: BaselineEntry[]): BaselineEntry[] {
return [...entries].sort((a, b) => {
if (a.rule !== b.rule) return a.rule.localeCompare(b.rule);
const fileA = a.file ?? '';
const fileB = b.file ?? '';
if (fileA !== fileB) return fileA.localeCompare(fileB);
return a.key.localeCompare(b.key);
});
}
4 changes: 4 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,9 @@ export function createProgram() {
'--explain <key>',
'Show where a specific key is defined, used, and its status',
)
.option(
'--baseline',
'Set current codebase state as baseline for future comparisons',
)
);
}
1 change: 1 addition & 0 deletions packages/cli/src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ async function runScanMode(opts: Options): Promise<boolean> {
expireWarnings: opts.expireWarnings,
inconsistentNamingWarnings: opts.inconsistentNamingWarnings,
listAll: opts.listAll,
baseline: opts.baseline,
});

return exitWithError;
Expand Down
Loading