diff --git a/.changelog/20250707143504_ck_18777_add_rule_to_validate_changelog_entry.md b/.changelog/20250707143504_ck_18777_add_rule_to_validate_changelog_entry.md new file mode 100644 index 0000000..3285989 --- /dev/null +++ b/.changelog/20250707143504_ck_18777_add_rule_to_validate_changelog_entry.md @@ -0,0 +1,7 @@ +--- +type: Feature +scope: eslint-plugin-ckeditor5-rules +closes: https://github.com/ckeditor/ckeditor5/issues/18777 +--- + +Add the `validate-changelog-entry` rule for validating Markdown-based changelog entries. diff --git a/.circleci/config.yml b/.circleci/config.yml index 6a08787..9ab18a0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -80,7 +80,8 @@ commands: jobs: notify_ci_failure: - machine: true + docker: + - image: cimg/node:22.12.0 parameters: hideAuthor: type: string @@ -108,7 +109,8 @@ jobs: no_output_timeout: 1h validate_and_tests: - machine: true + docker: + - image: cimg/node:22.12.0 steps: - checkout - bootstrap_repository_command @@ -120,7 +122,8 @@ jobs: command: yarn run test release_prepare: - machine: true + docker: + - image: cimg/node:22.12.0 steps: - checkout - bootstrap_repository_command @@ -129,7 +132,8 @@ jobs: command: yarn run release:prepare-packages --verbose --compile-only trigger_release_process: - machine: true + docker: + - image: cimg/node:22.12.0 steps: - checkout - bootstrap_repository_command @@ -153,7 +157,8 @@ jobs: command: yarn ckeditor5-dev-ci-trigger-circle-build release_project: - machine: true + docker: + - image: cimg/node:22.12.0 steps: - checkout - bootstrap_repository_command diff --git a/eslint.config.mjs b/eslint.config.mjs index a4044ae..b7bfb4d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,11 +3,18 @@ * For licensing, see LICENSE.md. */ +import fs from 'fs'; +import path from 'path'; import globals from 'globals'; import { defineConfig } from 'eslint/config'; import ckeditor5Config from 'eslint-config-ckeditor5'; import ckeditor5Rules from 'eslint-plugin-ckeditor5-rules'; +const projectPackages = fs + .readdirSync( path.resolve( import.meta.dirname, 'packages' ), { withFileTypes: true } ) + .filter( dirent => dirent.isDirectory() ) + .map( dirent => dirent.name ); + export default defineConfig( [ { extends: ckeditor5Config, @@ -40,5 +47,21 @@ export default defineConfig( [ ] } ] } + }, + { + files: [ '.changelog/**/*.md' ], + plugins: { + 'ckeditor5-rules': ckeditor5Rules + }, + language: 'markdown/gfm', + languageOptions: { + frontmatter: 'yaml' + }, + rules: { + 'ckeditor5-rules/validate-changelog-entry': [ 'error', { + allowedScopes: projectPackages, + repositoryType: 'mono' + } ] + } } ] ); diff --git a/packages/eslint-config-ckeditor5/eslint.config.mjs b/packages/eslint-config-ckeditor5/eslint.config.mjs index 724edd8..26876be 100644 --- a/packages/eslint-config-ckeditor5/eslint.config.mjs +++ b/packages/eslint-config-ckeditor5/eslint.config.mjs @@ -7,6 +7,7 @@ import globals from 'globals'; import { defineConfig } from 'eslint/config'; import js from '@eslint/js'; import ts from 'typescript-eslint'; +import markdown from '@eslint/markdown'; import mocha from 'eslint-plugin-mocha'; import stylistic from '@stylistic/eslint-plugin'; import ckeditor5Rules from 'eslint-plugin-ckeditor5-rules'; @@ -24,6 +25,8 @@ const rulesGeneral = [ ecmaVersion: 2020, sourceType: 'module' }, + + files: [ '**/*.@(js|ts|tsx)' ], rules: { /* @@ -482,10 +485,27 @@ const rulesDocs = [ } ]; +const rulesChangelog = [ + { + files: [ '**/*.md' ], + + plugins: { + markdown + }, + + language: 'markdown/gfm', + + languageOptions: { + frontmatter: 'yaml' + } + } +]; + export default defineConfig( [ rulesGeneral, rulesTypeScript, rulesSourceCode, rulesTests, - rulesDocs + rulesDocs, + rulesChangelog ] ); diff --git a/packages/eslint-config-ckeditor5/package.json b/packages/eslint-config-ckeditor5/package.json index ff94f6a..fbaae54 100644 --- a/packages/eslint-config-ckeditor5/package.json +++ b/packages/eslint-config-ckeditor5/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@eslint/js": "^9.26.0", + "@eslint/markdown": "^6.6.0", "@stylistic/eslint-plugin": "^4.2.0", "eslint-plugin-ckeditor5-rules": "^11.0.1", "eslint-plugin-mocha": "^11.0.0", diff --git a/packages/eslint-plugin-ckeditor5-rules/lib/index.js b/packages/eslint-plugin-ckeditor5-rules/lib/index.js index 9618093..2a5b282 100644 --- a/packages/eslint-plugin-ckeditor5-rules/lib/index.js +++ b/packages/eslint-plugin-ckeditor5-rules/lib/index.js @@ -23,6 +23,7 @@ module.exports = { 'allow-svg-imports-only-in-icons-package': require( './rules/allow-svg-imports-only-in-icons-package.js' ), 'prevent-license-key-leak': require( './rules/prevent-license-key-leak' ), 'require-as-const-returns-in-methods': require( './rules/require-as-const-returns-in-methods' ), - 'require-file-extensions-in-imports': require( './rules/require-file-extensions-in-imports' ) + 'require-file-extensions-in-imports': require( './rules/require-file-extensions-in-imports' ), + 'validate-changelog-entry': require( './rules/validate-changelog-entry' ) } }; diff --git a/packages/eslint-plugin-ckeditor5-rules/lib/rules/validate-changelog-entry.js b/packages/eslint-plugin-ckeditor5-rules/lib/rules/validate-changelog-entry.js new file mode 100644 index 0000000..a4cca89 --- /dev/null +++ b/packages/eslint-plugin-ckeditor5-rules/lib/rules/validate-changelog-entry.js @@ -0,0 +1,308 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +const yaml = require( 'yaml' ); + +const ALLOWED_TYPES = { + single: [ + 'feature', + 'fix', + 'other', + 'breaking change' + ], + mono: [ + 'feature', + 'fix', + 'other', + 'major breaking change', + 'minor breaking change' + ] +}; + +const placeholderTexts = [ + 'Required concise and meaningful summary of the change.', + 'Optional additional context or rationale.', + 'Remove if not needed.' +]; + +const ISSUE_SLUG_PATTERN = /^[a-z0-9.-]+\/[a-z0-9.-]+#\d+$/; +const ISSUE_PATTERN = /^\d+$/; +const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/[a-z0-9.-]+\/[a-z0-9.-]+\/issues\/\d+$/; +const NICK_NAME_PATTERN = /^(@?)[a-z0-9-_]+$/i; + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Validate changelog entries.', + category: 'CKEditor5', + url: 'https://ckeditor.com/docs/ckeditor5/latest/framework/contributing/code-style.html#valid-changelog-entries' + }, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + allowedScopes: { + type: 'array', + items: { + type: 'string' + } + }, + + repositoryType: { + type: 'string', + enum: [ 'single', 'mono' ] + } + }, + required: [ 'repositoryType' ] + } + ], + messages: { + 'missingChangeData': 'Changelog entry must include a YAML frontmatter.', + 'missingChangeSummary': 'Changelog entry must include a text summary.', + 'missingTypeField': 'Changelog entry must include a \'type\' field.', + 'invalidField': 'Invalid \'{{ kind }}\' value: \'{{ value }}\'.', + 'scopesInSingleRepository': 'Changelog entry for a single repository must not include the \'scopes\' field.', + 'defaultChangeSummary': 'Replace the default placeholder text with a meaningful summary.' + } + }, + + create( context ) { + const options = context.options[ 0 ]; + let hasFrontmatter = false; + let hasText = false; + + return { + text( node ) { + hasText ||= true; + + if ( placeholderTexts.includes( node.value.trim() ) ) { + return context.report( { + node, + messageId: 'defaultChangeSummary' + } ); + } + }, + + yaml( node ) { + hasFrontmatter ||= true; + + const lineCounter = new yaml.LineCounter(); + const doc = yaml.parseDocument( node.value, { + lineCounter + } ); + + const getKey = key => doc.contents?.items?.find( item => item.key.value === key ); + + [ + ...validateType( getKey( 'type' ), options ), + ...validateScopes( getKey( 'scope' ), options ), + ...validateCloses( getKey( 'closes' ) ), + ...validateSee( getKey( 'see' ) ), + ...validateCommunityCredits( getKey( 'communityCredits' ) ) + ] + .forEach( ( { range, ...error } ) => { + const lineOffset = node.position.start.line; + const start = lineCounter.linePos( range[ 0 ] ); + const end = lineCounter.linePos( range[ 1 ] ); + + context.report( { + ...error, + loc: { + start: { + line: lineOffset + start.line, + column: start.col + }, + end: { + line: lineOffset + end.line, + column: end.col + } + } + } ); + } ); + }, + + 'root:exit'( node ) { + if ( !hasFrontmatter ) { + // If frontmatter data is missing, display an error at the beginning of the file. + context.report( { + node: context.getSourceCode().ast, + messageId: 'missingChangeData', + loc: { + start: { + line: node.position.start.line, + column: node.position.start.column + }, + end: { + line: node.position.start.line, + column: node.position.start.column + } + } + } ); + } + + if ( !hasText ) { + // If text summary is missing, display an error at the end of the file. + context.report( { + node: context.getSourceCode().ast, + messageId: 'missingChangeSummary', + loc: { + start: { + line: node.position.end.line, + column: node.position.end.column + }, + end: { + line: node.position.end.line, + column: node.position.end.column + } + } + } ); + } + } + }; + } +}; + +/** + * Validates the "type" field of a changelog entry. + */ +function validateType( type, options ) { + if ( !type ) { + return [ + { + messageId: 'missingTypeField', + range: [ 0, 0 ] + } + ]; + } + + const { source, range } = type.value; + + if ( ALLOWED_TYPES[ options.repositoryType ].includes( source.toLowerCase() ) ) { + return []; + } + + return [ + { + messageId: 'invalidField', + data: { + kind: 'type', + value: source + }, + range + } + ]; +} + +/** + * Validates the "scopes" field of a changelog entry. + */ +function validateScopes( scopes, options ) { + if ( !scopes ) { + return []; + } + + const normalized = toArray( scopes ).filter( scope => !!scope.source ); + + if ( normalized.length && options.repositoryType === 'single' ) { + return [ + { + messageId: 'scopesInSingleRepository', + range: scopes.key.range + } + ]; + } + + return normalized.reduce( ( errors, scope ) => { + if ( !options.allowedScopes.includes( scope.source ) ) { + errors.push( { + messageId: 'invalidField', + data: { + kind: 'scope', + value: scope.source + }, + range: scope.range + } ); + } + + return errors; + }, [] ); +} + +/** + * Validates field containing issue in a changelog entry. + */ +function validateIssue( issue, kind ) { + if ( !issue ) { + return []; + } + + return toArray( issue ) + .filter( issue => !!issue.source ) + .reduce( ( errors, issue ) => { + if ( !ISSUE_SLUG_PATTERN.test( issue ) && !ISSUE_PATTERN.test( issue ) && !ISSUE_URL_PATTERN.test( issue ) ) { + errors.push( { + messageId: 'invalidField', + data: { + kind, + value: issue.source + }, + range: issue.range + } ); + } + + return errors; + }, [] ); +} + +/** + * Validates the "closes" field of a changelog entry. + */ +function validateCloses( issue ) { + return validateIssue( issue, 'closes' ); +} + +/** + * Validates the "see" field of a changelog entry. + */ +function validateSee( issue ) { + return validateIssue( issue, 'see' ); +} + +/** + * Validates the "communityCredits" field of a changelog entry. + */ +function validateCommunityCredits( communityCredits ) { + if ( !communityCredits ) { + return []; + } + + return toArray( communityCredits ) + .filter( issue => !!issue.source ) + .reduce( ( errors, credit ) => { + if ( !NICK_NAME_PATTERN.test( credit ) ) { + errors.push( { + messageId: 'invalidField', + data: { + kind: 'communityCredits', + value: credit.source + }, + range: credit.range + } ); + } + + return errors; + }, [] ); +} + +/** + * Ensures that the given pair is an array. + */ +function toArray( pair ) { + return pair.value.items ?? [ pair.value ]; +} diff --git a/packages/eslint-plugin-ckeditor5-rules/package.json b/packages/eslint-plugin-ckeditor5-rules/package.json index 41dfc49..85c6f8c 100644 --- a/packages/eslint-plugin-ckeditor5-rules/package.json +++ b/packages/eslint-plugin-ckeditor5-rules/package.json @@ -33,9 +33,12 @@ "fs-extra": "^11.1.1", "resolve.exports": "^2.0.2", "upath": "^2.0.1", - "validate-npm-package-name": "^6.0.0" + "validate-npm-package-name": "^6.0.0", + "yaml": "^2.8.0" }, "devDependencies": { + "@eslint/markdown": "^6.6.0", + "dedent": "^1.6.0", "eslint": "^9.26.0", "glob": "^11.0.2", "lodash": "^4.17.21", diff --git a/packages/eslint-plugin-ckeditor5-rules/tests/rules/validate-changelog-entry.mjs b/packages/eslint-plugin-ckeditor5-rules/tests/rules/validate-changelog-entry.mjs new file mode 100644 index 0000000..6081470 --- /dev/null +++ b/packages/eslint-plugin-ckeditor5-rules/tests/rules/validate-changelog-entry.mjs @@ -0,0 +1,528 @@ +/** + * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +import rule from '../../lib/rules/validate-changelog-entry.js'; +import dedent from 'dedent'; +import { RuleTester } from 'eslint'; +import markdown from '@eslint/markdown'; + +const ruleTester = new RuleTester( { + plugins: { + markdown + }, + language: 'markdown/gfm', + languageOptions: { + frontmatter: 'yaml' + } +} ); + +ruleTester.run( 'eslint-plugin-ckeditor5-rules/validate-changelog-entry', rule, { + valid: [ + // Single repository with only the "type" field. + { + code: dedent` + --- + type: feature + --- + + Change summary. + `, + options: [ { repositoryType: 'single' } ] + }, + + // Mono repository with only the "type" field. + { + code: dedent` + --- + type: feature + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + + // Uses "type: fix". + { + code: dedent` + --- + type: fix + --- + + Change summary. + `, + options: [ { repositoryType: 'single' } ] + }, + { + code: dedent` + --- + type: fix + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + + // Uses "type: other". + { + code: dedent` + --- + type: other + --- + + Change summary. + `, + options: [ { repositoryType: 'single' } ] + }, + { + code: dedent` + --- + type: other + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + + // Uses "type: breaking change" in single repository. + { + code: dedent` + --- + type: breaking change + --- + + Change summary. + `, + options: [ { repositoryType: 'single' } ] + }, + + // Uses "type: major breaking change" in mono repository. + { + code: dedent` + --- + type: major breaking change + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + + // Uses "type: minor breaking change" in mono repository. + { + code: dedent` + --- + type: minor breaking change + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + + // Uses allowed "scope" field. + { + code: dedent` + --- + type: feature + scope: + --- + + Change summary. + `, + options: [ { repositoryType: 'mono', allowedScopes: [ 'test' ] } ] + }, + { + code: dedent` + --- + type: feature + scope: + - + --- + + Change summary. + `, + options: [ { repositoryType: 'mono', allowedScopes: [ 'test' ] } ] + }, + { + code: dedent` + --- + type: feature + scope: test + --- + + Change summary. + `, + options: [ { repositoryType: 'mono', allowedScopes: [ 'test' ] } ] + }, + { + code: dedent` + --- + type: feature + scope: + - test + - test2 + --- + + Change summary. + `, + options: [ { repositoryType: 'mono', allowedScopes: [ 'test', 'test2' ] } ] + }, + + // Uses valid "closes" field. + { + code: dedent` + --- + type: feature + closes: + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + { + code: dedent` + --- + type: feature + closes: + - + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + { + code: dedent` + --- + type: feature + closes: 123 + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + { + code: dedent` + --- + type: feature + closes: + - 123 + - ckeditor/ckeditor5#123 + - https://github.com/ckeditor/ckeditor5/issues/18777 + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + + // Uses valid "see" field. + { + code: dedent` + --- + type: feature + see: + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + { + code: dedent` + --- + type: feature + see: + - + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + { + code: dedent` + --- + type: feature + see: 123 + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + { + code: dedent` + --- + type: feature + see: + - 123 + - ckeditor/ckeditor5#123 + - https://github.com/ckeditor/ckeditor5/issues/18777 + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + + // Uses valid "communityCredits" field. + { + code: dedent` + --- + type: feature + communityCredits: + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + { + code: dedent` + --- + type: feature + communityCredits: + - + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + { + code: dedent` + --- + type: feature + communityCredits: user + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + { + code: dedent` + --- + type: feature + communityCredits: + - user1 + - user2 + - user3 + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ] + }, + ], + + invalid: [ + // Empty file. + { + code: dedent``, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Changelog entry must include a YAML frontmatter.', + 'Changelog entry must include a text summary.' + ] + }, + + // Missing frontmatter. + { + code: dedent`Changelog summary`, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Changelog entry must include a YAML frontmatter.' + ] + }, + + // Empty frontmatter. + { + code: dedent` + --- + --- + Changelog summary + `, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Changelog entry must include a \'type\' field.' + ] + }, + + // Missing text summary. + { + code: dedent` + --- + type: feature + --- + `, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Changelog entry must include a text summary.' + ] + }, + + // Default text summary. + { + code: dedent` + --- + type: feature + --- + + Required concise and meaningful summary of the change. + + Optional additional context or rationale. **Remove if not needed.** + `, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Replace the default placeholder text with a meaningful summary.', + 'Replace the default placeholder text with a meaningful summary.', + 'Replace the default placeholder text with a meaningful summary.' + ] + }, + + // Invalid "type" field. + { + code: dedent` + --- + type: test + --- + Change summary. + `, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Invalid \'type\' value: \'test\'.' + ] + }, + + // Uses "type: breaking change" in mono repository. + { + code: dedent` + --- + type: breaking change + --- + + Change summary. + `, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Invalid \'type\' value: \'breaking change\'.' + ] + }, + + // Uses "type: major breaking change" in single repository. + { + code: dedent` + --- + type: major breaking change + --- + + Change summary. + `, + options: [ { repositoryType: 'single' } ], + errors: [ + 'Invalid \'type\' value: \'major breaking change\'.' + ] + }, + + // Uses "type: minor breaking change" in single repository. + { + code: dedent` + --- + type: minor breaking change + --- + + Change summary. + `, + options: [ { repositoryType: 'single' } ], + errors: [ + 'Invalid \'type\' value: \'minor breaking change\'.' + ] + }, + + // Scope in single repository. + { + code: dedent` + --- + type: feature + scope: test + --- + Change summary. + `, + options: [ { repositoryType: 'single' } ], + errors: [ + 'Changelog entry for a single repository must not include the \'scopes\' field.' + ] + }, + + // Invalid "scope" field. + { + code: dedent` + --- + type: feature + scope: test + --- + Change summary. + `, + options: [ { repositoryType: 'mono', allowedScopes: [ 'allowed' ] } ], + errors: [ + 'Invalid \'scope\' value: \'test\'.' + ] + }, + + // Invalid "closes" field. + { + code: dedent` + --- + type: feature + closes: test + --- + Change summary. + `, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Invalid \'closes\' value: \'test\'.' + ] + }, + + // Invalid "see" field. + { + code: dedent` + --- + type: feature + see: test + --- + Change summary. + `, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Invalid \'see\' value: \'test\'.' + ] + }, + + // Invalid "communityCredits" field. + { + code: dedent` + --- + type: feature + communityCredits: %^&* + --- + Change summary. + `, + options: [ { repositoryType: 'mono' } ], + errors: [ + 'Invalid \'communityCredits\' value: \'%^&*\'.' + ] + }, + ] +} );