Skip to content

Commit 5db6954

Browse files
authored
Linter: Introduce version gated linter rules (#1453)
This pull request introduces version-gated linter rule filtering so that upgrading Herb doesn't unexpectedly enable new rules for users who have locked their `.herb.yml` version. Each rule now declares `static introducedIn = this.version("X.Y.Z")` indicating the version it was first released in. When a user's `.herb.yml` version is older than a rule's `introducedIn`, that rule is automatically skipped. Users can still explicitly enable any rule in their config regardless of version **No `.herb.yml`** <img width="2093" height="623" alt="CleanShot 2026-03-22 at 04 54 37@2x" src="https://github.com/user-attachments/assets/9449b98d-ace2-4446-a3d4-36d9ffc275ab" /> **`.herb.yml` with older version** <img width="1942" height="2022" alt="CleanShot 2026-03-22 at 04 55 27@2x" src="https://github.com/user-attachments/assets/95315081-693d-4614-963e-de57428544b5" /> **Running `--upgrade`** <img width="1928" height="1748" alt="CleanShot 2026-03-22 at 04 59 31@2x" src="https://github.com/user-attachments/assets/245a6ac3-3c3e-4757-aee8-3c938e0da80b" />
1 parent c234f26 commit 5db6954

File tree

96 files changed

+1057
-64
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

96 files changed

+1057
-64
lines changed

bin/check_rule_versions

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/bin/bash
2+
3+
# Pre-release check: ensures no linter rules have introducedIn = "unreleased".
4+
# All rules must have a concrete version before publishing a release.
5+
6+
set -euo pipefail
7+
8+
RULES_DIR="javascript/packages/linter/src/rules"
9+
10+
unreleased_rules=$(grep -rl 'static introducedIn = "unreleased"' "$RULES_DIR" 2>/dev/null || true)
11+
12+
if [ -n "$unreleased_rules" ]; then
13+
echo ""
14+
echo "✗ Found linter rules with introducedIn = \"unreleased\":"
15+
echo ""
16+
17+
for file in $unreleased_rules; do
18+
rule_name=$(grep 'static ruleName' "$file" | head -1 | sed 's/.*= "//;s/".*//')
19+
echo "$rule_name ($file)"
20+
done
21+
22+
echo ""
23+
echo " Update these rules to the release version before publishing."
24+
echo ""
25+
exit 1
26+
fi
27+
28+
echo "✓ All linter rules have concrete introducedIn versions."

bin/publish_packages

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ set -euo pipefail
44

55
npm login
66

7+
echo "Checking linter rule versions..."
8+
bin/check_rule_versions
9+
710
echo "Building all packages..."
811
yarn nx run-many -t build --all
912

javascript/packages/config/src/config.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export type LoadOptions = {
9292
export type FromObjectOptions = {
9393
projectPath?: string
9494
version?: string
95+
configVersion?: string
9596
}
9697

9798
export class Config {
@@ -111,10 +112,12 @@ export class Config {
111112

112113
public readonly path: string
113114
public config: HerbConfig
115+
public readonly configVersion: string
114116

115-
constructor(projectPath: string, config: HerbConfig) {
117+
constructor(projectPath: string, config: HerbConfig, configVersion?: string) {
116118
this.path = Config.configPathFromProjectPath(projectPath)
117119
this.config = config
120+
this.configVersion = configVersion ?? config.version
118121
}
119122

120123
get projectPath(): string {
@@ -807,7 +810,7 @@ export class Config {
807810
partial: Partial<HerbConfigOptions>,
808811
options: FromObjectOptions = {}
809812
): Config {
810-
const { projectPath = process.cwd(), version = DEFAULT_VERSION } = options
813+
const { projectPath = process.cwd(), version = DEFAULT_VERSION, configVersion } = options
811814
const defaults = this.getDefaultConfig(version)
812815
const merged: HerbConfig = deepMerge(defaults, partial as any)
813816

@@ -825,7 +828,7 @@ export class Config {
825828
throw error
826829
}
827830

828-
return new Config(projectPath, merged)
831+
return new Config(projectPath, merged, configVersion)
829832
}
830833

831834
/**
@@ -1148,19 +1151,14 @@ export class Config {
11481151
throw error
11491152
}
11501153

1151-
if (parsed.version && parsed.version !== version) {
1152-
console.error(`\n⚠️ Configuration version mismatch in ${configPath}`)
1153-
console.error(` Config version: ${parsed.version}`)
1154-
console.error(` Current version: ${version}`)
1155-
console.error(` Consider updating your .herb.yml file.\n`)
1156-
}
1154+
const userConfigVersion: string = parsed.version || version
11571155

11581156
const defaults = this.getDefaultConfig(version)
11591157
const resolved = deepMerge(defaults, parsed as Partial<HerbConfig>)
11601158

11611159
resolved.version = version
11621160

1163-
return new Config(projectRoot, resolved)
1161+
return new Config(projectRoot, resolved, userConfigVersion)
11641162
}
11651163

11661164
/**

javascript/packages/config/test/config.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,4 +1227,122 @@ describe("@herb-tools/config", () => {
12271227
expect(defaultPatterns).toContain("**/*.turbo_stream.erb")
12281228
})
12291229
})
1230+
1231+
describe("Config.configVersion", () => {
1232+
test("defaults to config.version when not provided", () => {
1233+
const config = new Config(testDir, { version: "0.9.2" })
1234+
1235+
expect(config.configVersion).toBe("0.9.2")
1236+
})
1237+
1238+
test("preserves explicit configVersion", () => {
1239+
const config = new Config(testDir, { version: "0.9.2" }, "0.8.0")
1240+
1241+
expect(config.version).toBe("0.9.2")
1242+
expect(config.configVersion).toBe("0.8.0")
1243+
})
1244+
1245+
test("fromObject passes configVersion through", () => {
1246+
const config = Config.fromObject({}, { projectPath: testDir, configVersion: "0.7.0" })
1247+
1248+
expect(config.configVersion).toBe("0.7.0")
1249+
})
1250+
1251+
test("fromObject defaults configVersion to tool version when not specified", () => {
1252+
const config = Config.fromObject({}, { projectPath: testDir })
1253+
1254+
expect(config.configVersion).toBe(config.version)
1255+
})
1256+
1257+
test("load preserves user config version from .herb.yml", async () => {
1258+
createTestFile(testDir, ".herb.yml", "version: 0.8.0\n\nlinter:\n enabled: true\n")
1259+
1260+
const config = await Config.load(testDir, { version: "0.9.2", silent: true })
1261+
1262+
expect(config.version).toBe("0.9.2")
1263+
expect(config.configVersion).toBe("0.8.0")
1264+
})
1265+
1266+
test("load defaults configVersion to tool version when .herb.yml has no version", async () => {
1267+
createTestFile(testDir, ".herb.yml", "linter:\n enabled: true\n")
1268+
1269+
const config = await Config.load(testDir, { version: "0.9.2", silent: true })
1270+
1271+
expect(config.configVersion).toBe("0.9.2")
1272+
})
1273+
})
1274+
1275+
describe("Config upgrade workflow", () => {
1276+
test("mutateConfigFile adds disabled rules", async () => {
1277+
const configContent = dedent`
1278+
version: 0.8.0
1279+
1280+
linter:
1281+
enabled: true
1282+
`
1283+
1284+
createTestFile(testDir, ".herb.yml", configContent + "\n")
1285+
1286+
await Config.mutateConfigFile(join(testDir, ".herb.yml"), {
1287+
linter: {
1288+
rules: {
1289+
"new-rule-a": { enabled: false },
1290+
"new-rule-b": { enabled: false }
1291+
}
1292+
}
1293+
})
1294+
1295+
const config = await Config.load(testDir, { version: "0.9.2", silent: true })
1296+
1297+
expect(config.linter?.rules?.["new-rule-a"]?.enabled).toBe(false)
1298+
expect(config.linter?.rules?.["new-rule-b"]?.enabled).toBe(false)
1299+
})
1300+
1301+
test("mutateConfigFile preserves existing rules", async () => {
1302+
const configContent = dedent`
1303+
version: 0.8.0
1304+
1305+
linter:
1306+
enabled: true
1307+
rules:
1308+
existing-rule:
1309+
enabled: false
1310+
`
1311+
1312+
createTestFile(testDir, ".herb.yml", configContent + "\n")
1313+
1314+
await Config.mutateConfigFile(join(testDir, ".herb.yml"), {
1315+
linter: {
1316+
rules: {
1317+
"new-rule": { enabled: false }
1318+
}
1319+
}
1320+
})
1321+
1322+
const config = await Config.load(testDir, { version: "0.9.2", silent: true })
1323+
1324+
expect(config.linter?.rules?.["existing-rule"]?.enabled).toBe(false)
1325+
expect(config.linter?.rules?.["new-rule"]?.enabled).toBe(false)
1326+
})
1327+
1328+
test("version can be updated via file content replacement", async () => {
1329+
const configContent = dedent`
1330+
version: 0.8.0
1331+
1332+
linter:
1333+
enabled: true
1334+
`
1335+
1336+
const configPath = createTestFile(testDir, ".herb.yml", configContent + "\n")
1337+
1338+
const { readFileSync, writeFileSync } = await import("fs")
1339+
let content = readFileSync(configPath, "utf-8")
1340+
content = content.replace(/^version:\s*.+$/m, "version: 0.9.2")
1341+
writeFileSync(configPath, content, "utf-8")
1342+
1343+
const config = await Config.load(testDir, { version: "0.9.2", silent: true })
1344+
1345+
expect(config.configVersion).toBe("0.9.2")
1346+
})
1347+
})
12301348
})

