chore: integrate obsidianmd eslint plugin and fix recommended rules

Add eslint v9 + @typescript-eslint/parser + eslint-plugin-obsidianmd as
a dedicated `npm run lint:obsidian` track, separate from the existing
biome lint. Apply autofixes from the recommended config and resolve the
follow-on type/test issues.

- prefer-create-el: createEl('div'/'span') -> createDiv/createSpan
- prefer-active-window-timers: setTimeout/clearTimeout -> activeWindow.*
  + retype timer holders from ReturnType<typeof setTimeout> to number
- prefer-active-doc: drop globalThis.fetch in streaming
- no-useless-catch: drop no-op rethrow wrapper
- no-restricted-globals: streaming.ts is exempted because requestUrl
  does not support streaming responses
- @typescript-eslint/no-unsafe-*: disabled (out of scope for an Obsidian
  plugin lint pass; tsc + biome already cover type safety)
- tests: polyfill globalThis.activeWindow so unit tests still run in Node

Change-Id: I43cc652e29a66d490e2834e940843c39b211b80b
This commit is contained in:
wujunchen 2026-05-03 10:45:10 +08:00
parent 83dc9b2874
commit 33371cadd6
13 changed files with 4604 additions and 41 deletions

44
eslint.config.mjs Normal file
View file

@ -0,0 +1,44 @@
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
export default defineConfig([
...obsidianmd.configs.recommended,
{
files: ["main.ts", "src/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: { project: "./tsconfig.json" },
},
rules: {
// Out-of-scope for an Obsidian plugin lint pass: TS-strict noise
// from JSON.parse / dynamic i18n keys. Biome + tsc cover type safety.
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-return": "off",
},
},
{
// streaming.ts legitimately uses bare `fetch` because Obsidian's `requestUrl`
// does not support streaming responses (it's a one-shot wrapper).
files: ["src/streaming.ts"],
rules: {
"no-restricted-globals": "off",
},
},
{
ignores: [
"node_modules/",
"main.js",
"main.js.map",
"tests/",
"scripts/",
".e2e/",
".orchestration/",
".agent/",
".codex-tmp/",
],
},
]);

10
main.ts
View file

