From 9f614517c04e353985132bbf9b2769da46fb3c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:22:23 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20tsconfig.json=E3=81=ABskipLibCheck:=20tr?= =?UTF-8?q?ue=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B=20(#17651)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: tsconfig.jsonにskipLibCheck: trueを追加する * skipLibCheckで抜けたチェックを自前でやる --- .github/workflows/lint.yml | 23 ++ package.json | 3 +- .../backend/src/@types/probe-image-size.d.ts | 5 +- packages/frontend-builder/tsconfig.json | 1 + packages/frontend-embed/@types/theme.d.ts | 2 +- packages/frontend-embed/tsconfig.json | 1 + packages/frontend-shared/tsconfig.json | 1 + packages/frontend/@types/theme.d.ts | 2 +- packages/icons-subsetter/tsconfig.json | 1 + packages/misskey-bubble-game/tsconfig.json | 1 + packages/misskey-js/tsconfig.json | 1 + packages/misskey-reversi/tsconfig.json | 1 + packages/sw/tsconfig.json | 1 + scripts/check-dts.mjs | 198 ++++++++++++++++++ scripts/check-dts.test.mjs | 25 +++ 15 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 scripts/check-dts.mjs create mode 100644 scripts/check-dts.test.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7fe94eefa5..63ae5c7397 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,9 @@ on: - packages/misskey-bubble-game/** - packages/misskey-reversi/** - packages/shared/eslint.config.js + - scripts/check-dts*.mjs - .github/workflows/lint.yml + - package.json pull_request: paths: - packages/backend/** @@ -31,7 +33,9 @@ on: - packages/misskey-bubble-game/** - packages/misskey-reversi/** - packages/shared/eslint.config.js + - scripts/check-dts*.mjs - .github/workflows/lint.yml + - package.json jobs: pnpm_install: runs-on: ubuntu-latest @@ -113,3 +117,22 @@ jobs: - run: pnpm i --frozen-lockfile - run: pnpm --filter "${{ matrix.workspace }}^..." run build - run: pnpm --filter ${{ matrix.workspace }} run typecheck + + check-dts: + needs: [pnpm_install] + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + submodules: true + - name: Setup pnpm + uses: pnpm/action-setup@v6.0.3 + - uses: actions/setup-node@v6.4.0 + with: + node-version-file: '.node-version' + cache: 'pnpm' + - run: pnpm i --frozen-lockfile + - run: node --test scripts/check-dts.test.mjs + - run: pnpm check-dts diff --git a/package.json b/package.json index 9dd958d4f3..5dcc85c172 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "migrateandstart": "pnpm migrate && pnpm start", "watch": "pnpm dev", "dev": "node scripts/dev.mjs", - "lint": "pnpm --no-bail -r lint", + "check-dts": "node scripts/check-dts.mjs", + "lint": "pnpm --no-bail -r lint && pnpm check-dts", "cy:open": "pnpm cypress open --config-file=cypress.config.ts", "cy:run": "pnpm cypress run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", diff --git a/packages/backend/src/@types/probe-image-size.d.ts b/packages/backend/src/@types/probe-image-size.d.ts index 538836475c..f2efbc082d 100644 --- a/packages/backend/src/@types/probe-image-size.d.ts +++ b/packages/backend/src/@types/probe-image-size.d.ts @@ -26,7 +26,6 @@ declare module 'probe-image-size' { function probeImageSize(src: string | ReadStream, callback: (err: Error | null, result?: ProbeResult) => void): void; function probeImageSize(src: string | ReadStream, options: ProbeOptions, callback: (err: Error | null, result?: ProbeResult) => void): void; - namespace probeImageSize {} // Hack - - export = probeImageSize; + // eslint-disable-next-line import/no-default-export + export { probeImageSize as default }; } diff --git a/packages/frontend-builder/tsconfig.json b/packages/frontend-builder/tsconfig.json index ab943fded4..e2d1f7ada0 100644 --- a/packages/frontend-builder/tsconfig.json +++ b/packages/frontend-builder/tsconfig.json @@ -9,6 +9,7 @@ "sourceMap": false, "noEmit": true, "removeComments": true, + "skipLibCheck": true, "resolveJsonModule": true, "strict": true, "strictFunctionTypes": true, diff --git a/packages/frontend-embed/@types/theme.d.ts b/packages/frontend-embed/@types/theme.d.ts index 6ac1037493..6a010889d6 100644 --- a/packages/frontend-embed/@types/theme.d.ts +++ b/packages/frontend-embed/@types/theme.d.ts @@ -4,7 +4,7 @@ */ declare module '@@/themes/*.json5' { - import { Theme } from '@/theme.js'; + import { Theme } from '@@/js/theme.js'; const theme: Theme; diff --git a/packages/frontend-embed/tsconfig.json b/packages/frontend-embed/tsconfig.json index 24fa71de19..d0d7591f23 100644 --- a/packages/frontend-embed/tsconfig.json +++ b/packages/frontend-embed/tsconfig.json @@ -13,6 +13,7 @@ "module": "ES2022", "moduleResolution": "Bundler", "removeComments": false, + "skipLibCheck": true, "noLib": false, "strict": true, "strictNullChecks": true, diff --git a/packages/frontend-shared/tsconfig.json b/packages/frontend-shared/tsconfig.json index c2600dc45e..e038b9ca23 100644 --- a/packages/frontend-shared/tsconfig.json +++ b/packages/frontend-shared/tsconfig.json @@ -9,6 +9,7 @@ "sourceMap": false, "outDir": "./js-built", "removeComments": true, + "skipLibCheck": true, "resolveJsonModule": true, "strict": true, "strictFunctionTypes": true, diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts index 6ac1037493..6a010889d6 100644 --- a/packages/frontend/@types/theme.d.ts +++ b/packages/frontend/@types/theme.d.ts @@ -4,7 +4,7 @@ */ declare module '@@/themes/*.json5' { - import { Theme } from '@/theme.js'; + import { Theme } from '@@/js/theme.js'; const theme: Theme; diff --git a/packages/icons-subsetter/tsconfig.json b/packages/icons-subsetter/tsconfig.json index 08315a91cf..7bf3fd3cbf 100644 --- a/packages/icons-subsetter/tsconfig.json +++ b/packages/icons-subsetter/tsconfig.json @@ -8,6 +8,7 @@ "strictFunctionTypes": true, "strictNullChecks": true, "esModuleInterop": true, + "skipLibCheck": true, "lib": [ "esnext", "dom" diff --git a/packages/misskey-bubble-game/tsconfig.json b/packages/misskey-bubble-game/tsconfig.json index 0bb0c13c3a..e7d8e810c2 100644 --- a/packages/misskey-bubble-game/tsconfig.json +++ b/packages/misskey-bubble-game/tsconfig.json @@ -9,6 +9,7 @@ "sourceMap": false, "outDir": "./built", "removeComments": true, + "skipLibCheck": true, "strict": true, "strictFunctionTypes": true, "strictNullChecks": true, diff --git a/packages/misskey-js/tsconfig.json b/packages/misskey-js/tsconfig.json index e1197570fd..ef92336356 100644 --- a/packages/misskey-js/tsconfig.json +++ b/packages/misskey-js/tsconfig.json @@ -9,6 +9,7 @@ "sourceMap": false, "outDir": "./built", "removeComments": true, + "skipLibCheck": true, "strict": true, "strictFunctionTypes": true, "strictNullChecks": true, diff --git a/packages/misskey-reversi/tsconfig.json b/packages/misskey-reversi/tsconfig.json index 0bb0c13c3a..e7d8e810c2 100644 --- a/packages/misskey-reversi/tsconfig.json +++ b/packages/misskey-reversi/tsconfig.json @@ -9,6 +9,7 @@ "sourceMap": false, "outDir": "./built", "removeComments": true, + "skipLibCheck": true, "strict": true, "strictFunctionTypes": true, "strictNullChecks": true, diff --git a/packages/sw/tsconfig.json b/packages/sw/tsconfig.json index 9732a438ce..2669ac5277 100644 --- a/packages/sw/tsconfig.json +++ b/packages/sw/tsconfig.json @@ -13,6 +13,7 @@ "module": "nodenext", "moduleResolution": "nodenext", "removeComments": false, + "skipLibCheck": true, "noLib": false, "strict": true, "strictNullChecks": true, diff --git a/scripts/check-dts.mjs b/scripts/check-dts.mjs new file mode 100644 index 0000000000..e6eba67966 --- /dev/null +++ b/scripts/check-dts.mjs @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const defaultRootDir = path.resolve(scriptDir, '..'); + +// パス判定は OS 差分を避けるため、必要なところだけ POSIX 形式へ寄せる。 +function toPosixPath(filePath) { + return filePath.split(path.sep).join('/'); +} + +function isInside(parentDir, filePath) { + const relative = path.relative(parentDir, filePath); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +// skipLibCheck で隠したくない、リポジトリ管理下の手書き .d.ts だけを検査する。 +export function isCheckableDeclarationFile(filePath, rootDir = defaultRootDir) { + const absoluteRootDir = path.resolve(rootDir); + const absoluteFilePath = path.resolve(filePath); + if (!isInside(absoluteRootDir, absoluteFilePath)) return false; + if (!absoluteFilePath.endsWith('.d.ts')) return false; + + const relativePath = toPosixPath(path.relative(absoluteRootDir, absoluteFilePath)); + const segments = relativePath.split('/'); + if (segments.includes('node_modules') || segments.includes('.pnpm')) return false; + if (segments.includes('built') || segments.includes('js-built')) return false; + + return true; +} + +// root package.json の workspaces を信頼して、検査対象 package を列挙する。 +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function getWorkspacePackageDirs(rootDir) { + const rootPackageJson = readJson(path.join(rootDir, 'package.json')); + return rootPackageJson.workspaces + .filter((workspace) => !workspace.includes('*')) + .map((workspace) => path.resolve(rootDir, workspace)) + .filter((workspaceDir) => fs.existsSync(path.join(workspaceDir, 'package.json'))); +} + +function getTsconfigPaths(packageDir) { + return ts.sys.readDirectory( + packageDir, + ['.json'], + ['node_modules'], + ['**/tsconfig.json'], + undefined, + ); +} + +// tsconfig の include から漏れる package 直下の shim も拾えるよう、補助的に探索する。 +function discoverDeclarationFiles(packageDir, rootDir) { + return ts.sys.readDirectory( + packageDir, + ['.d.ts'], + undefined, + ['**/*.d.ts'], + undefined, + ).filter((filePath) => isCheckableDeclarationFile(filePath, rootDir)); +} + +// 各 package の設定をそのまま使い、検査時だけ skipLibCheck を後で上書きする。 +function readTsconfig(tsconfigPath) { + if (!fs.existsSync(tsconfigPath)) return undefined; + + const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (config.error != null) { + throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')); + } + + return ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(tsconfigPath)); +} + +function createFormatHost(rootDir) { + return { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => rootDir, + getNewLine: () => ts.sys.newLine, + }; +} + +// 通常の tsconfig は include 対象を使い、root tsconfig だけ package 直下の shim を足す。 +function isRootTsconfig(packageDir, tsconfigPath) { + return path.resolve(packageDir, 'tsconfig.json') === path.resolve(tsconfigPath); +} + +function getDeclarationFilesForTsconfig(packageDir, tsconfigPath, parsedConfig, rootDir) { + const declarationFiles = parsedConfig.fileNames + .filter((filePath) => isCheckableDeclarationFile(filePath, rootDir)); + + if (!isRootTsconfig(packageDir, tsconfigPath)) { + return declarationFiles; + } + + const extraRootConfigDeclarationFiles = discoverDeclarationFiles(packageDir, rootDir) + .filter((filePath) => { + return path.dirname(filePath) === packageDir; + }); + + return [...new Set([...declarationFiles, ...extraRootConfigDeclarationFiles])]; +} + +// node_modules 側の診断は出さず、対象 .d.ts 自身に出た診断だけを失敗扱いにする。 +function checkTsconfigDeclarations(packageDir, tsconfigPath, rootDir) { + const parsedConfig = readTsconfig(tsconfigPath); + if (parsedConfig == null) { + return { declarationFiles: [], diagnostics: [] }; + } + + const declarationFiles = getDeclarationFilesForTsconfig(packageDir, tsconfigPath, parsedConfig, rootDir); + if (declarationFiles.length === 0) { + return { declarationFiles, diagnostics: [] }; + } + + const program = ts.createProgram({ + rootNames: declarationFiles, + options: { + ...parsedConfig.options, + noEmit: true, + skipLibCheck: false, + }, + }); + + const diagnostics = ts.getPreEmitDiagnostics(program) + .filter((diagnostic) => diagnostic.file != null && isCheckableDeclarationFile(diagnostic.file.fileName, rootDir)); + + return { declarationFiles, diagnostics }; +} + +// 複数 tsconfig で同じ宣言を読むことがあるため、同一診断をまとめる。 +function deduplicateDiagnostics(diagnostics) { + const seen = new Set(); + return diagnostics.filter((diagnostic) => { + const key = [ + diagnostic.file?.fileName ?? '', + diagnostic.start ?? '', + diagnostic.code, + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + ].join('\0'); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function checkPackageDeclarations(packageDir, rootDir) { + const results = getTsconfigPaths(packageDir) + .map((tsconfigPath) => checkTsconfigDeclarations(packageDir, tsconfigPath, rootDir)); + + return { + declarationFiles: [...new Set(results.flatMap((result) => result.declarationFiles))], + diagnostics: deduplicateDiagnostics(results.flatMap((result) => result.diagnostics)), + }; +} + +// workspace 全体を走査し、CI が扱いやすい件数と診断リストに集約する。 +export function checkDeclarations(rootDir = defaultRootDir) { + const workspacePackageDirs = getWorkspacePackageDirs(rootDir); + const results = workspacePackageDirs.map((packageDir) => ({ + packageDir, + ...checkPackageDeclarations(packageDir, rootDir), + })); + + return { + results, + declarationFileCount: results.reduce((count, result) => count + result.declarationFiles.length, 0), + diagnostics: results.flatMap((result) => result.diagnostics), + }; +} + +// CLI 実行時は TypeScript 標準の formatter で、通常の tsc に近い形で表示する。 +function main() { + const { declarationFileCount, diagnostics } = checkDeclarations(defaultRootDir); + + if (diagnostics.length === 0) { + console.log(`Checked ${declarationFileCount} repository declaration files.`); + return; + } + + console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, createFormatHost(defaultRootDir))); + process.exitCode = 1; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/check-dts.test.mjs b/scripts/check-dts.test.mjs new file mode 100644 index 0000000000..0b9cb14dbf --- /dev/null +++ b/scripts/check-dts.test.mjs @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isCheckableDeclarationFile } from './check-dts.mjs'; + +const rootDir = '/repo'; + +test('detects repository-owned declaration files that should be checked', () => { + assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend/@types/theme.d.ts`, rootDir), true); + assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend/src/utility/virtual.d.ts`, rootDir), true); + assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/backend/test/global.d.ts`, rootDir), true); +}); + +test('ignores declarations outside the repository-owned surface', () => { + assert.equal(isCheckableDeclarationFile(`${rootDir}/node_modules/@types/node/index.d.ts`, rootDir), false); + assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend/node_modules/@types/foo/index.d.ts`, rootDir), false); + assert.equal(isCheckableDeclarationFile(`${rootDir}/node_modules/.pnpm/typescript/lib/lib.dom.d.ts`, rootDir), false); + assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/misskey-js/built/index.d.ts`, rootDir), false); + assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend-shared/js-built/i18n.d.ts`, rootDir), false); + assert.equal(isCheckableDeclarationFile(`${rootDir}/packages/frontend/src/theme.ts`, rootDir), false); +});