javascript/packages/language-server/src/linter_service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export class LinterService {
161161
}
162162
}, { projectPath: projectConfig?.projectPath || process.cwd() })
163163

164-
const filteredRules = Linter.filterRulesByConfig(this.allRules, config.linter?.rules)
164+
const { enabled: filteredRules } = Linter.filterRulesByConfig(this.allRules, config.linter?.rules, config.configVersion)
165165

166166
this.linter = new Linter(Herb, filteredRules, config, this.allRules)
167167
}

javascript/packages/linter/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"watch": "tsc -b -w",
2323
"test": "vitest run",
2424
"test:watch": "vitest --watch",
25-
"prepublishOnly": "yarn clean && yarn build && yarn test"
25+
"check:rule-versions": "! grep -rl 'static introducedIn = \"unreleased\"' src/rules/ 2>/dev/null || (echo '\\n✗ Found linter rules with introducedIn = \"unreleased\". Update them to a release version before publishing.\\n' && exit 1)",
26+
"prepublishOnly": "yarn check:rule-versions && yarn clean && yarn build && yarn test"
2627
},
2728
"exports": {
2829
"./package.json": "./package.json",

javascript/packages/linter/src/cli.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { Config, addHerbExtensionRecommendation, getExtensionsJsonRelativePath }
44

55
import { existsSync, statSync } from "fs"
66
import { resolve, relative } from "path"
7+
import { colorize } from "@herb-tools/highlighter"
78

9+
import { Linter } from "./linter.js"
10+
import { rules } from "./rules.js"
811
import { ArgumentParser } from "./cli/argument-parser.js"
912
import { FileProcessor } from "./cli/file-processor.js"
1013
import { OutputManager } from "./cli/output-manager.js"
@@ -144,7 +147,7 @@ export class CLI {
144147
const startTime = Date.now()
145148
const startDate = new Date()
146149

147-
const { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, fixUnsafe, ignoreDisableComments, force, init, loadCustomRules, failLevel, jobs } = this.argumentParser.parse(process.argv)
150+
const { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, fixUnsafe, ignoreDisableComments, force, init, upgrade, loadCustomRules, failLevel, jobs } = this.argumentParser.parse(process.argv)
148151

149152
this.determineProjectPath(patterns)
150153

@@ -171,6 +174,57 @@ export class CLI {
171174
process.exit(0)
172175
}
173176

177+
if (upgrade) {
178+
const configPath = configFile || this.projectPath
179+
180+
if (!Config.exists(configPath)) {
181+
console.error(`\n✗ No .herb.yml found. Run ${colorize("herb-lint --init", "cyan")} first.\n`)
182+
process.exit(1)
183+
}
184+
185+
const config = await Config.load(configPath, { version, exitOnError: true, createIfMissing: false, silent: true })
186+
const configVersion = config.configVersion
187+
188+
if (configVersion === version) {
189+
console.log(`\n✓ Your .herb.yml is already at version ${version}. Nothing to upgrade.\n`)
190+
process.exit(0)
191+
}
192+
193+
const { skippedByVersion } = Linter.filterRulesByConfig(rules, config.linter?.rules, configVersion)
194+
195+
const rulesMutation: Record<string, { enabled: boolean }> = {}
196+
197+
for (const rule of skippedByVersion) {
198+
rulesMutation[rule.ruleName] = { enabled: false }
199+
}
200+
201+
await Config.mutateConfigFile(config.path, {
202+
linter: { rules: rulesMutation }
203+
})
204+
205+
const { promises: fs } = await import("fs")
206+
let content = await fs.readFile(config.path, "utf-8")
207+
content = content.replace(/^version:\s*.+$/m, `version: ${version}`)
208+
await fs.writeFile(config.path, content, "utf-8")
209+
210+
console.log(`\n${colorize("✓", "brightGreen")} Updated ${colorize(".herb.yml", "cyan")} version from ${colorize(configVersion, "cyan")} to ${colorize(version, "cyan")}`)
211+
212+
if (skippedByVersion.length > 0) {
213+
console.log(`${colorize("✓", "brightGreen")} Disabled ${colorize(String(skippedByVersion.length), "bold")} newly introduced ${skippedByVersion.length === 1 ? "rule" : "rules"}:`)
214+
console.log("")
215+
216+
for (const rule of skippedByVersion) {
217+
console.log(` ${colorize(rule.ruleName, "white")}: ${colorize("enabled: false", "gray")}`)
218+
}
219+
220+
console.log("")
221+
console.log(` Enable rules individually in your ${colorize(".herb.yml", "cyan")} when you're ready.`)
222+
}
223+
224+
console.log("")
225+
process.exit(0)
226+
}
227+
174228
const silent = formatOption === 'json'
175229
const config = await Config.load(configFile || this.projectPath, { version, exitOnError: true, createIfMissing: false, silent })
176230
const linterConfig = config.options.linter || {}
@@ -183,7 +237,8 @@ export class CLI {
183237
showTiming,
184238
useGitHubActions,
185239
startTime,
186-
startDate
240+
startDate,
241+
toolVersion: version
187242
}
188243

189244
try {
@@ -260,6 +315,13 @@ export class CLI {
260315
const results = await this.fileProcessor.processFiles(files, formatOption, context)
261316

262317
await this.outputManager.outputResults({ ...results, files }, outputOptions)
318+
319+
if (!Config.exists(this.projectPath) && formatOption !== 'json' && !useGitHubActions) {
320+
console.log("")
321+
console.log(` ${colorize("TIP:", "bold")} Run ${colorize("herb-lint --init", "cyan")} to create a ${colorize(".herb.yml", "cyan")} and lock the ${colorize("version", "cyan")}.`)
322+
console.log(` This ensures upgrading Herb won't enable new rules until you update the ${colorize("version", "cyan")} in ${colorize(".herb.yml", "cyan")}.`)
323+
}
324+
263325
await this.afterProcess(results, outputOptions)
264326

265327
const effectiveFailLevel = failLevel || linterConfig.failLevel

javascript/packages/linter/src/cli/argument-parser.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface ParsedArguments {
2626
ignoreDisableComments: boolean
2727
force: boolean
2828
init: boolean
29+
upgrade: boolean
2930
loadCustomRules: boolean
3031
failLevel?: DiagnosticSeverity
3132
jobs: number
@@ -43,6 +44,7 @@ export class ArgumentParser {
4344
-h, --help show help
4445
-v, --version show version
4546
--init create a .herb.yml configuration file in the current directory
47+
--upgrade update .herb.yml version and disable all newly introduced rules
4648
-c, --config-file <path> explicitly specify path to .herb.yml config file
4749
--force force linting even if disabled in .herb.yml
4850
--fix automatically fix auto-correctable offenses
@@ -71,6 +73,7 @@ export class ArgumentParser {
7173
help: { type: "boolean", short: "h" },
7274
version: { type: "boolean", short: "v" },
7375
init: { type: "boolean" },
76+
upgrade: { type: "boolean" },
7477
"config-file": { type: "string", short: "c" },
7578
force: { type: "boolean" },
7679
fix: { type: "boolean" },
@@ -155,6 +158,7 @@ export class ArgumentParser {
155158
const ignoreDisableComments = values["ignore-disable-comments"] || false
156159
const configFile = values["config-file"]
157160
const init = values.init || false
161+
const upgrade = values.upgrade || false
158162
const loadCustomRules = !values["no-custom-rules"]
159163

160164
let failLevel: DiagnosticSeverity | undefined
@@ -181,7 +185,7 @@ export class ArgumentParser {
181185
jobs = parsed
182186
}
183187

184-
return { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, fixUnsafe, ignoreDisableComments, force, init, loadCustomRules, failLevel, jobs }
188+
return { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, fixUnsafe, ignoreDisableComments, force, init, upgrade, loadCustomRules, failLevel, jobs }
185189
}
186190

187191
private getFilePatterns(positionals: string[]): string[] {

0 commit comments

Comments
 (0)