@ -53,7 +53,7 @@ class ParallelReaderPlugin extends Plugin {
jobs!: GenerationJobManager;
activeBatch: BatchRunState | null = null;
_scrollDispose: (() => void) | null = null;
_settingsSaveTimer: ReturnType<typeof setTimeout> | null = null;
_settingsSaveTimer: number | null = null;
get cache(): Record<string, CacheEntry> {
return this.cacheManager.cache;
@ -198,15 +198,15 @@ class ParallelReaderPlugin extends Plugin {
async saveSettings() {
if (this._settingsSaveTimer) {
clearTimeout(this._settingsSaveTimer);
activeWindow.clearTimeout(this._settingsSaveTimer);
this._settingsSaveTimer = null;
}
await this.saveData({ settings: this.settings });
}
saveSettingsDebounced(delayMs = 400) {
if (this._settingsSaveTimer) clearTimeout(this._settingsSaveTimer);
this._settingsSaveTimer = setTimeout(() => {
if (this._settingsSaveTimer) activeWindow.clearTimeout(this._settingsSaveTimer);
this._settingsSaveTimer = activeWindow.setTimeout(() => {
this._settingsSaveTimer = null;
this.saveSettings().catch((e: unknown) => console.error('[parallel-reader] failed to save settings', e));
}, delayMs);
@ -214,7 +214,7 @@ class ParallelReaderPlugin extends Plugin {
async flushSettingsSave() {
if (!this._settingsSaveTimer) return;
clearTimeout(this._settingsSaveTimer);
activeWindow.clearTimeout(this._settingsSaveTimer);
this._settingsSaveTimer = null;
await this.saveSettings();
}

4509
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit",
"lint": "biome check main.ts src/ tests/ scripts/ .e2e/scripts/",
"lint:fix": "biome check --write main.ts src/ tests/ scripts/ .e2e/scripts/",
"lint:obsidian": "eslint main.ts src/",
"version": "node scripts/bump-version.mjs",
"test": "npm run build && npm run typecheck && node scripts/run-tests.mjs",
"test:only": "node scripts/run-tests.mjs",
@ -22,7 +23,10 @@
"devDependencies": {
"@biomejs/biome": "^2.4.13",
"@types/node": "^22.10.2",
"@typescript-eslint/parser": "^8.59.1",
"esbuild": "^0.24.2",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.2.9",
"obsidian": "^1.7.2",
"typescript": "^5.7.2"
}

View file

@ -34,7 +34,7 @@ function isValidCacheEntry(entry: unknown): entry is CacheEntry {
export class CacheManager {
cache: Record<string, CacheEntry> = {};
private _timer: ReturnType<typeof setTimeout> | null = null;
private _timer: number | null = null;
private _dirty = false;
constructor(
@ -104,7 +104,7 @@ export class CacheManager {
async save(): Promise<void> {
if (this._timer) {
clearTimeout(this._timer);
activeWindow.clearTimeout(this._timer);
this._timer = null;
}
this.prune();
@ -115,7 +115,7 @@ export class CacheManager {
scheduleSave(delayMs = 5000): void {
this._dirty = true;
if (this._timer) return;
this._timer = setTimeout(() => {
this._timer = activeWindow.setTimeout(() => {
this._timer = null;
if (!this._dirty) return;
this.save().catch((e: unknown) => console.error('[parallel-reader] failed to save cache', e));
@ -124,7 +124,7 @@ export class CacheManager {
async flush(): Promise<void> {
if (this._timer) {
clearTimeout(this._timer);
activeWindow.clearTimeout(this._timer);
this._timer = null;
}
if (!this._dirty) return;

View file

@ -63,17 +63,17 @@ export function runCli(
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
let child: ReturnType<typeof spawn>;
let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let timer: number | null = null;
const fail = (err: Error) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (timer) activeWindow.clearTimeout(timer);
reject(err);
};
const succeed = (value: { stdout: string; stderr: string }) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (timer) activeWindow.clearTimeout(timer);
resolve(value);
};
try {
@ -98,7 +98,7 @@ export function runCli(
let stdout = '';
let stderr = '';
timer = setTimeout(() => {
timer = activeWindow.setTimeout(() => {
try {
child.kill('SIGKILL');
} catch (e: unknown) {

View file

@ -155,13 +155,9 @@ export class GenerationJobManager {
if (this.isPending(key)) throw new GenerationJobAlreadyRunningError(key);
const wait = this.waitSlot(key);
if (wait) {
try {
await wait;
} catch (err) {
// Cancelled while queued (cancelAllWaiters) — slot was never reserved
// for this caller, so no releaseSlot is needed.
throw err;
}
// If cancelled while queued (cancelAllWaiters), the slot was never reserved
// for this caller, so no releaseSlot is needed — propagation is enough.
await wait;
if (this.jobs.has(key)) {
// Same-key racily inserted while we waited; release the slot we got.
this.reserved--;

View file

@ -29,12 +29,12 @@ export function visibleTopProbeY(
type ScheduleId = { readonly __brand: 'ScheduleId'; readonly raw: number | ReturnType<typeof setTimeout> };
function wrapId(raw: number | ReturnType<typeof setTimeout>): ScheduleId {
return { __brand: 'ScheduleId', raw } as ScheduleId;
return { __brand: 'ScheduleId', raw };
}
function defaultSchedule(callback: FrameRequestCallback): ScheduleId {
if (typeof requestAnimationFrame === 'function') return wrapId(requestAnimationFrame(callback));
return wrapId(setTimeout(() => callback(Date.now()), FALLBACK_FRAME_MS));
return wrapId(activeWindow.setTimeout(() => callback(Date.now()), FALLBACK_FRAME_MS));
}
function defaultCancel(id: ScheduleId) {
@ -42,7 +42,7 @@ function defaultCancel(id: ScheduleId) {
cancelAnimationFrame(id.raw as number);
return;
}
clearTimeout(id.raw as ReturnType<typeof setTimeout>);
activeWindow.clearTimeout(id.raw as number);
}
export function createRafThrottledHandler(

View file

@ -197,7 +197,7 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
this.plugin.settings.apiKeyEnvVar = envInput.value.trim();
this.plugin.saveSettingsDebounced();
});
envDetails.createEl('div', {
envDetails.createDiv({
text: this.tr('settingApiKeyEnvDesc'),
cls: 'parallel-reader-env-desc',
});

View file

@ -85,7 +85,7 @@ async function doStreamingFetch(
signal: AbortSignal,
settings: PluginSettings | null | undefined,
): Promise<string> {
const response = await globalThis.fetch(url, {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
@ -158,7 +158,7 @@ export async function streamingFetch(
signal?: AbortSignal,
settings?: PluginSettings | null,
): Promise<string> {
if (typeof globalThis.fetch !== 'function') {
if (typeof fetch !== 'function') {
throw new Error('Streaming requires fetch API');
}
@ -172,9 +172,9 @@ export async function streamingFetch(
if (signal.aborted) timeoutController.abort();
}
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let timeoutId: number | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
timeoutId = activeWindow.setTimeout(() => {
timeoutController.abort();
reject(new Error(`Streaming timed out after ${timeoutMs}ms`));
}, timeoutMs);
@ -186,7 +186,7 @@ export async function streamingFetch(
timeoutPromise,
]);
} finally {
if (timeoutId !== null) clearTimeout(timeoutId);
if (timeoutId !== null) activeWindow.clearTimeout(timeoutId);
if (signal && abortListener) signal.removeEventListener('abort', abortListener);
}
}

View file

@ -110,7 +110,7 @@ export class ParallelReaderView extends ItemView {
container.empty();
const header = container.createDiv({ cls: 'parallel-reader-header' });
const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' });
headerRow.createEl('div', { text: file.basename, cls: 'parallel-reader-title' });
headerRow.createDiv({ text: file.basename, cls: 'parallel-reader-title' });
const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' });
addIconButton(actions, 'square', this.plugin.t('actionCancel'), () => {
this.plugin.cancelGenerationForFile(file);
@ -120,7 +120,7 @@ export class ParallelReaderView extends ItemView {
cls: 'parallel-reader-state parallel-reader-loading parallel-reader-streaming-preview',
});
state.createDiv({ cls: 'parallel-reader-spinner' });
const titleEl = state.createEl('div', { cls: 'parallel-reader-state-title' });
const titleEl = state.createDiv({ cls: 'parallel-reader-state-title' });
titleEl.createSpan({ text: this.plugin.t('loadingGenerating') + ' ' });
titleEl.createSpan({ cls: 'parallel-reader-stream-counter', text: `${text.length} chars` });
const pre = state.createEl('pre', { cls: 'parallel-reader-stream-text' });
@ -174,7 +174,7 @@ export class ParallelReaderView extends ItemView {
private renderHeader(container: Element) {
const header = container.createDiv({ cls: 'parallel-reader-header' });
const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' });
headerRow.createEl('div', { text: this.sourceFile?.basename || '', cls: 'parallel-reader-title' });
headerRow.createDiv({ text: this.sourceFile?.basename || '', cls: 'parallel-reader-title' });
const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' });
if (this.sourceFile) {
if (this.plugin.isGeneratingFile(this.sourceFile)) {
@ -209,14 +209,14 @@ export class ParallelReaderView extends ItemView {
private renderLoadingState(container: Element) {
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-loading' });
state.createDiv({ cls: 'parallel-reader-spinner' });
state.createEl('div', { text: this.loadingMessage, cls: 'parallel-reader-state-title' });
state.createEl('div', { text: this.plugin.t('loadingSubtitle'), cls: 'parallel-reader-state-subtitle' });
state.createDiv({ text: this.loadingMessage, cls: 'parallel-reader-state-title' });
state.createDiv({ text: this.plugin.t('loadingSubtitle'), cls: 'parallel-reader-state-subtitle' });
}
private renderErrorState(container: Element) {
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-error' });
state.createEl('div', { text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' });
state.createEl('div', {
state.createDiv({ text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' });
state.createDiv({
text: this.errorMessage,
cls: 'parallel-reader-state-subtitle parallel-reader-selectable',
});
@ -254,14 +254,14 @@ export class ParallelReaderView extends ItemView {
card.dataset.idx = String(i);
if (s.startLine < 0) card.addClass('parallel-reader-card-unanchored');
const title = card.createEl('div', { cls: 'parallel-reader-card-title' });
const title = card.createDiv({ cls: 'parallel-reader-card-title' });
title.createSpan({ text: s.title });
if (s.startLine < 0) {
title.createEl('span', { text: ' ⚠', cls: 'parallel-reader-warn', title: this.plugin.t('anchorMismatch') });
title.createSpan({ text: ' ⚠', cls: 'parallel-reader-warn', title: this.plugin.t('anchorMismatch') });
}
if (s.gist) {
const gistEl = card.createEl('div', { cls: 'parallel-reader-gist' });
const gistEl = card.createDiv({ cls: 'parallel-reader-gist' });
MarkdownRenderer.render(this.app, s.gist, gistEl, sourcePath, this).catch(() => {
gistEl.setText(s.gist);
});
@ -269,13 +269,13 @@ export class ParallelReaderView extends ItemView {
const bs = s.bullets || [];
if (bs.length > 0) {
const bulletsEl = card.createEl('div', { cls: 'parallel-reader-bullets-md' });
const bulletsEl = card.createDiv({ cls: 'parallel-reader-bullets-md' });
const md = bs.map((b) => `- ${b}`).join('\n');
MarkdownRenderer.render(this.app, md, bulletsEl, sourcePath, this).catch(() => {
bulletsEl.setText(md);
});
} else if (!s.gist) {
card.createEl('div', { cls: 'parallel-reader-empty-li', text: this.plugin.t('emptyCard') });
card.createDiv({ cls: 'parallel-reader-empty-li', text: this.plugin.t('emptyCard') });
}
card.addEventListener('click', (e) => {

View file

@ -4,6 +4,11 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
// Polyfill Obsidian's `activeWindow` global for Node test runtime.
if (typeof globalThis.activeWindow === 'undefined') {
globalThis.activeWindow = globalThis;
}
const repoRoot = path.join(__dirname, '..');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel-reader-tests-'));

View file

@ -5,6 +5,11 @@ const os = require('os');
const path = require('path');
const { EventEmitter } = require('events');
// Polyfill Obsidian's `activeWindow` global for Node test runtime.
if (typeof globalThis.activeWindow === 'undefined') {
globalThis.activeWindow = globalThis;
}
// Load obsidian mock first — hooks Module._load so that require('obsidian')
// returns the mock for both us and the esbuild-bundled test-exports module.
const { getRequestUrlMock, setRequestUrlMock } = require('./obsidian-mock');