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/yaml-config.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 61 additions & 0 deletions apps/web/app/schema.json/route.ts
Original file line number Diff line number Diff line change
@@ -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);
};
2 changes: 1 addition & 1 deletion apps/web/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Works with any project that uses Docker.
</Card>
<Card icon={<SlidersHorizontal />} 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.
</Card>
<Card icon={<TerminalSquare />} title="CI-friendly">
Machine-readable `--json` output and a `--score` mode that exits non-zero
Expand Down
32 changes: 31 additions & 1 deletion apps/web/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ Unless you pass `--config <path>`, 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.

Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 25 additions & 9 deletions packages/core/src/config/loader.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,17 +16,29 @@ const fileExists = async (filePath: string): Promise<boolean> => {
}
};

const parseConfigFile = async (
filePath: string,
format: "JSON" | "YAML",
parse: (content: string) => unknown
): Promise<unknown> => {
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<unknown> => {
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 {
Expand Down Expand Up @@ -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 */
Expand Down
92 changes: 75 additions & 17 deletions packages/core/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuleCategory,
// RuleSeverity>` (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;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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" } });
});
});
2 changes: 1 addition & 1 deletion packages/docker-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions skills/docker-doctor/references/explain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +32,7 @@ npx @docker-doctor/cli@latest rules explain <rule> # 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
Expand Down