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
173 changes: 141 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,49 @@
# TPMineração de Repositórios: Detecção e Refatoração de Code Smells em Python
# PySniff — Detecção e Refatoração de Code Smells em Python via Mineração de Repositórios

Trabalho Prático da disciplina **Engenharia de Software II** — UFMG.

---

## 1. Membros do Grupo
## Membros do Grupo

- Gustavo Dias Apolinário
- Bernardo Vale dos Santos Bento
- Filipe Mauro da Terra Caldeira

---

## 2. Sobre o Sistema

Ferramenta de linha de comando que **identifica 5 code smells em código Python por análise estática**, construída sobre um pipeline de **mineração de repositórios** que extrai pares reais de refatoração (antes→depois) do histórico de commits do GitHub. O projeto tem duas entregas complementares:

1. **A CLI de detecção** (`cli.py`) — produto do enunciado: aponta-se para um arquivo ou diretório Python e ela reporta, por função/método, os smells encontrados com a evidência métrica de cada um.
2. **O dataset minerado com qualidade medida** (repo privado `tp-es2-dataset`) — pares before→after por smell, validados por juiz LLM (Gemma) e auditados por **sonda de qualidade humana** (2 anotadores, amostra estratificada, precisão reponderada por taxa-base, Cohen κ) — insumo para o fine-tuning futuro de um modelo de refatoração (alvo: Stable Code Instruct 3B + QLoRA).

> **Escopo real vs. proposta original:** a proposta inicial previa 12 smells e 11 LoRAs. O escopo executado — documentado com justificativas em `docs/DECISOES_PROJETO.md` — é de **5 smells com detecção por regras estáticas** e a trilha de fine-tuning condicionada à qualidade medida do dataset (a sonda de qualidade veio antes do treino, deliberadamente: *garbage in, garbage out*).
## Objetivo da Ferramenta

**PySniff** é uma ferramenta de linha de comando que ataca um problema de
**manutenção e evolução de software**: a presença de *code smells* — estruturas
de código que funcionam, mas dificultam manutenção, leitura e evolução. A
ferramenta **identifica 5 code smells em código Python por análise estática**,
e é construída sobre um pipeline de **mineração de repositórios** que extrai
pares reais de refatoração (antes→depois) do histórico de commits do GitHub.

O projeto tem **duas entregas complementares**:

1. **A CLI de detecção** (`cli.py`) — produto do enunciado: aponta-se para um
arquivo ou diretório Python e ela reporta, por função/método, os smells
encontrados com a evidência métrica de cada um.
2. **O dataset minerado com qualidade medida** (repo privado `tp-es2-dataset`) —
pares before→after por smell, validados por juiz LLM (Gemma) e auditados por
**sonda de qualidade humana** (2 anotadores, amostra estratificada, precisão
reponderada por taxa-base, Cohen κ) — insumo para o **fine-tuning futuro de um
modelo de refatoração** (Trilha B; alvo: Stable Code Instruct 3B + QLoRA),
que proporá automaticamente as correções dos smells detectados.

> **Escopo real vs. proposta original:** a proposta inicial previa 12 smells e
> 11 LoRAs. O escopo executado — documentado com justificativas em
> `docs/DECISOES_PROJETO.md` — é de **5 smells com detecção por regras estáticas**
> (a CLI deste repositório) e a trilha de fine-tuning condicionada à qualidade
> medida do dataset (a sonda de qualidade veio antes do treino, deliberadamente:
> *garbage in, garbage out*). A **Trilha B (refatoração) será continuada em
> breve.**

### Os 5 smells cobertos

| ID | Smell | Refatoração-alvo | Detecção |
| ID | Smell | Refatoração-alvo | Critério de detecção |
|---|---|---|---|
| R1 | Long Method | Extract Method | Lizard (NLOC > 30 ou CCN > 10) |
| R2 | Long Parameter List | Parameter Object | contagem AST (> 5 parâmetros) |
Expand All @@ -33,40 +53,127 @@ Ferramenta de linha de comando que **identifica 5 code smells em código Python

