Skip to content

Commit e34fc3e

Browse files
committed
Support rig-resolved type-aware linting
1 parent 60c5ad6 commit e34fc3e

6 files changed

Lines changed: 477 additions & 89 deletions

File tree

common/changes/@rushstack/heft-oxlint-plugin/add-heft-oxlint-plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{
44
"packageName": "@rushstack/heft-oxlint-plugin",
55
"comment": "Initial release of a Heft plugin for running oxlint, the fast Rust-based JavaScript/TypeScript linter.",
6-
"type": "none"
6+
"type": "minor"
77
}
88
],
99
"packageName": "@rushstack/heft-oxlint-plugin",

heft-plugins/heft-oxlint-plugin/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
This is a Heft plugin to run [oxlint](https://oxc.rs/docs/guide/usage/linter), the fast Rust-based
44
JavaScript/TypeScript linter from the Oxc project.
55

6-
Unlike `@rushstack/heft-lint-plugin` (ESLint/TSLint), oxlint does not perform type-aware linting and
7-
does not require a TypeScript program, so this plugin runs as a standalone task that invokes the
8-
`oxlint` binary and reports its findings through the Heft logger.
6+
This plugin runs as a standalone task that invokes the `oxlint` binary and reports its findings
7+
through the Heft logger. Type-aware linting is supported via the `typeAware` option (which passes
8+
`--type-aware` to oxlint); enabling it additionally requires the `oxlint-tsgolint` package. As with
9+
the `oxlint` package itself, `oxlint-tsgolint` is resolved from the consuming project or its shared
10+
rig, so it can be installed once in a rig rather than in every project.
911

1012
## Links
1113

heft-plugins/heft-oxlint-plugin/src/OxlintHelpers.ts

Lines changed: 178 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,16 @@ export interface IOxlintPluginOptions {
123123
noErrorOnUnmatchedPattern?: boolean;
124124
/** Disable automatic loading of nested configuration files. Maps to `--disable-nested-config`. */
125125
disableNestedConfig?: boolean;
126-
/** Enable rules that require type information. Maps to `--type-aware`. */
126+
/**
127+
* Enable rules that require type information. Maps to `--type-aware`. Requires the
128+
* `oxlint-tsgolint` package, which is resolved from the consuming project or its shared rig.
129+
*/
127130
typeAware?: boolean;
128-
/** Enable experimental type checking (includes TypeScript compiler diagnostics). Maps to `--type-check`. */
131+
/**
132+
* Enable experimental type checking (includes TypeScript compiler diagnostics). Maps to
133+
* `--type-check`. Requires the `oxlint-tsgolint` package, which is resolved from the consuming
134+
* project or its shared rig.
135+
*/
129136
typeCheck?: boolean;
130137
/** Report unused inline disable directives. Maps to `--report-unused-disable-directives`. */
131138
reportUnusedDisableDirectives?: boolean;
@@ -140,8 +147,9 @@ export interface IOxlintPluginOptions {
140147

141148
/**
142149
* If specified, a Static Analysis Results Interchange Format (SARIF) log of all findings will be
143-
* written to the provided path, relative to the project root. This is produced by an additional
144-
* oxlint invocation using `--format=sarif`.
150+
* written to the provided path, relative to the project root. When set, oxlint is run once with
151+
* `--format=sarif` and the reported diagnostics are extracted from that same SARIF output, so no
152+
* additional oxlint invocation is incurred.
145153
*/
146154
sarifLogPath?: string;
147155
}
@@ -175,6 +183,37 @@ export interface IOxlintJsonOutput {
175183
diagnostics: IOxlintDiagnostic[];
176184
}
177185

186+
// ----- SARIF types (subset needed for diagnostic extraction) -----
187+
188+
export interface IOxlintSarifRegion {
189+
startLine?: number;
190+
startColumn?: number;
191+
}
192+
193+
export interface IOxlintSarifPhysicalLocation {
194+
artifactLocation?: { uri?: string };
195+
region?: IOxlintSarifRegion;
196+
}
197+
198+
export interface IOxlintSarifLocation {
199+
physicalLocation?: IOxlintSarifPhysicalLocation;
200+
}
201+
202+
export interface IOxlintSarifResult {
203+
level?: string;
204+
message?: { text?: string };
205+
ruleId?: string;
206+
locations?: IOxlintSarifLocation[];
207+
}
208+
209+
export interface IOxlintSarifRun {
210+
results?: IOxlintSarifResult[];
211+
}
212+
213+
export interface IOxlintSarifLog {
214+
runs?: IOxlintSarifRun[];
215+
}
216+
178217
const DEFAULT_PATHS: ReadonlyArray<string> = ['src'];
179218

180219
// The file extensions that oxlint is able to lint. Changed files with any other extension (for
@@ -361,3 +400,138 @@ export function createFileErrorForDiagnostic(
361400
column: span?.column
362401
});
363402
}
403+
404+
/**
405+
* Extracts diagnostics from an oxlint SARIF log, converting each SARIF result into the common
406+
* {@link IOxlintDiagnostic} format used for error reporting.
407+
*/
408+
export function extractDiagnosticsFromSarif(sarifLog: IOxlintSarifLog): IOxlintDiagnostic[] {
409+
const diagnostics: IOxlintDiagnostic[] = [];
410+
411+
for (const run of sarifLog.runs ?? []) {
412+
for (const result of run.results ?? []) {
413+
const location: IOxlintSarifLocation | undefined = result.locations?.[0];
414+
const physicalLocation: IOxlintSarifPhysicalLocation | undefined = location?.physicalLocation;
415+
const uri: string = physicalLocation?.artifactLocation?.uri ?? '';
416+
const region: IOxlintSarifRegion | undefined = physicalLocation?.region;
417+
418+
let severity: 'error' | 'warning' | 'advice';
419+
switch (result.level) {
420+
case 'error':
421+
severity = 'error';
422+
break;
423+
case 'note':
424+
severity = 'advice';
425+
break;
426+
default:
427+
severity = 'warning';
428+
break;
429+
}
430+
431+
const diagnostic: IOxlintDiagnostic = {
432+
message: result.message?.text ?? '',
433+
code: result.ruleId,
434+
severity,
435+
filename: uri,
436+
labels: region
437+
? [{ span: { offset: 0, length: 0, line: region.startLine ?? 0, column: region.startColumn ?? 0 } }]
438+
: undefined
439+
};
440+
441+
diagnostics.push(diagnostic);
442+
}
443+
}
444+
445+
return diagnostics;
446+
}
447+
448+
/**
449+
* The maximum length (in characters) permitted for a single oxlint command line.
450+
*
451+
* @remarks
452+
* Windows caps the command line passed to `CreateProcess` at 32767 characters. We stay well under
453+
* that limit to leave room for the Node executable path, the oxlint bin path, and any argument
454+
* quoting overhead applied by the operating system.
455+
*/
456+
export const MAX_COMMAND_LINE_LENGTH: number = 30000;
457+
458+
/**
459+
* Splits the positional lint paths into batches such that each resulting oxlint command line stays
460+
* within {@link MAX_COMMAND_LINE_LENGTH}. This avoids exceeding the operating system's command-line
461+
* length limit (notably on Windows, where a large set of changed files could otherwise overflow the
462+
* ~32 KiB `CreateProcess` limit).
463+
*
464+
* @param prefixArgs - The command-line tokens that precede the paths on every invocation (for
465+
* example the Node executable, the oxlint bin path, and all non-path oxlint arguments).
466+
* @param lintPaths - The positional file or folder paths to lint.
467+
* @param maxCommandLineLength - The maximum permitted command-line length.
468+
* @returns One or more batches of lint paths. A single path that on its own exceeds the limit is
469+
* still returned in its own batch, since it cannot be split any further. Always returns at least
470+
* one batch so that callers can iterate uniformly.
471+
*/
472+
export function batchLintPaths(
473+
prefixArgs: ReadonlyArray<string>,
474+
lintPaths: ReadonlyArray<string>,
475+
maxCommandLineLength: number = MAX_COMMAND_LINE_LENGTH
476+
): string[][] {
477+
// Length contributed by the fixed prefix, counting a separating space before each token.
478+
let prefixLength: number = 0;
479+
for (const arg of prefixArgs) {
480+
prefixLength += arg.length + 1;
481+
}
482+
483+
const batches: string[][] = [];
484+
let currentBatch: string[] = [];
485+
let currentLength: number = prefixLength;
486+
487+
for (const lintPath of lintPaths) {
488+
const pathLength: number = lintPath.length + 1;
489+
if (currentBatch.length > 0 && currentLength + pathLength > maxCommandLineLength) {
490+
batches.push(currentBatch);
491+
currentBatch = [];
492+
currentLength = prefixLength;
493+
}
494+
currentBatch.push(lintPath);
495+
currentLength += pathLength;
496+
}
497+
498+
if (currentBatch.length > 0) {
499+
batches.push(currentBatch);
500+
}
501+
502+
if (batches.length === 0) {
503+
batches.push([]);
504+
}
505+
506+
return batches;
507+
}
508+
509+
/**
510+
* Merges the raw SARIF log output produced by one or more oxlint invocations into a single SARIF
511+
* document.
512+
*
513+
* @remarks
514+
* When linting is split into multiple batches (see {@link batchLintPaths}), each batch produces its
515+
* own SARIF log. This concatenates the `runs` from every batch into the first log so that all of the
516+
* findings are preserved in one file. When only a single log is provided, its original text is
517+
* returned unchanged so that the output is byte-for-byte identical to what oxlint emitted.
518+
*
519+
* @param rawSarifLogs - The raw (unparsed) SARIF stdout captured from each oxlint invocation.
520+
* @returns The merged SARIF log serialized as a string.
521+
*/
522+
export function mergeSarifLogs(rawSarifLogs: ReadonlyArray<string>): string {
523+
if (rawSarifLogs.length === 1) {
524+
return rawSarifLogs[0];
525+
}
526+
527+
const base: { runs?: unknown[] } = JSON.parse(rawSarifLogs[0]);
528+
base.runs = base.runs ?? [];
529+
for (let i: number = 1; i < rawSarifLogs.length; ++i) {
530+
const next: { runs?: unknown[] } = JSON.parse(rawSarifLogs[i]);
531+
if (next.runs) {
532+
base.runs.push(...next.runs);
533+
}
534+
}
535+
536+
return JSON.stringify(base, undefined, 2);
537+
}

0 commit comments

Comments
 (0)