Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ Current checks focus on patterns that often show up in unreviewed generated code
- [empty catch blocks](src/rules/empty-catch/README.md)
- [promise `.catch()` default fallbacks](src/rules/promise-default-fallbacks/README.md)
- [generic status envelopes](src/rules/generic-status-envelopes/README.md)
- [generic record casts](src/rules/generic-record-casts/README.md)
- [stringified unknown errors](src/rules/stringified-unknown-errors/README.md)
- [async wrapper / `return await` noise](src/rules/async-noise/README.md)
- [pass-through wrappers](src/rules/pass-through-wrappers/README.md)
Expand Down
2 changes: 2 additions & 0 deletions src/default-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { errorObscuringRule } from "./rules/error-obscuring";
import { errorSwallowingRule } from "./rules/error-swallowing";
import { promiseDefaultFallbacksRule } from "./rules/promise-default-fallbacks";
import { genericStatusEnvelopesRule } from "./rules/generic-status-envelopes";
import { genericRecordCastsRule } from "./rules/generic-record-casts";
import { stringifiedUnknownErrorsRule } from "./rules/stringified-unknown-errors";
import { barrelDensityRule } from "./rules/barrel-density";
import { directoryFanoutHotspotRule } from "./rules/directory-fanout-hotspot";
Expand Down Expand Up @@ -48,6 +49,7 @@ export function createDefaultRegistry(): Registry {
registry.registerRule(emptyCatchRule);
registry.registerRule(promiseDefaultFallbacksRule);
registry.registerRule(genericStatusEnvelopesRule);
registry.registerRule(genericRecordCastsRule);
registry.registerRule(stringifiedUnknownErrorsRule);
registry.registerRule(barrelDensityRule);
registry.registerRule(passThroughWrappersRule);
Expand Down
55 changes: 55 additions & 0 deletions src/rules/generic-record-casts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# types.generic-record-casts

Flags `Record<string, unknown>` casts on vague parsed/payload variables like `data`, `payload`, and `parsed`.

- **Family:** `types`
- **Severity:** `strong`
- **Scope:** `file`
- **Requires:** `file.ast`

## How it works

The rule looks for `as Record<string, unknown>` casts assigned into generic object-bag variables such as:

- `parsed`
- `payload`
- `body`
- `data`
- `result`
- `config`

It gives extra detail when the cast comes directly from `JSON.parse(...)`.

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.

To avoid obvious vendored noise, the rule skips very large bundled/generated files over `5000` logical lines.

## Flagged examples

```ts
const parsed = JSON.parse(raw) as Record<string, unknown>;
const payload = response as Record<string, unknown>;
const data = value as Record<string, unknown>;
```

## Usually ignored

```ts
const parsed = JSON.parse(raw) as UserConfig;
const token = value as { token: string };
const metadata = input as Map<string, string>;
```

## Scoring

Each generic record cast adds `2` points.
The file total is capped at `8`.

## Benchmark signal

Full pinned benchmark against the exact `known-ai-vs-solid-oss` cohort:

- Signal score: **0.78 / 1.00**
- Best separating metric: **findings / file (0.78)**
- Hit rate: **5/9 AI repos** vs **0/9 mature OSS repos**
- Full results: [experimental rule report](../../../reports/autoresearch-candidate-rule.md#typesgeneric-record-casts)
137 changes: 137 additions & 0 deletions src/rules/generic-record-casts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as ts from "typescript";
import type { RulePlugin } from "../../core/types";
import { getLineNumber, unwrapExpression, walk } from "../../facts/ts-helpers";
import { delta } from "../../rule-delta";

const MAX_LOGICAL_LINES = 5000;

const GENERIC_RECORD_KEYS = new Set([
"data",
"payload",
"body",
"parsed",
"obj",
"result",
"record",
"config",
"json",
"value",
]);

type MatchKind = "record-string-unknown-cast" | "json-parse-record-cast";

type RecordCastMatch = {
line: number;
kind: MatchKind;
};

function isRecordStringUnknownType(node: ts.TypeNode): boolean {
return (
ts.isTypeReferenceNode(node) &&
ts.isIdentifier(node.typeName) &&
node.typeName.text === "Record" &&
node.typeArguments?.length === 2 &&
node.typeArguments[0]?.kind === ts.SyntaxKind.StringKeyword &&
node.typeArguments[1]?.kind === ts.SyntaxKind.UnknownKeyword
);
}

function isInterestingInitializer(expression: ts.Expression): MatchKind | null {
const unwrapped = unwrapExpression(expression);

if (
ts.isCallExpression(unwrapped) &&
ts.isPropertyAccessExpression(unwrapped.expression) &&
ts.isIdentifier(unwrapped.expression.expression) &&
unwrapped.expression.expression.text === "JSON" &&
unwrapped.expression.name.text === "parse"
) {
return "json-parse-record-cast";
}

return "record-string-unknown-cast";
}

function summarizeAsExpression(
node: ts.AsExpression,
sourceFile: ts.SourceFile,
): RecordCastMatch | null {
if (!isRecordStringUnknownType(node.type)) {
return null;
}

const parent = node.parent;
if (!ts.isVariableDeclaration(parent) || !ts.isIdentifier(parent.name)) {
return null;
}

if (!GENERIC_RECORD_KEYS.has(parent.name.text)) {
return null;
}

return {
line: getLineNumber(sourceFile, node.getStart(sourceFile)),
kind: isInterestingInitializer(node.expression),
};
}

function findRecordCasts(sourceFile: ts.SourceFile): RecordCastMatch[] {
const matches: RecordCastMatch[] = [];

walk(sourceFile, (node) => {
if (!ts.isAsExpression(node)) {
return;
}

const match = summarizeAsExpression(node, sourceFile);
if (match) {
matches.push(match);
}
});

return matches;
}

export const genericRecordCastsRule: RulePlugin = {
id: "types.generic-record-casts",
family: "types",
severity: "strong",
scope: "file",
requires: ["file.ast"],
delta: delta.byLocations(),
supports(context) {
return context.scope === "file" && Boolean(context.file);
},
evaluate(context) {
if (context.file!.logicalLineCount > MAX_LOGICAL_LINES) {
return [];
}

const sourceFile = context.runtime.store.getFileFact<ts.SourceFile>(
context.file!.path,
"file.ast",
);
if (!sourceFile) {
return [];
}

const matches = findRecordCasts(sourceFile);
if (matches.length === 0) {
return [];
}

return [
{
ruleId: "types.generic-record-casts",
family: "types",
severity: "strong",
scope: "file",
path: context.file!.path,
message: `Found ${matches.length} generic Record<string, unknown> cast${matches.length === 1 ? "" : "s"} on vague parsed/payload variables`,
evidence: matches.map((match) => `line ${match.line}: ${match.kind}`),
score: Math.min(8, matches.length * 2),
locations: matches.map((match) => ({ path: context.file!.path, line: match.line })),
},
];
},
};
103 changes: 103 additions & 0 deletions tests/generic-record-casts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DEFAULT_CONFIG } from "../src/config";
import { analyzeRepository } from "../src/core/engine";
import { Registry } from "../src/core/registry";
import { createDefaultRegistry } from "../src/default-registry";
import { genericRecordCastsRule } from "../src/rules/generic-record-casts";

const tempDirs: string[] = [];

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

async function writeRepoFiles(rootDir: string, files: Record<string, string>): Promise<void> {
for (const [relativePath, content] of Object.entries(files)) {
const absolutePath = path.join(rootDir, relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content);
}
}

async function createTempRepo(files: Record<string, string>): Promise<string> {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "slop-scan-generic-record-casts-"));
tempDirs.push(rootDir);
await writeRepoFiles(rootDir, files);
return rootDir;
}