Cada detector expõe o contrato `detect(fn: FunctionInfo) -> DetectionResult` (`detectores/`).

## 3. Como usar a CLI
---

## Como Instalar

Requer **Python 3.11+** (testado em CI com 3.13 em Linux, macOS e Windows).

```bash
# 1. clone o repositório
git clone https://github.com/Gronoxx/tp-mineracao-manutencao
cd tp-mineracao-manutencao

# 2. crie e ative um ambiente virtual
python3 -m venv .venv
source .venv/bin/activate

# 3. instale as dependências da ferramenta
pip install -r requirements_cli.txt
```

> Para usar **apenas a CLI de detecção** (sem o pipeline de mineração), o
> conjunto mínimo de dependências é: `pip install lizard click rich`.

---

## Como Utilizar

A ferramenta tem dois comandos: `scan` (analisar código) e `smells` (listar o
que é detectado).

```bash
pip install -r detectores/requirements.txt click rich
# versão da ferramenta
python3 cli.py --version

# listar os smells suportados
# listar os 5 smells suportados
python3 cli.py smells

# analisar um arquivo ou diretório (árvore Rich por arquivo → função → smell)
python3 cli.py scan caminho/do/projeto/

# restringir smells, saída JSON, ou uso em CI
python3 cli.py scan src/ --smell long_method --smell dead_code
# restringir a smells específicos — por ID (R1–R5) ou nome
python3 cli.py scan src/ --smell R1 --smell dead_code

# saída JSON estruturada (para integração/scripts)
python3 cli.py scan src/ --json > resultado.json
python3 cli.py scan src/ --fail-on-detect # exit 1 se detectar algo

# uso em CI: retorna código de saída 1 se algum smell for detectado
python3 cli.py scan src/ --fail-on-detect
```

**Códigos de saída do `scan`:** `0` análise concluída · `1` `--fail-on-detect`
ativo e houve detecção · `2` nenhum arquivo `.py` encontrado. Veja
`python3 cli.py scan --help` para todos os detalhes.

---

## Como Executar os Testes Localmente

```bash
# instale as dependências de teste (além das da ferramenta)
pip install -r requirements_cli.txt -r requirements-dev.txt

# rode a suíte completa a partir da raiz do repositório
python3 -m pytest

# com relatório de cobertura
python3 -m pytest --cov
```

## 4. Pipeline de mineração e dataset
A suíte tem **mais de 250 testes** (detectores, CLI e pipeline de mineração) e é
executada **automaticamente a cada push e pull request** via GitHub Actions —
veja [`.github/workflows/python-app.yaml`](.github/workflows/python-app.yaml).

---

## Pipeline de Mineração e Dataset

- `extracao/` minera pares de refatoração de repositórios Git (PyDriller): modos
commit, PR, cross-file e rename, com validação por similaridade AST e checagem
comportamental heurística.
- **Ancoragem em rule-ID de linter** (achado central do projeto): minerar commits
que *removem* um aviso específico de linter (ex.: Pylint R0913, Ruff PLR2004)
multiplica o yield de pares válidos em 5–25× vs. heurísticas de mensagem de
commit — o yield é proporcional à **objetividade do critério** do smell.
Detalhes e números em `docs/DECISOES_PROJETO.md`.
- 5.072 candidatos minerados → 616 pares aprovados pelo juiz LLM (Gemma) →
**sonda de qualidade humana de 50 pares** (estratificada, cega ao proxy AST,
precisão populacional reponderada por taxa-base, κ) + re-auditoria assistida
por LLM. Resultados, codebook de anotação e decisão de curadoria vivem em
`tp-es2-dataset` e `tp-es2-anotador/CODEBOOK.md`.
- **Oracle firewall:** PyRef e Sourcery são reservados para avaliação — nunca
geram dados de treino; o split treino/teste é por repositório.

### Trilha B — Fine-tuning de refatoração (em desenvolvimento)

