From 06447950cde16c301fa43cabbfeb5598906db13e Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 19 Apr 2026 16:52:01 -0400 Subject: [PATCH] Add generic record casts rule --- README.md | 1 + src/default-registry.ts | 2 + src/rules/generic-record-casts/README.md | 55 +++++++++ src/rules/generic-record-casts/index.ts | 137 +++++++++++++++++++++++ tests/generic-record-casts.test.ts | 103 +++++++++++++++++ tests/heuristics.test.ts | 6 + 6 files changed, 304 insertions(+) create mode 100644 src/rules/generic-record-casts/README.md create mode 100644 src/rules/generic-record-casts/index.ts create mode 100644 tests/generic-record-casts.test.ts diff --git a/README.md b/README.md index 0b7fd40..da0b05d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/default-registry.ts b/src/default-registry.ts index a7771e6..de0f43a 100644 --- a/src/default-registry.ts +++ b/src/default-registry.ts @@ -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"; @@ -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); diff --git a/src/rules/generic-record-casts/README.md b/src/rules/generic-record-casts/README.md new file mode 100644 index 0000000..445b310 --- /dev/null +++ b/src/rules/generic-record-casts/README.md @@ -0,0 +1,55 @@ +# types.generic-record-casts + +Flags `Record` 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` 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; +const payload = response as Record; +const data = value as Record; +``` + +## Usually ignored + +```ts +const parsed = JSON.parse(raw) as UserConfig; +const token = value as { token: string }; +const metadata = input as Map; +``` + +## 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) diff --git a/src/rules/generic-record-casts/index.ts b/src/rules/generic-record-casts/index.ts new file mode 100644 index 0000000..23c9bea --- /dev/null +++ b/src/rules/generic-record-casts/index.ts @@ -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( + 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 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 })), + }, + ]; + }, +}; diff --git a/tests/generic-record-casts.test.ts b/tests/generic-record-casts.test.ts new file mode 100644 index 0000000..3be9226 --- /dev/null +++ b/tests/generic-record-casts.test.ts @@ -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): Promise { + 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): Promise { + 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 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;", + " const payload = response as Record;", + " const data = value as Record;", + " 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;", + "", + ].join("\n"); + + const rootDir = await createTempRepo({ + "src/bundle.ts": hugeFile, + }); + + const result = await analyzeRepository(rootDir, DEFAULT_CONFIG, createCandidateRegistry()); + + expect(result.findings).toHaveLength(0); + }); +}); diff --git a/tests/heuristics.test.ts b/tests/heuristics.test.ts index 5abd41a..5a6198d 100644 --- a/tests/heuristics.test.ts +++ b/tests/heuristics.test.ts @@ -44,6 +44,11 @@ describe("heuristic rule pack", () => { " }", "}", "", + "export function parsePayload(raw: string) {", + " const parsed = JSON.parse(raw) as Record;", + " return parsed;", + "}", + "", "export function createEnvelope() {", " return { success: false, error: 'Unauthorized' };", "}", @@ -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);