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
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: /bin/bash --version && /bin/bash scripts/test.sh
# Hard premise of this job: stock /bin/bash must be 3.2. A print alone
# would let the suite silently run under a different bash if the runner
# image ever ships something else.
- run: |
/bin/bash --version
/bin/bash -c '[[ ${BASH_VERSINFO[0]} -eq 3 && ${BASH_VERSINFO[1]} -eq 2 ]]'
/bin/bash scripts/test.sh
66 changes: 42 additions & 24 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ answer.
- `git` — all work happens on a `cleanup/YYYYMMDD` branch, never on main. With
no git repository the skill only diagnoses: its rollback depends on having a
good commit to go back to.
- For JS/TS projects: Node with `npx` (knip runs via `npx knip`, no prior
installation).
- For JS/TS projects: Node with `npx` (knip runs via `npx knip@6.32.0`,
pinned — never bare `npx knip`).
- Other stacks use the tools of each ecosystem (vulture, deadcode,
cargo-udeps, ReferenceTrimmer). Whatever is missing, the skill reports
instead of installing on its own.
instead of installing on its own; `pip install` only with confirmation.
- The gate (`scripts/gate.sh`) detects the stack from the manifest and runs
typecheck + tests for JS/TS, Go, Rust, Python, JVM, Ruby and .NET. The
toolchain has to be reachable: on PATH for most stacks and, for Python, also
Expand Down Expand Up @@ -111,7 +111,7 @@ exercises the real GNU `timeout` instead of the perl backend:
```bash
docker run --rm -v "$PWD":/repo:ro node:22-bookworm bash -c \
'apt-get update -qq && apt-get install -y -qq procps && cd /repo && bash scripts/test.sh'
# validated 2026-08: 86/86 cases, 5/5 properties, 77/77 invariants
# validated 2026-08: 127/127 cases, 5/5 properties, 151/151 invariants
```

The .NET heuristic was validated against the real SDK
Expand Down Expand Up @@ -155,9 +155,13 @@ into one of three levels:

| Level | Condition | What it does |
|---|---|---|
| GREEN | typecheck and tests pass | runs the phases without asking; phase 2 stops at the checkpoint |
| YELLOW | partial net, or no test file in the stack | only deps and orphan files, no touching exports |
| RED | no tests and no typecheck, or a baseline already failing | diagnoses only; nothing is deleted |
| GREEN | typecheck and tests pass | runs phase 1 without asking; phase 2 and phase 3 stop at the human checkpoint |
| YELLOW | partial net, or no test file in the stack | only deps and orphan files, no touching exports; does not run phase 2 or phase 3 |
| RED | no tests and no typecheck, or a baseline already failing | diagnoses only; nothing is deleted; no `CLEANUP_PROGRESS` commit |

Stack caps in `references/other-stacks.md` override the GREEN column (Python
always confirms before deleting; JVM/Ruby/.NET code stay YELLOW or diagnosis
by default).

A project that arrives with a red suite falls into RED, not YELLOW: with a
broken baseline there is no telling what the cleanup broke from what was
Expand All @@ -166,18 +170,26 @@ happen. The skill names the failing check and stops there.

A stack with no test file at all does not count as tested: the gate does not
count an empty suite, whether it declined to run it or ran it and got nothing
back, and the level stays at YELLOW. That covers Go and .NET
with no test file, a Rust crate with no `tests/*.rs` and no `#[test]`, a Maven
or Gradle build with no `src/test` anywhere, and a pytest run that exits 5
having collected nothing. A manifest carried for tooling and nothing else — a
`requirements.txt` for the docs build, a `Gemfile` for fastlane — is not a stack
without a suite: with no source of that language in the repo, the gate says
nothing about it. If your suite lives outside the usual place, promoting it is
your call — the gate never promotes itself.

