diff --git a/src/main.ts b/src/main.ts index b5fac37..71cbe2e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,7 @@ import {preferGetOrInsert} from './rules/prefer-get-or-insert.js'; import {preferCharCodeAtInLoop} from './rules/prefer-charcode-at-in-loop.js'; import {preferSliceOverSplitIndex} from './rules/prefer-slice-over-split-index.js'; import {preferThrowIfNoEntry} from './rules/prefer-throw-if-no-entry.js'; +import {baselineTarget} from './rules/baseline-target.js'; const plugin: ESLint.Plugin = { meta: { @@ -71,7 +72,8 @@ const plugin: ESLint.Plugin = { preferCharCodeAtInLoop as never as Rule.RuleModule, 'prefer-slice-over-split-index': preferSliceOverSplitIndex, 'prefer-throw-if-no-entry': preferThrowIfNoEntry, - 'ban-dependencies': banDependencies + 'ban-dependencies': banDependencies, + 'baseline-target': baselineTarget as never as Rule.RuleModule } }; diff --git a/src/rules/baseline-target.test.ts b/src/rules/baseline-target.test.ts new file mode 100644 index 0000000..194b930 --- /dev/null +++ b/src/rules/baseline-target.test.ts @@ -0,0 +1,85 @@ +import {RuleTester} from 'eslint'; +import {RuleTester as TSRuleTester} from '@typescript-eslint/rule-tester'; +import {baselineTarget} from './baseline-target.js'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..' +); +const typedRuleTester = new TSRuleTester({ + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ['*.ts'], + defaultProject: './tsconfig.json' + }, + tsconfigRootDir: rootDir + } + } +}); +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module' + } +}); + +ruleTester.run('baseline-target', baselineTarget as never, { + valid: ['const x = a ?? b;', 'x = 1;'], + invalid: [ + { + code: 'new URLPattern();', + errors: [ + { + messageId: 'exceedsYear', + data: {feature: 'urlpattern', year: 2025, floor: 2023}, + line: 1, + column: 5, + endLine: 1, + endColumn: 15 + } + ] + }, + { + code: 'Temporal.Now;', + errors: [ + { + messageId: 'notBaseline', + data: {feature: 'temporal'}, + line: 1, + column: 1, + endLine: 1, + endColumn: 9 + } + ] + }, + { + code: 'const a = 1;\nnew URLPattern();\nnew URLPattern();', + errors: [ + {messageId: 'exceedsYear', line: 2, column: 5}, + {messageId: 'exceedsYear', line: 3, column: 5} + ] + } + ] +}); + +typedRuleTester.run('baseline-target (typed)', baselineTarget, { + valid: ['const x = [1, 2, 3].at(-1);'], + invalid: [ + { + code: 'const {promise} = Promise.withResolvers();', + errors: [ + { + messageId: 'exceedsYear', + data: {feature: 'promise-withresolvers', year: 2024, floor: 2023}, + line: 1, + column: 27, + endLine: 1, + endColumn: 40 + } + ] + } + ] +}); diff --git a/src/rules/baseline-target.ts b/src/rules/baseline-target.ts new file mode 100644 index 0000000..670e765 --- /dev/null +++ b/src/rules/baseline-target.ts @@ -0,0 +1,75 @@ +import {createRequire} from 'node:module'; +import path from 'node:path'; +import type {TSESLint} from '@typescript-eslint/utils'; +import { + baselineStatusOf, + detectFeaturesForSource, + yearOf +} from 'baseline-detector'; +import {tryGetTypedParserServices} from '../utils/typescript.js'; + +type MessageIds = 'notBaseline' | 'exceedsYear'; + +const BASELINE_YEAR = 2023; + +export const baselineTarget: TSESLint.RuleModule = { + meta: { + type: 'problem', + docs: { + description: `Disallow features newer than Baseline ${BASELINE_YEAR}` + }, + schema: [], + messages: { + notBaseline: '`{{feature}}` is not Baseline yet', + exceedsYear: + '`{{feature}}` is Baseline {{year}}, above the {{floor}} floor' + } + }, + defaultOptions: [], + create(context) { + const services = tryGetTypedParserServices(context); + + return { + Program() { + const features = detectFeaturesForSource(context.sourceCode.text, { + fileName: context.filename, + ...(services && { + typescriptContext: { + ts: createRequire(path.join(context.cwd, 'package.json'))( + 'typescript' + ), + program: services.program, + checker: services.program.getTypeChecker() + } + }) + }); + + for (const feature of features.get(context.filename) ?? []) { + const status = baselineStatusOf(feature.id); + const year = yearOf(feature.id); + + for (const [start, end] of feature.ranges) { + const loc = { + start: context.sourceCode.getLocFromIndex(start), + end: context.sourceCode.getLocFromIndex(end) + }; + + if (status === false) { + context.report({ + loc, + messageId: 'notBaseline', + data: {feature: feature.id} + }); + } else if (year !== null && year > BASELINE_YEAR) { + context.report({ + loc, + messageId: 'exceedsYear', + data: {feature: feature.id, year, floor: BASELINE_YEAR} + }); + } + } + } + } + }; + } +};