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); - }); -});