Skip to content

Commit f01aa57

Browse files
Merge pull request #96 from CodeDeficient/claude/project-thread-36cl1x-object-keys-cast
Report casts of [], {} and Object.keys() as ordinary type assertions
2 parents 75df385 + d96cd29 commit f01aa57

4 files changed

Lines changed: 30 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ All notable changes will be documented in this file.
99
- `swallowed_error` (medium): a `catch` block whose only statements are `console` calls. The error is logged and the code carries on as if the operation succeeded. A comment in the block explaining why that is safe, a rethrow, or any other statement (such as `setError(e)`) means it is not reported, and so does a fallback after the `try`, as for `empty_catch`.
1010

1111
### Changed
12+
- A cast of `[]`, `{}` or `Object.keys(...)` to an array or object type is reported as `type_assertion` (medium), not `unsafe_array_assertion`/`unsafe_object_assertion` (high). `Object.keys(o) as Array<keyof typeof o>` is the standard way to type keys, since `Object.keys` returns `string[]`; ky uses it twice.
1213
- `empty_catch` skips `void p.catch(() => {})`, where `void` says the result is dropped on purpose, and a `try` that declares locals before assigning the outer variable (`const stats = await stat(p); isDir = stats.isDirectory();`). hono uses both.
1314
- The package ships compiled JavaScript (`dist/`) and runs it directly with Node. It used to run the TypeScript sources through `tsx` on every call: `karpeslop --version` took 1.5 s and an install was 43 MB. It now takes 0.6 s and 31 MB, most of it the TypeScript compiler the rules use to parse code. `tsx` is no longer a runtime dependency.
1415
- `fresh_package_version` skips packages the scanned workspace publishes itself (any `name` in a scanned package.json) and lockfile entries that link a workspace folder. TanStack Query got 184 warnings for `@tanstack/*` versions it had just released.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ npx karpeslop@latest
3232
| `swallowed_error` | medium | A `catch` block that only logs (`console.error('Failed', err)`) and lets execution continue as if nothing failed, or logs and then returns `[]`, `{}` or `''`, which the caller cannot tell apart from an empty success |
3333
| `unsafe_type_assertion` | high | `as any` |
3434
| `unsafe_double_type_assertion` | high | `as unknown as T` and other chained casts |
35-
| `unsafe_object_assertion` / `unsafe_array_assertion` | high | Casts to `Record<...>`, `{ ... }`, `T[]` |
35+
| `unsafe_object_assertion` / `unsafe_array_assertion` | high | Casts to `Record<...>`, `{ ... }`, `T[]`. A cast of `[]`, `{}` or `Object.keys(...)` (and `Object.getOwnPropertyNames`) only narrows a known shape and is reported as `type_assertion` |
3636
| `type_assertion` | medium | Any other cast (`as T`, `<T>x`). `as const` and `as unknown` are not casts |
3737
| `any_type_usage` | medium | Declared `any`, once per line. Not reported in generic constraints, conditional `extends`, fully erased generics like `Query<any, any, any>`, or the "any function" type `(...args: any[]) => any`, which `unknown` cannot express |
3838
| `ts_suppression` | medium | `@ts-ignore`, `@ts-nocheck` |

‎rules.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,20 @@ function isInnerOfChain(node: AssertionNode): boolean {
411411
return isCast(parent);
412412
}
413413

414+
/**
415+
* A value whose runtime shape is already known, so a cast to an object or
416+
* array type only narrows it: `[] as User[]`, `{} as Record<K, V>`, and
417+
* `Object.keys(o) as Array<keyof typeof o>` (Object.keys is typed string[]).
418+
*/
419+
function hasKnownShape(expr: ts.Expression): boolean {
420+
const inner = skipParentheses(expr);
421+
if (ts.isArrayLiteralExpression(inner)) return inner.elements.length === 0;
422+
if (ts.isObjectLiteralExpression(inner)) return inner.properties.length === 0;
423+
return ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) &&
424+
ts.isIdentifier(inner.expression.expression) && inner.expression.expression.text === 'Object' &&
425+
['keys', 'getOwnPropertyNames'].includes(inner.expression.name.text);
426+
}
427+
414428
/** Classify one cast. Chained casts report the chain, plus `as any` when it ends in any. */
415429
function classifyCast(node: AssertionNode): CastReport[] {
416430
const isAny = node.type.kind === ts.SyntaxKind.AnyKeyword;
@@ -422,6 +436,7 @@ function classifyCast(node: AssertionNode): CastReport[] {
422436
return reports;
423437
}
424438
if (isAny) return [{ type: 'unsafe_type_assertion', form: 'as_any' }];
439+
if (hasKnownShape(node.expression)) return [{ type: 'type_assertion', form: 'as_type' }];
425440
if (isObjectTypeNode(node.type)) return [{ type: 'unsafe_object_assertion', form: 'as_object' }];
426441
if (isArrayTypeNode(node.type)) return [{ type: 'unsafe_array_assertion', form: 'as_array' }];
427442
return [{ type: 'type_assertion', form: 'as_type' }];

‎tests/rules.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,19 @@ test('any: generic constraints, conditional extends and fully erased generics ar
375375
assert.equal(countOf('type F = (x: any, ...rest: unknown[]) => void;', 'any_type_usage'), 1);
376376
});
377377

378+
test('casts: a cast of a value whose shape is known is an ordinary type_assertion', () => {
379+
for (const code of ['const keys = Object.keys(types) as Array<keyof typeof types>;', 'const list = [] as User[];',
380+
'const map = {} as Record<string, number>;', 'const names = Object.getOwnPropertyNames(o) as Key[];']) {
381+
assert.deepEqual(typesIn(code), ['type_assertion'], code);
382+
}
383+
assert.deepEqual(typesIn('const users = JSON.parse(raw) as User[];'), ['unsafe_array_assertion']);
384+
assert.deepEqual(typesIn('const cfg = load() as Record<string, string>;'), ['unsafe_object_assertion']);
385+
assert.deepEqual(typesIn('const one = [x] as User[];'), ['unsafe_array_assertion']);
386+
// Values can be any (`Object.entries(o: {})` returns [string, any][]), so casting them is unchecked.
387+
assert.deepEqual(typesIn('const e = Object.entries(o) as [Key, number][];'), ['unsafe_array_assertion']);
388+
assert.deepEqual(typesIn('const v = Object.values(o) as number[];'), ['unsafe_array_assertion']);
389+
});
390+
378391
test('casts: every cast is reported, `as const` and `as unknown` are not', () => {
379392
assert.equal(countOf('const a = b as Foo;', 'type_assertion'), 1);
380393
assert.equal(countOf('const a = <Foo>b;', 'type_assertion'), 1);

0 commit comments

Comments
 (0)