Files
maestro/scripts/ui-typecheck-gate.test.ts
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

446 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// UI の型検査が「関所」として残り続けることを守るテスト (#1001)。
//
// issue #1001 の原因は個別の型エラーではなく、**UI 側に型検査の関所が無かった**
// こと。vite build は型を見ず、vitest は esbuild でトランスパイルするだけなので、
// 誰も止めないまま 12 ファイル 37 件が溜まった
// (origin/main = 34aa673e を素の状態でチェックアウトして `cd ui && npx tsc
// --noEmit` した実測。issue 本文の 30 件は起票時点の値)。
//
// 型エラーを潰すだけでは同じことが必ず再発する。だからこのテストが守るのは
// 「今エラーが 0 件であること」ではなく、**検査が実行される経路が残っていること**。
// 関所を外す変更は、ここで落ちる。
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const readJson = (p: string) => JSON.parse(readFileSync(join(repoRoot, p), 'utf8'));
/**
* ES2022 で標準ライブラリに入ったメソッドだけを使う検査用の小片 (#1314)。
* ui/tsconfig.json の lib がこれらを含んでいれば通り、含んでいなければ TS2550。
*/
const ES2022_PROBE_SOURCE = [
'const numbers = [1, 2, 3];',
'export const last = numbers.at(-1);',
"export const head = 'abc'.at(0);",
].join('\n');
/**
* ReadableStream を直接 for await で回す小片 (#1314)。
*
* この構文を成立させているのは lib.dom.asynciterable の 1 枚だけ。lib を明示した
* ときに書き起こし損ねやすい枚数で、落とすと「触っていないファイルが落ちる」が
* #1314 とは逆向きに起きる(今度は原因がこちらの設定変更になる)。
*/
const DOM_ASYNC_ITERABLE_PROBE_SOURCE = [
'export async function drain(stream: ReadableStream<Uint8Array>): Promise<number> {',
' let total = 0;',
' for await (const chunk of stream) total += chunk.byteLength;',
' return total;',
'}',
].join('\n');
/**
* ブラウザに無いもの——Node の型が漏れているかを見るための小片 3 つ (#1316)。
*
* 親ディレクトリの node_modules/@types が画面側の検査に混ざっていた頃は、3 つとも
* 診断ゼロで通っていた。とくに Buffer はブラウザに存在しないので、検査を素通りした
* まま出荷され、実行時に壊れる。1 つずつ分けてあるのは、どれが復活したかを
* 失敗メッセージだけで見分けられるようにするため。
*/
const NODE_TIMER_PROBE_SOURCE = 'export const handle: NodeJS.Timeout = setTimeout(() => {}, 0);';
const NODE_PROCESS_PROBE_SOURCE = 'export const flag = process.env.SOME_FLAG;';
const NODE_BUFFER_PROBE_SOURCE = "export const bytes = Buffer.from('hi');";
/** 上の 3 つの対照。ブラウザにあるものは通る、を同じ道具立てで確かめる。 */
const BROWSER_PROBE_SOURCE = [
'export const timer = window.setTimeout(() => {}, 0);',
'export const text = new TextDecoder().decode(new Uint8Array([104, 105]));',
].join('\n');
/**
* tsc が実際に組むファイル一覧を返す。
*
* 設定の字面(include / exclude / strict)を見るだけでは穴が残る。include は
* "src" のままで exclude に "src/**\/*.test.tsx" を足すと、227 本のテストが
* 黙って検査対象から外れる。解決済みの一覧を見れば、どちらの経路で抜いても落ちる。
*/
function resolveUiProgram(configName: string): string[] {
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, configName), ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
expect(parsed.errors).toEqual([]);
expect(parsed.options.strict).toBe(true);
return parsed.fileNames;
}
/**
* ui/tsconfig.json の compilerOptions だけで小さなコードを検査し、診断を返す。
*
* types を空にしてあるのが肝。TypeScript は node_modules/@types を親ディレクトリへ
* 遡って自動で取り込むので、既定のままだとリポジトリ直下の @types/* が画面側の
* 検査に混ざる。空にすると「画面側の設定だけで成り立っているか」を見られる。
*/
function diagnoseWithUiOptions(
source: string,
overrides: ts.CompilerOptions = {},
): readonly ts.Diagnostic[] {
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
expect(parsed.errors).toEqual([]);
const options: ts.CompilerOptions = {
...parsed.options,
...overrides,
types: [],
noEmit: true,
noResolve: true,
};
const probePath = join(uiDir, '__lib-probe__.ts');
const host = ts.createCompilerHost(options, true);
const readOriginal = host.readFile.bind(host);
const getOriginal = host.getSourceFile.bind(host);
host.readFile = (f) => (f === probePath ? source : readOriginal(f));
host.fileExists = (f) => f === probePath || ts.sys.fileExists(f);
host.getSourceFile = (f, langVersion, onError, shouldCreate) =>
f === probePath
? ts.createSourceFile(f, source, langVersion, true)
: getOriginal(f, langVersion, onError, shouldCreate);
const program = ts.createProgram([probePath], options, host);
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
}
/**
* 指定した設定ファイルの compilerOptions を**そのまま**使って小片を検査する (#1316)。
*
* 上の diagnoseWithUiOptions との違いは 2 点あり、どちらも外せない。
*
* 1. types を上書きしない。あちらは types: [] を強制するので、tsconfig.json が
* types を書いていようがいまいが同じ結果になる——つまり #1316 の検体を
* あちらに載せると、設定を直す前から通ってしまう(空振り)。ここは設定の
* 実物を見る必要があるので、上書きしない。
* 2. noResolve を立てない。noResolve は types で名指しした型定義の読み込みまで
* 止めてしまい、types: ["node"] を書いても Node の型が入らなくなる(実測)。
* それでは tsconfig.typecheck.json 側の検査が意味を失う。
*
* 小片は ui/ 直下に置いたことにする。@types の探索は置き場所から始まるので、
* 本物のファイルと同じ条件になる。
*/
function diagnoseWithConfigAsWritten(
configName: string,
source: string,
overrides: ts.CompilerOptions = {},
): readonly ts.Diagnostic[] {
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, configName), ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
expect(parsed.errors).toEqual([]);
const options: ts.CompilerOptions = { ...parsed.options, ...overrides, noEmit: true };
const probePath = join(uiDir, '__node-probe__.ts');
const host = ts.createCompilerHost(options, true);
const readOriginal = host.readFile.bind(host);
const getOriginal = host.getSourceFile.bind(host);
host.readFile = (f) => (f === probePath ? source : readOriginal(f));
host.fileExists = (f) => f === probePath || ts.sys.fileExists(f);
host.getSourceFile = (f, langVersion, onError, shouldCreate) =>
f === probePath
? ts.createSourceFile(f, source, langVersion, true)
: getOriginal(f, langVersion, onError, shouldCreate);
const program = ts.createProgram([probePath], options, host);
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
}
/** ディスク上に実在する ui/src の TypeScript ファイル一覧(設定の字面を通さずに数える)。 */
function sourceFilesOnDisk(): string[] {
return ts.sys.readDirectory(join(repoRoot, 'ui', 'src'), ['.ts', '.tsx'], ['node_modules'], undefined);
}
/**
* 設定ファイルを**本物のプログラムとして組み**、読み込まれたファイルを全部返す (#1316)。
*
* resolveUiProgram が返すのは根っこ(include / exclude で選ばれたファイル)だけで、
* そこから import や参照でたぐり寄せられた .d.ts は含まれない。types: [] が守るのは
* まさにその「たぐり寄せ」の側なので、根っこだけ見ていても関所の状態は分からない。
*
* 第 5 引数に設定ファイルのパスを渡すのが肝。渡さないと typeRoots の探索が
* プロセスの作業ディレクトリ(=リポジトリ直下)から始まり、@types/node が
* ui/node_modules ではなく直下から拾われる。本物の `cd ui && tsc` と条件が変わって
* しまい、ui/package.json の宣言が効いているかを見られなくなる(実測)。
*/
function realProgramFiles(configName: string): string[] {
const uiDir = join(repoRoot, 'ui');
const configPath = join(uiDir, configName);
const raw = ts.readConfigFile(configPath, ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir, undefined, configPath);
expect(parsed.errors).toEqual([]);
const program = ts.createProgram(parsed.fileNames, { ...parsed.options, noEmit: true });
return program.getSourceFiles().map((f) => f.fileName);
}
/** 上の一覧のうち、Node の型定義(@types/node 配下)だけを取り出す。 */
function nodeTypeFiles(files: readonly string[]): string[] {
return files.filter((f) => /[\\/]@types[\\/]node[\\/]/.test(f));
}
describe('UI 型検査のゲート (#1001)', () => {
it('ui のビルドは型検査を通ってからバンドルする', () => {
const build: string = readJson('ui/package.json').scripts.build;
expect(build).toContain('tsc --noEmit');
// 順序も守る: バンドルが先だと、型エラーでも成果物が出てしまう。
expect(build.indexOf('tsc --noEmit')).toBeLessThan(build.indexOf('vite build'));
});
it('型検査だけを単体で呼べる', () => {
// CI と開発者が「バンドルせずに型だけ見る」ために必要。
expect(readJson('ui/package.json').scripts.typecheck).toContain('tsc -p tsconfig.typecheck.json');
expect(readJson('package.json').scripts['typecheck:ui']).toContain('ui run typecheck');
});
it('CI に独立した型検査ステップがある(コメントアウトも不可)', () => {
// build に含まれてはいるが、独立ステップにしておくと「型で落ちた」のか
// 「バンドルで落ちた」のかがログを読まずに分かる。
const ci = readFileSync(join(repoRoot, '.gitea/workflows/ci.yml'), 'utf8');
const live = ci.split('\n').filter(line => !line.trim().startsWith('#'));
expect(live.some(line => line.includes('npm run typecheck:ui'))).toBe(true);
// ステップ単位で失敗を無視されると、赤いはずの CI が緑になる。
expect(live.some(line => line.includes('continue-on-error'))).toBe(false);
});
it('型検査の失敗を握り潰していない', () => {
// `tsc --noEmit || true` と書けば、上の 3 つはすべて通ったまま関所が
// 無力化する。実行されていることと、落ちること(=失敗が伝播すること)は別。
//
// 見るのは呼び出し連鎖の全段。ui のスクリプトだけを見ていると、CI の
// `run:` かルートの中継スクリプトに 1 つ足すだけで素通りする(実測で確認)。
const swallowed = /\|\|\s*(true|:|exit\s+0)|;\s*(true|exit\s+0)\s*$/;
const uiScripts = readJson('ui/package.json').scripts;
const rootScripts = readJson('package.json').scripts;
for (const script of [uiScripts.build, uiScripts.typecheck, rootScripts['typecheck:ui']]) {
expect(script).not.toMatch(swallowed);
expect(script).not.toContain('--noEmit false');
}
const ci = readFileSync(join(repoRoot, '.gitea/workflows/ci.yml'), 'utf8');
for (const line of ci.split('\n')) {
if (line.trim().startsWith('#') || !line.includes('typecheck:ui')) continue;
expect(line).not.toMatch(swallowed);
}
});
it('ui の TypeScript は、テストも E2E も設定ファイルも検査対象に入っている', () => {
// テスト側の型ずれ(実装のプロパティ追加にモックが追随していない等)は
// 実装と現実がずれ始めた合図。検査対象から外すと、その合図が消える。
//
// include / strict の形だけを見ると素通りする穴がある: include は "src" の
// ままで exclude に "src/**/*.test.tsx" を足すと、227 本のテストが黙って
// 検査対象から外れる。だから設定の字面ではなく、tsc が実際に組む
// ファイル一覧を見る(exclude であれ include であれ、抜けたら落ちる)。
const files = resolveUiProgram('tsconfig.typecheck.json');
const checked = files.filter((f) => /\.test\.tsx?$/.test(f));
expect(checked.length).toBeGreaterThan(0);
// 実装コードも当然入っていること(テストだけ検査しても意味がない)。
expect(files.some((f) => /\/src\/App\.tsx$/.test(f))).toBe(true);
// E2E と ui/ 直下の設定ファイル。ここが抜けていたのが元の状態で、
// 「UI に関所が無い」という本件の主題からすると範囲が中途半端だった。
expect(files.some((f) => /\/e2e\/.*\.spec\.ts$/.test(f))).toBe(true);
expect(files.some((f) => /\/e2e-auth\/.*\.spec\.ts$/.test(f))).toBe(true);
expect(files.some((f) => /\/ui\/playwright\.config\.ts$/.test(f))).toBe(true);
});
it('ビルド用の設定は出荷物だけを見る(製品ビルドを E2E とテストに依存させない)', () => {
// build は Dockerfile の npm run build:ui から呼ばれる製品ビルドの経路。
// ここに E2E を混ぜると、製品イメージのビルドが @playwright/test などの
// devDependency に依存する。検査の網は広く、ビルドの依存は狭く。
//
// テストとテスト基盤 (src/test/) も外してある (#1316)。この設定は types: [] で
// Node の型を断っており、node:fs や global を正当に使う src のテスト (15 本) とは
// 両立しない。src/test/ を外す理由はもう 1 つあって、dom-setup.ts が読む vitest は
// ui/package.json に書かれておらず、リポジトリ直下から解決されていた——直下の
// node_modules から入るファイルが 109 本から 60 本 (TypeScript の標準ライブラリ
// だけ) に減る。「外したぶんが検査されなくなる」抜け穴は下の 1 件が塞ぐ。
const files = resolveUiProgram('tsconfig.json');
expect(files.some((f) => /\/src\/App\.tsx$/.test(f))).toBe(true);
expect(files.filter((f) => /\.test\.tsx?$/.test(f))).toEqual([]);
expect(files.filter((f) => /\/src\/test\//.test(f))).toEqual([]);
expect(files.some((f) => /\/e2e(-auth)?\//.test(f))).toBe(false);
});
it('ui/src のファイルは 1 本残らず型検査用の設定に入っている (#1316)', () => {
// #1001 の元の見張りは「ビルド用の設定にテストが 1 本以上入っている」を見て
// いた。#1316 でビルド用からテストとテスト基盤を外したので、その担保をこちらへ
// 移す。数ではなくディスク上の実ファイルとの差分で見るぶん、元より強い——
// exclude を 1 行足して 1 ディレクトリ丸ごと外す、といった抜き方でも落ちるし、
// ビルド用から今後さらに何かを外しても、こちらに入っていなければ落ちる。
const onDisk = sourceFilesOnDisk();
expect(onDisk.length).toBeGreaterThan(0);
const checked = new Set(resolveUiProgram('tsconfig.typecheck.json'));
expect(onDisk.filter((f) => !checked.has(f))).toEqual([]);
});
});
describe('UI の標準ライブラリ設定 (#1314)', () => {
it('画面側の設定だけで ES2022 のメソッドが解決できる', () => {
// 親ディレクトリの @types を一切使わない条件で at() が引ける、が合格条件。
// lib の明示を消すと target (ES2020) から標準ライブラリが決まるので、ここが
// TS2550 で落ちる。
const diagnostics = diagnoseWithUiOptions(ES2022_PROBE_SOURCE);
const messages = diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '));
expect(messages).toEqual([]);
});
it('lib を ES2020 相当に戻すと、この検査は実際に落ちる', () => {
// 上の検査が「何を書いても通る空振り」でないことの担保。ここが通らなくなったら
// 検査そのものが壊れている(親の @types に頼る経路が復活した等)。
const diagnostics = diagnoseWithUiOptions(ES2022_PROBE_SOURCE, {
lib: ['lib.es2020.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
});
expect(diagnostics.map((d) => d.code)).toContain(2550);
});
it('ReadableStream を直接 for await で回せる(DOM.AsyncIterable が効いている)', () => {
// 直す前の暗黙値 lib.es2020.full は 6 枚を参照していた。lib を手で書き起こす
// ときに落ちやすいのがこの 1 枚で、落ちても既存コードは無傷のまま通る——
// 画面側のストリーム処理は getReader() 経由で、for await している 3 か所は
// 相手が非同期ジェネレータだから。次に誰かが本文を直接 for await した瞬間に
// 初めて落ちる。その「次の人」の代わりに、ここで先に踏んでおく。
const diagnostics = diagnoseWithUiOptions(DOM_ASYNC_ITERABLE_PROBE_SOURCE);
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
});
it('DOM.AsyncIterable を外すと、この検査は実際に落ちる', () => {
// 空振りの担保。1 枚だけ抜いた対照で、他は本物の設定のまま。
const diagnostics = diagnoseWithUiOptions(DOM_ASYNC_ITERABLE_PROBE_SOURCE, {
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
});
expect(diagnostics.length).toBeGreaterThan(0);
});
it('lib は tsconfig に明示されている(target 任せにしない)', () => {
// 失敗したときに「何を直せばよいか」が一行で分かるように、字面も見ておく。
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
const lib: unknown = raw.config?.compilerOptions?.lib;
expect(Array.isArray(lib)).toBe(true);
const entries = (lib as string[]).map((l) => l.toLowerCase());
expect(entries).toContain('dom');
expect(entries).toContain('dom.asynciterable');
});
});
describe('画面側に Node の型を混ぜない (#1316)', () => {
// 直す前は 3 つとも診断ゼロで通っていた。原因は個別の型エラーではなく、
// TypeScript が node_modules/@types を親ディレクトリへ遡って自動で取り込む
// 性質——ui/package.json は Node の型定義を依存に持っていないのに、リポジトリ
// 直下の @types/node がそっくり画面側の検査に入っていた。
//
// 関所は ui/tsconfig.json 側にしか置けない。ui/tsconfig.typecheck.json の側は
// e2e が better-sqlite3 を読み、その .d.ts の `/// <reference types="node" />`
// が Node の型をプログラム全体へ呼び戻すので、types をどう書いても src で
// Buffer が通ってしまう(--explainFiles で確認)。
const buildProbes: ReadonlyArray<readonly [string, string, number]> = [
['タイマーの戻り値に NodeJS.Timeout を当てる', NODE_TIMER_PROBE_SOURCE, 2503],
['環境変数を読む', NODE_PROCESS_PROBE_SOURCE, 2591],
['バッファを作る', NODE_BUFFER_PROBE_SOURCE, 2591],
];
it.each(buildProbes)('出荷物の設定では落ちる: %s', (_name, source, code) => {
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.json', source);
expect(diagnostics.map((d) => d.code)).toContain(code);
});
it('出荷物の設定でも、ブラウザにあるものは通る', () => {
// 上の 3 件が「何を書いても落ちる」空振りでないことの対照。同じ道具立てで、
// window.setTimeout と TextDecoder は診断ゼロであること。
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.json', BROWSER_PROBE_SOURCE);
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
});
it.each(buildProbes)('型検査用の設定では通る: %s', (_name, source) => {
// vite.config.ts と playwright の設定、e2e、src のテストはどれも Node の上で
// 動く。こちらまで断つと、それらが落ちる。
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.typecheck.json', source);
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
});
it.each(buildProbes)('型検査用の設定から types を空にすると、実際に落ちる: %s', (_name, source, code) => {
// 上の 3 件の空振り担保。types: ["node"] の 1 行が効いていることを見る。
//
// ここを本物の tsc(npx tsc -p tsconfig.typecheck.json)で確かめようとすると
// 空振りする: types を消しても、better-sqlite3 の三連スラッシュ参照が Node の
// 型を呼び戻すので通ってしまう。小片 1 つだけのプログラムなら、Node の型の
// 出どころは types しかない。
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.typecheck.json', source, { types: [] });
expect(diagnostics.map((d) => d.code)).toContain(code);
});
it('出荷物の本物のプログラムに、Node の型定義が 1 本も入っていない (#1316)', () => {
// ここが**関所の本体**。上の 3 件は小片 1 本だけの小さなプログラムを組んで
// いるので、実質「設定の字面に types: [] と書いてあるか」しか見ていない。
//
// types: [] が止めるのは自動取り込みだけで、プログラム内のどれかの .d.ts が
// `/// <reference types="node" />` を書いていればそこから Node の型が入る。
// 実際に ui/src へその 1 行を書いたファイルを 1 本置くと、`cd ui && npx tsc
// --noEmit` は Buffer を診断ゼロで通し、字面を見る見張りは全部緑のままだった
// (実測)。しかも実在の経路がある: ui/src の .d.ts がビルドツールの入口を
// 参照していて、そのパッケージは別の場所ですでに Node を参照している。版が
// 上がって入口側が Node を引いた瞬間、関所は黙って開く。
//
// だから本物のプログラムを組んで、読み込まれた @types/node のファイル数を見る。
// 参照が 1 本入るだけで 0 → 63 に跳ねるので、差ははっきり出る(実測)。
const leaked = nodeTypeFiles(realProgramFiles('tsconfig.json'));
expect(leaked).toEqual([]);
});
it('型検査用の本物のプログラムには Node の型定義が入っている(上の見張りの対照)', () => {
// 上の 1 件が「どう組んでも 0 本になる空振り」でないことの担保。同じ道具立てで
// types: ["node"] の側を組むと、Node の型定義が実際に読み込まれる。
//
// 出どころも見る。ui/package.json の devDependencies に @types/node を足した
// 意味は、参照先を ui 自身の node_modules に固定すること。リポジトリ直下から
// 拾われていたら宣言は飾りなので、そこまで含めて落とす。
const loaded = nodeTypeFiles(realProgramFiles('tsconfig.typecheck.json'));
expect(loaded.length).toBeGreaterThan(0);
const uiOwned = join(repoRoot, 'ui', 'node_modules');
expect(loaded.filter((f) => !f.startsWith(uiOwned))).toEqual([]);
});
it('親の型定義の自動取り込みは、設定に書いて断ってある', () => {
// 失敗したときに「何を直せばよいか」が一行で分かるように、字面も見ておく。
const uiDir = join(repoRoot, 'ui');
const build = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
expect(build.config?.compilerOptions?.types).toEqual([]);
const typecheck = ts.readConfigFile(join(uiDir, 'tsconfig.typecheck.json'), ts.sys.readFile);
expect(typecheck.config?.compilerOptions?.types).toEqual(['node']);
});
it('Node の型定義は ui の依存として宣言され、直下と同じ版の範囲を書いている', () => {
// 宣言しないと、参照先はリポジトリ直下の node_modules を運任せに拾うことに戻る。
//
// ここが見ているのは**範囲の文字列が一致していること**だけで、実際に入る版まで
// は見ていない。固定ファイルが直下と ui で別なので、解決される版はすでにずれて
// いる(直下 20.19.37 / ui 20.19.43)。同じ大版の中の差で、1 つのプログラムに
// 入る型定義は片方だけなので実害は無い。
//
// 大版をそろえる意味は #1314 のときとは変わった。#1314 で踏んだ「直下が 20 の
// まま ui だけ新しくなると出荷物が落ちる」形は、ui/tsconfig.json が types: [] に
// なった時点で出荷物の側からは消えている。いま残る意味は、画面側の検査とサーバー
// 側の Node の版をそろえておくこと。将来 Node を 22 に上げるとき、直下だけ上げた
// 瞬間にこのテストが落ちる——意図した動作で、「ui も一緒に上げろ」の合図。
const uiRange: unknown = readJson('ui/package.json').devDependencies['@types/node'];
const rootRange: unknown = readJson('package.json').devDependencies['@types/node'];
expect(typeof uiRange).toBe('string');
expect(uiRange).toBe(rootRange);
});
});