import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { loadConfig, DEFAULT_CONFIG, findProjectRoot, resolveConfiguredSeverity, applyConfiguredSeverity } from "./config"; import type { PostSynthDiagnostic } from "./post-synth"; import { writeFileSync, mkdirSync, rmSync } from "fs"; import { join, resolve } from "path"; const TEST_DIR = join(import.meta.dirname, "__test_config__"); beforeEach(() => { // Create test directory mkdirSync(TEST_DIR, { recursive: true }); }); afterEach(() => { // Clean up test directory rmSync(TEST_DIR, { recursive: true, force: true }); }); describe("loadConfig", () => { test("returns default config when no config file exists", () => { const config = loadConfig(TEST_DIR); expect(config).toEqual(DEFAULT_CONFIG); }); test("loads basic config file", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ rules: { "test-rule": "error", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({ "test-rule": "error", }); }); test("supports disabling rules with 'off'", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ rules: { "test-rule": "off", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules?.["test-rule"]).toBe("off"); }); test("supports all severity levels", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ rules: { "rule-1": "error", "rule-2": "warning", "rule-3": "info", "rule-4": "off", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules?.["rule-1"]).toBe("error"); expect(config.rules?.["rule-2"]).toBe("warning"); expect(config.rules?.["rule-3"]).toBe("info"); expect(config.rules?.["rule-4"]).toBe("off"); }); test("throws error for invalid severity", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ rules: { "test-rule": "invalid", }, }) ); expect(() => loadConfig(TEST_DIR)).toThrow( /rule "test-rule" has invalid severity "invalid"/ ); }); test("throws error for invalid config structure", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync(configPath, "[]"); // Array instead of object expect(() => loadConfig(TEST_DIR)).toThrow(/must be an object/); }); test("throws error for invalid rules type", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ rules: [], }) ); expect(() => loadConfig(TEST_DIR)).toThrow(/rules must be an object/); }); test("throws error for invalid JSON", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync(configPath, "{invalid json}"); expect(() => loadConfig(TEST_DIR)).toThrow(/Failed to parse config file/); }); test("extends single config file", () => { const baseConfigPath = join(TEST_DIR, "base.json"); writeFileSync( baseConfigPath, JSON.stringify({ rules: { "rule-1": "error", "rule-2": "warning", }, }) ); const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["./base.json"], rules: { "rule-3": "info", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({ "rule-1": "error", "rule-2": "warning", "rule-3": "info", }); }); test("extends multiple config files with override priority", () => { const base1Path = join(TEST_DIR, "base1.json"); writeFileSync( base1Path, JSON.stringify({ rules: { "rule-1": "error", "rule-2": "warning", }, }) ); const base2Path = join(TEST_DIR, "base2.json"); writeFileSync( base2Path, JSON.stringify({ rules: { "rule-2": "info", // Override base1 "rule-3": "error", }, }) ); const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["./base1.json", "./base2.json"], rules: { "rule-3": "warning", // Override base2 }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({ "rule-1": "error", "rule-2": "info", // From base2 "rule-3": "warning", // From main config }); }); test("throws error when extended config not found", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["./non-existent.json"], }) ); expect(() => loadConfig(TEST_DIR)).toThrow(/Extended config file not found/); }); test("throws error for invalid extends type", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: "single-string", }) ); expect(() => loadConfig(TEST_DIR)).toThrow(/extends must be an array/); }); test("throws error for non-string in extends array", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: [123], }) ); expect(() => loadConfig(TEST_DIR)).toThrow(/extends must be an array of strings/); }); test("supports nested extends", () => { const base1Path = join(TEST_DIR, "base1.json"); writeFileSync( base1Path, JSON.stringify({ rules: { "rule-1": "error", }, }) ); const base2Path = join(TEST_DIR, "base2.json"); writeFileSync( base2Path, JSON.stringify({ extends: ["./base1.json"], rules: { "rule-2": "warning", }, }) ); const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["./base2.json"], rules: { "rule-3": "info", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({ "rule-1": "error", "rule-2": "warning", "rule-3": "info", }); }); test("detects circular extends", () => { const config1Path = join(TEST_DIR, "config1.json"); const config2Path = join(TEST_DIR, "config2.json"); writeFileSync( config1Path, JSON.stringify({ extends: ["./config2.json"], }) ); writeFileSync( config2Path, JSON.stringify({ extends: ["./config1.json"], }) ); const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["./config1.json"], }) ); expect(() => loadConfig(TEST_DIR)).toThrow(/Circular extends detected/); }); test("handles empty config file", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync(configPath, "{}"); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({}); }); test("handles config with empty rules object", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ rules: {}, }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({}); }); test("handles config with empty extends array", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: [], rules: { "test-rule": "error", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({ "test-rule": "error", }); }); test("allows rule severity changes from extended config", () => { const basePath = join(TEST_DIR, "base.json"); writeFileSync( basePath, JSON.stringify({ rules: { "test-rule": "error", }, }) ); const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["./base.json"], rules: { "test-rule": "warning", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules?.["test-rule"]).toBe("warning"); }); test("allows disabling rules from extended config", () => { const basePath = join(TEST_DIR, "base.json"); writeFileSync( basePath, JSON.stringify({ rules: { "test-rule": "error", }, }) ); const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["./base.json"], rules: { "test-rule": "off", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules?.["test-rule"]).toBe("off"); }); test("default config uses strict preset severities", () => { expect(DEFAULT_CONFIG.rules).toEqual({ COR001: "error", COR002: "error", COR003: "warning", COR004: "warning", COR005: "warning", COR006: "error", COR007: "warning", COR008: "error", COR009: "warning", COR010: "warning", COR011: "error", COR012: "warning", COR013: "info", COR014: "warning", COR015: "warning", COMP001: "error", COMP002: "error", COMP003: "error", COMP004: "error", COMP005: "error", COMP006: "error", COMP007: "warning", }); }); test("no config file returns strict preset defaults", () => { const config = loadConfig(TEST_DIR); expect(config.rules?.["COR001"]).toBe("error"); expect(config.rules?.["COR008"]).toBe("error"); expect(config.rules?.["COR006"]).toBe("error"); expect(config.rules?.["COR009"]).toBe("warning"); expect(config.rules?.["COR005"]).toBe("warning"); expect(config.rules?.["COR002"]).toBe("error"); expect(config.rules?.["COR010"]).toBe("warning"); expect(config.rules?.["COR013"]).toBe("info"); }); test("extends relaxed preset via package path", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["@intentius/chant/lint/presets/relaxed"], }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({ COR001: "warning", COR002: "off", COR003: "off", COR004: "off", COR005: "off", COR006: "off", COR007: "off", COR008: "warning", COR009: "off", COR010: "warning", COR011: "warning", COR012: "off", COR013: "off", COR014: "off", COR015: "off", COMP001: "warning", COMP002: "warning", COMP003: "off", COMP004: "warning", COMP005: "off", COMP006: "off", COMP007: "off", }); }); test("extends strict preset via package path", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["@intentius/chant/lint/presets/strict"], }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({ COR001: "error", COR002: "error", COR003: "warning", COR004: "warning", COR005: "warning", COR006: "error", COR007: "warning", COR008: "error", COR009: "warning", COR010: "warning", COR011: "error", COR012: "warning", COR013: "info", COR014: "warning", COR015: "warning", COMP001: "error", COMP002: "error", COMP003: "error", COMP004: "error", COMP005: "error", COMP006: "error", COMP007: "warning", }); }); test("user rules override preset severities", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["@intentius/chant/lint/presets/strict"], rules: { COR001: "off", COR006: "warning", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules?.["COR001"]).toBe("off"); expect(config.rules?.["COR008"]).toBe("error"); expect(config.rules?.["COR006"]).toBe("warning"); }); test("throws error for unknown @intentius/chant preset", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["@intentius/chant/lint/presets/nonexistent"], }) ); expect(() => loadConfig(TEST_DIR)).toThrow(/Unknown preset/); }); test("config with no plugins field works (backward compat)", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ rules: { "test-rule": "error", }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({ "test-rule": "error" }); expect(config.plugins).toBeUndefined(); }); test("config with valid plugins array is loaded", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ rules: { "test-rule": "error" }, plugins: ["./my-plugin.ts", "./another-plugin.ts"], }) ); const config = loadConfig(TEST_DIR); expect(config.plugins).toEqual(["./my-plugin.ts", "./another-plugin.ts"]); }); test("throws error for non-array plugins", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ plugins: "not-an-array", }) ); expect(() => loadConfig(TEST_DIR)).toThrow(/plugins must be an array/); }); test("throws error for non-string in plugins array", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ plugins: ["valid-plugin", 123], }) ); expect(() => loadConfig(TEST_DIR)).toThrow(/plugins must be an array of strings/); }); test("plugins from extended config are not inherited", () => { const basePath = join(TEST_DIR, "base.json"); writeFileSync( basePath, JSON.stringify({ rules: { "rule-1": "error" }, plugins: ["./base-plugin.ts"], }) ); const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ extends: ["./base.json"], rules: { "rule-2": "warning" }, }) ); const config = loadConfig(TEST_DIR); expect(config.rules).toEqual({ "rule-1": "error", "rule-2": "warning" }); expect(config.plugins).toBeUndefined(); }); test("loads lint config from chant.config.ts", () => { const tsPath = join(TEST_DIR, "chant.config.ts"); writeFileSync( tsPath, `export default { lexicons: ["aws"], lint: { rules: { COR001: "off" } } };`, ); const config = loadConfig(TEST_DIR); expect(config.rules?.COR001).toBe("off"); }); test("prefers chant.config.ts over chant.config.json", () => { writeFileSync( join(TEST_DIR, "chant.config.ts"), `export default { lint: { rules: { COR001: "off" } } };`, ); writeFileSync( join(TEST_DIR, "chant.config.json"), JSON.stringify({ rules: { COR001: "error" } }), ); const config = loadConfig(TEST_DIR); expect(config.rules?.COR001).toBe("off"); }); test("chant.config.ts with only lexicons returns defaults for lint", () => { // Note: Bun caches require() by path, so we use a subdirectory const subDir = join(TEST_DIR, "no-lint-sub"); mkdirSync(subDir, { recursive: true }); writeFileSync( join(subDir, "chant.config.ts"), `export default { lexicons: ["aws"] };`, ); const config = loadConfig(subDir); expect(config).toEqual(DEFAULT_CONFIG); }); test("accepts ChantConfig-shape nested lint key in chant.config.json", () => { const configPath = join(TEST_DIR, "chant.config.json"); writeFileSync( configPath, JSON.stringify({ lexicons: ["aws"], lint: { rules: { "test-rule": "error", "noisy-rule": "off", }, }, }), ); const config = loadConfig(TEST_DIR); expect(config.rules?.["test-rule"]).toBe("error"); expect(config.rules?.["noisy-rule"]).toBe("off"); }); }); describe("findProjectRoot", () => { test("returns the directory holding chant.config.json", () => { writeFileSync(join(TEST_DIR, "chant.config.json"), "{}"); expect(findProjectRoot(TEST_DIR)).toBe(TEST_DIR); }); test("walks up from a subpath to the config-bearing root", () => { writeFileSync(join(TEST_DIR, "chant.config.json"), "{}"); const sub = join(TEST_DIR, "src", "lib"); mkdirSync(sub, { recursive: true }); expect(findProjectRoot(sub)).toBe(TEST_DIR); }); test("also recognizes chant.config.ts as a project root", () => { writeFileSync(join(TEST_DIR, "chant.config.ts"), "export default {};"); const sub = join(TEST_DIR, "src"); mkdirSync(sub, { recursive: true }); expect(findProjectRoot(sub)).toBe(TEST_DIR); }); test("stops at the nearest .git/package.json boundary when no config is found (#1117)", () => { const sub = join(TEST_DIR, "nowhere"); mkdirSync(sub, { recursive: true }); // No chant.config anywhere under TEST_DIR — walking up from this real // repo location reaches `packages/core`'s own package.json before the // filesystem root, so that's the returned boundary, not `sub` itself // (chant #1117 — discovery must never wander past the project just // because it declares no config). const packageRoot = resolve(import.meta.dirname, "..", ".."); expect(findProjectRoot(sub)).toBe(packageRoot); }); }); /** * chant #1138 — the one severity-resolution path AST lint rules * (`../cli/commands/lint.ts`'s `getDefaultRules`), COMP* checks * (`runComponentCheckDiagnostics`), and post-synth checks/policies * (`applyConfiguredSeverity`, below) all now call, keyed by whichever id the * caller has (a `LintRule.id`, a `ComponentCheck.id`, or a * `PostSynthDiagnostic.checkId`) — a rule id behaves the same regardless of * which phase produced it. */ describe("resolveConfiguredSeverity", () => { test("an id with no config entry falls back to the caller's default severity, with no options", () => { expect(resolveConfiguredSeverity(undefined, "COR001", "error")).toEqual({ severity: "error" }); expect(resolveConfiguredSeverity({}, "COR001", "warning")).toEqual({ severity: "warning" }); expect(resolveConfiguredSeverity({ OTHER: "off" }, "COR001", "error")).toEqual({ severity: "error" }); }); test("a bare severity string overrides the default", () => { expect(resolveConfiguredSeverity({ COR001: "warning" }, "COR001", "error")).toEqual({ severity: "warning" }); }); test('"off" suppresses regardless of the default severity', () => { expect(resolveConfiguredSeverity({ WAW019: "off" }, "WAW019", "error")).toEqual({ severity: "off" }); }); test("a [severity, options] tuple carries options through", () => { expect(resolveConfiguredSeverity({ COR009: ["warning", { max: 12 }] }, "COR009", "error")).toEqual({ severity: "warning", options: { max: 12 }, }); }); test("an invalid severity in a [severity, options] tuple throws, naming the bad value", () => { expect(() => resolveConfiguredSeverity({ COR009: ["fatal" as never, { max: 12 }] }, "COR009", "error"), ).toThrow(/severity "fatal"/); }); }); /** * chant #1138 — `lint.rules` severity overrides apply to a post-synth check * id (`diag.checkId`) through the identical `resolveConfiguredSeverity` an * AST rule id or a COMP* check id goes through, so * `lint.rules: { WAW019: "off" }` suppresses a post-synth finding just like a * pre-synth one — the bug this issue reports. */ describe("applyConfiguredSeverity", () => { function diag(overrides: Partial = {}): PostSynthDiagnostic { return { checkId: "WAW019", severity: "error", message: "open ingress", ...overrides }; } test("an unconfigured check id passes through unchanged — no drift for the common case", () => { const result = applyConfiguredSeverity([diag()], undefined); expect(result.diagnostics).toEqual([diag()]); expect(result.suppressed).toEqual([]); }); test('"off" suppresses the finding — moved to `suppressed`, not dropped, so it stays countable', () => { const result = applyConfiguredSeverity([diag()], { WAW019: "off" }); expect(result.diagnostics).toEqual([]); expect(result.suppressed).toEqual([diag()]); }); test('"warning" downgrades an error-severity finding', () => { const result = applyConfiguredSeverity([diag({ severity: "error" })], { WAW019: "warning" }); expect(result.diagnostics).toEqual([diag({ severity: "warning" })]); expect(result.suppressed).toEqual([]); }); test('"error" upgrades a warning-severity finding', () => { const result = applyConfiguredSeverity([diag({ severity: "warning" })], { WAW019: "error" }); expect(result.diagnostics).toEqual([diag({ severity: "error" })]); }); test("resolves each diagnostic by its own checkId — one config, independent ids", () => { const diags = [ diag({ checkId: "WAW019" }), diag({ checkId: "WAW049", message: "no logging" }), diag({ checkId: "WAW099", message: "untouched" }), ]; const result = applyConfiguredSeverity(diags, { WAW019: "off", WAW049: "warning" }); expect(result.suppressed.map((d) => d.checkId)).toEqual(["WAW019"]); expect(result.diagnostics.map((d) => d.checkId)).toEqual(["WAW049", "WAW099"]); expect(result.diagnostics.find((d) => d.checkId === "WAW049")?.severity).toBe("warning"); expect(result.diagnostics.find((d) => d.checkId === "WAW099")?.severity).toBe("error"); }); test("an empty diagnostics list is a no-op", () => { expect(applyConfiguredSeverity([], { WAW019: "off" })).toEqual({ diagnostics: [], suppressed: [] }); }); });