From d68c0169fd038bc991ab22d70c28d3174ca01305 Mon Sep 17 00:00:00 2001 From: Tomasz Tursa Date: Tue, 16 Dec 2025 23:01:36 +0100 Subject: [PATCH] feat(core): allow extending rulesets with aliases --- .../__fixtures__/aliases/description-check.ts | 25 ++++++++ .../aliases/extended-alias-collision.ts | 14 +++++ .../aliases/extended-definition.ts | 14 +++++ .../__tests__/__fixtures__/aliases/scope.ts | 61 +++++++++++++++++++ .../__fixtures__/aliases/version-check.ts | 25 ++++++++ .../validation/__tests__/validation.test.ts | 10 +++ .../ruleset/validation/validators/alias.ts | 56 ++++++++++++++--- 7 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/description-check.ts create mode 100644 packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/extended-alias-collision.ts create mode 100644 packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/extended-definition.ts create mode 100644 packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/scope.ts create mode 100644 packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/version-check.ts diff --git a/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/description-check.ts b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/description-check.ts new file mode 100644 index 000000000..6122af0ae --- /dev/null +++ b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/description-check.ts @@ -0,0 +1,25 @@ +import { pattern } from '@stoplight/spectral-functions'; +import { DiagnosticSeverity } from '@stoplight/types'; +import { RulesetDefinition } from '@stoplight/spectral-core'; + +export { ruleset as default }; + +const ruleset: RulesetDefinition = { + aliases: { + infoSection: ['$.info.section'], + }, + rules: { + 'check-description': { + message: 'API version must be 1.0.0', + given: '#infoSection', + severity: DiagnosticSeverity.Error, + then: { + field: 'description', + function: pattern, + functionOptions: { + match: 'Stoplight', + }, + }, + }, + }, +}; diff --git a/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/extended-alias-collision.ts b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/extended-alias-collision.ts new file mode 100644 index 000000000..35d2d728b --- /dev/null +++ b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/extended-alias-collision.ts @@ -0,0 +1,14 @@ +import { RulesetDefinition } from '@stoplight/spectral-core'; + +import _scope from './scope'; +import _desc from './description-check'; + + +export { ruleset as default }; + +const ruleset: RulesetDefinition = { + extends: [ + _scope, + _desc, + ], +}; diff --git a/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/extended-definition.ts b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/extended-definition.ts new file mode 100644 index 000000000..c27f43efc --- /dev/null +++ b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/extended-definition.ts @@ -0,0 +1,14 @@ +import { RulesetDefinition } from '@stoplight/spectral-core'; + +import _scope from './scope'; +import _version from './version-check'; + + +export { ruleset as default }; + +const ruleset: RulesetDefinition = { + extends: [ + _scope, + _version, + ], +}; diff --git a/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/scope.ts b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/scope.ts new file mode 100644 index 000000000..c36b6a9fc --- /dev/null +++ b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/scope.ts @@ -0,0 +1,61 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import { falsy, pattern, truthy } from '@stoplight/spectral-functions'; +import { RulesetDefinition } from '@stoplight/spectral-core'; + +export { ruleset as default }; + +const ruleset: RulesetDefinition = { + aliases: { + Stoplight: ['$..stoplight'], + }, + overrides: [ + { + files: ['*.yaml'], + rules: { + 'value-matches-stoplight': { + message: 'Value must contain Stoplight', + given: '#Stoplight', + severity: DiagnosticSeverity.Error, + then: { + field: 'description', + function: pattern, + functionOptions: { + match: 'Stoplight', + }, + }, + }, + }, + }, + { + files: ['**/*.json'], + aliases: { + Value: ['$..value'], + }, + rules: { + 'truthy-stoplight-property': { + message: 'Value must contain Stoplight', + given: '#Value', + severity: DiagnosticSeverity.Error, + then: { + function: truthy, + }, + }, + }, + }, + { + files: ['legacy/**/*.json'], + aliases: { + Value: ['$..value'], + }, + rules: { + 'falsy-value': { + given: '#Value', + severity: DiagnosticSeverity.Warning, + then: { + function: falsy, + }, + }, + }, + }, + ], +}; diff --git a/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/version-check.ts b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/version-check.ts new file mode 100644 index 000000000..820e0466c --- /dev/null +++ b/packages/core/src/ruleset/validation/__tests__/__fixtures__/aliases/version-check.ts @@ -0,0 +1,25 @@ +import { pattern } from '@stoplight/spectral-functions'; +import { DiagnosticSeverity } from '@stoplight/types'; +import { RulesetDefinition } from '@stoplight/spectral-core'; + +export { ruleset as default }; + +const ruleset: RulesetDefinition = { + aliases: { + infoSection: ['$.info'], + }, + rules: { + 'check-initial-version': { + message: 'API version must be 1.0.0', + given: '#infoSection', + severity: DiagnosticSeverity.Error, + then: { + field: 'version', + function: pattern, + functionOptions: { + match: '^1\\.0\\.0$', + }, + }, + }, + }, +}; diff --git a/packages/core/src/ruleset/validation/__tests__/validation.test.ts b/packages/core/src/ruleset/validation/__tests__/validation.test.ts index 59a88f911..3bd4b7350 100644 --- a/packages/core/src/ruleset/validation/__tests__/validation.test.ts +++ b/packages/core/src/ruleset/validation/__tests__/validation.test.ts @@ -4,6 +4,8 @@ import { assertValidRuleset, RulesetValidationError } from '../index'; import AggregateError = require('es-aggregate-error'); import invalidRuleset from './__fixtures__/invalid-ruleset'; import validRuleset from './__fixtures__/valid-flat-ruleset'; +import extendedRuleset from './__fixtures__/aliases/extended-definition'; +import aliasCollisionRuleset from './__fixtures__/aliases/extended-alias-collision'; import type { Format } from '../../format'; import { RulesetDefinition, RulesetOverridesDefinition } from '../../types'; @@ -63,6 +65,14 @@ describe('JS Ruleset Validation', () => { expect(assertValidRuleset.bind(null, validRuleset)).not.toThrow(); }); + it('given valid ruleset extending ruleset with alias should, emits no errors', () => { + expect(assertValidRuleset.bind(null, extendedRuleset)).not.toThrow(); + }); + + it('given valid ruleset extending rulesets with alias collision should, emits no errors', () => { + expect(assertValidRuleset.bind(null, aliasCollisionRuleset)).not.toThrow(); + }); + it.each([false, 2, null, 'foo', '12.foo.com'])( 'given invalid %s documentationUrl in a rule, throws', documentationUrl => { diff --git a/packages/core/src/ruleset/validation/validators/alias.ts b/packages/core/src/ruleset/validation/validators/alias.ts index e7de053a1..3dd49e88e 100644 --- a/packages/core/src/ruleset/validation/validators/alias.ts +++ b/packages/core/src/ruleset/validation/validators/alias.ts @@ -16,8 +16,54 @@ function getOverrides(overrides: unknown, key: string): Record return isPlainObject(actualOverrides) && isPlainObject(actualOverrides.aliases) ? actualOverrides.aliases : null; } +function getExtended(extended: Record, parsedPath: string[]): Record | null { + if (!Array.isArray(extended)) return null; + + const key = parsedPath[1]; + const index = Number(key); + if (Number.isNaN(index)) return null; + if (index < 0 && index >= extended.length) return null; + + const actualExtended: Record = extended[index] as Record; + const aliases = + isPlainObject(actualExtended) && isPlainObject(actualExtended.aliases) ? actualExtended.aliases : null; + + if (parsedPath.length >= 4 && parsedPath[2] === 'overrides') { + return { + ...aliases, + ...getOverrides(actualExtended.overrides, parsedPath[3]), + }; + } + + return aliases; +} + +function getResolvedAliases( + parsedPath: string[], + ruleset: { + aliases?: Record; + overrides?: Record; + extends?: Record; + }, +) { + if (parsedPath[0] === 'extends') { + return getExtended(ruleset.extends as Record, parsedPath); + } else if (parsedPath[0] === 'overrides') { + return { + ...ruleset.aliases, + ...getOverrides(ruleset.overrides, parsedPath[1]), + }; + } else { + return ruleset.aliases; + } +} + export function validateAlias( - ruleset: { aliases?: Record; overrides?: Record }, + ruleset: { + aliases?: Record; + overrides?: Record; + extends?: Record; + }, alias: string, path: string, ): Error | void { @@ -26,13 +72,7 @@ export function validateAlias( try { const formats: unknown = get(ruleset, [...parsedPath.slice(0, parsedPath.indexOf('rules') + 2), 'formats']); - const aliases = - parsedPath[0] === 'overrides' - ? { - ...ruleset.aliases, - ...getOverrides(ruleset.overrides, parsedPath[1]), - } - : ruleset.aliases; + const aliases = getResolvedAliases(parsedPath, ruleset); resolveAlias(aliases ?? null, alias, Array.isArray(formats) ? new Formats(formats) : null); } catch (ex) {