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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
- Public plugin helpers and plugin object shape: `src/plugin.ts`
- Discovery / ignore handling: `src/discovery/walk.ts`
- Reusable signals: `src/facts/*`
- Findings logic: `src/rules/*` (flat files; grouping is by rule `id` / `family`, not folders)
- Findings logic: `src/rules/<rule>/index.ts` with per-rule docs in `src/rules/<rule>/README.md` and shared helpers in `src/rules/shared/*`
- Output formats: `src/reporters/*`
- Current language scope: `src/languages/javascript-like.ts`

Expand Down
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,17 @@ slop-scan delta --base ../main --fail-on added,worsened

Current checks focus on patterns that often show up in unreviewed generated code:

- log-and-continue catch blocks
- error-obscuring catch blocks (default-return or generic replacement error)
- empty catch blocks
- async wrapper / `return await` noise
- pass-through wrappers
- barrel density
- duplicate helper/function signatures across source files
- over-fragmentation
- directory fan-out hotspots
- placeholder comments
- duplicated test mock/setup patterns
- [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)
- [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)
- [duplicate helper/function signatures across source files](src/rules/duplicate-function-signatures/README.md)
- [over-fragmentation](src/rules/over-fragmentation/README.md)
- [directory fan-out hotspots](src/rules/directory-fanout-hotspot/README.md)
- [placeholder comments](src/rules/placeholder-comments/README.md)
- [duplicated test mock/setup patterns](src/rules/duplicate-mock-setup/README.md)

## What you get back

Expand Down Expand Up @@ -283,6 +283,7 @@ That keeps the analyzer deterministic and extensible without turning it into one
## Docs

- plugin guide: [`docs/plugins.md`](docs/plugins.md)
- built-in rule docs: browse [`src/rules/`](src/rules)
- benchmark guide: [`benchmarks/README.md`](benchmarks/README.md)
- pinned benchmark report: [`reports/known-ai-vs-solid-oss-benchmark.md`](reports/known-ai-vs-solid-oss-benchmark.md)
- exploratory note on non-JS/TS candidates: [`reports/exploratory-vite-astro-openclaw-beads.md`](reports/exploratory-vite-astro-openclaw-beads.md)
Expand Down
48 changes: 48 additions & 0 deletions src/rules/async-noise/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# defensive.async-noise

Flags async ceremony that adds little value.

- **Family:** `defensive`
- **Severity:** `medium`
- **Scope:** `file`
- **Requires:** `file.functionSummaries`

## How it works

The rule reports two patterns:

- redundant `return await` around a direct call
- trivial async pass-through wrappers with no internal `await`

Boundary wrappers are exempted for common edge-facing targets such as `fetch`, `axios.*`, `prisma.*`, `redis.*`, and similar APIs, because those wrappers are often intentional integration boundaries.

## Flagged examples

```ts
async function loadUser(id: string) {
return await fetchUser(id);
}

async function getUser(id: string) {
return fetchUser(id);
}
```

## Usually ignored

```ts
async function loadUser(id: string) {
const user = await fetchUser(id);
return normalizeUser(user);
}

async function getJson(url: string) {
return fetch(url);
}
```

## Scoring

Redundant `return await` sites add `1.5` each.
Plain async pass-through wrappers add `0.75` each.
The total file contribution is capped at `4`.
8 changes: 4 additions & 4 deletions src/rules/async-noise.ts → src/rules/async-noise/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { RulePlugin } from "../core/types";
import type { FunctionSummary } from "../facts/types";
import { delta } from "../rule-delta";
import { BOUNDARY_WRAPPER_TARGET_PREFIXES } from "./helpers";
import type { RulePlugin } from "../../core/types";
import type { FunctionSummary } from "../../facts/types";
import { delta } from "../../rule-delta";
import { BOUNDARY_WRAPPER_TARGET_PREFIXES } from "../shared/helpers";

