diff --git a/.changeset/1901.md b/.changeset/1901.md new file mode 100644 index 00000000..d7f348e1 --- /dev/null +++ b/.changeset/1901.md @@ -0,0 +1,10 @@ +--- +'@asyncapi/cli': minor +--- + +feat: add --ruleset flag for custom Spectral rules + +- 5872e92: feat: add --ruleset flag for custom Spectral rules +- 97ec751: fixed the sonar issue + + diff --git a/src/apps/cli/commands/validate.ts b/src/apps/cli/commands/validate.ts index 24215db6..8d5e4a6b 100644 --- a/src/apps/cli/commands/validate.ts +++ b/src/apps/cli/commands/validate.ts @@ -57,6 +57,7 @@ export default class Validate extends Command { ...flags, suppressWarnings: flags['suppressWarnings'], suppressAllWarnings: flags['suppressAllWarnings'], + ruleset: flags['ruleset'], }; const result = await this.validationService.validateDocument( diff --git a/src/apps/cli/internal/flags/validate.flags.ts b/src/apps/cli/internal/flags/validate.flags.ts index 95a2268a..4f3fb2d2 100644 --- a/src/apps/cli/internal/flags/validate.flags.ts +++ b/src/apps/cli/internal/flags/validate.flags.ts @@ -24,5 +24,10 @@ export const validateFlags = () => { required: false, default: false, }), + ruleset: Flags.string({ + description: + 'Path to custom Spectral ruleset file (.js, .mjs, or .cjs)', + required: false, + }), }; }; diff --git a/src/domains/services/validation.service.ts b/src/domains/services/validation.service.ts index 1d0dffcf..689c1799 100644 --- a/src/domains/services/validation.service.ts +++ b/src/domains/services/validation.service.ts @@ -12,7 +12,8 @@ import { OpenAPISchemaParser } from '@asyncapi/openapi-schema-parser'; import { DiagnosticSeverity, Parser } from '@asyncapi/parser/cjs'; import { RamlDTSchemaParser } from '@asyncapi/raml-dt-schema-parser'; import { ProtoBuffSchemaParser } from '@asyncapi/protobuf-schema-parser'; -import { getDiagnosticSeverity } from '@stoplight/spectral-core'; +import { getDiagnosticSeverity, RulesetDefinition } from '@stoplight/spectral-core'; +import * as fs from 'node:fs'; import { html, json, @@ -23,7 +24,7 @@ import { text, } from '@stoplight/spectral-formatters'; import { red, yellow, green, cyan } from 'chalk'; -import { promises } from 'fs'; +import { promises } from 'node:fs'; import path from 'path'; import type { Diagnostic } from '@asyncapi/parser/cjs'; @@ -254,6 +255,24 @@ export class ValidationService extends BaseService { } } + /** + * Load a custom Spectral ruleset from file + */ + async loadCustomRuleset(rulesetPath: string): Promise { + const absolutePath = path.resolve(process.cwd(), rulesetPath); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`Ruleset file not found: ${absolutePath}`); + } + + if (!rulesetPath.endsWith('.js') && !rulesetPath.endsWith('.mjs') && !rulesetPath.endsWith('.cjs')) { + throw new Error(`Only JavaScript ruleset files (.js, .mjs, .cjs) are supported. Provided: ${rulesetPath}`); + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(absolutePath); + } + /** * Validates an AsyncAPI document */ @@ -264,9 +283,25 @@ export class ValidationService extends BaseService { try { const suppressAllWarnings = options.suppressAllWarnings ?? false; const suppressedWarnings = options.suppressWarnings ?? []; + const customRulesetPath = options.ruleset; let activeParser: Parser; - if (suppressAllWarnings || suppressedWarnings.length) { + if (customRulesetPath) { + const customRuleset = await this.loadCustomRuleset(customRulesetPath); + activeParser = new Parser({ + ruleset: customRuleset, + __unstable: { + resolver: { + cache: false, + resolvers: [createHttpWithAuthResolver()], + }, + }, + }); + activeParser.registerSchemaParser(AvroSchemaParser()); + activeParser.registerSchemaParser(OpenAPISchemaParser()); + activeParser.registerSchemaParser(RamlDTSchemaParser()); + activeParser.registerSchemaParser(ProtoBuffSchemaParser()); + } else if (suppressAllWarnings || suppressedWarnings.length) { activeParser = await this.buildAndRegisterCustomParser( specFile, suppressedWarnings, @@ -293,8 +328,15 @@ export class ValidationService extends BaseService { }; return this.createSuccessResult(result); - } catch (error) { - return this.handleServiceError(error); + } catch (error: any) { + let errorMessage = error?.message || error?.toString() || 'Unknown error'; + + if (error?.errors && Array.isArray(error.errors)) { + const errors = error.errors.map((e: any) => e?.message || e?.toString()).join('; '); + errorMessage = `${errorMessage}: ${errors}`; + } + + return this.createErrorResult(errorMessage); } } diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 9068b115..28bbe8ba 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -63,6 +63,7 @@ export interface ValidationOptions { output?: string; suppressWarnings?: string[]; suppressAllWarnings?: boolean; + ruleset?: string; } export interface ValidationResult { diff --git a/test/unit/services/validation.service.test.ts b/test/unit/services/validation.service.test.ts index de65f166..34828707 100644 --- a/test/unit/services/validation.service.test.ts +++ b/test/unit/services/validation.service.test.ts @@ -6,6 +6,47 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; +const spectralFunctionsPath = require.resolve('@stoplight/spectral-functions'); +const customJsRuleset = ` +const { pattern } = require('${spectralFunctionsPath.replace(/\\/g, '/')}'); +module.exports = { + extends: [], + rules: { + 'asyncapi-latest-version': { + description: 'Checks AsyncAPI version', + recommended: true, + severity: 3, + given: '$.asyncapi', + then: { + function: pattern, + functionOptions: { + match: '^2', + }, + }, + }, + }, +}; +`; + +const asyncAPIWithDescription = `{ + "asyncapi": "2.6.0", + "info": { + "title": "Test Service", + "version": "1.0.0", + "description": "A test service description" + }, + "channels": {} +}`; + +const asyncAPIWithoutDescription = `{ + "asyncapi": "2.6.0", + "info": { + "title": "Test Service", + "version": "1.0.0" + }, + "channels": {} +}`; + const validAsyncAPI = `{ "asyncapi": "2.6.0", "info": { @@ -251,4 +292,75 @@ describe('ValidationService', () => { } }); }); + + describe('validateDocument() with custom rulesets', () => { + let tempDir: string; + let jsRulesetPath: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'asyncapi-test-')); + jsRulesetPath = path.join(tempDir, '.spectral.js'); + + await fs.writeFile(jsRulesetPath, customJsRuleset, 'utf8'); + }); + + afterEach(async () => { + try { + await fs.rm(tempDir, { recursive: true }); + } catch (err) { + // Ignore cleanup errors + } + }); + + it('should validate with custom JS ruleset', async () => { + const specFile = new Specification(validAsyncAPI); + const options = { + 'diagnostics-format': 'stylish' as const, + ruleset: jsRulesetPath + }; + + const result = await validationService.validateDocument(specFile, options); + + if (!result.success) { + console.error('Test error:', JSON.stringify(result, null, 2)); + } + expect(result.success).to.equal(true); + if (result.success) { + expect(result.data).to.have.property('status'); + expect(result.data).to.have.property('diagnostics'); + } + }); + + it('should handle non-existent ruleset file', async () => { + const specFile = new Specification(validAsyncAPI); + const options = { + 'diagnostics-format': 'stylish' as const, + ruleset: '/non/existent/path/.spectral.yaml' + }; + + const result = await validationService.validateDocument(specFile, options); + + expect(result.success).to.equal(false); + expect(result.error).to.include('Ruleset file not found'); + }); + + it('should load custom ruleset using loadCustomRuleset method', async () => { + const ruleset = await validationService.loadCustomRuleset(jsRulesetPath); + // eslint-disable-next-line no-unused-expressions + expect(ruleset).to.exist; + }); + + it('should reject unsupported ruleset file types', async () => { + const yamlPath = path.join(tempDir, '.spectral.yaml'); + await fs.writeFile(yamlPath, 'rules: {}', 'utf8'); + + try { + await validationService.loadCustomRuleset(yamlPath); + expect.fail('Should have thrown an error'); + } catch (error: any) { + expect(error.message).to.include('Only JavaScript ruleset files'); + expect(error.message).to.include('.js, .mjs, .cjs'); + } + }); + }); });