Skip to content

Commit 2c29a62

Browse files
committed
Linter: Use --regenerate-todo to create a todo list for ignoring rules.
Using this parameter creates a .herb-todo.yml file that is used to ignore rules massively, this is usefull for big projects adopting the linter, to start ignoring all current offenses without having to ommit all in the files manually.
1 parent 00582b6 commit 2c29a62

File tree

8 files changed

+413
-11
lines changed

8 files changed

+413
-11
lines changed

javascript/packages/linter/src/cli.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,17 @@ export class CLI {
141141
const startTime = Date.now()
142142
const startDate = new Date()
143143

144-
let { pattern, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix } = this.argumentParser.parse(process.argv)
144+
let {
145+
pattern,
146+
formatOption,
147+
showTiming,
148+
theme,
149+
wrapLines,
150+
truncateLines,
151+
useGitHubActions,
152+
fix,
153+
regenerateTodo
154+
} = this.argumentParser.parse(process.argv)
145155

146156
this.determineProjectPath(pattern)
147157

@@ -170,7 +180,8 @@ export class CLI {
170180
const context = {
171181
projectPath: this.projectPath,
172182
pattern,
173-
fix
183+
fix,
184+
regenerateTodo
174185
}
175186

176187
const results = await this.fileProcessor.processFiles(files, formatOption, context)

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export interface ParsedArguments {
2222
wrapLines: boolean
2323
truncateLines: boolean
2424
useGitHubActions: boolean
25-
fix: boolean
25+
fix: boolean,
26+
regenerateTodo: boolean,
2627
}
2728

2829
export class ArgumentParser {
@@ -48,6 +49,7 @@ export class ArgumentParser {
4849
--no-timing hide timing information
4950
--no-wrap-lines disable line wrapping
5051
--truncate-lines enable line truncation (mutually exclusive with line wrapping)
52+
--regenerate-todo generate a .herb-todo.yml file with current diagnostics
5153
`
5254

5355
parse(argv: string[]): ParsedArguments {
@@ -66,7 +68,8 @@ export class ArgumentParser {
6668
"no-color": { type: "boolean" },
6769
"no-timing": { type: "boolean" },
6870
"no-wrap-lines": { type: "boolean" },
69-
"truncate-lines": { type: "boolean" }
71+
"truncate-lines": { type: "boolean" },
72+
"regenerate-todo": { type: "boolean" },
7073
},
7174
allowPositionals: true
7275
})
@@ -125,11 +128,23 @@ export class ArgumentParser {
125128
process.exit(1)
126129
}
127130

131+
const regenerateTodo = values["regenerate-todo"] || false
132+
128133
const theme = values.theme || DEFAULT_THEME
129134
const pattern = this.getFilePattern(positionals)
130135
const fix = values.fix || false
131136

132-
return { pattern, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix }
137+
return {
138+
pattern,
139+
formatOption,
140+
showTiming,
141+
theme,
142+
wrapLines,
143+
truncateLines,
144+
useGitHubActions,
145+
fix,
146+
regenerateTodo,
147+
}
133148
}
134149

135150
private getFilePattern(positionals: string[]): string {

javascript/packages/linter/src/cli/file-processor.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { colorize } from "@herb-tools/highlighter"
77

88
import type { Diagnostic } from "@herb-tools/core"
99
import type { FormatOption } from "./argument-parser.js"
10+
import { LintOffense } from "../types.js"
11+
import { LinterTodo } from "../linter-todo.js"
1012

1113
export interface ProcessedFile {
1214
filename: string
@@ -19,6 +21,7 @@ export interface ProcessingContext {
1921
projectPath?: string
2022
pattern?: string
2123
fix?: boolean
24+
regenerateTodo?: boolean
2225
}
2326

2427
export interface ProcessingResult {
@@ -35,6 +38,7 @@ export interface ProcessingResult {
3538

3639
export class FileProcessor {
3740
private linter: Linter | null = null
41+
private linterTodo: LinterTodo | null = null
3842

3943
private isRuleAutocorrectable(ruleName: string): boolean {
4044
if (!this.linter) return false
@@ -51,6 +55,15 @@ export class FileProcessor {
5155
}
5256

5357
async processFiles(files: string[], formatOption: FormatOption = 'detailed', context?: ProcessingContext): Promise<ProcessingResult> {
58+
59+
if (context?.projectPath) {
60+
this.linterTodo = new LinterTodo(context.projectPath)
61+
}
62+
63+
if (context?.regenerateTodo) {
64+
this.linterTodo?.clearTodoFile()
65+
}
66+
5467
let totalErrors = 0
5568
let totalWarnings = 0
5669
let totalIgnored = 0
@@ -60,7 +73,9 @@ export class FileProcessor {
6073
const allOffenses: ProcessedFile[] = []
6174
const ruleOffenses = new Map<string, { count: number, files: Set<string> }>()
6275

63-
for (const filename of files) {
76+
const todoOffenses: Record<string, LintOffense[]> = {}
77+
78+
for (const filename of files) {
6479
const filePath = context?.projectPath ? resolve(context.projectPath, filename) : resolve(filename)
6580
let content = readFileSync(filePath, "utf-8")
6681
const parseResult = Herb.parse(content)
@@ -85,10 +100,13 @@ export class FileProcessor {
85100
}
86101

87102
if (!this.linter) {
88-
this.linter = new Linter(Herb)
103+
this.linter = new Linter(Herb, undefined, this.linterTodo)
89104
}
90105

91106
const lintResult = this.linter.lint(content, { fileName: filename })
107+
if (context?.regenerateTodo) {
108+
todoOffenses[filename] = lintResult.offenses
109+
}
92110

93111
if (ruleCount === 0) {
94112
ruleCount = this.linter.getRuleCount()
@@ -154,6 +172,10 @@ export class FileProcessor {
154172
totalIgnored += lintResult.ignored
155173
}
156174

175+
if (context?.regenerateTodo && this.linterTodo) {
176+
this.linterTodo.regenerateTodoConfig(todoOffenses)
177+
}
178+
157179
return { totalErrors, totalWarnings, totalIgnored, filesWithOffenses, filesFixed, ruleCount, allOffenses, ruleOffenses, context }
158180
}
159181
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { LintOffense } from "./types"
2+
import YAML from "yaml"
3+
import { join, relative, isAbsolute } from "path"
4+
import { existsSync, unlinkSync, readFileSync, writeFileSync } from "fs"
5+
6+
export interface TodoConfig {
7+
excludes: {
8+
[ruleName: string]: {
9+
[filePath: string]: {
10+
warnings: number
11+
errors: number
12+
}
13+
}
14+
}
15+
}
16+
17+
export class LinterTodo {
18+
private static readonly TODO_FILE = '.herb-todo.yml'
19+
private todoConfig: TodoConfig | null = null
20+
private readonly projectPath: string
21+
private readonly todoPath: string
22+
23+
constructor(projectPath: string) {
24+
this.projectPath = projectPath
25+
this.todoPath = join(this.projectPath, LinterTodo.TODO_FILE)
26+
this.loadTodoConfig()
27+
}
28+
29+
clearTodoFile(): void {
30+
if (!this.todoExists()) return
31+
unlinkSync(this.todoPath)
32+
this.loadTodoConfig()
33+
}
34+
35+
regenerateTodoConfig(offenses: Record<string, LintOffense[]>): void {
36+
const config: TodoConfig = { excludes: {} }
37+
for (const filePath of Object.keys(offenses)) {
38+
const fileOffenses = offenses[filePath]
39+
const relativePath = isAbsolute(filePath) ? relative(this.projectPath, filePath) : filePath
40+
for (const offense of fileOffenses) {
41+
const ruleName = offense.rule
42+
if (!config.excludes[ruleName]) {
43+
config.excludes[ruleName] = {}
44+
}
45+
if (!config.excludes[ruleName][relativePath]) {
46+
config.excludes[ruleName][relativePath] = {
47+
warnings: 0,
48+
errors: 0
49+
}
50+
}
51+
if (offense.severity === 'warning') {
52+
config.excludes[ruleName][relativePath].warnings++
53+
} else if (offense.severity === 'error') {
54+
config.excludes[ruleName][relativePath].errors++
55+
}
56+
}
57+
}
58+
const todoPath = join(this.projectPath, LinterTodo.TODO_FILE)
59+
writeFileSync(todoPath, YAML.stringify(config))
60+
}
61+
62+
shouldIgnoreOffense(offenses: LintOffense[], filePath: string): LintOffense[] {
63+
if (!this.todoConfig) return offenses
64+
65+
const relativePath = isAbsolute(filePath) ? relative(this.projectPath, filePath) : filePath
66+
const remainingOffenses: LintOffense[] = []
67+
68+
// Track how many errors/warnings we've skipped per rule
69+
const skipped = new Map<string, { errors: number; warnings: number }>()
70+
71+
for (const offense of offenses) {
72+
// Only consider errors and warnings
73+
if (offense.severity !== 'error' && offense.severity !== 'warning') {
74+
remainingOffenses.push(offense)
75+
continue
76+
}
77+
78+
const rule = offense.rule
79+
const ruleExcludes = this.todoConfig.excludes[rule]
80+
const allowed = ruleExcludes ? ruleExcludes[relativePath] : undefined
81+
82+
if (!allowed) {
83+
// No allowed counts for this rule/file — keep the offense
84+
remainingOffenses.push(offense)
85+
continue
86+
}
87+
88+
const counts = skipped.get(rule) || { errors: 0, warnings: 0 }
89+
90+
if (offense.severity === 'error') {
91+
if (counts.errors < allowed.errors) {
92+
counts.errors++
93+
skipped.set(rule, counts)
94+
continue
95+
}
96+
remainingOffenses.push(offense)
97+
} else {
98+
if (counts.warnings < allowed.warnings) {
99+
counts.warnings++
100+
skipped.set(rule, counts)
101+
continue
102+
}
103+
remainingOffenses.push(offense)
104+
}
105+
}
106+
107+
return remainingOffenses
108+
}
109+
110+
private todoExists(): boolean {
111+
return existsSync(this.todoPath)
112+
}
113+
114+
private loadTodoConfig(): void {
115+
if (!this.todoExists()) {
116+
this.todoConfig = { excludes: {} }
117+
return
118+
}
119+
120+
try {
121+
const content = readFileSync(this.todoPath, 'utf8')
122+
const parsed: TodoConfig = YAML.parse(content)
123+
const normalized: TodoConfig = { excludes: {} }
124+
125+
for (const ruleName of Object.keys(parsed.excludes || {})) {
126+
const files = parsed.excludes[ruleName] || {}
127+
normalized.excludes[ruleName] = {}
128+
129+
for (const fileKey of Object.keys(files)) {
130+
const targetKey = isAbsolute(fileKey) ? relative(this.projectPath, fileKey) : fileKey
131+
normalized.excludes[ruleName][targetKey] = files[fileKey]
132+
}
133+
}
134+
135+
this.todoConfig = normalized
136+
}
137+
catch {
138+
console.log('Warning: Failed to load .herb-todo.yml. Ignoring todo configuration.')
139+
this.todoConfig = { excludes: {} }
140+
}
141+
}
142+
}

javascript/packages/linter/src/linter.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,25 @@ import { IdentityPrinter } from "@herb-tools/printer"
44
import { findNodeByLocation } from "./rules/rule-utils.js"
55

66
import type { RuleClass, Rule, ParserRule, LexerRule, SourceRule, LintResult, LintOffense, LintContext, AutofixResult } from "./types.js"
7+
import { LinterTodo } from "./linter-todo.js"
78
import type { HerbBackend } from "@herb-tools/core"
89

910
export class Linter {
1011
protected rules: RuleClass[]
1112
protected herb: HerbBackend
1213
protected offenses: LintOffense[]
14+
private linterTodo: LinterTodo | null
1315

1416
/**
1517
* Creates a new Linter instance.
1618
* @param herb - The Herb backend instance for parsing and lexing
1719
* @param rules - Array of rule classes (Parser/AST or Lexer) to use. If not provided, uses default rules.
1820
*/
19-
constructor(herb: HerbBackend, rules?: RuleClass[]) {
21+
constructor(herb: HerbBackend, rules?: RuleClass[], LinterTodo?: LinterTodo | null) {
2022
this.herb = herb
2123
this.rules = rules !== undefined ? rules : this.getDefaultRules()
2224
this.offenses = []
25+
this.linterTodo = LinterTodo || null
2326
}
2427

2528
/**
@@ -144,15 +147,22 @@ export class Linter {
144147
this.offenses.push(...kept)
145148
}
146149

150+
if (this.linterTodo && context?.fileName) {
151+
const filtered: LintOffense[] = this.linterTodo.shouldIgnoreOffense(this.offenses, context.fileName)
152+
this.offenses = filtered
153+
}
154+
147155
const errors = this.offenses.filter(offense => offense.severity === "error").length
148156
const warnings = this.offenses.filter(offense => offense.severity === "warning").length
149157

150-
return {
158+
const result: LintResult = {
151159
offenses: this.offenses,
152160
errors,
153161
warnings,
154-
ignored: ignoredCount
162+
ignored: ignoredCount,
155163
}
164+
165+
return result
156166
}
157167

158168
/**
@@ -213,7 +223,7 @@ export class Linter {
213223

214224
if (offense.autofixContext) {
215225
const originalNodeType = offense.autofixContext.node.type
216-
const location: Location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location
226+
const location: Location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location
217227

218228
const freshNode = findNodeByLocation(
219229
parseResult.value,

javascript/packages/linter/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export interface LexerRuleConstructor {
142142
*/
143143
export interface LintContext {
144144
fileName: string | undefined
145+
regenerateTodo?: boolean
145146
}
146147

147148
/**

0 commit comments

Comments
 (0)