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

add `--comment-warnings` flag (opt-in, off by default) that warns about `.env.example` keys lacking a documenting comment.
6 changes: 5 additions & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ For example, if your code uses `DATABASE_URL` but your `.env` defines `DATABAS_U

Only close matches are suggested (small edit distance, scaled to key length), and only the single closest key is shown per missing variable. Suggestions are advisory — they do **not** affect the exit code or the health score. Disable them with `--no-suggest`.

### 13 Health Score
### 13 Undocumented Example Keys (opt-in)

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

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

Expand Down Expand Up @@ -824,6 +825,42 @@ Usage in the configuration file:
}
```

### `--comment-warnings`

Warn about `.env.example` keys that lack a documenting comment (disabled by default — opt in with this flag).

A well documented `.env.example` is the fastest onboarding for a new contributor.

A key counts as **documented** when either:

- a `#` comment sits on the line **directly above** it, or
- it has an **inline** `#` comment after the value.

```dotenv
# Stripe webhook signing secret (dashboard.stripe.com/webhooks)
STRIPE_WEBHOOK_SECRET= # ✓ documented (comment above)
PORT=3000 # server port # ✓ documented (inline comment)
API_KEY= # ✗ reported (undocumented)
```

Undocumented keys are listed in the console output and in JSON (`commentWarnings`), count toward the [health score](./capabilities.md), can be suppressed with a [baseline](./baseline.md) (`comment` rule), and cause a non-zero exit under [`--strict`](#--strict).

> Note: the rule is intentionally simple. A shared section header (e.g. `# === Database ===`) counts only for the first key directly beneath it, so keys further down may still be reported.

Example usage:

```bash
dotenv-diff --comment-warnings
```

Usage in the configuration file:

```json
{
"commentWarnings": true
}
```

### `--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
9 changes: 9 additions & 0 deletions packages/cli/src/baseline/scanBaseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ export function collectBaselineEntries(
entries.push({ rule: 'expire', key: warning.key });
}

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

// Sort the key pair so the entry is identical regardless of scanner order
for (const warning of scanResult.inconsistentNamingWarnings ?? []) {
const pair = [warning.key1, warning.key2].sort().join('|');
Expand Down Expand Up @@ -205,6 +209,11 @@ export function applyBaselineEntries(
},
),
}),
...(scanResult.commentWarnings != null && {
commentWarnings: scanResult.commentWarnings.filter(
(w) => !has('comment', w.key),
),
}),
};
}

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 @@ -95,6 +95,10 @@ export function createProgram() {
'--no-inconsistent-naming-warnings',
'Disable inconsistent naming pattern warnings',
)
.option(
'--comment-warnings',
'Warn about .env.example keys that lack a documenting # comment',
)
.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 @@ -121,6 +121,7 @@ async function runScanMode(opts: Options): Promise<boolean> {
uppercaseKeys: opts.uppercaseKeys,
expireWarnings: opts.expireWarnings,
inconsistentNamingWarnings: opts.inconsistentNamingWarnings,
commentWarnings: opts.commentWarnings,
listAll: opts.listAll,
baseline: opts.baseline,
suggest: opts.suggest,
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/scanUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ export async function scanUsage(opts: ScanUsageOptions): Promise<ExitResult> {
scanResult.inconsistentNamingWarnings =
result.inconsistentNamingWarnings;
}
if (result.commentWarnings) {
scanResult.commentWarnings = result.commentWarnings;
}
if (
result.exampleFull &&
result.comparedAgainst === DEFAULT_EXAMPLE_FILE
Expand Down Expand Up @@ -204,6 +207,7 @@ export async function scanUsage(opts: ScanUsageOptions): Promise<ExitResult> {
(w) => w.daysLeft <= EXPIRE_THRESHOLD_DAYS,
).length ?? 0) > 0 ||
(scanResult.inconsistentNamingWarnings?.length ?? 0) > 0 ||
(scanResult.commentWarnings?.length ?? 0) > 0 ||
(scanResult.exampleWarnings?.length ?? 0) > 0)
),
};
Expand Down Expand Up @@ -278,6 +282,7 @@ function calculateStats(scanResult: ScanResult): void {
(scanResult.uppercaseWarnings?.length ?? 0) +
(scanResult.expireWarnings?.length ?? 0) +
(scanResult.inconsistentNamingWarnings?.length ?? 0) +
(scanResult.commentWarnings?.length ?? 0) +
(scanResult.secrets?.length ?? 0) +
scanResult.missing.length +
scanResult.unused.length +
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 @@ -57,6 +57,7 @@ export function normalizeOptions(raw: RawOptions): Options {
const uppercaseKeys = raw.uppercaseKeys !== false;
const expireWarnings = raw.expireWarnings !== false;
const inconsistentNamingWarnings = raw.inconsistentNamingWarnings !== false;
const commentWarnings = toBool(raw.commentWarnings);
const suggest = raw.suggest !== false;
const listAll = toBool(raw.listAll);
const explain =
Expand Down Expand Up @@ -105,6 +106,7 @@ export function normalizeOptions(raw: RawOptions): Options {
uppercaseKeys,
expireWarnings,
inconsistentNamingWarnings,
commentWarnings,
listAll,
explain,
matrix,
Expand Down
19 changes: 18 additions & 1 deletion packages/cli/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export interface RawOptions {
uppercaseKeys?: boolean;
expireWarnings?: boolean;
inconsistentNamingWarnings?: boolean;
commentWarnings?: boolean;
listAll?: boolean;
explain?: string;
matrix?: boolean | string[];
Expand Down Expand Up @@ -141,6 +142,7 @@ export interface Options {
uppercaseKeys: boolean;
expireWarnings: boolean;
inconsistentNamingWarnings: boolean;
commentWarnings: boolean;
listAll: boolean;
explain: string | undefined;
matrix: boolean;
Expand Down Expand Up @@ -240,6 +242,7 @@ export interface ScanUsageOptions extends ScanOptions {
uppercaseKeys?: boolean;
expireWarnings?: boolean;
inconsistentNamingWarnings?: boolean;
commentWarnings?: boolean;
listAll?: boolean;
baseline?: boolean;
suggest?: boolean;
Expand Down Expand Up @@ -285,6 +288,7 @@ export interface ScanResult {
uppercaseWarnings?: UppercaseWarning[];
expireWarnings?: ExpireWarning[];
inconsistentNamingWarnings?: InconsistentNamingWarning[];
commentWarnings?: CommentWarning[];
/** Typo suggestions for variables used in code but not defined in the env file */
suggestions?: TypoSuggestion[];
fileContentMap?: Map<string, string>;
Expand Down Expand Up @@ -339,6 +343,7 @@ export interface ComparisonOptions {
uppercaseKeys?: boolean;
expireWarnings?: boolean;
inconsistentNamingWarnings?: boolean;
commentWarnings?: boolean;
suggest?: boolean;
}

Expand Down Expand Up @@ -453,6 +458,17 @@ export interface ExpireWarning {
daysLeft: number;
}

/**
* Warning about an `.env.example` key that has no documenting comment.
* fx: `API_KEY=` with no `#` comment on the line above and no inline `#` comment.
*/
export interface CommentWarning {
/** The undocumented environment variable key */
key: string;
/** 1-based line number of the key in the example file */
line: number;
}

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

/**
* A single suppressed warning in the baseline file.
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/core/scan/computeHealthScore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export function computeHealthScore(scan: ScanResult): number {
// === 9. Inconsistent naming warnings ===
score -= (scan.inconsistentNamingWarnings?.length ?? 0) * 3;

// === 9b. Undocumented example keys (advisory) ===
score -= (scan.commentWarnings?.length ?? 0) * 2;

// === 10. Duplicate definitions ===
score -= (scan.duplicates?.env?.length ?? 0) * 10;
score -= (scan.duplicates?.example?.length ?? 0) * 10;
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/services/detectMissingComments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import fs from 'fs';
import type { CommentWarning } from '../config/types.js';
import { splitEnvLines, parseEnvLine } from '../core/envLine.js';

/**
* Detects `.env.example` keys that lack a documenting comment.
*
* A key counts as documented when either:
* - the line directly above it is a `#` comment, or
* - the key's own line has an inline comment after the value (`KEY=value # ...`).
*
* Everything else (a bare `KEY=` with no neighbouring comment) is reported.
* fx:
*
* # Stripe webhook signing secret
* STRIPE_WEBHOOK_SECRET= ← documented (comment above)
* PORT=3000 # server port ← documented (inline comment)
* API_KEY= ← reported (undocumented)
*
* @param filePath - Path to the `.env.example` file to inspect.
* @returns One warning per undocumented key, in file order.
*/
export function detectMissingComments(filePath: string): CommentWarning[] {
const lines = splitEnvLines(fs.readFileSync(filePath, 'utf8'));
const warnings: CommentWarning[] = [];

for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const parsed = parseEnvLine(line);
if (!parsed) continue; // blank line, comment line, or non-key line

// Inline comment: a `#` anywhere after the first `=`.
const eq = line.indexOf('=');
const hasInlineComment = eq !== -1 && line.slice(eq + 1).includes('#');

// Comment directly above: the immediately preceding line is a `#` comment.
const prev = i > 0 ? lines[i - 1]!.trim() : '';
const hasCommentAbove = prev.startsWith('#');

if (!hasInlineComment && !hasCommentAbove) {
warnings.push({ key: parsed.key, line: i + 1 });
}
}

return warnings;
}
8 changes: 7 additions & 1 deletion packages/cli/src/services/printScanResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { printUppercaseWarning } from '../ui/scan/printUppercaseWarning.js';
import { computeHealthScore } from '../core/scan/computeHealthScore.js';
import { printHealthScore } from '../ui/scan/printHealthScore.js';
import { printExpireWarnings } from '../ui/scan/printExpireWarnings.js';
import { printCommentWarnings } from '../ui/scan/printCommentWarnings.js';
import { printInconsistentNamingWarning } from '../ui/scan/printInconsistentNamingWarning.js';
import { printListAll } from '../ui/scan/printListAll.js';

Expand Down Expand Up @@ -122,6 +123,10 @@ export function printScanResult(
if (scanResult.expireWarnings) {
printExpireWarnings(scanResult.expireWarnings, opts.strict);
}
// Undocumented example keys
if (scanResult.commentWarnings) {
printCommentWarnings(scanResult.commentWarnings, opts.strict);
}
// Check for high severity secrets - ALWAYS exit with error
const hasHighSeveritySecrets = (scanResult.secrets ?? []).some(
(s) => s.severity === 'high',
Expand Down Expand Up @@ -178,7 +183,8 @@ export function printScanResult(
(scanResult.expireWarnings?.filter(
(w) => w.daysLeft <= EXPIRE_THRESHOLD_DAYS,
).length ?? 0) > 0 ||
(scanResult.inconsistentNamingWarnings?.length ?? 0) > 0;
(scanResult.inconsistentNamingWarnings?.length ?? 0) > 0 ||
(scanResult.commentWarnings?.length ?? 0) > 0;

if (hasStrictViolations) exitWithError = true;
}
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/services/processComparisonFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { toUpperSnakeCase } from '../core/helpers/toUpperSnakeCase.js';
import { resolveFromCwd } from '../core/helpers/resolveFromCwd.js';
import { detectEnvExpirations } from './detectEnvExpirations.js';
import { detectInconsistentNaming } from '../core/detectInconsistentNaming.js';
import { detectMissingComments } from './detectMissingComments.js';
import { DEFAULT_EXAMPLE_FILE } from '../config/constants.js';
import type {
ScanUsageOptions,
ScanResult,
Expand All @@ -18,6 +20,7 @@ import type {
ComparisonFile,
ExpireWarning,
InconsistentNamingWarning,
CommentWarning,
FixContext,
} from '../config/types.js';

Expand All @@ -36,6 +39,7 @@ export interface ProcessComparisonResult {
uppercaseWarnings?: UppercaseWarning[];
expireWarnings?: ExpireWarning[];
inconsistentNamingWarnings?: InconsistentNamingWarning[];
commentWarnings?: CommentWarning[];
error?: { message: string; shouldExit: boolean };
}

Expand All @@ -60,6 +64,7 @@ export function processComparisonFile(
let uppercaseWarnings: UppercaseWarning[] = [];
let expireWarnings: ExpireWarning[] = [];
let inconsistentNamingWarnings: InconsistentNamingWarning[] = [];
let commentWarnings: CommentWarning[] = [];

const fix: FixContext = {
fixApplied: false,
Expand Down Expand Up @@ -134,6 +139,19 @@ export function processComparisonFile(
inconsistentNamingWarnings = detectInconsistentNaming(allKeys);
}

// Warn about .env.example keys without a documenting comment. Runs on the
// example file (documentation lives there), not the env file — using the
// explicit --example path when given, else the default `.env.example`.
if (opts.commentWarnings) {
const examplePath = resolveFromCwd(
opts.cwd,
opts.examplePath ?? DEFAULT_EXAMPLE_FILE,
);
if (fs.existsSync(examplePath)) {
commentWarnings = detectMissingComments(examplePath);
}
}

// Apply fixes (both duplicates + missing keys + gitignore)
if (opts.fix) {
const { changed, result } = applyFixes({
Expand Down Expand Up @@ -178,6 +196,7 @@ export function processComparisonFile(
uppercaseWarnings,
expireWarnings,
inconsistentNamingWarnings,
commentWarnings,
error: {
message: errorMessage,
shouldExit: opts.isCiMode ?? false,
Expand All @@ -197,6 +216,7 @@ export function processComparisonFile(
uppercaseWarnings,
expireWarnings,
inconsistentNamingWarnings,
commentWarnings,
};
}

Expand Down
Loading