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/drift-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': minor
---

add drift warnings
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ PARTNER_API_TOKEN=

---

## Drift Warnings

A scan compares your code against a single file — so any key you add to `.env` and forget in `.env.example` is invisible until a new contributor clones the repo. Drift warnings catch exactly that:

```text
▸ Drift between .env and .env.example
──────────────────────────────────────────────────────────────────────
STRIPE_SECRET not documented in .env.example
──────────────────────────────────────────────────────────────────────
```

On by default; opt out with `--no-drift-warnings`.

→ See [Drift Warnings](./docs/drift_warnings.md) for more details.

---

## Expiration Warnings

Add expiration metadata to your environment variables to get warnings when they are about to expire. For example, in your `.env` file:
Expand Down
6 changes: 5 additions & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ Only close matches are suggested (small edit distance, scaled to key length), an

With `--comment-warnings`, flags `.env.example` keys that have no documenting comment — either a `#` comment on the line directly above or an inline `#` comment after the value. Off by default. See [`--comment-warnings`](./configuration_and_flags.md#--comment-warnings).

### 14 Health Score
### 14 Env / Example Drift

Flags keys that are set in an env file but missing from the example that documents it — the app runs on your machine, but a new contributor cloning the repo has no way to know the key exists. A scan compares code against a single file, so this is the only check that holds env files and example files up against each other. The env file checked is the one the scan compared against, paired with the example documenting it. On by default; the reverse direction is what [`--compare`](./compare.md) is for. See [Drift Warnings](./drift_warnings.md).

### 15 Health Score

A final score based on scan findings (missing, unused, duplicates, security warnings, and more).
40 changes: 40 additions & 0 deletions docs/configuration_and_flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ CLI flags always take precedence over configuration file values.
- [--inconsistent-naming-warnings](#--inconsistent-naming-warnings)
- [--no-inconsistent-naming-warnings](#--no-inconsistent-naming-warnings)
- [--comment-warnings](#--comment-warnings)
- [--no-drift-warnings](#--no-drift-warnings)
- [--suggest](#--suggest)
- [--no-suggest](#--no-suggest)

Expand Down Expand Up @@ -863,6 +864,45 @@ Usage in the configuration file:

See [Comment Warnings](./comment_warnings.md) for more details.

### `--no-drift-warnings`

Disable drift warnings between your `.env` and `.env.example` (enabled by default).

A scan compares your code against a single file — so without this check the example file is never held up against the values you actually run with. Drift warnings close that gap: any key set in an env file but **missing from the example that documents it** is reported.

```dotenv
# .env
DATABASE_URL=postgres://localhost
STRIPE_SECRET=sk_test_123 # ✗ reported (not in .env.example)

# .env.example
DATABASE_URL=
```

The check is one-directional on purpose: keys documented in the example but not set locally are normal during development, and [`--compare`](#--compare) already reports them. Keys marked [`@optional`](./optional_keys.md) in the env file are skipped.

The env file checked is the one the scan compared against, so use [`--env`](#--env-file) to target another. The exception is a scan that fell through to comparing against `.env.example` itself (no `.env` present): the env file beside it is used, so `.env.local` is still checked.

The pair is resolved with the same suffix convention as `--compare` (`.env.production` → `.env.example.production`, falling back to `.env.example`). Any accepted example name works, so `.env.local` against `.env.sample` pairs up too.

Drift keys are listed in the console output and in JSON (`driftWarnings`, each carrying `envFile` and `exampleFile`). They count toward the [health score](./capabilities.md), can be suppressed with a [baseline](./baseline.md) (`drift` rule), respect [`--ignore`](#--ignore-keys), and cause a non-zero exit under [`--strict`](#--strict).

Example usage:

```bash
dotenv-diff --no-drift-warnings
```

Usage in the configuration file:

```json
{
"driftWarnings": false
}
```

See [Drift Warnings](./drift_warnings.md) for more details.

### `--suggest`

Suggests the closest existing key when a missing variable looks like a typo (enabled by default). Missing entries are annotated with a `→ did you mean DATABAS_URL?` hint by cross-referencing the missing key against the keys that already exist (defined keys in scan mode, extra keys in compare mode). Only close matches are shown, and suggestions never change the exit code or health score.
Expand Down
88 changes: 88 additions & 0 deletions docs/drift_warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Drift Warnings

Drift warnings flag keys that are set in your `.env` but never made it into `.env.example`. That is the classic onboarding bug: the app runs fine on your machine, and a new contributor clones the repo with no way to know the key exists.

On by default.

## Why the scan needs this

A scan compares your code against a **single** file. So in the ordinary repo — `.env` and `.env.example` side by side — the scan reads `.env`, and `.env.example` is never held up against the values you actually run with.

```env
# .env
DATABASE_URL=postgres://localhost
STRIPE_SECRET=sk_test_123 # reported (not in .env.example)

# .env.example
DATABASE_URL=
```

```text
▸ Drift between .env and .env.example
──────────────────────────────────────────────────────────────────────
STRIPE_SECRET not documented in .env.example
──────────────────────────────────────────────────────────────────────
```

## Which env file is checked

The one the scan compared against — the report always concerns the file the run is about. With both `.env` and `.env.local` present the scan reads `.env`, so that is what drift checks; point [`--env`](./configuration_and_flags.md#--env-file) at `.env.local` to check that instead.

The one exception: when no `.env` exists, discovery falls through to comparing against `.env.example` itself. There the env file beside it is used, so a project running on `.env.local` alone is still checked rather than silently skipped.

## File pairing

Files are paired by the same suffix convention as [`--compare`](./compare.md): `.env.production` prefers `.env.example.production` and falls back to `.env.example`.

Any accepted example name works on either side — `.env.example`, `.env-example`, `.env.sample`, `.env.template`, in that priority order — so `.env.local` against a `.env.sample` pairs up fine.

`.env` itself and `.env`-plus-separator names (`.env.local`, `.env-local`) count as env files. `.envrc` does not: direnv's file is a shell script, not a dotenv file.

## Rules

- The check is **one-directional**: only keys present in an env file but absent from its example are reported. Keys documented in the example but not set locally are normal during development, and [`--compare`](./compare.md) already reports them
- Keys marked [`@optional`](./optional_keys.md) in the env file are skipped — the annotation already says the key is not required, so demanding it be documented would contradict it
- The annotation needs no handling on the example side: a key written there is documented by definition, whatever its annotations, so it can never drift
- Nothing is reported when a directory has no example file, or no env file
- Keys excluded by `--ignore` / `--ignore-regex`, and built-in excludes like `NODE_ENV`, are never reported

## Severity

Drift is a **warning**, not a failure: it does not change the exit code on its own. Under [`--strict`](./configuration_and_flags.md#--strict) it exits non-zero like every other warning. Each key costs 2 points of the [health score](./capabilities.md), and drift can be suppressed with a [baseline](./baseline.md) under the `drift` rule.

In JSON output each key appears under `driftWarnings` with the file pair it came from:

```json
{
"driftWarnings": [
{
"key": "STRIPE_SECRET",
"envFile": ".env.local",
"exampleFile": ".env.example"
}
]
}
```

## Enable / disable

On by default. Disable via CLI:

```bash
dotenv-diff --no-drift-warnings
```

Or in `dotenv-diff.config.json`:

```json
{
"driftWarnings": false
}
```

## See also

- [Writing a Good `.env.example`](./env_example_best_practices.md) — keeping the example file worth reading
- [Comment Warnings](./comment_warnings.md) — the other half of a useful `.env.example`: keys that exist but are undocumented
- [Matrix Comparison](./matrix.md) — drift between 3+ environment files, side by side
- [Configuration and Flags](./configuration_and_flags.md#--no-drift-warnings) — full flag reference
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ If you choose not to create a file, `dotenv-diff` will still scan your codebase
| [Expiration Warnings](./expiration_warnings.md) | How `@expire` annotations work and strict mode integration |
| [Optional Keys](./optional_keys.md) | Mark a key `@optional` so it is not reported as missing |
| [Comment Warnings](./comment_warnings.md) | Flag `.env.example` keys that lack a documenting comment |
| [Drift Warnings](./drift_warnings.md) | Flag keys set in `.env` that never made it into `.env.example` |
| [Ignore Comments](./ignore_comments.md) | Suppress false positives with inline/block ignore markers |
| [Monorepo Support](./monorepo_support.md) | Scan shared packages and cross-folder usage in monorepos |
| [Git Hooks and CI/CD](./git_hooks_ci.md) | Integrate dotenv-diff with Husky, lint-staged, and GitHub Actions |
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ PARTNER_API_TOKEN=

---

## Drift Warnings

A scan compares your code against a single file — so any key you add to `.env` and forget in `.env.example` is invisible until a new contributor clones the repo. Drift warnings catch exactly that:

```text
▸ Drift between .env and .env.example
──────────────────────────────────────────────────────────────────────
STRIPE_SECRET not documented in .env.example
──────────────────────────────────────────────────────────────────────
```

On by default; opt out with `--no-drift-warnings`.

→ See [Drift Warnings](https://github.com/Chrilleweb/dotenv-diff/blob/main/docs/drift_warnings.md) for more details.

---

## Expiration Warnings

Add expiration metadata to your environment variables to get warnings when they are about to expire. For example, in your `.env` file:
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/baseline/scanBaseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ export function collectBaselineEntries(
entries.push({ rule: 'comment', key: warning.key });
}

// key + env file: the same key can drift in more than one env file
for (const warning of scanResult.driftWarnings ?? []) {
entries.push({ rule: 'drift', key: warning.key, file: warning.envFile });
}

// 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('|');
Expand Down Expand Up @@ -214,6 +219,11 @@ export function applyBaselineEntries(
(w) => !has('comment', w.key),
),
}),
...(scanResult.driftWarnings != null && {
driftWarnings: scanResult.driftWarnings.filter(
(w) => !has('drift', w.key, w.envFile),
),
}),
};
}

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export function createProgram() {
'--comment-warnings',
'Warn about .env.example keys that lack a documenting # comment',
)
.option(
'--no-drift-warnings',
'Disable drift warnings for keys that are in .env but not in .env.example',
)
.option(
'--suggest',
'Suggest the closest existing key for likely typos (enabled by default)',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ async function runScanMode(opts: Options): Promise<boolean> {
expireWarnings: opts.expireWarnings,
inconsistentNamingWarnings: opts.inconsistentNamingWarnings,
commentWarnings: opts.commentWarnings,
driftWarnings: opts.driftWarnings,
listAll: opts.listAll,
baseline: opts.baseline,
suggest: opts.suggest,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/scanUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ export async function scanUsage(opts: ScanUsageOptions): Promise<ExitResult> {
if (result.commentWarnings) {
scanResult.commentWarnings = result.commentWarnings;
}
if (result.driftWarnings) {
scanResult.driftWarnings = result.driftWarnings;
}
if (
result.exampleFull &&
result.comparedAgainst === DEFAULT_EXAMPLE_FILE
Expand Down Expand Up @@ -208,6 +211,7 @@ function calculateStats(scanResult: ScanResult): void {
(scanResult.expireWarnings?.length ?? 0) +
(scanResult.inconsistentNamingWarnings?.length ?? 0) +
(scanResult.commentWarnings?.length ?? 0) +
(scanResult.driftWarnings?.length ?? 0) +
(scanResult.secrets?.length ?? 0) +
scanResult.missing.length +
scanResult.unused.length +
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ export const DEFAULT_ENV_FILE = '.env';
*/
export const DEFAULT_EXAMPLE_FILE = '.env.example';

/**
* Example/template file names that document required keys, in priority order.
* Earlier entries win when a directory contains more than one.
*/
export const EXAMPLE_FILE_CANDIDATES = [
DEFAULT_EXAMPLE_FILE,
'.env-example',
'.env.sample',
'.env.template',
] as const;

/**
* Name of the git directory used to detect repository root.
*/
Expand Down Expand Up @@ -50,6 +61,18 @@ export const DEFAULT_ENV_CANDIDATES = [
'.env.schema',
] as const;

/**
* Matches a dotenv key line (`KEY=` / `KEY=value`).
*/
export const ENV_KEY_LINE = /^[A-Za-z0-9_.-]+=/;

/**
* Matches an `@expire` annotation line in any of its accepted forms, capturing the date:
* `# @expire YYYY-MM-DD`, `// @expire YYYY-MM-DD`, `# expire YYYY-MM-DD`, or a bare `@expire YYYY-MM-DD`.
*/
export const EXPIRE_ANNOTATION =
/^(?:\/\/|#)?\s*@?expire\s+(\d{4}-\d{2}-\d{2})\s*$/i;

/**
* Patterns to check for in .gitignore when validating env file safety.
* These files should always be git-ignored to prevent committing secrets.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/config/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function normalizeOptions(raw: RawOptions): Options {
const expireWarnings = raw.expireWarnings !== false;
const inconsistentNamingWarnings = raw.inconsistentNamingWarnings !== false;
const commentWarnings = toBool(raw.commentWarnings);
const driftWarnings = raw.driftWarnings !== false;
const suggest = raw.suggest !== false;
const listAll = toBool(raw.listAll);
const explain =
Expand Down Expand Up @@ -107,6 +108,7 @@ export function normalizeOptions(raw: RawOptions): Options {
expireWarnings,
inconsistentNamingWarnings,
commentWarnings,
driftWarnings,
listAll,
explain,
matrix,
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export interface RawOptions {
expireWarnings?: boolean;
inconsistentNamingWarnings?: boolean;
commentWarnings?: boolean;
driftWarnings?: boolean;
listAll?: boolean;
explain?: string;
matrix?: boolean | string[];
Expand Down Expand Up @@ -143,6 +144,7 @@ export interface Options {
expireWarnings: boolean;
inconsistentNamingWarnings: boolean;
commentWarnings: boolean;
driftWarnings: boolean;
listAll: boolean;
explain: string | undefined;
matrix: boolean;
Expand Down Expand Up @@ -243,6 +245,7 @@ export interface ScanUsageOptions extends ScanOptions {
expireWarnings?: boolean;
inconsistentNamingWarnings?: boolean;
commentWarnings?: boolean;
driftWarnings?: boolean;
listAll?: boolean;
baseline?: boolean;
suggest?: boolean;
Expand Down Expand Up @@ -289,6 +292,7 @@ export interface ScanResult {
expireWarnings?: ExpireWarning[];
inconsistentNamingWarnings?: InconsistentNamingWarning[];
commentWarnings?: CommentWarning[];
driftWarnings?: DriftWarning[];
/** Typo suggestions for variables used in code but not defined in the env file */
suggestions?: TypoSuggestion[];
fileContentMap?: Map<string, string>;
Expand Down Expand Up @@ -469,6 +473,21 @@ export interface CommentWarning {
line: number;
}

/**
* Warning about a key that is set in the scanned env file but absent from the
* example file next to it — the two files have drifted apart.
* fx: `.env` gained `STRIPE_SECRET=sk_live_...` but `.env.example` was never updated,
* so a new contributor cloning the repo has no way to know the key exists.
*/
export interface DriftWarning {
/** The key present in the env file but missing from the example file */
key: string;
/** The env file the key is set in (e.g. `.env.local`) */
envFile: string;
/** The example file it is missing from (e.g. `.env.example`) */
exampleFile: string;
}

/**
* A "did you mean" suggestion produced when a reported key looks like a typo
* of an existing key.
Expand Down Expand Up @@ -511,7 +530,8 @@ export type BaselineRule =
| 'uppercase'
| 'expire'
| 'inconsistent-naming'
| 'comment';
| 'comment'
| 'drift';

/**
* A single suppressed warning in the baseline file.
Expand Down
Loading