diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1be3409..4ad175c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: - name: Build (TypeScript check) # Keep explicit typecheck for fast fail before full build/bundle. - run: bun run tsc --noEmit + run: bun run typecheck - name: Test suite (source) run: bun run test:fast diff --git a/backlog/tasks/task-90 - Expand-TypeScript-typecheck-coverage-beyond-src.md b/backlog/tasks/task-90 - Expand-TypeScript-typecheck-coverage-beyond-src.md new file mode 100644 index 0000000..dab2f0d --- /dev/null +++ b/backlog/tasks/task-90 - Expand-TypeScript-typecheck-coverage-beyond-src.md @@ -0,0 +1,38 @@ +--- +id: TASK-90 +title: Expand TypeScript typecheck coverage beyond src +status: Done +assignee: [] +created_date: '2026-03-06 08:18' +updated_date: '2026-03-06 08:23' +labels: + - tooling + - typescript +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/tsconfig.json + - /home/sudacode/projects/japanese/SubMiner/package.json + - /home/sudacode/projects/japanese/SubMiner/launcher + - /home/sudacode/projects/japanese/SubMiner/scripts +priority: medium +--- + +## Description + + +Bring all repository TypeScript entrypoints outside src/ into the enforced typecheck gate so CI and local checks cover launcher/ and script files, then resolve any surfaced diagnostics. + + +## Acceptance Criteria + +- [x] #1 TypeScript typecheck covers repository TypeScript entrypoints outside src/ that should be maintained in this repo, including launcher/ and script files. +- [x] #2 The enforced typecheck command used by CI and local development passes with the expanded coverage. +- [x] #3 Any diagnostics surfaced by the expanded coverage are fixed without weakening existing strictness for src/. +- [x] #4 Relevant documentation or command wiring is updated if the typecheck entrypoint changes. + + +## Final Summary + + +Added a dedicated repo-wide typecheck config at tsconfig.typecheck.json and wired package.json/CI to use `bun run typecheck` for launcher and scripts coverage without changing the existing src build config. Fixed the strict-null/indexing diagnostics surfaced in launcher/* and scripts/*, keeping src strictness intact. Verified with `bun run typecheck`, `bun run tsc --noEmit`, and `bun run test:launcher:src` (47 passing, plugin start gate OK). + diff --git a/launcher/aniskip-metadata.test.ts b/launcher/aniskip-metadata.test.ts index 4118d0b..b159031 100644 --- a/launcher/aniskip-metadata.test.ts +++ b/launcher/aniskip-metadata.test.ts @@ -166,8 +166,10 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => { assert.match(opts, /subminer-aniskip_intro_end=62/); assert.match(opts, /subminer-aniskip_lookup_status=ready/); assert.ok(payloadMatch !== null); - assert.equal(payloadMatch[1].includes('%'), false); - const payloadJson = Buffer.from(payloadMatch[1], 'base64url').toString('utf-8'); + const encodedPayload = payloadMatch[1]; + assert.ok(encodedPayload !== undefined); + assert.equal(encodedPayload.includes('%'), false); + const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8'); const payload = JSON.parse(payloadJson); assert.equal(payload.found, true); const first = payload.results?.[0]; diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts index 69aa573..22653ba 100644 --- a/launcher/aniskip-metadata.ts +++ b/launcher/aniskip-metadata.ts @@ -53,6 +53,13 @@ interface AniSkipPayloadResponse { results?: unknown; } +const ROMAN_SEASON_ALIASES: Record = { + 2: [' ii ', ' second season ', ' 2nd season '], + 3: [' iii ', ' third season ', ' 3rd season '], + 4: [' iv ', ' fourth season ', ' 4th season '], + 5: [' v ', ' fifth season ', ' 5th season '], +}; + const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword='; const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/'; const MAL_USER_AGENT = 'SubMiner-launcher/ani-skip'; @@ -188,14 +195,7 @@ function seasonSignalScore(requestedSeason: number | null, candidateTitle: strin return 40; } - const romanAliases = { - 2: [' ii ', ' second season ', ' 2nd season '], - 3: [' iii ', ' third season ', ' 3rd season '], - 4: [' iv ', ' fourth season ', ' 4th season '], - 5: [' v ', ' fifth season ', ' 5th season '], - } as const; - - const aliases = romanAliases[season] ?? []; + const aliases = ROMAN_SEASON_ALIASES[season] ?? []; return aliases.some((alias) => normalized.includes(alias)) ? 40 : hasAnySequelMarker(candidateTitle) diff --git a/launcher/jellyfin.ts b/launcher/jellyfin.ts index dab77a1..fccf88c 100644 --- a/launcher/jellyfin.ts +++ b/launcher/jellyfin.ts @@ -284,8 +284,10 @@ export function parseEpisodePathFromDisplay( const normalized = display.trim().replace(/\s+/g, ' '); const match = normalized.match(/^(.*?)\s+S(\d{1,2})E\d{1,3}\b/i); if (!match) return null; - const seriesName = match[1].trim(); - const seasonNumber = Number.parseInt(match[2], 10); + const seriesName = match[1]?.trim(); + const seasonText = match[2]; + if (!seriesName || !seasonText) return null; + const seasonNumber = Number.parseInt(seasonText, 10); if (!seriesName || !Number.isFinite(seasonNumber) || seasonNumber < 0) return null; return { seriesName, seasonNumber }; } diff --git a/launcher/jimaku.ts b/launcher/jimaku.ts index a4f9419..3bc5b48 100644 --- a/launcher/jimaku.ts +++ b/launcher/jimaku.ts @@ -59,6 +59,7 @@ function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined { const value = headers['x-ratelimit-reset-after']; if (!value) return undefined; const raw = Array.isArray(value) ? value[0] : value; + if (!raw) return undefined; const parsed = Number.parseFloat(raw); if (!Number.isFinite(parsed)) return undefined; return parsed; @@ -72,9 +73,14 @@ export function matchEpisodeFromName(name: string): { } { const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i); if (seasonEpisode && seasonEpisode.index !== undefined) { + const seasonText = seasonEpisode[1]; + const episodeText = seasonEpisode[2]; + if (!seasonText || !episodeText) { + return { season: null, episode: null, index: null, confidence: 'low' }; + } return { - season: Number.parseInt(seasonEpisode[1], 10), - episode: Number.parseInt(seasonEpisode[2], 10), + season: Number.parseInt(seasonText, 10), + episode: Number.parseInt(episodeText, 10), index: seasonEpisode.index, confidence: 'high', }; @@ -82,9 +88,14 @@ export function matchEpisodeFromName(name: string): { const alt = name.match(/(\d{1,2})x(\d{1,3})/i); if (alt && alt.index !== undefined) { + const seasonText = alt[1]; + const episodeText = alt[2]; + if (!seasonText || !episodeText) { + return { season: null, episode: null, index: null, confidence: 'low' }; + } return { - season: Number.parseInt(alt[1], 10), - episode: Number.parseInt(alt[2], 10), + season: Number.parseInt(seasonText, 10), + episode: Number.parseInt(episodeText, 10), index: alt.index, confidence: 'high', }; @@ -92,9 +103,13 @@ export function matchEpisodeFromName(name: string): { const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i); if (epOnly && epOnly.index !== undefined) { + const episodeText = epOnly[1]; + if (!episodeText) { + return { season: null, episode: null, index: null, confidence: 'low' }; + } return { season: null, - episode: Number.parseInt(epOnly[1], 10), + episode: Number.parseInt(episodeText, 10), index: epOnly.index, confidence: 'medium', }; @@ -102,9 +117,13 @@ export function matchEpisodeFromName(name: string): { const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/); if (numeric && numeric.index !== undefined) { + const episodeText = numeric[1]; + if (!episodeText) { + return { season: null, episode: null, index: null, confidence: 'low' }; + } return { season: null, - episode: Number.parseInt(numeric[1], 10), + episode: Number.parseInt(episodeText, 10), index: numeric.index, confidence: 'medium', }; @@ -117,7 +136,9 @@ function detectSeasonFromDir(mediaPath: string): number | null { const parent = path.basename(path.dirname(mediaPath)); const match = parent.match(/(?:Season|S)\s*(\d{1,2})/i); if (!match) return null; - const parsed = Number.parseInt(match[1], 10); + const seasonText = match[1]; + if (!seasonText) return null; + const parsed = Number.parseInt(seasonText, 10); return Number.isFinite(parsed) ? parsed : null; } diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 925fe4b..a5b643d 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -427,7 +427,7 @@ export async function startMpv( appPath: string, preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, options?: { startPaused?: boolean }, -): void { +): Promise { if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { fail(`Video file not found: ${target}`); } diff --git a/launcher/picker.ts b/launcher/picker.ts index 7c00914..38701df 100644 --- a/launcher/picker.ts +++ b/launcher/picker.ts @@ -207,7 +207,8 @@ export function pickLibrary( iconPath: ensureIcon(session, lib.id) || undefined, })); const idx = showRofiIconMenu(entries, 'Jellyfin Library', initialQuery, themePath); - return idx >= 0 ? visibleLibraries[idx].id : ''; + const selected = idx >= 0 ? visibleLibraries[idx] : undefined; + return selected?.id ?? ''; } const lines = visibleLibraries.map((lib) => `${lib.id}\t${lib.name} [${lib.kind}]`); @@ -244,7 +245,8 @@ export function pickItem( iconPath: ensureIcon(session, item.id) || undefined, })); const idx = showRofiIconMenu(entries, 'Jellyfin Item', initialQuery, themePath); - return idx >= 0 ? visibleItems[idx].id : ''; + const selected = idx >= 0 ? visibleItems[idx] : undefined; + return selected?.id ?? ''; } const lines = visibleItems.map((item) => `${item.id}\t${item.display}`); @@ -281,7 +283,8 @@ export function pickGroup( iconPath: ensureIcon(session, group.id) || undefined, })); const idx = showRofiIconMenu(entries, 'Jellyfin Anime/Folder', initialQuery, themePath); - return idx >= 0 ? visibleGroups[idx].id : ''; + const selected = idx >= 0 ? visibleGroups[idx] : undefined; + return selected?.id ?? ''; } const lines = visibleGroups.map((group) => `${group.id}\t${group.display}`); diff --git a/launcher/youtube.ts b/launcher/youtube.ts index f917a4c..9edd940 100644 --- a/launcher/youtube.ts +++ b/launcher/youtube.ts @@ -58,7 +58,7 @@ function pickBestCandidate(candidates: SubtitleCandidate[]): SubtitleCandidate | if (srtA !== srtB) return srtB - srtA; return b.size - a.size; }); - return scored[0]; + return scored[0] ?? null; } function scanSubtitleCandidates( @@ -120,7 +120,7 @@ function findAudioFile(tempDir: string, preferredExt: string): string | null { const preferred = audioFiles.find((entry) => entry.ext === `.${preferredExt.toLowerCase()}`); if (preferred) return preferred.path; audioFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); - return audioFiles[0].path; + return audioFiles[0]?.path ?? null; } async function runWhisper( diff --git a/package.json b/package.json index 457bf4d..6879137 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", + "typecheck:watch": "tsc --watch --preserveWatchOutput -p tsconfig.typecheck.json", "get-frequency": "bun run scripts/get_frequency.ts --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line", "get-frequency:electron": "bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line", "test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts", "test-yomitan-parser:electron": "bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js", - "build": "tsc && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh", + "build": "tsc -p tsconfig.json && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh", "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", "format": "prettier --write .", "format:check": "prettier --check .", @@ -24,6 +26,15 @@ "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", + "test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts", + "test:immersion:sqlite:dist": "node --experimental-sqlite --test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js", + "test:immersion:sqlite": "bun run tsc && bun run test:immersion:sqlite:dist", + "test:src": "node scripts/run-test-lane.mjs bun-src-full", + "test:launcher:unit:src": "node scripts/run-test-lane.mjs bun-launcher-unit", + "test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src", + "test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src", + "test:node:compat": "bun run tsc && node --experimental-sqlite --test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js", + "test:full": "bun run test:src && bun run test:launcher:unit:src && bun run test:node:compat", "test": "bun run test:fast", "test:config": "bun run test:config:src", "test:launcher": "bun run test:launcher:src", @@ -38,16 +49,7 @@ "build:appimage": "bun run build && electron-builder --linux AppImage", "build:mac": "bun run build && electron-builder --mac dmg zip", "build:mac:unsigned": "bun run build && env -u APPLE_ID -u APPLE_APP_SPECIFIC_PASSWORD -u APPLE_TEAM_ID -u CSC_LINK -u CSC_KEY_PASSWORD CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac dmg zip", - "build:mac:zip": "bun run build && electron-builder --mac zip", - "test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts", - "test:immersion:sqlite:dist": "node --experimental-sqlite --test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js", - "test:immersion:sqlite": "bun run tsc && bun run test:immersion:sqlite:dist", - "test:src": "node scripts/run-test-lane.mjs bun-src-full", - "test:launcher:unit:src": "node scripts/run-test-lane.mjs bun-launcher-unit", - "test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src", - "test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src", - "test:node:compat": "bun run tsc && node --experimental-sqlite --test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js", - "test:full": "bun run test:src && bun run test:launcher:unit:src && bun run test:node:compat" + "build:mac:zip": "bun run build && electron-builder --mac zip" }, "keywords": [ "anki", diff --git a/scripts/get_frequency.ts b/scripts/get_frequency.ts index 07c4c10..8b9f3b9 100644 --- a/scripts/get_frequency.ts +++ b/scripts/get_frequency.ts @@ -14,7 +14,7 @@ interface CliOptions { emitDiagnostics: boolean; mecabCommand?: string; mecabDictionaryPath?: string; - forceMecabOnly?: boolean; + forceMecabOnly: boolean; yomitanExtensionPath?: string; yomitanUserDataPath?: string; emitColoredLine: boolean; @@ -678,7 +678,7 @@ function getBandColor( } const normalizedBand = Math.ceil((safeRank / topX) * bandedColors.length); const band = Math.min(bandedColors.length, Math.max(1, normalizedBand)); - return bandedColors[band - 1]; + return bandedColors[band - 1] ?? colorSingle; } function getTokenColor(token: MergedToken, args: CliOptions): string { @@ -845,7 +845,26 @@ async function main(): Promise { ? simplifyTokenWithVerbose(token, getFrequencyRank) : simplifyToken(token), ) ?? null; - const diagnostics = { + const diagnostics: { + yomitan: { + available: boolean; + loaded: boolean; + forceMecabOnly: boolean; + note: string | null; + }; + mecab: { + command: string; + dictionaryPath: string | null; + available: boolean; + status?: 'ok' | 'no-tokens'; + note?: string; + }; + tokenizer: { + sourceHint: 'none' | 'yomitan-merged' | 'mecab-merge'; + mergedTokenCount: number; + totalTokenCount: number; + }; + } = { yomitan: { available: Boolean(yomitanState?.available), loaded: useYomitan, @@ -864,11 +883,11 @@ async function main(): Promise { }, }; if (tokens === null) { - diagnostics.mecab['status'] = 'no-tokens'; - diagnostics.mecab['note'] = + diagnostics.mecab.status = 'no-tokens'; + diagnostics.mecab.note = 'MeCab returned no parseable tokens. This is often caused by a missing/invalid MeCab dictionary path.'; } else { - diagnostics.mecab['status'] = 'ok'; + diagnostics.mecab.status = 'ok'; } const output = { diff --git a/scripts/test-yomitan-parser.ts b/scripts/test-yomitan-parser.ts index 220f244..079778a 100644 --- a/scripts/test-yomitan-parser.ts +++ b/scripts/test-yomitan-parser.ts @@ -348,7 +348,11 @@ function findSelectedCandidateIndexes( const mergedSignatures = mergedTokens.map(mergedTokenSignature); const selected: number[] = []; for (let i = 0; i < candidates.length; i += 1) { - const candidateSignatures = candidates[i].tokens.map(candidateTokenSignature); + const candidate = candidates[i]; + if (!candidate) { + continue; + } + const candidateSignatures = candidate.tokens.map(candidateTokenSignature); if (candidateSignatures.length !== mergedSignatures.length) { continue; } @@ -490,6 +494,9 @@ function renderTextOutput(payload: Record): void { } else { for (let i = 0; i < finalTokens.length; i += 1) { const token = finalTokens[i]; + if (!token) { + continue; + } process.stdout.write( ` [${i}] ${token.surface} -> ${token.headword} (${token.reading}) [${token.startPos}, ${token.endPos})\n`, ); @@ -505,6 +512,9 @@ function renderTextOutput(payload: Record): void { for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; + if (!candidate) { + continue; + } process.stdout.write( ` [${i}] source=${String(candidate.source)} index=${String(candidate.index)} selectedByTokenizer=${String(candidate.selectedByTokenizer)} tokenCount=${String(candidate.tokenCount)}\n`, ); @@ -514,6 +524,9 @@ function renderTextOutput(payload: Record): void { } for (let j = 0; j < tokens.length; j += 1) { const token = tokens[j]; + if (!token) { + continue; + } process.stdout.write( ` - ${token.surface} -> ${token.headword} (${token.reading}) [${token.startPos}, ${token.endPos})\n`, ); diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..cf4f0e6 --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/**/*", "launcher/**/*.ts", "scripts/*.ts"], + "exclude": ["node_modules", "dist", "vendor"] +}