type AsyncNoiseMatch = {
summary: FunctionSummary;
Expand Down
41 changes: 41 additions & 0 deletions src/rules/barrel-density/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# structure.barrel-density

Flags files that are effectively nothing but re-export barrels.

- **Family:** `structure`
- **Severity:** `medium`
- **Scope:** `file`
- **Requires:** `file.exportSummary`

## How it works

A file is reported when both of these are true:

- every top-level statement is a re-export
- there are at least 2 re-export statements

That keeps the rule focused on pure barrels instead of legitimate modules that happen to re-export one helper or type.

## Flagged example

```ts
export * from "./client";
export * from "./types";
export { createStore } from "./store";
```

## Usually ignored

```ts
import { createStoreImpl } from "./store";

export function createStore() {
return createStoreImpl();
}

export { type Store } from "./types";
```

## Scoring

The score starts at `1` and adds `0.5` per re-export statement, capped at `3`.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { RulePlugin } from "../core/types";
import type { ExportSummary } from "../facts/types";
import { delta } from "../rule-delta";
import type { RulePlugin } from "../../core/types";
import type { ExportSummary } from "../../facts/types";
import { delta } from "../../rule-delta";

/**
* Flags files that are mostly re-export barrels.
Expand Down
53 changes: 53 additions & 0 deletions src/rules/directory-fanout-hotspot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# structure.directory-fanout-hotspot

Flags directories whose file count is unusually large relative to nearby siblings or, when sibling context is weak, the repo-wide average.

- **Family:** `structure`
- **Severity:** `medium`
- **Scope:** `directory`
- **Requires:** `directory.metrics`

## How it works

The rule prefers a sibling-directory baseline when there is enough local context.
Otherwise it falls back to a repo-wide average.
A directory is reported only when its file count clears a dynamic threshold:

- sibling baseline: `ceil(medianSiblingCount * 2.25)`
- repo-wide fallback: `ceil(globalAverage * 2.5)`
- absolute minimum threshold: `6` files

It skips directories that are mostly tests and asset-like directories such as `icons/` or `assets/`.

## Flagged example

```text
src/
├── api/ # 3 files
├── auth/ # 4 files
├── billing/ # 3 files
└── generated-actions/ # 16 files
├── action-01.ts
├── action-02.ts
├── action-03.ts
└── ...
```

With siblings clustered around 3–4 files, `generated-actions/` becomes a local fan-out hotspot.

## Usually ignored

```text
src/icons/
├── add.tsx
├── remove.tsx
├── search.tsx
└── ...
```

Asset-like buckets and test-matrix directories are intentionally suppressed because wide directory shapes are expected there.

## Scoring

The rule starts at `2` and adds a bounded amount based on how far the directory is above the computed threshold.
The total directory contribution stays capped at `6`.
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import path from "node:path";
import type { RulePlugin } from "../core/types";
import { isTestFile } from "../facts/ts-helpers";
import type { DirectoryMetrics } from "../facts/types";
import { delta } from "../rule-delta";
import { average, countMatching, isAssetLikeDirectoryPath, median, ratio } from "./helpers";
import type { RulePlugin } from "../../core/types";
import { isTestFile } from "../../facts/ts-helpers";
import type { DirectoryMetrics } from "../../facts/types";
import { delta } from "../../rule-delta";
import { average, countMatching, isAssetLikeDirectoryPath, median, ratio } from "../shared/helpers";

/**
* Flags directories whose file count is unusually large relative to nearby
Expand Down
55 changes: 55 additions & 0 deletions src/rules/duplicate-function-signatures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# structure.duplicate-function-signatures

Flags repeated non-test helper shapes that show up across several source files.

- **Family:** `structure`
- **Severity:** `medium`
- **Scope:** `file`
- **Requires:** `repo.duplicateFunctionSignatures`

## How it works

A repo-level fact builds structural fingerprints for function bodies, normalizing local names so copy-pasted helpers still match after superficial renaming.
The rule then projects those duplicate clusters back onto each affected file.

A cluster only counts when it appears in **3 or more files**.
Tiny functions and pass-through wrappers are excluded before clustering, and test files are skipped entirely.

## Flagged example

```ts
// src/users/normalize.ts
export function normalizeUser(input: ApiUser) {
const name = input.name?.trim() ?? "";
const email = input.email?.toLowerCase() ?? "";
return { name, email, active: Boolean(input.active) };
}

// src/teams/normalize.ts
export function normalizeTeamMember(member: ApiMember) {
const name = member.name?.trim() ?? "";
const email = member.email?.toLowerCase() ?? "";
return { name, email, active: Boolean(member.active) };
}

// src/accounts/normalize.ts
export function normalizeAccountOwner(owner: ApiOwner) {
const name = owner.name?.trim() ?? "";
const email = owner.email?.toLowerCase() ?? "";
return { name, email, active: Boolean(owner.active) };
}
```

## Usually ignored

```ts
export function getUser(id: string) {
return loadUser(id);
}
```

Pass-through wrappers are excluded, and a duplicate that only appears in 2 files is below the reporting threshold.

## Scoring

Each duplicate cluster adds `1.25 + 0.5 * (fileCount - 3)` for the current file, capped at `6`.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { RulePlugin } from "../core/types";
import type { DuplicateFunctionIndex } from "../facts/types";
import { isTestFile } from "../facts/ts-helpers";
import type { RulePlugin } from "../../core/types";
import type { DuplicateFunctionIndex } from "../../facts/types";
import { isTestFile } from "../../facts/ts-helpers";

function findUniqueDuplicateFunctionClusters(
duplication: DuplicateFunctionIndex | undefined,
Expand Down
44 changes: 44 additions & 0 deletions src/rules/duplicate-mock-setup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# tests.duplicate-mock-setup

Flags repeated test mock/setup shapes across several test files.

- **Family:** `tests`
- **Severity:** `medium`
- **Scope:** `file`
- **Requires:** `repo.testMockDuplication`

## How it works

A repo-level fact fingerprints statement-level mock/setup shapes inside test files.
The rule reports a file when one of those shapes appears in **3 or more test files**.

Generic labels such as `vi.mock`, `jest.mock`, `vi.spyOn`, `jest.spyOn`, `sinon.stub`, and `sinon.spy` are filtered out so routine framework setup does not dominate the signal.
Cleanup-only statements like `mockReset` and `mockClear` are also ignored.

## Flagged example

```ts
// users.test.ts
vi.mocked(api.fetchUser).mockResolvedValue({ id: 1, name: "Ada" });

// teams.test.ts
vi.mocked(api.fetchUser).mockResolvedValue({ id: 2, name: "Lin" });

// accounts.test.ts
vi.mocked(api.fetchUser).mockResolvedValue({ id: 3, name: "Max" });
```

Once that same setup shape appears in 3 files, each participating file gets a finding.

## Usually ignored

```ts
vi.mock("./client");
vi.clearAllMocks();
```

Generic mock declarations and cleanup-only statements do not contribute to this rule.

## Scoring

Each duplicate setup cluster adds `1 + 0.5 * (fileCount - 2)` for the current file, capped at `5`.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { RulePlugin } from "../core/types";
import type { DuplicateTestSetupIndex } from "../facts/types";
import { isTestFile } from "../facts/ts-helpers";
import type { RulePlugin } from "../../core/types";
import type { DuplicateTestSetupIndex } from "../../facts/types";
import { isTestFile } from "../../facts/ts-helpers";

function findUniqueDuplicateMockSetupClusters(
duplication: DuplicateTestSetupIndex | undefined,
Expand Down
50 changes: 50 additions & 0 deletions src/rules/empty-catch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# defensive.empty-catch

Flags empty catch blocks that silently suppress failures.

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

## How it works

The rule reports small try/catch blocks when the catch body is empty.
It intentionally skips:

- common filesystem-existence probes
- documented local fallbacks where the try block only resolves local values and the catch explains that execution should fall through to another source
- larger try blocks where this structural approximation is less trustworthy

## Flagged example

```ts
export function parseConfig(raw: string) {
try {
return JSON.parse(raw);
} catch {}

return null;
}
```

## Usually ignored

```ts
export function loadTheme() {
let stored: string | null = null;

try {
stored = localStorage.getItem("theme");
} catch {
// fall through to the default theme
}

return stored ?? "light";
}
```

## Scoring

Each flagged catch uses the shared try/catch scoring helper, then the file total is capped at `8`.
Boundary-oriented catches are downweighted instead of fully ignored.
Loading
Loading