In JS/TS the same cap covers a sliced suite: with no `test` script and both
`test:unit` and `test:e2e` in the manifest, no slice answers for the whole
suite and the gate counts none of them. Promoting by hand is the wrong move
back, and the level stays at YELLOW. That covers JS/TS whose runner exits on an
empty suite ("No test files found") — including when it exits 0 because it was
told to, as with `--passWithNoTests`, since exit 0 is not proof a suite ran —,
Go and .NET with no test file, a Rust
crate with no `tests/*.rs` and no `#[test]`, a Maven or Gradle build with no
`src/test` anywhere, a Ruby `spec/` or `test/` holding no `*_spec.rb`,
`*_test.rb` or `test_*.rb` (the `Rake::TestTask` default), and a pytest run
that exits 5 having collected nothing. A
manifest carried for tooling and nothing else — a `requirements.txt` for the
docs build, a `Gemfile` for fastlane — is not a stack without a suite: with no
source of that language in the repo, the gate says nothing about it. If your
suite lives outside the usual place, promoting it is your call — the gate never
promotes itself.

In JS/TS the exact `npm init` placeholder (`echo "Error: no test specified" &&
exit 1`) is also YELLOW, with `'test' not counted` and the `npm init
placeholder` marker — not RED for a broken suite. The same cap covers a sliced
suite: with no `test` script and both `test:unit` and `test:e2e` in the
manifest, no slice answers for the whole suite and the gate counts none of
them. Promoting by hand is the wrong move
here, because the suite is not somewhere else, it is split; run every slice. A
lone slice does count as the suite, with one exception: watch mode never exits,
so the gate skips it. `watch`, `ui` and `debug` are read as whole segments of
Expand All @@ -196,8 +208,9 @@ With the level announced, it creates the cleanup branch and proceeds:

- **Phase 1 — dead code.** Configures knip until the hints reach zero, runs
in production mode and deletes in atomic commits, one per category: unused
deps, orphan files, dead exports. Each commit only lands with a green gate.
At the end, it produces an audit of what is left.
deps, orphan files, dead exports. Each step stages only pathspecs of that
step's artifacts (`git add -- …`, never `git add -A`), and only lands with a
green gate. At the end, it produces an audit of what is left.
- **Phase 1.5 — duplicate functions** (closes phase 1). Sweeps for functions
with different names doing the same thing (similarity-ts or fallow on
JS/TS, jscpd on other stacks) and applies the churn rule: a pair that
Expand Down Expand Up @@ -229,13 +242,16 @@ git revert <sha> # undoes only that category
Merging the branch is your decision, on your schedule. The skill never
pushes, never commits on main and never uses `git reset --hard` — its
rollback is `git restore --staged --worktree .`, which throws away everything
that has not been committed yet and coexists with hooks that block destructive
commands.
that has not been committed yet. If a security hook blocks that restore, the
skill **aborts** the pipeline (it does not work around the hook): it reports
the branch, the dirty tree and the manual command, then stops.

Note the "everything": a change of yours sitting in the working tree before the
skill started would go with it. That is why it demands a clean tree up front
and stops to ask when it does not find one — with a clean tree, what the
rollback discards is what the skill itself created.
rollback discards is what the skill itself created. Pathspec staging (instead
of `git add -A`) keeps drafts and local `.env` files out of the category
commit.

## Known limits

Expand All @@ -251,6 +267,8 @@ rollback discards is what the skill itself created.
suite, it is to fix the check the report names.
- Exit 124 is reserved for the watchdog, exactly as in GNU `timeout`: a
check that legitimately exits 124 under an active watchdog reads as TIMEOUT.
Exit 137 reads the same way while the watchdog runs with `-k`, since that is
the code the kill-after escalation produces against a check that ignores TERM.
- With a single `.sln`/`.slnx` at the root the gate passes it explicitly to
`dotnet`; with two or more it abstains and invokes with no argument, and
the ambiguity is MSBuild's again. It fails closed: run the gate by hand
Expand Down
66 changes: 42 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ commitar o que está pendente ou abortar, e nada acontece antes da sua resposta.
- `git` — todo o trabalho acontece numa branch `cleanup/YYYYMMDD`, nunca na
main. Sem repositório git a skill só diagnostica: o rollback dela depende de
ter um commit bom para onde voltar.
- Para projetos JS/TS: Node com `npx` (o knip roda via `npx knip`, sem
instalação prévia).
- Para projetos JS/TS: Node com `npx` (o knip roda via `npx knip@6.32.0`,
versão pinada — nunca `npx knip` sem versão).
- Outros stacks usam as ferramentas de cada ecossistema (vulture, deadcode,
cargo-udeps, ReferenceTrimmer). O que faltar, a skill aponta em vez de
instalar por conta.
instalar por conta; `pip install` só com confirmação.
- O gate (`scripts/gate.sh`) detecta o stack pelo manifesto e roda typecheck +
testes em JS/TS, Go, Rust, Python, JVM, Ruby e .NET. O toolchain precisa
estar alcançável: no PATH na maioria dos stacks e, em Python, também vale
Expand Down Expand Up @@ -108,7 +108,7 @@ exercita o GNU `timeout` real em vez do backend perl:
```bash
docker run --rm -v "$PWD":/repo:ro node:22-bookworm bash -c \
'apt-get update -qq && apt-get install -y -qq procps && cd /repo && bash scripts/test.sh'
# validado em 08/2026: 86/86 casos, 5/5 propriedades, 77/77 invariantes
# validado em 08/2026: 127/127 casos, 5/5 propriedades, 151/151 invariantes
```