O dataset minerado e auditado alimenta a **Trilha B**: o treino de um modelo que
proporá automaticamente a correção dos smells detectados. A infraestrutura vive
em `trilha_b/` (configuração e scripts de fine-tuning com QLoRA; alvo
**Stable Code Instruct 3B**). O modelo ainda não foi treinado — esta trilha será
continuada em breve.

- `extracao/` minera pares de refatoração de repositórios Git (PyDriller): modos commit, PR, cross-file e rename, com validação por similaridade AST e checagem comportamental heurística.
- **Ancoragem em rule-ID de linter** (achado central do projeto): minerar commits que *removem* um aviso específico de linter (ex.: Pylint R0913, Ruff PLR2004) multiplica o yield de pares válidos em 5–25× vs. heurísticas de mensagem de commit — o yield é proporcional à **objetividade do critério** do smell. Detalhes e números em `docs/DECISOES_PROJETO.md`.
- 5.072 candidatos minerados → 616 pares aprovados pelo juiz LLM (Gemma) → **sonda de qualidade humana de 50 pares** (estratificada, cega ao proxy AST, precisão populacional reponderada por taxa-base, κ) + re-auditoria assistida por LLM. Resultados, codebook de anotação e decisão de curadoria vivem em `tp-es2-dataset` e `tp-es2-anotador/CODEBOOK.md`.
- **Oracle firewall:** PyRef e Sourcery são reservados para avaliação — nunca geram dados de treino; split treino/teste é por repositório.
---

## 5. Tecnologias utilizadas
## Tecnologias Utilizadas

