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

fix: count docker-compose `${VAR}` interpolation as usage to avoid false "unused"
19 changes: 19 additions & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ import { MY_KEY } from '$env/static/public';
MY_KEY
```

Docker Compose files are also scanned for shell-style interpolation, so
variables referenced only from a `compose` file are not reported as unused:

```yaml
# docker-compose.yml, docker-compose.<env>.yml, compose.yml, compose.<env>.yaml
services:
db:
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} # ${VAR}
- POSTGRES_DB=${POSTGRES_DB:-app} # ${VAR:-default}, ${VAR:?err}, ${VAR:+alt}
healthcheck:
test: 'pg_isready --username=$POSTGRES_USER' # bare $VAR
```

> **Note:** Only files named like `docker-compose*.yml`/`.yaml` or
> `compose*.yml`/`.yaml` are treated as Compose files. Other YAML files are not
> scanned. A doubled `$$` (Compose's escape for a literal `$`) is not treated as
> a reference.

## What It Checks For

> **Note:** The scanner skips files containing any line over 500 characters, as these are likely minified or bundled — this avoids false positives across all checks below.
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration_and_flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ Usage in the configuration file:
## File Scanning Flags

> **Note:** Default file patterns: .ts, .js, jsx, tsx, vue, .mjs, .mts, .cjs, .cts, .svelte
>
> Docker Compose files (`docker-compose*.yml`/`.yaml`, `compose*.yml`/`.yaml`) are also scanned by default for `${VAR}` interpolation. Other YAML files are not scanned.

### `--files <patterns>`

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export interface Options {
}

export type EnvPatternName =
'process.env' | 'import.meta.env' | 'sveltekit' | 'vite';
'process.env' | 'import.meta.env' | 'sveltekit' | 'vite' | 'docker-compose';

/**
* Represents a single usage of an environment variable in the codebase.
Expand Down
57 changes: 57 additions & 0 deletions packages/cli/src/core/scan/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,63 @@ export const ENV_PATTERNS: Pattern[] = [
},
];

/**
* Patterns for detecting environment variable usage inside Docker Compose files
* via shell-style interpolation. These run only on compose files (see
* {@link isDockerComposeFile}), never on JS/TS sources, because a bare `${VAR}`
* matcher would misfire on ordinary template literals.
*
* Covers, with the variable captured in group 1:
* ${VAR} – plain interpolation
* ${VAR:-default} / ${VAR-default}
* ${VAR:?error} / ${VAR?error}
* ${VAR:+alt} / ${VAR+alt}
* $VAR – bare interpolation
*
* A doubled `$$` is Compose's escape for a literal `$`, so `$$VAR` must not be
* treated as a reference — the lookbehind on the bare form guards against that.
*/
export const DOCKER_COMPOSE_PATTERNS: Pattern[] = [
{
name: 'docker-compose' as const,
// Braced form, optionally followed by a :-/-/:?/?/:+/+ modifier and value.
regex: /\$\{([A-Z_][A-Z0-9_]*)(?::?[-?+][^}]*)?\}/g,
},
{
name: 'docker-compose' as const,
// Bare form. Not preceded by `$` (escape) or a word char (e.g. `PG$HOST`).
regex: /(?<![$\w])\$([A-Z_][A-Z0-9_]*)/g,
},
];

/**
* Filename globs, matched against the basename, for Docker Compose files that
* should be scanned for `${VAR}` interpolation. Kept deliberately narrow
* (compose files only, not arbitrary YAML) so the scanner's file surface — and
* with it the secret detector — is not widened to every `.yml` in the repo.
*/
export const DEFAULT_INCLUDE_FILE_GLOBS = [
'docker-compose*.yml',
'docker-compose*.yaml',
'compose*.yml',
'compose*.yaml',
];

/**
* Checks whether a file is a Docker Compose file, based on its name.
* @param filePath - The path (or basename) of the file to check.
* @returns True if the file is a Docker Compose file.
*/
export function isDockerComposeFile(filePath: string): boolean {
// Anchored to a path separator (or the start), so only the basename is
// matched — regardless of `/` or `\` separators. Matches compose.yml,
// docker-compose.yaml, docker-compose.dev.yml, compose.prod.yaml… but not
// composer.yml (any extra segment must start with '.' or '-').
return /(?:^|[\\/])(?:docker-)?compose(?:[.-][^\\/]*)?\.ya?ml$/i.test(
filePath,
);
}

// Default file extensions to include in scans
export const DEFAULT_INCLUDE_EXTENSIONS = [
'.js',
Expand Down
70 changes: 41 additions & 29 deletions packages/cli/src/core/scan/scanFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type {
} from '../../config/types.js';
import {
ENV_PATTERNS,
DOCKER_COMPOSE_PATTERNS,
buildSveltekitAliasPatterns,
isDockerComposeFile,
SVELTEKIT_IMPORT_REGEX,
SVELTEKIT_ALIAS_IMPORT_REGEX,
} from './patterns.js';
Expand Down Expand Up @@ -42,40 +44,50 @@ export function scanFile(
// Get relative path from cwd cross-platform compatible
const relativePath = normalizePath(path.relative(opts.cwd, filePath));

// Collect all $env imports used in this file
const envImports: string[] = [];

SVELTEKIT_IMPORT_REGEX.lastIndex = 0;
let importMatch: RegExpExecArray | null;
while ((importMatch = SVELTEKIT_IMPORT_REGEX.exec(content)) !== null) {
envImports.push(importMatch[1]!);
}
// Docker Compose files use shell-style `${VAR}` interpolation rather than any
// JS/TS accessor, so they get their own pattern set. Running the JS patterns
// on them (or the compose patterns on JS) would only produce false matches.
const isCompose = isDockerComposeFile(filePath);

// Collect all $env imports used in this file (SvelteKit only)
const envImports: string[] = [];
// Resolve the framework label for bare `env` object accessors (`env.X`,
// `{ ... } = env`), which look identical across frameworks. A `$env/*` import
// means SvelteKit; otherwise a `loadEnv(` call means Vite. When neither is
// present the label stays 'sveltekit' (the historical default).
const envObjectKind: EnvPatternName =
envImports.length > 0
? 'sveltekit'
: /\bloadEnv\s*\(/.test(content)
? 'vite'
: 'sveltekit';

// Detect aliased $env imports and build dynamic patterns for them
const allPatterns = [...ENV_PATTERNS];

SVELTEKIT_ALIAS_IMPORT_REGEX.lastIndex = 0;
let aliasImportMatch: RegExpExecArray | null;
while (
(aliasImportMatch = SVELTEKIT_ALIAS_IMPORT_REGEX.exec(content)) !== null
) {
allPatterns.push(
...buildSveltekitAliasPatterns(
aliasImportMatch[1]!,
aliasImportMatch[2]!,
),
);
let envObjectKind: EnvPatternName = 'sveltekit';

const allPatterns = isCompose
? [...DOCKER_COMPOSE_PATTERNS]
: [...ENV_PATTERNS];

if (!isCompose) {
SVELTEKIT_IMPORT_REGEX.lastIndex = 0;
let importMatch: RegExpExecArray | null;
while ((importMatch = SVELTEKIT_IMPORT_REGEX.exec(content)) !== null) {
envImports.push(importMatch[1]!);
}

envObjectKind =
envImports.length > 0
? 'sveltekit'
: /\bloadEnv\s*\(/.test(content)
? 'vite'
: 'sveltekit';

// Detect aliased $env imports and build dynamic patterns for them
SVELTEKIT_ALIAS_IMPORT_REGEX.lastIndex = 0;
let aliasImportMatch: RegExpExecArray | null;
while (
(aliasImportMatch = SVELTEKIT_ALIAS_IMPORT_REGEX.exec(content)) !== null
) {
allPatterns.push(
...buildSveltekitAliasPatterns(
aliasImportMatch[1]!,
aliasImportMatch[2]!,
),
);
}
}

for (const pattern of allPatterns) {
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/services/fileWalker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'path';
import fsSync from 'fs';
import {
DEFAULT_INCLUDE_EXTENSIONS,
DEFAULT_INCLUDE_FILE_GLOBS,
DEFAULT_EXCLUDE_PATTERNS,
} from '../core/scan/patterns.js';

Expand Down Expand Up @@ -224,7 +225,12 @@ export async function findFilesByPatterns(
* @returns An array of default glob patterns.
*/
export function getDefaultPatterns(): string[] {
return DEFAULT_INCLUDE_EXTENSIONS.map((ext) => `**/*${ext}`);
return [
...DEFAULT_INCLUDE_EXTENSIONS.map((ext) => `**/*${ext}`),
// Compose files are matched by basename glob (not extension) so arbitrary
// YAML is not pulled into the scan / secret-detection surface.
...DEFAULT_INCLUDE_FILE_GLOBS,
];
}

