chore: improve engineering quality and hardening

- Fix CI to run all 4 test files (was missing direct-modules.test.js)
- Remove unused ESLint dependencies (eslint, @typescript-eslint/*, eslint-plugin-obsidianmd)
- Enable noImplicitReturns in tsconfig.json for stricter type safety
- Make applyApiProviderPreset return new object instead of mutating input
- Add per-file error handling in batch processing loop with error count in summary
- Narrow all catch clause types to unknown with proper instanceof narrowing

Change-Id: I76975dbd5ebdb5645d18c5858c8a52fd6da3405e
This commit is contained in:
wujunchen 2026-04-27 11:15:35 +08:00
parent b1e35fc8fc
commit 5f12113fcd
15 changed files with 75 additions and 5156 deletions

View file

@ -27,7 +27,7 @@ jobs:
run: npm run typecheck
- name: Test
run: node tests/main.test.js && node tests/generation-job-manager.test.js && node tests/modules.test.js
run: node tests/main.test.js && node tests/generation-job-manager.test.js && node tests/modules.test.js && node tests/direct-modules.test.js
- name: Lint
run: npm run lint

40
main.js

File diff suppressed because one or more lines are too long

26
main.ts
View file

@ -9,6 +9,7 @@ import {
hasUnsafeBatchFolderSegments,
markBatchFileRunning,
normalizeBatchFolderInput,
recordBatchError,
recordBatchProcessed,
recordBatchSkip,
requestBatchCancel,
@ -139,7 +140,10 @@ class ParallelReaderPlugin extends Plugin {
name: this.t('cmdClearCurrent'),
callback: async () => {
const active = this.getActiveView();
if (!active?.file) return new Notice(this.t('noCurrentNote'));
if (!active?.file) {
new Notice(this.t('noCurrentNote'));
return;
}
await this.cacheManager.delete(active.file.path);
new Notice(this.t('cacheClearedFile', { name: active.file.basename }));
},
@ -228,7 +232,7 @@ class ParallelReaderPlugin extends Plugin {
if (this._settingsSaveTimer) clearTimeout(this._settingsSaveTimer);
this._settingsSaveTimer = setTimeout(() => {
this._settingsSaveTimer = null;
this.saveSettings().catch((e) => console.error('[parallel-reader] failed to save settings', e));
this.saveSettings().catch((e: unknown) => console.error('[parallel-reader] failed to save settings', e));
}, delayMs);
}
@ -539,7 +543,7 @@ class ParallelReaderPlugin extends Plugin {
}),
);
})
.catch((e) => {
.catch((e: unknown) => {
if (e instanceof GenerationJobAlreadyRunningError) {
new Notice(e.message);
return;
@ -550,9 +554,10 @@ class ParallelReaderPlugin extends Plugin {
return;
}
const kind = classifyGenerationError(e);
const msg = e instanceof Error ? e.message : String(e);
console.error(e);
if (view && this.viewIsShowingFile(view, file)) view.renderError(file, e.message || String(e));
new Notice(this.t('generationFailed', { kind: kind === 'unknown' ? '' : ` (${kind})`, error: e.message || e }));
if (view && this.viewIsShowingFile(view, file)) view.renderError(file, msg);
new Notice(this.t('generationFailed', { kind: kind === 'unknown' ? '' : ` (${kind})`, error: msg }));
});
}
@ -615,8 +620,13 @@ class ParallelReaderPlugin extends Plugin {
stats = recordBatchSkip(stats);
continue;
}
await this.runForFile(file, false);
stats = recordBatchProcessed(stats);
try {
await this.runForFile(file, false);
stats = recordBatchProcessed(stats);
} catch (e: unknown) {
stats = recordBatchError(stats);
console.error('[parallel-reader] batch error for', file.path, e);
}
}
} finally {
if (this.activeBatch === batch) this.activeBatch = null;
@ -624,6 +634,7 @@ class ParallelReaderPlugin extends Plugin {
const batchVars: Record<string, string | number> = {
processed: stats.processed,
skipped: stats.skipped,
errors: stats.errors,
total: stats.total,
};
if (batch.cancelled) new Notice(this.t('batchCancelled', batchVars));
@ -790,6 +801,7 @@ export const __test = {
normalizeStreamingTimeoutMs,
nextCardIndex,
pruneCacheEntries,
recordBatchError,
recordBatchProcessed,
recordBatchSkip,
requestBatchCancel,

5102
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,11 +15,7 @@
"devDependencies": {
"@biomejs/biome": "^2.4.13",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"esbuild": "^0.24.2",
"eslint": "^10.2.1",
"eslint-plugin-obsidianmd": "github:obsidianmd/eslint-plugin",
"obsidian": "^1.7.2",
"typescript": "^5.7.2"
}

View file

@ -12,6 +12,7 @@ export interface BatchStats {
total: number;
processed: number;
skipped: number;
errors: number;
}
export interface BatchRunState {
@ -68,7 +69,7 @@ export function batchProgressVars(index: number, total: number): { current: numb
}
export function createBatchStats(total: number): BatchStats {
return { total, processed: 0, skipped: 0 };
return { total, processed: 0, skipped: 0, errors: 0 };
}
export function recordBatchProcessed(stats: BatchStats): BatchStats {
@ -79,6 +80,10 @@ export function recordBatchSkip(stats: BatchStats): BatchStats {
return { ...stats, processed: stats.processed + 1, skipped: stats.skipped + 1 };
}
export function recordBatchError(stats: BatchStats): BatchStats {
return { ...stats, processed: stats.processed + 1, errors: stats.errors + 1 };
}
export function shouldSkipBatchFile(entry: CacheEntry | null, content: string, settings: PluginSettings): boolean {
return !!entry && cacheEntryMatches(entry, content, settings);
}

View file

@ -89,7 +89,7 @@ export class CacheManager {
this._timer = setTimeout(() => {
this._timer = null;
if (!this._dirty) return;
this.save().catch((e) => console.error('[parallel-reader] failed to save cache', e));
this.save().catch((e: unknown) => console.error('[parallel-reader] failed to save cache', e));
}, delayMs);
}

View file

@ -100,7 +100,7 @@ export class GenerationJobManager {
const result = await runner(job);
job.throwIfCancelled();
return result;
} catch (err) {
} catch (err: unknown) {
if (job.cancelled && !(err instanceof GenerationJobCancelledError)) {
throw new GenerationJobCancelledError(key);
}

View file

@ -158,8 +158,8 @@ export const STRINGS: Record<string, Record<string, string>> = {
batchFolderNotFound: '文件夹不存在:{path}',
batchNoMarkdown: '该文件夹下没有 Markdown 文件',
batchProgress: '批量生成:{current}/{total}',
batchDone: '批量生成完成:共处理 {processed}/{total} 篇,跳过缓存 {skipped} 篇',
batchCancelled: '批量生成已取消:已处理 {processed}/{total} 篇,跳过缓存 {skipped} 篇',
batchDone: '批量生成完成:共处理 {processed}/{total} 篇,跳过缓存 {skipped} 篇,失败 {errors} 篇',
batchCancelled: '批量生成已取消:已处理 {processed}/{total} 篇,跳过缓存 {skipped} 篇,失败 {errors} 篇',
},
en: {
appTitle: 'Parallel Reader',
@ -324,8 +324,8 @@ export const STRINGS: Record<string, Record<string, string>> = {
batchFolderNotFound: 'Folder not found: {path}',
batchNoMarkdown: 'No Markdown files found in that folder',
batchProgress: 'Batch generating: {current}/{total}',
batchDone: 'Batch complete: processed {processed}/{total}, skipped {skipped} (cached)',
batchCancelled: 'Batch cancelled: processed {processed}/{total}, skipped {skipped} (cached)',
batchDone: 'Batch complete: processed {processed}/{total}, skipped {skipped} (cached), errors {errors}',
batchCancelled: 'Batch cancelled: processed {processed}/{total}, skipped {skipped} (cached), errors {errors}',
},
};

View file

@ -230,7 +230,7 @@ async function requestJsonBodyWithStructuredFallback(
): Promise<Record<string, unknown>> {
try {
return await requestJsonBody(requestUrlImpl, label, url, headers, structuredBody, settings);
} catch (e) {
} catch (e: unknown) {
if (!fallbackBody || !shouldRetryWithoutStructuredOutput(e)) throw e;
console.warn(`[parallel-reader] ${label} structured output rejected; retrying without structured output`, e);
return requestJsonBody(requestUrlImpl, label + ' fallback', url, headers, fallbackBody, settings);

View file

@ -85,7 +85,10 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
.onChange(async (v) => {
this.plugin.settings.backend = v;
if (v === 'api' && !this.plugin.settings.apiBaseUrl) {
applyApiProviderPreset(this.plugin.settings, this.plugin.settings.apiProvider || 'anthropic');
this.plugin.settings = applyApiProviderPreset(
this.plugin.settings,
this.plugin.settings.apiProvider || 'anthropic',
);
}
await this.plugin.saveSettings();
this.display();
@ -121,7 +124,7 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
d.addOption(id, entry.label);
}
return d.setValue(this.plugin.settings.apiProvider).onChange(async (v) => {
applyApiProviderPreset(this.plugin.settings, v);
this.plugin.settings = applyApiProviderPreset(this.plugin.settings, v);
await this.plugin.saveSettings();
this.display();
});

View file

@ -310,7 +310,7 @@ export function modelForApi(settings: PluginSettings): string {
return raw;
}
export function applyApiProviderPreset(settings: PluginSettings, providerId: string) {
export function applyApiProviderPreset(settings: Readonly<PluginSettings>, providerId: string): PluginSettings {
const previousPreset = getApiPreset(settings);
const preset = API_PROVIDER_PRESETS[providerId] || API_PROVIDER_PRESETS.anthropic;
const previousModel = (settings.model || '').trim();
@ -319,12 +319,15 @@ export function applyApiProviderPreset(settings: PluginSettings, providerId: str
previousModel === DEFAULT_SETTINGS.model ||
(!!previousPreset.model && previousModel === previousPreset.model);
settings.apiProvider = providerId;
settings.apiFormat = preset.format;
settings.apiBaseUrl = preset.baseUrl;
settings.apiAuthType = preset.authType || 'auto';
settings.apiKeyEnvVar = preset.envVar || '';
if (shouldSwapModel) settings.model = preset.model || '';
return {
...settings,
apiProvider: providerId,
apiFormat: preset.format,
apiBaseUrl: preset.baseUrl,
apiAuthType: preset.authType || 'auto',
apiKeyEnvVar: preset.envVar || '',
...(shouldSwapModel ? { model: preset.model || '' } : {}),
};
}
export function normalizeSettings(settings: PluginSettings): PluginSettings {

View file

@ -22,7 +22,7 @@ export async function ensureVaultFolder(app: App, folderPath: string) {
if (app.vault.getAbstractFileByPath(folder)) continue;
try {
await app.vault.createFolder(folder);
} catch (e) {
} catch (e: unknown) {
if (!app.vault.getAbstractFileByPath(folder)) throw e;
}
}

View file

@ -264,9 +264,11 @@ assert.deepStrictEqual(t.batchProgressVars(1, 3), { current: 2, total: 3 }, 'pro
const batchStats = t.createBatchStats(3);
const skippedStats = t.recordBatchSkip(batchStats);
const processedStats = t.recordBatchProcessed(skippedStats);
assert.deepStrictEqual(batchStats, { total: 3, processed: 0, skipped: 0 }, 'batch stats are immutable');
assert.deepStrictEqual(skippedStats, { total: 3, processed: 1, skipped: 1 }, 'batch skip updates progress');
assert.deepStrictEqual(processedStats, { total: 3, processed: 2, skipped: 1 }, 'batch processed count accumulates');
const errorStats = t.recordBatchError(processedStats);
assert.deepStrictEqual(batchStats, { total: 3, processed: 0, skipped: 0, errors: 0 }, 'batch stats are immutable');
assert.deepStrictEqual(skippedStats, { total: 3, processed: 1, skipped: 1, errors: 0 }, 'batch skip updates progress');
assert.deepStrictEqual(processedStats, { total: 3, processed: 2, skipped: 1, errors: 0 }, 'batch processed count accumulates');
assert.deepStrictEqual(errorStats, { total: 3, processed: 3, skipped: 1, errors: 1 }, 'batch error count accumulates');
const batchState = t.createBatchRunState();
assert.deepStrictEqual(batchState, { cancelled: false, currentPath: '' }, 'batch state starts idle');

View file

@ -7,7 +7,7 @@
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"noImplicitReturns": false,
"noImplicitReturns": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true