From 48f42d9f6ca7206103ca289d160639d22a07559c Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Tue, 18 Aug 2026 10:09:33 +0200 Subject: [PATCH 1/6] chore: getSuffix func --- packages/cli/src/services/envDiscovery.ts | 15 +++++++++++---- .../test/unit/services/envDiscovery.test.ts | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/services/envDiscovery.ts b/packages/cli/src/services/envDiscovery.ts index f2ab311d..26a99fba 100644 --- a/packages/cli/src/services/envDiscovery.ts +++ b/packages/cli/src/services/envDiscovery.ts @@ -56,10 +56,7 @@ export function discoverEnvFiles({ } // try to find a matching example name based on the suffix (basename only for suffix derivation) - const suffix = - envBaseName === DEFAULT_ENV_FILE - ? '' - : envBaseName.replace(DEFAULT_ENV_FILE, ''); + const suffix = getSuffix(envBaseName, DEFAULT_ENV_FILE); const potentialExample = suffix ? `${DEFAULT_EXAMPLE_FILE}${suffix}` : DEFAULT_EXAMPLE_FILE; @@ -107,3 +104,13 @@ export function discoverEnvFiles({ alreadyWarnedMissingEnv, }; } + +/** + * Returns the suffix of the filename after the specified prefix. + * @param filename - The filename to extract the suffix from. + * @param prefix - The prefix to remove from the filename. + * @returns The suffix of the filename after the prefix, or an empty string. + */ +export function getSuffix(filename: string, prefix: string): string { + return filename.startsWith(prefix) ? filename.slice(prefix.length) : ''; +} diff --git a/packages/cli/test/unit/services/envDiscovery.test.ts b/packages/cli/test/unit/services/envDiscovery.test.ts index 8dfd2864..5e709ca2 100644 --- a/packages/cli/test/unit/services/envDiscovery.test.ts +++ b/packages/cli/test/unit/services/envDiscovery.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import { discoverEnvFiles } from '../../../src/services/envDiscovery.js'; +import { + discoverEnvFiles, + getSuffix, +} from '../../../src/services/envDiscovery.js'; describe('discoverEnvFiles', () => { let cwd: string; @@ -243,4 +246,18 @@ describe('discoverEnvFiles', () => { expect(result.primaryEnv).toBe(envPath); expect(result.envFiles[0]).toBe(envPath); }); + + it('Should return the correct suffix for a filename with a prefix', () => { + const filename = '.env.prod'; + const prefix = '.env'; + const suffix = getSuffix(filename, prefix); + expect(suffix).toBe('.prod'); + }); + + it('Should return an empty string for a filename without the prefix', () => { + const filename = 'config.env'; + const prefix = '.env'; + const suffix = getSuffix(filename, prefix); + expect(suffix).toBe(''); + }); }); From ce4e290bb5f574543d633f06ab255846e6da874b Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Tue, 18 Aug 2026 10:49:07 +0200 Subject: [PATCH 2/6] chore: suffix --- packages/cli/src/services/envDiscovery.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/services/envDiscovery.ts b/packages/cli/src/services/envDiscovery.ts index 26a99fba..3b6e870a 100644 --- a/packages/cli/src/services/envDiscovery.ts +++ b/packages/cli/src/services/envDiscovery.ts @@ -29,7 +29,7 @@ export function discoverEnvFiles({ exampleFlag, }: DiscoverEnvFilesArgs): Discovery { // Find all .env* files in the current directory except .env.example* - const envFiles = fs + let envFiles = fs .readdirSync(cwd) .filter( (f) => @@ -50,9 +50,7 @@ export function discoverEnvFiles({ // If the specified --env actually exists, make sure it's in the list (first) without duplicates if (fs.existsSync(envFlag)) { - const set = new Set([envFlag, ...envFiles]); - envFiles.length = 0; - envFiles.push(...Array.from(set)); + envFiles = [...new Set([envFlag, ...envFiles])]; } // try to find a matching example name based on the suffix (basename only for suffix derivation) @@ -71,7 +69,7 @@ export function discoverEnvFiles({ primaryExample = exampleFlag; if (exampleNameFromFlag.startsWith(DEFAULT_EXAMPLE_FILE)) { - const suffix = exampleNameFromFlag.slice(DEFAULT_EXAMPLE_FILE.length); + const suffix = getSuffix(exampleNameFromFlag, DEFAULT_EXAMPLE_FILE); const matchedEnv = `${DEFAULT_ENV_FILE}${suffix}`; if (fs.existsSync(path.resolve(cwd, matchedEnv))) { From acffcce71f7c65cbb4ca46a0ee10589b809cc162 Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Tue, 18 Aug 2026 12:37:57 +0200 Subject: [PATCH 3/6] chore: env key line constant --- packages/cli/src/config/constants.ts | 5 +++++ packages/cli/src/services/detectEnvExpirations.ts | 3 ++- packages/cli/src/services/detectOptionalKeys.ts | 6 ++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/config/constants.ts b/packages/cli/src/config/constants.ts index 52f296c6..14dc5fd3 100644 --- a/packages/cli/src/config/constants.ts +++ b/packages/cli/src/config/constants.ts @@ -50,6 +50,11 @@ 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_.-]+=/; + /** * Patterns to check for in .gitignore when validating env file safety. * These files should always be git-ignored to prevent committing secrets. diff --git a/packages/cli/src/services/detectEnvExpirations.ts b/packages/cli/src/services/detectEnvExpirations.ts index 16065694..42d4da15 100644 --- a/packages/cli/src/services/detectEnvExpirations.ts +++ b/packages/cli/src/services/detectEnvExpirations.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import type { ExpireWarning } from '../config/types.js'; +import { ENV_KEY_LINE } from '../config/constants.js'; // Number of milliseconds in a day const MS_PER_DAY = 1000 * 60 * 60 * 24; @@ -43,7 +44,7 @@ export function detectEnvExpirations(filePath: string): ExpireWarning[] { continue; } - const isEnvKey = /^[A-Za-z0-9_.-]+=/.test(line); + const isEnvKey = ENV_KEY_LINE.test(line); if (isEnvKey) { const key = line.split('=')[0]; diff --git a/packages/cli/src/services/detectOptionalKeys.ts b/packages/cli/src/services/detectOptionalKeys.ts index 1a4eb228..fcdfbaae 100644 --- a/packages/cli/src/services/detectOptionalKeys.ts +++ b/packages/cli/src/services/detectOptionalKeys.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import { splitEnvLines } from '../core/envLine.js'; +import { ENV_KEY_LINE } from '../config/constants.js'; /** * Matches an `@optional` annotation line in any of its accepted forms: @@ -8,9 +9,6 @@ import { splitEnvLines } from '../core/envLine.js'; */ const OPTIONAL_ANNOTATION = /^(?:\/\/|#)?\s*@?optional\s*$/i; -/** Matches a dotenv key line (`KEY=` / `KEY=value`). */ -const ENV_KEY_LINE = /^[A-Za-z0-9_.-]+=/; - /** * Detects keys marked `@optional` in a dotenv file. * @@ -34,7 +32,7 @@ const ENV_KEY_LINE = /^[A-Za-z0-9_.-]+=/; * annotations every key stays required, so a read error can only make reporting * stricter, never let a real problem through. * @param filePath - Path to the dotenv file (normally `.env.example`) - * @returns The keys marked optional + * @returns Array of the keys marked optional */ export function detectOptionalKeys(filePath: string): string[] { let content: string; From d1de04269277a12e058b93d7edc5fe383136360e Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Tue, 18 Aug 2026 12:49:01 +0200 Subject: [PATCH 4/6] chore: expire annotation --- packages/cli/src/services/detectEnvExpirations.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/services/detectEnvExpirations.ts b/packages/cli/src/services/detectEnvExpirations.ts index 42d4da15..7715396c 100644 --- a/packages/cli/src/services/detectEnvExpirations.ts +++ b/packages/cli/src/services/detectEnvExpirations.ts @@ -2,6 +2,12 @@ import fs from 'fs'; import type { ExpireWarning } from '../config/types.js'; import { ENV_KEY_LINE } from '../config/constants.js'; +/** + * Matches an `@expire` annotation line in any of its accepted forms: + * `# @expire YYYY-MM-DD`, `// @expire YYYY-MM-DD`, `# expire YYYY-MM-DD`, or a bare `@expire YYYY-MM-DD`. + */ +const EXPIRE_ANNOTATION = /^(?:\/\/|#)?\s*@?expire\s+(\d{4}-\d{2}-\d{2})/i; + // Number of milliseconds in a day const MS_PER_DAY = 1000 * 60 * 60 * 24; @@ -21,8 +27,6 @@ export function detectEnvExpirations(filePath: string): ExpireWarning[] { const warnings: ExpireWarning[] = []; - const reg = /(\/\/|#)?\s*@?expire\s+(\d{4}-\d{2}-\d{2})/i; - let pendingExpire: string | null = null; for (const raw of lines) { @@ -37,10 +41,10 @@ export function detectEnvExpirations(filePath: string): ExpireWarning[] { continue; } - const expireMatch = line.match(reg); + const expireMatch = line.match(EXPIRE_ANNOTATION); if (expireMatch) { - pendingExpire = expireMatch[2]!; // capture date + pendingExpire = expireMatch[1]!; // capture date continue; } From 34ce77219f9f5a0709de0f8fd15f6bb2e4a413dd Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Tue, 18 Aug 2026 13:22:39 +0200 Subject: [PATCH 5/6] chore: expire annotation --- packages/cli/src/config/constants.ts | 7 +++++++ packages/cli/src/services/detectEnvExpirations.ts | 8 +------- packages/cli/src/services/detectMissingComments.ts | 13 ++----------- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/config/constants.ts b/packages/cli/src/config/constants.ts index 14dc5fd3..0b2d484a 100644 --- a/packages/cli/src/config/constants.ts +++ b/packages/cli/src/config/constants.ts @@ -55,6 +55,13 @@ export const DEFAULT_ENV_CANDIDATES = [ */ 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. diff --git a/packages/cli/src/services/detectEnvExpirations.ts b/packages/cli/src/services/detectEnvExpirations.ts index 7715396c..2f3b7f4c 100644 --- a/packages/cli/src/services/detectEnvExpirations.ts +++ b/packages/cli/src/services/detectEnvExpirations.ts @@ -1,12 +1,6 @@ import fs from 'fs'; import type { ExpireWarning } from '../config/types.js'; -import { ENV_KEY_LINE } from '../config/constants.js'; - -/** - * Matches an `@expire` annotation line in any of its accepted forms: - * `# @expire YYYY-MM-DD`, `// @expire YYYY-MM-DD`, `# expire YYYY-MM-DD`, or a bare `@expire YYYY-MM-DD`. - */ -const EXPIRE_ANNOTATION = /^(?:\/\/|#)?\s*@?expire\s+(\d{4}-\d{2}-\d{2})/i; +import { ENV_KEY_LINE, EXPIRE_ANNOTATION } from '../config/constants.js'; // Number of milliseconds in a day const MS_PER_DAY = 1000 * 60 * 60 * 24; diff --git a/packages/cli/src/services/detectMissingComments.ts b/packages/cli/src/services/detectMissingComments.ts index b8ff3d59..3ffb8a86 100644 --- a/packages/cli/src/services/detectMissingComments.ts +++ b/packages/cli/src/services/detectMissingComments.ts @@ -1,16 +1,7 @@ import fs from 'fs'; import type { CommentWarning } from '../config/types.js'; import { splitEnvLines, parseEnvLine } from '../core/envLine.js'; - -/** - * A line that is *only* an `@expire` annotation, in any of its accepted forms: - * `# @expire 2025-12-12`, `// @expire 2025-12-12`, `# expire 2025-12-12`, or a - * bare `@expire 2025-12-12`. This is a machine annotation, not human - * documentation, so it never satisfies the "documented" rule on its own — but - * it is transparent: a real comment sitting above it still documents the key. - */ -const BARE_EXPIRE_ANNOTATION = - /^(?:\/\/|#)?\s*@?expire\s+\d{4}-\d{2}-\d{2}\s*$/i; +import { EXPIRE_ANNOTATION } from '../config/constants.js'; /** * Detects `.env.example` keys that lack a documenting comment. @@ -58,7 +49,7 @@ export function detectMissingComments(filePath: string): CommentWarning[] { for (let j = i - 1; j >= 0; j--) { const above = lines[j]!.trim(); if (above === '') break; // blank line ends the block - if (BARE_EXPIRE_ANNOTATION.test(above)) continue; // transparent annotation + if (EXPIRE_ANNOTATION.test(above)) continue; // transparent annotation if (above.startsWith('#')) { hasCommentAbove = true; // a real, prose comment } From 8933b0c02782be4191c62d253bf53e0402da915e Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Wed, 19 Aug 2026 15:55:27 +0200 Subject: [PATCH 6/6] feat: env drift --- .changeset/drift-warnings.md | 5 + README.md | 17 ++ docs/capabilities.md | 6 +- docs/configuration_and_flags.md | 40 +++ docs/drift_warnings.md | 88 ++++++ docs/index.md | 1 + packages/cli/README.md | 17 ++ packages/cli/src/baseline/scanBaseline.ts | 10 + packages/cli/src/cli/program.ts | 4 + packages/cli/src/cli/run.ts | 1 + packages/cli/src/commands/scanUsage.ts | 4 + packages/cli/src/config/constants.ts | 11 + packages/cli/src/config/options.ts | 2 + packages/cli/src/config/types.ts | 22 +- .../cli/src/core/helpers/isExampleFile.ts | 31 ++ .../cli/src/core/scan/computeExitDecision.ts | 1 + .../cli/src/core/scan/computeHealthScore.ts | 3 + .../cli/src/services/detectExampleDrift.ts | 171 +++++++++++ packages/cli/src/services/exampleDiscovery.ts | 9 +- packages/cli/src/services/printScanResult.ts | 5 + .../cli/src/services/processComparisonFile.ts | 17 ++ .../cli/src/ui/scan/printDriftWarnings.ts | 38 +++ packages/cli/src/ui/scan/scanJsonOutput.ts | 11 + .../test/e2e/cli.driftWarnings.e2e.test.ts | 218 ++++++++++++++ .../test/unit/baseline/scanBaseline.test.ts | 52 ++++ packages/cli/test/unit/cli/run.test.ts | 1 + .../cli/test/unit/commands/scanUsage.test.ts | 34 +++ .../unit/core/helpers/isExampleFile.test.ts | 42 +++ .../unit/services/detectExampleDrift.test.ts | 281 ++++++++++++++++++ .../unit/services/printScanResult.test.ts | 20 ++ .../services/processComparisonFile.test.ts | 31 ++ .../unit/ui/scan/printDriftWarnings.test.ts | 68 +++++ .../test/unit/ui/scan/scanJsonOutput.test.ts | 28 ++ 33 files changed, 1280 insertions(+), 9 deletions(-) create mode 100644 .changeset/drift-warnings.md create mode 100644 docs/drift_warnings.md create mode 100644 packages/cli/src/core/helpers/isExampleFile.ts create mode 100644 packages/cli/src/services/detectExampleDrift.ts create mode 100644 packages/cli/src/ui/scan/printDriftWarnings.ts create mode 100644 packages/cli/test/e2e/cli.driftWarnings.e2e.test.ts create mode 100644 packages/cli/test/unit/core/helpers/isExampleFile.test.ts create mode 100644 packages/cli/test/unit/services/detectExampleDrift.test.ts create mode 100644 packages/cli/test/unit/ui/scan/printDriftWarnings.test.ts diff --git a/.changeset/drift-warnings.md b/.changeset/drift-warnings.md new file mode 100644 index 00000000..03d8c9f8 --- /dev/null +++ b/.changeset/drift-warnings.md @@ -0,0 +1,5 @@ +--- +'dotenv-diff': minor +--- + +add drift warnings diff --git a/README.md b/README.md index 52a7003c..240f7061 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/capabilities.md b/docs/capabilities.md index 7987007f..7bf83944 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -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). diff --git a/docs/configuration_and_flags.md b/docs/configuration_and_flags.md index b36cd6f2..5de5b835 100644 --- a/docs/configuration_and_flags.md +++ b/docs/configuration_and_flags.md @@ -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) @@ -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. diff --git a/docs/drift_warnings.md b/docs/drift_warnings.md new file mode 100644 index 00000000..de41f282 --- /dev/null +++ b/docs/drift_warnings.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 54a22997..3aabcdd7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 | diff --git a/packages/cli/README.md b/packages/cli/README.md index 9d5b21db..651df287 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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: diff --git a/packages/cli/src/baseline/scanBaseline.ts b/packages/cli/src/baseline/scanBaseline.ts index 42551624..5eed1b5f 100644 --- a/packages/cli/src/baseline/scanBaseline.ts +++ b/packages/cli/src/baseline/scanBaseline.ts @@ -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('|'); @@ -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), + ), + }), }; } diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 51e33f64..e3134b76 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -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)', diff --git a/packages/cli/src/cli/run.ts b/packages/cli/src/cli/run.ts index 015b5a60..ad47c069 100644 --- a/packages/cli/src/cli/run.ts +++ b/packages/cli/src/cli/run.ts @@ -122,6 +122,7 @@ async function runScanMode(opts: Options): Promise { expireWarnings: opts.expireWarnings, inconsistentNamingWarnings: opts.inconsistentNamingWarnings, commentWarnings: opts.commentWarnings, + driftWarnings: opts.driftWarnings, listAll: opts.listAll, baseline: opts.baseline, suggest: opts.suggest, diff --git a/packages/cli/src/commands/scanUsage.ts b/packages/cli/src/commands/scanUsage.ts index 71455d4b..e46318bb 100644 --- a/packages/cli/src/commands/scanUsage.ts +++ b/packages/cli/src/commands/scanUsage.ts @@ -128,6 +128,9 @@ export async function scanUsage(opts: ScanUsageOptions): Promise { if (result.commentWarnings) { scanResult.commentWarnings = result.commentWarnings; } + if (result.driftWarnings) { + scanResult.driftWarnings = result.driftWarnings; + } if ( result.exampleFull && result.comparedAgainst === DEFAULT_EXAMPLE_FILE @@ -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 + diff --git a/packages/cli/src/config/constants.ts b/packages/cli/src/config/constants.ts index 0b2d484a..6c0ba44a 100644 --- a/packages/cli/src/config/constants.ts +++ b/packages/cli/src/config/constants.ts @@ -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. */ diff --git a/packages/cli/src/config/options.ts b/packages/cli/src/config/options.ts index f8f748a1..c837b2ce 100644 --- a/packages/cli/src/config/options.ts +++ b/packages/cli/src/config/options.ts @@ -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 = @@ -107,6 +108,7 @@ export function normalizeOptions(raw: RawOptions): Options { expireWarnings, inconsistentNamingWarnings, commentWarnings, + driftWarnings, listAll, explain, matrix, diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 863576ed..4b41c4e9 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -95,6 +95,7 @@ export interface RawOptions { expireWarnings?: boolean; inconsistentNamingWarnings?: boolean; commentWarnings?: boolean; + driftWarnings?: boolean; listAll?: boolean; explain?: string; matrix?: boolean | string[]; @@ -143,6 +144,7 @@ export interface Options { expireWarnings: boolean; inconsistentNamingWarnings: boolean; commentWarnings: boolean; + driftWarnings: boolean; listAll: boolean; explain: string | undefined; matrix: boolean; @@ -243,6 +245,7 @@ export interface ScanUsageOptions extends ScanOptions { expireWarnings?: boolean; inconsistentNamingWarnings?: boolean; commentWarnings?: boolean; + driftWarnings?: boolean; listAll?: boolean; baseline?: boolean; suggest?: boolean; @@ -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; @@ -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. @@ -511,7 +530,8 @@ export type BaselineRule = | 'uppercase' | 'expire' | 'inconsistent-naming' - | 'comment'; + | 'comment' + | 'drift'; /** * A single suppressed warning in the baseline file. diff --git a/packages/cli/src/core/helpers/isExampleFile.ts b/packages/cli/src/core/helpers/isExampleFile.ts new file mode 100644 index 00000000..745c54bd --- /dev/null +++ b/packages/cli/src/core/helpers/isExampleFile.ts @@ -0,0 +1,31 @@ +import { EXAMPLE_FILE_CANDIDATES } from '../../config/constants.js'; + +/** + * Options for {@link isExampleFile}. + */ +interface IsExampleFileOptions { + /** + * Also accept environment-suffixed variants (`.env.example.production`). + * Off by default, so only the bare documentation files match. + */ + withSuffix?: boolean; +} + +/** + * Reports whether a filename is an example/template file — one that documents + * which keys are required rather than holding real values. + * @param fileName - A file basename (e.g. `.env.sample`), matched case-insensitively. + * @param options - Whether environment-suffixed variants count. + * @returns True when the name is an example file. + */ +export function isExampleFile( + fileName: string, + { withSuffix = false }: IsExampleFileOptions = {}, +): boolean { + const lower = fileName.toLowerCase(); + + return EXAMPLE_FILE_CANDIDATES.some( + (candidate) => + lower === candidate || (withSuffix && lower.startsWith(`${candidate}.`)), + ); +} diff --git a/packages/cli/src/core/scan/computeExitDecision.ts b/packages/cli/src/core/scan/computeExitDecision.ts index a0218832..31341ad7 100644 --- a/packages/cli/src/core/scan/computeExitDecision.ts +++ b/packages/cli/src/core/scan/computeExitDecision.ts @@ -82,6 +82,7 @@ function hasStrictViolation( ) || (scan.inconsistentNamingWarnings?.length ?? 0) > 0 || (scan.commentWarnings?.length ?? 0) > 0 || + (scan.driftWarnings?.length ?? 0) > 0 || !!hasGitignoreIssue ); } diff --git a/packages/cli/src/core/scan/computeHealthScore.ts b/packages/cli/src/core/scan/computeHealthScore.ts index 130f406c..9a14f532 100644 --- a/packages/cli/src/core/scan/computeHealthScore.ts +++ b/packages/cli/src/core/scan/computeHealthScore.ts @@ -42,6 +42,9 @@ export function computeHealthScore(scan: ScanResult): number { // === 9b. Undocumented example keys (advisory) === score -= (scan.commentWarnings?.length ?? 0) * 2; + // === 9c. Keys missing from the example file (advisory) === + score -= (scan.driftWarnings?.length ?? 0) * 2; + // === 10. Duplicate definitions === score -= (scan.duplicates?.env?.length ?? 0) * 10; score -= (scan.duplicates?.example?.length ?? 0) * 10; diff --git a/packages/cli/src/services/detectExampleDrift.ts b/packages/cli/src/services/detectExampleDrift.ts new file mode 100644 index 00000000..56da1946 --- /dev/null +++ b/packages/cli/src/services/detectExampleDrift.ts @@ -0,0 +1,171 @@ +import fs from 'fs'; +import path from 'path'; +import type { DriftWarning } from '../config/types.js'; +import { parseEnvFile } from './parseEnvFile.js'; +import { detectOptionalKeys } from './detectOptionalKeys.js'; +import { getSuffix } from './envDiscovery.js'; +import { filterIgnoredKeys } from '../core/helpers/filterIgnoredKeys.js'; +import { isExampleFile } from '../core/helpers/isExampleFile.js'; +import { compareCodePoint } from '../core/helpers/compareCodePoint.js'; +import { + DEFAULT_ENV_FILE, + EXAMPLE_FILE_CANDIDATES, +} from '../config/constants.js'; + +/** + * Arguments for {@link detectExampleDrift}. + */ +interface DetectExampleDriftArgs { + /** Absolute path of the file the scan compared against. */ + comparisonPath: string; + /** Keys to ignore, from `--ignore`. */ + ignore: string[]; + /** Regex patterns of keys to ignore, from `--ignore-regex`. */ + ignoreRegex: RegExp[]; +} + +/** + * Detects keys that are set in an env file but missing from the example file + * that documents it. + * + * A scan compares code usage against a single file, so the example file is never + * held up against the values a developer actually runs with: a key added to + * `.env` and forgotten in `.env.example` goes unnoticed until a new contributor + * clones the repo and cannot start the app. This closes that gap. + * + * Exactly one env file is checked: the one the scan compared against, so the + * report always concerns the file the run is about. The exception is a scan that + * compared against an example file — with no `.env` present, discovery falls + * through to `.env.example` itself — where the env file beside it is used + * instead, so `.env.local` is still checked rather than silently skipped. + * + * The check is one-directional on purpose: only keys present in an env file but + * absent from its example are reported. The reverse (documented but not set + * locally) is already what `--compare` is for, and is expected during normal + * development. + * + * Keys marked `@optional` 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. + * + * Files are paired by the same suffix convention as `--compare`: `.env.local` + * prefers `.env.example.local` and falls back to `.env.example`. Any accepted + * example name works, so `.env.local` against `.env.sample` pairs up too. + * @param args - The file the scan compared against and the ignore config. + * @returns One warning per undocumented key, in the order they appear in the env file. + */ +export function detectExampleDrift({ + comparisonPath, + ignore, + ignoreRegex, +}: DetectExampleDriftArgs): DriftWarning[] { + const dir = path.dirname(comparisonPath); + + const envFile = resolveEnvFile(dir, path.basename(comparisonPath)); + if (!envFile) return []; + + const examplePath = resolveExampleFile(dir, envFile); + if (!examplePath) return []; + + const envPath = path.join(dir, envFile); + const optionalKeys = new Set(detectOptionalKeys(envPath)); + const envKeys = filterIgnoredKeys( + Object.keys(parseEnvFile(envPath)), + ignore, + ignoreRegex, + ); + const exampleKeys = new Set(Object.keys(parseEnvFile(examplePath))); + const exampleFile = path.basename(examplePath); + + return envKeys + .filter((key) => !exampleKeys.has(key) && !optionalKeys.has(key)) + .map((key) => ({ key, envFile, exampleFile })); +} + +/** + * Picks the env file to check drift for. + * + * Normally that is the file the scan compared against. When the scan compared + * against an example file instead — discovery falls through to `.env.example` + * when no `.env` exists — the first env file beside it is used, so a project + * running on `.env.local` alone is still checked. + * @param dir - The directory the comparison file lives in. + * @param comparisonFile - Basename of the file the scan compared against. + * @returns The env file basename, or null when the directory holds none. + */ +function resolveEnvFile(dir: string, comparisonFile: string): string | null { + if (!isExampleFile(comparisonFile, { withSuffix: true })) { + return comparisonFile; + } + + return findEnvFile(dir); +} + +/** + * Finds the env file in a directory that holds real values, preferring `.env` + * and otherwise taking the first by code point so the choice is deterministic. + * + * A separator is required after `.env`, so `.env` and `.env.local` count while + * `.envrc` (direnv) does not — that is a shell script, and reporting every line + * of it as an undocumented key would be noise. + * @param dir - The directory to read. + * @returns The env file basename, or null when the directory holds none. + */ +function findEnvFile(dir: string): string | null { + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + return null; + } + + const envFiles = entries.filter( + (name) => isEnvFileName(name) && !isExampleFile(name, { withSuffix: true }), + ); + + if (envFiles.includes(DEFAULT_ENV_FILE)) return DEFAULT_ENV_FILE; + + return envFiles.sort(compareCodePoint)[0] ?? null; +} + +/** + * Reports whether a filename is a dotenv file: `.env` itself, or `.env` + * followed by a separator and an environment name. + * @param name - The file basename. + * @returns True for `.env`, `.env.local`, `.env-local`; false for `.envrc`. + */ +function isEnvFileName(name: string): boolean { + return ( + name === DEFAULT_ENV_FILE || + name.startsWith(`${DEFAULT_ENV_FILE}.`) || + name.startsWith(`${DEFAULT_ENV_FILE}-`) + ); +} + +/** + * Finds the example file that documents a given env file. + * + * An environment-suffixed env file prefers the matching suffixed example + * (`.env.production` → `.env.example.production`), mirroring how `--compare` + * pairs files, and falls back to the unsuffixed names. Within each group the + * {@link EXAMPLE_FILE_CANDIDATES} order decides. + * @param dir - The directory both files live in. + * @param envFile - Basename of the env file. + * @returns Absolute path of the first example file that exists, or null. + */ +function resolveExampleFile(dir: string, envFile: string): string | null { + const suffix = getSuffix(envFile, DEFAULT_ENV_FILE); + + const names = suffix + ? [ + ...EXAMPLE_FILE_CANDIDATES.map((name) => `${name}${suffix}`), + ...EXAMPLE_FILE_CANDIDATES, + ] + : EXAMPLE_FILE_CANDIDATES; + + return ( + names.map((name) => path.join(dir, name)).find((p) => fs.existsSync(p)) ?? + null + ); +} diff --git a/packages/cli/src/services/exampleDiscovery.ts b/packages/cli/src/services/exampleDiscovery.ts index 018e6c11..06a973c2 100644 --- a/packages/cli/src/services/exampleDiscovery.ts +++ b/packages/cli/src/services/exampleDiscovery.ts @@ -6,12 +6,7 @@ import { shouldExclude } from './fileWalker.js'; import { filterIgnoredKeys } from '../core/helpers/filterIgnoredKeys.js'; import { normalizePath } from '../core/helpers/normalizePath.js'; import { DEFAULT_EXCLUDE_PATTERNS } from '../core/scan/patterns.js'; - -/** - * Matches example/template env files that document required keys: - * `.env.example`, `.env-example`, `.env.sample`, `.env.template` (case-insensitive). - */ -const EXAMPLE_FILE_PATTERN = /^\.env[.-](example|sample|template)$/i; +import { isExampleFile } from '../core/helpers/isExampleFile.js'; /** * Options for discovering example scopes. @@ -68,7 +63,7 @@ export function discoverExampleScopes( continue; } - if (!entry.isFile() || !EXAMPLE_FILE_PATTERN.test(entry.name)) continue; + if (!entry.isFile() || !isExampleFile(entry.name)) continue; const relDir = normalizePath(path.relative(cwd, absDir)); // Root example files are already handled by the primary comparison file. diff --git a/packages/cli/src/services/printScanResult.ts b/packages/cli/src/services/printScanResult.ts index 7cd689b7..f60466b5 100644 --- a/packages/cli/src/services/printScanResult.ts +++ b/packages/cli/src/services/printScanResult.ts @@ -24,6 +24,7 @@ import { computeExitDecision } from '../core/scan/computeExitDecision.js'; import { printHealthScore } from '../ui/scan/printHealthScore.js'; import { printExpireWarnings } from '../ui/scan/printExpireWarnings.js'; import { printCommentWarnings } from '../ui/scan/printCommentWarnings.js'; +import { printDriftWarnings } from '../ui/scan/printDriftWarnings.js'; import { printInconsistentNamingWarning } from '../ui/scan/printInconsistentNamingWarning.js'; import { printListAll } from '../ui/scan/printListAll.js'; @@ -118,6 +119,10 @@ export function printScanResult( if (scanResult.commentWarnings) { printCommentWarnings(scanResult.commentWarnings, opts.strict); } + // Keys in an env file that never made it into the example documenting it + if (scanResult.driftWarnings) { + printDriftWarnings(scanResult.driftWarnings, opts.strict); + } // Gitignore check const gitignoreIssue = checkGitignoreStatus({ cwd: opts.cwd, diff --git a/packages/cli/src/services/processComparisonFile.ts b/packages/cli/src/services/processComparisonFile.ts index a177e3ac..f8299442 100644 --- a/packages/cli/src/services/processComparisonFile.ts +++ b/packages/cli/src/services/processComparisonFile.ts @@ -11,6 +11,7 @@ import { detectEnvExpirations } from './detectEnvExpirations.js'; import { detectInconsistentNaming } from '../core/detectInconsistentNaming.js'; import { detectMissingComments } from './detectMissingComments.js'; import { detectOptionalKeys } from './detectOptionalKeys.js'; +import { detectExampleDrift } from './detectExampleDrift.js'; import { DEFAULT_EXAMPLE_FILE } from '../config/constants.js'; import type { ScanUsageOptions, @@ -22,6 +23,7 @@ import type { ExpireWarning, InconsistentNamingWarning, CommentWarning, + DriftWarning, FixContext, } from '../config/types.js'; @@ -41,6 +43,7 @@ export interface ProcessComparisonResult { expireWarnings?: ExpireWarning[]; inconsistentNamingWarnings?: InconsistentNamingWarning[]; commentWarnings?: CommentWarning[]; + driftWarnings?: DriftWarning[]; error?: { message: string; shouldExit: boolean }; } @@ -66,6 +69,7 @@ export function processComparisonFile( let expireWarnings: ExpireWarning[] = []; let inconsistentNamingWarnings: InconsistentNamingWarning[] = []; let commentWarnings: CommentWarning[] = []; + let driftWarnings: DriftWarning[] = []; const fix: FixContext = { fixApplied: false, @@ -172,6 +176,17 @@ export function processComparisonFile( } } + // Warn when the env file this run is about has drifted from the example + // documenting it. The scan only ever reads one file, so without this the + // example is never checked against the values actually in use. + if (opts.driftWarnings) { + driftWarnings = detectExampleDrift({ + comparisonPath: compareFile.path, + ignore: opts.ignore, + ignoreRegex: opts.ignoreRegex, + }); + } + // Apply fixes (both duplicates + missing keys + gitignore) if (opts.fix) { const { changed, result } = applyFixes({ @@ -217,6 +232,7 @@ export function processComparisonFile( expireWarnings, inconsistentNamingWarnings, commentWarnings, + driftWarnings, error: { message: errorMessage, shouldExit: opts.isCiMode ?? false, @@ -237,6 +253,7 @@ export function processComparisonFile( expireWarnings, inconsistentNamingWarnings, commentWarnings, + driftWarnings, }; } diff --git a/packages/cli/src/ui/scan/printDriftWarnings.ts b/packages/cli/src/ui/scan/printDriftWarnings.ts new file mode 100644 index 00000000..318f22f6 --- /dev/null +++ b/packages/cli/src/ui/scan/printDriftWarnings.ts @@ -0,0 +1,38 @@ +import type { DriftWarning } from '../../config/types.js'; +import { label, error, warning, divider, header, padLabel } from '../theme.js'; + +/** + * Prints warnings for keys set in an env file but missing from the example file + * that documents it. + * + * Every warning in a run concerns the same file pair — the scan checks one env + * file — so the pair is read off the first warning and printed as one heading. + * @param warnings Array of drift warnings, in env file order + * @param strict Whether strict mode is enabled (colours the rows as errors) + * @returns void + */ +export function printDriftWarnings( + warnings: DriftWarning[], + strict: boolean = false, +): void { + const first = warnings[0]; + if (!first) return; + + const indicator = strict ? error('▸') : warning('▸'); + const rowColor = strict ? error : warning; + const { envFile, exampleFile } = first; + + console.log(); + console.log( + `${indicator} ${header(`Drift between ${envFile} and ${exampleFile}`)}`, + ); + console.log(`${divider}`); + + for (const warn of warnings) { + console.log( + `${label(padLabel(warn.key))}${rowColor(`not documented in ${exampleFile}`)}`, + ); + } + + console.log(`${divider}`); +} diff --git a/packages/cli/src/ui/scan/scanJsonOutput.ts b/packages/cli/src/ui/scan/scanJsonOutput.ts index 0f199734..ffe14fd6 100644 --- a/packages/cli/src/ui/scan/scanJsonOutput.ts +++ b/packages/cli/src/ui/scan/scanJsonOutput.ts @@ -6,6 +6,7 @@ import type { ExpireWarning, InconsistentNamingWarning, CommentWarning, + DriftWarning, UppercaseWarning, FrameworkWarning, ExampleSecretWarning, @@ -55,6 +56,7 @@ interface ScanJsonOutput { logged?: EnvUsage[]; expireWarnings?: ExpireWarning[]; commentWarnings?: CommentWarning[]; + driftWarnings?: DriftWarning[]; uppercaseWarnings?: UppercaseWarning[]; inconsistentNamingWarnings?: InconsistentNamingWarning[]; frameworkWarnings?: FrameworkWarning[]; @@ -208,6 +210,15 @@ export function scanJsonOutput( })); } + // Keys in an env file that are missing from the example documenting it + if (scanResult.driftWarnings?.length) { + output.driftWarnings = scanResult.driftWarnings.map((w) => ({ + key: w.key, + envFile: w.envFile, + exampleFile: w.exampleFile, + })); + } + const healthScore = computeHealthScore(scanResult); output.healthScore = healthScore; diff --git a/packages/cli/test/e2e/cli.driftWarnings.e2e.test.ts b/packages/cli/test/e2e/cli.driftWarnings.e2e.test.ts new file mode 100644 index 00000000..b196add2 --- /dev/null +++ b/packages/cli/test/e2e/cli.driftWarnings.e2e.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { makeTmpDir, rmrf } from '../utils/fs-helpers.js'; +import { buildOnce, runCli, cleanupBuild } from '../utils/cli-helpers.js'; + +const tmpDirs: string[] = []; + +beforeAll(() => { + buildOnce(); +}); + +afterAll(() => { + cleanupBuild(); +}); + +afterEach(() => { + while (tmpDirs.length) { + const dir = tmpDirs.pop(); + if (dir) rmrf(dir); + } +}); + +/** + * Sets up a clean project: every key in `.env` is used in code (but never + * logged, which would be its own warning) and `.env` is gitignored. The only + * thing that varies is whether `.env.example` documents the same keys, so drift + * is the sole variable under test and `--strict` is otherwise satisfied. + */ +function setup(envBody: string, exampleBody: string | null) { + const cwd = makeTmpDir(); + tmpDirs.push(cwd); + fs.writeFileSync(path.join(cwd, '.gitignore'), '.env\n'); + fs.writeFileSync(path.join(cwd, '.env'), envBody); + if (exampleBody !== null) { + fs.writeFileSync(path.join(cwd, '.env.example'), exampleBody); + } + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'export const db = process.env.DB_URL;\nexport const key = process.env.STRIPE_SECRET;\n', + ); + return cwd; +} + +const DRIFTED_ENV = 'DB_URL=postgres://x\nSTRIPE_SECRET=sk_test\n'; +const PARTIAL_EXAMPLE = 'DB_URL=\n'; + +describe('Drift Warnings (--no-drift-warnings)', () => { + it('warns by default about a key in .env that is missing from .env.example', () => { + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + const res = runCli(cwd, ['--no-color']); + + expect(res.stdout).toContain('Drift between .env and .env.example'); + expect(res.stdout).toContain('STRIPE_SECRET'); + // DB_URL is documented in the example, so it must not be listed + expect(res.stdout).not.toContain('DB_URL'); + }); + + it('is a warning, not a failure, without --strict', () => { + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + const res = runCli(cwd, ['--no-color']); + + expect(res.status).toBe(0); + }); + + it('fails the run under --strict', () => { + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + const res = runCli(cwd, ['--no-color', '--strict']); + + expect(res.status).toBe(1); + }); + + it('says nothing when .env and .env.example agree', () => { + const cwd = setup(DRIFTED_ENV, 'DB_URL=\nSTRIPE_SECRET=\n'); + const res = runCli(cwd, ['--no-color']); + + expect(res.stdout).not.toContain('Drift between'); + }); + + it('says nothing when there is no .env.example to drift from', () => { + const cwd = setup(DRIFTED_ENV, null); + const res = runCli(cwd, ['--no-color']); + + expect(res.stdout).not.toContain('Drift between'); + }); + + it('says nothing when the directory holds only an example file', () => { + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + fs.rmSync(path.join(cwd, '.env')); + const res = runCli(cwd, ['--no-color']); + + expect(res.stdout).not.toContain('Drift between'); + }); + + it('checks only the file the scan compared against', () => { + const cwd = setup( + 'DB_URL=x\nSTRIPE_SECRET=y\n', + 'DB_URL=\nSTRIPE_SECRET=\n', + ); + fs.writeFileSync(path.join(cwd, '.env.local'), 'LOCAL_ONLY=1\n'); + + // The scan compares against .env, so .env.local is not this run's subject. + const res = runCli(cwd, ['--no-color']); + expect(res.stdout).not.toContain('Drift between'); + expect(res.stdout).not.toContain('LOCAL_ONLY'); + }); + + it('checks .env.local when --env points at it', () => { + const cwd = setup( + 'DB_URL=x\nSTRIPE_SECRET=y\n', + 'DB_URL=\nSTRIPE_SECRET=\n', + ); + fs.writeFileSync(path.join(cwd, '.env.local'), 'LOCAL_ONLY=1\n'); + + const res = runCli(cwd, ['--no-color', '--env', '.env.local']); + expect(res.stdout).toContain('Drift between .env.local and .env.example'); + expect(res.stdout).toContain('LOCAL_ONLY'); + }); + + it('checks .env.local when no .env exists for the scan to pick', () => { + // Auto-discovery compares the scan against .env.example itself here, so + // drift must not depend on which file the scan happened to choose. + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + fs.rmSync(path.join(cwd, '.env')); + fs.writeFileSync(path.join(cwd, '.env.local'), DRIFTED_ENV); + + const res = runCli(cwd, ['--no-color']); + expect(res.stdout).toContain('Drift between .env.local and .env.example'); + expect(res.stdout).toContain('STRIPE_SECRET'); + }); + + it('can be turned off with --no-drift-warnings', () => { + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + const res = runCli(cwd, ['--no-color', '--no-drift-warnings', '--strict']); + + expect(res.stdout).not.toContain('Drift between'); + expect(res.status).toBe(0); + }); + + it('honours --ignore', () => { + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + const res = runCli(cwd, ['--no-color', '--ignore', 'STRIPE_SECRET']); + + expect(res.stdout).not.toContain('Drift between'); + }); + + it('reports drift under driftWarnings in JSON mode', () => { + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + const res = runCli(cwd, ['--json']); + const output = JSON.parse(res.stdout); + + expect(output.driftWarnings).toEqual([ + { + key: 'STRIPE_SECRET', + envFile: '.env', + exampleFile: '.env.example', + }, + ]); + }); + + it('omits driftWarnings from JSON when there is no drift', () => { + const cwd = setup(DRIFTED_ENV, 'DB_URL=\nSTRIPE_SECRET=\n'); + const res = runCli(cwd, ['--json']); + const output = JSON.parse(res.stdout); + + expect(output.driftWarnings).toBeUndefined(); + }); + + it('does not report a key marked @optional in the env file', () => { + // The annotation applies to the key directly below it. + const cwd = setup( + 'DB_URL=postgres://x\n# @optional\nSTRIPE_SECRET=sk_test\n', + PARTIAL_EXAMPLE, + ); + const res = runCli(cwd, ['--no-color', '--strict']); + + expect(res.stdout).not.toContain('Drift between'); + expect(res.status).toBe(0); + }); + + it('pairs a suffixed env file with its suffixed example', () => { + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + fs.rmSync(path.join(cwd, '.env')); + fs.rmSync(path.join(cwd, '.env.example')); + fs.writeFileSync(path.join(cwd, '.env.production'), DRIFTED_ENV); + fs.writeFileSync( + path.join(cwd, '.env.example.production'), + PARTIAL_EXAMPLE, + ); + + const res = runCli(cwd, ['--no-color']); + expect(res.stdout).toContain( + 'Drift between .env.production and .env.example.production', + ); + expect(res.stdout).toContain('STRIPE_SECRET'); + }); + + it('pairs .env.local with a .env.sample', () => { + const cwd = setup(DRIFTED_ENV, null); + fs.rmSync(path.join(cwd, '.env')); + fs.writeFileSync(path.join(cwd, '.env.local'), DRIFTED_ENV); + fs.writeFileSync(path.join(cwd, '.env.sample'), PARTIAL_EXAMPLE); + + const res = runCli(cwd, ['--no-color']); + expect(res.stdout).toContain('Drift between .env.local and .env.sample'); + expect(res.stdout).toContain('STRIPE_SECRET'); + }); + + it('can be suppressed with a baseline', () => { + const cwd = setup(DRIFTED_ENV, PARTIAL_EXAMPLE); + runCli(cwd, ['--baseline']); + + const res = runCli(cwd, ['--no-color', '--strict']); + expect(res.stdout).not.toContain('Drift between'); + expect(res.status).toBe(0); + }); +}); diff --git a/packages/cli/test/unit/baseline/scanBaseline.test.ts b/packages/cli/test/unit/baseline/scanBaseline.test.ts index 280b720a..b9f4efb4 100644 --- a/packages/cli/test/unit/baseline/scanBaseline.test.ts +++ b/packages/cli/test/unit/baseline/scanBaseline.test.ts @@ -278,6 +278,24 @@ describe('collectBaselineEntries', () => { expect(result).toContainEqual({ rule: 'comment', key: 'UNDOCUMENTED' }); }); + it('collects drift warnings keyed by env file', () => { + const result = collectBaselineEntries({ + ...emptyScanResult, + driftWarnings: [ + { + key: 'STRIPE_SECRET', + envFile: '.env.local', + exampleFile: '.env.example', + }, + ], + }); + expect(result).toContainEqual({ + rule: 'drift', + key: 'STRIPE_SECRET', + file: '.env.local', + }); + }); + it('collects inconsistent-naming warnings as sorted key pair', () => { const result = collectBaselineEntries({ ...emptyScanResult, @@ -597,6 +615,40 @@ describe('applyBaselineEntries', () => { expect(after.commentWarnings).toBeUndefined(); }); + it('suppresses drift warning', () => { + const result: ScanResult = { + ...emptyScanResult, + driftWarnings: [ + { key: 'DRIFTED', envFile: '.env', exampleFile: '.env.example' }, + ], + }; + const entries: BaselineEntry[] = [ + { rule: 'drift', key: 'DRIFTED', file: '.env' }, + ]; + const after = applyBaselineEntries(result, entries); + expect(after.driftWarnings).toHaveLength(0); + }); + + it('keeps a drift warning baselined against a different env file', () => { + const result: ScanResult = { + ...emptyScanResult, + driftWarnings: [ + { key: 'DRIFTED', envFile: '.env.local', exampleFile: '.env.example' }, + ], + }; + const entries: BaselineEntry[] = [ + { rule: 'drift', key: 'DRIFTED', file: '.env' }, + ]; + const after = applyBaselineEntries(result, entries); + expect(after.driftWarnings).toHaveLength(1); + }); + + it('does not touch driftWarnings when field is absent', () => { + const result: ScanResult = { ...emptyScanResult }; + const after = applyBaselineEntries(result, [{ rule: 'drift', key: 'x' }]); + expect(after.driftWarnings).toBeUndefined(); + }); + it('suppresses inconsistent-naming warning (sorted pair)', () => { const result: ScanResult = { ...emptyScanResult, diff --git a/packages/cli/test/unit/cli/run.test.ts b/packages/cli/test/unit/cli/run.test.ts index c28d22bc..a4efca96 100644 --- a/packages/cli/test/unit/cli/run.test.ts +++ b/packages/cli/test/unit/cli/run.test.ts @@ -98,6 +98,7 @@ function createBaseOptions(overrides: Partial = {}): Options { expireWarnings: true, inconsistentNamingWarnings: true, commentWarnings: false, + driftWarnings: true, listAll: false, explain: undefined, baseline: false, diff --git a/packages/cli/test/unit/commands/scanUsage.test.ts b/packages/cli/test/unit/commands/scanUsage.test.ts index 088f2961..dab601f7 100644 --- a/packages/cli/test/unit/commands/scanUsage.test.ts +++ b/packages/cli/test/unit/commands/scanUsage.test.ts @@ -376,6 +376,40 @@ describe('scanUsage', () => { ); }); + it('attaches driftWarnings from processComparisonFile onto the scan result', async () => { + const drift = [ + { key: 'DRIFTED', envFile: '.env', exampleFile: '.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, + }, + driftWarnings: drift, + } as ProcessComparisonResult); + + await scanUsage({ ...baseOpts, json: false }); + + expect(printScanResult).toHaveBeenCalledWith( + expect.objectContaining({ driftWarnings: drift }), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + it('sets frameworkWarnings on scanResult when frameworkValidator returns results', async () => { const { frameworkValidator } = await import('../../../src/core/frameworks/frameworkValidator.js'); diff --git a/packages/cli/test/unit/core/helpers/isExampleFile.test.ts b/packages/cli/test/unit/core/helpers/isExampleFile.test.ts new file mode 100644 index 00000000..bb98f13a --- /dev/null +++ b/packages/cli/test/unit/core/helpers/isExampleFile.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { isExampleFile } from '../../../../src/core/helpers/isExampleFile.js'; + +describe('isExampleFile', () => { + it.each(['.env.example', '.env-example', '.env.sample', '.env.template'])( + 'accepts %s', + (name) => { + expect(isExampleFile(name)).toBe(true); + }, + ); + + it('matches case-insensitively', () => { + expect(isExampleFile('.ENV.Example')).toBe(true); + }); + + it.each(['.env', '.env.local', '.env.production', 'env.example', 'README'])( + 'rejects %s', + (name) => { + expect(isExampleFile(name)).toBe(false); + }, + ); + + it('rejects a suffixed variant by default', () => { + expect(isExampleFile('.env.example.production')).toBe(false); + }); + + it('accepts a suffixed variant with withSuffix', () => { + expect(isExampleFile('.env.example.production', { withSuffix: true })).toBe( + true, + ); + expect(isExampleFile('.env.sample.local', { withSuffix: true })).toBe(true); + }); + + it('does not treat a longer word as a suffix', () => { + // `.env.examples` is a different name, not `.env.example` plus a suffix. + expect(isExampleFile('.env.examples', { withSuffix: true })).toBe(false); + }); + + it('still rejects plain env files with withSuffix', () => { + expect(isExampleFile('.env.local', { withSuffix: true })).toBe(false); + }); +}); diff --git a/packages/cli/test/unit/services/detectExampleDrift.test.ts b/packages/cli/test/unit/services/detectExampleDrift.test.ts new file mode 100644 index 00000000..977d2e35 --- /dev/null +++ b/packages/cli/test/unit/services/detectExampleDrift.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { detectExampleDrift } from '../../../src/services/detectExampleDrift.js'; + +describe('detectExampleDrift', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drift-unit-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const write = (name: string, content: string): string => { + const file = path.join(dir, name); + fs.writeFileSync(file, content); + return file; + }; + + /** + * Runs the detector as the scan would, i.e. against the file the scan picked. + * Defaults to `.env`, the usual auto-discovery winner. + */ + const run = ( + comparisonFile = '.env', + ignore: string[] = [], + ignoreRegex: RegExp[] = [], + ) => + detectExampleDrift({ + comparisonPath: path.join(dir, comparisonFile), + ignore, + ignoreRegex, + }); + + const keys = ( + comparisonFile = '.env', + ignore: string[] = [], + ignoreRegex: RegExp[] = [], + ) => run(comparisonFile, ignore, ignoreRegex).map((w) => w.key); + + it('reports keys set in .env but absent from .env.example', () => { + write('.env', 'DB_URL=postgres://x\nSTRIPE_SECRET=sk_test\n'); + write('.env.example', 'DB_URL=\n'); + + expect(run()).toEqual([ + { + key: 'STRIPE_SECRET', + envFile: '.env', + exampleFile: '.env.example', + }, + ]); + }); + + it('returns nothing when both files document the same keys', () => { + write('.env', 'DB_URL=postgres://x\nAPI_KEY=abc\n'); + write('.env.example', 'API_KEY=\nDB_URL=\n'); + + expect(run()).toEqual([]); + }); + + it('does not report keys documented in the example but unset in .env', () => { + // One-directional on purpose: that direction is what --compare is for. + write('.env', 'DB_URL=postgres://x\n'); + write('.env.example', 'DB_URL=\nOPTIONAL_KEY=\n'); + + expect(run()).toEqual([]); + }); + + it('keeps the order the keys appear in the env file', () => { + write('.env', 'Z_KEY=1\nA_KEY=2\nM_KEY=3\n'); + write('.env.example', ''); + + expect(keys()).toEqual(['Z_KEY', 'A_KEY', 'M_KEY']); + }); + + it('returns nothing when there is no example file to drift from', () => { + write('.env', 'API_KEY=x\n'); + + expect(run()).toEqual([]); + }); + + it('returns nothing when the directory holds only an example file', () => { + write('.env.example', 'API_KEY=\n'); + + expect(run('.env.example')).toEqual([]); + }); + + it('returns nothing for an unreadable directory', () => { + expect( + detectExampleDrift({ + comparisonPath: path.join(dir, 'nope', '.env.example'), + ignore: [], + ignoreRegex: [], + }), + ).toEqual([]); + }); + + it('yields no warnings when the env file cannot be read', () => { + // A directory where a file is expected. Drift is advisory, so an unreadable + // file must degrade to "nothing to report" rather than break the scan. + fs.mkdirSync(path.join(dir, '.env')); + write('.env.example', 'API_KEY=\n'); + + expect(run()).toEqual([]); + }); + + describe('only the scanned env file', () => { + it('reports .env alone when the scan compared against .env', () => { + write('.env', 'ROOT_ONLY=1\n'); + write('.env.local', 'LOCAL_ONLY=1\n'); + write('.env.example', ''); + + expect(run('.env')).toEqual([ + { key: 'ROOT_ONLY', envFile: '.env', exampleFile: '.env.example' }, + ]); + }); + + it('reports .env.local alone when the scan compared against it', () => { + write('.env', 'ROOT_ONLY=1\n'); + write('.env.local', 'LOCAL_ONLY=1\n'); + write('.env.example', ''); + + expect(run('.env.local')).toEqual([ + { + key: 'LOCAL_ONLY', + envFile: '.env.local', + exampleFile: '.env.example', + }, + ]); + }); + + it('falls back to the env file beside an example comparison file', () => { + // With no .env present the scan compares against .env.example itself; + // .env.local is what the project actually runs on, so check that. + write('.env.local', 'DB_URL=x\nLOCAL_ONLY=1\n'); + write('.env.example', 'DB_URL=\n'); + + expect(run('.env.example')).toEqual([ + { + key: 'LOCAL_ONLY', + envFile: '.env.local', + exampleFile: '.env.example', + }, + ]); + }); + + it('prefers .env when falling back', () => { + write('.env', 'ROOT_ONLY=1\n'); + write('.env.local', 'LOCAL_ONLY=1\n'); + write('.env.example', ''); + + expect(run('.env.example')).toEqual([ + { key: 'ROOT_ONLY', envFile: '.env', exampleFile: '.env.example' }, + ]); + }); + + it('ignores .envrc and other names that merely start with .env', () => { + // direnv's .envrc is a shell script, not a dotenv file. + write('.env.example', ''); + write('.envrc', 'export SHELL_KEY=1\n'); + write('.environment', 'OTHER=1\n'); + + expect(run('.env.example')).toEqual([]); + }); + + it('never falls back to another example file', () => { + write('.env.example', 'DOCUMENTED=\n'); + write('.env.sample', 'ALSO_DOCUMENTED=\n'); + write('.env.example.production', 'PROD_DOC=\n'); + + expect(run('.env.example')).toEqual([]); + }); + }); + + describe('optional keys', () => { + it('does not report a key marked @optional in the env file', () => { + write('.env', 'API_KEY=x\n# @optional\nDEBUG_TOKEN=y\n'); + write('.env.example', ''); + + expect(keys()).toEqual(['API_KEY']); + }); + + it('accepts the other annotation styles', () => { + write('.env', '// optional\nA=1\n#@optional\nB=2\n@optional\nC=3\n'); + write('.env.example', ''); + + expect(run()).toEqual([]); + }); + + it('still reports keys below a blank line after the annotation', () => { + // A blank line ends the block, so the annotation cannot leak downwards. + write('.env', '# @optional\n\nAPI_KEY=x\n'); + write('.env.example', ''); + + expect(keys()).toEqual(['API_KEY']); + }); + + it('needs no handling for @optional on the example side', () => { + // A key written in the example is documented whatever its annotations, + // so it can never drift in the first place. + write('.env', 'REDIS_URL=x\n'); + write('.env.example', '# @optional\nREDIS_URL=\n'); + + expect(run()).toEqual([]); + }); + }); + + describe('example file resolution', () => { + it.each(['.env-example', '.env.sample', '.env.template'])( + 'pairs .env.local with %s', + (exampleName) => { + write('.env.local', 'LOCAL_ONLY=1\n'); + write(exampleName, ''); + + expect(run('.env.local')).toEqual([ + { + key: 'LOCAL_ONLY', + envFile: '.env.local', + exampleFile: exampleName, + }, + ]); + }, + ); + + it('prefers .env.example when several example files exist', () => { + write('.env', 'API_KEY=x\n'); + write('.env.sample', ''); + write('.env.example', 'API_KEY=\n'); + + expect(run()).toEqual([]); + }); + + it('prefers the suffix-matched example, as --compare does', () => { + write('.env.production', 'PROD_KEY=1\n'); + write('.env.example', ''); + write('.env.example.production', 'PROD_KEY=\n'); + + expect(run('.env.production')).toEqual([]); + }); + + it('falls back to the unsuffixed example when no suffixed one exists', () => { + write('.env.production', 'PROD_KEY=1\nEXTRA=2\n'); + write('.env.example', 'PROD_KEY=\n'); + + expect(run('.env.production')).toEqual([ + { + key: 'EXTRA', + envFile: '.env.production', + exampleFile: '.env.example', + }, + ]); + }); + }); + + describe('ignored keys', () => { + it('excludes keys listed in --ignore', () => { + write('.env', 'API_KEY=x\nDEBUG_TOKEN=y\n'); + write('.env.example', ''); + + expect(keys('.env', ['DEBUG_TOKEN'])).toEqual(['API_KEY']); + }); + + it('excludes keys matching --ignore-regex', () => { + write('.env', 'API_KEY=x\nTEST_A=1\nTEST_B=2\n'); + write('.env.example', ''); + + expect(keys('.env', [], [/^TEST_/])).toEqual(['API_KEY']); + }); + + it('excludes built-in defaults that never belong in an example file', () => { + write('.env', 'NODE_ENV=development\nAPI_KEY=x\n'); + write('.env.example', ''); + + expect(keys()).toEqual(['API_KEY']); + }); + }); +}); diff --git a/packages/cli/test/unit/services/printScanResult.test.ts b/packages/cli/test/unit/services/printScanResult.test.ts index 3200ede0..d4ebd513 100644 --- a/packages/cli/test/unit/services/printScanResult.test.ts +++ b/packages/cli/test/unit/services/printScanResult.test.ts @@ -64,6 +64,10 @@ vi.mock('../../../src/ui/scan/printCommentWarnings.js', () => ({ printCommentWarnings: vi.fn(), })); +vi.mock('../../../src/ui/scan/printDriftWarnings.js', () => ({ + printDriftWarnings: vi.fn(), +})); + vi.mock('../../../src/ui/scan/printExpireWarnings.js', () => ({ printExpireWarnings: vi.fn(), })); @@ -108,6 +112,7 @@ import { printExampleWarnings } from '../../../src/ui/scan/printExampleWarnings. import { printSecrets } from '../../../src/ui/scan/printSecrets.js'; import { printExpireWarnings } from '../../../src/ui/scan/printExpireWarnings.js'; import { printCommentWarnings } from '../../../src/ui/scan/printCommentWarnings.js'; +import { printDriftWarnings } from '../../../src/ui/scan/printDriftWarnings.js'; import { printConsolelogWarning } from '../../../src/ui/scan/printConsolelogWarning.js'; import type { SecretFinding } from '../../../src/core/security/secretDetectors.js'; import type { @@ -365,6 +370,21 @@ describe('printScanResult', () => { expect(printCommentWarnings).toHaveBeenCalled(); }); + it('prints drift warnings when present', () => { + printScanResult( + { + ...baseScanResult, + driftWarnings: [ + { key: 'DRIFTED', envFile: '.env', exampleFile: '.env.example' }, + ], + }, + baseOpts, + '.env', + ); + + expect(printDriftWarnings).toHaveBeenCalled(); + }); + it('returns exitWithError true when high severity example warning exists', () => { const warning: ExampleSecretWarning = { key: 'DB_PASSWORD', diff --git a/packages/cli/test/unit/services/processComparisonFile.test.ts b/packages/cli/test/unit/services/processComparisonFile.test.ts index 2c597599..716c1540 100644 --- a/packages/cli/test/unit/services/processComparisonFile.test.ts +++ b/packages/cli/test/unit/services/processComparisonFile.test.ts @@ -64,6 +64,12 @@ vi.mock('../../../src/services/detectOptionalKeys.js', () => ({ detectOptionalKeys: vi.fn(() => []), })); +vi.mock('../../../src/services/detectExampleDrift.js', () => ({ + detectExampleDrift: vi.fn(() => [ + { key: 'DRIFTED', envFile: '.env', exampleFile: '.env.example' }, + ]), +})); + import fs from 'fs'; import { processComparisonFile } from '../../../src/services/processComparisonFile.js'; import { applyFixes } from '../../../src/services/fixEnv.js'; @@ -71,6 +77,7 @@ import { parseEnvFile } from '../../../src/services/parseEnvFile.js'; import { findDuplicateKeys } from '../../../src/services/duplicates.js'; import { resolveFromCwd } from '../../../src/core/helpers/resolveFromCwd.js'; import { detectOptionalKeys } from '../../../src/services/detectOptionalKeys.js'; +import { detectExampleDrift } from '../../../src/services/detectExampleDrift.js'; describe('processComparisonFile', () => { const baseScanResult: ScanResult = { @@ -135,6 +142,30 @@ describe('processComparisonFile', () => { expect(result.commentWarnings).toEqual([{ key: 'UNDOC', line: 2 }]); }); + it('detects drift against the comparison file when enabled', () => { + const result = processComparisonFile(baseScanResult, compareFile, { + ...baseOpts, + driftWarnings: true, + }); + + expect(detectExampleDrift).toHaveBeenCalledWith( + expect.objectContaining({ comparisonPath: compareFile.path }), + ); + expect(result.driftWarnings).toEqual([ + { key: 'DRIFTED', envFile: '.env', exampleFile: '.env.example' }, + ]); + }); + + it('skips drift detection when disabled', () => { + const result = processComparisonFile(baseScanResult, compareFile, { + ...baseOpts, + driftWarnings: false, + }); + + expect(detectExampleDrift).not.toHaveBeenCalled(); + expect(result.driftWarnings).toEqual([]); + }); + it('falls back to the default example file and skips when it does not exist', () => { // examplePath undefined → resolves DEFAULT_EXAMPLE_FILE; existsSync false → no detection. vi.mocked(fs.existsSync).mockReturnValueOnce(false); diff --git a/packages/cli/test/unit/ui/scan/printDriftWarnings.test.ts b/packages/cli/test/unit/ui/scan/printDriftWarnings.test.ts new file mode 100644 index 00000000..95f2d150 --- /dev/null +++ b/packages/cli/test/unit/ui/scan/printDriftWarnings.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { printDriftWarnings } from '../../../../src/ui/scan/printDriftWarnings.js'; +import type { DriftWarning } from '../../../../src/config/types.js'; + +describe('printDriftWarnings', () => { + let consoleLogSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + const output = () => consoleLogSpy.mock.calls.flat().join(' '); + + it('prints nothing when there are no warnings', () => { + printDriftWarnings([]); + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it('prints the header and each undocumented key', () => { + const warnings: DriftWarning[] = [ + { key: 'STRIPE_SECRET', envFile: '.env', exampleFile: '.env.example' }, + { key: 'LEGACY_FLAG', envFile: '.env', exampleFile: '.env.example' }, + ]; + + printDriftWarnings(warnings); + + expect(output()).toContain('Drift between .env and .env.example'); + expect(output()).toContain('STRIPE_SECRET'); + expect(output()).toContain('LEGACY_FLAG'); + expect(output()).toContain('not documented in .env.example'); + }); + + it('names the file pair it was given', () => { + printDriftWarnings([ + { key: 'LOCAL_ONLY', envFile: '.env.local', exampleFile: '.env.sample' }, + ]); + + expect(output()).toContain('Drift between .env.local and .env.sample'); + expect(output()).toContain('not documented in .env.sample'); + }); + + it('prints a single heading for the run', () => { + // A scan checks one env file, so there is only ever one pair to announce. + printDriftWarnings([ + { key: 'A', envFile: '.env', exampleFile: '.env.example' }, + { key: 'B', envFile: '.env', exampleFile: '.env.example' }, + ]); + + const headers = consoleLogSpy.mock.calls + .flat() + .filter((line: unknown) => String(line).includes('Drift between')); + expect(headers).toHaveLength(1); + }); + + it('still prints in strict mode (error styling branch)', () => { + printDriftWarnings( + [{ key: 'API_KEY', envFile: '.env', exampleFile: '.env.example' }], + true, + ); + + expect(output()).toContain('Drift between'); + expect(output()).toContain('API_KEY'); + }); +}); diff --git a/packages/cli/test/unit/ui/scan/scanJsonOutput.test.ts b/packages/cli/test/unit/ui/scan/scanJsonOutput.test.ts index c10df5b0..5dc846c7 100644 --- a/packages/cli/test/unit/ui/scan/scanJsonOutput.test.ts +++ b/packages/cli/test/unit/ui/scan/scanJsonOutput.test.ts @@ -354,6 +354,34 @@ describe('scanJsonOutput', () => { expect(result.commentWarnings?.[1]).toEqual({ key: 'SECRET', line: 5 }); }); + it('includes drift warnings with their file pair', () => { + const scanResult = makeScanResult({ + driftWarnings: [ + { + key: 'STRIPE_SECRET', + envFile: '.env.local', + exampleFile: '.env.sample', + }, + ], + }); + + const result = scanJsonOutput(scanResult, ''); + + expect(result.driftWarnings).toEqual([ + { + key: 'STRIPE_SECRET', + envFile: '.env.local', + exampleFile: '.env.sample', + }, + ]); + }); + + it('omits drift warnings when there are none', () => { + const result = scanJsonOutput(makeScanResult({ driftWarnings: [] }), ''); + + expect(result.driftWarnings).toBeUndefined(); + }); + it('includes healthScore', () => { const scanResult = makeScanResult();