/**
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/test/unit/core/scan/patterns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DEFAULT_EXCLUDE_PATTERNS,
ENV_PATTERNS,
buildSveltekitAliasPatterns,
isDockerComposeFile,
SVELTEKIT_IMPORT_REGEX,
SVELTEKIT_ALIAS_IMPORT_REGEX,
} from '../../../../src/core/scan/patterns';
Expand Down Expand Up @@ -746,4 +747,34 @@ import { env as privateEnv } from '$env/dynamic/private';`;
]);
});
});

describe('isDockerComposeFile', () => {
it.each([
'compose.yml',
'compose.yaml',
'compose.prod.yaml',
'docker-compose.yml',
'docker-compose.dev.yml',
'src/docker-compose.override.yaml',
'/abs/path/to/compose.yml',
'DOCKER-COMPOSE.YML', // case-insensitive
])('treats %s as a compose file', (p) => {
expect(isDockerComposeFile(p)).toBe(true);
});

it('matches the basename on Windows-style backslash paths', () => {
expect(isDockerComposeFile('C:\\proj\\docker-compose.yml')).toBe(true);
});

it.each([
'composer.yml', // lock file, not compose
'app.yml',
'k8s-deployment.yaml',
'compose.txt',
'docker-compose.yml.bak',
'not-compose.yml', // "compose" not at a path boundary
])('does not treat %s as a compose file', (p) => {
expect(isDockerComposeFile(p)).toBe(false);
});
});
});
70 changes: 70 additions & 0 deletions packages/cli/test/unit/core/scan/scanFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,76 @@ const port = process.env.PORT;
});
});

describe('docker-compose interpolation (Issue B)', () => {
const compose = '/test/project/docker-compose.prod.yml';

it('detects ${VAR} interpolation as usage', () => {
const content = ' - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}';
const usages = scanFile(compose, content, baseOpts);

expect(usages).toHaveLength(1);
expect(usages[0]).toMatchObject({
variable: 'POSTGRES_PASSWORD',
pattern: 'docker-compose',
file: 'docker-compose.prod.yml',
});
});

it('detects ${VAR:-default} and ${VAR:?err} default-modifier forms', () => {
const content = [
' - DOMAINPASS=${SAMBA_ADMIN_PASSWORD:-Passw0rd!}',
' - DB=${POSTGRES_DB:?required}',
].join('\n');
const usages = scanFile(compose, content, baseOpts);

expect(usages.map((u) => u.variable)).toEqual([
'SAMBA_ADMIN_PASSWORD',
'POSTGRES_DB',
]);
});

it('detects bare $VAR interpolation', () => {
const content =
' test: "pg_isready --username=${POSTGRES_USER} --dbname=$POSTGRES_DB"';
const usages = scanFile(compose, content, baseOpts);

expect(usages.map((u) => u.variable)).toEqual([
'POSTGRES_USER',
'POSTGRES_DB',
]);
});

it('ignores the $$ escape for a literal dollar sign', () => {
const content = ' command: echo $$NOT_A_VAR';
const usages = scanFile(compose, content, baseOpts);

expect(usages).toHaveLength(0);
});

it('does not apply compose patterns to JS/TS files', () => {
// A bare ${VAR} in a template literal is a JS expression, not an env ref.
const content = 'const url = `${API_HOST}/v1`;';
const usages = scanFile('/test/project/src/app.ts', content, baseOpts);

expect(usages).toHaveLength(0);
});

it('recognises compose.yaml and docker-compose.<env>.yml names', () => {
for (const name of [
'compose.yaml',
'compose.dev.yml',
'docker-compose.yml',
]) {
const usages = scanFile(
`/test/project/${name}`,
' - X=${SOME_VAR}',
baseOpts,
);
expect(usages.map((u) => u.variable)).toEqual(['SOME_VAR']);
}
});
});

it('calculates correct line and column numbers', () => {
const content = `const x = 1;
const y = 2;
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/test/unit/services/filewalker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,22 @@ describe('filewalker', () => {
expect(result).not.toContain(testFile);
});

it('discovers docker-compose files by default but not arbitrary YAML', async () => {
fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), '');
fs.writeFileSync(path.join(tmpDir, 'docker-compose.prod.yml'), '');
fs.writeFileSync(path.join(tmpDir, 'compose.yaml'), '');
fs.writeFileSync(path.join(tmpDir, 'k8s-deployment.yaml'), '');

const result = await findFiles(tmpDir, {});
const basenames = result.map((f) => path.basename(f));

expect(basenames).toContain('docker-compose.yml');
expect(basenames).toContain('docker-compose.prod.yml');
expect(basenames).toContain('compose.yaml');
// Arbitrary YAML is deliberately not pulled into the scan surface.
expect(basenames).not.toContain('k8s-deployment.yaml');
});

it('handles directory read errors gracefully', async () => {
const result = await findFiles('/nonexistent', {});
expect(result).toEqual([]);
Expand Down