From ab3b866f2654491e3bf0ba00cbc6bfb551d04b4e Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Sat, 23 May 2026 22:56:32 +0200 Subject: [PATCH 1/6] feat(cli): --explain feature --- .changeset/odd-vans-glow.md | 5 + docs/configuration_and_flags.md | 39 +++ packages/cli/src/cli/program.ts | 4 + packages/cli/src/cli/run.ts | 17 ++ packages/cli/src/commands/explain.ts | 91 ++++++ packages/cli/src/config/options.ts | 3 + packages/cli/src/config/types.ts | 2 + packages/cli/src/ui/scan/printExplain.ts | 183 ++++++++++++ packages/cli/test/unit/cli/run.test.ts | 50 ++++ .../cli/test/unit/commands/explain.test.ts | 224 ++++++++++++++ packages/cli/test/unit/config/options.test.ts | 13 +- .../test/unit/ui/scan/printExplain.test.ts | 278 ++++++++++++++++++ 12 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 .changeset/odd-vans-glow.md create mode 100644 packages/cli/src/commands/explain.ts create mode 100644 packages/cli/src/ui/scan/printExplain.ts create mode 100644 packages/cli/test/unit/commands/explain.test.ts create mode 100644 packages/cli/test/unit/ui/scan/printExplain.test.ts diff --git a/.changeset/odd-vans-glow.md b/.changeset/odd-vans-glow.md new file mode 100644 index 00000000..332d6049 --- /dev/null +++ b/.changeset/odd-vans-glow.md @@ -0,0 +1,5 @@ +--- +'dotenv-diff': minor +--- + +feat: --explain to show a detailed breakdown of a single environment varaible diff --git a/docs/configuration_and_flags.md b/docs/configuration_and_flags.md index 455feddf..10c7c221 100644 --- a/docs/configuration_and_flags.md +++ b/docs/configuration_and_flags.md @@ -29,6 +29,7 @@ CLI flags always take precedence over configuration file values. ### Display Options - [--list-all](#--list-all) +- [--explain](#--explain-key) - [--show-unused](#--show-unused) - [--no-show-unused](#--no-show-unused) - [--show-stats](#--show-stats) @@ -465,6 +466,44 @@ Usage in the configuration file: --- +### `--explain ` + +Shows a detailed breakdown of a single environment variable: where it is defined in env files, where it is used in the codebase, and its overall status. + +This is useful for debugging a specific variable — for example when you want to confirm it is defined, find all usage sites, or understand why it is flagged as missing, unused, or ignored. + +Example usage: + +```bash +dotenv-diff --explain DATABASE_URL +``` + +The output shows: + +- **Key** — the variable name +- **Status** — one of: `ok`, `missing`, `unused`, `ignored`, or `duplicated` +- **Defined in** — which env files the key appears in (e.g. `.env`, `.env.example`) +- **Used in** — file paths and line numbers where the key is referenced in the codebase +- **Checks** — a checklist of whether the key is defined, used, duplicated, and/or ignored + +Use `--json` together with `--explain` to get the result as structured JSON: + +```bash +dotenv-diff --explain DATABASE_URL --json +``` + +The JSON output includes the full list of usage locations. + +Usage in the configuration file: + +```json +{ + "explain": "DATABASE_URL" +} +``` + +--- + ### `--show-unused` List variables that are defined in `.env` but not used in the codebase (enabled by default). diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 615887a0..10c21f34 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -96,5 +96,9 @@ export function createProgram() { '--list-all', 'List all unique environment variable keys found in codebase', ) + .option( + '--explain ', + 'Show where a specific key is defined, used, and its status', + ) ); } diff --git a/packages/cli/src/cli/run.ts b/packages/cli/src/cli/run.ts index 93d5b35c..d75fef65 100644 --- a/packages/cli/src/cli/run.ts +++ b/packages/cli/src/cli/run.ts @@ -14,6 +14,7 @@ import type { ExitResult, } from '../config/types.js'; import { scanUsage } from '../commands/scanUsage.js'; +import { explainKey } from '../commands/explain.js'; import { printErrorNotFound } from '../ui/compare/printErrorNotFound.js'; import { setupGlobalConfig } from '../ui/shared/setupGlobalConfig.js'; import { loadConfig } from '../config/loadConfig.js'; @@ -49,6 +50,22 @@ export async function run(program: Command): Promise { setupGlobalConfig(opts); + // Route to --explain mode + if (opts.explain) { + await explainKey({ + key: opts.explain, + cwd: opts.cwd, + include: opts.includeFiles, + exclude: opts.excludeFiles, + ignore: opts.ignore, + ignoreRegex: opts.ignoreRegex, + files: opts.files, + secrets: opts.secrets, + json: opts.json, + }); + process.exit(0); + } + // Route to appropriate command and handle exit const exitWithError = opts.compare ? await runCompareMode(opts) diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts new file mode 100644 index 00000000..cf43d20a --- /dev/null +++ b/packages/cli/src/commands/explain.ts @@ -0,0 +1,91 @@ +import fs from 'fs'; +import path from 'path'; +import { scanCodebase } from '../services/scanCodebase.js'; +import { parseEnvFile } from '../services/parseEnvFile.js'; +import { findDuplicateKeys } from '../core/duplicates.js'; +import type { ScanOptions } from '../config/types.js'; +import { DEFAULT_ENV_FILE, DEFAULT_EXAMPLE_FILE } from '../config/constants.js'; +import { printExplain, type ExplainResult } from '../ui/scan/printExplain.js'; +import { skipCommentedUsages } from '../core/helpers/skipCommentedUsages.js'; + +/** + * Options forwarded from the CLI for the --explain command. + */ +export interface ExplainOptions extends ScanOptions { + key: string; +} + +/** + * Implements `dotenv-diff --explain `. + * + * Reports where the key is defined in env files, where it is used in the + * codebase, and its overall status (defined / used / duplicated / ignored). + * @param opts Explain options from CLI + * @returns void + */ +export async function explainKey(opts: ExplainOptions): Promise { + const { key, cwd, ignore, ignoreRegex } = opts; + + // Find env files that contain the key + const envFiles = discoverEnvFilesSync(cwd); + const definedIn: string[] = []; + const isDuplicated = envFiles.some((filePath) => { + const dups = findDuplicateKeys(filePath); + return dups.some((d) => d.key === key); + }); + + for (const filePath of envFiles) { + const parsed = parseEnvFile(filePath); + if (Object.prototype.hasOwnProperty.call(parsed, key)) { + definedIn.push(path.relative(cwd, filePath)); + } + } + + // Scan codebase for usages + const scanResult = await scanCodebase(opts); + + // Filter out commented usages + const filteredUsages = skipCommentedUsages(scanResult.used); + const usages = filteredUsages.filter((u) => u.variable === key); + + // Check ignore status + const isIgnored = + ignore.includes(key) || ignoreRegex.some((rx) => rx.test(key)); + + // Print result + const result: ExplainResult = { + key, + definedIn, + usages, + isDuplicated, + isIgnored, + }; + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printExplain(result); + } +} + +/** + * Returns absolute paths to all .env* files in the cwd + * (both env files and example files). + * @param cwd Directory to search in + * @returns Array of absolute file paths + */ +function discoverEnvFilesSync(cwd: string): string[] { + let entries: string[] = []; + try { + entries = fs.readdirSync(cwd); + } catch { + return []; + } + + return entries + .filter( + (f) => + f.startsWith(DEFAULT_ENV_FILE) || f.startsWith(DEFAULT_EXAMPLE_FILE), + ) + .map((f) => path.resolve(cwd, f)); +} diff --git a/packages/cli/src/config/options.ts b/packages/cli/src/config/options.ts index 473cb660..28322507 100644 --- a/packages/cli/src/config/options.ts +++ b/packages/cli/src/config/options.ts @@ -57,6 +57,8 @@ export function normalizeOptions(raw: RawOptions): Options { const expireWarnings = raw.expireWarnings !== false; const inconsistentNamingWarnings = raw.inconsistentNamingWarnings !== false; const listAll = toBool(raw.listAll); + const explain = + typeof raw.explain === 'string' ? raw.explain.trim() : undefined; const cwd = process.cwd(); const envFlag = @@ -98,6 +100,7 @@ export function normalizeOptions(raw: RawOptions): Options { expireWarnings, inconsistentNamingWarnings, listAll, + explain, }; } diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 9251294c..029086d8 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -94,6 +94,7 @@ export interface RawOptions { expireWarnings?: boolean; inconsistentNamingWarnings?: boolean; listAll?: boolean; + explain?: string; } /** @@ -137,6 +138,7 @@ export interface Options { expireWarnings: boolean; inconsistentNamingWarnings: boolean; listAll: boolean; + explain: string | undefined; } export type EnvPatternName = 'process.env' | 'import.meta.env' | 'sveltekit'; diff --git a/packages/cli/src/ui/scan/printExplain.ts b/packages/cli/src/ui/scan/printExplain.ts new file mode 100644 index 00000000..893c34cb --- /dev/null +++ b/packages/cli/src/ui/scan/printExplain.ts @@ -0,0 +1,183 @@ +import type { EnvUsage } from '../../config/types.js'; +import { normalizePath } from '../../core/helpers/normalizePath.js'; +import { + accent, + dim, + error, + header, + label, + divider, + padLabel, + value, + warning, +} from '../theme.js'; + +export interface ExplainResult { + key: string; + definedIn: string[]; + usages: EnvUsage[]; + isDuplicated: boolean; + isIgnored: boolean; +} + +/** + * Prints a detailed explanation for a single environment variable key. + */ +export function printExplain(result: ExplainResult): void { + const { key, definedIn, usages, isDuplicated, isIgnored } = result; + const summary = getSummary(result); + + console.log(); + console.log(`${summary.indicator} ${header(`Explain ${key}`)}`); + console.log(divider); + + printRow('Key', value(key)); + printRow('Status', summary.text); + + console.log(); + + printFileList('Defined in', definedIn); + printUsageList(usages); + + console.log(); + + printChecks({ + isDefined: definedIn.length > 0, + isUsed: usages.length > 0, + isDuplicated, + isIgnored, + }); + + console.log(divider); +} + +/** + * Prints a standard two-column row. + */ +function printRow(name: string, content: string): void { + console.log(`${label(padLabel(name))}${content}`); +} + +/** + * Prints a continuation row aligned with the value column. + */ +function printContinuation(content: string): void { + console.log(`${label(padLabel(''))}${content}`); +} + +/** + * Prints env files where the key is defined. + */ +function printFileList(title: string, files: string[]): void { + if (files.length === 0) { + printRow(title, error('(not found in any env file)')); + return; + } + + files.forEach((file, index) => { + const content = value(normalizePath(file)); + + if (index === 0) { + printRow(title, content); + return; + } + + printContinuation(content); + }); +} + +/** + * Prints source usages for the key. + */ +function printUsageList(usages: EnvUsage[]): void { + if (usages.length === 0) { + printRow('Used in', warning('(not found in codebase)')); + return; + } + + printRow( + 'Used in', + accent( + `${usages.length} ${usages.length === 1 ? 'location' : 'locations'}`, + ), + ); + + for (const usage of usages) { + printUsage(usage); + } +} + +/** + * Prints one usage location with pattern and short code context. + */ +function printUsage(usage: EnvUsage): void { + const location = `${normalizePath(usage.file)}:${usage.line}`; + const context = usage.context.trim(); + const pattern = dim(`[${usage.pattern}]`); + + const content = context + ? `${accent(location)} ${pattern} ${dim(context)}` + : `${accent(location)} ${pattern}`; + + printContinuation(content); +} + +/** + * Prints status checks for the key. + */ +function printChecks(checks: { + isDefined: boolean; + isUsed: boolean; + isDuplicated: boolean; + isIgnored: boolean; +}): void { + printRow('Checks', formatCheck('Defined', checks.isDefined, 'error')); + printContinuation(formatCheck('Used', checks.isUsed, 'warning')); + printContinuation( + formatCheck('Not duplicated', !checks.isDuplicated, 'warning'), + ); + printContinuation(formatCheck('Not ignored', !checks.isIgnored, 'warning')); +} + +/** + * Formats a status check row. + */ +function formatCheck( + text: string, + ok: boolean, + failSeverity: 'error' | 'warning', +): string { + if (ok) { + return `${accent('✓')} ${accent(text)}`; + } + + const color = failSeverity === 'error' ? error : warning; + return `${color('✘')} ${color(text)}`; +} + +/** + * Returns the overall visual status for the explain result. + */ +function getSummary(result: ExplainResult): { + indicator: string; + text: string; +} { + if (result.definedIn.length === 0) { + return { + indicator: error('▸'), + text: error('missing from env files'), + }; + } + + if (result.usages.length === 0 || result.isDuplicated || result.isIgnored) { + return { + indicator: warning('▸'), + text: warning('needs attention'), + }; + } + + return { + indicator: accent('▸'), + text: accent('ok'), + }; +} diff --git a/packages/cli/test/unit/cli/run.test.ts b/packages/cli/test/unit/cli/run.test.ts index a24134f6..cb1577d0 100644 --- a/packages/cli/test/unit/cli/run.test.ts +++ b/packages/cli/test/unit/cli/run.test.ts @@ -47,6 +47,10 @@ vi.mock('../../../src/commands/init.js', () => ({ runInit: vi.fn(), })); +vi.mock('../../../src/commands/explain.js', () => ({ + explainKey: vi.fn(), +})); + import { run } from '../../../src/cli/run.js'; import { normalizeOptions } from '../../../src/config/options.js'; import { discoverEnvFiles } from '../../../src/services/envDiscovery.js'; @@ -57,6 +61,7 @@ import { scanUsage } from '../../../src/commands/scanUsage.js'; import { setupGlobalConfig } from '../../../src/ui/shared/setupGlobalConfig.js'; import { loadConfig } from '../../../src/config/loadConfig.js'; import { runInit } from '../../../src/commands/init.js'; +import { explainKey } from '../../../src/commands/explain.js'; function createBaseOptions(overrides: Partial = {}): Options { return { @@ -87,6 +92,7 @@ function createBaseOptions(overrides: Partial = {}): Options { expireWarnings: true, inconsistentNamingWarnings: true, listAll: false, + explain: undefined, ...overrides, }; } @@ -101,6 +107,7 @@ describe('run', () => { const mockSetupGlobalConfig = setupGlobalConfig as ReturnType; const mockLoadConfig = loadConfig as ReturnType; const mockRunInit = runInit as ReturnType; + const mockExplainKey = explainKey as ReturnType; let exitSpy: ReturnType; @@ -440,4 +447,47 @@ describe('run', () => { existsSpy.mockRestore(); }); + + it('routes to explainKey when opts.explain is set and exits with 0', async () => { + const program = { + parse: vi.fn(), + opts: vi.fn(() => ({ explain: 'MY_KEY' })), + } as unknown as Command; + + mockNormalizeOptions.mockReturnValue( + createBaseOptions({ explain: 'MY_KEY' }), + ); + mockExplainKey.mockResolvedValue(undefined); + // process.exit is mocked (doesn't really exit), so scanUsage is reached too + mockScanUsage.mockResolvedValue({ exitWithError: false }); + + await run(program); + + expect(mockExplainKey).toHaveBeenCalledOnce(); + expect(mockExplainKey).toHaveBeenCalledWith( + expect.objectContaining({ key: 'MY_KEY' }), + ); + // First exit(0) is from the explain path + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('passes json flag to explainKey', async () => { + const program = { + parse: vi.fn(), + opts: vi.fn(() => ({ explain: 'DB_URL' })), + } as unknown as Command; + + mockNormalizeOptions.mockReturnValue( + createBaseOptions({ explain: 'DB_URL', json: true }), + ); + mockExplainKey.mockResolvedValue(undefined); + mockScanUsage.mockResolvedValue({ exitWithError: false }); + + await run(program); + + expect(mockExplainKey).toHaveBeenCalledWith( + expect.objectContaining({ key: 'DB_URL', json: true }), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); }); diff --git a/packages/cli/test/unit/commands/explain.test.ts b/packages/cli/test/unit/commands/explain.test.ts new file mode 100644 index 00000000..94bc46d6 --- /dev/null +++ b/packages/cli/test/unit/commands/explain.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import { explainKey } from '../../../src/commands/explain.js'; +import type { ExplainOptions } from '../../../src/commands/explain.js'; + +// ---- mocks ---- +vi.mock('../../../src/services/scanCodebase.js', () => ({ + scanCodebase: vi.fn(), +})); + +vi.mock('../../../src/services/parseEnvFile.js', () => ({ + parseEnvFile: vi.fn(), +})); + +vi.mock('../../../src/core/duplicates.js', () => ({ + findDuplicateKeys: vi.fn(), +})); + +vi.mock('../../../src/core/helpers/skipCommentedUsages.js', () => ({ + skipCommentedUsages: vi.fn((usages) => usages), +})); + +vi.mock('../../../src/ui/scan/printExplain.js', () => ({ + printExplain: vi.fn(), +})); + +import { scanCodebase } from '../../../src/services/scanCodebase.js'; +import { parseEnvFile } from '../../../src/services/parseEnvFile.js'; +import { findDuplicateKeys } from '../../../src/core/duplicates.js'; +import { skipCommentedUsages } from '../../../src/core/helpers/skipCommentedUsages.js'; +import { printExplain } from '../../../src/ui/scan/printExplain.js'; + +const mockScanCodebase = scanCodebase as ReturnType; +const mockParseEnvFile = parseEnvFile as ReturnType; +const mockFindDuplicateKeys = findDuplicateKeys as ReturnType; +const mockSkipCommentedUsages = skipCommentedUsages as ReturnType; +const mockPrintExplain = printExplain as ReturnType; + +function baseOpts(overrides: Partial = {}): ExplainOptions { + return { + key: 'API_KEY', + cwd: '/project', + include: [], + exclude: [], + ignore: [], + ignoreRegex: [], + files: [], + secrets: false, + json: false, + ...overrides, + }; +} + +describe('explainKey', () => { + let readdirSyncSpy: ReturnType; + let logSpy: ReturnType; + + beforeEach(() => { + // By default no env files on disk + readdirSyncSpy = vi.spyOn(fs, 'readdirSync').mockReturnValue([] as never); + + mockParseEnvFile.mockReturnValue({}); + mockFindDuplicateKeys.mockReturnValue([]); + mockSkipCommentedUsages.mockImplementation((u) => u); + mockScanCodebase.mockResolvedValue({ used: [] }); + + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + readdirSyncSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('calls printExplain when json is false', async () => { + await explainKey(baseOpts()); + + expect(mockPrintExplain).toHaveBeenCalledOnce(); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('outputs JSON and does not call printExplain when json is true', async () => { + await explainKey(baseOpts({ json: true })); + + expect(mockPrintExplain).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledOnce(); + + const jsonArg = logSpy.mock.calls[0]?.[0]; + const parsed = JSON.parse(jsonArg as string); + expect(parsed).toMatchObject({ + key: 'API_KEY', + definedIn: [], + usages: [], + isDuplicated: false, + isIgnored: false, + }); + }); + + it('marks key as defined when env file contains it', async () => { + readdirSyncSpy.mockReturnValue(['.env'] as never); + mockParseEnvFile.mockReturnValue({ API_KEY: 'secret' }); + mockFindDuplicateKeys.mockReturnValue([]); + + await explainKey(baseOpts({ json: true })); + + const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(parsed.definedIn).toEqual(['.env']); + }); + + it('does not add file to definedIn when env file exists but key is absent', async () => { + readdirSyncSpy.mockReturnValue(['.env'] as never); + mockParseEnvFile.mockReturnValue({ OTHER_KEY: 'value' }); // API_KEY not present + + await explainKey(baseOpts({ json: true })); + + const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(parsed.definedIn).toEqual([]); + }); + + it('marks key as duplicated when findDuplicateKeys returns the key', async () => { + readdirSyncSpy.mockReturnValue(['.env'] as never); + mockParseEnvFile.mockReturnValue({ API_KEY: 'secret' }); + mockFindDuplicateKeys.mockReturnValue([{ key: 'API_KEY', count: 2 }]); + + await explainKey(baseOpts({ json: true })); + + const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(parsed.isDuplicated).toBe(true); + }); + + it('marks key as ignored when key is in ignore list', async () => { + await explainKey(baseOpts({ ignore: ['API_KEY'], json: true })); + + const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(parsed.isIgnored).toBe(true); + }); + + it('marks key as ignored when key matches ignoreRegex', async () => { + await explainKey(baseOpts({ ignoreRegex: [/^API_/], json: true })); + + const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(parsed.isIgnored).toBe(true); + }); + + it('includes only usages matching the key', async () => { + const usage = { + variable: 'API_KEY', + file: 'src/app.ts', + line: 5, + column: 10, + pattern: 'process.env' as const, + context: 'process.env.API_KEY', + }; + const otherUsage = { + variable: 'OTHER_KEY', + file: 'src/app.ts', + line: 6, + column: 10, + pattern: 'process.env' as const, + context: 'process.env.OTHER_KEY', + }; + mockScanCodebase.mockResolvedValue({ used: [usage, otherUsage] }); + + await explainKey(baseOpts({ json: true })); + + const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(parsed.usages).toHaveLength(1); + expect(parsed.usages[0].variable).toBe('API_KEY'); + }); + + it('returns early with empty definedIn when readdirSync throws', async () => { + readdirSyncSpy.mockImplementation(() => { + throw new Error('EACCES'); + }); + + await explainKey(baseOpts({ json: true })); + + const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(parsed.definedIn).toEqual([]); + }); + + it('discovers .env and .env.example files but not unrelated files', async () => { + readdirSyncSpy.mockReturnValue([ + '.env', + '.env.local', + '.env.example', + 'package.json', + 'src', + ] as never); + mockParseEnvFile.mockReturnValue({ API_KEY: 'x' }); + + await explainKey(baseOpts({ key: 'API_KEY', json: true })); + + // parseEnvFile should be called for .env, .env.local, .env.example — but NOT package.json or src + expect(mockParseEnvFile).toHaveBeenCalledTimes(3); + }); + + it('passes correct relative path relative to cwd for definedIn', async () => { + readdirSyncSpy.mockReturnValue(['.env'] as never); + mockParseEnvFile.mockReturnValue({ API_KEY: 'x' }); + + const cwd = '/my/project'; + await explainKey(baseOpts({ cwd, json: true })); + + const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(parsed.definedIn).toEqual(['.env']); + }); + + it('passes opts to printExplain with correct shape in non-json mode', async () => { + readdirSyncSpy.mockReturnValue(['.env'] as never); + mockParseEnvFile.mockReturnValue({ API_KEY: 'val' }); + + await explainKey(baseOpts()); + + expect(mockPrintExplain).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'API_KEY', + isDuplicated: false, + isIgnored: false, + }), + ); + }); +}); diff --git a/packages/cli/test/unit/config/options.test.ts b/packages/cli/test/unit/config/options.test.ts index ac160457..01ed07d0 100644 --- a/packages/cli/test/unit/config/options.test.ts +++ b/packages/cli/test/unit/config/options.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import path from 'path'; import { normalizeOptions } from '../../../src/config/options.js'; @@ -93,4 +93,15 @@ describe('normalizeOptions', () => { normalizeOptions({ scanUsage: 'false' as unknown as boolean }).scanUsage, ).toBe(false); }); + + it('parses explain as trimmed string when provided', () => { + expect(normalizeOptions({ explain: ' MY_KEY ' }).explain).toBe('MY_KEY'); + }); + + it('returns undefined for explain when not a string', () => { + expect(normalizeOptions({}).explain).toBeUndefined(); + expect( + normalizeOptions({ explain: true as unknown as string }).explain, + ).toBeUndefined(); + }); }); diff --git a/packages/cli/test/unit/ui/scan/printExplain.test.ts b/packages/cli/test/unit/ui/scan/printExplain.test.ts new file mode 100644 index 00000000..0a26231f --- /dev/null +++ b/packages/cli/test/unit/ui/scan/printExplain.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { printExplain } from '../../../../src/ui/scan/printExplain.js'; +import type { ExplainResult } from '../../../../src/ui/scan/printExplain.js'; +import type { EnvUsage } from '../../../../src/config/types.js'; + +vi.mock('../../../../src/core/helpers/normalizePath.js', () => ({ + normalizePath: (p: string) => p, +})); + +// Keep theme colors transparent so assertions work on raw text +vi.mock('../../../../src/ui/theme.js', () => ({ + accent: (s: string) => s, + dim: (s: string) => s, + error: (s: string) => s, + header: (s: string) => s, + label: (s: string) => s, + divider: '---', + padLabel: (s: string) => s, + value: (s: string) => s, + warning: (s: string) => s, +})); + +function makeUsage( + variable: string, + file = 'src/app.ts', + context = '', +): EnvUsage { + return { + variable, + file, + line: 5, + column: 10, + pattern: 'process.env', + context, + }; +} + +function baseResult(overrides: Partial = {}): ExplainResult { + return { + key: 'API_KEY', + definedIn: [], + usages: [], + isDuplicated: false, + isIgnored: false, + ...overrides, + }; +} + +describe('printExplain', () => { + let _logSpy: ReturnType; + let allOutput: string[]; + + beforeEach(() => { + allOutput = []; + _logSpy = vi + .spyOn(console, 'log') + .mockImplementation((...args: unknown[]) => { + allOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── getSummary branches ────────────────────────────────────────────────── + + it('shows "missing from env files" when definedIn is empty', () => { + printExplain(baseResult()); + + const output = allOutput.join('\n'); + expect(output).toContain('missing from env files'); + }); + + it('shows "needs attention" when key is defined but not used', () => { + printExplain(baseResult({ definedIn: ['.env'] })); + + const output = allOutput.join('\n'); + expect(output).toContain('needs attention'); + }); + + it('shows "needs attention" when key is duplicated', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY')], + isDuplicated: true, + }), + ); + + const output = allOutput.join('\n'); + expect(output).toContain('needs attention'); + }); + + it('shows "needs attention" when key is ignored', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY')], + isIgnored: true, + }), + ); + + const output = allOutput.join('\n'); + expect(output).toContain('needs attention'); + }); + + it('shows "ok" when key is defined, used, not duplicated, not ignored', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY')], + }), + ); + + const output = allOutput.join('\n'); + expect(output).toContain('ok'); + expect(output).not.toContain('needs attention'); + expect(output).not.toContain('missing from env files'); + }); + + // ── printFileList branches ─────────────────────────────────────────────── + + it('prints "(not found in any env file)" when definedIn is empty', () => { + printExplain(baseResult()); + + expect(allOutput.join('\n')).toContain('(not found in any env file)'); + }); + + it('prints single file on the label row', () => { + printExplain(baseResult({ definedIn: ['.env'] })); + + const output = allOutput.join('\n'); + expect(output).toContain('Defined in'); + expect(output).toContain('.env'); + }); + + it('prints multiple files with continuation rows', () => { + printExplain( + baseResult({ definedIn: ['.env', '.env.local', '.env.example'] }), + ); + + // The first file is on the label row, the rest are continuation rows; + // all three paths must appear in the combined output + const output = allOutput.join('\n'); + expect(output).toContain('.env'); + expect(output).toContain('.env.local'); + expect(output).toContain('.env.example'); + }); + + // ── printUsageList branches ────────────────────────────────────────────── + + it('prints "(not found in codebase)" when there are no usages', () => { + printExplain(baseResult({ definedIn: ['.env'] })); + + expect(allOutput.join('\n')).toContain('(not found in codebase)'); + }); + + it('reports "1 location" for a single usage', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY')], + }), + ); + + expect(allOutput.join('\n')).toContain('1 location'); + }); + + it('reports "2 locations" for multiple usages', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY'), makeUsage('API_KEY', 'src/b.ts')], + }), + ); + + expect(allOutput.join('\n')).toContain('2 locations'); + }); + + it('prints usage with context when context is non-empty', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY', 'src/app.ts', 'process.env.API_KEY')], + }), + ); + + const output = allOutput.join('\n'); + expect(output).toContain('src/app.ts:5'); + expect(output).toContain('process.env.API_KEY'); + expect(output).toContain('[process.env]'); + }); + + it('prints usage without context when context is empty', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY', 'src/other.ts', '')], + }), + ); + + const output = allOutput.join('\n'); + expect(output).toContain('src/other.ts:5'); + expect(output).toContain('[process.env]'); + }); + + // ── printChecks / formatCheck branches ────────────────────────────────── + + it('shows ✓ for all checks when key is fully healthy', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY')], + isDuplicated: false, + isIgnored: false, + }), + ); + + const checks = allOutput.filter((l) => l.includes('✓') || l.includes('✘')); + expect(checks.every((l) => l.includes('✓'))).toBe(true); + }); + + it('shows ✘ Defined when key is not defined', () => { + printExplain(baseResult({ definedIn: [] })); + + const checksOutput = allOutput.join('\n'); + expect(checksOutput).toContain('✘'); + expect(checksOutput).toContain('Defined'); + }); + + it('shows ✘ Used when key is not used', () => { + printExplain(baseResult({ definedIn: ['.env'] })); + + const checksOutput = allOutput.join('\n'); + // "Used" check should fail + expect(checksOutput).toContain('✘'); + }); + + it('shows ✘ Not duplicated when isDuplicated is true', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY')], + isDuplicated: true, + }), + ); + + expect(allOutput.join('\n')).toContain('✘'); + }); + + it('shows ✘ Not ignored when isIgnored is true', () => { + printExplain( + baseResult({ + definedIn: ['.env'], + usages: [makeUsage('API_KEY')], + isIgnored: true, + }), + ); + + expect(allOutput.join('\n')).toContain('✘'); + }); + + // ── structural output ──────────────────────────────────────────────────── + + it('prints the key name in the header', () => { + printExplain(baseResult({ key: 'MY_SECRET' })); + + expect(allOutput.join('\n')).toContain('MY_SECRET'); + }); + + it('prints divider at start and end', () => { + printExplain(baseResult()); + + const dividerLines = allOutput.filter((l) => l === '---'); + expect(dividerLines.length).toBeGreaterThanOrEqual(2); + }); +}); From cfeb6def587062292f2e0165321e606defbb8f01 Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Sun, 24 May 2026 12:17:21 +0200 Subject: [PATCH 2/6] chore: fixed ignored ui --- packages/cli/src/ui/scan/printExplain.ts | 5 ++++- packages/cli/test/unit/ui/scan/printExplain.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/scan/printExplain.ts b/packages/cli/src/ui/scan/printExplain.ts index 893c34cb..baa7a997 100644 --- a/packages/cli/src/ui/scan/printExplain.ts +++ b/packages/cli/src/ui/scan/printExplain.ts @@ -136,7 +136,10 @@ function printChecks(checks: { printContinuation( formatCheck('Not duplicated', !checks.isDuplicated, 'warning'), ); - printContinuation(formatCheck('Not ignored', !checks.isIgnored, 'warning')); + + if (checks.isIgnored) { + printContinuation(warning('⚠ Key is ignored')); + } } /** diff --git a/packages/cli/test/unit/ui/scan/printExplain.test.ts b/packages/cli/test/unit/ui/scan/printExplain.test.ts index 0a26231f..23c68d5d 100644 --- a/packages/cli/test/unit/ui/scan/printExplain.test.ts +++ b/packages/cli/test/unit/ui/scan/printExplain.test.ts @@ -249,7 +249,7 @@ describe('printExplain', () => { expect(allOutput.join('\n')).toContain('✘'); }); - it('shows ✘ Not ignored when isIgnored is true', () => { + it('shows ⚠ warning when isIgnored is true', () => { printExplain( baseResult({ definedIn: ['.env'], @@ -258,9 +258,9 @@ describe('printExplain', () => { }), ); - expect(allOutput.join('\n')).toContain('✘'); + expect(allOutput.join('\n')).toContain('⚠ Key is ignored'); + expect(allOutput.join('\n')).not.toContain('Not ignored'); }); - // ── structural output ──────────────────────────────────────────────────── it('prints the key name in the header', () => { From 534329a6dc70ccbc07b6437b85101d5f2cfd79c8 Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Sun, 24 May 2026 18:24:16 +0200 Subject: [PATCH 3/6] chore: updated readme and docs --- README.md | 12 ++++++++++++ docs/configuration_and_flags.md | 2 -- packages/cli/README.md | 12 ++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 69191c4d..d7217629 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,18 @@ API_TOKEN= --- +## Explain a variable (`--explain`) + +Inspect a specific environment variable to see where it is defined, where it is used in the codebase, and its overall status: + +```bash +dotenv-diff --explain DATABASE_URL +``` + +→ See [--explain Documentation](./docs/configuration_and_flags.md#--explain-key) for more details. + +--- + ## Monorepo support In monorepos with multiple apps and packages, you can include shared folders: diff --git a/docs/configuration_and_flags.md b/docs/configuration_and_flags.md index 10c7c221..c87d8531 100644 --- a/docs/configuration_and_flags.md +++ b/docs/configuration_and_flags.md @@ -492,8 +492,6 @@ Use `--json` together with `--explain` to get the result as structured JSON: dotenv-diff --explain DATABASE_URL --json ``` -The JSON output includes the full list of usage locations. - Usage in the configuration file: ```json diff --git a/packages/cli/README.md b/packages/cli/README.md index bc43619b..118ce5ad 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -98,6 +98,18 @@ API_TOKEN= --- +## Explain a variable (`--explain`) + +Inspect a specific environment variable to see where it is defined, where it is used in the codebase, and its overall status: + +```bash +dotenv-diff --explain DATABASE_URL +``` + +→ See [--explain Documentation](https://github.com/Chrilleweb/dotenv-diff/blob/main/docs/configuration_and_flags.md#--explain-key) for more details. + +--- + ## Monorepo support In monorepos with multiple apps and packages, you can include shared folders: From b5d30490ce047b9e28cff8b3d66cb10e78a6ef95 Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Sun, 24 May 2026 18:33:38 +0200 Subject: [PATCH 4/6] chore: jsdocs --- packages/cli/src/ui/scan/printExplain.ts | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/cli/src/ui/scan/printExplain.ts b/packages/cli/src/ui/scan/printExplain.ts index baa7a997..e8404cff 100644 --- a/packages/cli/src/ui/scan/printExplain.ts +++ b/packages/cli/src/ui/scan/printExplain.ts @@ -13,15 +13,22 @@ import { } from '../theme.js'; export interface ExplainResult { + /** The environment variable key being explained */ key: string; + /** List of env files where the key is defined */ definedIn: string[]; + /** List of usages for the environment variable */ usages: EnvUsage[]; + /** Indicates if the key is duplicated */ isDuplicated: boolean; + /** Indicates if the key is ignored */ isIgnored: boolean; } /** * Prints a detailed explanation for a single environment variable key. + * @param result The explain result to print + * @returns void */ export function printExplain(result: ExplainResult): void { const { key, definedIn, usages, isDuplicated, isIgnored } = result; @@ -53,6 +60,9 @@ export function printExplain(result: ExplainResult): void { /** * Prints a standard two-column row. + * @param name The label for the row + * @param content The content for the row + * @returns void */ function printRow(name: string, content: string): void { console.log(`${label(padLabel(name))}${content}`); @@ -60,6 +70,8 @@ function printRow(name: string, content: string): void { /** * Prints a continuation row aligned with the value column. + * @param content The content for the continuation row + * @returns void */ function printContinuation(content: string): void { console.log(`${label(padLabel(''))}${content}`); @@ -67,6 +79,9 @@ function printContinuation(content: string): void { /** * Prints env files where the key is defined. + * @param title The title for the file list + * @param files The list of files + * @returns void */ function printFileList(title: string, files: string[]): void { if (files.length === 0) { @@ -88,6 +103,8 @@ function printFileList(title: string, files: string[]): void { /** * Prints source usages for the key. + * @param usages The list of usages for the environment variable + * @returns void */ function printUsageList(usages: EnvUsage[]): void { if (usages.length === 0) { @@ -109,6 +126,8 @@ function printUsageList(usages: EnvUsage[]): void { /** * Prints one usage location with pattern and short code context. + * @param usage The usage information to print + * @returns void */ function printUsage(usage: EnvUsage): void { const location = `${normalizePath(usage.file)}:${usage.line}`; @@ -124,6 +143,8 @@ function printUsage(usage: EnvUsage): void { /** * Prints status checks for the key. + * @param checks The status of various checks for the key + * @returns void */ function printChecks(checks: { isDefined: boolean; @@ -144,6 +165,10 @@ function printChecks(checks: { /** * Formats a status check row. + * @param text The text for the check + * @param ok Whether the check passed + * @param failSeverity The severity of the failure ('error' or 'warning') + * @returns The formatted check string */ function formatCheck( text: string, @@ -160,6 +185,8 @@ function formatCheck( /** * Returns the overall visual status for the explain result. + * @param result The explain result to summarize + * @returns An object containing the indicator and text for the summary */ function getSummary(result: ExplainResult): { indicator: string; From a6fec7bc490bb2ed2bd9fc7b0cb46eefdf696278 Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Sun, 24 May 2026 18:43:03 +0200 Subject: [PATCH 5/6] chore: added ignore URLS --- packages/cli/src/cli/run.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/cli/run.ts b/packages/cli/src/cli/run.ts index d75fef65..f11cffd4 100644 --- a/packages/cli/src/cli/run.ts +++ b/packages/cli/src/cli/run.ts @@ -61,6 +61,7 @@ export async function run(program: Command): Promise { ignoreRegex: opts.ignoreRegex, files: opts.files, secrets: opts.secrets, + ignoreUrls: opts.ignoreUrls, json: opts.json, }); process.exit(0); From 885b295dfe5b99c27cbd526878ba25d22988ca6c Mon Sep 17 00:00:00 2001 From: Chrilleweb Date: Sun, 24 May 2026 18:43:54 +0200 Subject: [PATCH 6/6] chore: updated comment --- packages/cli/src/cli/run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli/run.ts b/packages/cli/src/cli/run.ts index f11cffd4..11460980 100644 --- a/packages/cli/src/cli/run.ts +++ b/packages/cli/src/cli/run.ts @@ -50,7 +50,7 @@ export async function run(program: Command): Promise { setupGlobalConfig(opts); - // Route to --explain mode + // Handle --explain flag if (opts.explain) { await explainKey({ key: opts.explain,