<p align="center">
  <img src="https://raw.githubusercontent.com/cheickmec/smellcheck/main/assets/logo.png" alt="smellcheck logo" width="200">
</p>

<h1 align="center">smellcheck</h1>

<p align="center">
  <strong>Python Code Smell Detector & Refactoring Guide</strong><br>
  83 refactoring patterns &middot; 60 automated AST checks &middot; zero dependencies
</p>

<p align="center">
  <a href="https://pypi.org/project/smellcheck/"><img src="https://img.shields.io/pypi/v/smellcheck" alt="PyPI"></a>
  <a href="https://pypi.org/project/smellcheck/"><img src="https://img.shields.io/pypi/pyversions/smellcheck" alt="Python"></a>
  <a href="https://github.com/cheickmec/smellcheck/actions/workflows/ci.yml"><img src="https://github.com/cheickmec/smellcheck/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <a href="https://codecov.io/gh/cheickmec/smellcheck"><img src="https://codecov.io/gh/cheickmec/smellcheck/branch/main/graph/badge.svg" alt="Coverage"></a>
  <a href="https://pypistats.org/packages/smellcheck"><img src="https://img.shields.io/pypi/dm/smellcheck" alt="Downloads"></a>
  <a href="https://github.com/cheickmec/smellcheck/blob/main/docs/installation.md#pre-commit"><img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit" alt="pre-commit"></a>
  <a href="https://github.com/cheickmec/smellcheck/blob/main/LICENSE"><img src="https://img.shields.io/github/license/cheickmec/smellcheck" alt="License"></a>
</p>

