mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
ci: Add rules engine and code health packages (no-changelog) (#27815)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3dde7e16f8
commit
3922984b74
26 changed files with 1515 additions and 168 deletions
24
packages/testing/code-health/eslint.config.mjs
Normal file
24
packages/testing/code-health/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(
|
||||
baseConfig,
|
||||
{
|
||||
ignores: ['coverage/**', 'dist/**'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'objectLiteralProperty',
|
||||
format: null,
|
||||
filter: {
|
||||
regex: '^[a-z]+-[a-z-]+$',
|
||||
match: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
37
packages/testing/code-health/package.json
Normal file
37
packages/testing/code-health/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "@n8n/code-health",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Static analysis and code quality enforcement for the n8n monorepo",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"code-health": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@n8n/rules-engine": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"fast-glob": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"yaml": "^2.4.0"
|
||||
}
|
||||
}
|
||||
92
packages/testing/code-health/src/cli.ts
Normal file
92
packages/testing/code-health/src/cli.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
toJSON,
|
||||
loadBaseline,
|
||||
generateBaseline,
|
||||
saveBaseline,
|
||||
filterReportByBaseline,
|
||||
} from '@n8n/rules-engine';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { parseArgs } from './cli/arg-parser.js';
|
||||
import type { CodeHealthContext } from './context.js';
|
||||
import { createDefaultRunner } from './index.js';
|
||||
|
||||
const BASELINE_FILENAME = '.code-health-baseline.json';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const options = parseArgs(args);
|
||||
|
||||
const rootDir = findMonorepoRoot(process.cwd());
|
||||
const baselinePath = path.join(rootDir, BASELINE_FILENAME);
|
||||
const context: CodeHealthContext = { rootDir };
|
||||
|
||||
const runner = createDefaultRunner();
|
||||
|
||||
if (options.command === 'rules') {
|
||||
console.log(JSON.stringify(runner.getRuleDetails(), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.rule) {
|
||||
runner.enableOnly([options.rule]);
|
||||
}
|
||||
|
||||
let report = options.rule
|
||||
? await runner.runRule(options.rule, context, rootDir)
|
||||
: await runner.run(context, rootDir);
|
||||
|
||||
if (!report) {
|
||||
console.error(JSON.stringify({ error: `Unknown rule: ${options.rule}` }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.command === 'baseline') {
|
||||
const baseline = generateBaseline(report, rootDir);
|
||||
saveBaseline(baseline, baselinePath);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
action: 'baseline-created',
|
||||
totalViolations: baseline.totalViolations,
|
||||
path: baselinePath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.ignoreBaseline && fs.existsSync(baselinePath)) {
|
||||
const baseline = loadBaseline(baselinePath);
|
||||
if (baseline) {
|
||||
report = filterReportByBaseline(report, baseline, rootDir);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(toJSON(report, rootDir));
|
||||
|
||||
if (report.summary.totalViolations > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir: string): string {
|
||||
let dir = startDir;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return startDir;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(JSON.stringify({ error: (error as Error).message }));
|
||||
process.exit(2);
|
||||
});
|
||||
37
packages/testing/code-health/src/cli/arg-parser.ts
Normal file
37
packages/testing/code-health/src/cli/arg-parser.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export interface CliOptions {
|
||||
command: 'analyze' | 'baseline' | 'rules';
|
||||
rule?: string;
|
||||
file?: string;
|
||||
ignoreBaseline: boolean;
|
||||
}
|
||||
|
||||
export function parseArgs(args: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
command: 'analyze',
|
||||
ignoreBaseline: false,
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
|
||||
if (args.length > 0 && !args[0].startsWith('-')) {
|
||||
const command = args[0];
|
||||
if (command === 'baseline' || command === 'rules') {
|
||||
options.command = command;
|
||||
}
|
||||
i = 1;
|
||||
}
|
||||
|
||||
for (; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--ignore-baseline') {
|
||||
options.ignoreBaseline = true;
|
||||
} else if (arg.startsWith('--rule=')) {
|
||||
options.rule = arg.slice('--rule='.length);
|
||||
} else if (arg.startsWith('--file=')) {
|
||||
options.file = arg.slice('--file='.length);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
3
packages/testing/code-health/src/context.ts
Normal file
3
packages/testing/code-health/src/context.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export interface CodeHealthContext {
|
||||
rootDir: string;
|
||||
}
|
||||
36
packages/testing/code-health/src/index.ts
Normal file
36
packages/testing/code-health/src/index.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { RuleRunner } from '@n8n/rules-engine';
|
||||
import type { RuleSettingsMap } from '@n8n/rules-engine';
|
||||
|
||||
import type { CodeHealthContext } from './context.js';
|
||||
import { CatalogViolationsRule } from './rules/catalog-violations.rule.js';
|
||||
|
||||
export type { CodeHealthContext } from './context.js';
|
||||
export { CatalogViolationsRule } from './rules/catalog-violations.rule.js';
|
||||
|
||||
const defaultRuleSettings: RuleSettingsMap = {
|
||||
'catalog-violations': {
|
||||
enabled: true,
|
||||
severity: 'error',
|
||||
options: { workspaceFile: 'pnpm-workspace.yaml' },
|
||||
},
|
||||
};
|
||||
|
||||
function mergeSettings(defaults: RuleSettingsMap, overrides?: RuleSettingsMap): RuleSettingsMap {
|
||||
if (!overrides) return defaults;
|
||||
|
||||
const merged = { ...defaults };
|
||||
for (const [ruleId, override] of Object.entries(overrides)) {
|
||||
const base = merged[ruleId];
|
||||
merged[ruleId] = base
|
||||
? { ...base, ...override, options: { ...base.options, ...override?.options } }
|
||||
: override;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function createDefaultRunner(settings?: RuleSettingsMap): RuleRunner<CodeHealthContext> {
|
||||
const runner = new RuleRunner<CodeHealthContext>();
|
||||
runner.registerRule(new CatalogViolationsRule());
|
||||
runner.applySettings(mergeSettings(defaultRuleSettings, settings));
|
||||
return runner;
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CodeHealthContext } from '../context.js';
|
||||
import { CatalogViolationsRule } from './catalog-violations.rule.js';
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'code-health-test-'));
|
||||
}
|
||||
|
||||
function writeFile(dir: string, relativePath: string, content: string): void {
|
||||
const fullPath = path.join(dir, relativePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
describe('CatalogViolationsRule', () => {
|
||||
let tmpDir: string;
|
||||
let rule: CatalogViolationsRule;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir();
|
||||
rule = new CatalogViolationsRule();
|
||||
rule.configure({ options: { workspaceFile: 'pnpm-workspace.yaml' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function context(): CodeHealthContext {
|
||||
return { rootDir: tmpDir };
|
||||
}
|
||||
|
||||
it('flags hardcoded version when dep exists in catalog', async () => {
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'pnpm-workspace.yaml',
|
||||
`
|
||||
packages:
|
||||
- packages/*
|
||||
catalog:
|
||||
lodash: ^4.17.0
|
||||
`,
|
||||
);
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'packages/foo/package.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'foo',
|
||||
dependencies: { lodash: '^4.17.21' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const violations = await rule.analyze(context());
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('lodash');
|
||||
expect(violations[0].message).toContain('"catalog:"');
|
||||
});
|
||||
|
||||
it('flags hardcoded version when dep exists in named catalog', async () => {
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'pnpm-workspace.yaml',
|
||||
`
|
||||
packages:
|
||||
- packages/*
|
||||
catalogs:
|
||||
frontend:
|
||||
vue: ^3.4.0
|
||||
`,
|
||||
);
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'packages/ui/package.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'ui',
|
||||
dependencies: { vue: '^3.3.0' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const violations = await rule.analyze(context());
|
||||
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].message).toContain('vue');
|
||||
expect(violations[0].message).toContain('"catalog:frontend"');
|
||||
});
|
||||
|
||||
it('ignores deps that already use catalog reference', async () => {
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'pnpm-workspace.yaml',
|
||||
`
|
||||
packages:
|
||||
- packages/*
|
||||
catalog:
|
||||
zod: ^3.0.0
|
||||
`,
|
||||
);
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'packages/core/package.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'core',
|
||||
dependencies: { zod: 'catalog:' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const violations = await rule.analyze(context());
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores workspace references', async () => {
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'pnpm-workspace.yaml',
|
||||
`
|
||||
packages:
|
||||
- packages/*
|
||||
catalog: {}
|
||||
`,
|
||||
);
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'packages/cli/package.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'cli',
|
||||
dependencies: { '@n8n/core': 'workspace:*' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const violations = await rule.analyze(context());
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags cross-package version mismatch for deps not in catalog', async () => {
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'pnpm-workspace.yaml',
|
||||
`
|
||||
packages:
|
||||
- packages/*
|
||||
catalog: {}
|
||||
`,
|
||||
);
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'packages/a/package.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'a',
|
||||
dependencies: { 'some-lib': '^1.0.0' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'packages/b/package.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'b',
|
||||
dependencies: { 'some-lib': '^2.0.0' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const violations = await rule.analyze(context());
|
||||
|
||||
expect(violations).toHaveLength(2);
|
||||
expect(violations[0].message).toContain('some-lib');
|
||||
expect(violations[0].message).toContain('2 different versions');
|
||||
});
|
||||
|
||||
it('does not flag cross-package when versions match', async () => {
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'pnpm-workspace.yaml',
|
||||
`
|
||||
packages:
|
||||
- packages/*
|
||||
catalog: {}
|
||||
`,
|
||||
);
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'packages/a/package.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'a',
|
||||
dependencies: { 'some-lib': '^1.0.0' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
writeFile(
|
||||
tmpDir,
|
||||
'packages/b/package.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'b',
|
||||
dependencies: { 'some-lib': '^1.0.0' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const violations = await rule.analyze(context());
|
||||
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { BaseRule } from '@n8n/rules-engine';
|
||||
import type { Violation } from '@n8n/rules-engine';
|
||||
|
||||
import type { CodeHealthContext } from '../context.js';
|
||||
import {
|
||||
findPackageJsonFiles,
|
||||
parsePackageJson,
|
||||
type PackageJsonInfo,
|
||||
} from '../utils/package-json-scanner.js';
|
||||
import { findInCatalog, parseCatalog, type CatalogData } from '../utils/workspace-parser.js';
|
||||
|
||||
interface DepUsage {
|
||||
pkg: string;
|
||||
version: string;
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export class CatalogViolationsRule extends BaseRule<CodeHealthContext> {
|
||||
readonly id = 'catalog-violations';
|
||||
readonly name = 'Catalog Violations';
|
||||
readonly description =
|
||||
'Detect dependencies that should use pnpm catalog references instead of hardcoded versions';
|
||||
readonly severity = 'error' as const;
|
||||
|
||||
async analyze(context: CodeHealthContext): Promise<Violation[]> {
|
||||
const { rootDir } = context;
|
||||
const options = this.getOptions();
|
||||
const workspaceFile = (options.workspaceFile as string) ?? 'pnpm-workspace.yaml';
|
||||
|
||||
const catalogData = parseCatalog(rootDir, workspaceFile);
|
||||
const packageJsonFiles = await findPackageJsonFiles(rootDir);
|
||||
const packages = packageJsonFiles.map(parsePackageJson);
|
||||
|
||||
return [
|
||||
...this.findHardcodedCatalogDeps(packages, catalogData),
|
||||
...this.findVersionMismatches(packages, catalogData),
|
||||
];
|
||||
}
|
||||
|
||||
private findHardcodedCatalogDeps(
|
||||
packages: PackageJsonInfo[],
|
||||
catalogData: CatalogData,
|
||||
): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const pkgInfo of packages) {
|
||||
for (const dep of pkgInfo.deps) {
|
||||
if (dep.usesCatalog || dep.version.startsWith('workspace:')) continue;
|
||||
|
||||
const catalogMatch = findInCatalog(catalogData, dep.name);
|
||||
if (!catalogMatch.found) continue;
|
||||
|
||||
const catalogRef = catalogMatch.catalogName
|
||||
? `"catalog:${catalogMatch.catalogName}"`
|
||||
: '"catalog:"';
|
||||
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
pkgInfo.filePath,
|
||||
dep.line,
|
||||
5,
|
||||
`${dep.name}@${dep.version} should use ${catalogRef} (exists in pnpm-workspace.yaml${catalogMatch.catalogName ? ` [${catalogMatch.catalogName}]` : ''})`,
|
||||
`Change to "${dep.name}": ${catalogRef}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private findVersionMismatches(
|
||||
packages: PackageJsonInfo[],
|
||||
catalogData: CatalogData,
|
||||
): Violation[] {
|
||||
const hardcodedVersions = new Map<string, DepUsage[]>();
|
||||
|
||||
for (const pkgInfo of packages) {
|
||||
for (const dep of pkgInfo.deps) {
|
||||
if (dep.usesCatalog || dep.version.startsWith('workspace:')) continue;
|
||||
if (findInCatalog(catalogData, dep.name).found) continue;
|
||||
|
||||
if (!hardcodedVersions.has(dep.name)) hardcodedVersions.set(dep.name, []);
|
||||
hardcodedVersions.get(dep.name)!.push({
|
||||
pkg: pkgInfo.packageName,
|
||||
version: dep.version,
|
||||
file: pkgInfo.filePath,
|
||||
line: dep.line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const [depName, usages] of hardcodedVersions) {
|
||||
if (usages.length < 2) continue;
|
||||
|
||||
const uniqueVersions = new Set(usages.map((u) => u.version));
|
||||
if (uniqueVersions.size <= 1) continue;
|
||||
|
||||
for (const usage of usages) {
|
||||
violations.push(
|
||||
this.createViolation(
|
||||
usage.file,
|
||||
usage.line,
|
||||
5,
|
||||
`${depName} appears in ${usages.length} packages with ${uniqueVersions.size} different versions — add to pnpm-workspace.yaml catalog`,
|
||||
`Centralize ${depName} in pnpm-workspace.yaml catalog section`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import fg from 'fast-glob';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface PackageJsonDep {
|
||||
name: string;
|
||||
version: string;
|
||||
line: number;
|
||||
usesCatalog: boolean;
|
||||
section: string;
|
||||
}
|
||||
|
||||
export interface PackageJsonInfo {
|
||||
filePath: string;
|
||||
packageName: string;
|
||||
deps: PackageJsonDep[];
|
||||
}
|
||||
|
||||
export async function findPackageJsonFiles(rootDir: string): Promise<string[]> {
|
||||
return await fg('packages/**/package.json', {
|
||||
cwd: rootDir,
|
||||
absolute: true,
|
||||
ignore: ['**/node_modules/**', '**/dist/**'],
|
||||
});
|
||||
}
|
||||
|
||||
export function parsePackageJson(filePath: string): PackageJsonInfo {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
let pkg: Record<string, unknown>;
|
||||
try {
|
||||
pkg = JSON.parse(content) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { filePath, packageName: path.basename(path.dirname(filePath)), deps: [] };
|
||||
}
|
||||
const packageName = (pkg.name as string) ?? path.basename(path.dirname(filePath));
|
||||
|
||||
const deps: PackageJsonDep[] = [];
|
||||
const sections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
|
||||
for (const section of sections) {
|
||||
const sectionDeps = pkg[section] as Record<string, string> | undefined;
|
||||
if (!sectionDeps || typeof sectionDeps !== 'object') continue;
|
||||
|
||||
for (const [name, version] of Object.entries(sectionDeps)) {
|
||||
deps.push({
|
||||
name,
|
||||
version: typeof version === 'string' ? version : String(version),
|
||||
line: findLineNumber(lines, name, section),
|
||||
usesCatalog: typeof version === 'string' && version.startsWith('catalog:'),
|
||||
section,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { filePath, packageName, deps };
|
||||
}
|
||||
|
||||
function findLineNumber(lines: string[], depName: string, section: string): number {
|
||||
let inSection = false;
|
||||
let braceDepth = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.includes(`"${section}"`)) {
|
||||
inSection = true;
|
||||
braceDepth = 0;
|
||||
}
|
||||
|
||||
if (inSection) {
|
||||
for (const char of line) {
|
||||
if (char === '{') braceDepth++;
|
||||
if (char === '}') braceDepth--;
|
||||
}
|
||||
|
||||
if (line.includes(`"${depName}"`)) return i + 1;
|
||||
|
||||
if (braceDepth <= 0 && inSection && !line.includes(`"${section}"`)) {
|
||||
inSection = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
57
packages/testing/code-health/src/utils/workspace-parser.ts
Normal file
57
packages/testing/code-health/src/utils/workspace-parser.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
export interface CatalogData {
|
||||
default: Record<string, string>;
|
||||
named: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export function parseCatalog(rootDir: string, workspaceFile = 'pnpm-workspace.yaml'): CatalogData {
|
||||
const filePath = path.join(rootDir, workspaceFile);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Workspace file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const workspace = parseYaml(content) as Record<string, unknown> | null;
|
||||
|
||||
if (!workspace || typeof workspace !== 'object') {
|
||||
return { default: {}, named: {} };
|
||||
}
|
||||
|
||||
const result: CatalogData = { default: {}, named: {} };
|
||||
|
||||
if (workspace.catalog && typeof workspace.catalog === 'object') {
|
||||
result.default = workspace.catalog as Record<string, string>;
|
||||
}
|
||||
|
||||
if (workspace.catalogs && typeof workspace.catalogs === 'object') {
|
||||
const catalogs = workspace.catalogs as Record<string, Record<string, string>>;
|
||||
for (const [name, deps] of Object.entries(catalogs)) {
|
||||
if (deps && typeof deps === 'object') {
|
||||
result.named[name] = deps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function findInCatalog(
|
||||
catalogData: CatalogData,
|
||||
depName: string,
|
||||
): { found: boolean; catalogName?: string; version?: string } {
|
||||
if (depName in catalogData.default) {
|
||||
return { found: true, version: catalogData.default[depName] };
|
||||
}
|
||||
|
||||
for (const [catalogName, deps] of Object.entries(catalogData.named)) {
|
||||
if (depName in deps) {
|
||||
return { found: true, catalogName, version: deps[depName] };
|
||||
}
|
||||
}
|
||||
|
||||
return { found: false };
|
||||
}
|
||||
4
packages/testing/code-health/tsconfig.build.json
Normal file
4
packages/testing/code-health/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts"]
|
||||
}
|
||||
20
packages/testing/code-health/tsconfig.json
Normal file
20
packages/testing/code-health/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
3
packages/testing/code-health/vitest.config.ts
Normal file
3
packages/testing/code-health/vitest.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createVitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
export default createVitestConfig();
|
||||
24
packages/testing/rules-engine/eslint.config.mjs
Normal file
24
packages/testing/rules-engine/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(
|
||||
baseConfig,
|
||||
{
|
||||
ignores: ['coverage/**', 'dist/**'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'objectLiteralProperty',
|
||||
format: null,
|
||||
filter: {
|
||||
regex: '^[a-z]+-[a-z-]+$',
|
||||
match: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
31
packages/testing/rules-engine/package.json
Normal file
31
packages/testing/rules-engine/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@n8n/rules-engine",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Lightweight rules engine for registering, running, and reporting rule violations",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
67
packages/testing/rules-engine/src/base-rule.ts
Normal file
67
packages/testing/rules-engine/src/base-rule.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { Severity, Violation, RuleResult, RuleSettings } from './types.js';
|
||||
|
||||
export abstract class BaseRule<TContext = unknown> {
|
||||
abstract readonly id: string;
|
||||
abstract readonly name: string;
|
||||
abstract readonly description: string;
|
||||
abstract readonly severity: Severity;
|
||||
|
||||
private settings: RuleSettings = {};
|
||||
|
||||
configure(settings: RuleSettings): void {
|
||||
this.settings = { ...this.settings, ...settings };
|
||||
}
|
||||
|
||||
getSettings(): RuleSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
getOptions(): Record<string, unknown> {
|
||||
return this.settings.options ?? {};
|
||||
}
|
||||
|
||||
getEffectiveSeverity(): Severity {
|
||||
if (this.settings.severity === 'off') return 'info';
|
||||
if (this.settings.severity) return this.settings.severity;
|
||||
return this.severity;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
if (this.settings.enabled === false) return false;
|
||||
if (this.settings.severity === 'off') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
abstract analyze(context: TContext): Violation[] | Promise<Violation[]>;
|
||||
|
||||
async execute(context: TContext, filesAnalyzed = 0): Promise<RuleResult> {
|
||||
const startTime = performance.now();
|
||||
const violations = await this.analyze(context);
|
||||
const endTime = performance.now();
|
||||
|
||||
return {
|
||||
rule: this.id,
|
||||
violations,
|
||||
filesAnalyzed,
|
||||
executionTimeMs: Math.round((endTime - startTime) * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
protected createViolation(
|
||||
file: string,
|
||||
line: number,
|
||||
column: number,
|
||||
message: string,
|
||||
suggestion?: string,
|
||||
): Violation {
|
||||
return {
|
||||
file,
|
||||
line,
|
||||
column,
|
||||
rule: this.id,
|
||||
message,
|
||||
severity: this.getEffectiveSeverity(),
|
||||
suggestion,
|
||||
};
|
||||
}
|
||||
}
|
||||
118
packages/testing/rules-engine/src/baseline.ts
Normal file
118
packages/testing/rules-engine/src/baseline.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { Violation, Report, Severity } from './types.js';
|
||||
|
||||
const BASELINE_VERSION = 1;
|
||||
|
||||
export interface BaselineEntry {
|
||||
rule: string;
|
||||
line: number;
|
||||
message: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface BaselineFile {
|
||||
version: number;
|
||||
generated: string;
|
||||
totalViolations: number;
|
||||
violations: Record<string, BaselineEntry[]>;
|
||||
}
|
||||
|
||||
function hashViolation(violation: Violation, rootDir: string): string {
|
||||
const relativePath = path.relative(rootDir, violation.file);
|
||||
const content = `${relativePath}:${violation.rule}:${violation.message}`;
|
||||
return crypto.createHash('md5').update(content).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
export function loadBaseline(filePath: string): BaselineFile | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const baseline = JSON.parse(content) as BaselineFile;
|
||||
|
||||
if (baseline.version !== BASELINE_VERSION) {
|
||||
console.warn(
|
||||
`Baseline version mismatch (got ${baseline.version}, expected ${BASELINE_VERSION}). Regenerate baseline.`,
|
||||
);
|
||||
}
|
||||
|
||||
return baseline;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateBaseline(report: Report, rootDir: string): BaselineFile {
|
||||
const violations: Record<string, BaselineEntry[]> = {};
|
||||
|
||||
for (const result of report.results) {
|
||||
for (const violation of result.violations) {
|
||||
const relativePath = path.relative(rootDir, violation.file);
|
||||
if (!violations[relativePath]) violations[relativePath] = [];
|
||||
|
||||
violations[relativePath].push({
|
||||
rule: violation.rule,
|
||||
line: violation.line,
|
||||
message: violation.message,
|
||||
hash: hashViolation(violation, rootDir),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: BASELINE_VERSION,
|
||||
generated: new Date().toISOString(),
|
||||
totalViolations: report.summary.totalViolations,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveBaseline(baseline: BaselineFile, filePath: string): void {
|
||||
fs.writeFileSync(filePath, JSON.stringify(baseline, null, '\t') + '\n');
|
||||
}
|
||||
|
||||
function isInBaseline(violation: Violation, baseline: BaselineFile, rootDir: string): boolean {
|
||||
const relativePath = path.relative(rootDir, violation.file);
|
||||
const fileBaseline = baseline.violations[relativePath];
|
||||
if (!fileBaseline) return false;
|
||||
|
||||
const violationHash = hashViolation(violation, rootDir);
|
||||
return fileBaseline.some((entry) => entry.hash === violationHash);
|
||||
}
|
||||
|
||||
export function filterReportByBaseline(
|
||||
report: Report,
|
||||
baseline: BaselineFile,
|
||||
rootDir: string,
|
||||
): Report {
|
||||
const filteredResults = report.results.map((result) => ({
|
||||
...result,
|
||||
violations: result.violations.filter((v) => !isInBaseline(v, baseline, rootDir)),
|
||||
}));
|
||||
|
||||
let totalViolations = 0;
|
||||
const byRule: Record<string, number> = {};
|
||||
const bySeverity: Record<Severity, number> = { error: 0, warning: 0, info: 0 };
|
||||
|
||||
for (const result of filteredResults) {
|
||||
totalViolations += result.violations.length;
|
||||
byRule[result.rule] = result.violations.length;
|
||||
for (const violation of result.violations) {
|
||||
bySeverity[violation.severity]++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...report,
|
||||
results: filteredResults,
|
||||
summary: {
|
||||
...report.summary,
|
||||
totalViolations,
|
||||
byRule,
|
||||
bySeverity,
|
||||
},
|
||||
};
|
||||
}
|
||||
21
packages/testing/rules-engine/src/index.ts
Normal file
21
packages/testing/rules-engine/src/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export type {
|
||||
Severity,
|
||||
Violation,
|
||||
RuleResult,
|
||||
RuleSettings,
|
||||
RuleSettingsMap,
|
||||
ReportSummary,
|
||||
Report,
|
||||
RuleInfo,
|
||||
} from './types.js';
|
||||
|
||||
export { BaseRule } from './base-rule.js';
|
||||
export { RuleRunner } from './rule-runner.js';
|
||||
export { toJSON } from './reporter.js';
|
||||
export {
|
||||
loadBaseline,
|
||||
saveBaseline,
|
||||
generateBaseline,
|
||||
filterReportByBaseline,
|
||||
} from './baseline.js';
|
||||
export type { BaselineFile, BaselineEntry } from './baseline.js';
|
||||
20
packages/testing/rules-engine/src/reporter.ts
Normal file
20
packages/testing/rules-engine/src/reporter.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import * as path from 'node:path';
|
||||
|
||||
import type { Report } from './types.js';
|
||||
|
||||
export function toJSON(report: Report, rootDir?: string): string {
|
||||
const cleaned = rootDir
|
||||
? {
|
||||
...report,
|
||||
results: report.results.map((result) => ({
|
||||
...result,
|
||||
violations: result.violations.map((v) => ({
|
||||
...v,
|
||||
file: path.relative(rootDir, v.file),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
: report;
|
||||
|
||||
return JSON.stringify(cleaned, null, 2);
|
||||
}
|
||||
80
packages/testing/rules-engine/src/rule-runner.test.ts
Normal file
80
packages/testing/rules-engine/src/rule-runner.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BaseRule } from './base-rule.js';
|
||||
import { RuleRunner } from './rule-runner.js';
|
||||
import type { Violation } from './types.js';
|
||||
|
||||
class TestRule extends BaseRule<{ value: string }> {
|
||||
readonly id = 'test-rule';
|
||||
readonly name = 'Test Rule';
|
||||
readonly description = 'A test rule';
|
||||
readonly severity = 'error' as const;
|
||||
|
||||
analyze(context: { value: string }): Violation[] {
|
||||
if (context.value === 'bad') {
|
||||
return [this.createViolation('test.ts', 1, 1, 'Found bad value')];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
describe('RuleRunner', () => {
|
||||
it('runs registered rules and returns report', async () => {
|
||||
const runner = new RuleRunner<{ value: string }>();
|
||||
runner.registerRule(new TestRule());
|
||||
|
||||
const report = await runner.run({ value: 'bad' }, '/root');
|
||||
|
||||
expect(report.summary.totalViolations).toBe(1);
|
||||
expect(report.results[0].rule).toBe('test-rule');
|
||||
expect(report.results[0].violations[0].message).toBe('Found bad value');
|
||||
});
|
||||
|
||||
it('returns zero violations for clean context', async () => {
|
||||
const runner = new RuleRunner<{ value: string }>();
|
||||
runner.registerRule(new TestRule());
|
||||
|
||||
const report = await runner.run({ value: 'good' }, '/root');
|
||||
|
||||
expect(report.summary.totalViolations).toBe(0);
|
||||
});
|
||||
|
||||
it('respects enableOnly', async () => {
|
||||
const runner = new RuleRunner<{ value: string }>();
|
||||
runner.registerRule(new TestRule());
|
||||
runner.enableOnly(['nonexistent']);
|
||||
|
||||
const report = await runner.run({ value: 'bad' }, '/root');
|
||||
|
||||
expect(report.results).toHaveLength(0);
|
||||
expect(report.rules.disabled).toContain('test-rule');
|
||||
});
|
||||
|
||||
it('applies settings to disable rules', async () => {
|
||||
const runner = new RuleRunner<{ value: string }>();
|
||||
runner.registerRule(new TestRule());
|
||||
runner.applySettings({ 'test-rule': { enabled: false } });
|
||||
|
||||
const report = await runner.run({ value: 'bad' }, '/root');
|
||||
|
||||
expect(report.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('runs a single rule by id', async () => {
|
||||
const runner = new RuleRunner<{ value: string }>();
|
||||
runner.registerRule(new TestRule());
|
||||
|
||||
const report = await runner.runRule('test-rule', { value: 'bad' }, '/root');
|
||||
|
||||
expect(report).not.toBeNull();
|
||||
expect(report!.summary.totalViolations).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null for unknown rule id', async () => {
|
||||
const runner = new RuleRunner<{ value: string }>();
|
||||
|
||||
const report = await runner.runRule('unknown', { value: 'bad' }, '/root');
|
||||
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
});
|
||||
111
packages/testing/rules-engine/src/rule-runner.ts
Normal file
111
packages/testing/rules-engine/src/rule-runner.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type { BaseRule } from './base-rule.js';
|
||||
import type { Report, RuleResult, Severity, RuleInfo, RuleSettingsMap } from './types.js';
|
||||
|
||||
export class RuleRunner<TContext = unknown> {
|
||||
private rules: Map<string, BaseRule<TContext>> = new Map();
|
||||
private enabledRules: Set<string> = new Set();
|
||||
|
||||
registerRule(rule: BaseRule<TContext>): void {
|
||||
this.rules.set(rule.id, rule);
|
||||
this.enabledRules.add(rule.id);
|
||||
}
|
||||
|
||||
applySettings(settings: RuleSettingsMap): void {
|
||||
for (const [ruleId, ruleSettings] of Object.entries(settings)) {
|
||||
const rule = this.rules.get(ruleId);
|
||||
if (rule && ruleSettings) {
|
||||
rule.configure(ruleSettings);
|
||||
if (ruleSettings.enabled === false || ruleSettings.severity === 'off') {
|
||||
this.enabledRules.delete(ruleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableOnly(ruleIds: string[]): void {
|
||||
this.enabledRules.clear();
|
||||
for (const id of ruleIds) {
|
||||
if (this.rules.has(id)) {
|
||||
this.enabledRules.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getEnabledRules(): string[] {
|
||||
return Array.from(this.enabledRules);
|
||||
}
|
||||
|
||||
getDisabledRules(): string[] {
|
||||
return Array.from(this.rules.keys()).filter((id) => !this.enabledRules.has(id));
|
||||
}
|
||||
|
||||
getRuleDetails(): RuleInfo[] {
|
||||
return Array.from(this.rules.values()).map((rule) => ({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
severity: rule.severity,
|
||||
enabled: this.enabledRules.has(rule.id),
|
||||
}));
|
||||
}
|
||||
|
||||
async run(context: TContext, projectRoot: string): Promise<Report> {
|
||||
const results: RuleResult[] = [];
|
||||
|
||||
for (const ruleId of this.enabledRules) {
|
||||
const rule = this.rules.get(ruleId);
|
||||
if (!rule) continue;
|
||||
|
||||
const result = await rule.execute(context);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
rules: {
|
||||
enabled: this.getEnabledRules(),
|
||||
disabled: this.getDisabledRules(),
|
||||
},
|
||||
results,
|
||||
summary: this.buildSummary(results),
|
||||
};
|
||||
}
|
||||
|
||||
async runRule(ruleId: string, context: TContext, projectRoot: string): Promise<Report | null> {
|
||||
const rule = this.rules.get(ruleId);
|
||||
if (!rule) return null;
|
||||
|
||||
const result = await rule.execute(context);
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
rules: {
|
||||
enabled: [ruleId],
|
||||
disabled: Array.from(this.rules.keys()).filter((id) => id !== ruleId),
|
||||
},
|
||||
results: [result],
|
||||
summary: this.buildSummary([result]),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSummary(results: RuleResult[]): Report['summary'] {
|
||||
const byRule: Record<string, number> = {};
|
||||
const bySeverity: Record<Severity, number> = { error: 0, warning: 0, info: 0 };
|
||||
let totalViolations = 0;
|
||||
let filesAnalyzed = 0;
|
||||
|
||||
for (const result of results) {
|
||||
byRule[result.rule] = result.violations.length;
|
||||
totalViolations += result.violations.length;
|
||||
filesAnalyzed += result.filesAnalyzed;
|
||||
|
||||
for (const violation of result.violations) {
|
||||
bySeverity[violation.severity]++;
|
||||
}
|
||||
}
|
||||
|
||||
return { totalViolations, byRule, bySeverity, filesAnalyzed };
|
||||
}
|
||||
}
|
||||
52
packages/testing/rules-engine/src/types.ts
Normal file
52
packages/testing/rules-engine/src/types.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export type Severity = 'error' | 'warning' | 'info';
|
||||
|
||||
export interface RuleSettings {
|
||||
enabled?: boolean;
|
||||
severity?: Severity | 'off';
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type RuleSettingsMap = Record<string, RuleSettings | undefined>;
|
||||
|
||||
export interface Violation {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
rule: string;
|
||||
message: string;
|
||||
severity: Severity;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface RuleResult {
|
||||
rule: string;
|
||||
violations: Violation[];
|
||||
filesAnalyzed: number;
|
||||
executionTimeMs: number;
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
totalViolations: number;
|
||||
byRule: Record<string, number>;
|
||||
bySeverity: Record<Severity, number>;
|
||||
filesAnalyzed: number;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
timestamp: string;
|
||||
projectRoot: string;
|
||||
rules: {
|
||||
enabled: string[];
|
||||
disabled: string[];
|
||||
};
|
||||
results: RuleResult[];
|
||||
summary: ReportSummary;
|
||||
}
|
||||
|
||||
export interface RuleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
severity: Severity;
|
||||
enabled: boolean;
|
||||
}
|
||||
4
packages/testing/rules-engine/tsconfig.build.json
Normal file
4
packages/testing/rules-engine/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts"]
|
||||
}
|
||||
20
packages/testing/rules-engine/tsconfig.json
Normal file
20
packages/testing/rules-engine/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
3
packages/testing/rules-engine/vitest.config.ts
Normal file
3
packages/testing/rules-engine/vitest.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createVitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
export default createVitestConfig();
|
||||
378
pnpm-lock.yaml
378
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue