build(ts): enable noUncheckedIndexedAccess and isolatedModules

This commit is contained in:
2026-02-20 01:50:09 -08:00
parent 06e8223d63
commit a4532a5fa0
45 changed files with 169 additions and 126 deletions

View File

@@ -17,3 +17,4 @@ Read first. Keep concise.
| `codex-preserve-linebreaks-20260220T063538Z-s4nd` | `codex-preserve-linebreaks` | `Add config option to preserve subtitle line breaks in visible overlay rendering` | `completed` | `docs/subagents/agents/codex-preserve-linebreaks-20260220T063538Z-s4nd.md` | `2026-02-20T06:42:51Z` |
| `codex-release-mpv-plugin-20260220T035757Z-d4yf` | `codex-release-mpv-plugin` | `Package optional release assets bundle (mpv plugin + rofi theme), move theme to assets/themes, update install/docs` | `completed` | `docs/subagents/agents/codex-release-mpv-plugin-20260220T035757Z-d4yf.md` | `2026-02-20T04:02:26Z` |
| `codex-bundle-config-example-20260220T092408Z-a1b2` | `codex-bundle-config-example` | `Bundle config.example.jsonc in release assets tarball and align install docs` | `completed` | `docs/subagents/agents/codex-bundle-config-example-20260220T092408Z-a1b2.md` | `2026-02-20T09:26:24Z` |
| `codex-tsconfig-modernize-20260220T093035Z-68qb` | `codex-tsconfig-modernize` | `Enable noUncheckedIndexedAccess + isolatedModules in root tsconfig and fix resulting compile errors` | `completed` | `docs/subagents/agents/codex-tsconfig-modernize-20260220T093035Z-68qb.md` | `2026-02-20T09:46:26Z` |

View File

@@ -0,0 +1,30 @@
# Agent Log: codex-tsconfig-modernize-20260220T093035Z-68qb
- alias: `codex-tsconfig-modernize`
- mission: `Enable noUncheckedIndexedAccess + isolatedModules in root tsconfig and fix resulting compile errors`
- status: `completed`
- started_utc: `2026-02-20T09:31:11Z`
- last_update_utc: `2026-02-20T09:46:26Z`
- planned_files:
- `tsconfig.json`
- `src/**/*.ts` (targeted type-safety fixes)
- `docs/subagents/INDEX.md`
- `docs/subagents/agents/codex-tsconfig-modernize-20260220T093035Z-68qb.md`
- assumptions:
- User wants requested config adopted for root project tsconfig.
- Keep root CJS build semantics unchanged; only add requested strict flags.
- phase:
- `handoff`
- notes:
- Read `docs/subagents/INDEX.md` and `docs/subagents/collaboration.md`.
- Root build uses `tsc` emit flow; avoid `noEmit` and conflicting module modes.
- Applied and kept: `lib` includes `DOM.Iterable`, `moduleDetection: force`, `noImplicitOverride: true`.
- Tried then reverted: `noUncheckedIndexedAccess`, `isolatedModules` (large compile breakage).
- Validation: `bun run tsc --noEmit` passed.
- New user directive: proceed with enabling both strict flags and remediate errors.
- Re-enabled `noUncheckedIndexedAccess` + `isolatedModules`; fixed all compile errors across source/tests.
- Final validation: `bun run tsc --noEmit` and `bun run test:fast` both pass.
- touched_files:
- `tsconfig.json`
- `docs/subagents/INDEX.md`
- `docs/subagents/agents/codex-tsconfig-modernize-20260220T093035Z-68qb.md`

View File

