diff --git a/.changeset/yaml-config.md b/.changeset/yaml-config.md
new file mode 100644
index 0000000..2fdbbec
--- /dev/null
+++ b/.changeset/yaml-config.md
@@ -0,0 +1,5 @@
+---
+"@docker-doctor/cli": patch
+---
+
+Support `docker-doctor.config.yaml` / `.yml` config files (resolved after `.json`, and via `--config`). A JSON Schema generated from the rule set is published at https://docker-doctor.vercel.app/schema.json for editor autocomplete/validation in YAML (`# yaml-language-server: $schema=…`) and JSON (`"$schema"` key) configs.
diff --git a/apps/web/app/schema.json/route.ts b/apps/web/app/schema.json/route.ts
new file mode 100644
index 0000000..dfdb72c
--- /dev/null
+++ b/apps/web/app/schema.json/route.ts
@@ -0,0 +1,61 @@
+import { allRules } from "@docker-doctor/core";
+import { NextResponse } from "next/server";
+
+// Prerendered once per deploy, so the schema always matches the rule set the
+// site was built against.
+export const revalidate = false;
+
+const SEVERITY_ENUM = ["error", "warning", "info", "off"];
+
+export const GET = (): NextResponse => {
+ const ruleProperties = Object.fromEntries(
+ allRules.map((rule) => [
+ rule.key,
+ { description: rule.message, enum: SEVERITY_ENUM },
+ ])
+ );
+
+ const categoryProperties = Object.fromEntries(
+ [...new Set(allRules.map((rule) => rule.category))]
+ .toSorted()
+ .map((category) => [category, { enum: SEVERITY_ENUM }])
+ );
+
+ const schema = {
+ $id: "https://docker-doctor.vercel.app/schema.json",
+ $schema: "http://json-schema.org/draft-07/schema#",
+ additionalProperties: false,
+ description:
+ "Configuration for the docker-doctor CLI (docker-doctor.config.{json,yaml,yml} or package.json#dockerDoctor).",
+ properties: {
+ $schema: { type: "string" },
+ categories: {
+ additionalProperties: false,
+ description: "Override every rule in a category at once.",
+ properties: categoryProperties,
+ type: "object",
+ },
+ ignore: {
+ additionalProperties: false,
+ properties: {
+ files: {
+ description: "Glob patterns for files to exclude from scanning.",
+ items: { type: "string" },
+ type: "array",
+ },
+ },
+ type: "object",
+ },
+ rules: {
+ additionalProperties: { enum: SEVERITY_ENUM },
+ description: "Override the severity of an individual rule.",
+ properties: ruleProperties,
+ type: "object",
+ },
+ },
+ title: "Docker Doctor configuration",
+ type: "object",
+ };
+
+ return NextResponse.json(schema);
+};
diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx
index 6917444..bb8c25d 100644
--- a/apps/web/content/docs/index.mdx
+++ b/apps/web/content/docs/index.mdx
@@ -34,7 +34,7 @@ Works with any project that uses Docker.
} title="Fully configurable">
Per-rule and per-category severities, plus file ignores, via
- `docker-doctor.config.ts`.
+ `docker-doctor.config.ts` — or YAML/JSON with a published schema.
} title="CI-friendly">
Machine-readable `--json` output and a `--score` mode that exits non-zero
diff --git a/apps/web/content/docs/reference/configuration.mdx b/apps/web/content/docs/reference/configuration.mdx
index 47cc7fd..8291062 100644
--- a/apps/web/content/docs/reference/configuration.mdx
+++ b/apps/web/content/docs/reference/configuration.mdx
@@ -12,7 +12,9 @@ Unless you pass `--config `, docker-doctor looks for the first of these, i
3. `docker-doctor.config.mjs`
4. `docker-doctor.config.cjs`
5. `docker-doctor.config.json`
-6. a `"dockerDoctor"` key in `package.json`
+6. `docker-doctor.config.yaml`
+7. `docker-doctor.config.yml`
+8. a `"dockerDoctor"` key in `package.json`
If none of these exist, docker-doctor runs with its built-in defaults.
@@ -45,6 +47,34 @@ export default {
The five valid `categories` keys are `Security`, `Performance`, `Best Practices`, `Compose`, and `Image Size`.
+## YAML and JSON configs
+
+If you'd rather stay in YAML (you're writing Compose files anyway), the same shape works as `docker-doctor.config.yaml` — no `package.json` or `"type": "module"` required:
+
+```yaml
+# yaml-language-server: $schema=https://docker-doctor.vercel.app/schema.json
+rules:
+ "docker-doctor/no-root-user": error
+categories:
+ "Image Size": "off"
+ignore:
+ files:
+ - "examples/**/Dockerfile"
+```
+
+The `yaml-language-server` comment gives you autocomplete and validation in editors with the YAML extension. JSON configs get the same via a `$schema` key:
+
+```json
+{
+ "$schema": "https://docker-doctor.vercel.app/schema.json",
+ "rules": {
+ "docker-doctor/no-root-user": "error"
+ }
+}
+```
+
+The schema at [`/schema.json`](https://docker-doctor.vercel.app/schema.json) is generated from the built-in rule set, so every rule key and category autocompletes with its description.
+
## Typed config
`@docker-doctor/cli` exports the `DockerDoctorConfig` type and a `defineConfig` helper. Which one to use depends on how you run docker-doctor:
diff --git a/apps/web/package.json b/apps/web/package.json
index 5a2a3e4..2e4c245 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,6 +14,7 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@databuddy/sdk": "^2.6.0",
+ "@docker-doctor/core": "workspace:*",
"cnfast": "^0.0.8",
"date-fns": "^4.4.0",
"fumadocs-core": "^16.12.1",
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 6ec0af3..23ca9fd 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "target": "ES2017",
+ "target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
diff --git a/bun.lock b/bun.lock
index c887dd8..d708e54 100644
--- a/bun.lock
+++ b/bun.lock
@@ -21,6 +21,7 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@databuddy/sdk": "^2.6.0",
+ "@docker-doctor/core": "workspace:*",
"cnfast": "^0.0.8",
"date-fns": "^4.4.0",
"fumadocs-core": "^16.12.1",
diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts
index 78def5a..cf99738 100644
--- a/packages/core/src/config/loader.ts
+++ b/packages/core/src/config/loader.ts
@@ -1,6 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
+import { parse as parseYaml } from "yaml";
+
import { ConfigError } from "../errors";
import type { DockerDoctorConfig } from "../schemas/config";
import { validateConfig } from "../schemas/config";
@@ -14,17 +16,29 @@ const fileExists = async (filePath: string): Promise => {
}
};
+const parseConfigFile = async (
+ filePath: string,
+ format: "JSON" | "YAML",
+ parse: (content: string) => unknown
+): Promise => {
+ try {
+ const content = await fs.readFile(filePath, "utf-8");
+ return parse(content);
+ } catch (error: unknown) {
+ const msg = error instanceof Error ? error.message : String(error);
+ throw new ConfigError({
+ message: `Failed to parse config ${format}: ${msg}`,
+ });
+ }
+};
+
const importConfig = async (filePath: string): Promise => {
if (filePath.endsWith(".json")) {
- try {
- const content = await fs.readFile(filePath, "utf-8");
- return JSON.parse(content);
- } catch (error: unknown) {
- const msg = error instanceof Error ? error.message : String(error);
- throw new ConfigError({
- message: `Failed to parse config JSON: ${msg}`,
- });
- }
+ return parseConfigFile(filePath, "JSON", JSON.parse);
+ }
+
+ if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
+ return parseConfigFile(filePath, "YAML", parseYaml);
}
try {
@@ -59,6 +73,8 @@ export const loadConfig = async (
"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 */
diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts
index 3c3222f..8f9f974 100644
--- a/packages/core/test/config.test.ts
+++ b/packages/core/test/config.test.ts
@@ -5,15 +5,6 @@ import path from "node:path";
import { loadConfig } from "../src/config/loader";
import { ConfigError } from "../src/errors";
-import type { DockerDoctorConfig } from "../src/schemas/config";
-
-// The `DockerDoctorConfig["categories"]` type is a full `Record` (all 5 keys), but a valid runtime config may legitimately
-// set only a subset. `as unknown as DockerDoctorConfig` below silences that
-// pre-existing type-vs-runtime mismatch without altering what is actually
-// compared at runtime (still a plain deep-equal on the literal object).
-const expectPartialCategories = (value: unknown): DockerDoctorConfig =>
- value as unknown as DockerDoctorConfig;
describe("loadConfig", () => {
let dir: string;
@@ -46,9 +37,7 @@ describe("loadConfig", () => {
JSON.stringify({ categories: { Security: "warning" } })
);
const config = await loadConfig(dir);
- expect(config).toEqual(
- expectPartialCategories({ categories: { Security: "warning" } })
- );
+ expect(config).toEqual({ categories: { Security: "warning" } });
});
test("returns valid ignore.files as-is", async () => {
@@ -149,11 +138,9 @@ describe("loadConfig", () => {
})
);
const config = await loadConfig(dir);
- expect(config).toEqual(
- expectPartialCategories({
- categories: { "Best Practices": "warning" },
- })
- );
+ expect(config).toEqual({
+ categories: { "Best Practices": "warning" },
+ });
});
test("silently strips unknown keys nested under ignore", async () => {
@@ -166,4 +153,75 @@ describe("loadConfig", () => {
const config = await loadConfig(dir);
expect(config).toEqual({ ignore: { files: ["a"] } });
});
+
+ test("loads a .yaml config", async () => {
+ await fs.writeFile(
+ path.join(dir, "docker-doctor.config.yaml"),
+ [
+ "rules:",
+ ' "docker-doctor/no-root-user": error',
+ "categories:",
+ ' "Image Size": "off"',
+ "ignore:",
+ " files:",
+ ' - "examples/**/Dockerfile"',
+ "",
+ ].join("\n")
+ );
+ const config = await loadConfig(dir);
+ expect(config).toEqual({
+ categories: { "Image Size": "off" },
+ ignore: { files: ["examples/**/Dockerfile"] },
+ rules: { "docker-doctor/no-root-user": "error" },
+ });
+ });
+
+ test("loads a .yml config", async () => {
+ await fs.writeFile(
+ path.join(dir, "docker-doctor.config.yml"),
+ 'rules:\n "docker-doctor/no-root-user": warning\n'
+ );
+ const config = await loadConfig(dir);
+ expect(config).toEqual({
+ rules: { "docker-doctor/no-root-user": "warning" },
+ });
+ });
+
+ test("prefers .json config over .yaml when both exist", async () => {
+ await fs.writeFile(
+ path.join(dir, "docker-doctor.config.json"),
+ JSON.stringify({ rules: { "from-json": "error" } })
+ );
+ await fs.writeFile(
+ path.join(dir, "docker-doctor.config.yaml"),
+ 'rules:\n "from-yaml": warning\n'
+ );
+ const config = await loadConfig(dir);
+ expect(config).toEqual({ rules: { "from-json": "error" } });
+ });
+
+ test("throws ConfigError on malformed YAML", async () => {
+ await fs.writeFile(
+ path.join(dir, "docker-doctor.config.yaml"),
+ "rules:\n bad: [unclosed\n"
+ );
+ await expect(loadConfig(dir)).rejects.toThrow(ConfigError);
+ });
+
+ test("throws ConfigError on invalid severity in YAML", async () => {
+ await fs.writeFile(
+ path.join(dir, "docker-doctor.config.yaml"),
+ 'rules:\n "no-latest-tag": banana\n'
+ );
+ await expect(loadConfig(dir)).rejects.toThrow(ConfigError);
+ });
+
+ test("loads a .yaml config via --config custom path", async () => {
+ await fs.writeFile(
+ path.join(dir, "custom.yaml"),
+ "categories:\n Security: error\n"
+ );
+ const config = await loadConfig(dir, "custom.yaml");
+ expect(config).toEqual({ categories: { Security: "error" } });
+ });
});
diff --git a/packages/docker-doctor/README.md b/packages/docker-doctor/README.md
index 0d2e2d1..abb89a7 100644
--- a/packages/docker-doctor/README.md
+++ b/packages/docker-doctor/README.md
@@ -61,7 +61,7 @@ export default {
} satisfies DockerDoctorConfig;
```
-A `defineConfig` helper is also exported for projects with `@docker-doctor/cli` installed — see the [configuration docs](https://docker-doctor.vercel.app/docs/reference/configuration).
+Prefer YAML? `docker-doctor.config.yaml` works too, with editor autocomplete via `# yaml-language-server: $schema=https://docker-doctor.vercel.app/schema.json`. A `defineConfig` helper is also exported for projects with `@docker-doctor/cli` installed — see the [configuration docs](https://docker-doctor.vercel.app/docs/reference/configuration).
## How the score works
diff --git a/skills/docker-doctor/references/explain.md b/skills/docker-doctor/references/explain.md
index 3d18d8a..3a151c4 100644
--- a/skills/docker-doctor/references/explain.md
+++ b/skills/docker-doctor/references/explain.md
@@ -14,7 +14,7 @@ npx @docker-doctor/cli@latest rules explain docker-doctor/no-root-user
```
3. Pick the narrowest change that matches the user's intent (see decision guide).
-4. **Hand-edit `docker-doctor.config.ts`** (or `.js`/`.mjs`/`.cjs`/`.json`, or the `dockerDoctor` key in `package.json`). There is no `rules disable`/`set`/`category` CLI subcommand — configuration is edited by hand.
+4. **Hand-edit `docker-doctor.config.ts`** (or `.js`/`.mjs`/`.cjs`/`.json`/`.yaml`/`.yml`, or the `dockerDoctor` key in `package.json`). There is no `rules disable`/`set`/`category` CLI subcommand — configuration is edited by hand.
5. Validate the change did what they wanted:
```bash
@@ -32,7 +32,7 @@ npx @docker-doctor/cli@latest rules explain # why it matters + how to
## Config shape
-Config lives in `docker-doctor.config.{ts,js,mjs,cjs,json}` or the `dockerDoctor` key in `package.json`. Three maps:
+Config lives in `docker-doctor.config.{ts,js,mjs,cjs,json,yaml,yml}` or the `dockerDoctor` key in `package.json`. Match the file format the project already uses. Three maps (same shape in every format):
```ts
// docker-doctor.config.ts