Skip to content

Commit aba2ff3

Browse files
feat(plugin-eslint): add artifact loading logic (#1077)
This PR includes: - add `loadArtifacts` helper and tests - add preperation logic - adjust models exports - adjust aritfacts schemas for glob patterns Related to: - #1068 --------- Co-authored-by: Matěj Chalk <34691111+matejchalk@users.noreply.github.com>
1 parent 56f1c4c commit aba2ff3

File tree

10 files changed

+419
-4
lines changed

10 files changed

+419
-4
lines changed

nx.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@
4040
}
4141
}
4242
},
43-
"e2e": {
44-
"dependsOn": ["^build"]
45-
},
43+
"e2e": { "dependsOn": ["^build"] },
4644
"lint": {
4745
"inputs": ["default", "{workspaceRoot}/eslint.config.?(c)js"],
4846
"executor": "@nx/linter:eslint",

packages/models/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export { commitSchema, type Commit } from './lib/commit.js';
3434
export {
3535
artifactGenerationCommandSchema,
3636
pluginArtifactOptionsSchema,
37+
type PluginArtifactOptions,
3738
} from './lib/configuration.js';
3839
export { coreConfigSchema, type CoreConfig } from './lib/core-config.js';
3940
export {
@@ -62,6 +63,7 @@ export {
6263
export {
6364
fileNameSchema,
6465
filePathSchema,
66+
globPathSchema,
6567
materialIconSchema,
6668
scoreSchema,
6769
slugSchema,

packages/models/src/lib/configuration.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod';
2+
import { globPathSchema } from './implementation/schemas.js';
23

34
/**
45
* Generic schema for a tool command configuration, reusable across plugins.
@@ -13,7 +14,9 @@ export const artifactGenerationCommandSchema = z.union([
1314

1415
export const pluginArtifactOptionsSchema = z.object({
1516
generateArtifactsCommand: artifactGenerationCommandSchema.optional(),
16-
artifactsPaths: z.union([z.string(), z.array(z.string()).min(1)]),
17+
artifactsPaths: z
18+
.union([globPathSchema, z.array(globPathSchema).min(1)])
19+
.describe('File paths or glob patterns for artifact files'),
1720
});
1821

1922
export type PluginArtifactOptions = z.infer<typeof pluginArtifactOptionsSchema>;

packages/models/src/lib/implementation/schemas.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,25 @@ export const filePathSchema = z
138138
.trim()
139139
.min(1, { message: 'The path is invalid' });
140140

141+
/**
142+
* Regex for glob patterns - validates file paths and glob patterns
143+
* Allows normal paths and paths with glob metacharacters: *, **, {}, [], !, ?
144+
* Excludes invalid path characters: <>"|
145+
*/
146+
const globRegex = /^!?[^<>"|]+$/;
147+
148+
export const globPathSchema = z
149+
.string()
150+
.trim()
151+
.min(1, { message: 'The glob pattern is invalid' })
152+
.regex(globRegex, {
153+
message:
154+
'The path must be a valid file path or glob pattern (supports *, **, {}, [], !, ?)',
155+
})
156+
.describe(
157+
'Schema for a glob pattern (supports wildcards like *, **, {}, !, etc.)',
158+
);
159+
141160
/** Schema for a fileNameSchema */
142161
export const fileNameSchema = z
143162
.string()

packages/models/src/lib/implementation/schemas.unit.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
22
import {
33
type TableCellValue,
44
docsUrlSchema,
5+
globPathSchema,
56
tableCellValueSchema,
67
weightSchema,
78
} from './schemas.js';
@@ -66,3 +67,23 @@ describe('docsUrlSchema', () => {
6667
);
6768
});
6869
});
70+
71+
describe('globPathSchema', () => {
72+
it.each([
73+
'**/*.ts',
74+
'src/components/*.jsx',
75+
'{src,lib,test}/**/*.js',
76+
'!node_modules/**',
77+
])('should accept a valid glob pattern: %s', pattern => {
78+
expect(() => globPathSchema.parse(pattern)).not.toThrow();
79+
});
80+
81+
it.each(['path<file.js', 'path>file.js', 'path"file.js', 'path|file.js'])(
82+
'should throw for invalid path with forbidden character: %s',
83+
pattern => {
84+
expect(() => globPathSchema.parse(pattern)).toThrow(
85+
'valid file path or glob pattern',
86+
);
87+
},
88+
);
89+
});

packages/plugin-eslint/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
},
3939
"type": "module",
4040
"dependencies": {
41+
"glob": "^11.0.0",
4142
"@code-pushup/utils": "0.76.0",
4243
"@code-pushup/models": "0.76.0",
4344
"yargs": "^17.7.2",

packages/plugin-eslint/src/lib/runner/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import path from 'node:path';
33
import type {
44
Audit,
55
AuditOutput,
6+
AuditOutputs,
7+
PluginArtifactOptions,
68
RunnerConfig,
79
RunnerFilesPaths,
810
} from '@code-pushup/models';
@@ -18,6 +20,7 @@ import {
1820
import type { ESLintPluginRunnerConfig, ESLintTarget } from '../config.js';
1921
import { lint } from './lint.js';
2022
import { lintResultsToAudits, mergeLinterOutputs } from './transform.js';
23+
import { loadArtifacts } from './utils.js';
2124

2225
export async function executeRunner({
2326
runnerConfigPath,
@@ -71,3 +74,34 @@ export async function createRunnerConfig(
7174
outputFile: runnerOutputPath,
7275
};
7376
}
77+
78+
export async function generateAuditOutputs(options: {
79+
audits: Audit[];
80+
targets: ESLintTarget[];
81+
artifacts?: PluginArtifactOptions;
82+
}): Promise<AuditOutputs> {
83+
const { audits, targets, artifacts } = options;
84+
const config: ESLintPluginRunnerConfig = {
85+
targets,
86+
slugs: audits.map(audit => audit.slug),
87+
};
88+
89+
ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`);
90+
91+
const linterOutputs = artifacts
92+
? await loadArtifacts(artifacts)
93+
: await asyncSequential(targets, lint);
94+
const lintResults = mergeLinterOutputs(linterOutputs);
95+
const failedAudits = lintResultsToAudits(lintResults);
96+
97+
return config.slugs.map(
98+
(slug): AuditOutput =>
99+
failedAudits.find(audit => audit.slug === slug) ?? {
100+
slug,
101+
score: 1,
102+
value: 0,
103+
displayValue: 'passed',
104+
details: { issues: [] },
105+
},
106+
);
107+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { z } from 'zod';
3+
import type {
4+
Audit,
5+
AuditOutput,
6+
pluginArtifactOptionsSchema,
7+
} from '@code-pushup/models';
8+
import { ui } from '@code-pushup/utils';
9+
import type { ESLintTarget } from '../config.js';
10+
import { generateAuditOutputs } from './index.js';
11+
import * as lintModule from './lint.js';
12+
import type { LinterOutput } from './types.js';
13+
import * as utilsFileModule from './utils.js';
14+
15+
describe('generateAuditOutputs', () => {
16+
const loadArtifactsSpy = vi.spyOn(utilsFileModule, 'loadArtifacts');
17+
const lintSpy = vi.spyOn(lintModule, 'lint');
18+
19+
const mockAudits: Audit[] = [
20+
{ slug: 'max-lines', title: 'Max lines', description: 'Test' },
21+
{ slug: 'no-unused-vars', title: 'No unused vars', description: 'Test' },
22+
];
23+
const mockTargetPatterns = { patterns: ['src/**/*.ts'] };
24+
const mockTargetPatternsAndConfigs = {
25+
patterns: ['lib/**/*.js'],
26+
eslintrc: '.eslintrc.js',
27+
};
28+
const mockTargets: ESLintTarget[] = [
29+
mockTargetPatterns,
30+
mockTargetPatternsAndConfigs,
31+
];
32+
33+
const mockLinterOutputs: LinterOutput[] = [
34+
{
35+
results: [
36+
{
37+
filePath: 'test.ts',
38+
messages: [
39+
{
40+
ruleId: 'max-lines',
41+
severity: 1,
42+
message: 'File has too many lines',
43+
line: 1,
44+
column: 1,
45+
},
46+
],
47+
} as any,
48+
],
49+
ruleOptionsPerFile: { 'test.ts': { 'max-lines': [] } },
50+
},
51+
{
52+
results: [
53+
{
54+
filePath: 'test.ts',
55+
messages: [
56+
{
57+
ruleId: 'max-lines',
58+
severity: 1,
59+
message: 'File has too many lines',
60+
line: 1,
61+
column: 1,
62+
},
63+
],
64+
} as any,
65+
],
66+
ruleOptionsPerFile: { 'test.ts': { 'max-lines': [] } },
67+
},
68+
];
69+
70+
const mockedAuditOutputs: AuditOutput[] = [
71+
{
72+
slug: 'max-lines',
73+
score: 0,
74+
value: 2,
75+
displayValue: '2 warnings',
76+
details: {
77+
issues: [
78+
{
79+
message: 'File has too many lines',
80+
severity: 'warning',
81+
source: {
82+
file: 'test.ts',
83+
position: {
84+
startLine: 1,
85+
startColumn: 1,
86+
},
87+
},
88+
},
89+
{
90+
message: 'File has too many lines',
91+
severity: 'warning',
92+
source: {
93+
file: 'test.ts',
94+
position: {
95+
startLine: 1,
96+
startColumn: 1,
97+
},
98+
},
99+
},
100+
],
101+
},
102+
},
103+
{
104+
slug: 'no-unused-vars',
105+
score: 1,
106+
value: 0,
107+
displayValue: 'passed',
108+
details: { issues: [] },
109+
},
110+
];
111+
112+
beforeEach(() => {
113+
vi.clearAllMocks();
114+
});
115+
116+
it('should use loadArtifacts when artifacts are provided', async () => {
117+
const artifacts: z.infer<typeof pluginArtifactOptionsSchema> = {
118+
artifactsPaths: ['path/to/artifacts.json'],
119+
};
120+
loadArtifactsSpy.mockResolvedValue(mockLinterOutputs);
121+
122+
await expect(
123+
generateAuditOutputs({
124+
audits: mockAudits,
125+
targets: mockTargets,
126+
artifacts,
127+
}),
128+
).resolves.toStrictEqual(mockedAuditOutputs);
129+
130+
expect(loadArtifactsSpy).toHaveBeenCalledWith(artifacts);
131+
expect(lintSpy).not.toHaveBeenCalled();
132+
expect(ui()).toHaveLogged('log', 'ESLint plugin executing 2 lint targets');
133+
});
134+
135+
it('should use internal linting logic when artifacts are not provided', async () => {
136+
lintSpy.mockResolvedValueOnce(mockLinterOutputs.at(0)!);
137+
lintSpy.mockResolvedValueOnce(mockLinterOutputs.at(0)!);
138+
139+
await expect(
140+
generateAuditOutputs({
141+
audits: mockAudits,
142+
targets: mockTargets,
143+
outputDir: 'custom-output',
144+
}),
145+
).resolves.toStrictEqual(mockedAuditOutputs);
146+
147+
expect(loadArtifactsSpy).not.toHaveBeenCalled();
148+
expect(lintSpy).toHaveBeenCalledTimes(2);
149+
});
150+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { ESLint } from 'eslint';
2+
import { glob } from 'glob';
3+
import type { PluginArtifactOptions } from '@code-pushup/models';
4+
import { executeProcess, readJsonFile } from '@code-pushup/utils';
5+
import type { LinterOutput } from './types.js';
6+
7+
export async function loadArtifacts(
8+
artifacts: PluginArtifactOptions,
9+
): Promise<LinterOutput[]> {
10+
if (artifacts.generateArtifactsCommand) {
11+
const { command, args = [] } =
12+
typeof artifacts.generateArtifactsCommand === 'string'
13+
? { command: artifacts.generateArtifactsCommand }
14+
: artifacts.generateArtifactsCommand;
15+
16+
await executeProcess({
17+
command,
18+
args,
19+
ignoreExitCode: true,
20+
});
21+
}
22+
23+
const initialArtifactPaths = Array.isArray(artifacts.artifactsPaths)
24+
? artifacts.artifactsPaths
25+
: [artifacts.artifactsPaths];
26+
27+
const artifactPaths = await glob(initialArtifactPaths);
28+
29+
return await Promise.all(
30+
artifactPaths.map(async artifactPath => {
31+
// ESLint CLI outputs raw ESLint.LintResult[], but we need LinterOutput format
32+
const results = await readJsonFile<ESLint.LintResult[]>(artifactPath);
33+
return {
34+
results,
35+
ruleOptionsPerFile: {}, // TODO
36+
};
37+
}),
38+
);
39+
}

0 commit comments

Comments
 (0)