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

add gitignore issues to --json
5 changes: 5 additions & 0 deletions .changeset/goofy-stamps-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': patch
---

fixed gitignore warning bug
56 changes: 14 additions & 42 deletions packages/cli/src/commands/scanUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ComparisonFile,
} from '../config/types.js';
import { determineComparisonFile } from '../core/scan/determineComparisonFile.js';
import { computeExitDecision } from '../core/scan/computeExitDecision.js';
import { printScanResult } from '../services/printScanResult.js';
import { scanJsonOutput } from '../ui/scan/scanJsonOutput.js';
import { printMissingExample } from '../ui/scan/printMissingExample.js';
Expand All @@ -15,11 +16,8 @@ import { printComparisonError } from '../ui/scan/printComparisonError.js';
import { skipCommentedUsages } from '../core/scan/skipCommentedUsages.js';
import { frameworkValidator } from '../core/frameworks/frameworkValidator.js';
import { detectSecretsInExample } from '../core/security/exampleSecretDetector.js';
import {
DEFAULT_EXAMPLE_FILE,
URGENT_EXPIRE_DAYS,
EXPIRE_THRESHOLD_DAYS,
} from '../config/constants.js';
import { DEFAULT_EXAMPLE_FILE } from '../config/constants.js';
import { checkGitignoreStatus } from '../services/git.js';
import { promptNoEnvScenario } from './prompts/promptNoEnvScenario.js';
import {
printBaselineWritten,
Expand Down Expand Up @@ -167,50 +165,24 @@ export async function scanUsage(opts: ScanUsageOptions): Promise<ExitResult> {

// JSON output
if (opts.json) {
// The console path runs this same check inside printScanResult. Both paths
// need it, and only one of them ever runs, so it is checked per branch.
const gitignoreIssue = checkGitignoreStatus({
cwd: opts.cwd,
});

const jsonOutput = scanJsonOutput(
scanResult,
comparedAgainst,
opts.listAll ?? false,
gitignoreIssue,
);
console.log(JSON.stringify(jsonOutput, null, 2));

// Check for high severity secrets
const hasHighSeveritySecrets = (scanResult.secrets ?? []).some(
(s) => s.severity === 'high',
);

// Check for high potential secrets in example warnings
const hasHighSeverityExampleWarnings = (
scanResult.exampleWarnings ?? []
).some((w) => w.severity === 'high');

const hasUrgentExpireWarnings = (scanResult.expireWarnings ?? []).some(
(w) => w.daysLeft <= URGENT_EXPIRE_DAYS,
);

return {
exitWithError:
scanResult.missing.length > 0 ||
hasHighSeveritySecrets ||
hasUrgentExpireWarnings ||
hasHighSeverityExampleWarnings ||
!!(
opts.strict &&
(scanResult.unused.length > 0 ||
(scanResult.duplicates?.env?.length ?? 0) > 0 ||
(scanResult.duplicates?.example?.length ?? 0) > 0 ||
(scanResult.secrets?.length ?? 0) > 0 ||
(scanResult.frameworkWarnings?.length ?? 0) > 0 ||
(scanResult.logged?.length ?? 0) > 0 ||
(scanResult.uppercaseWarnings?.length ?? 0) > 0 ||
(scanResult.expireWarnings?.filter(
(w) => w.daysLeft <= EXPIRE_THRESHOLD_DAYS,
).length ?? 0) > 0 ||
(scanResult.inconsistentNamingWarnings?.length ?? 0) > 0 ||
(scanResult.commentWarnings?.length ?? 0) > 0 ||
(scanResult.exampleWarnings?.length ?? 0) > 0)
),
};
return computeExitDecision(scanResult, {
strict: opts.strict,
hasGitignoreIssue: gitignoreIssue !== null,
});
}

// Console output
Expand Down
87 changes: 87 additions & 0 deletions packages/cli/src/core/scan/computeExitDecision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { ExitResult, ScanResult } from '../../config/types.js';
import {
EXPIRE_THRESHOLD_DAYS,
URGENT_EXPIRE_DAYS,
} from '../../config/constants.js';

/**
* Inputs that affect the exit decision but are not part of {@link ScanResult}.
*/
export interface ExitDecisionOptions {
/** Whether --strict is enabled, promoting warnings to failures */
strict?: boolean | undefined;
/**
* Whether a .gitignore issue was detected. Passed in rather than checked here
* because it needs the filesystem, while this function stays pure.
*/
hasGitignoreIssue?: boolean | undefined;
}

/**
* Determines whether a scan should exit with a non-zero code.
*
* This is the single source of truth shared by every output path, so a finding
* can never fail the build in one format and pass in another.
*
* Two tiers of findings exist:
* - Hard failures always exit non-zero: missing keys, high-severity secrets
* (in code or in the example file), and keys expiring within
* {@link URGENT_EXPIRE_DAYS} days.
* - Strict violations only exit non-zero under --strict: every remaining
* warning category.
* @param scanResult - The scan result to evaluate.
* @param opts - Options affecting the decision (strict mode, .gitignore state).
* @returns Whether the caller should exit with a non-zero code.
*/
export function computeExitDecision(
scanResult: ScanResult,
opts: ExitDecisionOptions = {},
): ExitResult {
const exitWithError =
hasHardFailure(scanResult) ||
(!!opts.strict && hasStrictViolation(scanResult, opts.hasGitignoreIssue));

return { exitWithError };
}

/**
* Findings severe enough to fail the build regardless of --strict.
* @param scan - The scan result to evaluate.
* @returns True when at least one hard failure was found.
*/
function hasHardFailure(scan: ScanResult): boolean {
return (
scan.missing.length > 0 ||
(scan.secrets ?? []).some((s) => s.severity === 'high') ||
(scan.exampleWarnings ?? []).some((w) => w.severity === 'high') ||
(scan.expireWarnings ?? []).some((w) => w.daysLeft <= URGENT_EXPIRE_DAYS)
);
}

/**
* Warning-level findings that only fail the build under --strict.
* @param scan - The scan result to evaluate.
* @param hasGitignoreIssue - Whether a .gitignore issue was detected.
* @returns True when at least one strict violation was found.
*/
function hasStrictViolation(
scan: ScanResult,
hasGitignoreIssue?: boolean,
): boolean {
return (
scan.unused.length > 0 ||
(scan.duplicates?.env?.length ?? 0) > 0 ||
(scan.duplicates?.example?.length ?? 0) > 0 ||
(scan.secrets?.length ?? 0) > 0 ||
(scan.exampleWarnings?.length ?? 0) > 0 ||
(scan.frameworkWarnings?.length ?? 0) > 0 ||
(scan.logged?.length ?? 0) > 0 ||
(scan.uppercaseWarnings?.length ?? 0) > 0 ||
(scan.expireWarnings ?? []).some(
(w) => w.daysLeft <= EXPIRE_THRESHOLD_DAYS,
) ||
(scan.inconsistentNamingWarnings?.length ?? 0) > 0 ||
(scan.commentWarnings?.length ?? 0) > 0 ||
!!hasGitignoreIssue
);
}
2 changes: 1 addition & 1 deletion packages/cli/src/services/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface GitignoreCheckOptions {

/** Are we in a git repo? (checks for .git directory in cwd) */
export function isGitRepo(cwd = process.cwd()): boolean {
return fs.existsSync(path.resolve(cwd, GIT_DIR));
return findGitRoot(cwd) !== null;
}

/**
Expand Down
74 changes: 12 additions & 62 deletions packages/cli/src/services/printScanResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ import type {
ExitResult,
FixContext,
} from '../config/types.js';
import {
DEFAULT_ENV_FILE,
EXPIRE_THRESHOLD_DAYS,
URGENT_EXPIRE_DAYS,
} from '../config/constants.js';
import { DEFAULT_ENV_FILE } from '../config/constants.js';
import { printHeader } from '../ui/scan/printHeader.js';
import { printStats } from '../ui/scan/printStats.js';
import { printMissing } from '../ui/scan/printMissing.js';
Expand All @@ -24,6 +20,7 @@ import { printExampleWarnings } from '../ui/scan/printExampleWarnings.js';
import { printConsolelogWarning } from '../ui/scan/printConsolelogWarning.js';
import { printUppercaseWarning } from '../ui/scan/printUppercaseWarning.js';
import { computeHealthScore } from '../core/scan/computeHealthScore.js';
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';
Expand All @@ -43,8 +40,6 @@ export function printScanResult(
comparedAgainst: string,
fixContext?: FixContext,
): ExitResult {
let exitWithError = false;

// Determine if output should be in JSON format
const isJson = opts.json;

Expand All @@ -60,16 +55,12 @@ export function printScanResult(
}

// Missing variables (used in code but not in env file)
if (
printMissing(
scanResult.missing,
scanResult.used,
comparedAgainst,
scanResult.suggestions ?? [],
)
) {
exitWithError = true;
}
printMissing(
scanResult.missing,
scanResult.used,
comparedAgainst,
scanResult.suggestions ?? [],
);

if (scanResult.frameworkWarnings) {
printFrameworkWarnings(scanResult.frameworkWarnings, opts.strict);
Expand Down Expand Up @@ -127,32 +118,6 @@ export function printScanResult(
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',
);

if (hasHighSeveritySecrets) {
exitWithError = true;
}

// Check for high severity example secrets - ALWAYS exit with error
const hasHighSeverityExampleSecrets = (scanResult.exampleWarnings ?? []).some(
(w) => w.severity === 'high',
);

if (hasHighSeverityExampleSecrets) {
exitWithError = true;
}

const hasUrgentExpireWarnings = (scanResult.expireWarnings ?? []).some(
(w) => w.daysLeft <= URGENT_EXPIRE_DAYS,
);

if (hasUrgentExpireWarnings) {
exitWithError = true;
}

// Gitignore check
const gitignoreIssue = checkGitignoreStatus({
cwd: opts.cwd,
Expand All @@ -169,25 +134,10 @@ export function printScanResult(

const hasGitignoreIssue = gitignoreIssue !== null;

if (opts.strict) {
const hasStrictViolations =
scanResult.unused.length > 0 ||
(scanResult.duplicates?.env?.length ?? 0) > 0 ||
(scanResult.duplicates?.example?.length ?? 0) > 0 ||
(scanResult.secrets?.length ?? 0) > 0 ||
(scanResult.exampleWarnings?.length ?? 0) > 0 ||
hasGitignoreIssue ||
(scanResult.frameworkWarnings?.length ?? 0) > 0 ||
(scanResult.logged?.length ?? 0) > 0 ||
(scanResult.uppercaseWarnings?.length ?? 0) > 0 ||
(scanResult.expireWarnings?.filter(
(w) => w.daysLeft <= EXPIRE_THRESHOLD_DAYS,
).length ?? 0) > 0 ||
(scanResult.inconsistentNamingWarnings?.length ?? 0) > 0 ||
(scanResult.commentWarnings?.length ?? 0) > 0;

if (hasStrictViolations) exitWithError = true;
}
const { exitWithError } = computeExitDecision(scanResult, {
strict: opts.strict,
hasGitignoreIssue,
});

if (opts.fix && fixContext) {
printAutoFix(fixContext, comparedAgainst || DEFAULT_ENV_FILE, isJson);
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/ui/scan/scanJsonOutput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
FrameworkWarning,
ExampleSecretWarning,
TypoSuggestion,
GitignoreIssue,
} from '../../config/types.js';
import { computeHealthScore } from '../../core/scan/computeHealthScore.js';
import { normalizePath } from '../../core/helpers/normalizePath.js';
Expand Down Expand Up @@ -50,6 +51,7 @@ interface ScanJsonOutput {
env?: Duplicate[];
example?: Duplicate[];
};
gitignoreIssue?: { reason: GitignoreIssue };
logged?: EnvUsage[];
expireWarnings?: ExpireWarning[];
commentWarnings?: CommentWarning[];
Expand All @@ -64,12 +66,15 @@ interface ScanJsonOutput {
* Creates a JSON output for the scan results.
* @param scanResult - The result of the scan.
* @param comparedAgainst - The file being compared against.
* @param listAll - Whether it should list all variables
* @param gitignoreIssue - A detected .gitignore issue, or null when there is none.
* @returns The JSON output.
*/
export function scanJsonOutput(
scanResult: ScanResult,
comparedAgainst: string,
listAll: boolean = false,
gitignoreIssue: { reason: GitignoreIssue } | null = null,
): ScanJsonOutput {
const output: ScanJsonOutput = {};

Expand Down Expand Up @@ -160,6 +165,10 @@ export function scanJsonOutput(
output.duplicates = scanResult.duplicates;
}

if (gitignoreIssue) {
output.gitignoreIssue = gitignoreIssue;
}

// Add logged variables if any
if (scanResult.logged?.length) {
output.logged = scanResult.logged.map((l) => ({
Expand Down
Loading