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:
Declan Carroll 2026-03-31 12:20:11 +01:00 committed by GitHub
parent 3dde7e16f8
commit 3922984b74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1515 additions and 168 deletions

View 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,
},
},
],
},
},
);

View 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"
}
}

View 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);
});

View 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;
}

View file

@ -0,0 +1,3 @@
export interface CodeHealthContext {
rootDir: string;
}

View 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;
}

View file

@ -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);
});
});

View file

@ -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;
}
}

View file

@ -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;
}

View 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 };
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["dist", "node_modules", "**/*.test.ts"]
}

View 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"]
}

View file

@ -0,0 +1,3 @@
import { createVitestConfig } from '@n8n/vitest-config/node';
export default createVitestConfig();

View 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,
},
},
],
},
},
);

View 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:"
}
}

View 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,
};
}
}

View 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,
},
};
}

View 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';

View 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);
}

View 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();
});
});

View 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 };
}
}

View 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;
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["dist", "node_modules", "**/*.test.ts"]
}

View 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"]
}

View file

@ -0,0 +1,3 @@
import { createVitestConfig } from '@n8n/vitest-config/node';
export default createVitestConfig();

File diff suppressed because it is too large Load diff