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
15 changes: 13 additions & 2 deletions javascript/packages/linter/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,17 @@ export class CLI {
const startTime = Date.now()
const startDate = new Date()

let { pattern, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix } = this.argumentParser.parse(process.argv)
let {
pattern,
formatOption,
showTiming,
theme,
wrapLines,
truncateLines,
useGitHubActions,
fix,
generateTodo
} = this.argumentParser.parse(process.argv)

this.determineProjectPath(pattern)

Expand Down Expand Up @@ -170,7 +180,8 @@ export class CLI {
const context = {
projectPath: this.projectPath,
pattern,
fix
fix,
generateTodo
}

const results = await this.fileProcessor.processFiles(files, formatOption, context)
Expand Down
21 changes: 18 additions & 3 deletions javascript/packages/linter/src/cli/argument-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export interface ParsedArguments {
wrapLines: boolean
truncateLines: boolean
useGitHubActions: boolean
fix: boolean
fix: boolean,
generateTodo: boolean,
}

export class ArgumentParser {
Expand All @@ -48,6 +49,7 @@ export class ArgumentParser {
--no-timing hide timing information
--no-wrap-lines disable line wrapping
--truncate-lines enable line truncation (mutually exclusive with line wrapping)
--generate-todo generate a .herb-todo.yml file with current diagnostics
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be --regenerate-todo as rubocop, or --generate-todo as standardrb. I think regenerate makes clear that you are destroying the old one. But generate-todo is a little less verbose and simple.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can have both so there is no surprise.

--generate-todo can generate a file if none is present yet. If one is present it could back out and say something like: .herb-todo.yml already exists, run "--regenerate-todo" to overwrite it.

--regenerate-todo would just always overwrite the current one, no matter if .herb-todo.yml exists or not.

`

parse(argv: string[]): ParsedArguments {
Expand All @@ -66,7 +68,8 @@ export class ArgumentParser {
"no-color": { type: "boolean" },
"no-timing": { type: "boolean" },
"no-wrap-lines": { type: "boolean" },
"truncate-lines": { type: "boolean" }
"truncate-lines": { type: "boolean" },
"generate-todo": { type: "boolean" },
},
allowPositionals: true
})
Expand Down Expand Up @@ -125,11 +128,23 @@ export class ArgumentParser {
process.exit(1)
}

const generateTodo = values["generate-todo"] || false

const theme = values.theme || DEFAULT_THEME
const pattern = this.getFilePattern(positionals)
const fix = values.fix || false

return { pattern, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix }
return {
pattern,
formatOption,
showTiming,
theme,
wrapLines,
truncateLines,
useGitHubActions,
fix,
generateTodo,
}
}

private getFilePattern(positionals: string[]): string {
Expand Down
26 changes: 24 additions & 2 deletions javascript/packages/linter/src/cli/file-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { colorize } from "@herb-tools/highlighter"

import type { Diagnostic } from "@herb-tools/core"
import type { FormatOption } from "./argument-parser.js"
import { LintOffense } from "../types.js"
import { LinterTodo } from "../linter-todo.js"

export interface ProcessedFile {
filename: string
Expand All @@ -19,6 +21,7 @@ export interface ProcessingContext {
projectPath?: string
pattern?: string
fix?: boolean
generateTodo?: boolean
}

export interface ProcessingResult {
Expand All @@ -35,6 +38,7 @@ export interface ProcessingResult {

export class FileProcessor {
private linter: Linter | null = null
private linterTodo: LinterTodo | null = null

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

async processFiles(files: string[], formatOption: FormatOption = 'detailed', context?: ProcessingContext): Promise<ProcessingResult> {

if (context?.projectPath) {
this.linterTodo = new LinterTodo(context.projectPath)
}

if (context?.generateTodo) {
this.linterTodo?.clearTodoFile()
}

let totalErrors = 0
let totalWarnings = 0
let totalIgnored = 0
Expand All @@ -60,7 +73,9 @@ export class FileProcessor {
const allOffenses: ProcessedFile[] = []
const ruleOffenses = new Map<string, { count: number, files: Set<string> }>()

for (const filename of files) {
const todoOffenses: Record<string, LintOffense[]> = {}

for (const filename of files) {
const filePath = context?.projectPath ? resolve(context.projectPath, filename) : resolve(filename)
let content = readFileSync(filePath, "utf-8")
const parseResult = Herb.parse(content)
Expand All @@ -85,10 +100,13 @@ export class FileProcessor {
}

if (!this.linter) {
this.linter = new Linter(Herb)
this.linter = new Linter(Herb, undefined, this.linterTodo)
Copy link
Contributor Author

@domingo2000 domingo2000 Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing undefined to let the linter set the default rules is not very idiomatic. Maybe it is better to pass the default rules on each call 🤷🏻 .

Copy link
Owner

@marcoroth marcoroth Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can refactor the Linter to take in an options object as the second argument. So it could be:

Suggested change
this.linter = new Linter(Herb, undefined, this.linterTodo)
this.linter = new Linter(Herb, { rules: customRules, linterTodo: this.linterTodo })

or just:

Suggested change
this.linter = new Linter(Herb, undefined, this.linterTodo)
this.linter = new Linter(Herb, { linterTodo: this.linterTodo })

}

const lintResult = this.linter.lint(content, { fileName: filename })
if (context?.generateTodo) {
todoOffenses[filename] = lintResult.offenses
}

if (ruleCount === 0) {
ruleCount = this.linter.getRuleCount()
Expand Down Expand Up @@ -154,6 +172,10 @@ export class FileProcessor {
totalIgnored += lintResult.ignored
}

if (context?.generateTodo) {
this.linterTodo?.generateTodoConfig(todoOffenses)
}

return { totalErrors, totalWarnings, totalIgnored, filesWithOffenses, filesFixed, ruleCount, allOffenses, ruleOffenses, context }
}
}
119 changes: 119 additions & 0 deletions javascript/packages/linter/src/linter-todo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import YAML from "yaml"
import { existsSync, unlinkSync, readFileSync, writeFileSync } from "fs"
import { join, relative, isAbsolute } from "path"

import { LinterRule, LintOffense } from "./types"

export interface TodoConfig {
excludes: {
[rule: LinterRule]: {
[filePath: string]: {
warning: number
error: number
}
}
}
}

export class LinterTodo {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name is not the best. Maybe TodoList? I belive that just Todo could fit too, but Todo could mean many more things.

private static readonly TODO_FILE = ".herb-todo.yml"
private todoConfig: TodoConfig | null = null
private readonly projectPath: string
private readonly todoPath: string

constructor(projectPath: string) {
this.projectPath = projectPath
this.todoPath = join(this.projectPath, LinterTodo.TODO_FILE)
this.loadTodoConfig()
}

clearTodoFile(): void {
if (!this.todoExists()) return
unlinkSync(this.todoPath)
this.loadTodoConfig()
}

generateTodoConfig(offenses: Record<string, LintOffense[]>): void {
const config: TodoConfig = { excludes: {} }
for (const filePath of Object.keys(offenses)) {
const fileOffenses = offenses[filePath]
const relativePath = isAbsolute(filePath) ? relative(this.projectPath, filePath) : filePath
for (const offense of fileOffenses) {
const ruleName = offense.rule
const ruleEntry = (config.excludes[ruleName] ??= {})
const ruleBaseline = (ruleEntry[relativePath] ??= { warning: 0, error: 0 })

if (offense.severity !== "warning" && offense.severity !== "error") {
continue
}

ruleBaseline[offense.severity]++
}
}
writeFileSync(this.todoPath, YAML.stringify(config))
}

filterOffenses(
offenses: LintOffense[],
filePath: string,
): LintOffense[] {
if (!this.todoConfig) return offenses

const relativePath = isAbsolute(filePath) ? relative(this.projectPath, filePath): filePath
const filteredOffenses: LintOffense[] = []

const ruleOffensesCounts = new Map<LinterRule, { error: number; warning: number }>()

for (const offense of offenses) {
if (offense.severity !== "error" && offense.severity !== "warning") {
filteredOffenses.push(offense)
continue
}

const ruleEntry = this.todoConfig.excludes[offense.rule]
const ruleBaseline = ruleEntry ? ruleEntry[relativePath] : undefined

if (!ruleBaseline) {
filteredOffenses.push(offense)
continue
}

if (!ruleOffensesCounts.has(offense.rule)) {
ruleOffensesCounts.set(offense.rule, { error: 0, warning: 0 })
}

const ruleCounts = ruleOffensesCounts.get(offense.rule)!

if (ruleCounts[offense.severity] < ruleBaseline[offense.severity]) {
ruleCounts[offense.severity]++
continue
}

filteredOffenses.push(offense)
}

return filteredOffenses
}

private todoExists(): boolean {
return existsSync(this.todoPath)
}

private loadTodoConfig(): void {
if (!this.todoExists()) {
this.todoConfig = { excludes: {} }
return
}

try {
const content = readFileSync(this.todoPath, "utf8")
const parsed: TodoConfig = YAML.parse(content)
this.todoConfig = parsed
} catch {
console.log(
"Warning: Failed to load .herb-todo.yml. Ignoring todo configuration.",
)
this.todoConfig = { excludes: {} }
}
}
}
12 changes: 10 additions & 2 deletions javascript/packages/linter/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,25 @@ import { IdentityPrinter } from "@herb-tools/printer"
import { findNodeByLocation } from "./rules/rule-utils.js"

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

export class Linter {
protected rules: RuleClass[]
protected herb: HerbBackend
protected offenses: LintOffense[]
private linterTodo: LinterTodo | null

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

/**
Expand Down Expand Up @@ -144,6 +147,11 @@ export class Linter {
this.offenses.push(...kept)
}

if (this.linterTodo && context?.fileName) {
const filtered: LintOffense[] = this.linterTodo.filterOffenses(this.offenses, context.fileName)
this.offenses = filtered
}

const errors = this.offenses.filter(offense => offense.severity === "error").length
const warnings = this.offenses.filter(offense => offense.severity === "warning").length

Expand Down Expand Up @@ -213,7 +221,7 @@ export class Linter {

if (offense.autofixContext) {
const originalNodeType = offense.autofixContext.node.type
const location: Location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location
const location: Location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location

const freshNode = findNodeByLocation(
parseResult.value,
Expand Down
1 change: 1 addition & 0 deletions javascript/packages/linter/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export interface LexerRuleConstructor {
*/
export interface LintContext {
fileName: string | undefined
generateTodo?: boolean
}

/**
Expand Down
Loading
Loading