Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@
}
}
},
"e2e": {
"dependsOn": ["^build"]
},
"e2e": { "dependsOn": ["^build"] },
"lint": {
"inputs": ["default", "{workspaceRoot}/eslint.config.?(c)js"],
"executor": "@nx/linter:eslint",
Expand Down
2 changes: 2 additions & 0 deletions packages/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export { commitSchema, type Commit } from './lib/commit.js';
export {
artifactGenerationCommandSchema,
pluginArtifactOptionsSchema,
type PluginArtifactOptions,
} from './lib/configuration.js';
export { coreConfigSchema, type CoreConfig } from './lib/core-config.js';
export {
Expand Down Expand Up @@ -62,6 +63,7 @@ export {
export {
fileNameSchema,
filePathSchema,
globPathSchema,
materialIconSchema,
scoreSchema,
slugSchema,
Expand Down
5 changes: 4 additions & 1 deletion packages/models/src/lib/configuration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { globPathSchema } from './implementation/schemas.js';

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

export const pluginArtifactOptionsSchema = z.object({
generateArtifactsCommand: artifactGenerationCommandSchema.optional(),
artifactsPaths: z.union([z.string(), z.array(z.string()).min(1)]),
artifactsPaths: z
.union([globPathSchema, z.array(globPathSchema).min(1)])
.describe('File paths or glob patterns for artifact files'),
});

export type PluginArtifactOptions = z.infer<typeof pluginArtifactOptionsSchema>;
19 changes: 19 additions & 0 deletions packages/models/src/lib/implementation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ export const filePathSchema = z
.trim()
.min(1, { message: 'The path is invalid' });

/**
* Regex for glob patterns - validates file paths and glob patterns
* Allows normal paths and paths with glob metacharacters: *, **, {}, [], !, ?
* Excludes invalid path characters: <>"|
*/
const globRegex = /^!?[^<>"|]+$/;

export const globPathSchema = z
.string()
.trim()
.min(1, { message: 'The glob pattern is invalid' })
.regex(globRegex, {
message:
'The path must be a valid file path or glob pattern (supports *, **, {}, [], !, ?)',
})
.describe(
'Schema for a glob pattern (supports wildcards like *, **, {}, !, etc.)',
);

/** Schema for a fileNameSchema */
export const fileNameSchema = z
.string()
Expand Down
21 changes: 21 additions & 0 deletions packages/models/src/lib/implementation/schemas.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
type TableCellValue,
docsUrlSchema,
globPathSchema,
tableCellValueSchema,
weightSchema,
} from './schemas.js';
Expand Down Expand Up @@ -66,3 +67,23 @@ describe('docsUrlSchema', () => {
);
});
});

describe('globPathSchema', () => {
it.each([
'**/*.ts',
'src/components/*.jsx',
'{src,lib,test}/**/*.js',
'!node_modules/**',
])('should accept a valid glob pattern: %s', pattern => {
expect(() => globPathSchema.parse(pattern)).not.toThrow();
});

it.each(['path<file.js', 'path>file.js', 'path"file.js', 'path|file.js'])(
'should throw for invalid path with forbidden character: %s',
pattern => {
expect(() => globPathSchema.parse(pattern)).toThrow(
'valid file path or glob pattern',
);
},
);
});
1 change: 1 addition & 0 deletions packages/plugin-eslint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
},
"type": "module",
"dependencies": {
"glob": "^11.0.0",
"@code-pushup/utils": "0.76.0",
"@code-pushup/models": "0.76.0",
"yargs": "^17.7.2",
Expand Down
34 changes: 34 additions & 0 deletions packages/plugin-eslint/src/lib/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import path from 'node:path';
import type {
Audit,
AuditOutput,
AuditOutputs,
PluginArtifactOptions,
RunnerConfig,
RunnerFilesPaths,
} from '@code-pushup/models';
Expand All @@ -18,6 +20,7 @@ import {
import type { ESLintPluginRunnerConfig, ESLintTarget } from '../config.js';
import { lint } from './lint.js';
import { lintResultsToAudits, mergeLinterOutputs } from './transform.js';
import { loadArtifacts } from './utils.js';

export async function executeRunner({
runnerConfigPath,
Expand Down Expand Up @@ -71,3 +74,34 @@ export async function createRunnerConfig(
outputFile: runnerOutputPath,
};
}

export async function generateAuditOutputs(options: {
audits: Audit[];
targets: ESLintTarget[];
artifacts?: PluginArtifactOptions;
}): Promise<AuditOutputs> {
const { audits, targets, artifacts } = options;
const config: ESLintPluginRunnerConfig = {
targets,
slugs: audits.map(audit => audit.slug),
};

ui().logger.log(`ESLint plugin executing ${targets.length} lint targets`);

const linterOutputs = artifacts
? await loadArtifacts(artifacts)
: await asyncSequential(targets, lint);
const lintResults = mergeLinterOutputs(linterOutputs);
const failedAudits = lintResultsToAudits(lintResults);

return config.slugs.map(
(slug): AuditOutput =>
failedAudits.find(audit => audit.slug === slug) ?? {
slug,
score: 1,
value: 0,
displayValue: 'passed',
details: { issues: [] },
},
);
}
150 changes: 150 additions & 0 deletions packages/plugin-eslint/src/lib/runner/index.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { z } from 'zod';
import type {
Audit,
AuditOutput,
pluginArtifactOptionsSchema,
} from '@code-pushup/models';
import { ui } from '@code-pushup/utils';
import type { ESLintTarget } from '../config.js';
import { generateAuditOutputs } from './index.js';
import * as lintModule from './lint.js';
import type { LinterOutput } from './types.js';
import * as utilsFileModule from './utils.js';

describe('generateAuditOutputs', () => {
const loadArtifactsSpy = vi.spyOn(utilsFileModule, 'loadArtifacts');
const lintSpy = vi.spyOn(lintModule, 'lint');

const mockAudits: Audit[] = [
{ slug: 'max-lines', title: 'Max lines', description: 'Test' },
{ slug: 'no-unused-vars', title: 'No unused vars', description: 'Test' },
];
const mockTargetPatterns = { patterns: ['src/**/*.ts'] };
const mockTargetPatternsAndConfigs = {
patterns: ['lib/**/*.js'],
eslintrc: '.eslintrc.js',
};
const mockTargets: ESLintTarget[] = [
mockTargetPatterns,
mockTargetPatternsAndConfigs,
];

const mockLinterOutputs: LinterOutput[] = [
{
results: [
{
filePath: 'test.ts',
messages: [
{
ruleId: 'max-lines',
severity: 1,
message: 'File has too many lines',
line: 1,
column: 1,
},
],
} as any,
],
ruleOptionsPerFile: { 'test.ts': { 'max-lines': [] } },
},
{
results: [
{
filePath: 'test.ts',
messages: [
{
ruleId: 'max-lines',
severity: 1,
message: 'File has too many lines',
line: 1,
column: 1,
},
],
} as any,
],
ruleOptionsPerFile: { 'test.ts': { 'max-lines': [] } },
},
];

const mockedAuditOutputs: AuditOutput[] = [
{
slug: 'max-lines',
score: 0,
value: 2,
displayValue: '2 warnings',
details: {
issues: [
{
message: 'File has too many lines',
severity: 'warning',
source: {
file: 'test.ts',
position: {
startLine: 1,
startColumn: 1,
},
},
},
{
message: 'File has too many lines',
severity: 'warning',
source: {
file: 'test.ts',
position: {
startLine: 1,
startColumn: 1,
},
},
},
],
},
},
{
slug: 'no-unused-vars',
score: 1,
value: 0,
displayValue: 'passed',
details: { issues: [] },
},
];

beforeEach(() => {
vi.clearAllMocks();
});

it('should use loadArtifacts when artifacts are provided', async () => {
const artifacts: z.infer<typeof pluginArtifactOptionsSchema> = {
artifactsPaths: ['path/to/artifacts.json'],
};
loadArtifactsSpy.mockResolvedValue(mockLinterOutputs);

await expect(
generateAuditOutputs({
audits: mockAudits,
targets: mockTargets,
artifacts,
}),
).resolves.toStrictEqual(mockedAuditOutputs);

expect(loadArtifactsSpy).toHaveBeenCalledWith(artifacts);
expect(lintSpy).not.toHaveBeenCalled();
expect(ui()).toHaveLogged('log', 'ESLint plugin executing 2 lint targets');
});

it('should use internal linting logic when artifacts are not provided', async () => {
lintSpy.mockResolvedValueOnce(mockLinterOutputs.at(0)!);
lintSpy.mockResolvedValueOnce(mockLinterOutputs.at(0)!);

await expect(
generateAuditOutputs({
audits: mockAudits,
targets: mockTargets,
outputDir: 'custom-output',
}),
).resolves.toStrictEqual(mockedAuditOutputs);

expect(loadArtifactsSpy).not.toHaveBeenCalled();
expect(lintSpy).toHaveBeenCalledTimes(2);
});
});
39 changes: 39 additions & 0 deletions packages/plugin-eslint/src/lib/runner/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ESLint } from 'eslint';
import { glob } from 'glob';
import type { PluginArtifactOptions } from '@code-pushup/models';
import { executeProcess, readJsonFile } from '@code-pushup/utils';
import type { LinterOutput } from './types.js';

export async function loadArtifacts(
artifacts: PluginArtifactOptions,
): Promise<LinterOutput[]> {
if (artifacts.generateArtifactsCommand) {
const { command, args = [] } =
typeof artifacts.generateArtifactsCommand === 'string'
? { command: artifacts.generateArtifactsCommand }
: artifacts.generateArtifactsCommand;

await executeProcess({
command,
args,
ignoreExitCode: true,
});
}

const initialArtifactPaths = Array.isArray(artifacts.artifactsPaths)
? artifacts.artifactsPaths
: [artifacts.artifactsPaths];

const artifactPaths = await glob(initialArtifactPaths);

return await Promise.all(
artifactPaths.map(async artifactPath => {
// ESLint CLI outputs raw ESLint.LintResult[], but we need LinterOutput format
const results = await readJsonFile<ESLint.LintResult[]>(artifactPath);
return {
results,
ruleOptionsPerFile: {}, // TODO
};
}),
);
}
Loading
Loading