Skip to content

Commit ec888a7

Browse files
authored
feat: baseline flag (#430)
* feat: baseline flag * chore: test coverage * chore: add changeset * chore(docs): docs for baseline flag * chore: ui for baseline * chore: test coverage * chore: added log to baseline * chore: updated docs * chore: fixed json output for baseline * chore: updated docs * chore: refactor * chore: JSDocs * chore: JSDocs * chore: updated JSDocs
1 parent b1c6768 commit ec888a7

15 files changed

Lines changed: 1377 additions & 0 deletions

File tree

.changeset/metal-houses-pull.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'dotenv-diff': minor
3+
---
4+
5+
add --baseline flag to suppress known issues

docs/baseline.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Baseline Workflow
2+
3+
The `--baseline` flag helps you adopt `dotenv-diff` in projects that already contain known warnings.
4+
5+
It records the current warning state into a baseline file, so future runs can focus on newly introduced issues.
6+
7+
## What `--baseline` does
8+
9+
When you run:
10+
11+
```bash
12+
dotenv-diff --baseline
13+
```
14+
15+
dotenv-diff will:
16+
17+
- scan your codebase as normal
18+
- collect the warnings from the current scan result
19+
- write a `dotenv-diff.baseline.json` file in the working directory
20+
- exit cleanly (`exit code 0`) after writing the file
21+
22+
On later runs (without `--baseline`), dotenv-diff automatically loads this file and suppresses matching warnings.
23+
24+
## Baseline file location
25+
26+
The baseline file is written in your current working directory:
27+
28+
```text
29+
dotenv-diff.baseline.json
30+
```
31+
32+
In monorepos, this means each app/package can keep its own baseline by running dotenv-diff from that folder.
33+
34+
## Supported warning categories
35+
36+
Baseline suppression supports the same categories produced by scan usage checks, including:
37+
38+
- missing variables
39+
- unused variables
40+
- duplicate keys (`.env` / `.env.example`)
41+
- framework warnings
42+
- uppercase key warnings
43+
- inconsistent naming warnings
44+
- expiration warnings
45+
- secret findings (stored as fingerprints)
46+
- `.env.example` secret warnings
47+
- logged variable usages (`console.log` of env variables)
48+
49+
## JSON mode
50+
51+
You can combine baseline with JSON output:
52+
53+
```bash
54+
dotenv-diff --baseline --json
55+
```
56+
57+
Success output includes:
58+
59+
- `file`
60+
- `warningsStored`
61+
62+
If writing fails, the process will exit with an exit code of `1`.
63+
64+
## Recommended workflow
65+
66+
1. Create a baseline once for the current state:
67+
68+
```bash
69+
dotenv-diff --baseline
70+
```
71+
72+
2. Commit `dotenv-diff.baseline.json`.
73+
74+
3. Run dotenv-diff normally in local development and CI.
75+
76+
4. Fix issues incrementally and remove stale baseline entries over time.
77+
78+
5. Recreate the baseline only when you intentionally accept a new known warning set.
79+
80+
## Best practices
81+
82+
- Review baseline changes in pull requests like any other code change.
83+
- Keep the baseline file small by removing entries after fixes.
84+
- Prefer fixing warnings over growing the baseline indefinitely.
85+
- Avoid regenerating baseline automatically in CI; treat it as a reviewed artifact.
86+
87+
## Related docs
88+
89+
- [Configuration and Flags](./configuration_and_flags.md#--baseline)
90+
- [Git Hooks and CI/CD](./git_hooks_ci.md)
91+
- [Monorepo Support](./monorepo_support.md)

docs/configuration_and_flags.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ CLI flags always take precedence over configuration file values.
1515
- [--ignore-regex](#--ignore-regex-patterns)
1616
- [--fix](#--fix)
1717
- [--json](#--json)
18+
- [--baseline](#--baseline)
1819
- [--color](#--color)
1920
- [--no-color](#--no-color)
2021
- [--ci](#--ci)
@@ -255,6 +256,37 @@ Usage in the configuration file:
255256
}
256257
```
257258

259+
### `--baseline`
260+
261+
Save the current warning state as a baseline file and exit cleanly.
262+
263+
When this flag is used, dotenv-diff scans as usual, then writes a `dotenv-diff.baseline.json` file in the current working directory.
264+
Future runs automatically load this file and suppress matching existing warnings, so new issues become easier to spot.
265+
266+
`--baseline` is useful when introducing dotenv-diff to an existing codebase with many known warnings.
267+
268+
Example usage:
269+
270+
```bash
271+
dotenv-diff --baseline
272+
```
273+
274+
Use with JSON output:
275+
276+
```bash
277+
dotenv-diff --baseline --json
278+
```
279+
280+
Usage in the configuration file:
281+
282+
```json
283+
{
284+
"baseline": true
285+
}
286+
```
287+
288+
See [Baseline Workflow](./baseline.md) for recommendations on when to create, refresh, and review baseline entries.
289+
258290
### `--color`
259291

260292
Enables colored output in the terminal (enabled by default).

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ If you choose not to create a file, `dotenv-diff` will still scan your codebase
4343
|---|---|
4444
| [Capabilities](./capabilities.md) | What the scanner checks for and how it works |and rules |
4545
| [Configuration and Flags](./configuration_and_flags.md) | Full CLI/config reference for options and behavior |
46+
| [Baseline Workflow](./baseline.md) | Set a warning baseline and suppress already-known findings safely |
4647
| [Comparing Files](./compare.md) | How to compare two `.env` files to detect differences |
4748
| [Expiration Warnings](./expiration_warnings.md) | How `@expire` annotations work and strict mode integration |
4849
| [Ignore Comments](./ignore_comments.md) | Suppress false positives with inline/block ignore markers |
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import crypto from 'crypto';
2+
import fs from 'fs';
3+
import type {
4+
BaselineEntry,
5+
BaselineFile,
6+
ScanResult,
7+
} from '../config/types.js';
8+
import { resolveFromCwd } from '../core/helpers/resolveFromCwd.js';
9+
10+
export const BASELINE_FILE = 'dotenv-diff.baseline.json';
11+
const BASELINE_VERSION = 1;
12+
13+
/**
14+
* Loads the baseline file from disk. Returns null if the file does not exist
15+
* or cannot be parsed into a valid shape.
16+
* @param cwd - Current working directory to resolve the baseline file from
17+
* @returns The parsed baseline file or null if not found/invalid
18+
*/
19+
export function loadBaselineFile(cwd: string): BaselineFile | null {
20+
const filePath = resolveFromCwd(cwd, BASELINE_FILE);
21+
if (!fs.existsSync(filePath)) return null;
22+
try {
23+
const raw = fs.readFileSync(filePath, 'utf8');
24+
const parsed = JSON.parse(raw) as unknown;
25+
if (
26+
typeof parsed === 'object' &&
27+
parsed !== null &&
28+
'version' in parsed &&
29+
Array.isArray((parsed as { entries?: unknown }).entries)
30+
) {
31+
return parsed as BaselineFile;
32+
}
33+
return null;
34+
} catch {
35+
return null;
36+
}
37+
}
38+
39+
/**
40+
* Writes a baseline file to disk and returns the absolute path it was written to.
41+
* @param cwd - Current working directory to resolve the baseline file from
42+
* @param entries - List of baseline entries to write
43+
* @returns Absolute file path of the written baseline file
44+
* @throws If writing the file fails (e.g. due to permissions or disk issues)
45+
*/
46+
export async function writeBaselineFile(
47+
cwd: string,
48+
entries: BaselineEntry[],
49+
): Promise<string> {
50+
const filePath = resolveFromCwd(cwd, BASELINE_FILE);
51+
const payload: BaselineFile = {
52+
version: BASELINE_VERSION,
53+
createdAt: new Date().toISOString(),
54+
entries,
55+
};
56+
await fs.promises.writeFile(
57+
filePath,
58+
`${JSON.stringify(payload, null, 2)}\n`,
59+
'utf8',
60+
);
61+
return filePath;
62+
}
63+
64+
/**
65+
* Converts a ScanResult into a deterministic list of baseline entries.
66+
*
67+
* Identifiers are chosen to be stable across runs — volatile fields like line
68+
* numbers and snippet text are excluded. Secrets are stored as a fingerprint
69+
* (SHA-256 truncated to 12 hex chars) of `file:snippet` so no secret value is
70+
* ever written to the baseline file.
71+
* @param scanResult The full scan result to convert into baseline entries
72+
* @returns A sorted list of baseline entries representing the scan result
73+
*/
74+
export function collectBaselineEntries(
75+
scanResult: ScanResult,
76+
): BaselineEntry[] {
77+
const entries: BaselineEntry[] = [];
78+
79+
for (const key of scanResult.missing) {
80+
entries.push({ rule: 'missing', key });
81+
}
82+
83+
for (const key of scanResult.unused) {
84+
entries.push({ rule: 'unused', key });
85+
}
86+
87+
for (const usage of scanResult.logged) {
88+
entries.push({
89+
rule: 'logged',
90+
key: usage.variable,
91+
file: usage.file,
92+
});
93+
}
94+
95+
for (const secret of scanResult.secrets) {
96+
entries.push({
97+
rule: 'secret',
98+
key: fingerprint(`${secret.file}:${secret.snippet}`),
99+
file: secret.file,
100+
});
101+
}
102+
103+
for (const warning of scanResult.exampleWarnings ?? []) {
104+
entries.push({ rule: 'example-secret', key: warning.key });
105+
}
106+
107+
for (const dup of scanResult.duplicates.env ?? []) {
108+
entries.push({ rule: 'duplicate-env', key: dup.key });
109+
}
110+
111+
for (const dup of scanResult.duplicates.example ?? []) {
112+
entries.push({ rule: 'duplicate-example', key: dup.key });
113+
}
114+
115+
// variable + file uniquely identifies a framework warning without line numbers
116+
for (const warning of scanResult.frameworkWarnings ?? []) {
117+
entries.push({
118+
rule: 'framework',
119+
key: warning.variable,
120+
file: warning.file,
121+
});
122+
}
123+
124+
for (const warning of scanResult.uppercaseWarnings ?? []) {
125+
entries.push({ rule: 'uppercase', key: warning.key });
126+
}
127+
128+
for (const warning of scanResult.expireWarnings ?? []) {
129+
entries.push({ rule: 'expire', key: warning.key });
130+
}
131+
132+
// Sort the key pair so the entry is identical regardless of scanner order
133+
for (const warning of scanResult.inconsistentNamingWarnings ?? []) {
134+
const pair = [warning.key1, warning.key2].sort().join('|');
135+
entries.push({ rule: 'inconsistent-naming', key: pair });
136+
}
137+
138+
return sortEntries(entries);
139+
}
140+
141+
/**
142+
* Returns a new ScanResult with every warning that is covered by a baseline
143+
* entry removed. The matching logic is the mirror image of
144+
* {@link collectBaselineEntries} so every entry written suppresses the
145+
* correct warning.
146+
* @param scanResult The full scan result to apply baseline filtering to
147+
* @param entries The list of baseline entries to apply
148+
* @returns A new ScanResult with baseline-covered warnings removed
149+
*/
150+
export function applyBaselineEntries(
151+
scanResult: ScanResult,
152+
entries: BaselineEntry[],
153+
): ScanResult {
154+
const has = (rule: string, key: string, file?: string): boolean =>
155+
entries.some(
156+
(e) =>
157+
e.rule === rule && e.key === key && (file == null || e.file === file),
158+
);
159+
160+
return {
161+
...scanResult,
162+
missing: scanResult.missing.filter((k) => !has('missing', k)),
163+
unused: scanResult.unused.filter((k) => !has('unused', k)),
164+
logged: scanResult.logged.filter((u) => !has('logged', u.variable, u.file)),
165+
secrets: scanResult.secrets.filter(
166+
(s) => !has('secret', fingerprint(`${s.file}:${s.snippet}`)),
167+
),
168+
duplicates: {
169+
...(scanResult.duplicates.env != null && {
170+
env: scanResult.duplicates.env.filter(
171+
(d) => !has('duplicate-env', d.key),
172+
),
173+
}),
174+
...(scanResult.duplicates.example != null && {
175+
example: scanResult.duplicates.example.filter(
176+
(d) => !has('duplicate-example', d.key),
177+
),
178+
}),
179+
},
180+
...(scanResult.exampleWarnings != null && {
181+
exampleWarnings: scanResult.exampleWarnings.filter(
182+
(w) => !has('example-secret', w.key),
183+
),
184+
}),
185+
...(scanResult.frameworkWarnings != null && {
186+
frameworkWarnings: scanResult.frameworkWarnings.filter(
187+
(w) => !has('framework', w.variable, w.file),
188+
),
189+
}),
190+
...(scanResult.uppercaseWarnings != null && {
191+
uppercaseWarnings: scanResult.uppercaseWarnings.filter(
192+
(w) => !has('uppercase', w.key),
193+
),
194+
}),
195+
...(scanResult.expireWarnings != null && {
196+
expireWarnings: scanResult.expireWarnings.filter(
197+
(w) => !has('expire', w.key),
198+
),
199+
}),
200+
...(scanResult.inconsistentNamingWarnings != null && {
201+
inconsistentNamingWarnings: scanResult.inconsistentNamingWarnings.filter(
202+
(w) => {
203+
const pair = [w.key1, w.key2].sort().join('|');
204+
return !has('inconsistent-naming', pair);
205+
},
206+
),
207+
}),
208+
};
209+
}
210+
211+
/**
212+
* SHA-256 fingerprint truncated to 12 hex chars. Stable across runs; used for
213+
* secrets so no secret value is ever committed to the baseline file.
214+
* @param input The string to fingerprint (e.g. `file:snippet` for a secret)
215+
* @returns A 12-character hex string representing the fingerprint
216+
*/
217+
function fingerprint(input: string): string {
218+
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
219+
}
220+
221+
function sortEntries(entries: BaselineEntry[]): BaselineEntry[] {
222+
return [...entries].sort((a, b) => {
223+
if (a.rule !== b.rule) return a.rule.localeCompare(b.rule);
224+
const fileA = a.file ?? '';
225+
const fileB = b.file ?? '';
226+
if (fileA !== fileB) return fileA.localeCompare(fileB);
227+
return a.key.localeCompare(b.key);
228+
});
229+
}

packages/cli/src/cli/program.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,9 @@ export function createProgram() {
100100
'--explain <key>',
101101
'Show where a specific key is defined, used, and its status',
102102
)
103+
.option(
104+
'--baseline',
105+
'Set current codebase state as baseline for future comparisons',
106+
)
103107
);
104108
}

packages/cli/src/cli/run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ async function runScanMode(opts: Options): Promise<boolean> {
107107
expireWarnings: opts.expireWarnings,
108108
inconsistentNamingWarnings: opts.inconsistentNamingWarnings,
109109
listAll: opts.listAll,
110+
baseline: opts.baseline,
110111
});
111112

112113
return exitWithError;

0 commit comments

Comments
 (0)