diff --git a/src/main/ipc/rendererLogs.ts b/src/main/ipc/rendererLogs.ts index daf2a4ad..589110d4 100644 --- a/src/main/ipc/rendererLogs.ts +++ b/src/main/ipc/rendererLogs.ts @@ -1,6 +1,10 @@ -import { RENDERER_BOOT, RENDERER_HEARTBEAT, RENDERER_LOG } from '@preload/constants/ipcChannels'; import { type IpcMain } from 'electron'; +// IPC channel names — must match the preload bindings in src/preload/index.ts +const RENDERER_LOG = 'renderer:log'; +const RENDERER_BOOT = 'renderer:boot'; +const RENDERER_HEARTBEAT = 'renderer:heartbeat'; + const lastHeartbeatByWebContentsId = new Map(); const lastHeartbeatWarnedAtByWebContentsId = new Map(); const hasReceivedHeartbeatByWebContentsId = new Set(); diff --git a/src/main/services/editor/EditorFileWatcher.ts b/src/main/services/editor/EditorFileWatcher.ts index fbb9c306..f3c44895 100644 --- a/src/main/services/editor/EditorFileWatcher.ts +++ b/src/main/services/editor/EditorFileWatcher.ts @@ -37,7 +37,7 @@ export class EditorFileWatcher { private flushTimer: ReturnType | null = null; private onChangeCallback: ((event: EditorFileChangeEvent) => void) | null = null; // Higher debounce = fewer IPC events during large bursts (checkout/build/format). - private readonly DEBOUNCE_MS = 350; + private readonly debounceMs = 350; private ignoreChangeUntilMs = 0; private watchedFilesKey = ''; private watchedDirsKey = ''; @@ -229,7 +229,7 @@ export class EditorFileWatcher { emit('delete'); if (emitted < MAX_EMITTED_EVENTS_PER_FLUSH) emit('create'); if (emitted < MAX_EMITTED_EVENTS_PER_FLUSH) emit('change'); - }, this.DEBOUNCE_MS); + }, this.debounceMs); } /** diff --git a/src/main/services/team/TaskBoundaryParser.ts b/src/main/services/team/TaskBoundaryParser.ts index 0180f803..88ebd814 100644 --- a/src/main/services/team/TaskBoundaryParser.ts +++ b/src/main/services/team/TaskBoundaryParser.ts @@ -34,7 +34,7 @@ const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/; export class TaskBoundaryParser { private cache = new Map(); - private readonly CACHE_TTL = 60 * 1000; // 60s + private readonly cacheTtl = 60 * 1000; // 60s /** Парсинг JSONL файла для обнаружения границ задач */ async parseBoundaries(filePath: string): Promise { @@ -127,7 +127,7 @@ export class TaskBoundaryParser { this.cache.set(filePath, { data: result, mtime: fileStat.mtimeMs, - expiresAt: Date.now() + this.CACHE_TTL, + expiresAt: Date.now() + this.cacheTtl, }); return result; } diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 674f92d1..3f6a830c 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -48,32 +48,6 @@ function withReadTimeout(promise: Promise, ms: number): Promise { }); } -async function readFileHead(filePath: string, maxBytes: number): Promise { - const handle = await fs.promises.open(filePath, 'r'); - try { - const stat = await handle.stat(); - const bytesToRead = Math.max(0, Math.min(stat.size, maxBytes)); - if (bytesToRead === 0) return ''; - const buffer = Buffer.alloc(bytesToRead); - await handle.read(buffer, 0, bytesToRead, 0); - return buffer.toString('utf8'); - } finally { - await handle.close(); - } -} - -function extractQuotedString(head: string, key: string): string | null { - const re = new RegExp(`"${key}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`); - const match = re.exec(head); - if (!match?.[1]) return null; - try { - const value = JSON.parse(match[1]) as unknown; - return typeof value === 'string' ? value : null; - } catch { - return null; - } -} - export class TeamConfigReader { constructor( private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() diff --git a/src/main/services/team/TeamFsWorkerClient.ts b/src/main/services/team/TeamFsWorkerClient.ts index e83755c8..4fb39708 100644 --- a/src/main/services/team/TeamFsWorkerClient.ts +++ b/src/main/services/team/TeamFsWorkerClient.ts @@ -44,7 +44,7 @@ type WorkerResponse = | { id: string; ok: false; error: string }; function makeId(): string { - return `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; } function resolveWorkerPath(): string | null { @@ -172,11 +172,7 @@ export class TeamFsWorkerClient { reject(error); }, }); - const msg: WorkerRequest = - op === 'listTeams' - ? ({ id, op, payload } as WorkerRequest) - : ({ id, op, payload } as WorkerRequest); - worker.postMessage(msg); + worker.postMessage({ id, op, payload } as WorkerRequest); }); } diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 4da50f03..57ec1c67 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -29,6 +29,105 @@ type WorkerResponse = | { id: string; ok: true; result: unknown; diag?: unknown } | { id: string; ok: false; error: string }; +// --------------------------------------------------------------------------- +// Diagnostic types +// --------------------------------------------------------------------------- + +interface SlowEntry { + teamName: string; + ms: number; +} + +interface ListTeamsDiag { + op: string; + startedAt: number; + teamsDir: string; + totalDirs: number; + returned: number; + skipped: number; + skipReasons: Record; + slowest: SlowEntry[]; + totalMs: number; +} + +interface GetAllTasksDiag { + op: string; + startedAt: number; + tasksBase: string; + teamDirs: number; + returned: number; + skipped: number; + skipReasons: Record; + slowestTeams: SlowEntry[]; + totalMs: number; +} + +interface TaskReadDiag { + skipped: number; + skipReasons: Record; +} + +// --------------------------------------------------------------------------- +// Parsed JSON types (loose shapes from disk) +// --------------------------------------------------------------------------- + +interface ParsedConfig { + name?: unknown; + description?: unknown; + color?: unknown; + projectPath?: unknown; + leadSessionId?: unknown; + deletedAt?: unknown; + projectPathHistory?: unknown; + sessionHistory?: unknown; + members?: unknown; +} + +interface RawMember { + name?: unknown; + agentType?: unknown; + role?: unknown; + color?: unknown; + removedAt?: unknown; +} + +interface ParsedTask { + id?: unknown; + subject?: unknown; + title?: unknown; + description?: unknown; + activeForm?: unknown; + owner?: unknown; + createdBy?: unknown; + status?: unknown; + blocks?: unknown; + blockedBy?: unknown; + related?: unknown; + createdAt?: unknown; + projectPath?: unknown; + comments?: unknown; + needsClarification?: unknown; + metadata?: { _internal?: unknown }; + workIntervals?: unknown; +} + +interface RawWorkInterval { + startedAt?: unknown; + completedAt?: unknown; +} + +interface RawComment { + id?: unknown; + author?: unknown; + text?: unknown; + createdAt?: unknown; + type?: unknown; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + function isAbortError(error: unknown): boolean { return ( !!error && @@ -71,7 +170,7 @@ async function readFileHeadUtf8(filePath: string, maxBytes: number): Promise { +function bumpSkipReason(reasons: Record, reason: string): void { + // eslint-disable-next-line no-param-reassign -- accumulator mutation is intentional + reasons[reason] = (reasons[reason] || 0) + 1; +} + +function pushSlowest(list: SlowEntry[], entry: SlowEntry, maxLen: number): void { + list.push(entry); + list.sort((a, b) => b.ms - a.ms); + // eslint-disable-next-line no-param-reassign -- truncate in-place is intentional + if (list.length > maxLen) list.length = maxLen; +} + +// --------------------------------------------------------------------------- +// listTeams +// --------------------------------------------------------------------------- + +function isRawMember(v: unknown): v is RawMember { + return !!v && typeof v === 'object'; +} + +function mergeMember( + m: RawMember, + memberMap: Map, + removedKeys: ReadonlySet +): void { + const name = typeof m.name === 'string' ? m.name.trim() : ''; + if (!name) return; + if (name === 'team-lead' || name === 'user' || m.agentType === 'team-lead') return; + const key = name.toLowerCase(); + if (removedKeys.has(key)) return; + const existing = memberMap.get(key); + memberMap.set(key, { + name: existing?.name ?? name, + role: (typeof m.role === 'string' && m.role.trim()) || existing?.role, + color: (typeof m.color === 'string' && m.color.trim()) || existing?.color, + }); +} + +async function listTeams( + payload: ListTeamsPayload +): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> { const startedAt = nowMs(); - const diag: any = { + const diag: ListTeamsDiag = { op: 'listTeams', startedAt, teamsDir: payload.teamsDir, @@ -136,7 +275,7 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; const skip = (reason: string): null => { diag.skipped++; - diag.skipReasons[reason] = (diag.skipReasons[reason] || 0) + 1; + bumpSkipReason(diag.skipReasons, reason); return null; }; @@ -149,7 +288,7 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; if (!stat.isFile()) return skip('config_not_file'); if (stat.size > payload.maxConfigBytes) return skip('config_too_large'); - let config: any = null; + let config: ParsedConfig | null = null; let displayName: string | null = null; let description = ''; let color: string | undefined; @@ -175,7 +314,7 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; deletedAt = typeof del === 'string' ? del : undefined; } else { const raw = await readFileUtf8WithTimeout(configPath, payload.maxConfigReadMs); - config = JSON.parse(raw); + config = JSON.parse(raw) as ParsedConfig; displayName = typeof config.name === 'string' ? config.name : null; description = typeof config.description === 'string' ? config.description : ''; color = @@ -191,10 +330,10 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; ? config.leadSessionId : undefined; projectPathHistory = Array.isArray(config.projectPathHistory) - ? config.projectPathHistory.slice(-payload.maxProjectPathHistoryInSummary) + ? (config.projectPathHistory as string[]).slice(-payload.maxProjectPathHistoryInSummary) : undefined; sessionHistory = Array.isArray(config.sessionHistory) - ? config.sessionHistory.slice(-payload.maxSessionHistoryInSummary) + ? (config.sessionHistory as string[]).slice(-payload.maxSessionHistoryInSummary) : undefined; deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined; } @@ -210,41 +349,25 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; const memberMap = new Map(); const removedKeys = new Set(); - const mergeMember = (m: any): void => { - const name = typeof m?.name === 'string' ? m.name.trim() : ''; - if (!name) return; - // Summary/memberCount should represent teammates (exclude the lead process). - if (name === 'team-lead' || name === 'user' || m?.agentType === 'team-lead') return; - const key = name.toLowerCase(); - // If meta marks this name removed, do not surface it in summaries - if (removedKeys.has(key)) return; - const existing = memberMap.get(key); - memberMap.set(key, { - name: existing?.name ?? name, - role: (typeof m.role === 'string' && m.role.trim()) || existing?.role, - color: (typeof m.color === 'string' && m.color.trim()) || existing?.color, - }); - }; try { const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json'); const metaStat = await fs.promises.stat(metaPath); if (metaStat.isFile() && metaStat.size <= payload.maxMembersMetaBytes) { const raw = await readFileUtf8WithTimeout(metaPath, payload.maxConfigReadMs); - const parsed = JSON.parse(raw); - const members: any[] = Array.isArray(parsed?.members) ? parsed.members : []; + const parsed = JSON.parse(raw) as { members?: unknown }; + const members: unknown[] = Array.isArray(parsed?.members) ? parsed.members : []; for (const member of members) { - if (!member || typeof member !== 'object') continue; + if (!isRawMember(member)) continue; const name = typeof member.name === 'string' ? member.name.trim() : ''; if (!name) continue; - // Summary/memberCount should represent teammates (exclude the lead process). if (name === 'team-lead' || member.agentType === 'team-lead') continue; const key = name.toLowerCase(); if (member.removedAt) { removedKeys.add(key); continue; } - mergeMember(member); + mergeMember(member, memberMap, removedKeys); } } } catch { @@ -253,8 +376,10 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; // Merge config members AFTER meta so removedAt can suppress stale config entries. if (config && Array.isArray(config.members)) { - for (const member of config.members) { - mergeMember(member); + for (const member of config.members as unknown[]) { + if (isRawMember(member)) { + mergeMember(member, memberMap, removedKeys); + } } } @@ -277,9 +402,7 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; const ms = nowMs() - t0; if (ms >= 250) { - diag.slowest.push({ teamName, ms }); - diag.slowest.sort((a: any, b: any) => b.ms - a.ms); - if (diag.slowest.length > 10) diag.slowest.length = 10; + pushSlowest(diag.slowest, { teamName, ms }, 10); } return summary; }); @@ -290,59 +413,69 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; return { teams, diag }; } +// --------------------------------------------------------------------------- +// Task normalization helpers +// --------------------------------------------------------------------------- + function normalizeWorkIntervals( - parsed: any + parsed: ParsedTask ): { startedAt: string; completedAt?: string }[] | undefined { - if (!Array.isArray(parsed?.workIntervals)) return undefined; + if (!Array.isArray(parsed.workIntervals)) return undefined; return (parsed.workIntervals as unknown[]) .filter( - (i): i is { startedAt: string; completedAt?: string } => + (i): i is RawWorkInterval => Boolean(i) && typeof i === 'object' && - typeof (i as any).startedAt === 'string' && - ((i as any).completedAt === undefined || typeof (i as any).completedAt === 'string') + typeof (i as RawWorkInterval).startedAt === 'string' && + ((i as RawWorkInterval).completedAt === undefined || + typeof (i as RawWorkInterval).completedAt === 'string') ) - .map((i) => ({ startedAt: i.startedAt, completedAt: i.completedAt })); + .map((i) => ({ + startedAt: i.startedAt as string, + completedAt: i.completedAt as string | undefined, + })); } -function normalizeComments(parsed: any): unknown[] | undefined { - if (!Array.isArray(parsed?.comments)) return undefined; +function normalizeComments(parsed: ParsedTask): unknown[] | undefined { + if (!Array.isArray(parsed.comments)) return undefined; return (parsed.comments as unknown[]) .filter( - (c) => - c && + (c): c is RawComment => + !!c && typeof c === 'object' && - typeof (c as any).id === 'string' && - typeof (c as any).author === 'string' && - typeof (c as any).text === 'string' && - typeof (c as any).createdAt === 'string' + typeof (c as RawComment).id === 'string' && + typeof (c as RawComment).author === 'string' && + typeof (c as RawComment).text === 'string' && + typeof (c as RawComment).createdAt === 'string' ) .map((c) => ({ - id: (c as any).id, - author: (c as any).author, - text: (c as any).text, - createdAt: (c as any).createdAt, + id: c.id as string, + author: c.author as string, + text: c.text as string, + createdAt: c.createdAt as string, type: - (c as any).type === 'regular' || - (c as any).type === 'review_request' || - (c as any).type === 'review_approved' - ? (c as any).type + c.type === 'regular' || c.type === 'review_request' || c.type === 'review_approved' + ? (c.type as string) : 'regular', })); } +// --------------------------------------------------------------------------- +// getAllTasks +// --------------------------------------------------------------------------- + async function readTasksDirForTeam( tasksDir: string, teamName: string, - payload: GetAllTasksPayload, - diag: any -): Promise { + payload: GetAllTasksPayload +): Promise<{ tasks: unknown[]; taskDiag: TaskReadDiag }> { + const taskDiag: TaskReadDiag = { skipped: 0, skipReasons: {} }; let entries: string[]; try { entries = await fs.promises.readdir(tasksDir); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return []; + return { tasks: [], taskDiag }; } throw error; } @@ -362,23 +495,22 @@ async function readTasksDirForTeam( try { const stat = await fs.promises.stat(taskPath); if (!stat.isFile() || stat.size > payload.maxTaskBytes) { - diag.skipped++; - diag.skipReasons.task_not_file_or_large = - (diag.skipReasons.task_not_file_or_large || 0) + 1; + taskDiag.skipped++; + bumpSkipReason(taskDiag.skipReasons, 'task_not_file_or_large'); continue; } const raw = await readFileUtf8WithTimeout(taskPath, payload.maxTaskReadMs); - const parsed = JSON.parse(raw); - const metadata = parsed?.metadata; + const parsed = JSON.parse(raw) as ParsedTask; + const metadata = parsed.metadata; if (metadata?._internal === true) { - diag.skipped++; - diag.skipReasons.task_internal = (diag.skipReasons.task_internal || 0) + 1; + taskDiag.skipped++; + bumpSkipReason(taskDiag.skipReasons, 'task_internal'); continue; } - if (parsed?.status === 'deleted') { - diag.skipped++; - diag.skipReasons.task_deleted = (diag.skipReasons.task_deleted || 0) + 1; + if (parsed.status === 'deleted') { + taskDiag.skipped++; + bumpSkipReason(taskDiag.skipReasons, 'task_deleted'); continue; } @@ -404,7 +536,7 @@ async function readTasksDirForTeam( const needsClarification = parsed.needsClarification === 'lead' || parsed.needsClarification === 'user' - ? parsed.needsClarification + ? (parsed.needsClarification as string) : undefined; tasks.push({ @@ -419,7 +551,7 @@ async function readTasksDirForTeam( parsed.status === 'in_progress' || parsed.status === 'completed' || parsed.status === 'deleted' - ? parsed.status + ? (parsed.status as string) : 'pending', workIntervals: normalizeWorkIntervals(parsed), blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined, @@ -436,23 +568,32 @@ async function readTasksDirForTeam( teamName, }); } catch (error) { - diag.skipped++; + taskDiag.skipped++; const code = (error as NodeJS.ErrnoException).code; if (code === 'READ_TIMEOUT') { - diag.skipReasons.task_read_timeout = (diag.skipReasons.task_read_timeout || 0) + 1; + bumpSkipReason(taskDiag.skipReasons, 'task_read_timeout'); } else { - diag.skipReasons.task_parse_failed = (diag.skipReasons.task_parse_failed || 0) + 1; + bumpSkipReason(taskDiag.skipReasons, 'task_parse_failed'); } } } - return tasks; + return { tasks, taskDiag }; +} + +function mergeTaskDiag(target: GetAllTasksDiag, source: TaskReadDiag): void { + // eslint-disable-next-line no-param-reassign -- accumulator mutation is intentional + target.skipped += source.skipped; + for (const [reason, count] of Object.entries(source.skipReasons)) { + // eslint-disable-next-line no-param-reassign -- accumulator mutation is intentional + target.skipReasons[reason] = (target.skipReasons[reason] || 0) + count; + } } async function getAllTasks( payload: GetAllTasksPayload -): Promise<{ tasks: unknown[]; diag: unknown }> { +): Promise<{ tasks: unknown[]; diag: GetAllTasksDiag }> { const startedAt = nowMs(); - const diag: any = { + const diag: GetAllTasksDiag = { op: 'getAllTasks', startedAt, tasksBase: payload.tasksBase, @@ -483,17 +624,16 @@ async function getAllTasks( const t0 = nowMs(); try { const tasksDir = path.join(payload.tasksBase, teamName); - const tasks = await readTasksDirForTeam(tasksDir, teamName, payload, diag); + const { tasks, taskDiag } = await readTasksDirForTeam(tasksDir, teamName, payload); + mergeTaskDiag(diag, taskDiag); const ms = nowMs() - t0; if (ms >= 250) { - diag.slowestTeams.push({ teamName, ms }); - diag.slowestTeams.sort((a: any, b: any) => b.ms - a.ms); - if (diag.slowestTeams.length > 10) diag.slowestTeams.length = 10; + pushSlowest(diag.slowestTeams, { teamName, ms }, 10); } return tasks; } catch { diag.skipped++; - diag.skipReasons.team_dir_failed = (diag.skipReasons.team_dir_failed || 0) + 1; + bumpSkipReason(diag.skipReasons, 'team_dir_failed'); return []; } }); @@ -504,6 +644,10 @@ async function getAllTasks( return { tasks, diag }; } +// --------------------------------------------------------------------------- +// Worker message handler +// --------------------------------------------------------------------------- + function post(msg: WorkerResponse): void { parentPort?.postMessage(msg); } @@ -521,7 +665,7 @@ parentPort?.on('message', async (msg: WorkerRequest) => { post({ id, ok: true, result: tasks, diag }); return; } - post({ id, ok: false, error: `Unknown op: ${String((msg as any).op)}` }); + post({ id, ok: false, error: `Unknown op: ${String(op)}` }); } catch (error) { post({ id, ok: false, error: error instanceof Error ? error.message : String(error) }); } diff --git a/src/renderer/hooks/useDiffNavigation.ts b/src/renderer/hooks/useDiffNavigation.ts index 8c2f46b1..7177fdff 100644 --- a/src/renderer/hooks/useDiffNavigation.ts +++ b/src/renderer/hooks/useDiffNavigation.ts @@ -281,7 +281,7 @@ export function useDiffNavigation( useEffect(() => { if (!isDialogOpen) return; - const handler = (event: KeyboardEvent) => { + const handler = (event: KeyboardEvent): void => { if (event.defaultPrevented) return; if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return; diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts index 7d7562ed..6db45f0b 100644 --- a/src/renderer/hooks/useFileSuggestions.ts +++ b/src/renderer/hooks/useFileSuggestions.ts @@ -133,6 +133,7 @@ export function useFileSuggestions( const cached = getQuickOpenCache(projectPath); if (cached) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- setLoading before async fetch is intentional return fetchFiles(projectPath); }, [projectPath, fetchTrigger, fetchFiles]); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index c54b8d71..7fb0450e 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -105,7 +105,8 @@ export function initializeNotificationListeners(): () => void { const nav: NavigatorWithUserAgentData | null = typeof navigator !== 'undefined' ? (navigator as NavigatorWithUserAgentData) : null; // Prefer UA-CH when available; fall back to deprecated-but-still-supported navigator.platform. - const platform = nav?.userAgentData?.platform ?? nav?.platform ?? nav?.userAgent ?? ''; + // eslint-disable-next-line sonarjs/deprecation -- navigator.platform is deprecated but needed as fallback + const platform: string = nav?.userAgentData?.platform ?? nav?.platform ?? nav?.userAgent ?? ''; const isWindows = platform.toLowerCase().includes('win'); const delayMs = isWindows ? 3000 : 0; cliStatusTimer = setTimeout(() => { diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index 5e2a1c99..55f9c601 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -335,7 +335,8 @@ export const createEditorSlice: StateCreator = (s const rootParts = splitPath(root); const fileParts = splitPath(filePath); const win = isWindowsishPath(root); - const eq = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b); + const eq = (a: string, b: string): boolean => + win ? a.toLowerCase() === b.toLowerCase() : a === b; const hasPrefix = fileParts.length >= rootParts.length && rootParts.every((seg, i) => eq(seg, fileParts[i])); @@ -1344,7 +1345,8 @@ function remapPath(p: string, oldPath: string, newPath: string): string { if (oldParts.length === 0) return p; const win = isWindowsishPath(oldPath) || isWindowsishPath(p) || isWindowsishPath(newPath); - const eq = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b); + const eq = (a: string, b: string): boolean => + win ? a.toLowerCase() === b.toLowerCase() : a === b; const matchesPrefix = pParts.length >= oldParts.length && oldParts.every((seg, i) => eq(seg, pParts[i])); @@ -1364,7 +1366,8 @@ function reverseRemapPath(p: string, oldPath: string, newPath: string): string { if (newParts.length === 0) return p; const win = isWindowsishPath(oldPath) || isWindowsishPath(p) || isWindowsishPath(newPath); - const eq = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b); + const eq = (a: string, b: string): boolean => + win ? a.toLowerCase() === b.toLowerCase() : a === b; const matchesPrefix = pParts.length >= newParts.length && newParts.every((seg, i) => eq(seg, pParts[i])); diff --git a/src/renderer/utils/buildSelectionAction.ts b/src/renderer/utils/buildSelectionAction.ts index 3b6e4519..678d61b3 100644 --- a/src/renderer/utils/buildSelectionAction.ts +++ b/src/renderer/utils/buildSelectionAction.ts @@ -74,7 +74,8 @@ export function buildFileAction( const fullParts = splitPath(filePath); const rootParts = splitPath(projectPath); const win = isWindowsishPath(projectPath); - const eq = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b); + const eq = (a: string, b: string): boolean => + win ? a.toLowerCase() === b.toLowerCase() : a === b; const hasPrefix = fullParts.length >= rootParts.length && rootParts.every((seg, i) => eq(seg, fullParts[i])); if (hasPrefix) { diff --git a/src/shared/utils/platformPath.ts b/src/shared/utils/platformPath.ts index 416483e9..82f0c0aa 100644 --- a/src/shared/utils/platformPath.ts +++ b/src/shared/utils/platformPath.ts @@ -39,7 +39,10 @@ export function stripTrailingSeparators(filePath: string): string { if (!filePath) return filePath; const p = filePath.replace(/\\/g, '/'); if (p === '/' || /^[A-Za-z]:\/$/.test(p)) return filePath; - return filePath.replace(/[/\\]+$/, ''); + // Trim trailing separators manually to avoid sonarjs/slow-regex + let end = filePath.length; + while (end > 0 && (filePath[end - 1] === '/' || filePath[end - 1] === '\\')) end--; + return end === filePath.length ? filePath : filePath.slice(0, end); } /** Prefer the separator style already present in the path. */ @@ -55,7 +58,12 @@ export function joinPath(base: string, ...segments: string[]): string { const sep = getPreferredSeparator(base); let out = stripTrailingSeparators(base); for (const seg of segments) { - const cleaned = seg.replace(/^[\\/]+|[\\/]+$/g, ''); + // Trim leading and trailing separators manually to avoid sonarjs/slow-regex + let start = 0; + while (start < seg.length && (seg[start] === '/' || seg[start] === '\\')) start++; + let segEnd = seg.length; + while (segEnd > start && (seg[segEnd - 1] === '/' || seg[segEnd - 1] === '\\')) segEnd--; + const cleaned = seg.slice(start, segEnd); if (!cleaned) continue; if (!out || out.endsWith('/') || out.endsWith('\\')) { out += cleaned;