diff --git a/javascript/packages/linter/src/cli.ts b/javascript/packages/linter/src/cli.ts index d22ab76ba..2cc8024e9 100644 --- a/javascript/packages/linter/src/cli.ts +++ b/javascript/packages/linter/src/cli.ts @@ -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) @@ -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) diff --git a/javascript/packages/linter/src/cli/argument-parser.ts b/javascript/packages/linter/src/cli/argument-parser.ts index a4af407d6..36d45220f 100644 --- a/javascript/packages/linter/src/cli/argument-parser.ts +++ b/javascript/packages/linter/src/cli/argument-parser.ts @@ -22,7 +22,8 @@ export interface ParsedArguments { wrapLines: boolean truncateLines: boolean useGitHubActions: boolean - fix: boolean + fix: boolean, + generateTodo: boolean, } export class ArgumentParser { @@ -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 ` parse(argv: string[]): ParsedArguments { @@ -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 }) @@ -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 { diff --git a/javascript/packages/linter/src/cli/file-processor.ts b/javascript/packages/linter/src/cli/file-processor.ts index 2e98cb798..05b85b651 100644 --- a/javascript/packages/linter/src/cli/file-processor.ts +++ b/javascript/packages/linter/src/cli/file-processor.ts @@ -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 @@ -19,6 +21,7 @@ export interface ProcessingContext { projectPath?: string pattern?: string fix?: boolean + generateTodo?: boolean } export interface ProcessingResult { @@ -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 @@ -51,6 +55,15 @@ export class FileProcessor { } async processFiles(files: string[], formatOption: FormatOption = 'detailed', context?: ProcessingContext): Promise { + + if (context?.projectPath) { + this.linterTodo = new LinterTodo(context.projectPath) + } + + if (context?.generateTodo) { + this.linterTodo?.clearTodoFile() + } + let totalErrors = 0 let totalWarnings = 0 let totalIgnored = 0 @@ -60,7 +73,9 @@ export class FileProcessor { const allOffenses: ProcessedFile[] = [] const ruleOffenses = new Map }>() - for (const filename of files) { + const todoOffenses: Record = {} + + 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) @@ -85,10 +100,13 @@ export class FileProcessor { } if (!this.linter) { - this.linter = new Linter(Herb) + this.linter = new Linter(Herb, undefined, this.linterTodo) } const lintResult = this.linter.lint(content, { fileName: filename }) + if (context?.generateTodo) { + todoOffenses[filename] = lintResult.offenses + } if (ruleCount === 0) { ruleCount = this.linter.getRuleCount() @@ -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 } } } diff --git a/javascript/packages/linter/src/linter-todo.ts b/javascript/packages/linter/src/linter-todo.ts new file mode 100644 index 000000000..fbe100ec8 --- /dev/null +++ b/javascript/packages/linter/src/linter-todo.ts @@ -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 { + 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): 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() + + 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: {} } + } + } +} diff --git a/javascript/packages/linter/src/linter.ts b/javascript/packages/linter/src/linter.ts index 7c8630ef5..ccf05a2c9 100644 --- a/javascript/packages/linter/src/linter.ts +++ b/javascript/packages/linter/src/linter.ts @@ -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 } /** @@ -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 @@ -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, diff --git a/javascript/packages/linter/src/types.ts b/javascript/packages/linter/src/types.ts index ab0d1d31a..b752b17e9 100644 --- a/javascript/packages/linter/src/types.ts +++ b/javascript/packages/linter/src/types.ts @@ -142,6 +142,7 @@ export interface LexerRuleConstructor { */ export interface LintContext { fileName: string | undefined + generateTodo?: boolean } /** diff --git a/javascript/packages/linter/test/linter-todo.test.ts b/javascript/packages/linter/test/linter-todo.test.ts new file mode 100644 index 000000000..f081f401e --- /dev/null +++ b/javascript/packages/linter/test/linter-todo.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, afterEach, expect, describe, test } from "vitest" +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" +import YAML from "yaml" + +import { LinterRule, LintOffense, LintSeverity } from "../src/types" +import { LinterTodo } from "../src/linter-todo" +import { Location } from "@herb-tools/core" + +describe("LinterTodo", () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "herb-test-")) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + const createOffense = ( + rule: LinterRule, + severity: LintSeverity, + ): LintOffense => + ({ + rule, + message: "", + severity, + location: Location.from({ + start: { line: 1, column: 1 }, + end: { line: 1, column: 2 }, + }), + }) as unknown as LintOffense + + describe("#generateTodoConfig", () => { + test("generates a todo config with correct counts", () => { + const offenses: Record = { + "src/test.html.erb": [ + createOffense("rule1", "error"), + createOffense("rule1", "warning"), + createOffense("rule1", "warning"), + createOffense("rule2", "error"), + ], + } + + const linterTodo = new LinterTodo(tmpDir) + + linterTodo.generateTodoConfig(offenses) + + const todoContent = YAML.parse( + readFileSync(join(tmpDir, ".herb-todo.yml"), "utf8"), + ) + expect(todoContent).toEqual({ + excludes: { + rule1: { + "src/test.html.erb": { + error: 1, + warning: 2, + }, + }, + rule2: { + "src/test.html.erb": { + error: 1, + warning: 0, + }, + }, + }, + }) + }) + }) + + describe("#filterOffenses", () => { + test("ignores offenses within the allowed count", () => { + const todoConfig = { + excludes: { + rule1: { + "src/test.html.erb": { + error: 1, + warning: 2, + }, + }, + }, + } + writeFileSync(join(tmpDir, ".herb-todo.yml"), YAML.stringify(todoConfig)) + + const linterTodo = new LinterTodo(tmpDir) + + const offenses = [ + createOffense("rule1", "error"), + createOffense("rule1", "warning"), + createOffense("rule1", "warning"), + ] + + const remaining = linterTodo.filterOffenses( + offenses, + "src/test.html.erb", + ) + expect(remaining).toHaveLength(0) + }) + + test("reports offenses exceeding the allowed count", () => { + const todoConfig = { + excludes: { + rule1: { + "src/test.html.erb": { + error: 1, + warning: 1, + }, + }, + }, + } + writeFileSync(join(tmpDir, ".herb-todo.yml"), YAML.stringify(todoConfig)) + + const f = new LinterTodo(tmpDir) + + const offenses = [ + createOffense("rule1", "error"), + createOffense("rule1", "error"), // This exceeds the limit + createOffense("rule1", "warning"), + createOffense("rule1", "warning"), // This exceeds the limit + ] + + const remaining = f.filterOffenses(offenses, "src/test.html.erb") + expect(remaining).toHaveLength(2) + expect(remaining[0].severity).toBe("error") + expect(remaining[1].severity).toBe("warning") + }) + + test("ignores info and hint severities when generating counts", () => { + const todoConfig = { + excludes: { + rule1: { + "src/test.html.erb": { + error: 1, + warning: 1, + }, + }, + }, + } + writeFileSync(join(tmpDir, ".herb-todo.yml"), YAML.stringify(todoConfig)) + + const linterTodo = new LinterTodo(tmpDir) + + const offenses = [ + createOffense("rule1", "error"), + createOffense("rule1", "info"), + createOffense("rule1", "hint"), + ] + + const remaining = linterTodo.filterOffenses( + offenses, + "src/test.html.erb", + ) + + expect(remaining).toHaveLength(2) + expect(remaining[0].severity).toBe("info") + expect(remaining[1].severity).toBe("hint") + }) + + test("returns all offenses when no todo config exists", () => { + const offenses = [ + createOffense("rule1", "error"), + createOffense("rule1", "warning"), + ] + + const linterTodo = new LinterTodo(tmpDir) + const remaining = linterTodo.filterOffenses( + offenses, + "src/test.html.erb", + ) + expect(remaining).toEqual(offenses) + }) + }) +}) diff --git a/javascript/packages/linter/test/linter.test.ts b/javascript/packages/linter/test/linter.test.ts index bfcd750ff..5ea3f5279 100644 --- a/javascript/packages/linter/test/linter.test.ts +++ b/javascript/packages/linter/test/linter.test.ts @@ -1,9 +1,15 @@ import { describe, test, expect, beforeAll } from "vitest" +import dedent from "dedent" +import { mkdtempSync, writeFileSync, rmSync } from "fs" +import { tmpdir } from "os" +import { join as pathJoin } from "path" import { Herb } from "@herb-tools/node-wasm" import { Linter } from "../src/linter.js" +import { LinterTodo } from "../src/linter-todo.js" import { HTMLTagNameLowercaseRule } from "../src/rules/html-tag-name-lowercase.js" +import { HTMLAttributeDoubleQuotesRule } from "../src/rules/html-attribute-double-quotes.js" import { ParserRule, SourceRule } from "../src/types.js" import type { LintOffense, LintContext } from "../src/types.js" @@ -203,4 +209,40 @@ describe("@herb-tools/linter", () => { expect(lintResult.ignored).toBe(2) }) }) + + describe("Todo disabling with .herb-todo.yml", () => { + test("omits offenses listed in todo", () => { + const tmpDir = mkdtempSync(pathJoin(tmpdir(), "herb-todo-test-")) + const todoPath = pathJoin(tmpDir, ".herb-todo.yml") + const fileName = "test.html" + const todoContent = dedent` + excludes: + html-tag-name-lowercase: + ${fileName}: + errors: 2 + warnings: 0 + ` + writeFileSync(todoPath, todoContent) + + const html = dedent(` +
+ Hello +
+ `) + + try { + const linterTodo = new LinterTodo(tmpDir) + const linter = new Linter(Herb, [HTMLTagNameLowercaseRule, HTMLAttributeDoubleQuotesRule], linterTodo) + const lintResult = linter.lint(html, { fileName }) + + expect(lintResult.errors).toBe(2) + expect(lintResult.warnings).toBe(1) + expect(lintResult.offenses).toHaveLength(3) + expect(lintResult.offenses.filter(o => o.rule === "html-tag-name-lowercase")).toHaveLength(2) + expect(lintResult.offenses.filter(o => o.rule === "html-attribute-double-quotes")).toHaveLength(1) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + }) })