import { readFileSync, existsSync } from "fs"; import { join, dirname, resolve } from "path"; import { z } from "zod"; import { evaluateProjectConfigSync } from "../config-sandbox"; import type { Severity, RuleConfig } from "./rule"; import type { PostSynthDiagnostic } from "./post-synth"; import { moduleDir, getRuntime } from "../runtime-adapter"; import strictPreset from "./presets/strict.json"; // chant #1117 — the upward config-discovery walk moved to a shared module // (`../project-root`) so `chant build`/`lint.policies` use the identical walk // `chant lint`/`chant graph` already did. Re-exported here since this is // still where every existing call site (`./config.test.ts`, `../cli/commands/lint.ts`) // imports it from. export { findProjectRoot } from "../project-root"; /** Mapping of built-in preset names to their file paths */ const BUILTIN_PRESETS: Record = { "@intentius/chant/lint/presets/strict": resolve(moduleDir(import.meta.url), "presets/strict.json"), "@intentius/chant/lint/presets/relaxed": resolve(moduleDir(import.meta.url), "presets/relaxed.json"), }; // ── Zod schemas for lint config validation ───────────────────────── const SeveritySchema = z.enum(["off", "error", "warning", "info"]); const RuleConfigSchema = z.union([ SeveritySchema, z.tuple([SeveritySchema, z.record(z.string(), z.unknown())]), ]); export const LintConfigSchema = z.object({ rules: z.record(z.string(), RuleConfigSchema).optional(), extends: z.array(z.string()).optional(), overrides: z.array(z.object({ files: z.array(z.string()), rules: z.record(z.string(), RuleConfigSchema), })).optional(), plugins: z.array(z.string()).optional(), policies: z.array(z.string()).optional(), }); /** * Check if a Zod union error is specifically about an invalid severity string * (as opposed to a completely wrong type like a number). */ function isBadSeverityError(issue: z.ZodIssue, config: unknown, path: readonly (string | number)[]): string | null { if (issue.code !== "invalid_union") return null; // Navigate to the value in the config to see what was actually provided let value: unknown = config; for (const key of path) { if (value == null || typeof value !== "object") return null; value = (value as Record)[key]; } // If the value is a string, the user meant it as a severity — show the specific severity error if (typeof value === "string") { return value; } return null; } /** * Format a Zod error into a human-readable message matching existing error patterns. */ function formatLintConfigError(configPath: string, error: z.ZodError, rawConfig: unknown): string { const issue = error.issues[0]; const path = issue.path; // Cast path to (string | number)[] — Zod's path is PropertyKey[] but only uses string | number const p = path as (string | number)[]; // rules.{ruleId} — invalid severity or structure if (p[0] === "rules" && p.length >= 2) { const ruleId = p[1]; const badValue = isBadSeverityError(issue, rawConfig, p); if (badValue !== null) { return `Invalid config file ${configPath}: rule "${ruleId}" has invalid severity "${badValue}". Must be "off", "error", "warning", or "info"`; } return `Invalid config file ${configPath}: rule "${ruleId}" must be a severity string or [severity, options] tuple`; } // rules — wrong type if (p[0] === "rules" && p.length === 1) { return `Invalid config file ${configPath}: rules must be an object`; } // extends — wrong type or element type if (p[0] === "extends") { if (p.length === 1) { return `Invalid config file ${configPath}: extends must be an array`; } return `Invalid config file ${configPath}: extends must be an array of strings`; } // overrides[i].* errors if (p[0] === "overrides") { if (p.length === 1) { return `Invalid config file ${configPath}: overrides must be an array`; } const i = p[1]; if (p.length === 2) { return `Invalid config file ${configPath}: overrides[${i}] must be an object`; } if (p[2] === "files") { if (p.length === 3) { return `Invalid config file ${configPath}: overrides[${i}].files must be an array`; } return `Invalid config file ${configPath}: overrides[${i}].files must be an array of strings`; } if (p[2] === "rules") { if (p.length === 3) { return `Invalid config file ${configPath}: overrides[${i}].rules must be an object`; } if (p.length >= 4) { const ruleId = p[3]; const badValue = isBadSeverityError(issue, rawConfig, p); if (badValue !== null) { return `Invalid config file ${configPath}: overrides[${i}] rule "${ruleId}" has invalid severity "${badValue}". Must be "off", "error", "warning", or "info"`; } return `Invalid config file ${configPath}: overrides[${i}] rule "${ruleId}" must be a severity string or [severity, options] tuple`; } } } // plugins — wrong type or element type if (p[0] === "plugins") { if (p.length === 1) { return `Invalid config file ${configPath}: plugins must be an array`; } return `Invalid config file ${configPath}: plugins must be an array of strings`; } // Fallback return `Invalid config file ${configPath}: ${issue.message}`; } /** * Per-file rule override */ export interface LintOverride { /** Glob patterns to match file paths */ files: string[]; /** Rule overrides for matched files */ rules: Record; } /** * Lint configuration */ export interface LintConfig { /** Rule configurations: rule ID -> severity string or [severity, options] tuple */ rules?: Record; /** Array of config file paths to extend from */ extends?: string[]; /** Per-file rule overrides via glob patterns */ overrides?: LintOverride[]; /** Array of plugin file paths to load custom rules from (project-local, not inherited) */ plugins?: string[]; /** * Array of file paths to load project-authored organizational policy checks * from — each exporting one or more {@link PostSynthCheck} objects. They run * during `chant build` over the resolved resources, with the current `env` in * context. Distinct from `plugins` (declarative lint rules) by authorship and * phase; same engine. */ policies?: string[]; } /** * Parsed rule configuration with severity and optional options */ export interface ParsedRuleConfig { severity: "off" | Severity; options?: Record; } /** * Parse a rule config value into severity and options */ export function parseRuleConfig(value: RuleConfig): ParsedRuleConfig { if (typeof value === "string") { return { severity: value as "off" | Severity }; } if (!Array.isArray(value) || value.length !== 2) { throw new Error(`Invalid rule config: expected a severity string or [severity, options] tuple`); } const [severity, options] = value; if (typeof severity !== "string" || !isValidSeverity(severity)) { throw new Error( `Invalid rule config: severity "${severity}" must be "off", "error", "warning", or "info"` ); } if (typeof options !== "object" || options === null || Array.isArray(options)) { throw new Error(`Invalid rule config: options must be a plain object`); } return { severity, options }; } /** * Resolve one check/rule id's effective severity (and options) against an * already-resolved `lint.rules` map — the ONE place `"off"`/severity-override * resolution happens, so a rule id behaves identically regardless of which * phase produced it. * * chant #1138 — before this, the same `lint.rules: { ID: "off" }` config was * resolved by two independent call sites that had grown their own copy of * this logic (`../cli/commands/lint.ts`'s `getDefaultRules` for AST COR/EVL * rules, and its `runComponentCheckDiagnostics` for whole-component COMP* * checks) — identical in effect, but a rule id's suppression having two * places to (potentially, eventually) diverge is itself the bug class #1138 * is about. Both were converted to call this instead, and post-synth checks/ * policies (`./post-synth.ts`'s `applyConfiguredSeverity`) now go through it * too, closing the gap the issue reports: a post-synth check id honors * `lint.rules` exactly like an AST rule id does. * * `rules` takes the already-resolved map (`config.rules`, or * `resolveRulesForFile`'s per-file merge) rather than a whole `LintConfig` — * callers that need per-file `overrides` resolve that first; post-synth * checks have no per-file scope to begin with (see {@link * ./post-synth.ts!PostSynthDiagnostic}'s doc for why), so they always pass * `config.rules` directly. */ export function resolveConfiguredSeverity( rules: Record | undefined, id: string, defaultSeverity: Severity, ): ParsedRuleConfig { const configValue = rules?.[id]; if (configValue === undefined) return { severity: defaultSeverity }; return parseRuleConfig(configValue); } /** * Default configuration with all rules enabled at strict preset severities */ export const DEFAULT_CONFIG: LintConfig = { rules: { ...strictPreset.rules } as Record, extends: [], }; /** * Validate that a severity string is valid */ function isValidSeverity(value: string): value is "off" | Severity { return value === "off" || value === "error" || value === "warning" || value === "info"; } /** * Load and merge a single config file */ function loadConfigFile(configPath: string, visited: Set = new Set()): LintConfig { // Check for circular extends if (visited.has(configPath)) { throw new Error(`Circular extends detected: ${configPath}`); } visited.add(configPath); // Read and parse config file if (!existsSync(configPath)) { throw new Error(`Config file not found: ${configPath}`); } let content: string; try { content = readFileSync(configPath, "utf-8"); } catch (err) { throw new Error(`Failed to read config file ${configPath}: ${err instanceof Error ? err.message : String(err)}`); } let raw: unknown; try { raw = JSON.parse(content); } catch (err) { throw new Error(`Failed to parse config file ${configPath}: ${err instanceof Error ? err.message : String(err)}`); } // Validate config structure if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { throw new Error(`Invalid config file ${configPath}: must be an object`); } // Accept both shapes: lint fields at top level (legacy) OR nested under a // "lint" key (matches chant.config.ts ChantConfig shape). When the JSON has // both a top-level "rules"/"extends"/etc AND a "lint": {...}, prefer the // nested one — that matches the explicit ChantConfig contract. const rawObj = raw as Record; let config: LintConfig; if ( rawObj.lint && typeof rawObj.lint === "object" && !Array.isArray(rawObj.lint) ) { config = rawObj.lint as LintConfig; } else { config = rawObj as LintConfig; } // Validate with Zod schema const parseResult = LintConfigSchema.safeParse(config); if (!parseResult.success) { throw new Error(formatLintConfigError(configPath, parseResult.error, config)); } // Process extends let mergedConfig: LintConfig = { rules: {}, extends: [] }; if (config.extends && config.extends.length > 0) { const baseDir = dirname(configPath); for (const extendPath of config.extends) { // Resolve paths: relative, built-in preset, or absolute let resolvedPath: string; if (extendPath.startsWith(".")) { resolvedPath = join(baseDir, extendPath); } else if (extendPath.startsWith("@intentius/chant")) { const builtinPath = BUILTIN_PRESETS[extendPath]; if (builtinPath) { resolvedPath = builtinPath; } else { throw new Error(`Unknown preset: ${extendPath} (extended from ${configPath})`); } } else { resolvedPath = extendPath; } // Check if extended config exists if (!existsSync(resolvedPath)) { throw new Error(`Extended config file not found: ${resolvedPath} (extended from ${configPath})`); } // Load extended config recursively const extendedConfig = loadConfigFile(resolvedPath, visited); // Merge rules (later configs override earlier ones) mergedConfig.rules = { ...mergedConfig.rules, ...extendedConfig.rules, }; } } // Merge current config rules on top mergedConfig.rules = { ...mergedConfig.rules, ...config.rules, }; // Preserve overrides from the current config if (config.overrides) { mergedConfig.overrides = config.overrides; } // Preserve plugins from the current config only (not inherited from extends) if (config.plugins) { mergedConfig.plugins = config.plugins; } return mergedConfig; } /** * Load lint configuration from a directory. * * Tries `chant.config.ts` first (extracts `lint` property from ChantConfig), * then falls back to `chant.config.json` (legacy LintConfig format). * Returns default configuration if neither exists. * * chant #1113 — the `chant.config.ts` branch executes project-authored code, * so it goes through `../config-sandbox.ts` like every other config load * rather than `require`-ing the file itself. Unarmed (which is every `chant * lint` invocation today — `lint` has no `--sandbox` flag) that is the * identical `createRequire` path this used before, moved one module over. * * @param dir - Directory path to search for config file * @returns Loaded and merged configuration, or default config if not found */ export function loadConfig(dir: string): LintConfig { // Try chant.config.ts first — Bun has native require() for .ts, Node uses tsx's loader const tsConfigPath = join(dir, "chant.config.ts"); if (existsSync(tsConfigPath)) { try { const config = evaluateProjectConfigSync(tsConfigPath, dir); if (typeof config === "object" && config !== null) { // ChantConfig format: extract lint property if ("lint" in config && typeof config.lint === "object") { return config.lint as LintConfig; } // Bare rules at top level if ("rules" in config) { return config as LintConfig; } // Config exists but has no lint section — use defaults return DEFAULT_CONFIG; } } catch { // Fall through to JSON } } // Fall back to chant.config.json const jsonConfigPath = join(dir, "chant.config.json"); if (!existsSync(jsonConfigPath)) { return DEFAULT_CONFIG; } try { return loadConfigFile(jsonConfigPath); } catch (error) { throw error; } } /** * Resolve the effective rules for a specific file path by applying overrides. * * Starts with the base config rules, then iterates through overrides in order. * For each override whose file globs match the given path, merges override rules on top. * * @param config - The loaded lint configuration * @param filePath - The file path to resolve rules for (relative to project root) * @returns Merged rule configuration with overrides applied */ export function resolveRulesForFile(config: LintConfig, filePath: string): Record { const rules: Record = { ...config.rules }; if (!config.overrides) { return rules; } for (const override of config.overrides) { const matches = override.files.some((pattern) => { return getRuntime().globMatch(pattern, filePath); }); if (matches) { Object.assign(rules, override.rules); } } return rules; } /** Result of applying `lint.rules` to a set of post-synth diagnostics. */ export interface PostSynthSeverityResult { /** Diagnostics after config resolution — `"off"`-suppressed ones removed, everything else at its resolved severity. */ diagnostics: PostSynthDiagnostic[]; /** * Diagnostics `lint.rules` turned `"off"`, unaltered — present so a caller * can report a count (chant #1138) rather than the finding simply * vanishing, mirroring `../lint/engine.ts`'s `LintRunResult.suppressed` for * AST rules. */ suppressed: PostSynthDiagnostic[]; } /** * Apply `lint.rules` severity overrides to already-produced post-synth * diagnostics — one lexicon-shipped check's findings, or one project's * `lint.policies` findings, it doesn't matter which: both are plain * `PostSynthDiagnostic[]` by the time they reach this function, so both go * through the identical resolution an AST rule id or a COMP* check id gets * (`resolveConfiguredSeverity`, above), keyed by `diag.checkId` instead of * `LintRule.id`/`ComponentCheck.id`. `"off"` suppresses (moved to * `suppressed`); any other configured severity replaces `diag.severity`, * exactly as a config override changes an AST diagnostic's reported level. * * Lives here rather than in `./post-synth.ts` (where `PostSynthDiagnostic` is * declared) on purpose: `post-synth.ts` is a leaf every lexicon's checks * import as a real runtime module, and this file resolves built-in preset * paths via the runtime adapter at module scope — pulling that into every * lexicon's check barrel merely to share one filter function would be the * wrong trade. Only this file's TYPE (`PostSynthDiagnostic`) crosses back, * which costs nothing at runtime. * * chant #1138 — deliberately does NOT also honor `chant-disable` source * comments. `PostSynthDiagnostic` has no source anchor to disable AT — see * its doc comment (`./post-synth.ts`) for why `entity` doesn't supply one — * so there is no coherent site to check for a directive. Even in the one case * a real anchor sometimes exists (a live, in-process `ctx.entities` value * stamped with build provenance, `../provenance.ts`'s `getProvenance`), it * would not generalize: not every check sets `entity`, `entity` isn't * guaranteed to be an entities-map key (it's a name in the synthesized * OUTPUT — a CFN logical id, a k8s `metadata.name` — which a serializer is * free to have derived, prefixed, or renamed from the source-level entity * name), and that provenance never crosses the `--sandbox` policy child's * JSON wire (`../discovery/entity-wire-codec.ts` doesn't carry it, and * re-deriving it on the far side would mean sending source file paths into a * channel that's supposed to carry only the resolved build). Building * directive suppression on a sometimes-present, sometimes-not anchor would * make `chant build` and `chant build --sandbox` disagree about the * identical finding depending on which path happened to still have the * entity object around — precisely the kind of inconsistency #1138 exists to * remove. Config severity (`"off"`) is the one suppression surface this can * offer uniformly; a check that wants a per-instance escape hatch can read * `ctx.env`/its own options to decide not to emit a diagnostic at all. */ export function applyConfiguredSeverity( diagnostics: readonly PostSynthDiagnostic[], rules: Record | undefined, ): PostSynthSeverityResult { const kept: PostSynthDiagnostic[] = []; const suppressed: PostSynthDiagnostic[] = []; for (const diag of diagnostics) { const resolved = resolveConfiguredSeverity(rules, diag.checkId, diag.severity); if (resolved.severity === "off") { suppressed.push(diag); continue; } kept.push(resolved.severity === diag.severity ? diag : { ...diag, severity: resolved.severity }); } return { diagnostics: kept, suppressed }; }