From 713179650a98273c60f42a3910c397246a681432 Mon Sep 17 00:00:00 2001 From: Ryuya Date: Wed, 22 Jul 2026 02:26:12 -0700 Subject: [PATCH] fix: exclude RegExp legacy statics --- derived/current/classification.json | 129 ++++++++++-- derived/current/compat-management-report.json | 51 +++-- derived/current/generation.json | 58 ++++-- fixtures/typescript/smoke/positive-flag.ts | 25 +++ .../reference/libBaseline.errors.txt | 32 ++- .../tests/baselines/reference/libBaseline.js | 24 +++ .../baselines/reference/libBaseline.symbols | 41 +++- .../baselines/reference/libBaseline.types | 36 ++++ .../tests/cases/compiler/libBaseline.ts | 24 +++ generated/current/baseline.d.ts | 15 -- generated/current/year/2020/index.d.ts | 15 -- generated/current/year/2021/index.d.ts | 15 -- generated/current/year/2022/index.d.ts | 15 -- generated/current/year/2023/index.d.ts | 15 -- generated/current/year/2024/index.d.ts | 15 -- generated/current/year/2025/index.d.ts | 15 -- lib/classifier.mjs | 193 ++++++++++++++++-- lib/compat-management-registry.mjs | 27 +++ lib/negative-probes.mjs | 16 ++ registry/compat-management.json | 79 ++++++- registry/compat-management.schema.json | 35 ++++ test/classifier.test.mjs | 138 +++++++++++++ test/compat-management-schema.test.mjs | 41 ++++ test/consumer-smoke.test.mjs | 6 +- test/generate.test.mjs | 27 ++- test/negative-probes.test.mjs | 37 +++- test/packed-consumer-smoke.test.mjs | 6 +- test/year-entrypoints.test.mjs | 6 +- 28 files changed, 951 insertions(+), 185 deletions(-) diff --git a/derived/current/classification.json b/derived/current/classification.json index 2f05786..28297f2 100644 --- a/derived/current/classification.json +++ b/derived/current/classification.json @@ -15,22 +15,22 @@ "emitCompatCount": 997, "notModeledUpstreamCount": 51, "alreadyExcludedUpstreamCount": 1, - "managedCompatCount": 55, + "managedCompatCount": 60, "managedCategoryCounts": { "actionable_upstream_gap": 12, "handled_outside_lib": 3, - "legacy_excluded": 9, + "legacy_excluded": 14, "tracked_by_canonical_lib": 27, "ts_settled_limitation": 4 }, "managedDeliveryCounts": { "cover-checker": 2, "defer-upstream": 43, - "exclude": 10 + "exclude": 15 }, "managedUpstreamStateCounts": { "actionable": 7, - "settled": 16, + "settled": 21, "tracked": 32 }, "selectedUnitCount": 1444, @@ -40,9 +40,9 @@ "compatManagementRegistrySummary": { "kind": "typescript-baseline-lib/compat-management-registry", "schemaVersion": 1, - "sourceHash": "sha256-499ed0fef28ff4f9a9757e19996b9e498ba8ee658fab3659be1e81cfb73c70ed", - "groupCount": 18, - "managedCompatCount": 55 + "sourceHash": "sha256-fbebf10c03122d6fe78bafd62030a5842dabf0cf8f2800c3667b601ffee86133", + "groupCount": 19, + "managedCompatCount": 60 }, "classifiedCompatRows": [ { @@ -14256,10 +14256,28 @@ "includeInTarget": false, "resolutionKind": "member", "resolvedUnitIds": [ + "lib.es5.d.ts::RegExpConstructor.$_::294", "lib.es5.d.ts::RegExpConstructor.input::293" ], "transforms": [], - "notes": "member surface RegExp.input" + "notes": "javascript.builtins.RegExp.input maps to static members input, $_", + "management": { + "groupId": "regexp-legacy-statics-excluded", + "category": "legacy_excluded", + "delivery": "exclude", + "upstreamState": "settled", + "reason": "TypeScript models the RegExp legacy static aliases as separate properties, but they share the same compatibility status and must be excluded together.", + "sourceUrls": [ + "https://github.com/tc39/proposal-regexp-legacy-features/" + ], + "externalAction": { + "kind": "none", + "note": "No external action. Keep canonical names and aliases bound to the same compat rows." + }, + "expectedResolutionKinds": [ + "member" + ] + } }, { "compatKey": "javascript.builtins.RegExp.lastIndex", @@ -14294,10 +14312,28 @@ "includeInTarget": false, "resolutionKind": "member", "resolvedUnitIds": [ + "lib.es5.d.ts::RegExpConstructor.$&::296", "lib.es5.d.ts::RegExpConstructor.lastMatch::295" ], "transforms": [], - "notes": "member surface RegExp.lastMatch" + "notes": "javascript.builtins.RegExp.lastMatch maps to static members lastMatch, $&", + "management": { + "groupId": "regexp-legacy-statics-excluded", + "category": "legacy_excluded", + "delivery": "exclude", + "upstreamState": "settled", + "reason": "TypeScript models the RegExp legacy static aliases as separate properties, but they share the same compatibility status and must be excluded together.", + "sourceUrls": [ + "https://github.com/tc39/proposal-regexp-legacy-features/" + ], + "externalAction": { + "kind": "none", + "note": "No external action. Keep canonical names and aliases bound to the same compat rows." + }, + "expectedResolutionKinds": [ + "member" + ] + } }, { "compatKey": "javascript.builtins.RegExp.lastParen", @@ -14312,10 +14348,28 @@ "includeInTarget": false, "resolutionKind": "member", "resolvedUnitIds": [ + "lib.es5.d.ts::RegExpConstructor.$+::298", "lib.es5.d.ts::RegExpConstructor.lastParen::297" ], "transforms": [], - "notes": "member surface RegExp.lastParen" + "notes": "javascript.builtins.RegExp.lastParen maps to static members lastParen, $+", + "management": { + "groupId": "regexp-legacy-statics-excluded", + "category": "legacy_excluded", + "delivery": "exclude", + "upstreamState": "settled", + "reason": "TypeScript models the RegExp legacy static aliases as separate properties, but they share the same compatibility status and must be excluded together.", + "sourceUrls": [ + "https://github.com/tc39/proposal-regexp-legacy-features/" + ], + "externalAction": { + "kind": "none", + "note": "No external action. Keep canonical names and aliases bound to the same compat rows." + }, + "expectedResolutionKinds": [ + "member" + ] + } }, { "compatKey": "javascript.builtins.RegExp.leftContext", @@ -14330,10 +14384,28 @@ "includeInTarget": false, "resolutionKind": "member", "resolvedUnitIds": [ + "lib.es5.d.ts::RegExpConstructor.$`::300", "lib.es5.d.ts::RegExpConstructor.leftContext::299" ], "transforms": [], - "notes": "member surface RegExp.leftContext" + "notes": "javascript.builtins.RegExp.leftContext maps to static members leftContext, $`", + "management": { + "groupId": "regexp-legacy-statics-excluded", + "category": "legacy_excluded", + "delivery": "exclude", + "upstreamState": "settled", + "reason": "TypeScript models the RegExp legacy static aliases as separate properties, but they share the same compatibility status and must be excluded together.", + "sourceUrls": [ + "https://github.com/tc39/proposal-regexp-legacy-features/" + ], + "externalAction": { + "kind": "none", + "note": "No external action. Keep canonical names and aliases bound to the same compat rows." + }, + "expectedResolutionKinds": [ + "member" + ] + } }, { "compatKey": "javascript.builtins.RegExp.multiline", @@ -14397,20 +14469,23 @@ "lib.es5.d.ts::RegExpConstructor.$9::292" ], "transforms": [], - "notes": "javascript.builtins.RegExp.n maps to the legacy RegExpConstructor $1..$9 static properties", + "notes": "javascript.builtins.RegExp.n maps to static members $1, $2, $3, $4, $5, $6, $7, $8, $9", "management": { - "groupId": "legacy-annex-b-excluded", + "groupId": "regexp-legacy-statics-excluded", "category": "legacy_excluded", "delivery": "exclude", "upstreamState": "settled", - "reason": "These are legacy or Annex B-style web compatibility surfaces that should not become first-class baseline declarations.", + "reason": "TypeScript models the RegExp legacy static aliases as separate properties, but they share the same compatibility status and must be excluded together.", "sourceUrls": [ - "https://tc39.es/ecma262/2026/multipage/additional-ecmascript-features-for-web-browsers.html" + "https://github.com/tc39/proposal-regexp-legacy-features/" ], "externalAction": { "kind": "none", - "note": "No external action. Keep excluded unless the spec position changes." - } + "note": "No external action. Keep canonical names and aliases bound to the same compat rows." + }, + "expectedResolutionKinds": [ + "member" + ] } }, { @@ -14426,10 +14501,28 @@ "includeInTarget": false, "resolutionKind": "member", "resolvedUnitIds": [ + "lib.es5.d.ts::RegExpConstructor.$'::302", "lib.es5.d.ts::RegExpConstructor.rightContext::301" ], "transforms": [], - "notes": "member surface RegExp.rightContext" + "notes": "javascript.builtins.RegExp.rightContext maps to static members rightContext, $'", + "management": { + "groupId": "regexp-legacy-statics-excluded", + "category": "legacy_excluded", + "delivery": "exclude", + "upstreamState": "settled", + "reason": "TypeScript models the RegExp legacy static aliases as separate properties, but they share the same compatibility status and must be excluded together.", + "sourceUrls": [ + "https://github.com/tc39/proposal-regexp-legacy-features/" + ], + "externalAction": { + "kind": "none", + "note": "No external action. Keep canonical names and aliases bound to the same compat rows." + }, + "expectedResolutionKinds": [ + "member" + ] + } }, { "compatKey": "javascript.builtins.RegExp.source", diff --git a/derived/current/compat-management-report.json b/derived/current/compat-management-report.json index 570e34a..2149d7b 100644 --- a/derived/current/compat-management-report.json +++ b/derived/current/compat-management-report.json @@ -8,32 +8,32 @@ "kind": "typescript-baseline-lib/compat-management-registry", "schemaVersion": 1, "sourcePath": "registry/compat-management.json", - "sourceHash": "sha256-499ed0fef28ff4f9a9757e19996b9e498ba8ee658fab3659be1e81cfb73c70ed", - "groupCount": 18, - "managedCompatCount": 55 + "sourceHash": "sha256-fbebf10c03122d6fe78bafd62030a5842dabf0cf8f2800c3667b601ffee86133", + "groupCount": 19, + "managedCompatCount": 60 }, "summary": { - "managedCompatCount": 55, + "managedCompatCount": 60, "managedCategoryCounts": { "actionable_upstream_gap": 12, "handled_outside_lib": 3, - "legacy_excluded": 9, + "legacy_excluded": 14, "tracked_by_canonical_lib": 27, "ts_settled_limitation": 4 }, "managedDeliveryCounts": { "cover-checker": 2, "defer-upstream": 43, - "exclude": 10 + "exclude": 15 }, "managedUpstreamStateCounts": { "actionable": 7, - "settled": 16, + "settled": 21, "tracked": 32 }, "managedResolutionKindCounts": { "already-excluded-upstream": 1, - "member": 1, + "member": 6, "not-modeled-upstream": 51, "root-availability": 2 } @@ -520,7 +520,7 @@ "kind": "none", "note": "No external action. Keep excluded unless the spec position changes." }, - "compatKeyCount": 9, + "compatKeyCount": 8, "compatKeys": [ "javascript.builtins.Date.getYear", "javascript.builtins.Date.setYear", @@ -529,13 +529,40 @@ "javascript.builtins.Object.defineSetter", "javascript.builtins.Object.lookupGetter", "javascript.builtins.Object.lookupSetter", - "javascript.builtins.Object.proto", - "javascript.builtins.RegExp.n" + "javascript.builtins.Object.proto" ], "actualResolutionKinds": [ - "member", "not-modeled-upstream" ] + }, + { + "id": "regexp-legacy-statics-excluded", + "category": "legacy_excluded", + "delivery": "exclude", + "upstreamState": "settled", + "expectedResolutionKinds": [ + "member" + ], + "reason": "TypeScript models the RegExp legacy static aliases as separate properties, but they share the same compatibility status and must be excluded together.", + "sourceUrls": [ + "https://github.com/tc39/proposal-regexp-legacy-features/" + ], + "externalAction": { + "kind": "none", + "note": "No external action. Keep canonical names and aliases bound to the same compat rows." + }, + "compatKeyCount": 6, + "compatKeys": [ + "javascript.builtins.RegExp.input", + "javascript.builtins.RegExp.lastMatch", + "javascript.builtins.RegExp.lastParen", + "javascript.builtins.RegExp.leftContext", + "javascript.builtins.RegExp.n", + "javascript.builtins.RegExp.rightContext" + ], + "actualResolutionKinds": [ + "member" + ] } ] } diff --git a/derived/current/generation.json b/derived/current/generation.json index 58ed27c..c42c030 100644 --- a/derived/current/generation.json +++ b/derived/current/generation.json @@ -17,9 +17,9 @@ "summary": { "sourceLibCount": 84, "classifiedCompatCount": 1150, - "selectedUnitCount": 1906, + "selectedUnitCount": 1901, "completeContainerCount": 172, - "excludedUnitCount": 409, + "excludedUnitCount": 414, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 2, "allowEntryCount": 15, @@ -1099,7 +1099,7 @@ { "year": 2020, "outputPath": "generated/current/year/2020/index.d.ts", - "contentHash": "sha256-aa80f5ba1d4d9970926612e2ae6c7eb253ef2b44e523ff885fbf896f00be77f9", + "contentHash": "sha256-a211ab22eb9752333b9c614a57eeb1689b97a116d2a7d8f9eb44130ae63d576f", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -1723,14 +1723,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1547, + "selectedUnitCount": 1542, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 1 }, { "year": 2021, "outputPath": "generated/current/year/2021/index.d.ts", - "contentHash": "sha256-3ea92b0290076b42ea3d54aed4cb1a8bb9bc0053fcbd0ff345ae28ecfa51bae5", + "contentHash": "sha256-d5eae27351671819ee1bbf892a62f6a181c0f2509a648c0a307e9c6ad0ff1fef", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -2418,14 +2418,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1788, + "selectedUnitCount": 1783, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 2 }, { "year": 2022, "outputPath": "generated/current/year/2022/index.d.ts", - "contentHash": "sha256-739b273874376570226373ae7f23e200925ecbb9e5d0489f56fd357547725d88", + "contentHash": "sha256-afea5cbf913cdcc885b448ca6d75908546346bc368a99089a0365aff70a5d400", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -3123,14 +3123,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1843, + "selectedUnitCount": 1838, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 2 }, { "year": 2023, "outputPath": "generated/current/year/2023/index.d.ts", - "contentHash": "sha256-a5a699351a7726d18cbbe6b3e89c37583a3bfc021e817bfdb31144fc0de1c7fb", + "contentHash": "sha256-41b12b45750e6ea6c38804e6f885ea82e307e2a290f18550f1fcfa58aa80548f", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -3850,14 +3850,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1906, + "selectedUnitCount": 1901, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 2 }, { "year": 2024, "outputPath": "generated/current/year/2024/index.d.ts", - "contentHash": "sha256-a6cb9a7de70cbc8c9ec9f3176fc8361a898c3f8e8e4bc01cd5e717cf5847fec2", + "contentHash": "sha256-28acaad2d01790d586ab9aa39d8e22b22755e00da10afdca60828954609752d5", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -4607,14 +4607,14 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 1967, + "selectedUnitCount": 1962, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 0 }, { "year": 2025, "outputPath": "generated/current/year/2025/index.d.ts", - "contentHash": "sha256-f1c7636db5d360cdbd806a96be98c2334b3755fa033231cae2c744c7ee961f98", + "contentHash": "sha256-2371b7fc8f777f6ba6832eb35bf5aae17d004df7663722983a1fb54814cb3cb3", "includedCompatKeys": [ "javascript.builtins.AggregateError", "javascript.builtins.AggregateError.AggregateError", @@ -5404,7 +5404,7 @@ "javascript.builtins.globalThis", "javascript.builtins.undefined" ], - "selectedUnitCount": 2124, + "selectedUnitCount": 2119, "preservedTypeOnlyUnitCount": 29, "transformedUnitCount": 0 } @@ -5993,6 +5993,24 @@ "javascript.builtins.RegExp.compile" ] }, + { + "unitId": "lib.es5.d.ts::RegExpConstructor.$&::296", + "compatKeys": [ + "javascript.builtins.RegExp.lastMatch" + ] + }, + { + "unitId": "lib.es5.d.ts::RegExpConstructor.$'::302", + "compatKeys": [ + "javascript.builtins.RegExp.rightContext" + ] + }, + { + "unitId": "lib.es5.d.ts::RegExpConstructor.$+::298", + "compatKeys": [ + "javascript.builtins.RegExp.lastParen" + ] + }, { "unitId": "lib.es5.d.ts::RegExpConstructor.$1::284", "compatKeys": [ @@ -6047,6 +6065,18 @@ "javascript.builtins.RegExp.n" ] }, + { + "unitId": "lib.es5.d.ts::RegExpConstructor.$_::294", + "compatKeys": [ + "javascript.builtins.RegExp.input" + ] + }, + { + "unitId": "lib.es5.d.ts::RegExpConstructor.$`::300", + "compatKeys": [ + "javascript.builtins.RegExp.leftContext" + ] + }, { "unitId": "lib.es5.d.ts::RegExpConstructor.input::293", "compatKeys": [ diff --git a/fixtures/typescript/smoke/positive-flag.ts b/fixtures/typescript/smoke/positive-flag.ts index 4ba86d6..a4f9298 100644 --- a/fixtures/typescript/smoke/positive-flag.ts +++ b/fixtures/typescript/smoke/positive-flag.ts @@ -6,3 +6,28 @@ new Intl.NumberFormat().formatRange(1, 2); Object.hasOwn({ baseline: true }, "baseline"); new Error("problem", { cause: new Error("root") }); new RegExp("baseline", "d").hasIndices; + +type __BaselineLegacyRegExpStatic = + | "$1" + | "$2" + | "$3" + | "$4" + | "$5" + | "$6" + | "$7" + | "$8" + | "$9" + | "input" + | "$_" + | "lastMatch" + | "$&" + | "lastParen" + | "$+" + | "leftContext" + | "$`" + | "rightContext" + | "$'"; +type __BaselineAssertNever = T; +type __BaselineHasNoLegacyRegExpStatics = __BaselineAssertNever< + Extract<__BaselineLegacyRegExpStatic, keyof typeof RegExp> +>; diff --git a/fixtures/typescript/tests/baselines/reference/libBaseline.errors.txt b/fixtures/typescript/tests/baselines/reference/libBaseline.errors.txt index 6728072..fd5c4e8 100644 --- a/fixtures/typescript/tests/baselines/reference/libBaseline.errors.txt +++ b/fixtures/typescript/tests/baselines/reference/libBaseline.errors.txt @@ -1,7 +1,7 @@ -libBaseline.ts(12,29): error TS2339: Property 'caller' does not exist on type '() => void'. -libBaseline.ts(13,12): error TS2339: Property 'substr' does not exist on type '"baseline"'. -libBaseline.ts(14,24): error TS2339: Property 'compile' does not exist on type 'RegExp'. -libBaseline.ts(15,47): error TS2339: Property 'callee' does not exist on type 'IArguments'. +libBaseline.ts(36,29): error TS2339: Property 'caller' does not exist on type '() => void'. +libBaseline.ts(37,12): error TS2339: Property 'substr' does not exist on type '"baseline"'. +libBaseline.ts(38,24): error TS2339: Property 'compile' does not exist on type 'RegExp'. +libBaseline.ts(39,47): error TS2339: Property 'callee' does not exist on type 'IArguments'. ==== libBaseline.ts (4 errors) ==== @@ -16,6 +16,30 @@ libBaseline.ts(15,47): error TS2339: Property 'callee' does not exist on type 'I new Error("problem", { cause: new Error("root") }); /baseline/d.hasIndices; + type __BaselineLegacyRegExpStatic = + | "$1" + | "$2" + | "$3" + | "$4" + | "$5" + | "$6" + | "$7" + | "$8" + | "$9" + | "input" + | "$_" + | "lastMatch" + | "$&" + | "lastParen" + | "$+" + | "leftContext" + | "$`" + | "rightContext" + | "$'"; + type __BaselineAssertNever = T; + type __BaselineHasNoLegacyRegExpStatics = __BaselineAssertNever< + Extract<__BaselineLegacyRegExpStatic, keyof typeof RegExp> + >; (function probeCaller() {}).caller; // Error ~~~~~~ !!! error TS2339: Property 'caller' does not exist on type '() => void'. diff --git a/fixtures/typescript/tests/baselines/reference/libBaseline.js b/fixtures/typescript/tests/baselines/reference/libBaseline.js index 0ab4394..3fc4fdf 100644 --- a/fixtures/typescript/tests/baselines/reference/libBaseline.js +++ b/fixtures/typescript/tests/baselines/reference/libBaseline.js @@ -12,6 +12,30 @@ Object.hasOwn({ baseline: true }, "baseline"); new Error("problem", { cause: new Error("root") }); /baseline/d.hasIndices; +type __BaselineLegacyRegExpStatic = + | "$1" + | "$2" + | "$3" + | "$4" + | "$5" + | "$6" + | "$7" + | "$8" + | "$9" + | "input" + | "$_" + | "lastMatch" + | "$&" + | "lastParen" + | "$+" + | "leftContext" + | "$`" + | "rightContext" + | "$'"; +type __BaselineAssertNever = T; +type __BaselineHasNoLegacyRegExpStatics = __BaselineAssertNever< + Extract<__BaselineLegacyRegExpStatic, keyof typeof RegExp> +>; (function probeCaller() {}).caller; // Error "baseline".substr(1); // Error new RegExp("baseline").compile("baseline"); // Error diff --git a/fixtures/typescript/tests/baselines/reference/libBaseline.symbols b/fixtures/typescript/tests/baselines/reference/libBaseline.symbols index d2e1d61..b73f12a 100644 --- a/fixtures/typescript/tests/baselines/reference/libBaseline.symbols +++ b/fixtures/typescript/tests/baselines/reference/libBaseline.symbols @@ -40,14 +40,51 @@ new Error("problem", { cause: new Error("root") }); >/baseline/d.hasIndices : Symbol(RegExp.hasIndices, Decl(lib.baseline.d.ts, --, --)) >hasIndices : Symbol(RegExp.hasIndices, Decl(lib.baseline.d.ts, --, --)) +type __BaselineLegacyRegExpStatic = +>__BaselineLegacyRegExpStatic : Symbol(__BaselineLegacyRegExpStatic, Decl(libBaseline.ts, 9, 23)) + + | "$1" + | "$2" + | "$3" + | "$4" + | "$5" + | "$6" + | "$7" + | "$8" + | "$9" + | "input" + | "$_" + | "lastMatch" + | "$&" + | "lastParen" + | "$+" + | "leftContext" + | "$`" + | "rightContext" + | "$'"; +type __BaselineAssertNever = T; +>__BaselineAssertNever : Symbol(__BaselineAssertNever, Decl(libBaseline.ts, 30, 11)) +>T : Symbol(T, Decl(libBaseline.ts, 31, 27)) +>T : Symbol(T, Decl(libBaseline.ts, 31, 27)) + +type __BaselineHasNoLegacyRegExpStatics = __BaselineAssertNever< +>__BaselineHasNoLegacyRegExpStatics : Symbol(__BaselineHasNoLegacyRegExpStatics, Decl(libBaseline.ts, 31, 48)) +>__BaselineAssertNever : Symbol(__BaselineAssertNever, Decl(libBaseline.ts, 30, 11)) + + Extract<__BaselineLegacyRegExpStatic, keyof typeof RegExp> +>Extract : Symbol(Extract, Decl(lib.baseline.d.ts, --, --)) +>__BaselineLegacyRegExpStatic : Symbol(__BaselineLegacyRegExpStatic, Decl(libBaseline.ts, 9, 23)) +>RegExp : Symbol(RegExp, Decl(lib.baseline.d.ts, --, --), Decl(lib.baseline.d.ts, --, --), Decl(lib.baseline.d.ts, --, --), Decl(lib.baseline.d.ts, --, --), Decl(lib.baseline.d.ts, --, --) ... and 3 more) + +>; (function probeCaller() {}).caller; // Error ->probeCaller : Symbol(probeCaller, Decl(libBaseline.ts, 11, 1)) +>probeCaller : Symbol(probeCaller, Decl(libBaseline.ts, 35, 1)) "baseline".substr(1); // Error new RegExp("baseline").compile("baseline"); // Error >RegExp : Symbol(RegExp, Decl(lib.baseline.d.ts, --, --), Decl(lib.baseline.d.ts, --, --), Decl(lib.baseline.d.ts, --, --), Decl(lib.baseline.d.ts, --, --), Decl(lib.baseline.d.ts, --, --) ... and 3 more) (function probeArguments() { return arguments.callee; })(); // Error ->probeArguments : Symbol(probeArguments, Decl(libBaseline.ts, 14, 1)) +>probeArguments : Symbol(probeArguments, Decl(libBaseline.ts, 38, 1)) >arguments : Symbol(arguments) diff --git a/fixtures/typescript/tests/baselines/reference/libBaseline.types b/fixtures/typescript/tests/baselines/reference/libBaseline.types index 46d3a06..3213098 100644 --- a/fixtures/typescript/tests/baselines/reference/libBaseline.types +++ b/fixtures/typescript/tests/baselines/reference/libBaseline.types @@ -123,6 +123,42 @@ new Error("problem", { cause: new Error("root") }); >hasIndices : boolean > : ^^^^^^^ +type __BaselineLegacyRegExpStatic = +>__BaselineLegacyRegExpStatic : __BaselineLegacyRegExpStatic +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + | "$1" + | "$2" + | "$3" + | "$4" + | "$5" + | "$6" + | "$7" + | "$8" + | "$9" + | "input" + | "$_" + | "lastMatch" + | "$&" + | "lastParen" + | "$+" + | "leftContext" + | "$`" + | "rightContext" + | "$'"; +type __BaselineAssertNever = T; +>__BaselineAssertNever : T +> : ^ + +type __BaselineHasNoLegacyRegExpStatics = __BaselineAssertNever< +>__BaselineHasNoLegacyRegExpStatics : never +> : ^^^^^ + + Extract<__BaselineLegacyRegExpStatic, keyof typeof RegExp> +>RegExp : RegExpConstructor +> : ^^^^^^^^^^^^^^^^^ + +>; (function probeCaller() {}).caller; // Error >(function probeCaller() {}).caller : any > : ^^^ diff --git a/fixtures/typescript/tests/cases/compiler/libBaseline.ts b/fixtures/typescript/tests/cases/compiler/libBaseline.ts index 1c7affd..d22e299 100644 --- a/fixtures/typescript/tests/cases/compiler/libBaseline.ts +++ b/fixtures/typescript/tests/cases/compiler/libBaseline.ts @@ -12,6 +12,30 @@ Object.hasOwn({ baseline: true }, "baseline"); new Error("problem", { cause: new Error("root") }); /baseline/d.hasIndices; +type __BaselineLegacyRegExpStatic = + | "$1" + | "$2" + | "$3" + | "$4" + | "$5" + | "$6" + | "$7" + | "$8" + | "$9" + | "input" + | "$_" + | "lastMatch" + | "$&" + | "lastParen" + | "$+" + | "leftContext" + | "$`" + | "rightContext" + | "$'"; +type __BaselineAssertNever = T; +type __BaselineHasNoLegacyRegExpStatics = __BaselineAssertNever< + Extract<__BaselineLegacyRegExpStatic, keyof typeof RegExp> +>; (function probeCaller() {}).caller; // Error "baseline".substr(1); // Error new RegExp("baseline").compile("baseline"); // Error diff --git a/generated/current/baseline.d.ts b/generated/current/baseline.d.ts index d06951b..fcf8bed 100644 --- a/generated/current/baseline.d.ts +++ b/generated/current/baseline.d.ts @@ -6612,21 +6612,6 @@ interface RegExpConstructor { (pattern: string, flags?: string): RegExp; readonly "prototype": RegExp; - - /** @deprecated A legacy feature for browser compatibility */ - "$_": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$&": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$+": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$`": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$'": string; } declare var RegExp: RegExpConstructor; diff --git a/generated/current/year/2020/index.d.ts b/generated/current/year/2020/index.d.ts index e0d624d..e34efaf 100644 --- a/generated/current/year/2020/index.d.ts +++ b/generated/current/year/2020/index.d.ts @@ -4267,21 +4267,6 @@ interface RegExpConstructor { (pattern: string, flags?: string): RegExp; readonly "prototype": RegExp; - - /** @deprecated A legacy feature for browser compatibility */ - "$_": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$&": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$+": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$`": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$'": string; } declare var RegExp: RegExpConstructor; diff --git a/generated/current/year/2021/index.d.ts b/generated/current/year/2021/index.d.ts index 50259b7..4709804 100644 --- a/generated/current/year/2021/index.d.ts +++ b/generated/current/year/2021/index.d.ts @@ -5481,21 +5481,6 @@ interface RegExpConstructor { (pattern: string, flags?: string): RegExp; readonly "prototype": RegExp; - - /** @deprecated A legacy feature for browser compatibility */ - "$_": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$&": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$+": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$`": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$'": string; } declare var RegExp: RegExpConstructor; diff --git a/generated/current/year/2022/index.d.ts b/generated/current/year/2022/index.d.ts index d3a3527..03486ec 100644 --- a/generated/current/year/2022/index.d.ts +++ b/generated/current/year/2022/index.d.ts @@ -6174,21 +6174,6 @@ interface RegExpConstructor { (pattern: string, flags?: string): RegExp; readonly "prototype": RegExp; - - /** @deprecated A legacy feature for browser compatibility */ - "$_": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$&": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$+": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$`": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$'": string; } declare var RegExp: RegExpConstructor; diff --git a/generated/current/year/2023/index.d.ts b/generated/current/year/2023/index.d.ts index d06951b..fcf8bed 100644 --- a/generated/current/year/2023/index.d.ts +++ b/generated/current/year/2023/index.d.ts @@ -6612,21 +6612,6 @@ interface RegExpConstructor { (pattern: string, flags?: string): RegExp; readonly "prototype": RegExp; - - /** @deprecated A legacy feature for browser compatibility */ - "$_": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$&": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$+": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$`": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$'": string; } declare var RegExp: RegExpConstructor; diff --git a/generated/current/year/2024/index.d.ts b/generated/current/year/2024/index.d.ts index f146a43..065dad2 100644 --- a/generated/current/year/2024/index.d.ts +++ b/generated/current/year/2024/index.d.ts @@ -6953,21 +6953,6 @@ interface RegExpConstructor { (pattern: string, flags?: string): RegExp; readonly "prototype": RegExp; - - /** @deprecated A legacy feature for browser compatibility */ - "$_": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$&": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$+": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$`": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$'": string; } declare var RegExp: RegExpConstructor; diff --git a/generated/current/year/2025/index.d.ts b/generated/current/year/2025/index.d.ts index 9a639fa..268e7a9 100644 --- a/generated/current/year/2025/index.d.ts +++ b/generated/current/year/2025/index.d.ts @@ -7694,21 +7694,6 @@ declare global { (pattern: string, flags?: string): RegExp; readonly "prototype": RegExp; - - /** @deprecated A legacy feature for browser compatibility */ - "$_": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$&": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$+": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$`": string; - - /** @deprecated A legacy feature for browser compatibility */ - "$'": string; } var RegExp: RegExpConstructor; diff --git a/lib/classifier.mjs b/lib/classifier.mjs index 1169bd6..7c973e9 100644 --- a/lib/classifier.mjs +++ b/lib/classifier.mjs @@ -20,6 +20,7 @@ import { findLongestCompatRoot, getCompatSegments, getDeclarationUnits, + getMemberUnits, getPreferredBaseDeclarationUnits, getPreferredMemberUnits, getRootSurface, @@ -271,6 +272,12 @@ export function classifyCompatRowsForTarget(options) { compatManagementRegistry: options.compatManagementRegistry, typedArrayFamily, })); + validateDeclarationMappingCoverage({ + classifiedCompatRows, + compatManagementRegistry: options.compatManagementRegistry, + inventory: options.inventory, + typedArrayFamily, + }); validateCompatManagementRegistry({ classifiedCompatRows, compatManagementRegistry: options.compatManagementRegistry, @@ -278,6 +285,85 @@ export function classifyCompatRowsForTarget(options) { return classifiedCompatRows; } +/** + * A declaration mapping opts its root container into reverse-provenance + * auditing. Every member in that container must be claimed by at least one + * compat row, so a new TypeScript-only alias cannot be promoted implicitly by + * complete-container generation. + * + * @param {{ + * classifiedCompatRows: ClassifiedCompatRow[]; + * compatManagementRegistry: CompatManagementRegistry; + * inventory: import("./surface-inventory.mjs").SurfaceInventory; + * typedArrayFamily: () => { instanceSymbols: string[]; constructorSymbols: string[]; }; + * }} options + */ +function validateDeclarationMappingCoverage(options) { + const mappedEntries = options.compatManagementRegistry.entries.flatMap(entry => + entry.declarationMapping ? [{ entry, declarationMapping: entry.declarationMapping }] : [] + ); + if (!mappedEntries.length) { + return; + } + + const classifiedRowByCompatKey = new Map( + options.classifiedCompatRows.map(row => [row.compatKey, row]), + ); + const claimedMemberKeys = new Set( + options.classifiedCompatRows + .flatMap(row => row.resolvedUnitIds) + .map(unitId => options.inventory.unitById.get(unitId)) + .flatMap(unit => unit?.ownerSymbol && unit.memberName + ? [getMemberClaimKey(unit.ownerSymbol, unit.memberName)] + : []), + ); + const auditedContainerSymbols = new Set(); + + for (const { entry, declarationMapping } of mappedEntries) { + const classifiedRow = classifiedRowByCompatKey.get(entry.compatKey); + if (!classifiedRow) { + throw new Error(`Declaration mapping ${entry.compatKey} has no classified compat row`); + } + const rootResolution = resolveCompatRoot( + options.inventory, + getCompatSegments(entry.compatKey), + entry, + options.typedArrayFamily, + ); + if (!rootResolution?.rootSurface) { + throw new Error(`Declaration mapping ${entry.compatKey} has no root surface for ${classifiedRow.compatRoot}`); + } + for (const symbol of getDeclarationMappingContainerSymbols(rootResolution.rootSurface, declarationMapping.scope)) { + auditedContainerSymbols.add(symbol); + } + } + + const unclaimedMembers = [...auditedContainerSymbols] + .flatMap(symbol => options.inventory.memberUnitsByOwnerSymbol.get(symbol) ?? []) + .flatMap(unit => + unit.ownerSymbol + && unit.memberName + && !claimedMemberKeys.has(getMemberClaimKey(unit.ownerSymbol, unit.memberName)) + ? [`${unit.ownerSymbol}.${unit.memberName}`] + : [] + ); + const uniqueUnclaimedMembers = [...new Set(unclaimedMembers)].sort(compareStringsCaseSensitive); + if (uniqueUnclaimedMembers.length) { + throw new Error([ + "Declaration mapping coverage drift detected. Members in mapped containers lack compat claims:", + ...uniqueUnclaimedMembers.map(member => `- ${member}`), + ].join("\n")); + } +} + +/** + * @param {string} ownerSymbol + * @param {string} memberName + */ +function getMemberClaimKey(ownerSymbol, memberName) { + return `${ownerSymbol}\0${memberName}`; +} + /** * @param {{ * classifiedCompatRows: ClassifiedCompatRow[]; @@ -455,6 +541,21 @@ function classifyCompatRow(options) { const tailSegments = compatSegments.slice(compatRoot.split(".").length); + if (compatManagement?.declarationMapping) { + return createClassifiedRow( + compatRow, + compatRoot, + includeInTarget, + resolveDeclarationMapping({ + compatKey: compatRow.compatKey, + declarationMapping: compatManagement.declarationMapping, + inventory, + rootSurface, + }), + compatManagement, + ); + } + const manualResolution = resolveManualClassification({ compatRow, compatRoot, @@ -524,6 +625,70 @@ function classifyCompatRow(options) { ); } +/** + * @param {{ + * compatKey: string; + * declarationMapping: DeclarationMapping; + * inventory: import("./surface-inventory.mjs").SurfaceInventory; + * rootSurface: import("./surface-inventory.mjs").RootSurfaceRecord; + * }} options + */ +function resolveDeclarationMapping(options) { + const containerSymbols = getDeclarationMappingContainerSymbols( + options.rootSurface, + options.declarationMapping.scope, + ); + if (!containerSymbols.length) { + throw new Error( + `Declaration mapping ${options.compatKey} found no ${options.declarationMapping.scope} containers`, + ); + } + + const unitsByContainerAndMember = containerSymbols.flatMap(containerSymbol => + options.declarationMapping.memberNames.map(memberName => ({ + containerSymbol, + memberName, + units: getMemberUnits(options.inventory, containerSymbol, memberName), + })) + ); + const missingMembers = unitsByContainerAndMember + .filter(entry => !entry.units.length) + .map(entry => `${entry.containerSymbol}.${entry.memberName}`); + if (missingMembers.length) { + throw new Error( + `Declaration mapping ${options.compatKey} could not resolve ${options.declarationMapping.scope} members: ` + + missingMembers.join(", "), + ); + } + + return { + resolutionKind: "member", + resolvedUnitIds: [...new Set(unitsByContainerAndMember.flatMap(entry => entry.units.map(unit => unit.id)))] + .sort(compareStringsCaseSensitive), + notes: `${options.compatKey} maps to ${options.declarationMapping.scope} members ${options.declarationMapping.memberNames.join(", ")}`, + transforms: [], + }; +} + +/** + * @param {import("./surface-inventory.mjs").RootSurfaceRecord} rootSurface + * @param {DeclarationMapping["scope"]} scope + */ +function getDeclarationMappingContainerSymbols(rootSurface, scope) { + const containerSymbols = scope === "static" ? rootSurface.staticContainerSymbols : rootSurface.instanceContainerSymbols; + const oppositeContainerSymbols = scope === "static" ? rootSurface.instanceContainerSymbols : rootSurface.staticContainerSymbols; + const ambiguousSymbols = [...containerSymbols] + .filter(symbol => oppositeContainerSymbols.has(symbol)) + .sort(compareStringsCaseSensitive); + if (ambiguousSymbols.length) { + throw new Error( + `Declaration mapping cannot distinguish ${scope} members on shared containers: ${ambiguousSymbols.join(", ")}`, + ); + } + return [...containerSymbols] + .sort(compareStringsCaseSensitive); +} + /** * @param {import("./surface-inventory.mjs").SurfaceInventory} inventory * @param {string[]} compatSegments @@ -844,26 +1009,6 @@ function resolveManualClassification(options) { const qualifierPath = tailSegments.join("."); - if (compatRow.compatKey === "javascript.builtins.RegExp.n") { - // BCD represents RegExp's legacy static properties $1..$9 with a single - // key "RegExp.n". TypeScript's lib models them individually as - // "$1".."$9" on RegExpConstructor, so map them explicitly. Without the - // mapping, this falls to not-modeled-upstream and could be emitted via - // complete-container promotion despite being excluded (baselineStatus: - // false). - const legacyMatchUnits = Array.from({ length: 9 }, (_, index) => `$${index + 1}`) - .flatMap(memberName => getPreferredMemberUnits(inventory, "RegExpConstructor", memberName)); - if (!legacyMatchUnits.length) { - throw new Error(`Unable to resolve RegExpConstructor $1..$9 for ${compatRow.compatKey}`); - } - return { - resolutionKind: "member", - resolvedUnitIds: [...new Set(legacyMatchUnits.map(unit => unit.id))].sort(compareStringsCaseSensitive), - notes: `${compatRow.compatKey} maps to the legacy RegExpConstructor $1..$9 static properties`, - transforms: [], - }; - } - if (compatRow.compatKey === "javascript.builtins.JSON.parse.reviver_parameter_context_argument") { // Watchdog on a pinned verdict: this row is treated as excluded on the // assumption that TypeScript's JSON.parse reviver doesn't yet model the @@ -1284,12 +1429,20 @@ function isRegistryManagedResolutionKind(resolutionKind) { * upstreamState: string; * compatRoot?: string; * expectedResolutionKinds?: string[]; + * declarationMapping?: DeclarationMapping; * reason: string; * sourceUrls: string[]; * externalAction?: any; * }} CompatManagementEntry */ +/** + * @typedef {{ + * scope: "instance" | "static"; + * memberNames: string[]; + * }} DeclarationMapping + */ + /** * @typedef {{ * kind: string; diff --git a/lib/compat-management-registry.mjs b/lib/compat-management-registry.mjs index a275354..7c68504 100644 --- a/lib/compat-management-registry.mjs +++ b/lib/compat-management-registry.mjs @@ -30,6 +30,15 @@ export async function loadCompatManagementRegistry(filePath) { const entries = []; /** @type {Map} */ const entryByCompatKey = new Map(); + /** @type {Map} */ + const declarationMappingByCompatKey = new Map( + Object.entries(data.declarationMappings ?? {}) + .sort(([left], [right]) => compareStringsCaseSensitive(left, right)) + .map(([compatKey, mapping]) => [compatKey, { + scope: mapping.scope, + memberNames: [...mapping.memberNames], + }]), + ); for (const rawGroup of data.groups) { const group = normalizeCompatManagementGroup(rawGroup); @@ -46,6 +55,7 @@ export async function loadCompatManagementRegistry(filePath) { upstreamState: group.upstreamState, compatRoot: group.compatRoot, expectedResolutionKinds: group.expectedResolutionKinds, + declarationMapping: declarationMappingByCompatKey.get(compatKey), reason: group.reason, sourceUrls: group.sourceUrls, externalAction: group.externalAction, @@ -55,6 +65,15 @@ export async function loadCompatManagementRegistry(filePath) { } } + const unmanagedMappingKeys = [...declarationMappingByCompatKey.keys()] + .filter(compatKey => !entryByCompatKey.has(compatKey)); + if (unmanagedMappingKeys.length) { + throw new Error( + `Compat management registry ${filePath} declares mappings for unmanaged compat keys:\n` + + unmanagedMappingKeys.map(compatKey => `- ${compatKey}`).join("\n"), + ); + } + return { kind: data.kind, schemaVersion: data.schemaVersion, @@ -168,8 +187,16 @@ function createAjv2020(options) { * upstreamState: string; * compatRoot?: string; * expectedResolutionKinds?: string[]; + * declarationMapping?: DeclarationMapping; * reason: string; * sourceUrls: string[]; * externalAction?: any; * }} CompatManagementEntry */ + +/** + * @typedef {{ + * scope: "instance" | "static"; + * memberNames: string[]; + * }} DeclarationMapping + */ diff --git a/lib/negative-probes.mjs b/lib/negative-probes.mjs index b0de9d5..1759ed8 100644 --- a/lib/negative-probes.mjs +++ b/lib/negative-probes.mjs @@ -28,6 +28,22 @@ * }} NegativeProbe */ +export const REGEXP_LEGACY_STATIC_MEMBER_NAMES = Object.freeze([ + "$1", "$2", "$3", "$4", "$5", "$6", "$7", "$8", "$9", + "input", "$_", "lastMatch", "$&", "lastParen", "$+", "leftContext", "$`", "rightContext", "$'", +]); + +export const REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION = [ + "type __BaselineLegacyRegExpStatic =", + ...REGEXP_LEGACY_STATIC_MEMBER_NAMES.map((memberName, index) => + ` | ${JSON.stringify(memberName)}${index === REGEXP_LEGACY_STATIC_MEMBER_NAMES.length - 1 ? ";" : ""}` + ), + "type __BaselineAssertNever = T;", + "type __BaselineHasNoLegacyRegExpStatics = __BaselineAssertNever<", + " Extract<__BaselineLegacyRegExpStatic, keyof typeof RegExp>", + ">;", +].join("\n"); + /** @type {NegativeProbe[]} */ export const STABLE_NEGATIVE_PROBES = [ { diff --git a/registry/compat-management.json b/registry/compat-management.json index 2a550ee..f73c836 100644 --- a/registry/compat-management.json +++ b/registry/compat-management.json @@ -2,6 +2,57 @@ "$schema": "./compat-management.schema.json", "kind": "typescript-baseline-lib/compat-management-registry", "schemaVersion": 1, + "declarationMappings": { + "javascript.builtins.RegExp.input": { + "scope": "static", + "memberNames": [ + "input", + "$_" + ] + }, + "javascript.builtins.RegExp.lastMatch": { + "scope": "static", + "memberNames": [ + "lastMatch", + "$&" + ] + }, + "javascript.builtins.RegExp.lastParen": { + "scope": "static", + "memberNames": [ + "lastParen", + "$+" + ] + }, + "javascript.builtins.RegExp.leftContext": { + "scope": "static", + "memberNames": [ + "leftContext", + "$`" + ] + }, + "javascript.builtins.RegExp.n": { + "scope": "static", + "memberNames": [ + "$1", + "$2", + "$3", + "$4", + "$5", + "$6", + "$7", + "$8", + "$9" + ] + }, + "javascript.builtins.RegExp.rightContext": { + "scope": "static", + "memberNames": [ + "rightContext", + "$'" + ] + } + }, "groups": [ { "id": "generator-function-partial-model", @@ -421,8 +472,32 @@ "javascript.builtins.Object.defineSetter", "javascript.builtins.Object.lookupGetter", "javascript.builtins.Object.lookupSetter", - "javascript.builtins.Object.proto", - "javascript.builtins.RegExp.n" + "javascript.builtins.Object.proto" + ] + }, + { + "id": "regexp-legacy-statics-excluded", + "category": "legacy_excluded", + "delivery": "exclude", + "upstreamState": "settled", + "expectedResolutionKinds": [ + "member" + ], + "reason": "TypeScript models the RegExp legacy static aliases as separate properties, but they share the same compatibility status and must be excluded together.", + "sourceUrls": [ + "https://github.com/tc39/proposal-regexp-legacy-features/" + ], + "externalAction": { + "kind": "none", + "note": "No external action. Keep canonical names and aliases bound to the same compat rows." + }, + "compatKeys": [ + "javascript.builtins.RegExp.input", + "javascript.builtins.RegExp.lastMatch", + "javascript.builtins.RegExp.lastParen", + "javascript.builtins.RegExp.leftContext", + "javascript.builtins.RegExp.n", + "javascript.builtins.RegExp.rightContext" ] } ] diff --git a/registry/compat-management.schema.json b/registry/compat-management.schema.json index 223d210..1b004ff 100644 --- a/registry/compat-management.schema.json +++ b/registry/compat-management.schema.json @@ -19,6 +19,16 @@ "schemaVersion": { "const": 1 }, + "declarationMappings": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "$ref": "#/$defs/declarationMapping" + } + }, "groups": { "type": "array", "items": { @@ -27,6 +37,31 @@ } }, "$defs": { + "declarationMapping": { + "type": "object", + "required": [ + "scope", + "memberNames" + ], + "additionalProperties": false, + "properties": { + "scope": { + "enum": [ + "instance", + "static" + ] + }, + "memberNames": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, "group": { "type": "object", "required": [ diff --git a/test/classifier.test.mjs b/test/classifier.test.mjs index 95a5aa9..c015808 100644 --- a/test/classifier.test.mjs +++ b/test/classifier.test.mjs @@ -156,6 +156,8 @@ const FIXTURE_LIB_SOURCE = [ /** * @param {{ * rows: Array>; + * declarationMappings?: Record>; + * additionalLibSources?: Record; * registryGroups?: Array>; * baselineTarget?: string; * libSource?: string; @@ -170,6 +172,9 @@ async function classifyFixture(options) { version: "0.0.0-test", }); fs.writeFileSync(path.join(libDirectory, "lib.es5.d.ts"), options.libSource ?? FIXTURE_LIB_SOURCE); + for (const [fileName, sourceText] of Object.entries(options.additionalLibSources ?? {})) { + fs.writeFileSync(path.join(libDirectory, fileName), sourceText); + } const sourceLibEntries = await discoverBuiltinSourceLibEntries({ libDirectory, @@ -190,6 +195,7 @@ async function classifyFixture(options) { writeJsonFile(path.join(tempDirectory, "registry.json"), { kind: "typescript-baseline-lib/compat-management-registry", schemaVersion: 1, + ...(options.declarationMappings ? { declarationMappings: options.declarationMappings } : {}), groups: options.registryGroups ?? [], }); @@ -349,6 +355,138 @@ test("classifier routes synthetic compat rows to the expected resolution kinds", assert.equal(argumentsCallee.includeInTarget, false); }); +test("classifier resolves declaration mappings and audits their containers", async () => { + const compatKey = "javascript.builtins.Widget.legacy"; + const widgetLibSource = [ + "interface UnrelatedHelper {", + ' "$future": string;', + "}", + "interface Widget {}", + "declare var Widget: WidgetConstructor;", + "interface WidgetConstructor {", + " new(): Widget;", + " readonly prototype: Widget;", + " legacy: string;", + ' "$alias": string;', + "}", + "", + ].join("\n"); + const rows = [ + row("javascript.builtins.Widget", "high"), + row("javascript.builtins.Widget.Widget", "high"), + row(compatKey, false), + ]; + const declarationMappings = { + [compatKey]: { + scope: "static", + memberNames: ["legacy", "$alias"], + }, + }; + const registryGroups = [{ + id: "widget-legacy", + category: "legacy_excluded", + delivery: "exclude", + upstreamState: "settled", + expectedResolutionKinds: ["member"], + reason: "Synthetic declaration mapping fixture.", + sourceUrls: ["https://example.com/widget-legacy"], + externalAction: { kind: "none", note: "Synthetic fixture." }, + compatKeys: [compatKey], + }]; + + const classification = await classifyFixture({ + libSource: widgetLibSource, + additionalLibSources: { + "lib.es2015.core.d.ts": [ + "interface WidgetConstructor {", + ' "$alias": string;', + "}", + "", + ].join("\n"), + }, + rows, + declarationMappings, + registryGroups, + }); + const classifiedRow = findRow(classification, compatKey); + assert.equal(classifiedRow.resolutionKind, "member"); + assert.deepEqual( + classifiedRow.resolvedUnitIds.map(unitId => unitId.match(/WidgetConstructor\.(.+)::\d+$/)?.[1]).sort(), + ["$alias", "$alias", "legacy"], + ); + + await assert.rejects( + classifyFixture({ + libSource: widgetLibSource.replace(' "$alias": string;\n', ""), + rows, + declarationMappings, + registryGroups, + }), + /Declaration mapping javascript\.builtins\.Widget\.legacy could not resolve static members: WidgetConstructor\.\$alias/, + ); + + await assert.rejects( + classifyFixture({ + libSource: widgetLibSource.replace(' "$alias": string;\n', ' "$alias": string;\n "$future": string;\n'), + rows, + declarationMappings, + registryGroups, + }), + /Members in mapped containers lack compat claims:[\s\S]*WidgetConstructor\.\$future/, + ); + + await assert.rejects( + classifyFixture({ + libSource: [ + "declare class Widget {", + " static legacy: string;", + "}", + "", + ].join("\n"), + rows: [row("javascript.builtins.Widget", "high"), row(compatKey, false)], + declarationMappings: { + [compatKey]: { scope: "static", memberNames: ["legacy"] }, + }, + registryGroups, + }), + /Declaration mapping cannot distinguish static members on shared containers: Widget/, + ); +}); + +test("classifier audits declaration mappings on synthetic roots", async () => { + const compatKey = "javascript.builtins.TypedArray.at"; + const rows = [row(compatKey, false), ...typedArrayFamilyRows()]; + const declarationMappings = { + [compatKey]: { scope: "instance", memberNames: ["at"] }, + }; + const registryGroups = [{ + id: "typed-array-mapping", + category: "legacy_excluded", + delivery: "exclude", + upstreamState: "settled", + expectedResolutionKinds: ["member"], + reason: "Synthetic root declaration mapping fixture.", + sourceUrls: ["https://example.com/typed-array-mapping"], + externalAction: { kind: "none", note: "Synthetic fixture." }, + compatKeys: [compatKey], + }]; + const classification = await classifyFixture({ rows, declarationMappings, registryGroups }); + + const classifiedRow = findRow(classification, compatKey); + assert.equal(classifiedRow.resolutionKind, "member"); + assert.equal(classifiedRow.resolvedUnitIds.length, 12); + + await assert.rejects( + classifyFixture({ + libSource: FIXTURE_LIB_SOURCE.replace(" at(index: number): number;\n", ""), + rows, + declarationMappings, + registryGroups, + }), + /could not resolve instance members: Int8Array\.at/, + ); +}); + test("classifier honors the low baseline target and rejects unknown targets", async () => { const lowTarget = await classifyFixture({ rows: [row("javascript.builtins.Iterator.map", "low")], diff --git a/test/compat-management-schema.test.mjs b/test/compat-management-schema.test.mjs index c253507..3a61376 100644 --- a/test/compat-management-schema.test.mjs +++ b/test/compat-management-schema.test.mjs @@ -25,6 +25,47 @@ test("compat-management registry accepts the canonical registry", async () => { assert.equal(registry.kind, "typescript-baseline-lib/compat-management-registry"); assert.ok(registry.groups.length > 0); assert.ok(registry.entries.length > 0); + assert.deepEqual( + registry.entryByCompatKey.get("javascript.builtins.RegExp.input")?.declarationMapping, + { scope: "static", memberNames: ["input", "$_"] }, + ); +}); + +test("compat-management registry validates declaration mappings", async () => { + const tempDirectory = createTempDirectory(tempDirectories); + const registry = readJsonFile(repoRegistryPath); + const invalidSchemaPath = path.join(tempDirectory, "compat-management.mapping-schema.json"); + writeJsonFile(invalidSchemaPath, { + ...registry, + declarationMappings: { + ...registry.declarationMappings, + "javascript.builtins.RegExp.input": { + scope: "both", + memberNames: ["input"], + stray: true, + }, + }, + }); + await assert.rejects( + () => loadCompatManagementRegistry(invalidSchemaPath), + /failed JSON schema validation/, + ); + + const unmanagedMappingPath = path.join(tempDirectory, "compat-management.unmanaged-mapping.json"); + writeJsonFile(unmanagedMappingPath, { + ...registry, + declarationMappings: { + ...registry.declarationMappings, + "javascript.builtins.Widget.ghost": { + scope: "static", + memberNames: ["ghost"], + }, + }, + }); + await assert.rejects( + () => loadCompatManagementRegistry(unmanagedMappingPath), + /declares mappings for unmanaged compat keys:[\s\S]*javascript\.builtins\.Widget\.ghost/, + ); }); test("compat-management registry fails schema validation on unexpected properties and enum drift", async () => { diff --git a/test/consumer-smoke.test.mjs b/test/consumer-smoke.test.mjs index bed220d..aad6407 100644 --- a/test/consumer-smoke.test.mjs +++ b/test/consumer-smoke.test.mjs @@ -5,7 +5,10 @@ import { execFileSync } from "node:child_process"; import path from "node:path"; import test from "node:test"; import { baselinePackageName } from "../deploy/package-registry.mjs"; -import { renderNegativeProbeSource } from "../lib/negative-probes.mjs"; +import { + REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION, + renderNegativeProbeSource, +} from "../lib/negative-probes.mjs"; import { cleanupTempDirectories, loadActiveNegativeProbesFromRepo, @@ -76,6 +79,7 @@ test("staged consumer smoke: stock tsc accepts supported baseline APIs and rejec "const reversed = [1, 2, 3].toReversed();", "Intl.supportedValuesOf(\"currency\");", "reversed.length;", + REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION, "", ].join("\n")); diff --git a/test/generate.test.mjs b/test/generate.test.mjs index 0efe69b..d455aa4 100644 --- a/test/generate.test.mjs +++ b/test/generate.test.mjs @@ -29,6 +29,7 @@ test("generate emits the current TypeScript-declarable Baseline JavaScript surfa const fixture = createManifest(tempDirectory); /** @type {{ featureRows: unknown[]; compatRows: Array<{ compatKey: string; baselineStatus: string | boolean | undefined; }>; }} */ const dataset = readJsonFile(repoDatasetPath); + const registry = readJsonFile(repoRegistryPath); const expectedLibCompatRows = dataset.compatRows.filter(row => isLibCompatKey(row.compatKey)); const expectedHighCompatRows = expectedLibCompatRows.filter(row => row.baselineStatus === "high"); @@ -106,10 +107,6 @@ test("generate emits the current TypeScript-declarable Baseline JavaScript surfa assert.doesNotMatch(topLevelOutput, probe.pattern, `excluded runtime surface ${probe.name} must stay absent`); } - // RegExp legacy statics (BCD single key RegExp.n) must not leak in either. - assert.doesNotMatch(topLevelOutput, /"\$1"/); - assert.doesNotMatch(topLevelOutput, /"lastMatch"/); - // The exclusion-invariant audit info must appear in the report. assert.ok(generationReport.summary.excludedUnitCount > 0); assert.ok(Array.isArray(generationReport.excludedUnits)); @@ -119,6 +116,28 @@ test("generate emits the current TypeScript-declarable Baseline JavaScript surfa entry => entry.compatKeys.includes("javascript.builtins.Function.caller"), ), ); + const classifiedRowByCompatKey = new Map(classificationReport.classifiedCompatRows.map( + /** @param {{ compatKey: string; }} row */ + row => [row.compatKey, row], + )); + const excludedUnitById = new Map(generationReport.excludedUnits.map( + /** @param {{ unitId: string; }} entry */ + entry => [entry.unitId, entry], + )); + for (const compatKey of Object.keys(registry.declarationMappings ?? {})) { + const classifiedRow = classifiedRowByCompatKey.get(compatKey); + assert.ok(classifiedRow, `expected mapped classification row ${compatKey}`); + assert.equal(classifiedRow.resolutionKind, "member"); + assert.ok(classifiedRow.resolvedUnitIds.length > 0); + if (!classifiedRow.includeInTarget) { + for (const unitId of classifiedRow.resolvedUnitIds) { + assert.ok( + excludedUnitById.get(unitId)?.compatKeys.includes(compatKey), + `mapped unit ${unitId} must retain exclusion provenance for ${compatKey}`, + ); + } + } + } assert.equal(classificationReport.summary.featureCount, dataset.featureRows.length); assert.equal(classificationReport.summary.compatCount, dataset.compatRows.length); diff --git a/test/negative-probes.test.mjs b/test/negative-probes.test.mjs index 8372cee..89d8b27 100644 --- a/test/negative-probes.test.mjs +++ b/test/negative-probes.test.mjs @@ -6,12 +6,16 @@ import path from "node:path"; import test from "node:test"; import { LOW_NEGATIVE_PROBE_CANDIDATES, + REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION, + REGEXP_LEGACY_STATIC_MEMBER_NAMES, STABLE_NEGATIVE_PROBES, renderNegativeProbeSource, selectActiveNegativeProbes, } from "../lib/negative-probes.mjs"; import { loadActiveNegativeProbesFromRepo, + readJsonFile, + repoRegistryPath, repoRoot, } from "./helpers.mjs"; @@ -24,6 +28,13 @@ const compilerFixturePath = path.join( "compiler", "libBaseline.ts", ); +const positiveSmokeFixturePath = path.join( + repoRoot, + "fixtures", + "typescript", + "smoke", + "positive-flag.ts", +); test("checked-in classification still excludes every stable probe and at least one low probe", () => { // If a stable probe flips to included or the low pool is wiped out, this @@ -44,7 +55,7 @@ test("checked-in classification still excludes every stable probe and at least o ); }); -test("compiler fixture negatives stay in sync with the stable probes", () => { +test("TypeScript fixtures stay in sync with stable exclusion checks", () => { // Reference baselines are static expected values, so the compiler fixture // uses only permanently stable probes. This catches any drift between the // fixture and the probe definitions. @@ -61,6 +72,30 @@ test("compiler fixture negatives stay in sync with the stable probes", () => { `compiler fixture must not hard-code dated low probe: ${candidate.compatKey}`, ); } + assert.ok( + fixtureSource.includes(REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION), + "compiler fixture must contain the RegExp legacy static absence assertion", + ); + assert.ok( + fs.readFileSync(positiveSmokeFixturePath, "utf8").includes(REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION), + "upstream smoke fixture must contain the RegExp legacy static absence assertion", + ); + + const registry = readJsonFile(repoRegistryPath); + const legacyGroup = registry.groups.find( + /** @param {{ id: string; }} group */ + group => group.id === "regexp-legacy-statics-excluded", + ); + assert.ok(legacyGroup); + const mappedMemberNames = legacyGroup.compatKeys.flatMap( + /** @param {string} compatKey */ + compatKey => registry.declarationMappings[compatKey].memberNames, + ); + assert.deepEqual( + [...new Set(mappedMemberNames)].sort(), + [...REGEXP_LEGACY_STATIC_MEMBER_NAMES].sort(), + "compiler assertion and declaration mappings must cover the same RegExp legacy statics", + ); }); test("selectActiveNegativeProbes fails with actionable messages", () => { diff --git a/test/packed-consumer-smoke.test.mjs b/test/packed-consumer-smoke.test.mjs index c41327e..95faed2 100644 --- a/test/packed-consumer-smoke.test.mjs +++ b/test/packed-consumer-smoke.test.mjs @@ -4,7 +4,10 @@ import assert from "node:assert/strict"; import path from "node:path"; import test from "node:test"; import { baselinePackageName } from "../deploy/package-registry.mjs"; -import { renderNegativeProbeSource } from "../lib/negative-probes.mjs"; +import { + REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION, + renderNegativeProbeSource, +} from "../lib/negative-probes.mjs"; import { cleanupTempDirectories, createBaselinePackageTarball, @@ -63,6 +66,7 @@ test("packed consumer smoke: npm-packed baseline package typechecks through comp "const result = Promise.withResolvers();", "reversed.length + values.length;", "result.promise;", + REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION, "", ].join("\n")); diff --git a/test/year-entrypoints.test.mjs b/test/year-entrypoints.test.mjs index 0de9e34..dec28df 100644 --- a/test/year-entrypoints.test.mjs +++ b/test/year-entrypoints.test.mjs @@ -12,6 +12,7 @@ import { } from "../deploy/package-lib.mjs"; import { baselinePackageName } from "../deploy/package-registry.mjs"; import { resolveBaselineYears } from "../lib/generator.mjs"; +import { REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION } from "../lib/negative-probes.mjs"; import { buildUpdateSummary, renderUpdateMarkdown, @@ -146,7 +147,10 @@ test("packed year entrypoints enforce API boundaries under TypeScript 6 and 7", repoManifest.firstClassLib.firstYear, )) { const name = `standalone-${year}`; - writeTextFile(path.join(consumerDirectory, `${name}.ts`), "export {};\n"); + writeTextFile( + path.join(consumerDirectory, `${name}.ts`), + `${REGEXP_LEGACY_STATIC_ABSENCE_ASSERTION}\n`, + ); writeConsumerConfig(consumerDirectory, name, [`${baselinePackageName}/year/${year}`]); const configPath = path.join(consumerDirectory, `tsconfig.${name}.json`); runTsc(["-p", configPath], { cwd: consumerDirectory });