From 025f15210d4517fea27f0f7faa264b9e89904ca0 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 19:30:59 -0300 Subject: [PATCH 01/12] fix(refs): ranking de churn sem contar hash nem linha em branco O pipeline prescrito usava --format=%H --name-only, que joga o hash do commit e a linha separadora no mesmo fluxo dos nomes de arquivo. O uniq -c contava os tres como se fossem arquivos: a linha em branco liderava o ranking e cada SHA empatava em 1 com os arquivos raros. audit.md pedia os '20 mais modificados' sem dar receita nenhuma, entao recebeu o mesmo one-liner corrigido em vez do convite a rederivar o quebrado. Closes #17 --- references/audit.md | 10 ++++++---- references/phase-2-consolidation.md | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/references/audit.md b/references/audit.md index 2a2b0c8..5591c2b 100644 --- a/references/audit.md +++ b/references/audit.md @@ -17,10 +17,12 @@ paragraph mental model of the architecture as it actually is — you built most of it while configuring the phase 1 dead-code tool. If the model contradicts the README, the contradiction is itself a finding. -Pull churn data: `git log --stat --since="6 months ago"`. Intersect the 20 -largest files with the 20 most modified — that intersection is where debt -usually hides, and it is what separates "actually has debt" from "just looks -messy". +Pull churn data: `git log --stat --since="6 months ago"`, ranking the most +modified files with +`git log --no-merges --format= --name-only | sed '/^$/d' | sort | uniq -c | sort -rn`. +Intersect the 20 largest files with the 20 most modified — that intersection +is where debt usually hides, and it is what separates "actually has debt" +from "just looks messy". ## The nine dimensions diff --git a/references/phase-2-consolidation.md b/references/phase-2-consolidation.md index d9a0630..eac5856 100644 --- a/references/phase-2-consolidation.md +++ b/references/phase-2-consolidation.md @@ -48,11 +48,12 @@ Imagine deleting the module. set — ask for it explicitly with `npx knip --cycles` (shortcut for `--include cycles`), or a plain `npx knip` will report nothing about circular dependencies. -2. **Cross with churn volume (file-level).** `git log --format=%H - --name-only | sort | uniq -c | sort -rn` — the intersection between +2. **Cross with churn volume (file-level).** The intersection between "changed a lot" and "heavily coupled" is where consolidation pays the - most. This is a different, weaker signal than the pair-level co-change of - step 0: it says a file is hot, not that two files move together. + most. Rank the files by + `git log --no-merges --format= --name-only | sed '/^$/d' | sort | uniq -c | sort -rn`. + This is a different, weaker signal than the pair-level co-change of step + 0: it says a file is hot, not that two files move together. 3. **Actually read the candidate clusters.** Do not judge by file name. 4. **Rank by confidence**, not by size. From a3fa74886d8c16c31a6bbec8b1d02ceff1257ba5 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 19:31:05 -0300 Subject: [PATCH 02/12] fix(fase-1): relatorio do knip nao e mais truncado antes de rodar O redirecionamento direto abria e truncava knip-report.json antes de o knip comecar, entao qualquer falha (config quebrada, plugin, OOM) apagava o relatorio anterior e deixava um arquivo vazio, que o passo 1.3 le como 'nada a deletar'. Falha silenciosa em vez de erro visivel. Passa a escrever em .tmp e mover so em caso de sucesso. --no-exit-code e o que torna o && utilizavel: knip sai 1 sempre que encontra algo, que e o caso normal aqui, e 2 so quando de fato falhou. Comando byte a byte igual nos dois arquivos. Closes #21 --- SKILL.md | 12 +++++++++++- references/knip-config.md | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index df8ce7b..a36d8bf 100644 --- a/SKILL.md +++ b/SKILL.md @@ -246,9 +246,19 @@ graph (entry, project, paths, plugin), not to silence the output. ## 1.2 Run in production mode ```bash -npx knip --production --reporter json > knip-report.json +npx knip --production --no-exit-code --reporter json > knip-report.json.tmp && mv knip-report.json.tmp knip-report.json ``` +Write to a temp file and move only on success. A plain `> knip-report.json` +truncates the file before knip even starts, so a crash leaves an empty report +that 1.3 reads as "nothing to delete" — a silent failure. `--no-exit-code` is +what makes the `&&` usable: knip exits 1 whenever it finds issues, which is the +normal case here, and 2 only when it actually failed. + +Check the report before 1.3 consumes it: `test -s knip-report.json` and it has +to parse as JSON. If it does not, knip failed — fix that instead of proceeding +with an empty list. + Production mode excludes tests and devDependencies automatically. That matters because a function imported only by a test is technically alive, but it is dead as far as the application is concerned — and that is exactly the code you want diff --git a/references/knip-config.md b/references/knip-config.md index 22b9233..b0dc6a2 100644 --- a/references/knip-config.md +++ b/references/knip-config.md @@ -24,7 +24,13 @@ names. Adjust the `$schema` version below to the major in use. 2. Resolve **every** configuration hint 3. Only then write/adjust `knip.json` 4. Repeat 2–3 until the hints reach zero -5. `npx knip --production --reporter json > knip-report.json` +5. `npx knip --production --no-exit-code --reporter json > knip-report.json.tmp && mv knip-report.json.tmp knip-report.json` +6. Check the result before using it: non-empty file that parses as JSON + +The temp file plus `&&` is what keeps a crashed run from wiping the previous +report — a plain `>` truncates it before knip starts. `--no-exit-code` is +required there because knip exits 1 on every run that finds issues; only exit +2 means it failed. The right mindset: when knip reports something unexpected, it is telling the truth about the module graph — it could not reach that code from an entry. A From 08d02b8dc97e6a411cc1a935b630588d52f84840 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 19:38:39 -0300 Subject: [PATCH 03/12] fix(fase-1): knip re-rodado entre categorias e install depois de podar deps As tres categorias liam um knip-report congelado. Elas se alimentam: apagar arquivo orfao mata export que o relatorio ainda via vivo e libera dep que via usada, e export listado como morto pode estar em arquivo ja removido pela categoria anterior. O ciclo passa a regenerar o relatorio depois do commit de cada categoria, e a contagem final vira estado medido. Tirar a entrada do package.json nao tira o pacote do node_modules e o gate nunca instala, entao typecheck e teste passavam com a dep removida por engano e a quebra so aparecia na CI. O protocolo passa a rodar o install simples do package manager antes do gate, com o lockfile entrando no commit da categoria. Forma congelada fica de fora: --frozen-lockfile e --immutable recusam manifesto podado, e npm ci deixa o lockfile obsoleto (medido em npm 11 e 12: sai 0, mas nao reescreve o lock). gate.sh nao muda. Inclui o mkdir -p no exemplo de git mv da fase 3 (parte de #18), para casar com references/phase-3-structure.md. Closes #19, closes #20 --- SKILL.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/SKILL.md b/SKILL.md index a36d8bf..e4387bc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -269,7 +269,8 @@ Never exclude tests with `ignore` to get the same effect. ## 1.3 Delete in atomic commits, one per category Run all three without asking (GREEN level) or the first two (YELLOW). Each one -is: delete → `git add -A` → gate → commit. For the gate, use `scripts/gate.sh` +is: delete → `git add -A` → gate → commit → regenerate the report. For the +gate, use `scripts/gate.sh` (it detects the stack and the package manager and runs typecheck + tests in the right order); if it exits with code 3, run the stack's equivalent commands by hand. @@ -288,12 +289,51 @@ of the user's. Kept separate because if something breaks in production two weeks from now, the user needs to revert *one* commit — not a 400-file cleanup. +**Unused deps: install after pruning the manifest.** Removing an entry from +`package.json` does not remove the package from `node_modules`, and the gate +never installs — the resolver still finds the package on disk, typecheck and +tests pass, and the break only surfaces on CI or on the next machine that +installs from the pruned manifest. So, after editing the manifest and before +`git add -A`, run the package manager's plain install: + +```bash +npm install # npm +pnpm install --no-frozen-lockfile # pnpm +yarn install --no-immutable # yarn berry (yarn 1: yarn install) +bun install # bun +``` + +Plain, never the frozen form. `--frozen-lockfile`, `--immutable` and the CI +defaults that turn them on refuse a lockfile that no longer matches the +manifest — which is exactly the state a correct prune produces, so a good +deletion would come back as a red gate for the wrong reason. `npm ci` accepts +the removal but never writes the lockfile, leaving a stale one in the commit. +The updated lockfile goes in this category's commit: it is what carries the +prune to every other machine. + +**Regenerate the report between categories.** After each category's commit, run +the 1.2 command again — same hardened form, same file — and read the next +category from the fresh report. The three feed each other: deleting orphan +files kills exports the old report still saw as alive and frees deps it saw as +used, while some exports it lists as dead live in files the previous category +already removed. On a frozen report those second-order items survive the +cleanup and a category tries to edit paths that no longer exist. The cost is +two extra knip runs per cleanup. + +A category that is skipped (YELLOW does not run exports) or that fails its gate +leaves no commit and nothing changed on disk, so there is nothing to regenerate +from — keep the current report and go to the next category. The regeneration +after the last category that did commit is the one the final report counts +against. + **If the gate fails:** `git restore --staged --worktree .`, record the category as failed in `CLEANUP_PROGRESS.md` along with the error, and **move on to the next category**. Do not stop the entire pipeline and do not try to fix it — if typecheck broke, knip was wrong about that category, and the useful information is which -category, not a patch. +category, not a patch. On the deps category, that restore brings back +`package.json` and the lockfile but not `node_modules`: run the install again +before starting orphan files. Do not run `knip --fix` until the config has settled for two or three rounds with no surprises. @@ -420,6 +460,7 @@ structure with a rationale. Only then execute. GREEN level runs the whole plan without asking. One folder per commit: ```bash +mkdir -p src/features/billing git mv src/utils/format.ts src/features/billing/format.ts # always git mv ``` @@ -489,5 +530,11 @@ Branch: `cleanup/YYYYMMDD` · Level: GREEN · N commits - (nothing) ``` +The phase 1 line counts what each category actually removed, tallied per commit +from the report that category ran on — not the numbers of the first report, +which stopped describing the repo the moment the first commit landed. The last +regeneration settles the rest: whatever it still lists is what survived, and it +belongs under "Failed / not done", along with any category that was skipped. + If the level was RED, the report is diagnosis only: list what you would do and what needs to exist (tests, typecheck) to make it possible. From e3e1dd8bd5ee53e4fb1c18c6328f016980158db1 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 19:38:54 -0300 Subject: [PATCH 04/12] fix(fase-3): git mv precedido de mkdir -p, com invariante O exemplo canonico de movimentacao nao criava o diretorio de destino, e a fase 3 existe justamente para aplicar uma estrutura que ainda nao existe: git mv para diretorio inexistente morre com exit 128, e o agente rodando sem supervisao le isso como passo que falhou, ou parte para mv + git rm, que a propria doc proibe por atrapalhar a deteccao de rename. Secao 6 do coherence_test amarra as duas coisas: todo arquivo de protocolo com linha de comando git mv precisa do mkdir -p na linha imediatamente acima. Mencao em prosa nao conta (ancora em ^git mv). Piso derivado no mesmo estilo da secao 4. O item nao entrou na lista 'Do not forget': aquela lista e sobre o que aponta para caminho antigo e quebra em silencio; diretorio faltando aborta alto, antes de mover, e e lido depois do fato. Ficou junto do comando. Closes #18 --- references/phase-3-structure.md | 8 ++++++++ scripts/coherence_test.sh | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/references/phase-3-structure.md b/references/phase-3-structure.md index aa0f75a..b1e1586 100644 --- a/references/phase-3-structure.md +++ b/references/phase-3-structure.md @@ -56,9 +56,17 @@ return: anyone new understands it without explanation. One folder per commit. Always: ```bash +mkdir -p src/features/billing git mv src/utils/format.ts src/features/billing/format.ts ``` +The `mkdir -p` is not decoration: phase 3 applies a structure that does not +exist yet, and moving into a directory nobody created fails with `fatal: +renaming ... failed: No such file or directory` and exit 128. That is a +missing precondition, not a failed step — create the directory and repeat the +move. Do not fall back to `mv` plus `git rm`/`git add`: that is `rm` + +`create` under another name. + `git mv` preserves history. `rm` + `create` destroys that file's `git blame` — exactly the information someone will want six months from now when asking "why is this like this". diff --git a/scripts/coherence_test.sh b/scripts/coherence_test.sh index 7c31951..3142b4c 100755 --- a/scripts/coherence_test.sh +++ b/scripts/coherence_test.sh @@ -283,6 +283,40 @@ for readme in README.md README.en.md; do done done +# 6. Whoever shows a `git mv` also shows the mkdir -p it needs. --------------- +# Phase 3 applies a structure that does not exist yet, so the destination +# directory has to be created before the move: `git mv` into a missing +# directory dies with exit 128, and an agent running unsupervised reads that +# as a failed step. Only command lines count — a line that *starts* with +# `git mv` — so the prose mentions in the READMEs and in the explanatory +# paragraphs, where the phrase is named and not run, stay out of it. +gitmv_files() { + grep -l -E '^git mv ' SKILL.md references/*.md 2>/dev/null | sort +} + +moves=$(gitmv_files) + +# Floor, same reason as in section 4: a derivation that comes back without the +# two files that carry the move today is broken, and the loop below would +# assert over an empty list and pass. +for f in SKILL.md references/phase-3-structure.md; do + if printf '%s\n' "$moves" | grep -qx -F -- "$f"; then + pass "$f is derived as a file that runs git mv" + else + fail "$f is derived as a file that runs git mv" \ + "no git mv command line — derived list: $(printf '%s' "$moves" | tr '\n' ' ')" + fi +done + +# The mkdir has to be on the line right above, not merely somewhere in the +# file: what is being read is a snippet, not a page. +for f in $moves; do + orphan=$(awk '/^git mv / && prev !~ /^mkdir -p / { print FILENAME ":" FNR ": " $0 } + { prev = $0 }' "$f") + check "git mv preceded by mkdir -p in $f" \ + "$([[ -z $orphan ]] && echo 0 || echo 1)" "$orphan" +done + echo "----" echo "$((total-failures))/$total invariants held" [[ $failures -eq 0 ]] From 5f35d1457dfc362c485f7cbcae69e7da96f62ec1 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 19:38:54 -0300 Subject: [PATCH 05/12] feat(gate): aceitar apelidos de script no stack JS/TS O gate so reconhecia scripts com nome exato typecheck e test, entao projeto com type-check ou test:unit caia em exit 3 e exigia gate manual mesmo tendo rede de seguranca completa. Falhava fechado, nunca dava verde falso, mas custava a autonomia que a skill promete. Passa a aceitar typecheck/type-check/tsc/check-types e test/test:unit, com o primeiro nome definido pelo projeto vencendo e os demais ignorados. O que chega em run() e o KIND, nunca o apelido: run() classifica por kind, e nome cru ali faria o check rodar, passar e nao contar, caindo em exit 3 do mesmo jeito. checks= mantem o vocabulario canonico; o apelido aparece so na linha humana. bash 3.2, sem arrays. 6 casos novos no gate_test (57 -> 63), incluindo o ramo yarn com lockfile, que ate agora nao tinha teste nenhum. Closes #23 --- scripts/gate.sh | 25 ++++++++++++++++----- scripts/gate_test.sh | 53 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/scripts/gate.sh b/scripts/gate.sh index b0c2bec..6f50bd9 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -242,11 +242,26 @@ if [[ -f package.json ]]; then echo "[gate] package.json unparseable — JS/TS checks skipped" >&2 incomplete=1 else - for script in typecheck test; do - if node -e "const s=require('./package.json').scripts||{};process.exit(s['$script']?0:1)" 2>/dev/null; then - if [[ $PM == yarn ]]; then run "$script" yarn "$script" - else run "$script" "$PM" run "$script"; fi - fi + # Each entry is ":