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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@
node_modules/
dist/
benchmarks/.cache/
autoresearch.md
autoresearch.sh
autoresearch.checks.sh
autoresearch.ideas.md
benchmarks/results/autoresearch-candidate-rule.json
reports/autoresearch-candidate-rule.md
scripts/benchmark-experimental-rule.ts
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Current checks focus on patterns that often show up in unreviewed generated code
- [log-and-continue catch blocks](src/rules/error-swallowing/README.md)
- [error-obscuring catch blocks](src/rules/error-obscuring/README.md) (default-return or generic replacement error)
- [empty catch blocks](src/rules/empty-catch/README.md)
- [promise `.catch()` default fallbacks](src/rules/promise-default-fallbacks/README.md)
- [async wrapper / `return await` noise](src/rules/async-noise/README.md)
- [pass-through wrappers](src/rules/pass-through-wrappers/README.md)
- [barrel density](src/rules/barrel-density/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 @@ -17,6 +17,7 @@ import { asyncNoiseRule } from "./rules/async-noise";
import { emptyCatchRule } from "./rules/empty-catch";
import { errorObscuringRule } from "./rules/error-obscuring";
import { errorSwallowingRule } from "./rules/error-swallowing";
import { promiseDefaultFallbacksRule } from "./rules/promise-default-fallbacks";
import { barrelDensityRule } from "./rules/barrel-density";
import { directoryFanoutHotspotRule } from "./rules/directory-fanout-hotspot";
import { duplicateFunctionSignaturesRule } from "./rules/duplicate-function-signatures";
Expand All @@ -43,6 +44,7 @@ export function createDefaultRegistry(): Registry {
registry.registerRule(errorSwallowingRule);
registry.registerRule(errorObscuringRule);
registry.registerRule(emptyCatchRule);
registry.registerRule(promiseDefaultFallbacksRule);
registry.registerRule(barrelDensityRule);
registry.registerRule(passThroughWrappersRule);
registry.registerRule(duplicateFunctionSignaturesRule);
Expand Down
68 changes: 68 additions & 0 deletions src/rules/promise-default-fallbacks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# defensive.promise-default-fallbacks

Flags promise `.catch()` handlers that suppress rejected async work with a cheap fallback.

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

## How it works

The rule looks for promise-chain catch handlers that turn rejection into:

- an empty handler body like `.catch(() => {})`
- a direct sentinel default like `null`, `undefined`, `false`, `0`, `""`, `[]`, or `{}`
- a log-and-default block like `console.error(error); return false`

This is intentionally distinct from the existing `try/catch` defensive rules. It targets the promise-chain version of the same failure-suppression habit, which shows up frequently in generated async glue code.

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

## Flagged examples

```ts
export async function loadConfig() {
return fetchConfig().catch(() => null);
}

export async function readClipboard() {
return navigator.clipboard.readText().catch(() => {});
}

export async function loadFlag() {
return fetchFlag().catch((error) => {
console.error("flag load failed", error);
return false;
});
}
```

## Usually ignored

```ts
export async function loadConfig() {
return fetchConfig().catch((error) => {
throw error;
});
}

export async function loadConfigResult() {
return fetchConfig().catch(() => ({ ok: false, reason: "missing" }));
}
```

## Scoring

Each flagged promise catch adds `2` points.
Log-and-default handlers add `2.5` 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.98 / 1.00**
- Best separating metric: **findings / file (0.99)**
- Hit rate: **9/9 AI repos** vs **4/9 mature OSS repos**
- Full results: [experimental rule report](../../../reports/autoresearch-candidate-rule.md#defensivepromise-default-fallbacks)
159 changes: 159 additions & 0 deletions src/rules/promise-default-fallbacks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Flags promise `.catch()` handlers that convert a rejected async path into a
* cheap fallback value or an implicit `undefined`.
*
* This is intentionally narrower than the existing try/catch rules: it focuses
* on promise-chain catch callbacks that quietly coerce failures into `null`,
* `undefined`, `false`, `0`, `""`, `[]`, `{}`, or an empty handler body. That
* pattern keeps control flow moving while hiding the original rejection.
*/
import * as ts from "typescript";
import type { RulePlugin } from "../../core/types";
import {
getLineNumber,
isDefaultLiteral,
isLoggingCall,
unwrapExpression,
walk,
} from "../../facts/ts-helpers";
import { delta } from "../../rule-delta";

const MAX_LOGICAL_LINES = 5000;

type PromiseDefaultFallbackMatch = {
line: number;
kind: "default-return" | "empty-handler" | "log+default";
};

function isCatchCall(node: ts.CallExpression): boolean {
return ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "catch";
}

function getCatchHandler(node: ts.CallExpression): ts.ArrowFunction | ts.FunctionExpression | null {
const [handler] = node.arguments;
if (!handler) {
return null;
}

return ts.isArrowFunction(handler) || ts.isFunctionExpression(handler) ? handler : null;
}

function statementIsLogging(statement: ts.Statement): boolean {
return ts.isExpressionStatement(statement) && isLoggingCall(statement.expression);
}

function summarizeCatchHandler(
handler: ts.ArrowFunction | ts.FunctionExpression,
sourceFile: ts.SourceFile,
): PromiseDefaultFallbackMatch | null {
if (ts.isBlock(handler.body)) {
const statements = handler.body.statements;
if (statements.length === 0) {
return {
line: getLineNumber(sourceFile, handler.getStart(sourceFile)),
kind: "empty-handler",
};
}

const returnStatements = statements.filter(ts.isReturnStatement);

if (returnStatements.length !== 1) {
return null;
}

const [returnStatement] = returnStatements;
if (!returnStatement || !isDefaultLiteral(returnStatement.expression)) {
return null;
}

const hasOnlyLoggingAndReturn = statements.every(
(statement) => statement === returnStatement || statementIsLogging(statement),
);
if (!hasOnlyLoggingAndReturn) {
return null;
}

return {
line: getLineNumber(sourceFile, handler.getStart(sourceFile)),
kind: statements.some(statementIsLogging) ? "log+default" : "default-return",
};
}

return isDefaultLiteral(unwrapExpression(handler.body))
? {
line: getLineNumber(sourceFile, handler.getStart(sourceFile)),
kind: "default-return",
}
: null;
}

function findPromiseDefaultFallbacks(sourceFile: ts.SourceFile): PromiseDefaultFallbackMatch[] {
const matches: PromiseDefaultFallbackMatch[] = [];

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

const handler = getCatchHandler(node);
if (!handler) {
return;
}

const match = summarizeCatchHandler(handler, sourceFile);
if (match) {
matches.push(match);
}
});

return matches;
}

export const promiseDefaultFallbacksRule: RulePlugin = {
id: "defensive.promise-default-fallbacks",
family: "defensive",
severity: "strong",
scope: "file",
requires: ["file.ast"],
delta: delta.byLocations(),
supports(context) {
return context.scope === "file" && Boolean(context.file);
},
evaluate(context) {
// Huge bundled/generated files are noisy outliers for this heuristic and can
// otherwise let one vendored blob dominate a repo-level signal.
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 = findPromiseDefaultFallbacks(sourceFile);
if (matches.length === 0) {
return [];
}

return [
{
ruleId: "defensive.promise-default-fallbacks",
family: "defensive",
severity: "strong",
scope: "file",
path: context.file!.path,
message: `Found ${matches.length} promise catch handler${matches.length === 1 ? "" : "s"} that suppress rejections with cheap fallbacks`,
evidence: matches.map((match) => `line ${match.line}: ${match.kind}`),
score: Math.min(
8,
matches.reduce((total, match) => total + (match.kind === "log+default" ? 2.5 : 2), 0),
),
locations: matches.map((match) => ({ path: context.file!.path, line: match.line })),
},
];
},
};
5 changes: 5 additions & 0 deletions tests/heuristics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ describe("heuristic rule pack", () => {
" return await getData(id);",
"}",
"",
"export async function fetchDataSafely(id: string) {",
" return getData(id).catch(() => null);",
"}",
"",
"export function wrap(id: string) {",
" return getData(id);",
"}",
Expand All @@ -75,6 +79,7 @@ describe("heuristic rule pack", () => {

expect(ruleIds.has("comments.placeholder-comments")).toBe(true);
expect(ruleIds.has("defensive.error-obscuring")).toBe(true);
expect(ruleIds.has("defensive.promise-default-fallbacks")).toBe(true);
expect(ruleIds.has("defensive.async-noise")).toBe(true);
expect(ruleIds.has("structure.pass-through-wrappers")).toBe(true);
expect(ruleIds.has("structure.barrel-density")).toBe(true);
Expand Down
Loading
Loading