build: expand typecheck coverage beyond src

This commit is contained in:
2026-03-06 01:03:51 -08:00
parent 8c2c950564
commit ebe9515486
13 changed files with 156 additions and 44 deletions

View File

@@ -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

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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).
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -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];

View File

@@ -53,6 +53,13 @@ interface AniSkipPayloadResponse {
results?: unknown;
}
const ROMAN_SEASON_ALIASES: Record<number, readonly string[]> = {
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)

View File

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

View File

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

View File

@@ -427,7 +427,7 @@ export async function startMpv(
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: { startPaused?: boolean },
): void {
): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
}

View File

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

View File

@@ -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(

View File

@@ -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",

View File

@@ -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<void> {
? 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<void> {
},
};
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 = {

View File

@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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`,
);

12
tsconfig.typecheck.json Normal file
View File

@@ -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"]
}