A heurística .NET foi validada contra o SDK real (`mcr.microsoft.com/dotnet/sdk:8.0`
Expand Down Expand Up @@ -152,9 +152,13 @@ e se classifica em um de três níveis:

| Nível | Condição | O que ela faz |
|---|---|---|
| GREEN | typecheck e testes passam | executa as fases sem perguntar; a fase 2 para no checkpoint |
| YELLOW | rede parcial, ou nenhum arquivo de teste no stack | só deps e arquivos órfãos, sem mexer em exports |
| RED | sem testes e sem typecheck, ou baseline já vermelho | só diagnostica; nada é deletado |
| GREEN | typecheck e testes passam | executa a fase 1 sem perguntar; fase 2 e fase 3 param no checkpoint humano |
| YELLOW | rede parcial, ou nenhum arquivo de teste no stack | só deps e arquivos órfãos, sem mexer em exports; não roda fase 2 nem fase 3 |
| RED | sem testes e sem typecheck, ou baseline já vermelho | só diagnostica; nada é deletado; não commit de `CLEANUP_PROGRESS` |

Os caps por stack em `references/other-stacks.md` sobrescrevem a coluna GREEN
(Python confirma antes de deletar; JVM/Ruby/.NET código ficam em YELLOW ou só
diagnóstico por padrão).

Projeto que já chega com a suíte vermelha cai em RED, não em YELLOW: com o
baseline quebrado não dá para separar o que a limpeza quebrou do que já estava
Expand All @@ -163,18 +167,26 @@ skill diz qual check falhou e para por aí.

Stack sem nenhum arquivo de teste não conta como testado: o gate não conta
suíte vazia, seja porque não a rodou, seja porque rodou e não voltou nada, e o
nível fica em YELLOW. Vale para Go e .NET sem arquivo de
teste, para crate Rust sem `tests/*.rs` nem `#[test]`, para build Maven ou
Gradle sem nenhum `src/test`, e para pytest que sai 5 sem coletar nada.
Manifesto que está ali só por ferramenta — um `requirements.txt` do build da
documentação, um `Gemfile` do fastlane — não é stack sem suíte: sem código
daquela linguagem no repositório, o gate não fala dele. Se a sua suíte mora
fora do lugar padrão, a promoção é sua — o gate não se promove sozinho.

Em JS/TS o mesmo cap pega a suíte fatiada: sem script `test`, com `test:unit`
e `test:e2e` no manifesto, nenhuma fatia responde pela suíte inteira e o gate
não conta nenhuma delas. Promover à mão aqui é o caminho errado, porque a
suíte não está fora do lugar, está dividida; rode as fatias todas. Uma fatia
nível fica em YELLOW. Vale para JS/TS cujo runner sai com suíte vazia
("No test files found") — inclusive quando ele sai 0 porque mandaram, como em
`--passWithNoTests`, já que exit 0 não é prova de que uma suíte rodou —,
Go e .NET sem arquivo de teste, crate Rust sem
`tests/*.rs` nem `#[test]`, build Maven ou Gradle sem nenhum `src/test`, Ruby
cujo `spec/` ou `test/` não guarda nenhum `*_spec.rb`, `*_test.rb` nem
`test_*.rb` (o padrão do `Rake::TestTask`), e pytest
que sai 5 sem coletar nada. Manifesto que está ali só por ferramenta —
um `requirements.txt` do build da documentação, um `Gemfile` do fastlane —
não é stack sem suíte: sem código daquela linguagem no repositório, o gate
não fala dele. Se a sua suíte mora fora do lugar padrão, a promoção é sua —
o gate não se promove sozinho.

Em JS/TS o placeholder exato do `npm init` (`echo "Error: no test specified"
&& exit 1`) também cai em YELLOW com a linha `'test' not counted` e o marcador
`npm init placeholder` — não é RED de suíte quebrada. O mesmo cap pega a suíte
fatiada: sem script `test`, com `test:unit` e `test:e2e` no manifesto, nenhuma
fatia responde pela suíte inteira e o gate não conta nenhuma delas. Promover
à mão aqui é o caminho errado, porque a suíte não está fora do lugar, está
dividida; rode as fatias todas. Uma fatia
sozinha vale como a suíte, com uma exceção: modo watch nunca termina, então o
gate não o executa. `watch`, `ui` e `debug` são lidos como segmentos inteiros
do nome, o que pega `test:watch:all` e deixa `test:watchdog` em paz. Só que não
Expand All @@ -192,8 +204,9 @@ Com o nível anunciado, ela cria a branch de limpeza e segue:

- **Fase 1 — código morto.** Configura o knip até os hints zerarem, roda em
modo produção e deleta em commits atômicos, um por categoria: deps não
usadas, arquivos órfãos, exports mortos. Cada commit só entra com gate
verde. No fim, produz uma auditoria do que sobrou.
usadas, arquivos órfãos, exports mortos. Cada passo faz stage só com
pathspecs dos artefatos daquele passo (`git add -- …`, nunca `git add -A`),
e só entra com gate verde. No fim, produz uma auditoria do que sobrou.
- **Fase 1.5 — funções duplicadas** (fecha a fase 1). Varre funções com nomes
diferentes fazendo a mesma coisa (similarity-ts ou fallow em JS/TS, jscpd
nos demais stacks) e aplica a regra do churn: par que muda junto no git é
Expand Down Expand Up @@ -224,12 +237,15 @@ git revert <sha> # desfaz só aquela categoria
O merge da branch é decisão sua, no seu tempo. A skill nunca faz push, nunca
commita na main e nunca usa `git reset --hard` — o rollback dela é
`git restore --staged --worktree .`, que joga fora tudo o que ainda não foi
commitado e convive com hooks que bloqueiam comandos destrutivos.
commitado. Se um hook de segurança bloquear esse restore, a skill **aborta**
o pipeline (não contorna o hook): reporta a branch, a árvore suja e o comando
manual, e para.

Note o "tudo": alteração sua que estava no diretório antes de a skill começar
entraria nessa conta. É por isso que ela exige árvore limpa no início e
interrompe para perguntar quando não está — com a árvore limpa, o que o
rollback joga fora foi ela mesma que criou.
rollback joga fora foi ela mesma que criou. O stage por pathspec (em vez de
`git add -A`) evita engolir rascunhos e `.env` locais no commit da categoria.

## Limites conhecidos

Expand All @@ -244,7 +260,9 @@ rollback joga fora foi ela mesma que criou.
passo é criar uma verificação mínima; com a suíte quebrada, é consertar o
check que o relatório nomeia.
- Exit 124 é reservado ao watchdog, igual ao GNU `timeout`: um check que
legitimamente sai 124 sob watchdog ativo é lido como TIMEOUT.
legitimamente sai 124 sob watchdog ativo é lido como TIMEOUT. Exit 137 vale
o mesmo enquanto o watchdog roda com `-k`, porque é o código que a escalada
kill-after produz contra um check que ignora TERM.
- Com uma única `.sln`/`.slnx` na raiz o gate a passa explícita ao `dotnet`;
com duas ou mais ele se abstém e invoca sem argumento, e a ambiguidade
volta a ser do MSBuild. Falha fechada: rode o gate manual apontando a
Expand Down
Loading
Loading