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/expire-comment-interaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': patch
---

fix `@expire` and `--comment-warnings` interaction
9 changes: 9 additions & 0 deletions packages/cli/src/services/detectEnvExpirations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ export function detectEnvExpirations(filePath: string): ExpireWarning[] {
for (const raw of lines) {
const line = raw.trim();

// A blank line ends the current annotation block. Comments are carried
// over (an `@expire` may sit above a documented key), but an empty line
// signals "end of block" so a pending annotation cannot leak across a gap
// and attach itself to an unrelated key further down the file.
if (line === '') {
pendingExpire = null;
continue;
}

const expireMatch = line.match(reg);

if (expireMatch) {
Expand Down
40 changes: 34 additions & 6 deletions packages/cli/src/services/detectMissingComments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,37 @@ 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;

/**
* 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 # ...`).
* - its own line has an inline comment after the value (`KEY=value # ...`), or
* - a real `#` comment sits in the contiguous run of comment/annotation lines
* directly above it. `@expire` annotation lines are transparent (they do not
* themselves document, but they don't hide a real comment above them either);
* a blank line or a non-comment line ends the run.
*
* Everything else (a bare `KEY=` with no neighbouring comment) is reported.
* fx:
*
* # Stripe webhook signing secret
* STRIPE_WEBHOOK_SECRET= ← documented (comment above)
* # Rotated quarterly
* # @expire 2026-12-31
* ROTATING_KEY= ← documented (comment above the annotation)
* PORT=3000 # server port ← documented (inline comment)
* API_KEY= ← reported (undocumented)
* # @expire 2026-12-31
* API_KEY= ← reported (only an annotation above, no prose)
*
* @param filePath - Path to the `.env.example` file to inspect.
* @returns One warning per undocumented key, in file order.
Expand All @@ -33,9 +50,20 @@ export function detectMissingComments(filePath: string): CommentWarning[] {
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('#');
// Comment above: walk up through the contiguous run of lines directly above
// the key. `@expire` annotations are skipped over (transparent); the first
// real `#` comment documents the key. A blank line or any non-comment line
// ends the run — the comment then belongs to something else, not this key.
let hasCommentAbove = false;
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 (above.startsWith('#')) {
hasCommentAbove = true; // a real, prose comment
}
break; // real comment or a non-comment line (e.g. another key) ends the run
}

if (!hasInlineComment && !hasCommentAbove) {
warnings.push({ key: parsed.key, line: i + 1 });
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/scan/printCommentWarnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function printCommentWarnings(
const rowColor = strict ? error : warning;

console.log();
console.log(`${indicator} ${header('Undocumented keys')}`);
console.log(`${indicator} ${header('Undocumented keys in .env.example')}`);
console.log(`${divider}`);

for (const warn of warnings) {
Expand Down
50 changes: 50 additions & 0 deletions packages/cli/test/unit/services/detectEnvExpirations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,56 @@ describe('detectEnvExpirations', () => {
expect(result[0]!.key).toBe('A');
});

it('attaches expiration across a comment line to the following key', () => {
// A comment between the annotation and the key does not break the link.
fs.writeFileSync(
envPath,
`
@expire 2024-12-31
# what this key is for
API_KEY=123
`,
);

const result = detectEnvExpirations(envPath);

expect(result).toEqual([
{ key: 'API_KEY', date: '2024-12-31', daysLeft: 30 },
]);
});

it('does not attach expiration across a blank line (blank line ends the block)', () => {
// A blank line signals the end of the annotation block, so the annotation
// must not leak onto an unrelated key further down.
fs.writeFileSync(
envPath,
`# @expire 2024-12-31

API_KEY=123
`,
);

const result = detectEnvExpirations(envPath);

expect(result).toEqual([]);
});

it('does not let an annotation leak across a blank line to a distant key', () => {
fs.writeFileSync(
envPath,
`# @expire 2024-12-31
# some documentation

# an unrelated section
DATABASE_URL=postgres://localhost
`,
);

const result = detectEnvExpirations(envPath);

expect(result).toEqual([]);
});

it('handles multiple expiration blocks', () => {
fs.writeFileSync(
envPath,
Expand Down
68 changes: 68 additions & 0 deletions packages/cli/test/unit/services/detectMissingComments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,72 @@ describe('detectMissingComments', () => {
// The line directly above the key is blank, not a comment.
expect(run('# note\n\nAPI_KEY=')).toEqual([{ key: 'API_KEY', line: 3 }]);
});

it('does not treat a bare @expire annotation above a key as documentation', () => {
// An `@expire` annotation is a machine hint, not human docs, so the key
// below it is still undocumented and must be reported.
expect(run('# @expire 2025-12-12\nAPI_KEY=')).toEqual([
{ key: 'API_KEY', line: 2 },
]);
});

it('does not treat a bare expire annotation (no @) above a key as documentation', () => {
expect(run('# expire 2025-12-12\nAPI_KEY=')).toEqual([
{ key: 'API_KEY', line: 2 },
]);
});

it('treats a comment that adds prose alongside an expire annotation as documentation', () => {
// The comment carries real information beyond the annotation, so it counts.
expect(run('# @expire 2025-12-12 rotate before then\nAPI_KEY=')).toEqual(
[],
);
});

it('accepts a key whose line directly above is a real comment, even with an expire annotation earlier', () => {
// Mirrors the user scenario: annotation, then a documenting comment, then
// the key. The line directly above the key is a real comment.
expect(
run('# @expire 2025-12-12\n# what this key is for\nAPI_KEY='),
).toEqual([]);
});

it('sees a real comment above an @expire annotation that sits directly on the key', () => {
// The annotation is transparent: a real comment above it still documents
// the key, even though the annotation is the line immediately above.
expect(
run('# Stripe webhook secret\n# @expire 2026-12-31\nSTRIPE_KEY='),
).toEqual([]);
});

it('sees a real comment above a bare (no #) @expire annotation', () => {
expect(run('# Expiring secret\n@expire 2025-12-31\nOLD_API_KEY=')).toEqual(
[],
);
});

it('sees a real comment above a // style @expire annotation', () => {
expect(run('# legacy token\n// @expire 2024-01-01\nLEGACY=')).toEqual([]);
});

it('still reports a key when only annotations (no prose) sit above it', () => {
// Two annotations and nothing else — no human documentation, so reported.
expect(run('# @expire 2026-01-01\n# @expire 2026-02-01\nAPI_KEY=')).toEqual(
[{ key: 'API_KEY', line: 3 }],
);
});

it('does not let a comment leak across a blank line above the annotation', () => {
// Blank line ends the run, so the comment does not document ORPHAN.
expect(run('# note\n\n# @expire 2026-01-01\nORPHAN=')).toEqual([
{ key: 'ORPHAN', line: 4 },
]);
});

it('does not carry a comment across an intervening key line', () => {
// `# doc` documents FIRST; SECOND has a key line directly above it.
expect(run('# doc\nFIRST=1\nSECOND=2')).toEqual([
{ key: 'SECOND', line: 3 },
]);
});
});