Skip to content
Open
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
9 changes: 9 additions & 0 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@ linter:
html-tag-name-lowercase:
severity: warning # Options: error, warning, info, hint

# Set options defined by a specific rule
html-allowed-script-type:
allowedTypes:
- text/javascript
- application/json
allowBlank: false

# Rule with file pattern restrictions
html-img-require-alt:
# Only apply this rule to files matching these patterns
Expand Down Expand Up @@ -230,6 +237,8 @@ Each rule can be configured with the following options:
- **`only`**: Array of glob patterns - Restrict rule to ONLY these files (can override parent excludes, overrides `include`)
- **`exclude`**: Array of glob patterns - Exclude files from this rule (always applied)

Rules may also define their own options. These are documented on the individual rule page and can be set alongside the common options above. Herb validates those options after built-in and project-local rules are loaded, so misspelled option names and values of the wrong type produce configuration errors.

### Setting the Default for All Rules <Badge type="tip" text="^0.11.0" />

The `all` pseudo rule sets the default `enabled` state for every rule you don't list explicitly. It's the way to opt into a fully explicit rule set without having to know (and repeat) which rules are on by default:
Expand Down
12 changes: 9 additions & 3 deletions javascript/packages/config/src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,29 @@ export const FilesConfigSchema = z.object({
exclude: z.array(z.string()).optional().describe("Glob patterns to exclude (e.g., ['node_modules/**/*', 'vendor/**/*', '**/*.html.erb'])"),
}).strict().optional()