function createCandidateRegistry(): Registry {
const baseRegistry = createDefaultRegistry();
const registry = new Registry();

for (const language of baseRegistry.getLanguages()) {
registry.registerLanguage(language);
}

for (const provider of baseRegistry.getFactProviders()) {
registry.registerFactProvider(provider);
}

registry.registerRule(genericRecordCastsRule);
return registry;
}

describe("generic-record-casts rule", () => {
test("flags generic Record<string, unknown> casts on vague bag variables", async () => {
const rootDir = await createTempRepo({
"src/slop.ts": [
"export function parseData(raw: string, response: unknown, value: unknown) {",
" const parsed = JSON.parse(raw) as Record<string, unknown>;",
" const payload = response as Record<string, unknown>;",
" const data = value as Record<string, unknown>;",
" return { parsed, payload, data };",
"}",
].join("\n"),
"src/legit.ts": [
"type UserConfig = { token: string };",
"",
"export function parseConfig(raw: string) {",
" return JSON.parse(raw) as UserConfig;",
"}",
].join("\n"),
});

const result = await analyzeRepository(rootDir, DEFAULT_CONFIG, createCandidateRegistry());
const finding = result.findings.find(
(nextFinding) => nextFinding.ruleId === "types.generic-record-casts",
);

expect(finding).toBeDefined();
expect(finding?.path).toBe("src/slop.ts");
expect(finding?.evidence).toEqual([
"line 2: json-parse-record-cast",
"line 3: record-string-unknown-cast",
"line 4: record-string-unknown-cast",
]);
expect(finding?.locations).toEqual([
{ path: "src/slop.ts", line: 2 },
{ path: "src/slop.ts", line: 3 },
{ path: "src/slop.ts", line: 4 },
]);
expect(result.findings).toHaveLength(1);
});

test("ignores giant bundled files that would otherwise create vendor noise", async () => {
const hugeFile = [
...Array.from({ length: 5001 }, (_, index) => `export const filler${index} = ${index};`),
"const parsed = JSON.parse(raw) as Record<string, unknown>;",
"",
].join("\n");

const rootDir = await createTempRepo({
"src/bundle.ts": hugeFile,
});

const result = await analyzeRepository(rootDir, DEFAULT_CONFIG, createCandidateRegistry());

expect(result.findings).toHaveLength(0);
});
});
6 changes: 6 additions & 0 deletions tests/heuristics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ describe("heuristic rule pack", () => {
" }",
"}",
"",
"export function parsePayload(raw: string) {",
" const parsed = JSON.parse(raw) as Record<string, unknown>;",
" return parsed;",
"}",
"",
"export function createEnvelope() {",
" return { success: false, error: 'Unauthorized' };",
"}",
Expand Down Expand Up @@ -90,6 +95,7 @@ describe("heuristic rule pack", () => {
expect(ruleIds.has("defensive.error-obscuring")).toBe(true);
expect(ruleIds.has("defensive.promise-default-fallbacks")).toBe(true);
expect(ruleIds.has("api.generic-status-envelopes")).toBe(true);
expect(ruleIds.has("types.generic-record-casts")).toBe(true);
expect(ruleIds.has("defensive.stringified-unknown-errors")).toBe(true);
expect(ruleIds.has("defensive.async-noise")).toBe(true);
expect(ruleIds.has("structure.pass-through-wrappers")).toBe(true);
Expand Down
Loading