**smellcheck** is a Python code smell detector and refactoring catalog. It works as a pip-installable CLI, GitHub Action, pre-commit hook, or [Agent Skills](https://agentskills.io) plugin for AI coding assistants.

**No dependencies.** Pure Python stdlib (`ast`, `pathlib`, `json`). Runs anywhere Python 3.10+ runs.

> **What are code smells?** Code smells are surface-level patterns in source code that hint at deeper design problems — not bugs, but structural weaknesses that make code harder to maintain, extend, or understand. [Learn more →](https://github.com/cheickmec/smellcheck/blob/main/docs/code-smells-guide.md)

## Installation

### pip

```bash
pip install smellcheck

smellcheck src/
smellcheck myfile.py --format json
smellcheck src/ --min-severity warning --fail-on warning
```

Also available as a **GitHub Action**, **pre-commit hook**, **SARIF/Code Scanning** integration, **[Agent Skills](https://agentskills.io) plugin**, and **Cursor native plugin** for Claude Code, Cursor, Copilot, Gemini CLI, and more.

**[Full installation guide →](https://github.com/cheickmec/smellcheck/blob/main/docs/installation.md)**

## Usage

```bash
# Scan a directory
smellcheck src/

# Scan multiple files
smellcheck file1.py file2.py

# JSON output
smellcheck src/ --format json

# GitHub Actions annotations
smellcheck src/ --format github

# SARIF output (for GitHub Code Scanning)
smellcheck src/ --format sarif > results.sarif

# JUnit XML output (for Jenkins, GitLab, CircleCI, Azure DevOps)
smellcheck src/ --format junit > smellcheck-results.xml

# GitLab CodeClimate output (for MR code quality widget)
smellcheck src/ --format gitlab > gl-code-quality-report.json

# Filter by severity
smellcheck src/ --min-severity warning

# Control exit code
smellcheck src/ --fail-on warning   # exit 1 on warning or error
smellcheck src/ --fail-on info      # exit 1 on any finding

# Run only specific checks
smellcheck src/ --select SC101,SC701,SC210

# Skip specific checks
smellcheck src/ --ignore SC601,SC202

# Module execution
python3 -m smellcheck src/

# Generate a baseline of current findings
smellcheck src/ --generate-baseline > .smellcheck-baseline.json

# Only report findings not in the baseline
smellcheck src/ --baseline .smellcheck-baseline.json

# Disable caching for a fresh scan
smellcheck src/ --no-cache

# Use a custom cache directory
smellcheck src/ --cache-dir .my-cache

# Clear cached results
smellcheck --clear-cache

# Show documentation for a rule (description + before/after example)
smellcheck --explain SC701

# List all rules in a family
smellcheck --explain SC4

# List all rules grouped by family
smellcheck --explain all

# Generate a phased refactoring plan
smellcheck src/ --plan
smellcheck src/ --plan --format json
```

## Configuration

smellcheck reads `[tool.smellcheck]` from the nearest `pyproject.toml`:

```toml
[tool.smellcheck]
extends = "base.toml"               # inherit from a shared config file
select = ["SC101", "SC201", "SC701"]  # only run these checks (default: all)
ignore = ["SC601", "SC202"]          # skip these checks
per-file-ignores = {"tests/*" = ["SC201", "SC206"]}  # per-path overrides
fail-on = "warning"                  # override default fail-on
format = "text"                      # override default format
baseline = ".smellcheck-baseline.json"  # suppress known findings
cache = true                           # enable file-level caching (default: true)
cache-dir = ".smellcheck-cache"        # cache directory (default: .smellcheck-cache)
```

CLI flags override config values. **Precedence:** CLI flags > environment variables > pyproject.toml > defaults.

### Environment variables

Set `SMELLCHECK_*` variables for CI/CD without per-repo config files:

```bash
SMELLCHECK_FAIL_ON=warning SMELLCHECK_FORMAT=json smellcheck src/
```

Supported: `SMELLCHECK_MIN_SEVERITY`, `SMELLCHECK_FAIL_ON`, `SMELLCHECK_FORMAT`, `SMELLCHECK_SELECT`, `SMELLCHECK_IGNORE`, `SMELLCHECK_BASELINE`. See the [Configuration Reference](https://github.com/cheickmec/smellcheck/blob/main/docs/configuration.md) for details.

### Config inheritance (`extends`)

Use `extends` to inherit settings from a shared base config:

```toml
# base.toml — shared across repos
[tool.smellcheck]
ignore = ["SC601"]
fail-on = "warning"
```

```toml
# pyproject.toml — project overrides
[tool.smellcheck]
extends = "base.toml"
ignore = ["SC202"]  # adds to base; final ignore = ["SC601", "SC202"]
```

Multiple bases are supported — later entries override earlier ones for scalar values, while `ignore` lists are unioned and `per-file-ignores` are deep-merged:

```toml
extends = ["base.toml", "strict.toml"]
```

Paths are relative to the file containing the `extends` key. Chains are resolved recursively (up to 5 levels deep).

See the [Configuration Reference](https://github.com/cheickmec/smellcheck/blob/main/docs/configuration.md) for the full CLI flag table, JSON/baseline schemas, exit codes, and flag interaction details.

## Suppression

### Per-line

Add `# noqa: SC701` to a line to suppress that check on that line:

```python
def foo(x=[]):  # noqa: SC701
    return x
```

Use `# noqa` (no codes) to suppress all findings on that line. Multiple codes: `# noqa: SC601,SC202`

### Block-level

Disable specific checks for a range of lines with `# smellcheck: disable` / `# smellcheck: enable`:

```python
# smellcheck: disable SC301, SC305
class LegacyGodObject:
    """This class is intentionally large for backward compatibility."""

    def method_one(self):
        self._temp = compute()  # SC305 suppressed by block directive

    def method_two(self):
        use(self._temp)
# smellcheck: enable SC301, SC305
```

Disable all checks for a range:

```python
# smellcheck: disable-all
# ... everything in this range is suppressed ...
# smellcheck: enable-all
```

### File-level

Suppress checks for an entire file (place at top of file):

```python
# smellcheck: disable-file SC301, SC305
```

Use `# smellcheck: disable-file` (no codes) to suppress all checks for the entire file.

### Scope rules

- `disable` / `enable` apply from that line to the matching `enable` (or end of file if no match)
- `disable-all` / `enable-all` work the same way but for all checks at once
- `disable-file` applies to the entire file
- Per-line `# noqa` still works alongside block directives
- Block directives do not affect cross-file findings (use `per-file-ignores` in config instead)

## Baseline

For large codebases, you can adopt smellcheck incrementally using a baseline file. The baseline records fingerprints of existing findings so only **new** issues are reported.

```bash
# 1. Generate a baseline from the current state
smellcheck src/ --generate-baseline > .smellcheck-baseline.json

# 2. Run with the baseline — only new findings are reported
smellcheck src/ --baseline .smellcheck-baseline.json

# 3. Or set it in pyproject.toml so every run uses it automatically
```

Fingerprints are resilient to line-number changes — renaming or moving code around won't break the baseline. When you fix a baselined smell, its entry is silently ignored.

`--generate-baseline` and `--baseline` are mutually exclusive.

## Diff-Aware Scanning

Focus on files you actually changed — skip the rest of the codebase:

```bash
# Only scan files changed vs. main branch
smellcheck src/ --diff main

# Only scan files changed in the last commit
smellcheck src/ --diff HEAD~1

# Only scan uncommitted changes (shorthand for --diff HEAD)
smellcheck src/ --changed-only
```

In CI, this keeps PR feedback fast and relevant:

```yaml
- uses: cheickmec/smellcheck@v0
  with:
    diff: origin/main
    fail-on: warning
```

Cross-file checks (cyclic imports, shotgun surgery, etc.) run on the changed file set only. This is best-effort — for full cross-file accuracy, run without `--diff`.

`--diff` and `--generate-baseline` are mutually exclusive. `--diff` composes with all other flags (`--baseline`, `--format`, `--fail-on`, `--select`, `--ignore`).

## Caching

smellcheck caches per-file analysis results in `.smellcheck-cache/` to skip unchanged files on repeated scans. This is especially useful for pre-commit hooks and editor integrations.

Cache entries are keyed by file content hash, config hash, and smellcheck version — any change invalidates the relevant entry. Cross-file analysis (cyclic imports, duplicate code, etc.) always re-runs since it depends on the full file set.

```bash
# Caching is enabled by default — just run normally
smellcheck src/

# Disable caching for a guaranteed fresh scan
smellcheck src/ --no-cache

# Use a custom cache directory
smellcheck src/ --cache-dir /tmp/sc-cache

# Clear all cached results
smellcheck --clear-cache
```

Old cache entries are not automatically evicted. Run `smellcheck --clear-cache` periodically or after upgrading to reclaim disk space.

Add `.smellcheck-cache/` to your `.gitignore`. You can also configure caching in `pyproject.toml`:

```toml
[tool.smellcheck]
cache = false                    # disable caching
cache-dir = ".smellcheck-cache"  # custom cache directory
```

## Features

- **60 automated smell checks** -- per-file AST analysis, cross-file dependency analysis, and OO metrics
- **83 refactoring patterns** -- numbered catalog with before/after examples, trade-offs, and severity levels
- **Zero dependencies** -- stdlib-only, runs on any Python 3.10+ installation
- **Multiple output formats** -- text (terminal), JSON (machine-readable), GitHub annotations (CI), SARIF 2.1.0 (Code Scanning), JUnit XML (Jenkins/GitLab/CircleCI), GitLab CodeClimate (MR quality widget)
- **Configurable** -- pyproject.toml config, inline suppression, CLI overrides
- **Baseline support** -- adopt incrementally by suppressing existing findings and only failing on new ones
- **File-level caching** -- content-hash based caching skips unchanged files for fast repeated scans
- **Multiple distribution channels** -- pip, GitHub Action, pre-commit, Agent Skills ([full list](https://github.com/cheickmec/smellcheck/blob/main/docs/installation.md))

## Detected Patterns

Every rule is identified by an **SC code** (e.g. `SC701`). Use SC codes in `--select`, `--ignore`, and `# noqa` comments.

### Per-File (43 checks)

| SC Code | Pattern | Severity |
|---------|---------|----------|
| SC101 | Setters (half-built objects) | warning |
| SC102 | UPPER_CASE without Final | info |
| SC103 | Unprotected public attributes | info |
| SC104 | Half-built objects (init assigns None) | warning |
| SC105 | Boolean flag parameters | info |
| SC106 | Global mutable state | info |
| SC107 | Sequential IDs | info |
| SC201 | Long functions (>20 lines) | warning |
| SC202 | Generic names (data, result, tmp) | info |
| SC203 | input() in business logic | warning |
| SC204 | Functions returning None or list | info |
| SC205 | Excessive decorators (>3) | info |
| SC206 | Too many parameters (>5) | warning |
| SC207 | CQS violation (query + modify) | info |
| SC208 | Unused function parameters | warning |
| SC209 | Long lambda (>60 chars) | info |
| SC210 | Cyclomatic complexity (>10) | warning |
| SC301 | Extract class (too many methods) | info |
| SC302 | isinstance chains | warning |
| SC303 | Singleton pattern | warning |
| SC304 | Dataclass candidate | info |
| SC305 | Sequential tuple indexing | info |
| SC306 | Lazy class (<2 methods) | info |
| SC307 | Temporary fields | info |
| SC401 | Dead code after return | warning |
| SC402 | Deep nesting (>4 levels) | warning |
| SC403 | Loop + append pattern | info |
| SC404 | Complex boolean expressions | warning |
| SC405 | Boolean control flag in loop | info |
| SC406 | Complex comprehension (>2 generators) | info |
| SC407 | Missing default else branch | info |
| SC501 | Error codes instead of exceptions | warning |
| SC502 | Law of Demeter violation | info |
| SC601 | Magic numbers | info |
| SC602 | Bare except / unused exception variable | error |
| SC603 | String concatenation for multiline | info |
| SC604 | contextlib candidate | info |
| SC605 | Empty catch block | warning |
| SC701 | Mutable default arguments | error |
| SC702 | open() without context manager | warning |
| SC703 | Blocking calls in async functions | warning |
| SC704 | Sync I/O imports in async module | warning |
| SC705 | asyncio.to_thread tech debt hint | info |

### Cross-File (12 checks)

| SC Code | Pattern | Description |
|---------|---------|-------------|
| SC211 | Feature envy | Function accesses external attributes more than own |
| SC308 | Deep inheritance | Inheritance depth >4 |
| SC309 | Wide hierarchy | >5 direct subclasses |
| SC503 | Cyclic imports | DFS cycle detection |
| SC504 | God modules | >500 lines or >30 top-level definitions |
| SC505 | Shotgun surgery | Function called from >5 different files |
| SC506 | Inappropriate intimacy | >3 bidirectional class references between files |
| SC507 | Speculative generality | Abstract class with no concrete subclasses |
| SC508 | Unstable dependency | Stable module depends on unstable module |
| SC509 | Lazy re-export module | Module only re-exports imported symbols with no logic |
| SC606 | Duplicate functions | AST-normalized hashing across files |
| SC706 | Conflicting concurrency libraries | Monkey-patching libs coexist with asyncio-based libs |

### OO Metrics (5 checks)

| SC Code | Metric | Threshold |
|---------|--------|-----------|
| SC801 | Lack of Cohesion of Methods | >0.8 |
| SC802 | Coupling Between Objects | >8 |
| SC803 | Excessive Fan-Out | >15 |
| SC804 | Response for a Class | >20 |
| SC805 | Middle Man (delegation ratio) | >50% |

See the [OO Metrics Interpretation Guide](https://github.com/cheickmec/smellcheck/blob/main/docs/oo-metrics-guide.md) for detailed explanations, real-world examples, and when-to-ignore guidance.

## Analysis Tiers

smellcheck runs three analysis passes, each building on the previous one:

1. **Per-file AST checks** — Each `.py` file is parsed independently. An AST visitor walks the tree and runs ~35 `_check_*` methods that detect local smells. This is fast and needs no cross-file context.

2. **Cross-file analysis** — After all files are visited, smellcheck builds an import graph and analyzes relationships between modules. This detects architecture smells like cyclic imports, god modules, shotgun surgery, duplicate logic, and excessive coupling.

3. **OO metrics** — Class-level and module-level metrics (LCOM, CBO, fan-out, RFC, middle-man ratio) are computed from aggregated AST data collected during the per-file pass. These highlight structural problems that only emerge when looking at a class or module as a whole.

> **`--diff` tradeoff:** When using `--diff`, only changed files are analyzed. Per-file checks are accurate for those files, but cross-file and OO metric checks may miss issues introduced in unchanged files that depend on the changed ones. For full accuracy, run a periodic full scan without `--diff`.

## Refactoring Reference Files

Each pattern includes a description, before/after code examples, and trade-offs:

| File | Patterns |
|------|----------|
| [`state.md`](https://github.com/cheickmec/smellcheck/blob/main/plugins/python-refactoring/skills/python-refactoring/references/state.md) | Immutability, setters, attributes (SC101–SC107) |
| [`functions.md`](https://github.com/cheickmec/smellcheck/blob/main/plugins/python-refactoring/skills/python-refactoring/references/functions.md) | Extraction, naming, parameters, CQS (SC201–SC210) |
| [`types.md`](https://github.com/cheickmec/smellcheck/blob/main/plugins/python-refactoring/skills/python-refactoring/references/types.md) | Classes, reification, polymorphism, nulls (SC301–SC309) |
| [`control.md`](https://github.com/cheickmec/smellcheck/blob/main/plugins/python-refactoring/skills/python-refactoring/references/control.md) | Guards, pipelines, conditionals, phases (SC401–SC407) |
| [`architecture.md`](https://github.com/cheickmec/smellcheck/blob/main/plugins/python-refactoring/skills/python-refactoring/references/architecture.md) | DI, singletons, exceptions, delegates (SC501–SC509) |
| [`hygiene.md`](https://github.com/cheickmec/smellcheck/blob/main/plugins/python-refactoring/skills/python-refactoring/references/hygiene.md) | Constants, dead code, comments, style (SC601–SC606) |
| [`idioms.md`](https://github.com/cheickmec/smellcheck/blob/main/plugins/python-refactoring/skills/python-refactoring/references/idioms.md) | Context managers, generators, unpacking, async (SC701–SC706) |
| [`metrics.md`](https://github.com/cheickmec/smellcheck/blob/main/plugins/python-refactoring/skills/python-refactoring/references/metrics.md) | OO metrics: cohesion, coupling, fan-out, response, delegation (SC801–SC805) |

## How It Compares

| Feature | smellcheck | [PyExamine](https://github.com/KarthikShivasankar/python_smells_detector) | [SMART-Dal](https://github.com/SMART-Dal/smell-detector-python) | [Pyscent](https://github.com/whyjay17/Pyscent) |
|---------|------------|-----------|-----------|---------|
| Automated detections | 60 | 49 | 31 | 11 |
| Refactoring guidance | 83 patterns | None | None | None |
| Dependencies | 0 (stdlib) | pylint, radon | DesigniteJava | pylint, radon, cohesion |
| Python-specific idioms | Yes | No | No | No |
| Cross-file analysis | Yes | Limited | Yes | No |
| OO metrics | 5 | 19 | 0 | 1 |
| Distribution channels | 4 (pip, GHA, pre-commit, Agent Skills) | 1 | 1 | 1 |

## Contributing

Contributions welcome — see [CONTRIBUTING.md](https://github.com/cheickmec/smellcheck/blob/main/CONTRIBUTING.md) for the full guide. The core detector is `src/smellcheck/detector.py`; add new checks by extending the `SmellDetector` AST visitor class and adding a cross-file analysis function if needed.

```bash
# Development setup
git clone https://github.com/cheickmec/smellcheck.git
cd smellcheck
pip install -e .
pip install pytest

# Run tests
pytest tests/ -v

# Self-check
smellcheck src/smellcheck/
```

## License

MIT
