Skip to content

Commit bc35b96

Browse files
authored
refactor: standardize runtime guard patterns with lodash-es (#154)
* refactor(ui): replace runtime type guards with lodash-es predicates * refactor(runtime): migrate typeof checks to lodash-es in core modules * refactor(ipc): standardize runtime guards with lodash-es * test(refactor): align runtime guard checks with lodash-es * docs(agents): add lodash-es runtime guard skill summary
1 parent 0223e8d commit bc35b96

33 files changed

Lines changed: 303 additions & 208 deletions
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# lodash-es-runtime-guards
2+
3+
Replace runtime `typeof` checks and ad-hoc empty-string checks with `lodash-es` predicates.
4+
5+
## When to Use
6+
7+
- You see runtime checks like `typeof value === 'string'`.
8+
- You see object guards like `value && typeof value === 'object'`.
9+
- You see emptiness checks like `value.trim() !== ''` or `value.trim() === ''`.
10+
- You want consistent guard style across app, server, IPC, and tests.
11+
12+
## Do Not Replace
13+
14+
- Type-level `typeof` (TypeScript only), for example:
15+
- `ReturnType<typeof fn>`
16+
- `keyof typeof CONST_MAP`
17+
- `z.infer<typeof Schema>`
18+
19+
These are compile-time types and must remain as-is.
20+
21+
## Guard Mapping
22+
23+
- `typeof x === 'string'` -> `isString(x)`
24+
- `typeof x !== 'string'` -> `!isString(x)`
25+
- `typeof x === 'number'` -> `isNumber(x)`
26+
- `typeof x === 'boolean'` -> `isBoolean(x)`
27+
- `typeof x === 'function'` -> `isFunction(x)`
28+
- `typeof x === 'undefined'` -> `isUndefined(x)`
29+
- `x && typeof x === 'object'` -> `isObjectLike(x)`
30+
- `typeof x === 'object' && x !== null` -> `isObjectLike(x)`
31+
- `typeof x === 'object' && !Array.isArray(x)` -> `isObjectLike(x) && !isArray(x)`
32+
33+
## String Emptiness Pattern
34+
35+
- Prefer:
36+
- `isEmpty(value.trim())`
37+
- Instead of:
38+
- `value.trim() === ''`
39+
- `value.trim() !== ''`
40+
- `value.trim().length === 0`
41+
42+
## Import Style
43+
44+
- Use named imports from `lodash-es`:
45+
46+
```ts
47+
import { isString, isNumber, isObjectLike, isEmpty } from 'lodash-es';
48+
```
49+
50+
- Do not use full-package imports.
51+
- Reuse existing imports in file; avoid duplicate imports.
52+
53+
## Refactor Checklist
54+
55+
1. Replace runtime checks first, preserving behavior.
56+
2. Keep array/object branching semantics unchanged.
57+
3. Re-scan:
58+
59+
```bash
60+
rg -n "typeof\s+[^\n]+(?:===|!==)\s*'" src
61+
```
62+
63+
4. Confirm only type-level `typeof` remains:
64+
65+
```bash
66+
rg -n "\btypeof\b" src
67+
```
68+
69+
5. Run verification:
70+
71+
```bash
72+
npm run type-check
73+
```
74+

src/components/CloudAccountList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import {
7171
SelectTrigger,
7272
SelectValue,
7373
} from '@/components/ui/select';
74-
import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es';
74+
import { filter, flatMap, isEmpty, isNumber, size, sumBy } from 'lodash-es';
7575
import {
7676
clampQuotaPercentage,
7777
getQuotaStatus,
@@ -313,7 +313,7 @@ export function CloudAccountList() {
313313
{
314314
onSuccess: (updatedAccount) => {
315315
const credits = updatedAccount.quota?.ai_credits?.credits;
316-
if (typeof credits === 'number') {
316+
if (isNumber(credits)) {
317317
toast({
318318
title: t('cloud.toast.quotaRefreshed'),
319319
description: `AI credits: $${credits.toFixed(2)}`,

src/components/ui/dropdown-menu.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
3+
import { isNumber, isObjectLike } from 'lodash-es';
34
import { Check, ChevronRight, Circle } from 'lucide-react';
45
import { cn } from '@/lib/utils';
56

@@ -52,11 +53,11 @@ const DropdownMenuContent = React.forwardRef<
5253
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
5354
}
5455
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => {
55-
if (typeof collisionPadding === 'number') {
56+
if (isNumber(collisionPadding)) {
5657
if (collisionPadding < 0) {
5758
throw new Error('collisionPadding must be a non-negative number');
5859
}
59-
} else if (typeof collisionPadding === 'object' && collisionPadding !== null) {
60+
} else if (isObjectLike(collisionPadding)) {
6061
for (const key of Object.keys(collisionPadding)) {
6162
if ((collisionPadding as any)[key] < 0) {
6263
throw new Error(`collisionPadding.${key} must be a non-negative number`);

src/ipc/cloud/handler.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { logger } from '../../utils/logger';
66

77
import { shell } from 'electron';
88
import fs from 'fs';
9+
import { isEmpty, isString } from 'lodash-es';
910
import { updateTrayMenu } from '../../ipc/tray/handler';
1011
import {
1112
ensureGlobalOriginalFromCurrentStorage,
@@ -46,7 +47,7 @@ function isEnterpriseClient(clientKey?: string): boolean {
4647
}
4748

4849
function normalizeProjectId(projectId?: string): string | null {
49-
if (typeof projectId !== 'string') {
50+
if (!isString(projectId)) {
5051
return null;
5152
}
5253
const normalized = projectId.trim();
@@ -148,7 +149,7 @@ async function clearAccountStatus(account: CloudAccount): Promise<void> {
148149

149150
function hydrateActiveOAuthClientFromSettings(): void {
150151
const preferredClientKey = CloudAccountRepo.getSetting<string>(ACTIVE_OAUTH_CLIENT_KEY_SETTING, '');
151-
if (preferredClientKey && preferredClientKey.trim() !== '') {
152+
if (isString(preferredClientKey) && !isEmpty(preferredClientKey.trim())) {
152153
try {
153154
GoogleAPIService.setActiveOAuthClientKey(preferredClientKey);
154155
} catch (error) {
@@ -195,7 +196,7 @@ async function backfillMissingOAuthClientKeyForLegacyAccounts(
195196
}
196197

197198
const projectMissing =
198-
typeof account.token.project_id !== 'string' || account.token.project_id.trim() === '';
199+
!isString(account.token.project_id) || isEmpty(account.token.project_id.trim());
199200
if (activeClientKey === ENTERPRISE_OAUTH_CLIENT_KEY && projectMissing) {
200201
skippedEnterpriseGuardCount += 1;
201202
continue;
@@ -232,7 +233,7 @@ export async function addGoogleAccount(
232233
oauthClientKey?: string,
233234
): Promise<CloudAccount> {
234235
try {
235-
if (oauthClientKey && oauthClientKey.trim() !== '') {
236+
if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
236237
setActiveOAuthClient(oauthClientKey);
237238
} else {
238239
hydrateActiveOAuthClientFromSettings();
@@ -684,7 +685,7 @@ export async function forcePollCloudMonitor(): Promise<void> {
684685
}
685686

686687
export async function startAuthFlow(oauthClientKey?: string): Promise<void> {
687-
if (oauthClientKey && oauthClientKey.trim() !== '') {
688+
if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
688689
setActiveOAuthClient(oauthClientKey);
689690
} else {
690691
hydrateActiveOAuthClientFromSettings();

src/ipc/database/cloudHandler.ts

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs from 'fs';
44
import { v4 as uuidv4 } from 'uuid';
55
import { desc, eq } from 'drizzle-orm';
66
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
7+
import { isNumber, isObjectLike, isPlainObject, isString } from 'lodash-es';
78
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
89
import { logger } from '../../utils/logger';
910
import {
@@ -35,14 +36,14 @@ type DrizzleExecutor = Pick<
3536
>;
3637

3738
function isSqliteBusyError(error: unknown): boolean {
38-
if (!error || typeof error !== 'object') {
39+
if (!isObjectLike(error)) {
3940
return false;
4041
}
4142
const err = error as { code?: string; message?: string };
4243
if (err.code && SQLITE_BUSY_CODES.has(err.code)) {
4344
return true;
4445
}
45-
if (typeof err.message === 'string') {
46+
if (isString(err.message)) {
4647
return err.message.includes('SQLITE_BUSY') || err.message.includes('SQLITE_LOCKED');
4748
}
4849
return false;
@@ -168,32 +169,29 @@ interface MigrationStats {
168169
failedFields: number;
169170
}
170171

171-
function isRecord(value: unknown): value is Record<string, unknown> {
172-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
173-
}
174-
175172
function readStringCandidate(
176173
source: Record<string, unknown>,
177174
...keys: string[]
178175
): string | undefined {
179176
for (const key of keys) {
180177
const candidate = source[key];
181-
if (typeof candidate === 'string' && candidate.length > 0) {
178+
if (isString(candidate) && candidate.length > 0) {
182179
return candidate;
183180
}
184181
}
185182
return undefined;
186183
}
187184

188185
function normalizeDeviceProfile(value: unknown): DeviceProfile | undefined {
189-
if (!isRecord(value)) {
186+
if (!isPlainObject(value)) {
190187
return undefined;
191188
}
189+
const valueRecord = value as Record<string, unknown>;
192190

193-
const machineId = readStringCandidate(value, 'machineId', 'machine_id');
194-
const macMachineId = readStringCandidate(value, 'macMachineId', 'mac_machine_id');
195-
const devDeviceId = readStringCandidate(value, 'devDeviceId', 'dev_device_id');
196-
const sqmId = readStringCandidate(value, 'sqmId', 'sqm_id');
191+
const machineId = readStringCandidate(valueRecord, 'machineId', 'machine_id');
192+
const macMachineId = readStringCandidate(valueRecord, 'macMachineId', 'mac_machine_id');
193+
const devDeviceId = readStringCandidate(valueRecord, 'devDeviceId', 'dev_device_id');
194+
const sqmId = readStringCandidate(valueRecord, 'sqmId', 'sqm_id');
197195

198196
if (!machineId || !macMachineId || !devDeviceId || !sqmId) {
199197
return undefined;
@@ -217,45 +215,47 @@ function areDeviceProfilesEqual(left: DeviceProfile, right: DeviceProfile): bool
217215
}
218216

219217
function readVersionedProfilePayload(value: unknown): unknown {
220-
if (!isRecord(value)) {
218+
if (!isPlainObject(value)) {
221219
return value;
222220
}
223-
if (!('schemaVersion' in value)) {
221+
const valueRecord = value as Record<string, unknown>;
222+
if (!('schemaVersion' in valueRecord)) {
224223
return value;
225224
}
226225

227-
const schemaVersion = value.schemaVersion;
228-
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
226+
const schemaVersion = valueRecord.schemaVersion;
227+
if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) {
229228
throw new Error('invalid_device_profile_schema_version');
230229
}
231230
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
232231
throw new Error(`unsupported_device_profile_schema_version:${schemaVersion}`);
233232
}
234-
if (!('profile' in value)) {
233+
if (!('profile' in valueRecord)) {
235234
throw new Error('invalid_device_profile_payload');
236235
}
237-
return value.profile;
236+
return valueRecord.profile;
238237
}
239238

240239
function readVersionedHistoryPayload(value: unknown): unknown {
241-
if (!isRecord(value)) {
240+
if (!isPlainObject(value)) {
242241
return value;
243242
}
244-
if (!('schemaVersion' in value)) {
243+
const valueRecord = value as Record<string, unknown>;
244+
if (!('schemaVersion' in valueRecord)) {
245245
return value;
246246
}
247247

248-
const schemaVersion = value.schemaVersion;
249-
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
248+
const schemaVersion = valueRecord.schemaVersion;
249+
if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) {
250250
throw new Error('invalid_device_history_schema_version');
251251
}
252252
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
253253
throw new Error(`unsupported_device_history_schema_version:${schemaVersion}`);
254254
}
255-
if (!('history' in value)) {
255+
if (!('history' in valueRecord)) {
256256
throw new Error('invalid_device_history_payload');
257257
}
258-
return value.history;
258+
return valueRecord.history;
259259
}
260260

261261
function serializeDeviceProfile(profile: DeviceProfile | undefined): string | null {
@@ -285,23 +285,25 @@ function normalizeDeviceHistory(value: unknown): DeviceProfileVersion[] | undefi
285285

286286
const normalized: DeviceProfileVersion[] = [];
287287
for (const item of value) {
288-
if (!isRecord(item)) {
288+
if (!isPlainObject(item)) {
289289
continue;
290290
}
291+
const itemRecord = item as Record<string, unknown>;
291292

292-
const profile = normalizeDeviceProfile(item.profile);
293+
const profile = normalizeDeviceProfile(itemRecord.profile);
293294
if (!profile) {
294295
continue;
295296
}
296297

297-
const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : uuidv4();
298-
const createdAtCandidate = item.createdAt;
298+
const id = isString(itemRecord.id) && itemRecord.id.length > 0 ? itemRecord.id : uuidv4();
299+
const createdAtCandidate = itemRecord.createdAt;
299300
const createdAt =
300-
typeof createdAtCandidate === 'number' && Number.isFinite(createdAtCandidate)
301+
isNumber(createdAtCandidate) && Number.isFinite(createdAtCandidate)
301302
? Math.floor(createdAtCandidate)
302303
: Math.floor(Date.now() / 1000);
303-
const label = typeof item.label === 'string' && item.label.length > 0 ? item.label : 'legacy';
304-
const isCurrent = item.isCurrent === true;
304+
const label =
305+
isString(itemRecord.label) && itemRecord.label.length > 0 ? itemRecord.label : 'legacy';
306+
const isCurrent = itemRecord.isCurrent === true;
305307

306308
normalized.push({
307309
id,

src/ipc/database/handler.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
22
import fs from 'fs';
33
import path from 'path';
44
import { eq } from 'drizzle-orm';
5+
import { isString } from 'lodash-es';
56
import { AccountBackupData, AccountInfo } from '../../types/account';
67
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
78
import { logger } from '../../utils/logger';
@@ -192,8 +193,8 @@ export function getCurrentAccountInfo(): AccountInfo {
192193
// Helper to find email in object
193194
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
194195
if (!obj) return '';
195-
if (typeof obj.email === 'string') return obj.email;
196-
if (obj.user && typeof obj.user.email === 'string') return obj.user.email;
196+
if (isString(obj.email)) return obj.email;
197+
if (obj.user && isString(obj.user.email)) return obj.user.email;
197198
return '';
198199
};
199200

@@ -323,7 +324,7 @@ function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
323324
for (const key of KEYS_TO_BACKUP) {
324325
if (key in backup.data) {
325326
const value = backup.data[key];
326-
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
327+
const stringValue = isString(value) ? value : JSON.stringify(value);
327328
tx.insert(itemTable)
328329
.values({ key, value: stringValue })
329330
.onConflictDoUpdate({

0 commit comments

Comments
 (0)