Skip to content

Commit df9e2d4

Browse files
authored
Add generic record casts rule (#22)
1 parent 6d1db30 commit df9e2d4

6 files changed

Lines changed: 304 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ Current checks focus on patterns that often show up in unreviewed generated code
136136
- [empty catch blocks](src/rules/empty-catch/README.md)
137137
- [promise `.catch()` default fallbacks](src/rules/promise-default-fallbacks/README.md)
138138
- [generic status envelopes](src/rules/generic-status-envelopes/README.md)
139+
- [generic record casts](src/rules/generic-record-casts/README.md)
139140
- [stringified unknown errors](src/rules/stringified-unknown-errors/README.md)
140141
- [async wrapper / `return await` noise](src/rules/async-noise/README.md)
141142
- [pass-through wrappers](src/rules/pass-through-wrappers/README.md)

src/default-registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { errorObscuringRule } from "./rules/error-obscuring";
1919
import { errorSwallowingRule } from "./rules/error-swallowing";
2020
import { promiseDefaultFallbacksRule } from "./rules/promise-default-fallbacks";
2121
import { genericStatusEnvelopesRule } from "./rules/generic-status-envelopes";
22+
import { genericRecordCastsRule } from "./rules/generic-record-casts";
2223
import { stringifiedUnknownErrorsRule } from "./rules/stringified-unknown-errors";
2324
import { barrelDensityRule } from "./rules/barrel-density";
2425
import { directoryFanoutHotspotRule } from "./rules/directory-fanout-hotspot";
@@ -48,6 +49,7 @@ export function createDefaultRegistry(): Registry {
4849
registry.registerRule(emptyCatchRule);
4950
registry.registerRule(promiseDefaultFallbacksRule);
5051
registry.registerRule(genericStatusEnvelopesRule);
52+
registry.registerRule(genericRecordCastsRule);
5153
registry.registerRule(stringifiedUnknownErrorsRule);
5254
registry.registerRule(barrelDensityRule);
5355
registry.registerRule(passThroughWrappersRule);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# types.generic-record-casts
2+
3+
Flags `Record<string, unknown>` casts on vague parsed/payload variables like `data`, `payload`, and `parsed`.
4+
5+
- **Family:** `types`
6+
- **Severity:** `strong`
7+
- **Scope:** `file`
8+
- **Requires:** `file.ast`
9+
10+
## How it works
11+
12+
The rule looks for `as Record<string, unknown>` casts assigned into generic object-bag variables such as:
13+
14+
- `parsed`
15+
- `payload`
16+
- `body`
17+
- `data`
18+
- `result`
19+
- `config`
20+
21+
It gives extra detail when the cast comes directly from `JSON.parse(...)`.
22+
23+
This pattern often shows up in generated or hurried glue code that turns unknown structured input into a generic property bag instead of validating it into a domain-shaped type.
24+
25+
To avoid obvious vendored noise, the rule skips very large bundled/generated files over `5000` logical lines.
26+
27+
## Flagged examples
28+
29+
```ts
30+
const parsed = JSON.parse(raw) as Record<string, unknown>;
31+
const payload = response as Record<string, unknown>;
32+
const data = value as Record<string, unknown>;
33+
```
34+
35+
## Usually ignored
36+
37+
```ts
38+
const parsed = JSON.parse(raw) as UserConfig;
39+
const token = value as { token: string };
40+
const metadata = input as Map<string, string>;
41+
```
42+
43+
## Scoring
44+
45+
Each generic record cast adds `2` points.
46+
The file total is capped at `8`.
47+
48+
## Benchmark signal
49+
50+
Full pinned benchmark against the exact `known-ai-vs-solid-oss` cohort:
51+
52+
- Signal score: **0.78 / 1.00**
53+
- Best separating metric: **findings / file (0.78)**
54+
- Hit rate: **5/9 AI repos** vs **0/9 mature OSS repos**
55+
- Full results: [experimental rule report](../../../reports/autoresearch-candidate-rule.md#typesgeneric-record-casts)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import * as ts from "typescript";
2+
import type { RulePlugin } from "../../core/types";
3+
import { getLineNumber, unwrapExpression, walk } from "../../facts/ts-helpers";
4+
import { delta } from "../../rule-delta";
5+
6+
const MAX_LOGICAL_LINES = 5000;
7+
8+
const GENERIC_RECORD_KEYS = new Set([
9+
"data",
10+
"payload",
11+
"body",
12+
"parsed",
13+
"obj",
14+
"result",
15+
"record",
16+
"config",
17+
"json",
18+
"value",
19+
]);
20+
21+
type MatchKind = "record-string-unknown-cast" | "json-parse-record-cast";
22+
23+
type RecordCastMatch = {
24+
line: number;
25+
kind: MatchKind;
26+
};
27+
28+
function isRecordStringUnknownType(node: ts.TypeNode): boolean {
29+
return (
30+
ts.isTypeReferenceNode(node) &&
31+
ts.isIdentifier(node.typeName) &&
32+
node.typeName.text === "Record" &&
33+
node.typeArguments?.length === 2 &&
34+
node.typeArguments[0]?.kind === ts.SyntaxKind.StringKeyword &&
35+
node.typeArguments[1]?.kind === ts.SyntaxKind.UnknownKeyword
36+
);
37+
}
38+
39+
function isInterestingInitializer(expression: ts.Expression): MatchKind | null {
40+
const unwrapped = unwrapExpression(expression);
41+
42+
if (
43+
ts.isCallExpression(unwrapped) &&
44+
ts.isPropertyAccessExpression(unwrapped.expression) &&
45+
ts.isIdentifier(unwrapped.expression.expression) &&
46+
unwrapped.expression.expression.text === "JSON" &&
47+
unwrapped.expression.name.text === "parse"
48+
) {
49+
return "json-parse-record-cast";
50+
}
51+
52+
return "record-string-unknown-cast";
53+
}
54+
55+
function summarizeAsExpression(
56+
node: ts.AsExpression,
57+
sourceFile: ts.SourceFile,
58+
): RecordCastMatch | null {
59+
if (!isRecordStringUnknownType(node.type)) {
60+
return null;
61+
}
62+
63+
const parent = node.parent;
64+
if (!ts.isVariableDeclaration(parent) || !ts.isIdentifier(parent.name)) {
65+
return null;
66+
}
67+
68+
if (!GENERIC_RECORD_KEYS.has(parent.name.text)) {
69+
return null;
70+
}
71+
72+
return {
73+
line: getLineNumber(sourceFile, node.getStart(sourceFile)),
74+
kind: isInterestingInitializer(node.expression),
75+
};
76+
}
77+
78+
function findRecordCasts(sourceFile: ts.SourceFile): RecordCastMatch[] {
79+
const matches: RecordCastMatch[] = [];
80+
81+
walk(sourceFile, (node) => {
82+
if (!ts.isAsExpression(node)) {
83+
return;
84+
}
85+
86+
const match = summarizeAsExpression(node, sourceFile);
87+
if (match) {
88+
matches.push(match);
89+
}
90+
});
91+
92+
return matches;
93+
}
94+
95+
export const genericRecordCastsRule: RulePlugin = {
96+
id: "types.generic-record-casts",
97+
family: "types",
98+
severity: "strong",
99+
scope: "file",
100+
requires: ["file.ast"],
101+
delta: delta.byLocations(),
102+
supports(context) {
103+
return context.scope === "file" && Boolean(context.file);
104+
},
105+
evaluate(context) {
106+
if (context.file!.logicalLineCount > MAX_LOGICAL_LINES) {
107+
return [];
108+
}
109+
110+
const sourceFile = context.runtime.store.getFileFact<ts.SourceFile>(
111+
context.file!.path,
112+
"file.ast",
113+
);
114+
if (!sourceFile) {
115+
return [];
116+
}
117+
118+
const matches = findRecordCasts(sourceFile);
119+
if (matches.length === 0) {
120+
return [];
121+
}
122+
123+
return [
124+
{
125+
ruleId: "types.generic-record-casts",
126+
family: "types",
127+
severity: "strong",
128+
scope: "file",
129+
path: context.file!.path,
130+
message: `Found ${matches.length} generic Record<string, unknown> cast${matches.length === 1 ? "" : "s"} on vague parsed/payload variables`,
131+
evidence: matches.map((match) => `line ${match.line}: ${match.kind}`),
132+
score: Math.min(8, matches.length * 2),
133+
locations: matches.map((match) => ({ path: context.file!.path, line: match.line })),
134+
},
135+
];
136+
},
137+
};

tests/generic-record-casts.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { DEFAULT_CONFIG } from "../src/config";
6+
import { analyzeRepository } from "../src/core/engine";
7+
import { Registry } from "../src/core/registry";
8+
import { createDefaultRegistry } from "../src/default-registry";
9+
import { genericRecordCastsRule } from "../src/rules/generic-record-casts";
10+
11+
const tempDirs: string[] = [];
12+
13+
afterEach(async () => {
14+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
15+
});
16+
17+
async function writeRepoFiles(rootDir: string, files: Record<string, string>): Promise<void> {
18+
for (const [relativePath, content] of Object.entries(files)) {
19+
const absolutePath = path.join(rootDir, relativePath);
20+
await mkdir(path.dirname(absolutePath), { recursive: true });
21+
await writeFile(absolutePath, content);
22+
}
23+
}
24+
25+
async function createTempRepo(files: Record<string, string>): Promise<string> {
26+
const rootDir = await mkdtemp(path.join(os.tmpdir(), "slop-scan-generic-record-casts-"));
27+
tempDirs.push(rootDir);
28+
await writeRepoFiles(rootDir, files);
29+
return rootDir;
30+
}
31+
32+
function createCandidateRegistry(): Registry {
33+
const baseRegistry = createDefaultRegistry();
34+
const registry = new Registry();
35+
36+
for (const language of baseRegistry.getLanguages()) {
37+
registry.registerLanguage(language);
38+
}
39+
40+
for (const provider of baseRegistry.getFactProviders()) {
41+
registry.registerFactProvider(provider);
42+
}
43+
44+
registry.registerRule(genericRecordCastsRule);
45+
return registry;
46+
}
47+
48+
describe("generic-record-casts rule", () => {
49+
test("flags generic Record<string, unknown> casts on vague bag variables", async () => {
50+
const rootDir = await createTempRepo({
51+
"src/slop.ts": [
52+
"export function parseData(raw: string, response: unknown, value: unknown) {",
53+
" const parsed = JSON.parse(raw) as Record<string, unknown>;",
54+
" const payload = response as Record<string, unknown>;",
55+
" const data = value as Record<string, unknown>;",
56+
" return { parsed, payload, data };",
57+
"}",
58+
].join("\n"),
59+
"src/legit.ts": [
60+
"type UserConfig = { token: string };",
61+
"",
62+
"export function parseConfig(raw: string) {",
63+
" return JSON.parse(raw) as UserConfig;",
64+
"}",
65+
].join("\n"),
66+
});
67+
68+
const result = await analyzeRepository(rootDir, DEFAULT_CONFIG, createCandidateRegistry());
69+
const finding = result.findings.find(
70+
(nextFinding) => nextFinding.ruleId === "types.generic-record-casts",
71+
);
72+
73+
expect(finding).toBeDefined();
74+
expect(finding?.path).toBe("src/slop.ts");
75+
expect(finding?.evidence).toEqual([
76+
"line 2: json-parse-record-cast",
77+
"line 3: record-string-unknown-cast",
78+
"line 4: record-string-unknown-cast",
79+
]);
80+
expect(finding?.locations).toEqual([
81+
{ path: "src/slop.ts", line: 2 },
82+
{ path: "src/slop.ts", line: 3 },
83+
{ path: "src/slop.ts", line: 4 },
84+
]);
85+
expect(result.findings).toHaveLength(1);
86+
});
87+
88+
test("ignores giant bundled files that would otherwise create vendor noise", async () => {
89+
const hugeFile = [
90+
...Array.from({ length: 5001 }, (_, index) => `export const filler${index} = ${index};`),
91+
"const parsed = JSON.parse(raw) as Record<string, unknown>;",
92+
"",
93+
].join("\n");
94+
95+
const rootDir = await createTempRepo({
96+
"src/bundle.ts": hugeFile,
97+
});
98+
99+
const result = await analyzeRepository(rootDir, DEFAULT_CONFIG, createCandidateRegistry());
100+
101+
expect(result.findings).toHaveLength(0);
102+
});
103+
});

tests/heuristics.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ describe("heuristic rule pack", () => {
4444
" }",
4545
"}",
4646
"",
47+
"export function parsePayload(raw: string) {",
48+
" const parsed = JSON.parse(raw) as Record<string, unknown>;",
49+
" return parsed;",
50+
"}",
51+
"",
4752
"export function createEnvelope() {",
4853
" return { success: false, error: 'Unauthorized' };",
4954
"}",
@@ -90,6 +95,7 @@ describe("heuristic rule pack", () => {
9095
expect(ruleIds.has("defensive.error-obscuring")).toBe(true);
9196
expect(ruleIds.has("defensive.promise-default-fallbacks")).toBe(true);
9297
expect(ruleIds.has("api.generic-status-envelopes")).toBe(true);
98+
expect(ruleIds.has("types.generic-record-casts")).toBe(true);
9399
expect(ruleIds.has("defensive.stringified-unknown-errors")).toBe(true);
94100
expect(ruleIds.has("defensive.async-noise")).toBe(true);
95101
expect(ruleIds.has("structure.pass-through-wrappers")).toBe(true);

0 commit comments

Comments
 (0)