From fd81a540af9ba385b7436fcc33df34feb482a143 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:07:06 -0600 Subject: [PATCH 1/4] feat: add baseline-target rule --- src/main.ts | 4 ++- src/rules/baseline-target.test.ts | 23 +++++++++++++ src/rules/baseline-target.ts | 54 +++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/rules/baseline-target.test.ts create mode 100644 src/rules/baseline-target.ts diff --git a/src/main.ts b/src/main.ts index b5fac37..d5e8717 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..26711a0 --- /dev/null +++ b/src/rules/baseline-target.test.ts @@ -0,0 +1,23 @@ +import {RuleTester} from 'eslint'; +import {baselineTarget} from './baseline-target.js'; + +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: {year: 2025, floor: 2023}}] + }, + { + code: 'Temporal.Now;', + errors: [{messageId: 'notBaseline', data: {feature: 'temporal'}}] + } + ] +}); \ No newline at end of file diff --git a/src/rules/baseline-target.ts b/src/rules/baseline-target.ts new file mode 100644 index 0000000..b0597a1 --- /dev/null +++ b/src/rules/baseline-target.ts @@ -0,0 +1,54 @@ +import type {TSESLint} from '@typescript-eslint/utils'; +import { + detectBaselineTargetForFeatures, + detectBaselineYearForFeatures, + detectFeaturesForSource +} from 'baseline-detector'; + +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: 'This file needs Baseline {{year}}, above the {{floor}} floor' + } + }, + defaultOptions: [], + create(context) { + return { + Program(node) { + const features = detectFeaturesForSource(context.sourceCode.text, { + fileName: context.filename + }); + const target = detectBaselineTargetForFeatures(features); + const year = detectBaselineYearForFeatures(features); + // the order of the checks matters, a feature that is not baseline makes year come back null, and so does a file + // with no features at all. + if(target.status === false){ + context.report({ + node, + messageId: 'notBaseline', + data: { feature: target.reason } + }); + return; + } + + if (year !== null && year > BASELINE_YEAR) { + context.report({ + node, + messageId: 'exceedsYear', + data: { year, floor: BASELINE_YEAR } + }); + } + } + }; + } +}; From a5aa6fbdcba89f435dcee59ed850a670f27246b4 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:56:47 -0600 Subject: [PATCH 2/4] feat: pass typescriptContext to baseline-target --- src/rules/baseline-target.test.ts | 30 +++++++++++++++++++++++++++++- src/rules/baseline-target.ts | 25 ++++++++++++++++++++----- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/rules/baseline-target.test.ts b/src/rules/baseline-target.test.ts index 26711a0..f7b49f3 100644 --- a/src/rules/baseline-target.test.ts +++ b/src/rules/baseline-target.test.ts @@ -1,6 +1,24 @@ 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, @@ -20,4 +38,14 @@ ruleTester.run('baseline-target', baselineTarget as never, { errors: [{messageId: 'notBaseline', data: {feature: 'temporal'}}] } ] -}); \ No newline at end of file +}); + +typedRuleTester.run('baseline-target (typed)', baselineTarget, { + valid: ['const x = [1, 2, 3].at(-1);'], + invalid: [ + { + code: 'const {promise} = Promise.withResolvers();', + errors: [{messageId: 'exceedsYear', data: {year: 2024, floor: 2023}}] + } + ] +}); diff --git a/src/rules/baseline-target.ts b/src/rules/baseline-target.ts index b0597a1..3c79cd1 100644 --- a/src/rules/baseline-target.ts +++ b/src/rules/baseline-target.ts @@ -1,9 +1,12 @@ +import {createRequire} from 'node:module'; +import path from 'node:path'; import type {TSESLint} from '@typescript-eslint/utils'; import { detectBaselineTargetForFeatures, detectBaselineYearForFeatures, detectFeaturesForSource } from 'baseline-detector'; +import {tryGetTypedParserServices} from '../utils/typescript.js'; type MessageIds = 'notBaseline' | 'exceedsYear'; @@ -18,25 +21,37 @@ export const baselineTarget: TSESLint.RuleModule = { schema: [], messages: { notBaseline: '`{{feature}}` is not Baseline yet', - exceedsYear: 'This file needs Baseline {{year}}, above the {{floor}} floor' + exceedsYear: + 'This file needs Baseline {{year}}, above the {{floor}} floor' } }, defaultOptions: [], create(context) { + const services = tryGetTypedParserServices(context); + return { Program(node) { const features = detectFeaturesForSource(context.sourceCode.text, { - fileName: context.filename + fileName: context.filename, + ...(services && { + typescriptContext: { + ts: createRequire(path.join(context.cwd, 'package.json'))( + 'typescript' + ), + program: services.program, + checker: services.program.getTypeChecker() + } + }) }); const target = detectBaselineTargetForFeatures(features); const year = detectBaselineYearForFeatures(features); // the order of the checks matters, a feature that is not baseline makes year come back null, and so does a file // with no features at all. - if(target.status === false){ + if (target.status === false) { context.report({ node, messageId: 'notBaseline', - data: { feature: target.reason } + data: {feature: target.reason} }); return; } @@ -45,7 +60,7 @@ export const baselineTarget: TSESLint.RuleModule = { context.report({ node, messageId: 'exceedsYear', - data: { year, floor: BASELINE_YEAR } + data: {year, floor: BASELINE_YEAR} }); } } From c94d9fb603eb7b4577f647300a20dfa021bb63f9 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:51:23 -0600 Subject: [PATCH 3/4] style: format baseline-target import --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index d5e8717..71cbe2e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,7 +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'; +import {baselineTarget} from './rules/baseline-target.js'; const plugin: ESLint.Plugin = { meta: { From 1cb1f7b9d51f242d25ec4efa3045b5c7db94f7f1 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:57:03 -0600 Subject: [PATCH 4/4] feat: report baseline-target per usage --- src/rules/baseline-target.test.ts | 40 ++++++++++++++++++++++-- src/rules/baseline-target.ts | 52 +++++++++++++++++-------------- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/src/rules/baseline-target.test.ts b/src/rules/baseline-target.test.ts index f7b49f3..194b930 100644 --- a/src/rules/baseline-target.test.ts +++ b/src/rules/baseline-target.test.ts @@ -31,11 +31,36 @@ ruleTester.run('baseline-target', baselineTarget as never, { invalid: [ { code: 'new URLPattern();', - errors: [{messageId: 'exceedsYear', data: {year: 2025, floor: 2023}}] + 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'}}] + 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} + ] } ] }); @@ -45,7 +70,16 @@ typedRuleTester.run('baseline-target (typed)', baselineTarget, { invalid: [ { code: 'const {promise} = Promise.withResolvers();', - errors: [{messageId: 'exceedsYear', data: {year: 2024, floor: 2023}}] + 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 index 3c79cd1..670e765 100644 --- a/src/rules/baseline-target.ts +++ b/src/rules/baseline-target.ts @@ -2,9 +2,9 @@ import {createRequire} from 'node:module'; import path from 'node:path'; import type {TSESLint} from '@typescript-eslint/utils'; import { - detectBaselineTargetForFeatures, - detectBaselineYearForFeatures, - detectFeaturesForSource + baselineStatusOf, + detectFeaturesForSource, + yearOf } from 'baseline-detector'; import {tryGetTypedParserServices} from '../utils/typescript.js'; @@ -22,7 +22,7 @@ export const baselineTarget: TSESLint.RuleModule = { messages: { notBaseline: '`{{feature}}` is not Baseline yet', exceedsYear: - 'This file needs Baseline {{year}}, above the {{floor}} floor' + '`{{feature}}` is Baseline {{year}}, above the {{floor}} floor' } }, defaultOptions: [], @@ -30,7 +30,7 @@ export const baselineTarget: TSESLint.RuleModule = { const services = tryGetTypedParserServices(context); return { - Program(node) { + Program() { const features = detectFeaturesForSource(context.sourceCode.text, { fileName: context.filename, ...(services && { @@ -43,25 +43,31 @@ export const baselineTarget: TSESLint.RuleModule = { } }) }); - const target = detectBaselineTargetForFeatures(features); - const year = detectBaselineYearForFeatures(features); - // the order of the checks matters, a feature that is not baseline makes year come back null, and so does a file - // with no features at all. - if (target.status === false) { - context.report({ - node, - messageId: 'notBaseline', - data: {feature: target.reason} - }); - return; - } - if (year !== null && year > BASELINE_YEAR) { - context.report({ - node, - messageId: 'exceedsYear', - data: {year, floor: BASELINE_YEAR} - }); + 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} + }); + } + } } } };