From 6b35576ded8761dc98270d0acbbabfd89f30912f Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:03:46 +0700 Subject: [PATCH 1/3] refactor(core): look up the config file without awaiting in a loop loadConfig tried each candidate name in turn, with an `await` inside a `for` loop under an `eslint-disable no-await-in-loop` that gave no reason. findFirstConfigFile checks every name at once and takes the first that exists, in the same order, so the suppression goes. A new test pins the order with a .mjs, a .json and a .yaml config side by side. --- packages/core/src/config/loader.ts | 47 ++++++++++++++++++------------ packages/core/test/config.test.ts | 18 ++++++++++++ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 00d177b..80d6d0b 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -20,6 +20,30 @@ const fileExists = async (filePath: string): Promise => { } }; +const CONFIG_FILE_NAMES = [ + "docker-doctor.config.ts", + "docker-doctor.config.js", + "docker-doctor.config.mjs", + "docker-doctor.config.cjs", + "docker-doctor.config.json", + "docker-doctor.config.yaml", + "docker-doctor.config.yml", +]; + +const findFirstConfigFile = async ( + rootDir: string +): Promise => { + const candidates = CONFIG_FILE_NAMES.map((name) => path.join(rootDir, name)); + + const exists = await Promise.all( + candidates.map(async (candidate) => await fileExists(candidate)) + ); + + const first = exists.indexOf(true); + + return first === -1 ? undefined : candidates[first]; +}; + const describeError = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); @@ -194,26 +218,11 @@ export const loadConfig = async ( configObject = await importConfig(fullPath); } else { - const candidates = [ - "docker-doctor.config.ts", - "docker-doctor.config.js", - "docker-doctor.config.mjs", - "docker-doctor.config.cjs", - "docker-doctor.config.json", - "docker-doctor.config.yaml", - "docker-doctor.config.yml", - ]; - - /* eslint-disable no-await-in-loop */ - for (const cand of candidates) { - const fullPath = path.join(rootDir, cand); - - if (await fileExists(fullPath)) { - configObject = await importConfig(fullPath); - break; - } + const configPath = await findFirstConfigFile(rootDir); + + if (configPath !== undefined) { + configObject = await importConfig(configPath); } - /* eslint-enable no-await-in-loop */ configObject ??= await readPackageConfig(rootDir); } diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 9f2427e..f56e323 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -22,6 +22,24 @@ describe("loadConfig", () => { expect(config).toEqual({}); }); + test("reads the first config file in lookup order", async () => { + await fs.writeFile( + path.join(dir, "docker-doctor.config.yaml"), + "rules:\n docker-doctor/no-root-user: info\n" + ); + await fs.writeFile( + path.join(dir, "docker-doctor.config.json"), + JSON.stringify({ rules: { "docker-doctor/no-root-user": "error" } }) + ); + await fs.writeFile( + path.join(dir, "docker-doctor.config.mjs"), + 'export default { rules: { "docker-doctor/no-root-user": "warning" } };\n' + ); + + const config = await loadConfig(dir); + expect(config.rules).toEqual({ "docker-doctor/no-root-user": "warning" }); + }); + test("warns about unknown rule and category keys", async () => { await fs.writeFile( path.join(dir, "docker-doctor.config.json"), From d9f4f836c3a36ba5345898a255465f5dfc8d0f86 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:05:00 +0700 Subject: [PATCH 2/3] refactor(core): read long-syntax volume entries from mappings only volumeMount also accepted a YAML sequence as a long-syntax entry, with a comment admitting that a sequence has none of the keys it reads. It returned a mount with every field undefined, which every rule then ignored. It now returns undefined for a sequence, as for any other value that is not a mapping. A new test pins that the three mount rules stay silent on such an entry. --- packages/core/src/rules/compose-mounts.ts | 4 +--- packages/core/test/rules.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/src/rules/compose-mounts.ts b/packages/core/src/rules/compose-mounts.ts index ca6d188..b7d712a 100644 --- a/packages/core/src/rules/compose-mounts.ts +++ b/packages/core/src/rules/compose-mounts.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { composeField, isComposeMapping, - isComposeSequence, isComposeString, } from "../parsers/compose-value"; import { hasText } from "../text"; @@ -121,8 +120,7 @@ export const volumeMount = ( }; } - // A sequence here is invalid Compose. It has none of these keys. - if (isComposeMapping(volume) || isComposeSequence(volume)) { + if (isComposeMapping(volume)) { return { readOnly: isComposeTrue(composeField(volume, "read_only")), source: stringOrUndefined(composeField(volume, "source")), diff --git a/packages/core/test/rules.test.ts b/packages/core/test/rules.test.ts index 654da92..4828dda 100644 --- a/packages/core/test/rules.test.ts +++ b/packages/core/test/rules.test.ts @@ -1227,6 +1227,28 @@ describe("Compose Security Rules", () => { ); }); + test("compose mount rules skip a volume entry written as a sequence", () => { + const source = `services: + agent: + image: myagent:1.0 + volumes: + - [/var/run/docker.sock, /var/run/docker.sock] + - [/, /host] +`; + + const composeContent = parseCompose(source, "compose.yml"); + + expect( + noDockerSocketMount.check(composeContent, "compose.yml") + ).toHaveLength(0); + expect(noBroadBindMount.check(composeContent, "compose.yml")).toHaveLength( + 0 + ); + expect( + preferReadOnlyBindMount.check(composeContent, "compose.yml") + ).toHaveLength(0); + }); + test("prefer-read-only-bind-mount: writable host paths in short and long syntax", () => { const source = `services: web: From 9680110f08df650bacaf4b494c73f56b15ffa457 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:06:04 +0700 Subject: [PATCH 3/3] refactor(core): drop an unreachable check from classifyHostPath path.posix.normalize turns `./src` into `src`, and the trailing-slash trim turns `./` into `.`, so `normalized.startsWith("./")` could never match. Relative paths reach `project` through the fall-through branch, which its comment already explains. New tests pin `.`, `./`, `./src`, `src/data` and the two parent spellings. --- packages/core/src/rules/compose-mounts.ts | 2 +- packages/core/test/compose-mounts.test.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/compose-mounts.test.ts diff --git a/packages/core/src/rules/compose-mounts.ts b/packages/core/src/rules/compose-mounts.ts index b7d712a..6dcd62d 100644 --- a/packages/core/src/rules/compose-mounts.ts +++ b/packages/core/src/rules/compose-mounts.ts @@ -249,7 +249,7 @@ export const classifyHostPath = (hostPath: string): HostPathScope => { return normalized.startsWith("~/.") ? "home-dotfile" : "host"; } - if (normalized === "." || normalized.startsWith("./")) { + if (normalized === ".") { return "project"; } diff --git a/packages/core/test/compose-mounts.test.ts b/packages/core/test/compose-mounts.test.ts new file mode 100644 index 0000000..cb12b97 --- /dev/null +++ b/packages/core/test/compose-mounts.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; + +import type { HostPathScope } from "../src/rules/compose-mounts"; +import { classifyHostPath } from "../src/rules/compose-mounts"; + +describe("classifyHostPath", () => { + const cases: [string, HostPathScope][] = [ + [".", "project"], + ["./", "project"], + [".//", "project"], + ["./src", "project"], + ["src/data", "project"], + ["./../shared", "parent"], + ["../shared", "parent"], + ]; + + test.each(cases)("%s is %s", (hostPath, scope) => { + expect(classifyHostPath(hostPath)).toBe(scope); + }); +});