diff --git a/lint/fixer/status.ts b/lint/fixer/status.ts index bd6bef48ac99bd..74a5d317300856 100644 --- a/lint/fixer/status.ts +++ b/lint/fixer/status.ts @@ -21,9 +21,19 @@ export const fixStatusValue = (value: Identifier): Identifier => { compat.status.standard_track = true; } - if (!checkExperimental(compat)) { + const experimentalCheck = checkExperimental(compat); + if ( + !experimentalCheck.valid && + experimentalCheck.reason === 'multi-engine' + ) { compat.status.experimental = false; } + if ( + !experimentalCheck.valid && + experimentalCheck.reason === 'single-engine-recent' + ) { + compat.status.experimental = true; + } if (compat.status.deprecated) { // All sub-features are also deprecated. diff --git a/lint/linter/test-status.test.ts b/lint/linter/test-status.test.ts index 2967886baff306..8de3a26e408865 100644 --- a/lint/linter/test-status.test.ts +++ b/lint/linter/test-status.test.ts @@ -5,11 +5,85 @@ import assert from 'node:assert/strict'; import { CompatStatement } from '../../types/types.js'; import { Logger } from '../utils.js'; +import bcd from '../../index.js'; -import test, { checkExperimental } from './test-status.js'; +import test, { checkExperimental, parseVersion } from './test-status.js'; + +const { browsers } = bcd; + +/** + * Find a Chrome version released within the last year (guaranteed to be < 2 years old) + * @returns Chrome version string + */ +const getRecentChromeVersion = (): string => { + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + + // Find the first retired version released within the last year + const recentVersion = Object.entries(browsers.chrome.releases) + .filter( + ([, release]) => release.status === 'retired' && release.release_date, + ) + .map(([version, release]) => ({ + version, + date: new Date(release.release_date as string), + })) + .filter(({ date }) => date > oneYearAgo) + .sort((a, b) => b.date.getTime() - a.date.getTime())[0]; + + return recentVersion?.version || '130'; // Fallback to 130 if none found +}; + +/** + * Find a Chrome version released more than 2 years ago + * @returns Chrome version string + */ +const getOldChromeVersion = (): string => { + const twoYearsAgo = new Date(); + twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2); + twoYearsAgo.setDate(twoYearsAgo.getDate() - 1); // Subtract one day for safety + + // Find a version released before 2 years ago + const oldVersion = Object.entries(browsers.chrome.releases) + .filter( + ([, release]) => release.status === 'retired' && release.release_date, + ) + .map(([version, release]) => ({ + version, + date: new Date(release.release_date as string), + })) + .filter(({ date }) => date < twoYearsAgo) + .sort((a, b) => b.date.getTime() - a.date.getTime())[0]; + + return oldVersion?.version || '100'; // Fallback to 100 if none found +}; + +describe('parseVersion', () => { + it('should return null for non-string values', () => { + assert.equal(parseVersion(null), null); + assert.equal(parseVersion(undefined), null); + assert.equal(parseVersion(true), null); + assert.equal(parseVersion(false), null); + }); + + it('should return null for preview version', () => { + assert.equal(parseVersion('preview'), null); + }); + + it('should handle ≤ notation', () => { + assert.equal(parseVersion('≤79'), '79'); + assert.equal(parseVersion('≤12'), '12'); + }); + + it('should return version as-is for normal versions', () => { + assert.equal(parseVersion('76'), '76'); + assert.equal(parseVersion('1'), '1'); + assert.equal(parseVersion('123'), '123'); + }); +}); describe('checkExperimental', () => { - it('should return true when data is not experimental', () => { + it('should return valid:true when data is not experimental', () => { const data: CompatStatement = { status: { experimental: false, @@ -19,10 +93,11 @@ describe('checkExperimental', () => { support: {}, }; - assert.equal(checkExperimental(data), true); + const result = checkExperimental(data); + assert.equal(result.valid, true); }); - it('should return true when data is experimental but supported by only one engine', () => { + it('should return valid:true when data is experimental but supported by only one engine', () => { const data: CompatStatement = { status: { experimental: true, @@ -32,19 +107,18 @@ describe('checkExperimental', () => { support: { firefox: { version_added: '1', - version_removed: null, }, chrome: { version_added: 'preview', - version_removed: null, }, }, }; - assert.equal(checkExperimental(data), true); + const result = checkExperimental(data); + assert.equal(result.valid, true); }); - it('should return false when data is experimental and supported by more than one engine', () => { + it('should return valid:false with multi-engine reason when experimental and supported by more than one engine', () => { const data: CompatStatement = { status: { experimental: true, @@ -54,16 +128,127 @@ describe('checkExperimental', () => { support: { firefox: { version_added: '1', - version_removed: null, }, chrome: { version_added: '1', - version_removed: null, }, }, }; - assert.equal(checkExperimental(data), false); + const result = checkExperimental(data); + assert.equal(result.valid, false); + assert.equal(result.reason, 'multi-engine'); + }); + + it('should return valid:false with single-engine-recent reason when experimental:false and single engine < 2 years', () => { + const recentVersion = getRecentChromeVersion(); + const data: CompatStatement = { + status: { + experimental: false, + standard_track: true, + deprecated: false, + }, + support: { + chrome: { + version_added: recentVersion, + }, + }, + }; + + const result = checkExperimental(data); + assert.equal(result.valid, false); + assert.equal(result.reason, 'single-engine-recent'); + assert.equal(result.engine, 'Blink'); + assert.ok(result.releaseDate); + }); + + it('should return valid:true when experimental:false and single engine >= 2 years old', () => { + const oldVersion = getOldChromeVersion(); + const data: CompatStatement = { + status: { + experimental: false, + standard_track: true, + deprecated: false, + }, + support: { + chrome: { + version_added: oldVersion, + }, + }, + }; + + const result = checkExperimental(data); + assert.equal(result.valid, true); + }); + + it('should return valid:true when experimental:false and multiple engines', () => { + const data: CompatStatement = { + status: { + experimental: false, + standard_track: true, + deprecated: false, + }, + support: { + firefox: { + version_added: '130', + }, + chrome: { + version_added: '130', + }, + }, + }; + + const result = checkExperimental(data); + assert.equal(result.valid, true); + }); + + it('should ignore features behind flags when checking engine count', () => { + const recentVersion = getRecentChromeVersion(); + const data: CompatStatement = { + status: { + experimental: false, + standard_track: true, + deprecated: false, + }, + support: { + chrome: { + version_added: recentVersion, + }, + firefox: { + version_added: '120', + flags: [{ type: 'preference', name: 'test' }], + }, + }, + }; + + const result = checkExperimental(data); + // Should see only Chrome (Blink) as having real support + assert.equal(result.valid, false); + assert.equal(result.reason, 'single-engine-recent'); + }); + + it('should ignore preview versions when checking engine count', () => { + const recentVersion = getRecentChromeVersion(); + const data: CompatStatement = { + status: { + experimental: false, + standard_track: true, + deprecated: false, + }, + support: { + chrome: { + version_added: recentVersion, + }, + firefox: { + version_added: 'preview', + }, + }, + }; + + const result = checkExperimental(data); + // Should see only Chrome (Blink) as having real support + assert.equal(result.valid, false); + assert.equal(result.reason, 'single-engine-recent'); }); }); @@ -144,11 +329,9 @@ describe('checkStatus', () => { support: { firefox: { version_added: '1', - version_removed: null, }, chrome: { version_added: '1', - version_removed: null, }, }, }; @@ -157,5 +340,50 @@ describe('checkStatus', () => { assert.equal(logger.messages.length, 1); assert.ok(logger.messages[0].message.includes('should be set to')); + assert.ok(logger.messages[0].message.includes('false')); + }); + + it('should log error when experimental:false but single engine support < 2 years', () => { + const recentVersion = getRecentChromeVersion(); + const data: CompatStatement = { + status: { + experimental: false, + standard_track: true, + deprecated: false, + }, + support: { + chrome: { + version_added: recentVersion, + }, + }, + }; + + test.check(logger, { data, path: { category: 'api' } }); + + assert.equal(logger.messages.length, 1); + assert.ok(logger.messages[0].message.includes('should be set to')); + assert.ok(logger.messages[0].message.includes('true')); + assert.ok(logger.messages[0].message.includes('single browser engine')); + assert.ok(logger.messages[0].message.includes('Blink')); + }); + + it('should not log error when experimental:false and single engine support >= 2 years', () => { + const oldVersion = getOldChromeVersion(); + const data: CompatStatement = { + status: { + experimental: false, + standard_track: true, + deprecated: false, + }, + support: { + chrome: { + version_added: oldVersion, + }, + }, + }; + + test.check(logger, { data, path: { category: 'api' } }); + + assert.equal(logger.messages.length, 0); }); }); diff --git a/lint/linter/test-status.ts b/lint/linter/test-status.ts index dc0ec2c25a082e..07f2fe84a9db7e 100644 --- a/lint/linter/test-status.ts +++ b/lint/linter/test-status.ts @@ -3,17 +3,120 @@ import chalk from 'chalk-template'; -import { Linter, Logger, LinterData } from '../utils.js'; -import { BrowserName, CompatStatement } from '../../types/types.js'; +import { Linter, Logger, LinterData, ENGINES } from '../utils.js'; +import { + BrowserName, + CompatStatement, + SimpleSupportStatement, +} from '../../types/types.js'; import bcd from '../../index.js'; const { browsers } = bcd; +/** + * Parse version string to extract numeric version + * @param version The version string (e.g., "76", "≤79", "preview", true, false, null) + * @returns The numeric version string or null + */ +export const parseVersion = ( + version: string | boolean | null | undefined, +): string | null => { + if (!version || typeof version !== 'string') { + return null; + } + + // Skip preview builds + if (version === 'preview') { + return null; + } + + // Handle ≤ notation - use the specified version + if (version.startsWith('≤')) { + return version.substring(1); + } + + return version; +}; + +/** + * Get the earliest release date for a feature in a specific engine + * @param data The compat statement + * @param engine The engine name (Blink, Gecko, or WebKit) + * @returns The earliest release date or null if not found + */ +export const getEarliestReleaseDate = ( + data: CompatStatement, + engine: string, +): Date | null => { + let earliest: Date | null = null; + + for (const [browserName, support] of Object.entries(data.support)) { + // Consider only the first part of an array statement + const statement: SimpleSupportStatement = Array.isArray(support) + ? support[0] + : support; + + // Ignore anything behind flag, prefix or alternative name + if (statement.flags || statement.prefix || statement.alternative_name) { + continue; + } + + // Only consider added features (not removed-only) + if (!statement.version_added || statement.version_removed) { + continue; + } + + const browser = browsers[browserName as BrowserName]; + if (!browser) { + continue; + } + + // Check if this browser uses the target engine + const currentRelease = Object.values(browser.releases).find( + (r) => r.status === 'current', + ); + + if (currentRelease?.engine !== engine) { + continue; + } + + // Parse the version and look up release date + const version = parseVersion(statement.version_added); + if (!version) { + continue; + } + + const release = browser.releases[version]; + if (!release?.release_date) { + continue; + } + + const date = new Date(release.release_date); + if (!earliest || date < earliest) { + earliest = date; + } + } + + return earliest; +}; + +/** + * Information about experimental status validation + */ +export interface ExperimentalCheckResult { + valid: boolean; + reason?: 'multi-engine' | 'single-engine-recent'; + engine?: string; + releaseDate?: string; +} + /** * Check if experimental should be true or false * @param data The data to check - * @returns The expected experimental status + * @returns Validation result with details about any issues */ -export const checkExperimental = (data: CompatStatement): boolean => { +export const checkExperimental = ( + data: CompatStatement, +): ExperimentalCheckResult => { if (data.status?.experimental) { // Check if experimental should be false (code copied from migration 007) @@ -48,18 +151,90 @@ export const checkExperimental = (data: CompatStatement): boolean => { } let engineCount = 0; - for (const engine of ['Blink', 'Gecko', 'WebKit']) { + for (const engine of ENGINES) { + if (engineSupport.has(engine)) { + engineCount++; + } + } + + if (engineCount > 1) { + return { valid: false, reason: 'multi-engine' }; + } + } + + // Check if experimental is false but should be true + if (data.status?.experimental === false) { + const browserSupport = new Set(); + + for (const [browser, support] of Object.entries(data.support)) { + // Consider only the first part of an array statement. + const statement = Array.isArray(support) ? support[0] : support; + // Ignore anything behind flag, prefix or alternative name + if (statement.flags || statement.prefix || statement.alternative_name) { + continue; + } + if (statement.version_added && !statement.version_removed) { + if (statement.version_added !== 'preview') { + browserSupport.add(browser as BrowserName); + } + } + } + + // Check which of Blink, Gecko and WebKit support it + const engineSupport = new Set(); + + for (const browser of browserSupport) { + const currentRelease = Object.values(browsers[browser].releases).find( + (r) => r.status === 'current', + ); + const engine = currentRelease?.engine; + if (engine) { + engineSupport.add(engine); + } + } + + // Count engines among the major three + let engineCount = 0; + for (const engine of ENGINES) { if (engineSupport.has(engine)) { engineCount++; } } + // If 2+ engines support it, experimental: false is valid if (engineCount > 1) { - return false; + return { valid: true }; + } + + // If single engine, check if it's been stable for 2+ years + if (engineCount === 1) { + const engine = Array.from(engineSupport).find((e) => ENGINES.includes(e)); + + if (engine) { + const earliestDate = getEarliestReleaseDate(data, engine); + + if (earliestDate) { + const twoYearsAgo = new Date(); + twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2); + + if (earliestDate > twoYearsAgo) { + // ERROR: Single engine support is less than 2 years old + return { + valid: false, + reason: 'single-engine-recent', + engine, + releaseDate: earliestDate.toISOString().split('T')[0], + }; + } + } + } } + + // If no engine support or single engine > 2 years old, it's valid + return { valid: true }; } - return true; + return { valid: true }; }; /** @@ -96,11 +271,19 @@ const checkStatus = ( ); } - if (!checkExperimental(data)) { - logger.error( - chalk`{red {bold Experimental} should be set to {bold false} as the feature is {bold supported} in {bold multiple browser} engines.}`, - { fixable: true }, - ); + const experimentalCheck = checkExperimental(data); + if (!experimentalCheck.valid) { + if (experimentalCheck.reason === 'multi-engine') { + logger.error( + chalk`{red {bold Experimental} should be set to {bold false} as the feature is {bold supported} in {bold multiple browser} engines.}`, + { fixable: true }, + ); + } else if (experimentalCheck.reason === 'single-engine-recent') { + logger.error( + chalk`{red {bold Experimental} should be set to {bold true} as the feature is only supported in a single browser engine ({bold ${experimentalCheck.engine}}) for less than 2 years (since ${experimentalCheck.releaseDate}).}`, + { fixable: true }, + ); + } } }; diff --git a/lint/utils.ts b/lint/utils.ts index 367680074ddd53..afc71bfb26ac53 100644 --- a/lint/utils.ts +++ b/lint/utils.ts @@ -12,6 +12,8 @@ export interface LintOptions { only?: string[]; } +export const ENGINES = ['Blink', 'Gecko', 'WebKit']; + const INVISIBLES_MAP: Readonly> = Object.freeze( Object.assign(Object.create(null), { '\0': '\\0', // ␀ (0x00)