Skip to content

Commit 6a33006

Browse files
authored
fix: count docker-compose variables (#486)
1 parent 7507a6a commit 6a33006

10 files changed

Lines changed: 249 additions & 31 deletions

File tree

.changeset/docker-compose-usage.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'dotenv-diff': patch
3+
---
4+
5+
fix: count docker-compose `${VAR}` interpolation as usage to avoid false "unused"

docs/capabilities.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,25 @@ import { MY_KEY } from '$env/static/public';
4545
MY_KEY
4646
```
4747

48+
Docker Compose files are also scanned for shell-style interpolation, so
49+
variables referenced only from a `compose` file are not reported as unused:
50+
51+
```yaml
52+
# docker-compose.yml, docker-compose.<env>.yml, compose.yml, compose.<env>.yaml
53+
services:
54+
db:
55+
environment:
56+
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} # ${VAR}
57+
- POSTGRES_DB=${POSTGRES_DB:-app} # ${VAR:-default}, ${VAR:?err}, ${VAR:+alt}
58+
healthcheck:
59+
test: 'pg_isready --username=$POSTGRES_USER' # bare $VAR
60+
```
61+
62+
> **Note:** Only files named like `docker-compose*.yml`/`.yaml` or
63+
> `compose*.yml`/`.yaml` are treated as Compose files. Other YAML files are not
64+
> scanned. A doubled `$$` (Compose's escape for a literal `$`) is not treated as
65+
> a reference.
66+
4867
## What It Checks For
4968

5069
> **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.

docs/configuration_and_flags.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,8 @@ Usage in the configuration file:
381381
## File Scanning Flags
382382

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

packages/cli/src/config/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export interface Options {
149149
}
150150

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

154154
/**
155155
* Represents a single usage of an environment variable in the codebase.

packages/cli/src/core/scan/patterns.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,63 @@ export const ENV_PATTERNS: Pattern[] = [
206206
},
207207
];
208208

209+
/**
210+
* Patterns for detecting environment variable usage inside Docker Compose files
211+
* via shell-style interpolation. These run only on compose files (see
212+
* {@link isDockerComposeFile}), never on JS/TS sources, because a bare `${VAR}`
213+
* matcher would misfire on ordinary template literals.
214+
*
215+
* Covers, with the variable captured in group 1:
216+
* ${VAR} – plain interpolation
217+
* ${VAR:-default} / ${VAR-default}
218+
* ${VAR:?error} / ${VAR?error}
219+
* ${VAR:+alt} / ${VAR+alt}
220+
* $VAR – bare interpolation
221+
*
222+
* A doubled `$$` is Compose's escape for a literal `$`, so `$$VAR` must not be
223+
* treated as a reference — the lookbehind on the bare form guards against that.
224+
*/
225+
export const DOCKER_COMPOSE_PATTERNS: Pattern[] = [
226+
{
227+
name: 'docker-compose' as const,
228+
// Braced form, optionally followed by a :-/-/:?/?/:+/+ modifier and value.
229+
regex: /\$\{([A-Z_][A-Z0-9_]*)(?::?[-?+][^}]*)?\}/g,
230+
},
231+
{
232+
name: 'docker-compose' as const,
233+
// Bare form. Not preceded by `$` (escape) or a word char (e.g. `PG$HOST`).
234+
regex: /(?<![$\w])\$([A-Z_][A-Z0-9_]*)/g,
235+
},
236+
];
237+
238+
/**
239+
* Filename globs, matched against the basename, for Docker Compose files that
240+
* should be scanned for `${VAR}` interpolation. Kept deliberately narrow
241+
* (compose files only, not arbitrary YAML) so the scanner's file surface — and
242+
* with it the secret detector — is not widened to every `.yml` in the repo.
243+
*/
244+
export const DEFAULT_INCLUDE_FILE_GLOBS = [
245+
'docker-compose*.yml',
246+
'docker-compose*.yaml',
247+
'compose*.yml',
248+
'compose*.yaml',
249+
];
250+
251+
/**
252+
* Checks whether a file is a Docker Compose file, based on its name.
253+
* @param filePath - The path (or basename) of the file to check.
254+
* @returns True if the file is a Docker Compose file.
255+
*/
256+
export function isDockerComposeFile(filePath: string): boolean {
257+
// Anchored to a path separator (or the start), so only the basename is
258+
// matched — regardless of `/` or `\` separators. Matches compose.yml,
259+
// docker-compose.yaml, docker-compose.dev.yml, compose.prod.yaml… but not
260+
// composer.yml (any extra segment must start with '.' or '-').
261+
return /(?:^|[\\/])(?:docker-)?compose(?:[.-][^\\/]*)?\.ya?ml$/i.test(
262+
filePath,
263+
);
264+
}
265+
209266
// Default file extensions to include in scans
210267
export const DEFAULT_INCLUDE_EXTENSIONS = [
211268
'.js',

packages/cli/src/core/scan/scanFile.ts

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import type {
66
} from '../../config/types.js';
77
import {
88
ENV_PATTERNS,
9+
DOCKER_COMPOSE_PATTERNS,
910
buildSveltekitAliasPatterns,
11+
isDockerComposeFile,
1012
SVELTEKIT_IMPORT_REGEX,
1113
SVELTEKIT_ALIAS_IMPORT_REGEX,
1214
} from './patterns.js';
@@ -42,40 +44,50 @@ export function scanFile(
4244
// Get relative path from cwd cross-platform compatible
4345
const relativePath = normalizePath(path.relative(opts.cwd, filePath));
4446

45-
// Collect all $env imports used in this file
46-
const envImports: string[] = [];
47-
48-
SVELTEKIT_IMPORT_REGEX.lastIndex = 0;
49-
let importMatch: RegExpExecArray | null;
50-
while ((importMatch = SVELTEKIT_IMPORT_REGEX.exec(content)) !== null) {
51-
envImports.push(importMatch[1]!);
52-
}
47+
// Docker Compose files use shell-style `${VAR}` interpolation rather than any
48+
// JS/TS accessor, so they get their own pattern set. Running the JS patterns
49+
// on them (or the compose patterns on JS) would only produce false matches.
50+
const isCompose = isDockerComposeFile(filePath);
5351

52+
// Collect all $env imports used in this file (SvelteKit only)
53+
const envImports: string[] = [];
5454
// Resolve the framework label for bare `env` object accessors (`env.X`,
5555
// `{ ... } = env`), which look identical across frameworks. A `$env/*` import
5656
// means SvelteKit; otherwise a `loadEnv(` call means Vite. When neither is
5757
// present the label stays 'sveltekit' (the historical default).
58-
const envObjectKind: EnvPatternName =
59-
envImports.length > 0
60-
? 'sveltekit'
61-
: /\bloadEnv\s*\(/.test(content)
62-
? 'vite'
63-
: 'sveltekit';
64-
65-
// Detect aliased $env imports and build dynamic patterns for them
66-
const allPatterns = [...ENV_PATTERNS];
67-
68-
SVELTEKIT_ALIAS_IMPORT_REGEX.lastIndex = 0;
69-
let aliasImportMatch: RegExpExecArray | null;
70-
while (
71-
(aliasImportMatch = SVELTEKIT_ALIAS_IMPORT_REGEX.exec(content)) !== null
72-
) {
73-
allPatterns.push(
74-
...buildSveltekitAliasPatterns(
75-
aliasImportMatch[1]!,
76-
aliasImportMatch[2]!,
77-
),
78-
);
58+
let envObjectKind: EnvPatternName = 'sveltekit';
59+
60+
const allPatterns = isCompose
61+
? [...DOCKER_COMPOSE_PATTERNS]
62+
: [...ENV_PATTERNS];
63+
64+
if (!isCompose) {
65+
SVELTEKIT_IMPORT_REGEX.lastIndex = 0;
66+
let importMatch: RegExpExecArray | null;
67+
while ((importMatch = SVELTEKIT_IMPORT_REGEX.exec(content)) !== null) {
68+
envImports.push(importMatch[1]!);
69+
}
70+
71+
envObjectKind =
72+
envImports.length > 0
73+
? 'sveltekit'
74+
: /\bloadEnv\s*\(/.test(content)
75+
? 'vite'
76+
: 'sveltekit';
77+
78+
// Detect aliased $env imports and build dynamic patterns for them
79+
SVELTEKIT_ALIAS_IMPORT_REGEX.lastIndex = 0;
80+
let aliasImportMatch: RegExpExecArray | null;
81+
while (
82+
(aliasImportMatch = SVELTEKIT_ALIAS_IMPORT_REGEX.exec(content)) !== null
83+
) {
84+
allPatterns.push(
85+
...buildSveltekitAliasPatterns(
86+
aliasImportMatch[1]!,
87+
aliasImportMatch[2]!,
88+
),
89+
);
90+
}
7991
}
8092

8193
for (const pattern of allPatterns) {

packages/cli/src/services/fileWalker.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from 'path';
33
import fsSync from 'fs';
44
import {
55
DEFAULT_INCLUDE_EXTENSIONS,
6+
DEFAULT_INCLUDE_FILE_GLOBS,
67
DEFAULT_EXCLUDE_PATTERNS,
78
} from '../core/scan/patterns.js';
89

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

230236
/**

packages/cli/test/unit/core/scan/patterns.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
DEFAULT_EXCLUDE_PATTERNS,
66
ENV_PATTERNS,
77
buildSveltekitAliasPatterns,
8+
isDockerComposeFile,
89
SVELTEKIT_IMPORT_REGEX,
910
SVELTEKIT_ALIAS_IMPORT_REGEX,
1011
} from '../../../../src/core/scan/patterns';
@@ -746,4 +747,34 @@ import { env as privateEnv } from '$env/dynamic/private';`;
746747
]);
747748
});
748749
});
750+
751+
describe('isDockerComposeFile', () => {
752+
it.each([
753+
'compose.yml',
754+
'compose.yaml',
755+
'compose.prod.yaml',
756+
'docker-compose.yml',
757+
'docker-compose.dev.yml',
758+
'src/docker-compose.override.yaml',
759+
'/abs/path/to/compose.yml',
760+
'DOCKER-COMPOSE.YML', // case-insensitive
761+
])('treats %s as a compose file', (p) => {
762+
expect(isDockerComposeFile(p)).toBe(true);
763+
});
764+
765+
it('matches the basename on Windows-style backslash paths', () => {
766+
expect(isDockerComposeFile('C:\\proj\\docker-compose.yml')).toBe(true);
767+
});
768+
769+
it.each([
770+
'composer.yml', // lock file, not compose
771+
'app.yml',
772+
'k8s-deployment.yaml',
773+
'compose.txt',
774+
'docker-compose.yml.bak',
775+
'not-compose.yml', // "compose" not at a path boundary
776+
])('does not treat %s as a compose file', (p) => {
777+
expect(isDockerComposeFile(p)).toBe(false);
778+
});
779+
});
749780
});

packages/cli/test/unit/core/scan/scanFile.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,76 @@ const port = process.env.PORT;
5454
});
5555
});
5656

57+
describe('docker-compose interpolation (Issue B)', () => {
58+
const compose = '/test/project/docker-compose.prod.yml';
59+
60+
it('detects ${VAR} interpolation as usage', () => {
61+
const content = ' - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}';
62+
const usages = scanFile(compose, content, baseOpts);
63+
64+
expect(usages).toHaveLength(1);
65+
expect(usages[0]).toMatchObject({
66+
variable: 'POSTGRES_PASSWORD',
67+
pattern: 'docker-compose',
68+
file: 'docker-compose.prod.yml',
69+
});
70+
});
71+
72+
it('detects ${VAR:-default} and ${VAR:?err} default-modifier forms', () => {
73+
const content = [
74+
' - DOMAINPASS=${SAMBA_ADMIN_PASSWORD:-Passw0rd!}',
75+
' - DB=${POSTGRES_DB:?required}',
76+
].join('\n');
77+
const usages = scanFile(compose, content, baseOpts);
78+
79+
expect(usages.map((u) => u.variable)).toEqual([
80+
'SAMBA_ADMIN_PASSWORD',
81+
'POSTGRES_DB',
82+
]);
83+
});
84+
85+
it('detects bare $VAR interpolation', () => {
86+
const content =
87+
' test: "pg_isready --username=${POSTGRES_USER} --dbname=$POSTGRES_DB"';
88+
const usages = scanFile(compose, content, baseOpts);
89+
90+
expect(usages.map((u) => u.variable)).toEqual([
91+
'POSTGRES_USER',
92+
'POSTGRES_DB',
93+
]);
94+
});
95+
96+
it('ignores the $$ escape for a literal dollar sign', () => {
97+
const content = ' command: echo $$NOT_A_VAR';
98+
const usages = scanFile(compose, content, baseOpts);
99+
100+
expect(usages).toHaveLength(0);
101+
});
102+
103+
it('does not apply compose patterns to JS/TS files', () => {
104+
// A bare ${VAR} in a template literal is a JS expression, not an env ref.
105+
const content = 'const url = `${API_HOST}/v1`;';
106+
const usages = scanFile('/test/project/src/app.ts', content, baseOpts);
107+
108+
expect(usages).toHaveLength(0);
109+
});
110+
111+
it('recognises compose.yaml and docker-compose.<env>.yml names', () => {
112+
for (const name of [
113+
'compose.yaml',
114+
'compose.dev.yml',
115+
'docker-compose.yml',
116+
]) {
117+
const usages = scanFile(
118+
`/test/project/${name}`,
119+
' - X=${SOME_VAR}',
120+
baseOpts,
121+
);
122+
expect(usages.map((u) => u.variable)).toEqual(['SOME_VAR']);
123+
}
124+
});
125+
});
126+
57127
it('calculates correct line and column numbers', () => {
58128
const content = `const x = 1;
59129
const y = 2;

packages/cli/test/unit/services/filewalker.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,22 @@ describe('filewalker', () => {
434434
expect(result).not.toContain(testFile);
435435
});
436436

437+
it('discovers docker-compose files by default but not arbitrary YAML', async () => {
438+
fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), '');
439+
fs.writeFileSync(path.join(tmpDir, 'docker-compose.prod.yml'), '');
440+
fs.writeFileSync(path.join(tmpDir, 'compose.yaml'), '');
441+
fs.writeFileSync(path.join(tmpDir, 'k8s-deployment.yaml'), '');
442+
443+
const result = await findFiles(tmpDir, {});
444+
const basenames = result.map((f) => path.basename(f));
445+
446+
expect(basenames).toContain('docker-compose.yml');
447+
expect(basenames).toContain('docker-compose.prod.yml');
448+
expect(basenames).toContain('compose.yaml');
449+
// Arbitrary YAML is deliberately not pulled into the scan surface.
450+
expect(basenames).not.toContain('k8s-deployment.yaml');
451+
});
452+
437453
it('handles directory read errors gracefully', async () => {
438454
const result = await findFiles('/nonexistent', {});
439455
expect(result).toEqual([]);

0 commit comments

Comments
 (0)