- **CLI:** [Click](https://click.palletsprojects.com) (definição dos comandos) +
[Rich](https://github.com/Textualize/rich) (árvore e tabelas no terminal).
- **Análise estática:** `ast` (stdlib), [Lizard](https://github.com/terryyin/lizard),
Pylint, Ruff, Vulture.
- **Mineração:** PyDriller, GitPython, PyGithub (issues/PRs), Seart GHS para
seleção de repositórios.
- **Dataset/juiz:** Gemma (juiz LLM local), proxy de qualidade por padrão AST
(`estimate_positive_quality.py`), anotador web próprio (`tp-es2-anotador`).
- **Testes:** [pytest](https://github.com/pytest-dev/pytest) — mais de 250 testes
cobrindo detectores, CLI e pipeline de mineração.
- **CI:** GitHub Actions (executa a suíte em Linux, macOS e Windows).
- **Trilha de fine-tuning (em desenvolvimento, `trilha_b/`):** HuggingFace
Transformers + PEFT (QLoRA), alvo Stable Code Instruct 3B.

- **Mineração:** PyDriller, GitPython, PyGithub (issues/PRs), Seart GHS para seleção de repositórios.
- **Análise estática:** `ast` (stdlib), Lizard, Pylint, Ruff, Vulture.
- **CLI:** Click + Rich.
- **Dataset/juiz:** Gemma (juiz LLM local), proxy de qualidade por padrão AST (`estimate_positive_quality.py`), anotador web próprio (`tp-es2-anotador`).
- **Testes:** pytest (243 testes — pipeline de mineração, detectores e CLI).
- **Trilha de fine-tuning (em desenvolvimento, `trilha_b/`):** HuggingFace Transformers + PEFT (QLoRA), alvo Stable Code Instruct 3B.
---

## 6. Estrutura do repositório
## Estrutura do Repositório

O projeto usa três repositórios, separados por papel:

Expand All @@ -78,14 +185,16 @@ O projeto usa três repositórios, separados por papel:

Layout do código:

- `cli.py` — CLI de detecção (produto do enunciado).
- `cli.py` — CLI de detecção (produto do enunciado: comandos `scan` e `smells`).
- `detectores/` — os 5 detectores estáticos (`detect(fn) -> DetectionResult`).
- `extracao/` — mineração de pares de refatoração via PyDriller.
- `core/` — tipos compartilhados, schema e oráculo de avaliação.
- `trilha_b/` — configuração e scripts de fine-tuning (QLoRA; ainda não treinado).
- `gemma_judge_dataset.py`, `estimate_positive_quality.py` — juiz LLM e proxy de qualidade do dataset.
- `gemma_judge_dataset.py`, `estimate_positive_quality.py` — juiz LLM e proxy de
qualidade do dataset.
- `docs/` — enunciado, decisões de projeto datadas e checkpoints.
- `tests/` — suíte pytest (rodar da raiz: `python3 -m pytest`).
- `.github/workflows/` — GitHub Actions (CI).

---

Expand Down
97 changes: 82 additions & 15 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
cada função/método e apresenta os smells encontrados (árvore Rich ou JSON).

Uso:
python3 cli.py --version
python3 cli.py scan caminho/arquivo.py
python3 cli.py scan caminho/projeto/ --smell long_method --smell dead_code
python3 cli.py scan caminho/projeto/ --smell R1 --smell dead_code
python3 cli.py scan projeto/ --json > resultado.json
python3 cli.py smells
"""
Expand All @@ -27,6 +28,9 @@
from detectores import DETECTORS # noqa: E402
from extracao.mineracao.ast_utils import parse_file # noqa: E402

# Versão semântica da ferramenta (SemVer). Primeira release pública.
__version__ = "1.0.0"

# Diretórios que nunca contêm código do usuário a analisar
_SKIP_DIRS = {".git", "__pycache__", ".venv", "venv", ".tox", "node_modules",
".mypy_cache", ".ruff_cache", "build", "dist", ".eggs"}
Expand All @@ -40,6 +44,30 @@
"dead_code": ("R5", "Código morto — candidato a remoção"),
}

# Mapa ID (R1–R5) → nome canônico do smell, para o usuário filtrar por qualquer um dos dois.
_ID_PARA_SMELL = {rid.upper(): nome for nome, (rid, _) in SMELL_INFO.items()}


def _normalizar_smells(ctx, param, valores):
"""Aceita ID (R1–R5, case-insensitive) ou nome do smell; normaliza para o nome canônico.

Mantém a ordem de digitação e remove duplicatas (ex.: `--smell R2 --smell
long_param_list` vira um único `long_param_list`). Erro de usuário claro
quando o valor não corresponde a nenhum smell.
"""
canonicos = []
for valor in valores:
chave = valor.strip()
if chave in DETECTORS:
canonicos.append(chave)
elif chave.upper() in _ID_PARA_SMELL:
canonicos.append(_ID_PARA_SMELL[chave.upper()])
else:
ids = ", ".join(f"{rid} ({nome})" for nome, (rid, _) in SMELL_INFO.items())
raise click.BadParameter(
f"'{valor}' não é um smell conhecido. Use o ID ou o nome: {ids}.")
return tuple(dict.fromkeys(canonicos)) # dedup preservando ordem


def _coletar_arquivos(caminhos: tuple[str, ...]) -> list[Path]:
"""Expande arquivos/diretórios em uma lista ordenada de arquivos .py."""
Expand Down Expand Up @@ -127,21 +155,47 @@ def _analisar(arquivos: list[Path], smells: tuple[str, ...]) -> tuple[list[dict]
return resultados, avisos


@click.group()
@click.group(
epilog="\b\n"
"Exemplos:\n"
" cli.py scan src/ analisa um diretório inteiro\n"
" cli.py scan app.py --smell R1 só Long Method (filtro por ID)\n"
" cli.py scan src/ --json > out.json saída estruturada p/ integração\n"
" cli.py smells lista os smells suportados\n")
@click.version_option(__version__, "-V", "--version", "--V", prog_name="PySniff")
def cli():
"""Detector de code smells Python (5 smells, R1–R5) por análise estática."""
"""PySniff — detector de code smells em Python por análise estática.

Aponte a ferramenta para arquivos ou diretórios e ela reporta, por
função/método, os 5 smells de manutenção suportados (R1–R5) com a
evidência métrica de cada detecção. Use `smells` para ver a lista e
`scan --help` para os detalhes da análise.
"""

@cli.command()
@click.argument("caminhos", nargs=-1, required=True, type=click.Path(exists=True))
@click.option("--smell", "smells", multiple=True,
type=click.Choice(sorted(DETECTORS)), help="Restringe aos smells dados (repetível).")
@click.option("--json", "como_json", is_flag=True, help="Saída JSON em vez de árvore Rich.")

@cli.command(
epilog="\b\n"
"Códigos de saída:\n"
" 0 análise concluída (mesmo com smells, exceto se --fail-on-detect)\n"
" 1 --fail-on-detect ativo e ao menos um smell detectado\n"
" 2 nenhum arquivo .py encontrado nos caminhos informados\n")
@click.argument("caminhos", nargs=-1, required=True, type=click.Path(exists=True),
metavar="CAMINHOS...")
@click.option("--smell", "smells", multiple=True, metavar="SMELL", callback=_normalizar_smells,
help="Restringe a um smell, por ID (R1–R5) ou nome (ex.: long_method). "
"Repetível. Padrão: todos.")
@click.option("--json", "como_json", is_flag=True,
help="Emite JSON estruturado em vez da árvore visual (para integração/scripts).")
@click.option("--fail-on-detect", is_flag=True,
help="Sai com código 1 se qualquer smell for detectado (uso em CI).")
help="Retorna código de saída 1 se algum smell for detectado (útil em CI).")
def scan(caminhos, smells, como_json, fail_on_detect):
"""Analisa arquivos ou diretórios e reporta smells por função."""
"""Analisa CAMINHOS (arquivos .py ou diretórios) e reporta smells por função.

Diretórios são percorridos recursivamente; pastas como .venv, .git e
__pycache__ são ignoradas automaticamente.
"""
console = Console(stderr=False)
filtrado = bool(smells) # o usuário restringiu os smells via --smell?
smells = smells or tuple(sorted(DETECTORS, key=lambda s: SMELL_INFO[s][0]))
arquivos = _coletar_arquivos(caminhos)
if not arquivos:
Expand All @@ -161,11 +215,10 @@ def scan(caminhos, smells, como_json, fail_on_detect):
"resumo": total_por_smell, "avisos": avisos},
ensure_ascii=False, indent=1, default=str))
else:
console.print(f"[dim]Analisando {len(arquivos)} arquivo(s) "
f"com {len(smells)} detector(es)…[/dim]")
for aviso in avisos:
console.print(f"[yellow]aviso:[/yellow] {aviso}")
if not resultados:
console.print(f"[green]Nenhum smell detectado[/green] "
f"({len(arquivos)} arquivo(s), {len(smells)} detector(es)).")
for r in resultados:
arvore = Tree(f"[bold]{r['arquivo']}[/bold]")
for f in r["funcoes"]:
Expand All @@ -175,8 +228,22 @@ def scan(caminhos, smells, como_json, fail_on_detect):
no_fn.add(f"[red]{rid} {a['smell']}[/red] — "
f"{_resumo_evidencia(a['smell'], a['evidence'])}")
console.print(arvore)
if resultados:
tabela = Table(title="Resumo")

total_deteccoes = sum(total_por_smell.values())
if total_deteccoes == 0:
if filtrado:
quais = ", ".join(f"{SMELL_INFO[s][0]} ({s})" for s in smells)
console.print(f"[bold green]✓ Nenhum smell do tipo {quais} detectado[/bold green] "
f"em {len(arquivos)} arquivo(s).")
else:
console.print(f"[bold green]✓ Nenhum smell detectado[/bold green] "
f"em {len(arquivos)} arquivo(s).")
else:
n_funcoes = sum(len(r["funcoes"]) for r in resultados)
console.print(f"[bold red]✗ {total_deteccoes} smell(s)[/bold red] "
f"em {n_funcoes} função(ões) de {len(resultados)} arquivo(s) "
f"(de {len(arquivos)} analisado(s)).")
tabela = Table(title="Detecções por smell")
tabela.add_column("Smell")
tabela.add_column("Detecções", justify="right")
for s in smells:
Expand Down
Loading
Loading