@@ -411,7 +411,7 @@ export class AnkiIntegration {
return;
}
const noteInfo = notesInfo[0];
const noteInfo = notesInfo[0]!;
this.appendKnownWordsFromNoteInfo(noteInfo);
const fields = this.extractFields(noteInfo.fields);
@@ -1000,13 +1000,13 @@ export class AnkiIntegration {
private extractLastSoundTag(value: string): string {
const matches = value.match(/\[sound:[^\]]+\]/g);
if (!matches || matches.length === 0) return '';
return matches[matches.length - 1];
return matches[matches.length - 1]!;
}
private extractLastImageTag(value: string): string {
const matches = value.match(/<img\b[^>]*>/gi);
if (!matches || matches.length === 0) return '';
return matches[matches.length - 1];
return matches[matches.length - 1]!;
}
private extractImageTags(value: string): string[] {
@@ -1472,7 +1472,7 @@ export class AnkiIntegration {
log.warn('Keep note not found:', keepNoteId);
return;
}
const keepNoteInfo = keepNotesInfo[0];
const keepNoteInfo = keepNotesInfo[0]!;
const mergedFields = await this.computeFieldGroupingMergedFields(
keepNoteId,
deleteNoteId,
@@ -1539,7 +1539,7 @@ export class AnkiIntegration {
if (!originalNotesInfo || originalNotesInfo.length === 0) {
return false;
}
const originalNoteInfo = originalNotesInfo[0];
const originalNoteInfo = originalNotesInfo[0]!;
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
const originalFields = this.extractFields(originalNoteInfo.fields);

View File

@@ -199,7 +199,7 @@ export class CardCreationService {
return;
}
const noteInfo = notesInfoResult[0];
const noteInfo = notesInfoResult[0]!;
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || '';
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
@@ -366,7 +366,7 @@ export class CardCreationService {
return;
}
const noteInfo = notesInfoResult[0];
const noteInfo = notesInfoResult[0]!;
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || '';
@@ -536,7 +536,7 @@ export class CardCreationService {
const noteInfoResult = await this.deps.client.notesInfo([noteId]);
const noteInfos = noteInfoResult as CardCreationNoteInfo[];
if (noteInfos.length > 0) {
const createdNoteInfo = noteInfos[0];
const createdNoteInfo = noteInfos[0]!;
this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo);
resolvedSentenceAudioField =
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;

View File

@@ -23,7 +23,7 @@ export async function findDuplicateNote(
): Promise<number | null> {
let fieldName = '';
for (const name of Object.keys(noteInfo.fields)) {
if (['word', 'expression'].includes(name.toLowerCase()) && noteInfo.fields[name].value) {
if (['word', 'expression'].includes(name.toLowerCase()) && noteInfo.fields[name]?.value) {
fieldName = name;
break;
}

View File

@@ -100,7 +100,7 @@ export class FieldGroupingService {
this.deps.showOsdNotification('Card not found');
return;
}
const noteInfoBeforeUpdate = notesInfo[0];
const noteInfoBeforeUpdate = notesInfo[0]!;
const fields = this.deps.extractFields(noteInfoBeforeUpdate.fields);
const expressionText = fields.expression || fields.word || '';
if (!expressionText) {
@@ -135,7 +135,7 @@ export class FieldGroupingService {
return;
}
const noteInfo = refreshedInfo[0];
const noteInfo = refreshedInfo[0]!;
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
await this.deps.handleFieldGroupingAuto(

View File

@@ -236,7 +236,7 @@ export class KnownWordCacheManager {
}
if (decks.length === 1) {
return `deck:"${escapeAnkiSearchValue(decks[0])}"`;
return `deck:"${escapeAnkiSearchValue(decks[0]!)}"`;
}
const deckQueries = decks.map((deck) => `deck:"${escapeAnkiSearchValue(deck)}"`);

View File

@@ -115,7 +115,7 @@ export function parseArgs(argv: string[]): CliArgs {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) continue;
if (!arg || !arg.startsWith('--')) continue;
if (arg === '--background') args.background = true;
else if (arg === '--start') args.start = true;

View File

@@ -54,7 +54,7 @@ export function resolveConfigDir(options: ConfigPathOptions): string {
}
}
return path.join(baseDirs[0], getDefaultAppName(options));
return path.join(baseDirs[0]!, getDefaultAppName(options));
}
export function resolveConfigFilePath(options: ConfigPathOptions): string {
@@ -72,5 +72,5 @@ export function resolveConfigFilePath(options: ConfigPathOptions): string {
}
}
return path.join(baseDirs[0], getDefaultAppName(options), DEFAULT_FILE_NAMES[0]);
return path.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!);
}

View File

@@ -168,7 +168,7 @@ function pickBestSearchResult(
return titles.includes(normalizedTarget);
});
const selected = exact ?? candidates[0];
const selected = exact ?? candidates[0]!;
const selectedTitle =
selected.title?.english || selected.title?.romaji || selected.title?.native || title;
return { id: selected.id, title: selectedTitle };

View File

@@ -129,7 +129,7 @@ test('refreshKnownWords throws when integration is unavailable', async () => {
await assert.rejects(
async () => {
await registered.refreshKnownWords();
await registered.refreshKnownWords!();
},
{ message: 'AnkiConnect integration not enabled' },
);
@@ -144,7 +144,7 @@ test('refreshKnownWords delegates to integration', async () => {
},
};
await registered.refreshKnownWords();
await registered.refreshKnownWords!();
assert.equal(refreshed, 1);
});
@@ -158,7 +158,7 @@ test('setAnkiConnectEnabled disables active integration and broadcasts changes',
},
};
registered.setAnkiConnectEnabled(false);
registered.setAnkiConnectEnabled!(false);
assert.deepEqual(state.patches, [false]);
assert.equal(destroyed, 1);
@@ -188,8 +188,8 @@ test('clearAnkiHistory and respondFieldGrouping execute runtime callbacks', () =
deleteDuplicate: true,
cancelled: false,
};
registered.clearAnkiHistory();
registered.respondFieldGrouping(choice);
registered.clearAnkiHistory!();
registered.respondFieldGrouping!(choice);
options.getSubtitleTimingTracker = originalGetTracker;
@@ -201,7 +201,7 @@ test('clearAnkiHistory and respondFieldGrouping execute runtime callbacks', () =
test('buildKikuMergePreview returns guard error when integration is missing', async () => {
const { registered } = createHarness();
const result = await registered.buildKikuMergePreview({
const result = await registered.buildKikuMergePreview!({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
@@ -227,7 +227,7 @@ test('buildKikuMergePreview delegates to integration when available', async () =
},
};
const result = await registered.buildKikuMergePreview({
const result = await registered.buildKikuMergePreview!({
keepNoteId: 3,
deleteNoteId: 4,
deleteDuplicate: true,
@@ -240,7 +240,7 @@ test('buildKikuMergePreview delegates to integration when available', async () =
test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => {
const { registered, state } = createHarness();
const searchResult = await registered.searchJimakuEntries({ query: 'test' });
const searchResult = await registered.searchJimakuEntries!({ query: 'test' });
assert.deepEqual(state.fetchCalls, [
{
endpoint: '/api/entries/search',
@@ -250,6 +250,6 @@ test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to
assert.equal((searchResult as { ok: boolean }).ok, true);
assert.equal((searchResult as { data: unknown[] }).data.length, 2);
registered.onDownloadedSubtitle('/tmp/subtitle.ass');
registered.onDownloadedSubtitle!('/tmp/subtitle.ass');
assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]);
});

View File

@@ -209,30 +209,31 @@ test('runAppReadyRuntime aggregates multiple critical anki mapping errors', asyn
await runAppReadyRuntime(deps);
const firstErrorSet = capturedErrors[0]!;
assert.equal(capturedErrors.length, 1);
assert.equal(capturedErrors[0].length, 5);
assert.equal(firstErrorSet.length, 5);
assert.ok(
capturedErrors[0].includes(
firstErrorSet.includes(
'ankiConnect.fields.audio must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
capturedErrors[0].includes(
firstErrorSet.includes(
'ankiConnect.fields.image must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
capturedErrors[0].includes(
firstErrorSet.includes(
'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
capturedErrors[0].includes(
firstErrorSet.includes(
'ankiConnect.fields.miscInfo must be a non-empty string when ankiConnect is enabled.',
),
);
assert.ok(
capturedErrors[0].includes(
firstErrorSet.includes(
'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.',
),
);

View File

@@ -124,8 +124,8 @@ testIfSqlite('persists and retrieves minimum immersion tracking fields', async (
const summaries = await tracker.getSessionSummaries(10);
assert.ok(summaries.length >= 1);
assert.ok(summaries[0].linesSeen >= 1);
assert.ok(summaries[0].cardsMined >= 2);
assert.ok(summaries[0]!.linesSeen >= 1);
assert.ok(summaries[0]!.cardsMined >= 2);
tracker.destroy();
@@ -376,8 +376,8 @@ testIfSqlite('monthly rollups are grouped by calendar month', async () => {
const videoRows = rows.filter((row) => row.videoId === 1);
assert.equal(videoRows.length, 2);
assert.equal(videoRows[0].rollupDayOrMonth, 202602);
assert.equal(videoRows[1].rollupDayOrMonth, 202601);
assert.equal(videoRows[0]!.rollupDayOrMonth, 202602);
assert.equal(videoRows[1]!.rollupDayOrMonth, 202601);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);

View File

@@ -1443,7 +1443,7 @@ export class ImmersionTrackerService {
const parsed = new URL(mediaPath);
const parts = parsed.pathname.split('/').filter(Boolean);
if (parts.length > 0) {
const leaf = decodeURIComponent(parts[parts.length - 1]);
const leaf = decodeURIComponent(parts[parts.length - 1]!);
return this.normalizeText(leaf.replace(/\.[^/.]+$/, ''));
}
return this.normalizeText(parsed.hostname) || 'unknown';

View File

@@ -35,7 +35,6 @@ export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {
getIgnoredPos1Entries,
JlptIgnoredPos1Entry,
JLPT_EXCLUDED_TERMS,
JLPT_IGNORED_MECAB_POS1,
JLPT_IGNORED_MECAB_POS1_ENTRIES,
@@ -43,6 +42,7 @@ export {
shouldIgnoreJlptByTerm,
shouldIgnoreJlptForMecabPos1,
} from './jlpt-token-filter';
export type { JlptIgnoredPos1Entry } from './jlpt-token-filter';
export { loadYomitanExtension } from './yomitan-extension-loader';
export {
getJimakuLanguagePreference,
@@ -72,8 +72,6 @@ export {
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
MpvIpcClient,
MpvRuntimeClientLike,
MpvTrackProperty,
playNextSubtitleRuntime,
replayCurrentSubtitleRuntime,
resolveCurrentAudioStreamIndex,
@@ -81,6 +79,7 @@ export {
setMpvSubVisibilityRuntime,
showMpvOsdRuntime,
} from './mpv';
export type { MpvRuntimeClientLike, MpvTrackProperty } from './mpv';
export {
applyMpvSubtitleRenderMetricsPatch,
DEFAULT_MPV_SUBTITLE_RENDER_METRICS,

View File

@@ -70,11 +70,11 @@ test('start posts capabilities on socket connect', async () => {
});
service.start();
sockets[0].emit('open');
sockets[0]!.emit('open');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].input, 'http://jellyfin.local:8096/Sessions/Capabilities/Full');
assert.equal(fetchCalls[0]!.input, 'http://jellyfin.local:8096/Sessions/Capabilities/Full');
assert.equal(service.isConnected(), true);
});
@@ -97,9 +97,9 @@ test('socket headers include jellyfin authorization metadata', () => {
service.start();
assert.equal(seenHeaders.length, 1);
assert.ok(seenHeaders[0].Authorization.includes('Client="SubMiner"'));
assert.ok(seenHeaders[0].Authorization.includes('DeviceId="device-auth"'));
assert.ok(seenHeaders[0]['X-Emby-Authorization']);
assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"'));
assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"'));
assert.ok(seenHeaders[0]!['X-Emby-Authorization']);
});
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
@@ -124,7 +124,7 @@ test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
});
service.start();
const socket = sockets[0];
const socket = sockets[0]!;
socket.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemId: 'movie-1' } }));
socket.emit(
'message',
@@ -174,13 +174,13 @@ test('schedules reconnect with bounded exponential backoff', () => {
});
service.start();
sockets[0].emit('close');
sockets[0]!.emit('close');
pendingTimers.shift()?.();
sockets[1].emit('close');
sockets[1]!.emit('close');
pendingTimers.shift()?.();
sockets[2].emit('close');
sockets[2]!.emit('close');
pendingTimers.shift()?.();
sockets[3].emit('close');
sockets[3]!.emit('close');
assert.deepEqual(delays, [100, 200, 400, 400]);
assert.equal(sockets.length, 4);
@@ -216,7 +216,7 @@ test('Jellyfin remote stop prevents further reconnect/network activity', () => {
service.start();
assert.equal(sockets.length, 1);
sockets[0].emit('close');
sockets[0]!.emit('close');
assert.equal(pendingTimers.length, 1);
service.stop();
@@ -252,7 +252,7 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
});
service.start();
sockets[0].emit('open');
sockets[0]!.emit('open');
await new Promise((resolve) => setTimeout(resolve, 0));
const expectedPayload = buildJellyfinTimelinePayload({
@@ -310,7 +310,7 @@ test('advertiseNow validates server registration using Sessions endpoint', async
});
service.start();
sockets[0].emit('open');
sockets[0]!.emit('open');
const ok = await service.advertiseNow();
assert.equal(ok, true);
assert.ok(calls.some((url) => url.endsWith('/Sessions')));

View File

@@ -119,7 +119,7 @@ test('listItems supports search and formats title', async () => {
limit: 25,
},
);
assert.equal(items[0].title, 'Space Show S01E02 Pilot');
assert.equal(items[0]!.title, 'Space Show S01E02 Pilot');
} finally {
globalThis.fetch = originalFetch;
}
@@ -338,14 +338,14 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
[2, 3, 4],
);
assert.equal(
tracks[0].deliveryUrl,
tracks[0]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token',
);
assert.equal(
tracks[1].deliveryUrl,
tracks[1]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token',
);
assert.equal(tracks[2].deliveryUrl, 'https://cdn.example.com/subs.srt');
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
} finally {
globalThis.fetch = originalFetch;
}
@@ -556,9 +556,9 @@ test('listSubtitleTracks falls back from PlaybackInfo to item media sources', as
);
assert.equal(requestCount, 2);
assert.equal(tracks.length, 1);
assert.equal(tracks[0].index, 11);
assert.equal(tracks[0]!.index, 11);
assert.equal(
tracks[0].deliveryUrl,
tracks[0]!.deliveryUrl,
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token',
);
} finally {

View File

@@ -177,8 +177,8 @@ test('splitMpvMessagesFromBuffer parses complete lines and preserves partial buf
assert.equal(parsed.messages.length, 2);
assert.equal(parsed.nextBuffer, '{"partial"');
assert.equal(parsed.messages[0].event, 'shutdown');
assert.equal(parsed.messages[1].name, 'media-title');
assert.equal(parsed.messages[0]!.event, 'shutdown');
assert.equal(parsed.messages[1]!.name, 'media-title');
});
test('splitMpvMessagesFromBuffer reports invalid JSON lines', () => {
@@ -189,7 +189,7 @@ test('splitMpvMessagesFromBuffer reports invalid JSON lines', () => {
});
assert.equal(errors.length, 1);
assert.equal(errors[0].line, '{invalid}');
assert.equal(errors[0]!.line, '{invalid}');
});
test('visibility and boolean parsers handle text values', () => {

View File

@@ -84,11 +84,11 @@ test('scheduleMpvReconnect clears existing timer and increments attempt', () =>
assert.equal(nextAttempt, 4);
assert.equal(cleared.length, 1);
assert.equal(cleared[0], existing);
assert.equal(cleared[0]!, existing);
assert.equal(setTimers.length, 1);
assert.equal(calls.length, 1);
assert.equal(calls[0].attempt, 4);
assert.equal(calls[0].delay, getMpvReconnectDelay(3, true));
assert.equal(calls[0]!.attempt, 4);
assert.equal(calls[0]!.delay, getMpvReconnectDelay(3, true));
assert.equal(connected, 1);
});

View File

@@ -54,8 +54,8 @@ test('MpvIpcClient handles sub-text property change and broadcasts tokenized sub
});
assert.equal(events.length, 1);
assert.equal(events[0].text, '字幕');
assert.equal(events[0].isOverlayVisible, false);
assert.equal(events[0]!.text, '字幕');
assert.equal(events[0]!.isOverlayVisible, false);
});
test('MpvIpcClient parses JSON line protocol in processBuffer', () => {
@@ -70,8 +70,8 @@ test('MpvIpcClient parses JSON line protocol in processBuffer', () => {
(client as any).processBuffer();
assert.equal(seen.length, 2);
assert.equal(seen[0].name, 'path');
assert.equal(seen[1].request_id, 1);
assert.equal(seen[0]!.name, 'path');
assert.equal(seen[1]!.request_id, 1);
assert.equal((client as any).buffer, '{"partial":');
});

View File

@@ -83,5 +83,5 @@ test('overlay measurement store rate-limits invalid payload warnings', () => {
now = 11_000;
store.report({ layer: 'visible' });
assert.equal(warnings.length, 1);
assert.match(warnings[0], /Dropped 3 invalid measurement payload/);
assert.match(warnings[0]!, /Dropped 3 invalid measurement payload/);
});

View File

@@ -23,7 +23,7 @@ const VIDEO_EXTENSIONS = new Set([
]);
function getPathExtension(pathValue: string): string {
const normalized = pathValue.split(/[?#]/, 1)[0];
const normalized = pathValue.split(/[?#]/, 1)[0] ?? '';
const dot = normalized.lastIndexOf('.');
return dot >= 0 ? normalized.slice(dot).toLowerCase() : '';
}

View File

@@ -26,7 +26,7 @@ export function shortcutMatchesInputForLocalFallback(
const parts = normalized.split('+').filter(Boolean);
if (parts.length === 0) return false;
const keyToken = parts[parts.length - 1];
const keyToken = parts[parts.length - 1]!;
const modifierTokens = new Set(parts.slice(0, -1));
const allowedModifiers = new Set(['shift', 'alt', 'meta', 'control', 'commandorcontrol']);
for (const token of modifierTokens) {

View File

@@ -339,6 +339,6 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
assert.equal(syncOutputIndex >= 0, true);
const outputPath = ffArgs[syncOutputIndex + 1];
assert.equal(typeof outputPath, 'string');
assert.ok(outputPath.length > 0);
assert.equal(fs.readFileSync(outputPath, 'utf8'), '');
assert.ok(outputPath!.length > 0);
assert.equal(fs.readFileSync(outputPath!, 'utf8'), '');
});

View File

@@ -28,7 +28,8 @@ export function cycleSecondarySubMode(deps: CycleSecondarySubModeDeps): void {
const currentMode = deps.getSecondarySubMode();
const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode);
const nextMode = SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length];
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
const nextMode = SECONDARY_SUB_CYCLE[(safeIndex + 1) % SECONDARY_SUB_CYCLE.length]!;
deps.setSecondarySubMode(nextMode);
deps.broadcastSecondarySubMode(nextMode);
deps.showMpvOsd(`Secondary subtitle: ${nextMode}`);

View File

@@ -82,8 +82,9 @@ export function serializeSubtitleMarkup(
const klass = computeWordClass(token, options);
const parts = token.surface.split('\n');
for (let index = 0; index < parts.length; index += 1) {
if (parts[index]) {
chunks.push(`<span class="${klass}">${escapeHtml(parts[index])}</span>`);
const part = parts[index];
if (part) {
chunks.push(`<span class="${klass}">${escapeHtml(part)}</span>`);
}
if (index < parts.length - 1) {
chunks.push('<br>');

View File

@@ -20,7 +20,7 @@ export class Texthooker {
}
this.server = http.createServer((req, res) => {
const urlPath = (req.url || '/').split('?')[0];
const urlPath = (req.url || '/').split('?')[0] ?? '/';
const filePath = path.join(texthookerPath, urlPath === '/' ? 'index.html' : urlPath);
const ext = path.extname(filePath);

View File

@@ -377,7 +377,7 @@ function isRepeatedKanaSfx(text: string): boolean {
let hasAdjacentRepeat = false;
for (let i = 0; i < chars.length; i += 1) {
const char = chars[i];
const char = chars[i]!;
counts.set(char, (counts.get(char) ?? 0) + 1);
if (i > 0 && chars[i] === chars[i - 1]) {
hasAdjacentRepeat = true;

View File

@@ -73,7 +73,8 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
setTimeout(() => {
if (!settingsWindow.isDestroyed()) {
settingsWindow.setSize(settingsWindow.getSize()[0], settingsWindow.getSize()[1]);
const [width = 0, height = 0] = settingsWindow.getSize();
settingsWindow.setSize(width, height);
settingsWindow.webContents.invalidate();
settingsWindow.show();
}

View File

@@ -51,7 +51,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;
const parsed = Number.parseFloat(raw);
const parsed = Number.parseFloat(raw!);
if (!Number.isFinite(parsed)) return undefined;
return parsed;
}
@@ -146,8 +146,8 @@ function matchEpisodeFromName(name: string): {
const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i);
if (seasonEpisode && seasonEpisode.index !== undefined) {
return {
season: Number.parseInt(seasonEpisode[1], 10),
episode: Number.parseInt(seasonEpisode[2], 10),
season: Number.parseInt(seasonEpisode[1]!, 10),
episode: Number.parseInt(seasonEpisode[2]!, 10),
index: seasonEpisode.index,
confidence: 'high',
};
@@ -156,8 +156,8 @@ function matchEpisodeFromName(name: string): {
const alt = name.match(/(\d{1,2})x(\d{1,3})/i);
if (alt && alt.index !== undefined) {
return {
season: Number.parseInt(alt[1], 10),
episode: Number.parseInt(alt[2], 10),
season: Number.parseInt(alt[1]!, 10),
episode: Number.parseInt(alt[2]!, 10),
index: alt.index,
confidence: 'high',
};
@@ -167,7 +167,7 @@ function matchEpisodeFromName(name: string): {
if (epOnly && epOnly.index !== undefined) {
return {
season: null,
episode: Number.parseInt(epOnly[1], 10),
episode: Number.parseInt(epOnly[1]!, 10),
index: epOnly.index,
confidence: 'medium',
};
@@ -177,7 +177,7 @@ function matchEpisodeFromName(name: string): {
if (numeric && numeric.index !== undefined) {
return {
season: null,
episode: Number.parseInt(numeric[1], 10),
episode: Number.parseInt(numeric[1]!, 10),
index: numeric.index,
confidence: 'medium',
};
@@ -190,7 +190,7 @@ 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 parsed = Number.parseInt(match[1]!, 10);
return Number.isFinite(parsed) ? parsed : null;
}

View File

@@ -29,7 +29,7 @@ test('manual anilist setup submission forwards access token to callback consumer
const handled = handleSubmission('subminer://anilist-setup?access_token=abc123');
assert.equal(handled, true);
assert.equal(consumed.length, 1);
assert.ok(consumed[0].includes('https://anilist.subminer.moe/#access_token=abc123'));
assert.ok(consumed[0]!.includes('https://anilist.subminer.moe/#access_token=abc123'));
});
test('maybe focus anilist setup window focuses existing window', () => {
@@ -179,7 +179,7 @@ test('anilist setup did-fail-load handler forwards details', () => {
});
assert.equal(seen.length, 1);
assert.equal(seen[0].errorCode, -3);
assert.equal(seen[0]!.errorCode, -3);
});
test('anilist setup did-finish-load handler triggers fallback on blank page', () => {

View File

@@ -49,8 +49,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
launch();
assert.equal(spawnedArgs.length, 1);
assert.ok(spawnedArgs[0].includes('--idle=yes'));
assert.ok(spawnedArgs[0].some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
assert.ok(spawnedArgs[0]!.includes('--idle=yes'));
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
});

View File

@@ -38,8 +38,8 @@ test('tray menu template contains expected entries and handlers', () => {
});
assert.equal(template.length, 7);
template[0].click?.();
template[5].type === 'separator' ? calls.push('separator') : calls.push('bad');
template[6].click?.();
template[0]!.click?.();
template[5]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[6]!.click?.();
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
});

View File

@@ -180,7 +180,7 @@ export class MediaGenerator {
): Promise<Buffer> {
const { format, quality = 92, maxWidth, maxHeight } = options;
const ext = format === 'webp' ? 'webp' : format === 'png' ? 'png' : 'jpg';
const codecMap: Record<string, string> = {
const codecMap: Record<'jpg' | 'png' | 'webp', string> = {
jpg: 'mjpeg',
png: 'png',
webp: 'webp',

View File

@@ -41,7 +41,7 @@ test('handleError logs context and recovers overlay state', () => {
assert.equal(dismissed, 1);
assert.equal(restored, 1);
assert.equal(shown.length, 1);
assert.match(shown[0], /recovered/i);
assert.match(shown[0]!, /recovered/i);
assert.equal(payloads.length, 1);
const payload = payloads[0] as {

View File

@@ -126,12 +126,12 @@ export function createMouseHandlers(
if (!probeChar || isBoundary(probeChar)) return null;
let start = probeIndex;
while (start > 0 && !isBoundary(text[start - 1])) {
while (start > 0 && !isBoundary(text[start - 1] ?? '')) {
start -= 1;
}
let end = probeIndex + 1;
while (end < text.length && !isBoundary(text[end])) {
while (end < text.length && !isBoundary(text[end] ?? '')) {
end += 1;
}

View File

@@ -204,7 +204,7 @@ export function createJimakuModal(
if (index < 0 || index >= ctx.state.jimakuEntries.length) return;
ctx.state.selectedEntryIndex = index;
ctx.state.currentEntryId = ctx.state.jimakuEntries[index].id;
ctx.state.currentEntryId = ctx.state.jimakuEntries[index]!.id;
renderEntries();
if (ctx.state.currentEntryId !== null) {
@@ -223,7 +223,7 @@ export function createJimakuModal(
return;
}
const file = ctx.state.jimakuFiles[index];
const file = ctx.state.jimakuFiles[index]!;
setJimakuStatus('Downloading subtitle...');
const result: JimakuDownloadResult = await window.electronAPI.jimakuDownloadFile({

View File

@@ -30,7 +30,7 @@ export function createRuntimeOptionsModal(
if (ctx.state.runtimeOptionSelectedIndex >= ctx.state.runtimeOptions.length) {
return null;
}
return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex];
return ctx.state.runtimeOptions[ctx.state.runtimeOptionSelectedIndex] ?? null;
}
function renderRuntimeOptionsList(): void {
@@ -114,11 +114,11 @@ export function createRuntimeOptionsModal(
? (safeIndex + 1) % option.allowedValues.length
: (safeIndex - 1 + option.allowedValues.length) % option.allowedValues.length;
ctx.state.runtimeOptionDraftValues.set(option.id, option.allowedValues[nextIndex]);
const nextValue = option.allowedValues[nextIndex];
if (nextValue === undefined) return;
ctx.state.runtimeOptionDraftValues.set(option.id, nextValue);
renderRuntimeOptionsList();
setRuntimeOptionsStatus(
`Selected ${option.label}: ${formatRuntimeOptionValue(option.allowedValues[nextIndex])}`,
);
setRuntimeOptionsStatus(`Selected ${option.label}: ${formatRuntimeOptionValue(nextValue)}`);
}
async function applySelectedRuntimeOption(): Promise<void> {

View File

@@ -357,7 +357,7 @@ function createSectionNode(
list.className = 'session-help-item-list';
section.rows.forEach((row, rowIndex) => {
const globalIndex = globalIndexMap[sectionIndex] + rowIndex;
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
const button = createShortcutRow(row, globalIndex);
list.appendChild(button);
});
@@ -406,6 +406,7 @@ export function createSessionHelpModal(
item.tabIndex = idx === next ? 0 : -1;
});
const activeItem = items[next];
if (!activeItem) return;
activeItem.focus({ preventScroll: true });
activeItem.scrollIntoView({
block: 'nearest',
@@ -562,7 +563,7 @@ export function createSessionHelpModal(
const shortcutSections = buildOverlayShortcutSections(shortcuts);
if (shortcutSections.length > 0) {
shortcutSections[0].title = 'Overlay shortcuts (configurable)';
shortcutSections[0]!.title = 'Overlay shortcuts (configurable)';
}
const colorSection = buildColorSection(styleConfig ?? {});
helpSections = [...bindingSections, ...shortcutSections, colorSection];

View File

@@ -145,10 +145,11 @@ function renderWithTokens(
if (surface.includes('\n')) {
const parts = surface.split('\n');
for (let i = 0; i < parts.length; i += 1) {
if (parts[i]) {
const part = parts[i];
if (part) {
const span = document.createElement('span');
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
span.textContent = parts[i];
span.textContent = part;
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
fragment.appendChild(span);
@@ -277,7 +278,7 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
const fragment = document.createDocumentFragment();
for (let i = 0; i < lines.length; i += 1) {
fragment.appendChild(document.createTextNode(lines[i]));
fragment.appendChild(document.createTextNode(lines[i] ?? ''));
if (i < lines.length - 1) {
fragment.appendChild(document.createElement('br'));
}
@@ -361,8 +362,9 @@ export function createSubtitleRenderer(ctx: RendererContext) {
const lines = normalized.split('\n');
for (let i = 0; i < lines.length; i += 1) {
if (lines[i]) {
ctx.dom.secondarySubRoot.appendChild(document.createTextNode(lines[i]));
const line = lines[i];
if (line) {
ctx.dom.secondarySubRoot.appendChild(document.createTextNode(line));
}
if (i < lines.length - 1) {
ctx.dom.secondarySubRoot.appendChild(document.createElement('br'));

View File

@@ -47,7 +47,7 @@ function setPathValue(target: Record<string, unknown>, path: string, value: unkn
const parts = path.split('.');
let current = target;
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i];
const part = parts[i]!;
const isLeaf = i === parts.length - 1;
if (isLeaf) {
current[part] = value;
@@ -188,7 +188,7 @@ export class RuntimeOptionsManager {
direction === 1
? (safeIndex + 1) % values.length
: (safeIndex - 1 + values.length) % values.length;
return this.setOptionValue(id, values[nextIndex]);
return this.setOptionValue(id, values[nextIndex]!);
}
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {

View File

@@ -307,6 +307,7 @@ export function markNPlusOneTargets(tokens: MergedToken[], minSentenceWords = 3)
let sentenceWordCount = 0;
for (let i = start; i < endExclusive; i++) {
const token = markedTokens[i];
if (!token) continue;
if (!isSentenceBoundaryToken(token) && token.surface.trim().length > 0) {
sentenceWordCount += 1;
}
@@ -317,8 +318,8 @@ export function markNPlusOneTargets(tokens: MergedToken[], minSentenceWords = 3)
}
if (sentenceWordCount >= minimumSentenceWords && sentenceCandidates.length === 1) {
markedTokens[sentenceCandidates[0]] = {
...markedTokens[sentenceCandidates[0]],
markedTokens[sentenceCandidates[0]!] = {
...markedTokens[sentenceCandidates[0]!]!,
isNPlusOneTarget: true,
};
}
@@ -326,6 +327,7 @@ export function markNPlusOneTargets(tokens: MergedToken[], minSentenceWords = 3)
for (let i = 0; i < markedTokens.length; i++) {
const token = markedTokens[i];
if (!token) continue;
if (isSentenceBoundaryToken(token)) {
markSentence(sentenceStart, i);
sentenceStart = i + 1;

View File

@@ -177,10 +177,10 @@ export class MacOSWindowTracker extends BaseWindowTracker {
if (result && result !== 'not-found') {
const parts = result.split(',');
if (parts.length === 4) {
const x = parseInt(parts[0], 10);
const y = parseInt(parts[1], 10);
const width = parseInt(parts[2], 10);
const height = parseInt(parts[3], 10);
const x = parseInt(parts[0]!, 10);
const y = parseInt(parts[1]!, 10);
const width = parseInt(parts[2]!, 10);
const height = parseInt(parts[3]!, 10);
if (
Number.isFinite(x) &&

View File

@@ -47,10 +47,10 @@ export function parseX11WindowGeometry(winInfo: string): {
return null;
}
return {
x: parseInt(xMatch[1], 10),
y: parseInt(yMatch[1], 10),
width: parseInt(widthMatch[1], 10),
height: parseInt(heightMatch[1], 10),
x: parseInt(xMatch[1]!, 10),
y: parseInt(yMatch[1]!, 10),
width: parseInt(widthMatch[1]!, 10),
height: parseInt(heightMatch[1]!, 10),
};
}
@@ -59,7 +59,7 @@ export function parseX11WindowPid(raw: string): number | null {
if (!pidMatch) {
return null;
}
const pid = Number.parseInt(pidMatch[1], 10);
const pid = Number.parseInt(pidMatch[1]!, 10);
return Number.isInteger(pid) ? pid : null;
}

View File

@@ -2,10 +2,14 @@
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022", "DOM"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"moduleDetection": "force",
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,