From 4ef6e241d62b4be836614b42bc7f491e4a2d2823 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sat, 22 Aug 2026 14:18:04 +0800 Subject: [PATCH] feat(config): reject unknown configuration fields --- __tests__/configure.spec.ts | 88 +++++++++++++++++++++++++++++++++++++ src/utils/configure.ts | 19 ++++++++ 2 files changed, 107 insertions(+) diff --git a/__tests__/configure.spec.ts b/__tests__/configure.spec.ts index 202aea8..f3cf998 100644 --- a/__tests__/configure.spec.ts +++ b/__tests__/configure.spec.ts @@ -165,6 +165,94 @@ describe("configuration validation", () => { ); }); + test("rejects an unknown configuration field", () => { + const configPath = path.join(tmpDir, ".lintmdrc"); + writeFileSync(configPath, JSON.stringify({ extensons: [".md"] }), "utf8"); + + const error = captureCliError(() => getLintConfig(configPath)); + + expect(error.code).toBe("CONFIG_INVALID"); + expect(error.detail).toContain('Unknown configuration field "extensons".'); + }); + + test("rejects unknown fields without fuzzy suggestions", () => { + const configPath = path.join(tmpDir, ".lintmdrc"); + writeFileSync( + configPath, + JSON.stringify({ excludeFile: ["dist/**"] }), + "utf8" + ); + + const error = captureCliError(() => getLintConfig(configPath)); + + expect(error.detail).toContain( + 'Unknown configuration field "excludeFile".' + ); + expect(error.detail).not.toContain("Did you mean"); + }); + + test("reports all unknown fields", () => { + const configPath = path.join(tmpDir, ".lintmdrc"); + writeFileSync( + configPath, + JSON.stringify({ threads: 2, extensionsX: [], extra: true }), + "utf8" + ); + + const error = captureCliError(() => getLintConfig(configPath)); + + expect(error.detail).toBe( + [ + 'Unknown configuration field "threads".', + 'Unknown configuration field "extensionsX".', + 'Unknown configuration field "extra".', + ].join("\n") + ); + }); + + test("aggregates unknown-field and type errors", () => { + const configPath = path.join(tmpDir, ".lintmdrc"); + writeFileSync( + configPath, + JSON.stringify({ extensons: [".md"], excludeFiles: "node_modules" }), + "utf8" + ); + + const error = captureCliError(() => getLintConfig(configPath)); + + expect(error.detail).toBe( + [ + '"excludeFiles" must be an array of strings.', + 'Unknown configuration field "extensons".', + ].join("\n") + ); + }); + + test("sanitizes control characters in unknown field names", () => { + const configPath = path.join(tmpDir, ".lintmdrc"); + writeFileSync(configPath, '{"evil\\u001B[31mfield": 1}', "utf8"); + + const error = captureCliError(() => getLintConfig(configPath)); + + expect(error.detail).toContain('Unknown configuration field "evil'); + expect(error.detail).not.toContain("\u001B"); + }); + + test("does not reject fields inside rules", () => { + const configPath = path.join(tmpDir, ".lintmdrc"); + writeFileSync( + configPath, + JSON.stringify({ + rules: { "some-future-core-rule": { option: true } }, + }), + "utf8" + ); + + expect(getLintConfig(configPath)).toMatchObject({ + rules: { "some-future-core-rule": { option: true } }, + }); + }); + test("accepts a well-shaped configuration and keeps defaults for absent fields", () => { const configPath = path.join(tmpDir, ".lintmdrc"); writeFileSync( diff --git a/src/utils/configure.ts b/src/utils/configure.ts index 00290c4..fcd298f 100644 --- a/src/utils/configure.ts +++ b/src/utils/configure.ts @@ -4,6 +4,13 @@ import * as path from "path"; import { CliError } from "../cli/cli-error"; import type { CLIConfig, ThreadCount } from "../types"; import { parseSize } from "./parse-size"; +import { sanitizeTerminalText } from "./sanitize-terminal"; + +const KNOWN_CONFIG_FIELDS: ReadonlySet = new Set([ + "excludeFiles", + "extensions", + "rules", +]); const collectStringArrayErrors = (field: string, value: unknown): string[] => { if (!Array.isArray(value)) { @@ -53,6 +60,18 @@ export const validateConfigShape = ( errors.push('"rules" must be an object.'); } + // Unknown root fields are rejected, not ignored: a typo like "extensons" + // would otherwise silently fall back to defaults. Field names come from + // user JSON, so sanitize before embedding in terminal output. + for (const field of Object.keys(config)) { + if (KNOWN_CONFIG_FIELDS.has(field)) continue; + errors.push( + `Unknown configuration field ${JSON.stringify( + sanitizeTerminalText(field) + )}.` + ); + } + if (errors.length > 0) { throw new CliError( "CONFIG_INVALID",