diff --git a/.changeset/expire-comment-interaction.md b/.changeset/expire-comment-interaction.md new file mode 100644 index 00000000..f5f34163 --- /dev/null +++ b/.changeset/expire-comment-interaction.md @@ -0,0 +1,5 @@ +--- +'dotenv-diff': patch +--- + +fix `@expire` and `--comment-warnings` interaction diff --git a/packages/cli/src/services/detectEnvExpirations.ts b/packages/cli/src/services/detectEnvExpirations.ts index 230e3434..16065694 100644 --- a/packages/cli/src/services/detectEnvExpirations.ts +++ b/packages/cli/src/services/detectEnvExpirations.ts @@ -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) { diff --git a/packages/cli/src/services/detectMissingComments.ts b/packages/cli/src/services/detectMissingComments.ts index 94c4bdb2..b8ff3d59 100644 --- a/packages/cli/src/services/detectMissingComments.ts +++ b/packages/cli/src/services/detectMissingComments.ts @@ -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. @@ -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 }); diff --git a/packages/cli/src/ui/scan/printCommentWarnings.ts b/packages/cli/src/ui/scan/printCommentWarnings.ts index 90cea180..7f7b0a8b 100644 --- a/packages/cli/src/ui/scan/printCommentWarnings.ts +++ b/packages/cli/src/ui/scan/printCommentWarnings.ts @@ -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) { diff --git a/packages/cli/test/unit/services/detectEnvExpirations.test.ts b/packages/cli/test/unit/services/detectEnvExpirations.test.ts index 7f57b1a4..36f8dd6d 100644 --- a/packages/cli/test/unit/services/detectEnvExpirations.test.ts +++ b/packages/cli/test/unit/services/detectEnvExpirations.test.ts @@ -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, diff --git a/packages/cli/test/unit/services/detectMissingComments.test.ts b/packages/cli/test/unit/services/detectMissingComments.test.ts index dcfbb9e0..56c3caed 100644 --- a/packages/cli/test/unit/services/detectMissingComments.test.ts +++ b/packages/cli/test/unit/services/detectMissingComments.test.ts @@ -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 }, + ]); + }); });