From 7f920128cdd6295b3bfd38d0b0d38be01be1421c Mon Sep 17 00:00:00 2001 From: oratis Date: Tue, 25 Aug 2026 23:36:20 +0800 Subject: [PATCH] test: hold type packages at the runtime floor, not the newest release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #262 and #279 are the same defect a day apart. The typescript group swept `@types/vscode` to ^1.125.0 against `engines.vscode: ^1.85.0`, and `@types/node` was proposed at ^26.2.0 against `engines.node: >=22` across all seven manifests. Type packages decide which APIs the compiler accepts, so they have to describe the floor we claim to run on. Point them at the newest release and a call added after that floor typechecks cleanly and throws on the runtime we promised — and the suite will not catch it, because CI runs the floor and the broken call is in whatever path the tests skip. `vsce package` happens to refuse the vscode case, but only minutes into release:check. Nothing at all refuses the node case. The check compares to the precision each ecosystem actually uses. Node's unit is the MAJOR — `^22.10.0` against `>=22` is the intended pin and a minor comparison would fail it, which is exactly what the first draft of this test did. VS Code ships everything as 1.x, so its unit is the MINOR. Manifests are discovered rather than listed, so a new package is covered the day it is added. Verified both directions: re-applying ^26.2.0 fails the seven Node cases, re-applying ^1.125.0 fails the vscode case, and the current tree passes. Dependabot is told to ignore `@types/node` majors, with the reason next to it; minor and patch inside the supported major still flow through the typescript group. Co-Authored-By: Claude Opus 5 --- .github/dependabot.yml | 8 ++ scripts/types-track-supported-runtime.test.ts | 105 ++++++++++++++++++ scripts/vscode-types-engine.test.ts | 62 ----------- 3 files changed, 113 insertions(+), 62 deletions(-) create mode 100644 scripts/types-track-supported-runtime.test.ts delete mode 100644 scripts/vscode-types-engine.test.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index feaed9e..6af3277 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,14 @@ updates: # decision, not a dependency update. Raise `engines.vscode` deliberately # and move the types with it, together. - dependency-name: '@types/vscode' + # Same rule, same reason: `@types/node` describes the OLDEST Node we + # support (`engines.node`, which CI pins), not the newest release. Types + # above that floor let a call added in a later Node compile and then throw + # on the version we ship for — and unlike `@types/vscode` nothing refuses + # the build, so it fails in a user's terminal instead. Minor and patch + # inside the supported major still flow through the `typescript` group. + - dependency-name: '@types/node' + update-types: ['version-update:semver-major'] groups: typescript: patterns: diff --git a/scripts/types-track-supported-runtime.test.ts b/scripts/types-track-supported-runtime.test.ts new file mode 100644 index 0000000..8af9229 --- /dev/null +++ b/scripts/types-track-supported-runtime.test.ts @@ -0,0 +1,105 @@ +// Type packages track the OLDEST runtime we support, never the newest release. +// +// `@types/node` and `@types/vscode` are not ordinary dependencies. They decide +// which APIs the compiler will accept, so they have to describe the *floor* of +// what we claim to run on. Point them at the newest release and a call added +// after that floor typechecks cleanly and then throws on the runtime we +// promised to support — and the test suite will not catch it, because CI runs +// the floor and the broken call is in whatever path the tests do not execute. +// +// This is not hypothetical and it is not once: the `typescript` dependency +// group swept `@types/vscode` to ^1.125.0 against `engines.vscode: ^1.85.0` +// (#262), and `@types/node` was proposed at ^26.2.0 against +// `engines.node: >=22` (#279) the same day. `vsce package` happens to catch the +// first, minutes into `release:check`; nothing at all catches the second. +// +// So check both here, in seconds, with a message that names the trade-off. +// Fixing a failure is never "take the bump": raising the floor drops support +// for every version in between, which is a decision to make deliberately and +// then apply to the types and the engine together. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const root = resolve(import.meta.dirname, '..'); +const readPkg = (path: string): Record => + JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Record; + +/** `^1.85.0`, `>=22`, `22.10.0` → the lowest version the range admits. */ +function floor(range: string): number[] { + const match = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(range); + if (!match) throw new Error(`cannot read a version out of ${JSON.stringify(range)}`); + return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)]; +} + +/** + * Compare two floors to `precision` components. + * + * The unit that carries meaning differs per ecosystem. Node's is the MAJOR: + * `@types/node@^22.10.0` against `engines.node: '>=22'` is the intended pin, and + * comparing minors would fail it. VS Code ships everything as `1.x`, so its unit + * is the MINOR — 1.85 versus 1.125 is the whole question there. + */ +function compare(a: number[], b: number[], precision: number): number { + for (let i = 0; i < precision; i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +/** Every workspace `package.json`, found rather than listed. */ +function manifests(dir: string = root, found: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (['node_modules', 'dist', 'target', 'out', '.git'].includes(entry)) continue; + const path = join(dir, entry); + if (statSync(path).isDirectory()) manifests(path, found); + else if (entry === 'package.json') found.push(relative(root, path)); + } + return found; +} + +const typesOf = (pkg: Record, name: string): string | undefined => + (pkg as { devDependencies?: Record; dependencies?: Record }) + .devDependencies?.[name] ?? + (pkg as { dependencies?: Record }).dependencies?.[name]; + +describe('@types/node', () => { + // The floor lives in the root `engines.node` and is what CI pins; every + // package in the workspace runs on it, so every package's types answer to it. + const engine = (readPkg('package.json') as { engines: { node: string } }).engines.node; + + it.each(manifests().filter((path) => typesOf(readPkg(path), '@types/node') !== undefined))( + '%s does not type against a newer Node than we support', + (path) => { + const types = typesOf(readPkg(path), '@types/node')!; + expect( + compare(floor(types), floor(engine), 1), + `${path} declares @types/node ${types} while the workspace supports Node ${engine}. ` + + `Types above the floor let a call added in a later Node compile and then fail on ` + + `the oldest one we ship for — raise engines.node deliberately, or hold the types.`, + ).toBeLessThanOrEqual(0); + }, + ); +}); + +describe('@types/vscode', () => { + const pkg = readPkg('apps/vscode/package.json') as { + engines: { vscode: string }; + devDependencies: Record; + }; + + it('does not type against a newer VS Code than the extension runs on', () => { + const engine = pkg.engines.vscode; + const types = pkg.devDependencies['@types/vscode']; + expect(types, 'apps/vscode must declare @types/vscode').toBeTypeOf('string'); + + expect( + compare(floor(types!), floor(engine), 2), + `@types/vscode ${types} is newer than engines.vscode ${engine}. ` + + `vsce package refuses this outright, but only minutes into release:check. ` + + `Raising engines.vscode drops every VS Code in between — decide that on purpose.`, + ).toBeLessThanOrEqual(0); + }); +}); diff --git a/scripts/vscode-types-engine.test.ts b/scripts/vscode-types-engine.test.ts deleted file mode 100644 index eb3435b..0000000 --- a/scripts/vscode-types-engine.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -// `@types/vscode` may never be newer than `engines.vscode`. -// -// `engines.vscode` states the OLDEST VS Code the extension supports. The types -// have to match that floor, not the newest release: compiling against 1.125's -// API surface while claiming to run on 1.85 lets an editor-version-gated call -// typecheck cleanly and then throw `undefined is not a function` on the older -// editor nobody tested. -// -// `vsce package` already enforces this and refuses to build: -// -// ERROR @types/vscode ^1.125.0 greater than engines.vscode ^1.85.0 -// -// but only inside `pnpm release:check`, several minutes into CI and after a -// full workspace build. A dependency bot proposing the newer types (#262) is -// the ordinary way this happens, so catch it in the unit suite where the -// message arrives in seconds — and where it names the reason rather than the -// symptom. -// -// Fixing it is not "take the bump": raising `engines.vscode` drops every VS -// Code between the two versions, which is a support decision to make on -// purpose. Move both together when you make it. - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const root = resolve(import.meta.dirname, '..'); -const pkg = JSON.parse(readFileSync(resolve(root, 'apps/vscode/package.json'), 'utf8')) as { - engines: { vscode: string }; - devDependencies: Record; -}; - -/** `^1.85.0` → `[1, 85, 0]`. Ranges here are always a caret over an exact version. */ -function floor(range: string): number[] { - const match = /(\d+)\.(\d+)\.(\d+)/.exec(range); - if (!match) throw new Error(`cannot read a version out of ${JSON.stringify(range)}`); - return [Number(match[1]), Number(match[2]), Number(match[3])]; -} - -function compare(a: number[], b: number[]): number { - for (let i = 0; i < 3; i++) { - const diff = (a[i] ?? 0) - (b[i] ?? 0); - if (diff !== 0) return diff; - } - return 0; -} - -describe('the VS Code extension', () => { - it('does not compile against a newer API than it claims to run on', () => { - const engine = pkg.engines.vscode; - const types = pkg.devDependencies['@types/vscode']; - expect(types, 'apps/vscode must declare @types/vscode').toBeTypeOf('string'); - - expect( - compare(floor(types!), floor(engine)), - `@types/vscode ${types} is newer than engines.vscode ${engine}. ` + - `The types must match the oldest supported editor, so either pin them back ` + - `or raise engines.vscode deliberately — raising it drops support for every ` + - `VS Code in between.`, - ).toBeLessThanOrEqual(0); - }); -});