const RuleConfigBaseSchema = z.object({
export const RuleConfigBaseSchema = z.object({
enabled: z.boolean().optional().describe("Whether the rule is enabled"),
severity: SeverityConfigSchema.optional().describe("Severity level for the rule"),
autoCorrect: z.boolean().optional().describe("Whether autocorrection is enabled for the rule"),
include: z.array(z.string()).optional().describe("Additional glob patterns to include for this rule (additive, ignored when 'only' is present)"),
only: z.array(z.string()).optional().describe("Only apply this rule to files matching these glob patterns (overrides all 'include' patterns)"),
exclude: z.array(z.string()).optional().describe("Don't apply this rule to files matching these glob patterns"),
})

export const RuleConfigSchema = RuleConfigBaseSchema.optional()
export const BASE_RULE_CONFIG_KEYS = RuleConfigBaseSchema.keyof().options

// NOTE: Custom options are verified in a second step, and produce `RuleOptionsValidationError` when invalid
const RuleConfigWithOptionsSchema = RuleConfigBaseSchema.catchall(z.unknown())

export const RuleConfigSchema = RuleConfigWithOptionsSchema.optional()

export const LinterConfigSchema = z.object({
enabled: z.boolean().optional().describe("Whether the linter is enabled"),
failLevel: SeveritySchema.optional().describe("Exit with error code when diagnostics of this severity or higher are present (e.g., 'warning' will fail on warnings and errors)"),
logLevel: SeveritySchema.optional().describe("Only report diagnostics of this severity or higher (e.g., 'warning' hides info and hint diagnostics from the output and from CI annotations)"),
include: z.array(z.string()).optional().describe("Additional glob patterns to include beyond defaults (e.g., ['**/*.xml.erb', 'custom/**/*.html'])"),
exclude: z.array(z.string()).optional().describe("Glob patterns to exclude from linting"),
rules: z.record(z.string(), RuleConfigBaseSchema).optional().describe("Per-rule configuration"),
rules: z.record(z.string(), RuleConfigWithOptionsSchema).optional().describe("Per-rule configuration"),
}).strict().optional()

const RewriterConfigSchema = z.object({
Expand Down
29 changes: 18 additions & 11 deletions javascript/packages/config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ import packageJson from "../package.json"
import configTemplate from "./config-template.yml"
import defaultsYaml from "../../../../lib/herb/defaults.yml"

import { stringify, parse, parseDocument, isMap, isScalar, isAlias, visit } from "yaml"
import { stringify, parse, parseDocument, isMap, isScalar, visit } from "yaml"
import { semverGreaterThan } from "@herb-tools/core"
import { promises as fs } from "fs"
import { fromZodError } from "zod-validation-error"
import { deepMerge } from "./merge.js"

import { ZodError, z } from "zod"
import { HerbConfigSchema } from "./config-schema.js"
import { BASE_RULE_CONFIG_KEYS, HerbConfigSchema } from "./config-schema.js"
import { omit } from "./utils/omit.js"

import type { FrameworkSchema, TemplateEngineSchema } from "./config-schema.js"
import type { RuleConfigBaseSchema, FrameworkSchema, TemplateEngineSchema } from "./config-schema.js"

import type { DiagnosticSeverity } from "@herb-tools/core"

Expand Down Expand Up @@ -62,14 +63,9 @@ export function resolveSeverity(severity: SeverityConfig, mode: LinterMode): Dia
*/
export const ALL_RULES_KEY = "all"

export type RuleConfig = {
enabled?: boolean
severity?: SeverityConfig
autoCorrect?: boolean
include?: string[]
only?: string[]
exclude?: string[]
}
export type BaseRuleConfig = z.infer<typeof RuleConfigBaseSchema>

export type RuleConfig = BaseRuleConfig & Record<string, unknown>

export type LinterConfig = {
enabled?: boolean
Expand Down Expand Up @@ -273,6 +269,17 @@ export class Config {
return !this.isRuleDisabled(ruleName)
}

/**
* Return custom options configured for a rule.
* @param ruleName - The name of the rule to check
* @returns The custom options for the rule
*/
public getRuleOptions(ruleName: string): Record<string, unknown> {
const ruleConfig = this.config.linter?.rules?.[ruleName] || {}

return omit(ruleConfig, BASE_RULE_CONFIG_KEYS)
}

/**
* Get the files configuration for a specific tool.
* Both include and exclude patterns are additive:
Expand Down
1 change: 1 addition & 0 deletions javascript/packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
LinterConfig,
FormatterConfig,
EngineConfig,
BaseRuleConfig,
RuleConfig,
FilesConfig,
LoadOptions,
Expand Down
9 changes: 9 additions & 0 deletions javascript/packages/config/src/utils/omit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function omit(object: Record<string, unknown>, keys: readonly string[]): Record<string, unknown> {
const result = { ...object }

for (const key of keys) {
delete result[key]
}

return result
}
35 changes: 35 additions & 0 deletions javascript/packages/config/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,41 @@ describe("@herb-tools/config", () => {
})
})

describe("custom rule options", () => {
test("loads arbitrary options from YAML", async () => {
createTestFile(testDir, ".herb.yml", dedent`
version: 0.10.3

linter:
rules:
html-allowed-script-type:
enabled: true
severity: warning
autoCorrect: true
include:
- app/views/**/*.erb
only:
- app/views/scripts/**/*.erb
exclude:
- app/views/scripts/legacy/**/*.erb
allowedTypes:
- text/javascript
- application/json
allowBlank: false
nested:
mode: strict
`)

const config = await Config.load(testDir, { version: "0.10.3", silent: true })

expect(config.getRuleOptions("html-allowed-script-type")).toEqual({
allowedTypes: ["text/javascript", "application/json"],
allowBlank: false,
nested: { mode: "strict" }
})
})
})

describe("YAML anchors and aliases", () => {
test("loads configuration using YAML anchors and aliases", async () => {
createTestFile(testDir, ".herb.yml", dedent`
Expand Down
3 changes: 2 additions & 1 deletion javascript/packages/linter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
"@herb-tools/rewriter": "0.10.3",
"@ruby/prism": "^1.9.0",
"picomatch": "^4.0.5",
"tinyglobby": "^0.2.15"
"tinyglobby": "^0.2.15",
"zod": "^4.4.3"
},
"files": [
"package.json",
Expand Down
74 changes: 66 additions & 8 deletions javascript/packages/linter/src/linter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import picomatch from "picomatch"
import { ZodError } from "zod"

import { Location, semverGreaterThan } from "@herb-tools/core"
import { IdentityPrinter, IndentPrinter } from "@herb-tools/printer"
Expand All @@ -9,8 +10,6 @@ import { parseHerbDisableLine } from "./herb-disable-comment-utils.js"
import { hasLinterIgnoreDirective } from "./linter-ignore.js"
import { ParseCache } from "./parse-cache.js"

import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"

import { DEFAULT_RULE_CONFIG } from "./types.js"
import { resolveSeverity, ALL_RULES_KEY } from "@herb-tools/config"

Expand Down Expand Up @@ -65,6 +64,41 @@ export interface FilterRulesOptions {
all?: boolean
}

export class RuleOptionsValidationError extends Error {
constructor(ruleName: string, problem: string) {
super(`${ruleName}: ${problem}`)
}
}

export class UnknownRuleOptionsError extends RuleOptionsValidationError {
constructor(ruleName: string, issue: Extract<ZodError["issues"][number], { code: "unrecognized_keys" }>) {
const prefix = issue.path.map(String)
const names = issue.keys.map(key => [...prefix, key].join("."))

super(ruleName, `Unknown options: ${names.join(", ")}`)
}
}

export class InvalidRuleOptionsError extends RuleOptionsValidationError {
constructor(ruleName: string, issue: ZodError["issues"][number]) {
const optionName = issue.path.map(String).join(".")
const message = issue.message.replace(/^Invalid input: /, "")
const problem = optionName ? `${optionName}: ${message}` : message

super(ruleName, `Invalid options: ${problem}`)
}
}

function validationErrorFor(ruleName: string, error: ZodError): RuleOptionsValidationError {
const issue = error.issues[0]!

if (issue.code === "unrecognized_keys") {
return new UnknownRuleOptionsError(ruleName, issue)
} else {
return new InvalidRuleOptionsError(ruleName, issue)
}
}

export class Linter {
public rules: RuleClass[]
public rulesSkippedByVersion: VersionSkippedRule[] = []
Expand Down Expand Up @@ -122,6 +156,30 @@ export class Linter {
this.rules = rules !== undefined ? rules : this.getDefaultRules()
this.allAvailableRules = allAvailableRules !== undefined ? allAvailableRules : this.rules
this.offenses = []
this.validateRuleOptions()
}

/** Create a rule instance with its custom per-rule options applied. */
protected createRule(ruleClass: RuleClass): Rule {
const rule = new ruleClass()
const options = this.config?.getRuleOptions(ruleClass.ruleName) ?? {}

try {
return rule.configure(options)
} catch (error) {
if (error instanceof ZodError) {
throw validationErrorFor(ruleClass.ruleName, error)
}

throw error
}
}

/** Validate custom options after built-in and project-local rules are available. */
protected validateRuleOptions(): void {
for (const ruleClass of this.allAvailableRules) {
this.createRule(ruleClass)
}
}

/**
Expand Down Expand Up @@ -464,8 +522,8 @@ export class Linter {
const hasParserRule = this.findRuleClass("parser-no-errors")

if (hasParserRule) {
const rule = new ParserNoErrorsRule()
const offenses = rule.check(parseResult)
const rule = this.createRule(hasParserRule) as ParserRule
const offenses = rule.check(parseResult) as LintOffense[]

this.offenses.push(...offenses)
}
Expand Down Expand Up @@ -493,7 +551,7 @@ export class Linter {
const regularRules = this.rules.filter(ruleClass => ruleClass.ruleName !== "herb-disable-comment-unnecessary")

for (const ruleClass of regularRules) {
const rule = new ruleClass()
const rule = this.createRule(ruleClass)
const parserOptions = this.isParserRuleClass(ruleClass) ? (rule as ParserRule).parserOptions : {}
const parseResult = this.parseCache.get(source, parserOptions)

Expand Down Expand Up @@ -522,7 +580,7 @@ export class Linter {
const unnecessaryRuleClass = this.findRuleClass("herb-disable-comment-unnecessary")

if (unnecessaryRuleClass) {
const unnecessaryRule = new unnecessaryRuleClass() as ParserRule
const unnecessaryRule = this.createRule(unnecessaryRuleClass) as ParserRule
const parseResult = this.parseCache.get(source, unnecessaryRule.parserOptions)
const unboundOffenses = unnecessaryRule.check(parseResult, context)
const boundOffenses = this.bindSeverity(unboundOffenses, unnecessaryRuleClass.ruleName)
Expand Down Expand Up @@ -646,7 +704,7 @@ export class Linter {
continue
}

const rule = new ruleClass() as ParserRule
const rule = this.createRule(ruleClass) as ParserRule
const isUnsafe = (ruleClass as any).unsafeAutocorrectable === true || offense.autofixContext?.unsafe === true

if (!rule.autofix) {
Expand Down Expand Up @@ -719,7 +777,7 @@ export class Linter {
continue
}

const rule = new ruleClass() as SourceRule
const rule = this.createRule(ruleClass) as SourceRule
const isUnsafe = (ruleClass as any).unsafeAutocorrectable === true || offense.autofixContext?.unsafe === true

if (!rule.autofix) {
Expand Down
Loading
Loading