Skip to content

Commit a0ec12c

Browse files
authored
Linter CLI: Implement a Linter Worker to parallelize file processing (#1371)
This pull request implements a linter worker to speed up the file processing of the linter CLI. On a quick initial test, it seems to be quite significant using the default (`auto`) option: ### App 1 **Before** ``` Summary: Checked 421 files Files 24 with offenses | 397 clean (421 total) Offenses 23 errors | 33 warnings | 1 info (57 offenses across 24 files) Fixable 57 offenses | 25 autocorrectable using `--fix` Start at 01:58:43 Duration 2958ms (71 rules) ``` **After** ``` Summary: Checked 421 files Files 24 with offenses | 397 clean (421 total) Offenses 23 errors | 33 warnings | 1 info (57 offenses across 24 files) Fixable 57 offenses | 25 autocorrectable using `--fix` Start at 01:57:40 Duration 1264ms (71 rules) ``` ### App 2 **Before** ``` Summary: Checked 1384 files Files 564 with offenses | 820 clean (1384 total) Offenses 1998 errors | 282 warnings (2280 offenses across 564 files) Fixable 2280 offenses | 591 autocorrectable using `--fix` Start at 01:59:52 Duration 7292ms (70 rules) ``` **After** ``` Summary: Checked 1384 files Files 564 with offenses | 820 clean (1384 total) Offenses 1998 errors | 282 warnings (2280 offenses across 564 files) Fixable 2280 offenses | 591 autocorrectable using `--fix` Start at 01:59:20 Duration 2427ms (70 rules) ```
1 parent e2b3d16 commit a0ec12c

File tree

7 files changed

+446
-7
lines changed

7 files changed

+446
-7
lines changed

javascript/packages/linter/rollup.config.mjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ const external = [
1010
"url",
1111
"fs",
1212
"module",
13+
"os",
14+
"worker_threads",
15+
"node:path",
16+
"node:url",
17+
"node:fs",
18+
"node:os",
19+
"node:worker_threads",
1320
]
1421

1522
function isExternal(id) {
@@ -41,6 +48,27 @@ export default [
4148
],
4249
},
4350

51+
// Lint worker entry point (CommonJS - used by worker_threads)
52+
{
53+
input: "src/cli/lint-worker.ts",
54+
output: {
55+
file: "dist/lint-worker.js",
56+
format: "cjs",
57+
sourcemap: true,
58+
},
59+
external: isExternal,
60+
plugins: [
61+
nodeResolve(),
62+
commonjs(),
63+
json(),
64+
typescript({
65+
tsconfig: "./tsconfig.json",
66+
rootDir: "src/",
67+
module: "esnext",
68+
}),
69+
],
70+
},
71+
4472
// Library exports (ESM)
4573
{
4674
input: "src/index.ts",

javascript/packages/linter/src/cli.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export class CLI {
144144
const startTime = Date.now()
145145
const startDate = new Date()
146146

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

149149
this.determineProjectPath(patterns)
150150

@@ -246,13 +246,15 @@ export class CLI {
246246

247247
const context: ProcessingContext = {
248248
projectPath: this.projectPath,
249+
configPath: configFile,
249250
pattern: patterns.join(' '),
250251
fix,
251252
fixUnsafe,
252253
ignoreDisableComments,
253254
linterConfig,
254255
config: processingConfig,
255-
loadCustomRules
256+
loadCustomRules,
257+
jobs
256258
}
257259

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

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import dedent from "dedent"
22

3+
import { availableParallelism } from "node:os"
34
import { parseArgs } from "util"
45
import { Herb } from "@herb-tools/node-wasm"
56

@@ -27,6 +28,7 @@ export interface ParsedArguments {
2728
init: boolean
2829
loadCustomRules: boolean
2930
failLevel?: DiagnosticSeverity
31+
jobs: number
3032
}
3133

3234
export class ArgumentParser {
@@ -53,6 +55,8 @@ export class ArgumentParser {
5355
--github enable GitHub Actions annotations (combines with --format)
5456
--no-github disable GitHub Actions annotations (even in GitHub Actions environment)
5557
--no-custom-rules disable loading custom rules from project (custom rules are loaded by default from .herb/rules/**/*.{mjs,js})
58+
-j, --jobs <n> number of parallel workers for linting files [default: auto]
59+
use "auto" to detect based on available CPU cores
5660
--theme syntax highlighting theme (${THEME_NAMES.join("|")}) or path to custom theme file [default: ${DEFAULT_THEME}]
5761
--no-color disable colored output
5862
--no-timing hide timing information
@@ -83,7 +87,8 @@ export class ArgumentParser {
8387
"no-timing": { type: "boolean" },
8488
"no-wrap-lines": { type: "boolean" },
8589
"truncate-lines": { type: "boolean" },
86-
"no-custom-rules": { type: "boolean" }
90+
"no-custom-rules": { type: "boolean" },
91+
jobs: { type: "string", short: "j" }
8792
},
8893
allowPositionals: true
8994
})
@@ -163,7 +168,20 @@ export class ArgumentParser {
163168
}
164169
}
165170

166-
return { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, fixUnsafe, ignoreDisableComments, force, init, loadCustomRules, failLevel }
171+
let jobs = availableParallelism()
172+
173+
if (values.jobs && values.jobs !== "auto") {
174+
const parsed = parseInt(values.jobs, 10)
175+
176+
if (isNaN(parsed) || parsed < 1) {
177+
console.error(`Error: Invalid --jobs value "${values.jobs}". Must be a positive integer or "auto".`)
178+
process.exit(1)
179+
}
180+
181+
jobs = parsed
182+
}
183+
184+
return { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, fixUnsafe, ignoreDisableComments, force, init, loadCustomRules, failLevel, jobs }
167185
}
168186

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

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

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ import { Linter } from "../linter.js"
33
import { loadCustomRules } from "../loader.js"
44
import { Config } from "@herb-tools/config"
55

6-
import { readFileSync, writeFileSync } from "fs"
7-
import { resolve } from "path"
6+
import { Worker } from "node:worker_threads"
7+
import { readFileSync, writeFileSync } from "node:fs"
8+
import { resolve, dirname, join } from "node:path"
9+
import { fileURLToPath } from "node:url"
10+
import { availableParallelism } from "node:os"
811
import { colorize } from "@herb-tools/highlighter"
912

1013
import type { Diagnostic } from "@herb-tools/core"
1114
import type { FormatOption } from "./argument-parser.js"
1215
import type { HerbConfigOptions } from "@herb-tools/config"
16+
import type { WorkerInput, WorkerResult } from "./lint-worker.js"
1317

1418
export interface ProcessedFile {
1519
filename: string
@@ -20,13 +24,15 @@ export interface ProcessedFile {
2024

2125
export interface ProcessingContext {
2226
projectPath?: string
27+
configPath?: string
2328
pattern?: string
2429
fix?: boolean
2530
fixUnsafe?: boolean
2631
ignoreDisableComments?: boolean
2732
linterConfig?: HerbConfigOptions['linter']
2833
config?: Config
2934
loadCustomRules?: boolean
35+
jobs?: number
3036
}
3137

3238
export interface ProcessingResult {
@@ -44,6 +50,13 @@ export interface ProcessingResult {
4450
context?: ProcessingContext
4551
}
4652

53+
/**
54+
* Minimum number of files required to use parallel processing.
55+
* Below this threshold, sequential processing is faster due to
56+
* worker thread startup overhead (loading WASM, config, etc.).
57+
*/
58+
const PARALLEL_FILE_THRESHOLD = 10
59+
4760
export class FileProcessor {
4861
private linter: Linter | null = null
4962
private customRulesLoaded: boolean = false
@@ -61,6 +74,17 @@ export class FileProcessor {
6174
}
6275

6376
async processFiles(files: string[], formatOption: FormatOption = 'detailed', context?: ProcessingContext): Promise<ProcessingResult> {
77+
const jobs = context?.jobs ?? 1
78+
const shouldParallelize = jobs > 1 && files.length >= PARALLEL_FILE_THRESHOLD
79+
80+
if (shouldParallelize) {
81+
return this.processFilesInParallel(files, jobs, formatOption, context)
82+
}
83+
84+
return this.processFilesSequentially(files, formatOption, context)
85+
}
86+
87+
private async processFilesSequentially(files: string[], formatOption: FormatOption = 'detailed', context?: ProcessingContext): Promise<ProcessingResult> {
6488
let totalErrors = 0
6589
let totalWarnings = 0
6690
let totalInfo = 0
@@ -223,4 +247,161 @@ export class FileProcessor {
223247

224248
return result
225249
}
250+
251+
private async processFilesInParallel(files: string[], jobs: number, formatOption: FormatOption, context?: ProcessingContext): Promise<ProcessingResult> {
252+
const workerCount = Math.min(jobs, files.length)
253+
const chunks = this.splitIntoChunks(files, workerCount)
254+
const workerPath = this.resolveWorkerPath()
255+
256+
const workerPromises = chunks.map(chunk => this.runWorker(workerPath, chunk, context))
257+
const workerResults = await Promise.all(workerPromises)
258+
259+
for (const result of workerResults) {
260+
if (result.error) {
261+
throw new Error(`Worker error: ${result.error}`)
262+
}
263+
}
264+
265+
return this.aggregateWorkerResults(workerResults, formatOption, context)
266+
}
267+
268+
private resolveWorkerPath(): string {
269+
try {
270+
const currentDir = dirname(fileURLToPath(import.meta.url))
271+
272+
return join(currentDir, "lint-worker.js")
273+
} catch {
274+
return join(__dirname, "lint-worker.js")
275+
}
276+
}
277+
278+
private splitIntoChunks(files: string[], chunkCount: number): string[][] {
279+
const chunks: string[][] = Array.from({ length: chunkCount }, () => [])
280+
281+
for (let i = 0; i < files.length; i++) {
282+
chunks[i % chunkCount].push(files[i])
283+
}
284+
285+
return chunks.filter(chunk => chunk.length > 0)
286+
}
287+
288+
private runWorker(workerPath: string, files: string[], context?: ProcessingContext): Promise<WorkerResult> {
289+
return new Promise((resolve, reject) => {
290+
const workerData: WorkerInput = {
291+
files,
292+
projectPath: context?.projectPath || process.cwd(),
293+
configPath: context?.configPath,
294+
fix: context?.fix || false,
295+
fixUnsafe: context?.fixUnsafe || false,
296+
ignoreDisableComments: context?.ignoreDisableComments || false,
297+
loadCustomRules: context?.loadCustomRules || false,
298+
}
299+
300+
const worker = new Worker(workerPath, { workerData })
301+
302+
worker.on("message", (result: WorkerResult) => {
303+
resolve(result)
304+
})
305+
306+
worker.on("error", (error) => {
307+
reject(error)
308+
})
309+
310+
worker.on("exit", (code) => {
311+
if (code !== 0) {
312+
reject(new Error(`Worker exited with code ${code}`))
313+
}
314+
})
315+
})
316+
}
317+
318+
private aggregateWorkerResults(results: WorkerResult[], formatOption: FormatOption, context?: ProcessingContext): ProcessingResult {
319+
let totalErrors = 0
320+
let totalWarnings = 0
321+
let totalInfo = 0
322+
let totalHints = 0
323+
let totalIgnored = 0
324+
let totalWouldBeIgnored = 0
325+
let filesWithOffenses = 0
326+
let filesFixed = 0
327+
let ruleCount = 0
328+
329+
const allOffenses: ProcessedFile[] = []
330+
const ruleOffenses = new Map<string, { count: number, files: Set<string> }>()
331+
332+
for (const result of results) {
333+
totalErrors += result.totalErrors
334+
totalWarnings += result.totalWarnings
335+
totalInfo += result.totalInfo
336+
totalHints += result.totalHints
337+
totalIgnored += result.totalIgnored
338+
totalWouldBeIgnored += result.totalWouldBeIgnored
339+
filesWithOffenses += result.filesWithOffenses
340+
filesFixed += result.filesFixed
341+
342+
if (result.ruleCount > 0) {
343+
ruleCount = result.ruleCount
344+
}
345+
346+
for (const offense of result.offenses) {
347+
allOffenses.push({
348+
filename: offense.filename,
349+
offense: offense.offense,
350+
content: offense.content,
351+
autocorrectable: offense.autocorrectable
352+
})
353+
}
354+
355+
for (const [rule, data] of result.ruleOffenses) {
356+
const existing = ruleOffenses.get(rule) || { count: 0, files: new Set<string>() }
357+
existing.count += data.count
358+
359+
for (const file of data.files) {
360+
existing.files.add(file)
361+
}
362+
363+
ruleOffenses.set(rule, existing)
364+
}
365+
366+
if (formatOption !== 'json') {
367+
for (const fixMessage of result.fixMessages) {
368+
const [filename, countStr] = fixMessage.split("\t")
369+
const count = parseInt(countStr, 10)
370+
console.log(`${colorize("\u2713", "brightGreen")} ${colorize(filename, "cyan")} - ${colorize(`Fixed ${count} ${count === 1 ? "offense" : "offenses"}`, "green")}`)
371+
}
372+
}
373+
}
374+
375+
const processingResult: ProcessingResult = {
376+
totalErrors,
377+
totalWarnings,
378+
totalInfo,
379+
totalHints,
380+
totalIgnored,
381+
filesWithOffenses,
382+
filesFixed,
383+
ruleCount,
384+
allOffenses,
385+
ruleOffenses,
386+
context
387+
}
388+
389+
if (totalWouldBeIgnored > 0) {
390+
processingResult.totalWouldBeIgnored = totalWouldBeIgnored
391+
}
392+
393+
return processingResult
394+
}
395+
396+
/**
397+
* Returns the default number of parallel jobs based on available CPU cores.
398+
* Returns 1 if parallelism detection fails.
399+
*/
400+
static defaultJobs(): number {
401+
try {
402+
return availableParallelism()
403+
} catch {
404+
return 1
405+
}
406+
}
226407
}

javascript/packages/linter/src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ export { FileProcessor } from "./file-processor.js"
33
export { SummaryReporter } from "./summary-reporter.js"
44
export { OutputManager } from "./output-manager.js"
55

6+
export type { WorkerInput, WorkerResult, WorkerOffense } from "./lint-worker.js"
7+
68
export * from "./formatters/index.js"

0 commit comments

Comments
 (0)