@nx/oxlint 23.3.0-pr.37068.f95e11e → 23.3.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/executors/lint/lint.batch-impl.d.ts +11 -0
- package/dist/src/executors/lint/lint.batch-impl.js +18 -0
- package/dist/src/executors/lint/lint.impl.d.ts +9 -0
- package/dist/src/executors/lint/lint.impl.js +17 -0
- package/dist/src/executors/lint/options.d.ts +15 -0
- package/dist/src/executors/lint/options.js +104 -0
- package/dist/src/executors/lint/partition.d.ts +34 -0
- package/dist/src/executors/lint/partition.js +92 -0
- package/dist/src/executors/lint/render/agent.d.ts +3 -0
- package/dist/src/executors/lint/render/agent.js +18 -0
- package/dist/src/executors/lint/render/default.d.ts +6 -0
- package/dist/src/executors/lint/render/default.js +61 -0
- package/dist/src/executors/lint/render/github.d.ts +6 -0
- package/dist/src/executors/lint/render/github.js +37 -0
- package/dist/src/executors/lint/render/index.d.ts +5 -0
- package/dist/src/executors/lint/render/index.js +20 -0
- package/dist/src/executors/lint/render/shared.d.ts +25 -0
- package/dist/src/executors/lint/render/shared.js +59 -0
- package/dist/src/executors/lint/run-lint-tasks.d.ts +14 -0
- package/dist/src/executors/lint/run-lint-tasks.js +88 -0
- package/dist/src/executors/lint/run-oxlint.d.ts +42 -0
- package/dist/src/executors/lint/run-oxlint.js +40 -0
- package/dist/src/executors/lint/schema.d.ts +10 -0
- package/dist/src/executors/lint/schema.json +61 -0
- package/dist/src/plugins/plugin.js +22 -62
- package/dist/src/utils/oxlint-bin.d.ts +6 -0
- package/dist/src/utils/oxlint-bin.js +18 -0
- package/executors.json +11 -0
- package/package.json +10 -6
- package/readme-template.md +18 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](<>)
|
|
13
13
|
[](http://commitizen.github.io/cz-cli/)
|
|
14
14
|
[](https://go.nx.dev/community)
|
|
15
|
-
[](https://nx.dev/nx-cloud)
|
|
16
16
|
|
|
17
17
|
</div>
|
|
18
18
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExecutorContext, TaskGraph } from '@nx/devkit';
|
|
2
|
+
import type { TaskResult } from '@nx/devkit/internal';
|
|
3
|
+
import type { LintExecutorSchema } from './schema.js';
|
|
4
|
+
/**
|
|
5
|
+
* Yields rather than returns, so Nx prints each task's output as its result
|
|
6
|
+
* streams in, unless it holds batch output for a log group.
|
|
7
|
+
*/
|
|
8
|
+
export default function batchLintExecutor(taskGraph: TaskGraph, inputs: Record<string, LintExecutorSchema>, _overrides: LintExecutorSchema, context: ExecutorContext): AsyncGenerator<{
|
|
9
|
+
task: string;
|
|
10
|
+
result: TaskResult;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runLintTasks } from './run-lint-tasks.js';
|
|
2
|
+
/**
|
|
3
|
+
* Yields rather than returns, so Nx prints each task's output as its result
|
|
4
|
+
* streams in, unless it holds batch output for a log group.
|
|
5
|
+
*/
|
|
6
|
+
export default async function* batchLintExecutor(taskGraph, inputs, _overrides, context) {
|
|
7
|
+
const tasks = Object.values(taskGraph.tasks).map((task) => ({
|
|
8
|
+
taskId: task.id,
|
|
9
|
+
projectName: task.target.project,
|
|
10
|
+
projectRoot: task.projectRoot ??
|
|
11
|
+
context.projectsConfigurations.projects[task.target.project].root,
|
|
12
|
+
options: inputs[task.id],
|
|
13
|
+
}));
|
|
14
|
+
const results = runLintTasks(tasks, context.root);
|
|
15
|
+
for (const [task, result] of Object.entries(results)) {
|
|
16
|
+
yield { task, result };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ExecutorContext } from '@nx/devkit';
|
|
2
|
+
import type { LintExecutorSchema } from './schema.js';
|
|
3
|
+
/**
|
|
4
|
+
* The non-batched entry (`--batch=false`, `parallelism: false`): the batch core
|
|
5
|
+
* with a single task, so both paths lint and report identically.
|
|
6
|
+
*/
|
|
7
|
+
export default function lintExecutor(options: LintExecutorSchema, context: ExecutorContext): Promise<{
|
|
8
|
+
success: boolean;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { runLintTasks } from './run-lint-tasks.js';
|
|
2
|
+
/**
|
|
3
|
+
* The non-batched entry (`--batch=false`, `parallelism: false`): the batch core
|
|
4
|
+
* with a single task, so both paths lint and report identically.
|
|
5
|
+
*/
|
|
6
|
+
export default async function lintExecutor(options, context) {
|
|
7
|
+
const [result] = Object.values(runLintTasks([
|
|
8
|
+
{
|
|
9
|
+
taskId: context.projectName,
|
|
10
|
+
projectName: context.projectName,
|
|
11
|
+
projectRoot: context.projectsConfigurations.projects[context.projectName].root,
|
|
12
|
+
options,
|
|
13
|
+
},
|
|
14
|
+
], context.root));
|
|
15
|
+
process.stdout.write(result.terminalOutput);
|
|
16
|
+
return { success: result.success };
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LintExecutorSchema, OxlintOutputFormat } from './schema.js';
|
|
2
|
+
export interface ResolvedLintOptions {
|
|
3
|
+
/** Flags forwarded to Oxlint, in order: target options, `args`, then CLI overrides. */
|
|
4
|
+
flags: string[];
|
|
5
|
+
format: OxlintOutputFormat;
|
|
6
|
+
silent: boolean;
|
|
7
|
+
maxWarnings: number | undefined;
|
|
8
|
+
denyWarnings: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Splits the executor options into flags for Oxlint and the ones this executor
|
|
12
|
+
* applies per task: the run needs `--format=json`, `--silent` would empty the
|
|
13
|
+
* JSON, and Oxlint's exit code covers the whole run, not one project.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveLintOptions(options: LintExecutorSchema): ResolvedLintOptions;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createCliOptions, createOverrides } from '@nx/devkit/internal';
|
|
2
|
+
const SUPPORTED_FORMATS = [
|
|
3
|
+
'default',
|
|
4
|
+
'agent',
|
|
5
|
+
'github',
|
|
6
|
+
'json',
|
|
7
|
+
];
|
|
8
|
+
/**
|
|
9
|
+
* Splits the executor options into flags for Oxlint and the ones this executor
|
|
10
|
+
* applies per task: the run needs `--format=json`, `--silent` would empty the
|
|
11
|
+
* JSON, and Oxlint's exit code covers the whole run, not one project.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveLintOptions(options) {
|
|
14
|
+
const { lintFilePatterns: _patterns, nestedProjectRoots: _nestedRoots, args, __unparsed__: unparsed = [], ...forwarded } = options;
|
|
15
|
+
// Nx passes each CLI override parsed (`config: 'a.json'`) and verbatim
|
|
16
|
+
// (`--config=a.json`), and the verbatim copy wins. Nx's parse names `-c` and
|
|
17
|
+
// positionals; the raw names add `--no-*` flags, which it names unprefixed.
|
|
18
|
+
const cliOverrides = createOverrides(unparsed);
|
|
19
|
+
const cliNames = new Set([
|
|
20
|
+
...unparsed.map(flagName).filter(Boolean),
|
|
21
|
+
...Object.keys(cliOverrides).map(kebabCase),
|
|
22
|
+
]);
|
|
23
|
+
const fromOptions = Object.fromEntries(Object.entries(forwarded).filter(([key]) => !cliNames.has(kebabCase(key))));
|
|
24
|
+
const resolved = {
|
|
25
|
+
flags: [],
|
|
26
|
+
format: 'default',
|
|
27
|
+
silent: false,
|
|
28
|
+
maxWarnings: undefined,
|
|
29
|
+
denyWarnings: false,
|
|
30
|
+
};
|
|
31
|
+
const candidates = [
|
|
32
|
+
...createCliOptions(fromOptions),
|
|
33
|
+
...argsTokens(args, cliOverrides.args),
|
|
34
|
+
...unparsed,
|
|
35
|
+
];
|
|
36
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
37
|
+
const flag = candidates[i];
|
|
38
|
+
const name = flagName(flag);
|
|
39
|
+
const value = () => flag.includes('=') ? flag.slice(flag.indexOf('=') + 1) : candidates[++i];
|
|
40
|
+
if (name === 'verbose' || name === 'no-verbose') {
|
|
41
|
+
// Nx keeps its own --verbose in the options it hands an executor.
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
else if (name &&
|
|
45
|
+
['args', 'lint-file-patterns', 'nested-project-roots'].includes(kebabCase(name))) {
|
|
46
|
+
// The CLI copy of an option destructured above: drop it and its value.
|
|
47
|
+
value();
|
|
48
|
+
}
|
|
49
|
+
else if (name === 'format' || flag.startsWith('-f')) {
|
|
50
|
+
// Oxlint also takes the value attached, as in `-fjson`.
|
|
51
|
+
resolved.format = assertSupportedFormat(/^-f[^=]/.test(flag) ? flag.slice(2) : value());
|
|
52
|
+
}
|
|
53
|
+
else if (name === 'silent') {
|
|
54
|
+
resolved.silent = true;
|
|
55
|
+
}
|
|
56
|
+
else if (name === 'max-warnings') {
|
|
57
|
+
resolved.maxWarnings = Number(value());
|
|
58
|
+
}
|
|
59
|
+
else if (name === 'deny-warnings') {
|
|
60
|
+
resolved.denyWarnings = true;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
resolved.flags.push(flag);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
function assertSupportedFormat(value) {
|
|
69
|
+
if (!SUPPORTED_FORMATS.includes(value)) {
|
|
70
|
+
throw new Error(`Unsupported Oxlint output format "${value}". @nx/oxlint renders ${SUPPORTED_FORMATS.map((f) => `"${f}"`).join(', ')}.`);
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
/** Splits on whitespace outside single or double quotes, and strips the quotes. */
|
|
75
|
+
function splitArgsString(args) {
|
|
76
|
+
if (!args) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const tokens = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
80
|
+
return tokens.map((token) => token.replace(/"([^"]*)"|'([^']*)'/g, (_, double, single) => double ?? single));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Nx splits a CLI `--args` string on commas; rejoining recovers what was typed,
|
|
84
|
+
* with Nx's interpolation applied. A repeated `--args` arrives as an array.
|
|
85
|
+
*/
|
|
86
|
+
function argsTokens(args, cliArgs) {
|
|
87
|
+
if (cliArgs === undefined) {
|
|
88
|
+
return Array.isArray(args) ? args : splitArgsString(args);
|
|
89
|
+
}
|
|
90
|
+
const values = [args].flat();
|
|
91
|
+
return Array.isArray(cliArgs)
|
|
92
|
+
? values.flatMap((value) => splitArgsString(value))
|
|
93
|
+
: splitArgsString(values.join(','));
|
|
94
|
+
}
|
|
95
|
+
function flagName(flag) {
|
|
96
|
+
if (!flag.startsWith('--')) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const end = flag.indexOf('=');
|
|
100
|
+
return flag.slice(2, end === -1 ? undefined : end);
|
|
101
|
+
}
|
|
102
|
+
function kebabCase(key) {
|
|
103
|
+
return key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
|
104
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { OxlintDiagnostic } from './run-oxlint.js';
|
|
2
|
+
export interface TaskScope {
|
|
3
|
+
taskId: string;
|
|
4
|
+
/** Workspace-relative files or directories this task lints. */
|
|
5
|
+
paths: string[];
|
|
6
|
+
/** Nested project roots under `paths` whose files this task never lints. */
|
|
7
|
+
excludedRoots: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Gives each diagnostic to every task that lints its file when run on its own,
|
|
11
|
+
* so overlapping paths report in each task. A diagnostic with no file, or
|
|
12
|
+
* outside every task's paths, goes to the `outsideOwners` tasks. Every task
|
|
13
|
+
* gets an entry, empty when nothing was reported.
|
|
14
|
+
*/
|
|
15
|
+
export declare function partitionDiagnostics(diagnostics: OxlintDiagnostic[], tasks: TaskScope[], outsideOwners: string[]): Map<string, OxlintDiagnostic[]>;
|
|
16
|
+
export declare function excludedNestedRoots(paths: string[], nestedProjectRoots: string[]): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Oxlint reports `filename` workspace-relative, except under some terminals
|
|
19
|
+
* where it becomes a `file://` URL or an absolute path
|
|
20
|
+
* (https://github.com/oxc-project/oxc/issues/24916).
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizeFilename(filename: string, workspaceRoot: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* `--ignore-pattern` flags for the nested roots the tasks exclude, so Oxlint
|
|
25
|
+
* never lints or fixes files of a project outside this run. A root stays
|
|
26
|
+
* linted when its project is in the run or another task lints it, since a
|
|
27
|
+
* pattern applies to the whole run. Anchored, because a bare single-segment
|
|
28
|
+
* pattern also matches a same-named directory a task owns.
|
|
29
|
+
*/
|
|
30
|
+
export declare function nestedProjectIgnorePatterns(tasks: {
|
|
31
|
+
projectRoot: string;
|
|
32
|
+
paths: string[];
|
|
33
|
+
excludedRoots: string[];
|
|
34
|
+
}[]): string[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
/**
|
|
4
|
+
* Gives each diagnostic to every task that lints its file when run on its own,
|
|
5
|
+
* so overlapping paths report in each task. A diagnostic with no file, or
|
|
6
|
+
* outside every task's paths, goes to the `outsideOwners` tasks. Every task
|
|
7
|
+
* gets an entry, empty when nothing was reported.
|
|
8
|
+
*/
|
|
9
|
+
export function partitionDiagnostics(diagnostics, tasks, outsideOwners) {
|
|
10
|
+
const byTask = new Map(tasks.map(({ taskId }) => [taskId, []]));
|
|
11
|
+
for (const diagnostic of diagnostics) {
|
|
12
|
+
for (const task of tasks) {
|
|
13
|
+
if (diagnostic.filename && lintsFile(task, diagnostic.filename)) {
|
|
14
|
+
byTask.get(task.taskId).push(diagnostic);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (!diagnostic.filename ||
|
|
18
|
+
!tasks.some((task) => task.paths.some((path) => contains(path, diagnostic.filename)))) {
|
|
19
|
+
for (const taskId of outsideOwners) {
|
|
20
|
+
byTask.get(taskId).push(diagnostic);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return byTask;
|
|
25
|
+
}
|
|
26
|
+
export function excludedNestedRoots(paths, nestedProjectRoots) {
|
|
27
|
+
return nestedProjectRoots.filter((root) => paths.some((path) => root !== normalizePath(path) && contains(path, root)));
|
|
28
|
+
}
|
|
29
|
+
function lintsFile(task, file) {
|
|
30
|
+
return (task.paths.some((path) => contains(path, file)) &&
|
|
31
|
+
!task.excludedRoots.some((root) => contains(root, file)));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Oxlint reports `filename` workspace-relative, except under some terminals
|
|
35
|
+
* where it becomes a `file://` URL or an absolute path
|
|
36
|
+
* (https://github.com/oxc-project/oxc/issues/24916).
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeFilename(filename, workspaceRoot) {
|
|
39
|
+
let path = filename.startsWith('file://')
|
|
40
|
+
? fileURLToPath(filename)
|
|
41
|
+
: filename;
|
|
42
|
+
if (isAbsolute(path)) {
|
|
43
|
+
path = relative(workspaceRoot, path);
|
|
44
|
+
}
|
|
45
|
+
path = path.split(sep).join('/');
|
|
46
|
+
return path.startsWith('./') ? path.slice(2) : path;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `--ignore-pattern` flags for the nested roots the tasks exclude, so Oxlint
|
|
50
|
+
* never lints or fixes files of a project outside this run. A root stays
|
|
51
|
+
* linted when its project is in the run or another task lints it, since a
|
|
52
|
+
* pattern applies to the whole run. Anchored, because a bare single-segment
|
|
53
|
+
* pattern also matches a same-named directory a task owns.
|
|
54
|
+
*/
|
|
55
|
+
export function nestedProjectIgnorePatterns(tasks) {
|
|
56
|
+
const inRun = new Set(tasks.map((t) => t.projectRoot));
|
|
57
|
+
const excluded = [];
|
|
58
|
+
for (const root of [
|
|
59
|
+
...new Set(tasks.flatMap((t) => t.excludedRoots)),
|
|
60
|
+
].sort()) {
|
|
61
|
+
if (inRun.has(root)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// Excluding a root already prunes everything under it.
|
|
65
|
+
if (excluded.some((parent) => root.startsWith(`${parent}/`))) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!tasks.some((task) => lintsFile(task, root))) {
|
|
69
|
+
excluded.push(root);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return excluded.map((root) => `--ignore-pattern=/${escapeIgnorePattern(root)}`);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Escape the gitignore metacharacters in a path so `--ignore-pattern` matches
|
|
76
|
+
* it literally: to Oxlint's matcher `\`, `[`, `]`, `*` and `?` are pattern
|
|
77
|
+
* syntax, and a trailing space is stripped unless escaped.
|
|
78
|
+
*/
|
|
79
|
+
function escapeIgnorePattern(pattern) {
|
|
80
|
+
return pattern
|
|
81
|
+
.replace(/([\\[\]*?])/g, '\\$1')
|
|
82
|
+
.replace(/ +$/, (spaces) => spaces.replace(/ /g, '\\ '));
|
|
83
|
+
}
|
|
84
|
+
/** Oxlint expands no globs, so containment is the whole match. */
|
|
85
|
+
function contains(path, file) {
|
|
86
|
+
const dir = normalizePath(path);
|
|
87
|
+
return dir === '' || file === dir || file.startsWith(`${dir}/`);
|
|
88
|
+
}
|
|
89
|
+
function normalizePath(path) {
|
|
90
|
+
const normalized = path.replace(/^\.\//, '').replace(/\/$/, '');
|
|
91
|
+
return normalized === '.' ? '' : normalized;
|
|
92
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { position } from './shared.js';
|
|
2
|
+
/** One line per diagnostic, matching Oxlint's own agent formatter. */
|
|
3
|
+
export function renderAgent(diagnostics) {
|
|
4
|
+
return diagnostics
|
|
5
|
+
.map((d) => {
|
|
6
|
+
const { line, column } = position(d);
|
|
7
|
+
const location = d.filename
|
|
8
|
+
? `${d.filename}:${line}:${column}`
|
|
9
|
+
: '<unknown>';
|
|
10
|
+
const code = d.code ? ` ${d.code}` : '';
|
|
11
|
+
const help = d.help ? ` help: ${compact(d.help)}` : '';
|
|
12
|
+
return `${location}: ${d.severity}${code}: ${compact(d.message)}${help}\n`;
|
|
13
|
+
})
|
|
14
|
+
.join('');
|
|
15
|
+
}
|
|
16
|
+
function compact(text) {
|
|
17
|
+
return text.split(/\s+/).filter(Boolean).join(' ');
|
|
18
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { OxlintDiagnostic } from '../run-oxlint.js';
|
|
2
|
+
/**
|
|
3
|
+
* Oxlint's terminal report. Source lines come from disk because the JSON report
|
|
4
|
+
* carries no source text.
|
|
5
|
+
*/
|
|
6
|
+
export declare function renderGraphical(diagnostics: OxlintDiagnostic[], workspaceRoot: string): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { position, SourceCache } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Oxlint's terminal report. Source lines come from disk because the JSON report
|
|
5
|
+
* carries no source text.
|
|
6
|
+
*/
|
|
7
|
+
export function renderGraphical(diagnostics, workspaceRoot) {
|
|
8
|
+
const sources = new SourceCache(workspaceRoot);
|
|
9
|
+
let out = '';
|
|
10
|
+
for (const d of diagnostics) {
|
|
11
|
+
const isError = d.severity === 'error';
|
|
12
|
+
const paint = isError ? pc.red : pc.yellow;
|
|
13
|
+
const { line, column } = position(d);
|
|
14
|
+
const code = d.code ? `${paint(d.code)}: ` : '';
|
|
15
|
+
out += `\n ${paint(isError ? '×' : '⚠')} ${code}${indent(d.message, ` ${paint('│')} `)}\n`;
|
|
16
|
+
if (d.filename) {
|
|
17
|
+
out += ` ${pc.dim(`╭─[${d.filename}:${line}:${column}]`)}\n`;
|
|
18
|
+
const lines = sources.lines(d.filename);
|
|
19
|
+
const span = d.labels[0]?.span;
|
|
20
|
+
if (lines && span) {
|
|
21
|
+
// Oxlint shows no line for the empty string after a final newline.
|
|
22
|
+
const lineCount = lines.at(-1) === '' ? lines.length - 1 : lines.length;
|
|
23
|
+
const first = Math.max(1, line - 1);
|
|
24
|
+
const last = Math.min(lineCount, line + 1);
|
|
25
|
+
const width = String(last).length;
|
|
26
|
+
for (let n = first; n <= last; n++) {
|
|
27
|
+
const text = lines[n - 1];
|
|
28
|
+
out += ` ${pc.dim(String(n).padStart(width))} ${pc.dim('│')}${text ? ` ${text}` : ''}\n`;
|
|
29
|
+
if (n === line) {
|
|
30
|
+
out += underline(width, column, span, lines[n - 1], d.labels[0].label, paint);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
out += ` ${pc.dim('╰────')}\n`;
|
|
35
|
+
}
|
|
36
|
+
if (d.help) {
|
|
37
|
+
out += ` ${pc.cyan('help')}: ${indent(d.help, ' '.repeat(8))}\n`;
|
|
38
|
+
}
|
|
39
|
+
if (d.note) {
|
|
40
|
+
out += ` ${pc.cyan('note')}: ${indent(d.note, ' '.repeat(11))}\n`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function indent(text, prefix) {
|
|
46
|
+
return text.replaceAll('\n', `\n${prefix}`);
|
|
47
|
+
}
|
|
48
|
+
function underline(width, column, span, lineText, label, paint) {
|
|
49
|
+
const bytes = Buffer.from(lineText);
|
|
50
|
+
// A multi-line span is underlined to the end of its first line.
|
|
51
|
+
const length = Math.max(1, bytes.subarray(column - 1, column - 1 + span.length).toString().length);
|
|
52
|
+
const gutter = ` ${' '.repeat(width)} ${pc.dim('·')} `;
|
|
53
|
+
const pad = ' '.repeat(bytes.subarray(0, column - 1).toString().length);
|
|
54
|
+
if (!label) {
|
|
55
|
+
return `${gutter}${pad}${paint('─'.repeat(length))}\n`;
|
|
56
|
+
}
|
|
57
|
+
const mid = Math.floor((length - 1) / 2);
|
|
58
|
+
const bar = '─'.repeat(mid) + '┬' + '─'.repeat(length - mid - 1);
|
|
59
|
+
return (`${gutter}${pad}${paint(bar)}\n` +
|
|
60
|
+
`${gutter}${pad}${' '.repeat(mid)}${paint('╰── ' + label)}\n`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { OxlintDiagnostic } from '../run-oxlint.js';
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Actions workflow commands, one annotation per diagnostic
|
|
4
|
+
* (https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions).
|
|
5
|
+
*/
|
|
6
|
+
export declare function renderGithub(diagnostics: OxlintDiagnostic[], workspaceRoot: string): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { position, SourceCache } from './shared.js';
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Actions workflow commands, one annotation per diagnostic
|
|
4
|
+
* (https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions).
|
|
5
|
+
*/
|
|
6
|
+
export function renderGithub(diagnostics, workspaceRoot) {
|
|
7
|
+
const sources = new SourceCache(workspaceRoot);
|
|
8
|
+
return diagnostics
|
|
9
|
+
.map((d) => {
|
|
10
|
+
const level = d.severity === 'error' ? 'error' : 'warning';
|
|
11
|
+
const title = `title=${escapeProperty(d.code ?? 'oxlint')}`;
|
|
12
|
+
if (!d.filename) {
|
|
13
|
+
return `::${level} ${title}::${escapeData(d.message)}\n`;
|
|
14
|
+
}
|
|
15
|
+
const span = d.labels[0]?.span;
|
|
16
|
+
const { line, column } = position(d);
|
|
17
|
+
const end = span ? sources.endOf(d.filename, span) : { line, column };
|
|
18
|
+
const props = [
|
|
19
|
+
`file=${escapeProperty(d.filename)}`,
|
|
20
|
+
`line=${line}`,
|
|
21
|
+
`endLine=${end.line}`,
|
|
22
|
+
`col=${column}`,
|
|
23
|
+
`endColumn=${end.column}`,
|
|
24
|
+
title,
|
|
25
|
+
].join(',');
|
|
26
|
+
const message = escapeData(`${d.filename}:${line}:${column}: ${d.message}`);
|
|
27
|
+
return `::${level} ${props}::${message}\n`;
|
|
28
|
+
})
|
|
29
|
+
.join('');
|
|
30
|
+
}
|
|
31
|
+
// https://github.com/actions/toolkit/blob/main/packages/core/src/command.ts
|
|
32
|
+
function escapeData(s) {
|
|
33
|
+
return s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
34
|
+
}
|
|
35
|
+
function escapeProperty(s) {
|
|
36
|
+
return escapeData(s).replace(/:/g, '%3A').replace(/,/g, '%2C');
|
|
37
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OxlintDiagnostic } from '../run-oxlint.js';
|
|
2
|
+
import type { OxlintOutputFormat } from '../schema.js';
|
|
3
|
+
import { type RenderContext } from './shared.js';
|
|
4
|
+
export { countBySeverity } from './shared.js';
|
|
5
|
+
export declare function renderDiagnostics(format: OxlintOutputFormat, diagnostics: OxlintDiagnostic[], context: RenderContext): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { renderAgent } from './agent.js';
|
|
2
|
+
import { renderGraphical } from './default.js';
|
|
3
|
+
import { renderGithub } from './github.js';
|
|
4
|
+
import { summary } from './shared.js';
|
|
5
|
+
export { countBySeverity } from './shared.js';
|
|
6
|
+
export function renderDiagnostics(format, diagnostics, context) {
|
|
7
|
+
switch (format) {
|
|
8
|
+
case 'json':
|
|
9
|
+
return JSON.stringify({ diagnostics }, null, 2) + '\n';
|
|
10
|
+
case 'github':
|
|
11
|
+
return (renderGithub(diagnostics, context.workspaceRoot) + summary(diagnostics));
|
|
12
|
+
case 'agent':
|
|
13
|
+
return renderAgent(diagnostics);
|
|
14
|
+
case 'default':
|
|
15
|
+
return context.agentMode
|
|
16
|
+
? renderAgent(diagnostics)
|
|
17
|
+
: renderGraphical(diagnostics, context.workspaceRoot) +
|
|
18
|
+
summary(diagnostics);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { OxlintDiagnostic, OxlintSpan } from '../run-oxlint.js';
|
|
2
|
+
export interface RenderContext {
|
|
3
|
+
workspaceRoot: string;
|
|
4
|
+
/** In CI or under an AI agent `default` renders as `agent`, as Oxlint does. */
|
|
5
|
+
agentMode: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function countBySeverity(diagnostics: OxlintDiagnostic[]): {
|
|
8
|
+
errors: number;
|
|
9
|
+
warnings: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function summary(diagnostics: OxlintDiagnostic[]): string;
|
|
12
|
+
export declare function position(d: OxlintDiagnostic): {
|
|
13
|
+
line: number;
|
|
14
|
+
column: number;
|
|
15
|
+
};
|
|
16
|
+
export declare class SourceCache {
|
|
17
|
+
private cache;
|
|
18
|
+
private workspaceRoot;
|
|
19
|
+
constructor(workspaceRoot: string);
|
|
20
|
+
lines(file: string): string[] | null;
|
|
21
|
+
endOf(file: string, span: OxlintSpan): {
|
|
22
|
+
line: number;
|
|
23
|
+
column: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
export function countBySeverity(diagnostics) {
|
|
5
|
+
let errors = 0;
|
|
6
|
+
for (const d of diagnostics) {
|
|
7
|
+
if (d.severity === 'error')
|
|
8
|
+
errors++;
|
|
9
|
+
}
|
|
10
|
+
return { errors, warnings: diagnostics.length - errors };
|
|
11
|
+
}
|
|
12
|
+
export function summary(diagnostics) {
|
|
13
|
+
const { errors, warnings } = countBySeverity(diagnostics);
|
|
14
|
+
const line = `Found ${warnings} warning${warnings === 1 ? '' : 's'} and ${errors} error${errors === 1 ? '' : 's'}.`;
|
|
15
|
+
return ((diagnostics.length ? '\n' : '') +
|
|
16
|
+
(errors ? pc.red(line) : warnings ? pc.yellow(line) : line) +
|
|
17
|
+
'\n');
|
|
18
|
+
}
|
|
19
|
+
export function position(d) {
|
|
20
|
+
const span = d.labels[0]?.span;
|
|
21
|
+
return { line: span?.line ?? 1, column: span?.column ?? 1 };
|
|
22
|
+
}
|
|
23
|
+
export class SourceCache {
|
|
24
|
+
// No parameter property: Node's strip-only TypeScript mode rejects them,
|
|
25
|
+
// which would push the whole executor onto the slower swc fallback.
|
|
26
|
+
constructor(workspaceRoot) {
|
|
27
|
+
this.cache = new Map();
|
|
28
|
+
this.workspaceRoot = workspaceRoot;
|
|
29
|
+
}
|
|
30
|
+
lines(file) {
|
|
31
|
+
if (!this.cache.has(file)) {
|
|
32
|
+
try {
|
|
33
|
+
this.cache.set(file, readFileSync(join(this.workspaceRoot, file), 'utf-8').split('\n'));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
this.cache.set(file, null);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return this.cache.get(file);
|
|
40
|
+
}
|
|
41
|
+
endOf(file, span) {
|
|
42
|
+
const lines = this.lines(file);
|
|
43
|
+
if (!lines) {
|
|
44
|
+
return { line: span.line, column: span.column + span.length };
|
|
45
|
+
}
|
|
46
|
+
let remaining = span.offset + span.length;
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
const lineLength = Buffer.byteLength(lines[i]) + 1;
|
|
49
|
+
if (remaining < lineLength) {
|
|
50
|
+
return { line: i + 1, column: remaining + 1 };
|
|
51
|
+
}
|
|
52
|
+
remaining -= lineLength;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
line: lines.length,
|
|
56
|
+
column: Buffer.byteLength(lines.at(-1) ?? '') + 1,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type TaskResult } from '@nx/devkit/internal';
|
|
2
|
+
import type { LintExecutorSchema } from './schema.js';
|
|
3
|
+
export interface LintTask {
|
|
4
|
+
taskId: string;
|
|
5
|
+
projectName: string;
|
|
6
|
+
projectRoot: string;
|
|
7
|
+
options: LintExecutorSchema;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Lints every task with a single Oxlint run and splits the report back per
|
|
11
|
+
* task. Oxlint takes one flag set per process, so the run uses the first
|
|
12
|
+
* task's flags and warns when another task resolves different ones.
|
|
13
|
+
*/
|
|
14
|
+
export declare function runLintTasks(tasks: LintTask[], workspaceRoot: string): Record<string, TaskResult>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { logger } from '@nx/devkit';
|
|
2
|
+
import { interpolate, isAiAgent, isCI, } from '@nx/devkit/internal';
|
|
3
|
+
import { resolveLintOptions } from './options.js';
|
|
4
|
+
import { excludedNestedRoots, nestedProjectIgnorePatterns, normalizeFilename, partitionDiagnostics, } from './partition.js';
|
|
5
|
+
import { countBySeverity, renderDiagnostics } from './render/index.js';
|
|
6
|
+
import { runOxlint } from './run-oxlint.js';
|
|
7
|
+
/**
|
|
8
|
+
* Lints every task with a single Oxlint run and splits the report back per
|
|
9
|
+
* task. Oxlint takes one flag set per process, so the run uses the first
|
|
10
|
+
* task's flags and warns when another task resolves different ones.
|
|
11
|
+
*/
|
|
12
|
+
export function runLintTasks(tasks, workspaceRoot) {
|
|
13
|
+
const resolved = tasks.map((task) => {
|
|
14
|
+
const paths = (task.options.lintFilePatterns ?? ['{projectRoot}']).map((p) => normalizeFilename(interpolate(p, {
|
|
15
|
+
workspaceRoot: '',
|
|
16
|
+
projectRoot: task.projectRoot,
|
|
17
|
+
projectName: task.projectName,
|
|
18
|
+
}), workspaceRoot));
|
|
19
|
+
return {
|
|
20
|
+
...task,
|
|
21
|
+
...resolveLintOptions(task.options),
|
|
22
|
+
paths,
|
|
23
|
+
excludedRoots: excludedNestedRoots(paths, task.options.nestedProjectRoots ?? []),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
const flags = resolved[0].flags;
|
|
27
|
+
const sharesRunFlags = (r) => r.flags.join('\u0000') === flags.join('\u0000');
|
|
28
|
+
const differing = resolved.find((r) => !sharesRunFlags(r));
|
|
29
|
+
if (differing) {
|
|
30
|
+
logger.warn(`[@nx/oxlint] ${differing.projectName} resolves different Oxlint options than ${resolved[0].projectName}. Oxlint runs once for the whole batch, using ${resolved[0].projectName}'s options.`);
|
|
31
|
+
}
|
|
32
|
+
const ignores = nestedProjectIgnorePatterns(resolved);
|
|
33
|
+
const paths = [...new Set(resolved.flatMap((r) => r.paths))];
|
|
34
|
+
const startTime = Date.now();
|
|
35
|
+
// A task whose files are all ignored is a clean task, not an error. The
|
|
36
|
+
// forwarded flags go after this executor's own, so a `--` among them cannot
|
|
37
|
+
// turn those into paths.
|
|
38
|
+
const run = runOxlint([
|
|
39
|
+
...ignores,
|
|
40
|
+
'--no-error-on-unmatched-pattern',
|
|
41
|
+
...flags,
|
|
42
|
+
...paths.map((path) => (path.startsWith('-') ? `./${path}` : path)),
|
|
43
|
+
], workspaceRoot);
|
|
44
|
+
const endTime = Date.now();
|
|
45
|
+
const results = {};
|
|
46
|
+
if (run.ok === false) {
|
|
47
|
+
for (const { taskId } of resolved) {
|
|
48
|
+
results[taskId] = {
|
|
49
|
+
success: false,
|
|
50
|
+
terminalOutput: run.output + '\n',
|
|
51
|
+
startTime,
|
|
52
|
+
endTime,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
for (const diagnostic of run.report.diagnostics) {
|
|
58
|
+
diagnostic.filename = normalizeFilename(diagnostic.filename ?? '', workspaceRoot);
|
|
59
|
+
}
|
|
60
|
+
// The tasks whose flags the run uses also own the diagnostics outside every
|
|
61
|
+
// task's paths: those for a path in the forwarded flags or a config file,
|
|
62
|
+
// and those with no file.
|
|
63
|
+
const byTask = partitionDiagnostics(run.report.diagnostics, resolved, resolved.filter(sharesRunFlags).map((r) => r.taskId));
|
|
64
|
+
const agentMode = !!isCI() || isAiAgent();
|
|
65
|
+
for (const r of resolved) {
|
|
66
|
+
const diagnostics = byTask.get(r.taskId);
|
|
67
|
+
const { errors, warnings } = countBySeverity(diagnostics);
|
|
68
|
+
const success = errors === 0 &&
|
|
69
|
+
(!r.denyWarnings || warnings === 0) &&
|
|
70
|
+
(r.maxWarnings === undefined || warnings <= r.maxWarnings);
|
|
71
|
+
results[r.taskId] = {
|
|
72
|
+
success,
|
|
73
|
+
terminalOutput: r.silent
|
|
74
|
+
? ''
|
|
75
|
+
: renderDiagnostics(r.format, diagnostics, {
|
|
76
|
+
workspaceRoot,
|
|
77
|
+
agentMode,
|
|
78
|
+
}),
|
|
79
|
+
startTime,
|
|
80
|
+
endTime,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (resolved.some((r) => !r.silent)) {
|
|
84
|
+
const { number_of_files, threads_count, start_time } = run.report;
|
|
85
|
+
process.stdout.write(`Finished in ${Math.round(start_time * 1000)}ms on ${number_of_files} files using ${threads_count} threads.\n`);
|
|
86
|
+
}
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Offsets, lengths and columns count UTF-8 bytes. */
|
|
2
|
+
export interface OxlintSpan {
|
|
3
|
+
offset: number;
|
|
4
|
+
length: number;
|
|
5
|
+
line: number;
|
|
6
|
+
column: number;
|
|
7
|
+
}
|
|
8
|
+
export interface OxlintLabel {
|
|
9
|
+
label?: string;
|
|
10
|
+
span: OxlintSpan;
|
|
11
|
+
}
|
|
12
|
+
export interface OxlintDiagnostic {
|
|
13
|
+
message: string;
|
|
14
|
+
/** Absent when no rule raised the diagnostic, as for an unreadable file. */
|
|
15
|
+
code?: string;
|
|
16
|
+
severity: 'error' | 'warning' | 'advice';
|
|
17
|
+
/** Empty or absent when the diagnostic has no source file. */
|
|
18
|
+
filename?: string;
|
|
19
|
+
labels: OxlintLabel[];
|
|
20
|
+
help?: string;
|
|
21
|
+
note?: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface OxlintReport {
|
|
25
|
+
diagnostics: OxlintDiagnostic[];
|
|
26
|
+
number_of_files: number;
|
|
27
|
+
threads_count: number;
|
|
28
|
+
start_time: number;
|
|
29
|
+
}
|
|
30
|
+
export type OxlintRun = {
|
|
31
|
+
ok: true;
|
|
32
|
+
report: OxlintReport;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
output: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* One `oxlint --format=json` invocation from the workspace root. If Oxlint
|
|
39
|
+
* produces no JSON, its raw output is returned because that text is the only
|
|
40
|
+
* explanation Oxlint gives.
|
|
41
|
+
*/
|
|
42
|
+
export declare function runOxlint(args: string[], workspaceRoot: string): OxlintRun;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { resolveOxlintBin } from '../../utils/oxlint-bin.js';
|
|
3
|
+
/**
|
|
4
|
+
* One `oxlint --format=json` invocation from the workspace root. If Oxlint
|
|
5
|
+
* produces no JSON, its raw output is returned because that text is the only
|
|
6
|
+
* explanation Oxlint gives.
|
|
7
|
+
*/
|
|
8
|
+
export function runOxlint(args, workspaceRoot) {
|
|
9
|
+
const bin = resolveOxlintBin(workspaceRoot);
|
|
10
|
+
if (!bin) {
|
|
11
|
+
return {
|
|
12
|
+
ok: false,
|
|
13
|
+
output: `Unable to resolve "oxlint" from ${workspaceRoot}. Install it as a devDependency of the workspace.`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const result = spawnSync(process.execPath, [bin, '--format=json', ...args], {
|
|
17
|
+
cwd: workspaceRoot,
|
|
18
|
+
encoding: 'utf-8',
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
21
|
+
windowsHide: true,
|
|
22
|
+
});
|
|
23
|
+
if (result.error) {
|
|
24
|
+
return { ok: false, output: result.error.message };
|
|
25
|
+
}
|
|
26
|
+
const report = parseReport(result.stdout);
|
|
27
|
+
if (!report) {
|
|
28
|
+
return { ok: false, output: `${result.stdout}${result.stderr}`.trim() };
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, report };
|
|
31
|
+
}
|
|
32
|
+
function parseReport(stdout) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(stdout);
|
|
35
|
+
return Array.isArray(parsed?.diagnostics) ? parsed : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type OxlintOutputFormat = 'default' | 'agent' | 'github' | 'json';
|
|
2
|
+
|
|
3
|
+
export interface LintExecutorSchema {
|
|
4
|
+
lintFilePatterns?: string[];
|
|
5
|
+
nestedProjectRoots?: string[];
|
|
6
|
+
format?: OxlintOutputFormat;
|
|
7
|
+
args?: string | string[];
|
|
8
|
+
__unparsed__?: string[];
|
|
9
|
+
[forwarded: string]: unknown;
|
|
10
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 2,
|
|
3
|
+
"outputCapture": "direct-nodejs",
|
|
4
|
+
"$schema": "https://json-schema.org/schema",
|
|
5
|
+
"title": "Oxlint Lint Target",
|
|
6
|
+
"description": "Lints a project with Oxlint. Runs in batch mode by default, so one Oxlint process lints every project in the run, from the workspace root. Options not listed here are forwarded to Oxlint as CLI flags; paths in them are relative to the workspace root.",
|
|
7
|
+
"cli": "nx",
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"lintFilePatterns": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"description": "One or more files or directories to pass to Oxlint. Oxlint does not expand globs.",
|
|
13
|
+
"default": ["{projectRoot}"],
|
|
14
|
+
"items": {
|
|
15
|
+
"type": "string"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"nestedProjectRoots": {
|
|
19
|
+
"type": "array",
|
|
20
|
+
"items": {
|
|
21
|
+
"type": "string"
|
|
22
|
+
},
|
|
23
|
+
"description": "Workspace-relative roots of projects nested inside this one. The inferred target sets this.",
|
|
24
|
+
"hidden": true,
|
|
25
|
+
"x-priority": "internal"
|
|
26
|
+
},
|
|
27
|
+
"format": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Output format. Rendered by Nx from the Oxlint JSON report, so only these values are supported. `default` matches the Oxlint terminal output, and switches to `agent` in CI or under an AI agent, as Oxlint does.",
|
|
30
|
+
"enum": ["default", "agent", "github", "json"],
|
|
31
|
+
"default": "default"
|
|
32
|
+
},
|
|
33
|
+
"args": {
|
|
34
|
+
"oneOf": [
|
|
35
|
+
{
|
|
36
|
+
"type": "array",
|
|
37
|
+
"items": {
|
|
38
|
+
"type": "string"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"type": "string"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"description": "Extra arguments appended to the Oxlint command. A string is split on whitespace outside quotes, with no shell escaping or expansion; an array passes each item as one argument. Paths are relative to the workspace root, which is where Oxlint runs. In a batch, Oxlint uses the first project's arguments, so list extra paths in `lintFilePatterns` instead."
|
|
46
|
+
},
|
|
47
|
+
"__unparsed__": {
|
|
48
|
+
"hidden": true,
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "string"
|
|
52
|
+
},
|
|
53
|
+
"$default": {
|
|
54
|
+
"$source": "unparsed"
|
|
55
|
+
},
|
|
56
|
+
"x-priority": "internal"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"additionalProperties": true,
|
|
60
|
+
"examplesFile": "../../../docs/lint-examples.md"
|
|
61
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { buildPackageJsonPatterns, buildPackageJsonWorkspacesMatcher, calculateHashesForCreateNodes, combineGlobPatterns, globWithWorkspaceContext, hashObject, PluginCache, TargetProjectLocator, workspaceDataDirectory,
|
|
1
|
+
import { buildPackageJsonPatterns, buildPackageJsonWorkspacesMatcher, calculateHashesForCreateNodes, combineGlobPatterns, globWithWorkspaceContext, hashObject, PluginCache, TargetProjectLocator, workspaceDataDirectory, } from '@nx/devkit/internal';
|
|
2
2
|
import { createNodesFromFiles, DependencyType, detectPackageManager, getPackageManagerCommand, readJsonFile, validateDependency, } from '@nx/devkit';
|
|
3
3
|
import { getLockFileName, getRootTsConfigFileName } from '@nx/js';
|
|
4
4
|
import { walkTsconfigExtendsChain, } from '@nx/js/internal';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
|
-
import {
|
|
8
|
-
import { dirname as nativeDirname, join as nativeJoin, relative as nativeRelative, sep as nativeSep, } from 'node:path';
|
|
7
|
+
import { relative as nativeRelative, sep as nativeSep } from 'node:path';
|
|
9
8
|
import { basename, dirname, join, normalize, sep } from 'node:path/posix';
|
|
10
9
|
import { OXLINT_CONFIG_FILENAMES } from '../utils/config-file.js';
|
|
10
|
+
import { resolveOxlintBin } from '../utils/oxlint-bin.js';
|
|
11
11
|
/**
|
|
12
12
|
* Source types Oxlint can parse. It does not lint JSON. Not configurable:
|
|
13
13
|
* Oxlint picks the files it lints itself, so this only decides whether a
|
|
@@ -49,9 +49,8 @@ const internalCreateNodes = async (configFilePath, options, context, projectRoot
|
|
|
49
49
|
return cached;
|
|
50
50
|
}
|
|
51
51
|
// Require something lintable, so docs-only projects get no target even
|
|
52
|
-
// when they own a config
|
|
53
|
-
//
|
|
54
|
-
// check and is memoized across configs.
|
|
52
|
+
// when they own a config. This spans the whole workspace, so it stays
|
|
53
|
+
// behind the cache check and is memoized across configs.
|
|
55
54
|
const shouldInferTarget = ((await getLintableFilesPerProjectRoot()).get(projectRoot) ?? 0) > 0;
|
|
56
55
|
if (!shouldInferTarget) {
|
|
57
56
|
projectsCache.set(hash, {});
|
|
@@ -451,7 +450,6 @@ function collectTsconfigChainsByProjectRoot(projectRoots, workspaceRoot) {
|
|
|
451
450
|
}
|
|
452
451
|
return result;
|
|
453
452
|
}
|
|
454
|
-
const localRequire = createRequire(import.meta.url);
|
|
455
453
|
/**
|
|
456
454
|
* Asks Oxlint itself which files it would lint. A glob cannot answer this:
|
|
457
455
|
* Oxlint honours `ignorePatterns`, `.eslintignore` and `.gitignore`, including
|
|
@@ -463,14 +461,8 @@ const localRequire = createRequire(import.meta.url);
|
|
|
463
461
|
* rather than failing graph construction over another project's broken config.
|
|
464
462
|
*/
|
|
465
463
|
function enumerateLintableFilesWithOxlint(workspaceRoot) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
bin = nativeJoin(nativeDirname(localRequire.resolve('oxlint/package.json', { paths: [workspaceRoot] })), 'bin', 'oxlint');
|
|
469
|
-
}
|
|
470
|
-
catch {
|
|
471
|
-
return null;
|
|
472
|
-
}
|
|
473
|
-
if (!existsSync(bin)) {
|
|
464
|
+
const bin = resolveOxlintBin(workspaceRoot);
|
|
465
|
+
if (!bin) {
|
|
474
466
|
return null;
|
|
475
467
|
}
|
|
476
468
|
try {
|
|
@@ -540,23 +532,11 @@ function ancestorIgnorePaths(projectRoot) {
|
|
|
540
532
|
}
|
|
541
533
|
return result;
|
|
542
534
|
}
|
|
543
|
-
/**
|
|
544
|
-
* Escape the gitignore metacharacters in a path so `--ignore-pattern` matches it
|
|
545
|
-
* literally. The value crosses two languages and `quoteShellArg` only covers the
|
|
546
|
-
* shell: to Oxlint's matcher `\`, `[`, `]`, `*` and `?` are pattern syntax, and a
|
|
547
|
-
* trailing space is stripped unless escaped — so `/a[b]` excludes `ab` while
|
|
548
|
-
* leaving `a[b]` walked, which is both a miss and a silent over-exclusion.
|
|
549
|
-
*/
|
|
550
|
-
function escapeIgnorePattern(pattern) {
|
|
551
|
-
return pattern
|
|
552
|
-
.replace(/([\\[\]*?])/g, '\\$1')
|
|
553
|
-
.replace(/ +$/, (spaces) => spaces.replace(/ /g, '\\ '));
|
|
554
|
-
}
|
|
555
535
|
/**
|
|
556
536
|
* Direct child project roots for each project root, keyed by the parent. A root
|
|
557
537
|
* belongs to its NEAREST enclosing root, so a grandchild lands under the child
|
|
558
538
|
* rather than the grandparent — which is what keeps an outer project from
|
|
559
|
-
*
|
|
539
|
+
* carrying an exclusion that a shallower one already covers.
|
|
560
540
|
*
|
|
561
541
|
* Built once per run: resolving each root's parent by walking up is linear in
|
|
562
542
|
* the workspace, where asking every project which roots sit below it is not.
|
|
@@ -584,24 +564,6 @@ function nestedRootsByParentRoot(projectRoots) {
|
|
|
584
564
|
}
|
|
585
565
|
return byParent;
|
|
586
566
|
}
|
|
587
|
-
/**
|
|
588
|
-
* The project roots nested directly below `projectRoot`, relative to it and
|
|
589
|
-
* limited to `lintDir` when the lint walk starts there. An excluded root either
|
|
590
|
-
* has its own inferred target or owns nothing lintable, so pruning it drops no
|
|
591
|
-
* lint coverage.
|
|
592
|
-
*
|
|
593
|
-
* Callers must emit each one anchored (`/dir`) and never as `dir/**`. A gitignore
|
|
594
|
-
* pattern is anchored only when it contains a slash, so a bare single-segment
|
|
595
|
-
* root would also match a same-named directory the outer project owns; and
|
|
596
|
-
* `dir/**` matches only entries inside `dir`, leaving Oxlint free to descend and
|
|
597
|
-
* read that directory's ignore files.
|
|
598
|
-
*/
|
|
599
|
-
function nestedProjectRoots(projectRoot, nestedRootsByParent, lintDir) {
|
|
600
|
-
const prefix = projectRoot === '.' ? '' : `${projectRoot}/`;
|
|
601
|
-
return (nestedRootsByParent.get(projectRoot) ?? [])
|
|
602
|
-
.map((root) => root.slice(prefix.length))
|
|
603
|
-
.filter((rel) => !lintDir || rel === lintDir || rel.startsWith(`${lintDir}/`));
|
|
604
|
-
}
|
|
605
567
|
// Only the keys are read, so the value type is left open for all callers.
|
|
606
568
|
function getRootForDirectory(directory, roots) {
|
|
607
569
|
let currentPath = normalize(directory);
|
|
@@ -614,15 +576,15 @@ function getRootForDirectory(directory, roots) {
|
|
|
614
576
|
return roots.has(currentPath) ? currentPath : null;
|
|
615
577
|
}
|
|
616
578
|
function getProjectUsingOxlintConfig(configFilePath, projectRoot, nestedRootsByParent, options, context, pmc, configChainsByConfig, jsPluginSpecifiersByConfig, tsconfigChainOutsideProjectRoot, rootConfig) {
|
|
579
|
+
// Linter-agnostic on purpose: a nested ESLint project's files are still
|
|
580
|
+
// its own.
|
|
581
|
+
const nestedProjectRoots = nestedRootsByParent.get(projectRoot) ?? [];
|
|
617
582
|
let standaloneSrcPath;
|
|
618
583
|
if (projectRoot === '.' &&
|
|
619
584
|
existsSync(join(context.workspaceRoot, projectRoot, 'package.json'))) {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
else if (existsSync(join(context.workspaceRoot, projectRoot, 'lib'))) {
|
|
624
|
-
standaloneSrcPath = 'lib';
|
|
625
|
-
}
|
|
585
|
+
// A directory that is itself a project belongs to that project.
|
|
586
|
+
standaloneSrcPath = ['src', 'lib'].find((dir) => existsSync(join(context.workspaceRoot, projectRoot, dir)) &&
|
|
587
|
+
!nestedProjectRoots.includes(dir));
|
|
626
588
|
}
|
|
627
589
|
if (projectRoot === '.' && !standaloneSrcPath) {
|
|
628
590
|
return null;
|
|
@@ -638,20 +600,18 @@ function getProjectUsingOxlintConfig(configFilePath, projectRoot, nestedRootsByP
|
|
|
638
600
|
...(configChainsByConfig.get(config) ?? []),
|
|
639
601
|
])),
|
|
640
602
|
];
|
|
641
|
-
const isRootProject = projectRoot === '.';
|
|
642
|
-
const lintPath = isRootProject && standaloneSrcPath ? `./${standaloneSrcPath}` : '.';
|
|
643
|
-
const nestedIgnoreArgs = nestedProjectRoots(projectRoot, nestedRootsByParent, isRootProject && standaloneSrcPath ? standaloneSrcPath : '')
|
|
644
|
-
// `quoteShellArg` documents that it cannot keep `%` literal through cmd.exe,
|
|
645
|
-
// so on Windows such a root is left unexcluded rather than excluded wrongly:
|
|
646
|
-
// it gets linted twice, which is what happened before this exclusion existed.
|
|
647
|
-
.filter((relativeRoot) => process.platform !== 'win32' || !relativeRoot.includes('%'))
|
|
648
|
-
.map((relativeRoot) => `--ignore-pattern ${quoteShellArg(escapeIgnorePattern(`/${relativeRoot}`))}`);
|
|
649
603
|
const jsPluginFiles = new Set(configInputs.flatMap((config) => localJsPluginFiles(config, jsPluginSpecifiersByConfig.get(config) ?? [])));
|
|
650
604
|
const lintableFiles = `{projectRoot}/${LINTABLE_FILES_GLOB}`;
|
|
651
605
|
const usesBoundariesBridge = configInputs.some((config) => (jsPluginSpecifiersByConfig.get(config) ?? []).includes(BOUNDARIES_PLUGIN_SPECIFIER));
|
|
652
606
|
const targetConfig = {
|
|
653
|
-
|
|
654
|
-
|
|
607
|
+
executor: '@nx/oxlint:lint',
|
|
608
|
+
...((standaloneSrcPath || nestedProjectRoots.length > 0) && {
|
|
609
|
+
options: {
|
|
610
|
+
// Every other project lints its root, the executor's default.
|
|
611
|
+
...(standaloneSrcPath && { lintFilePatterns: [standaloneSrcPath] }),
|
|
612
|
+
...(nestedProjectRoots.length > 0 && { nestedProjectRoots }),
|
|
613
|
+
},
|
|
614
|
+
}),
|
|
655
615
|
cache: true,
|
|
656
616
|
inputs: [
|
|
657
617
|
// Only what Oxlint can lint, so a README or JSON edit is not a re-lint.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute path of Oxlint's CLI entry as installed in the workspace, or null
|
|
3
|
+
* when `oxlint` cannot be resolved from there. Spawn it through
|
|
4
|
+
* `process.execPath` so nothing depends on a shell or on PATH.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveOxlintBin(workspaceRoot: string): string | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const localRequire = createRequire(import.meta.url);
|
|
5
|
+
/**
|
|
6
|
+
* Absolute path of Oxlint's CLI entry as installed in the workspace, or null
|
|
7
|
+
* when `oxlint` cannot be resolved from there. Spawn it through
|
|
8
|
+
* `process.execPath` so nothing depends on a shell or on PATH.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveOxlintBin(workspaceRoot) {
|
|
11
|
+
try {
|
|
12
|
+
const bin = join(dirname(localRequire.resolve('oxlint/package.json', { paths: [workspaceRoot] })), 'bin', 'oxlint');
|
|
13
|
+
return existsSync(bin) ? bin : null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/executors.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"executors": {
|
|
3
|
+
"lint": {
|
|
4
|
+
"implementation": "./dist/src/executors/lint/lint.impl",
|
|
5
|
+
"batchImplementation": "./dist/src/executors/lint/lint.batch-impl",
|
|
6
|
+
"preferBatch": true,
|
|
7
|
+
"schema": "./dist/src/executors/lint/schema.json",
|
|
8
|
+
"description": "Lint a project with Oxlint. Batched by default: one Oxlint process lints every project in the run."
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nx/oxlint",
|
|
3
3
|
"description": "Experimental. The Oxlint plugin for Nx contains generators and utilities used for linting JavaScript/TypeScript projects within an Nx workspace.",
|
|
4
|
-
"version": "23.3.0-
|
|
4
|
+
"version": "23.3.0-rc.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist",
|
|
8
8
|
"!dist/tsconfig.tsbuildinfo",
|
|
9
9
|
"migrations.json",
|
|
10
|
-
"generators.json"
|
|
10
|
+
"generators.json",
|
|
11
|
+
"executors.json"
|
|
11
12
|
],
|
|
12
13
|
"publishConfig": {
|
|
13
14
|
"access": "public"
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"./package.json": "./package.json",
|
|
52
53
|
"./migrations.json": "./migrations.json",
|
|
53
54
|
"./generators.json": "./generators.json",
|
|
55
|
+
"./executors.json": "./executors.json",
|
|
54
56
|
"./generators": {
|
|
55
57
|
"@nx/nx-source": "./generators.ts",
|
|
56
58
|
"types": "./dist/generators.d.ts",
|
|
@@ -67,11 +69,13 @@
|
|
|
67
69
|
"supportsOptionalMigrations": true
|
|
68
70
|
},
|
|
69
71
|
"generators": "./generators.json",
|
|
72
|
+
"executors": "./executors.json",
|
|
70
73
|
"dependencies": {
|
|
74
|
+
"@nx/devkit": "23.3.0-rc.0",
|
|
75
|
+
"@nx/eslint-plugin": "23.3.0-rc.0",
|
|
76
|
+
"@nx/js": "23.3.0-rc.0",
|
|
71
77
|
"tslib": "^2.3.0",
|
|
72
|
-
"
|
|
73
|
-
"@nx/eslint-plugin": "23.3.0-pr.37068.f95e11e",
|
|
74
|
-
"@nx/js": "23.3.0-pr.37068.f95e11e"
|
|
78
|
+
"picocolors": "^1.1.0"
|
|
75
79
|
},
|
|
76
80
|
"peerDependencies": {
|
|
77
81
|
"oxlint": "^1.43.0"
|
|
@@ -85,6 +89,6 @@
|
|
|
85
89
|
"node": "^20.19.0 || >=22.12.0"
|
|
86
90
|
},
|
|
87
91
|
"devDependencies": {
|
|
88
|
-
"nx": "23.3.0-
|
|
92
|
+
"nx": "23.3.0-rc.0"
|
|
89
93
|
}
|
|
90
94
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<p style="text-align: center;">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-dark.svg">
|
|
4
|
+
<img alt="Nx - Smart Monorepos · Fast Builds" src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-light.svg" width="100%">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
{{links}}
|
|
9
|
+
|
|
10
|
+
<hr>
|
|
11
|
+
|
|
12
|
+
# Nx: Smart Monorepos · Fast Builds
|
|
13
|
+
|
|
14
|
+
Get to green PRs in half the time. Nx optimizes your builds, scales your CI, and fixes failed PRs. Built for developers and AI agents.
|
|
15
|
+
|
|
16
|
+
This package is an [Oxlint plugin for Nx](https://nx.dev/nx-api/oxlint).
|
|
17
|
+
|
|
18
|
+
{{content}}
|