From 7a9d460e4a7142909ad3279bced863b6587ebd1c Mon Sep 17 00:00:00 2001 From: decobot Date: Tue, 18 Aug 2026 14:05:02 -0300 Subject: [PATCH 1/3] feat(reconcile): per-file snapshot diff of upstream drift since the migration cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrations run for weeks. The Fresh/Deno source repo keeps shipping while the migration team hand-fixes what the codemod got wrong, so upstream changes silently never reach the migrated TanStack repo. New `deco-reconcile` bin: diffs BASE_SHA..SOURCE_HEAD on the source and emits ONE patch per file, plus the context needed to port each one — candidate target paths (basename match over the target tree, ranked by the conventional guess) and, per candidate, the target commits since the migration commit. A non-empty collision list means someone hand-fixed that file: reconcile hunk by hunk instead of applying the upstream change whole. The script makes no judgement and writes nothing to the target. Applying is the agent's job under the new `deco-reconcile-snapshot` skill, which carries the rebase-not-re-migration protocol: learn the translation rules from the cut↔migration commit pair, resolve collisions by asking whether the local fix exists because of the new stack or because of a defect upstream already fixed, sweep applied CSS for the selector-token damage a previous codemod caused, and stop-and-report when collisions exceed one human review sitting. `manifest.json` doubles as resume state (`done` per file). CMS content (`.deco/`) is filtered out — it syncs through its own channel. Co-Authored-By: Claude Opus 5 (1M context) --- .../skills/deco-reconcile-snapshot/SKILL.md | 137 ++++++++++ packages/blocks-cli/package.json | 3 +- packages/blocks-cli/scripts/reconcile.test.ts | 135 ++++++++++ packages/blocks-cli/scripts/reconcile.ts | 251 ++++++++++++++++++ 4 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/deco-reconcile-snapshot/SKILL.md create mode 100644 packages/blocks-cli/scripts/reconcile.test.ts create mode 100644 packages/blocks-cli/scripts/reconcile.ts diff --git a/.agents/skills/deco-reconcile-snapshot/SKILL.md b/.agents/skills/deco-reconcile-snapshot/SKILL.md new file mode 100644 index 00000000..2915c787 --- /dev/null +++ b/.agents/skills/deco-reconcile-snapshot/SKILL.md @@ -0,0 +1,137 @@ +--- +name: deco-reconcile-snapshot +description: Reconcile a migrated TanStack repo against upstream changes that landed on the Fresh/Deno source repo after the migration cut. Runs `deco-reconcile` to produce one diff per changed file, then ports each one by hand — a rebase, not a re-migration. Use when a migration has been running for weeks and the source repo kept moving, or when the user says "reconcile", "trazer as mudanças de origem", "o Deno mudou desde o corte", "snapshot diff", or "sincronizar com a origem". +--- + +# Reconciliação de snapshot + +Você reconcilia um repositório migrado contra novas mudanças do repositório de origem. + +Entre o commit de corte e hoje, duas coisas aconteceram em paralelo: o time de +origem continuou desenvolvendo, e o time de migração corrigiu à mão o que o +codemod cuspiu errado. Seu trabalho é trazer a primeira sem destruir a segunda. + +É um **rebase**, não uma re-migração. Rodar o codemod sobre a árvore inteira é +sempre a resposta errada: sobrescreve correção manual e reintroduz defeito já +consertado. + +## Passo 0 — gerar o insumo + +```bash +npx -p @decocms/blocks-cli deco-reconcile \ + --source --base \ + --target --target-base \ + --verbose +``` + +Saída em `/.reconcile//`: + +- `manifest.json` — a lista de trabalho **e** o estado de retomada +- `INDEX.md` — a mesma tabela, para humano +- `patches/NNN-.patch` — um diff por arquivo + +O script não escreve nada no alvo e não emite julgamento. `targetCandidates` é +palpite (basename + convenção), não mapeamento — confirme. + +Se `--base` não for conhecido: é o `SOURCE_HEAD` do relatório da rodada anterior. +Primeira rodada, é o commit de origem em que a migração foi feita. + +## O loop + +Um arquivo por vez, na ordem do manifest. Delegue um subagente por arquivo — o +patch, os candidatos e o log de colisão cabem num prompt. Ao terminar um arquivo, +marque `done: true` no `manifest.json`; é assim que a sessão retoma. + +**Gate, antes de aplicar qualquer coisa:** conte as entradas com +`collision.length > 0`. Se passar do que cabe em revisão humana numa sentada, +**pare e reporte**. Reconciliação grande demais para revisar é sinal de que falta +data de convergência combinada com o cliente — é conversa, não problema para +resolver aqui. + +Para cada arquivo, na ordem: + +**1. Onde ele cai no alvo?** O layout mudou na migração. Confira os +`targetCandidates` contra a árvore do alvo; se não bater, descubra o mapeamento +comparando `BASE_SHA` com o commit de migração do alvo — não presuma convenção. +Lista vazia normalmente é arquivo novo: migre inteiro. + +**2. Alguém já mexeu nele?** `collision` já responde (`git log` do alvo, do commit +de migração para cá). Vazio, o caminho é livre. Não-vazio, você está numa colisão +e precisa entender por quê antes de aplicar qualquer linha. + +**3. Traduza.** Nenhuma linha da stack antiga chega crua ao alvo. + +**4. Aplique.** Em colisão, hunk a hunk. + +## Aprenda as regras de tradução antes de usá-las + +Não parta de lista decorada. Compare o commit de corte com o commit de migração +do alvo: o par mostra como **este projeto** converteu cada idioma. Extraia as +regras de lá e declare-as no relatório antes de aplicar. + +Classes que costumam aparecer — confirme cada uma no par antes de assumir: + +- reatividade e estado compartilhado, incluindo se ler valor no render ainda inscreve o componente +- efeito colateral em fase de render, legal na stack antiga e ilegal na nova +- `key` no elemento retornado por `.map()` +- detecção de browser, tipos de framework, imports de CDN, APIs de runtime +- o salto de versão do Tailwind: utilitários removidos, configuração que virou CSS-first +- APIs do framework antigo que viraram no-op no novo — modo de falha silencioso, então procure as que o alvo já reescreveu e trate igual + +Referência das regras já catalogadas: `.agents/skills/deco-to-tanstack-migration/` +(`references/gotchas.md` e o índice de learnings). Use como pista, não como +verdade — o par de commits deste projeto manda. + +## Colisão + +É onde você gasta o tempo. Para cada hunk, responda antes de aplicar: + +> A correção local existe por causa da stack nova, ou por causa de um defeito que +> a mudança upstream já resolve? + +Primeiro caso, a correção manda. Segundo, a mudança upstream manda. Leia a +mensagem do commit que corrigiu — ela vem no `collision` justamente porque +costuma dizer qual dos dois é. + +## Verifique a tradução, não confie nela + +Codemod já apagou tokens de seletor CSS em migração anterior: 40 fragmentos, uma +única palavra sumindo de `#id`, `label[for=""]` e combinadores, gerando regra +inválida que derrubava a declaração inteira. Ninguém tinha tocado no arquivo desde +a migração e o defeito sobreviveu semanas. + +Depois de aplicar, varra o que entrou procurando id ou classe começando com +hífen, atributo com valor vazio, combinador sem alvo, classe vazia, seletor que +não casa com nada. + +Rode a verificação do alvo — typecheck, testes, build. + +## Regras que não se negociam + +**Paridade é o critério de aceite.** Se a mudança upstream traz um bug, ele é +portado como está. Corrigir é decisão do dono do produto. Vale inclusive para bug +que você tem certeza de que é bug. + +**Migração não é refatoração.** Código que parece morto é migrado. Provar que está +morto custa análise, aprovação e risco; migrar custa perto de zero. Única exceção: +o que impede compilar — e aí a resposta é fazer compilar, não remover. + +**Conteúdo de CMS não entra aqui.** Blocos publicados chegam por sincronização +própria (o `deco-reconcile` já filtra `.deco/`). Arquivo removido upstream que é +referenciado por bloco: não remova, sinalize. + +**Não invente escopo.** Você aplica o que mudou entre os dois commits. Melhoria +que você enxergar vira nota, não commit. + +## Saída + +Relatório, antes de qualquer commit: + +1. `SOURCE_HEAD` — vira o `BASE_SHA` da próxima rodada +2. As regras de tradução que você extraiu do par de commits +3. Arquivos aplicados sem colisão: origem → alvo → regras usadas +4. **Uma seção por colisão**: hunk upstream, correção local, qual prevaleceu, por quê +5. O que exige decisão humana — arquivo removido com referência de CMS, mudança que depende de endpoint novo, conflito que você não resolveu com confiança +6. Verificações rodadas e resultado + +Nunca dê push no remoto do alvo sem confirmação explícita do usuário. diff --git a/packages/blocks-cli/package.json b/packages/blocks-cli/package.json index d4384e03..3c60674f 100644 --- a/packages/blocks-cli/package.json +++ b/packages/blocks-cli/package.json @@ -17,7 +17,8 @@ "deco-audit-observability": "./scripts/audit-observability-config.ts", "deco-migrate-blocks-to-kv": "./scripts/migrate-blocks-to-kv.ts", "deco-sync-blocks-to-kv": "./scripts/sync-blocks-to-kv.ts", - "deco-upgrade-6-to-7": "./scripts/upgrade-6-to-7.ts" + "deco-upgrade-6-to-7": "./scripts/upgrade-6-to-7.ts", + "deco-reconcile": "./scripts/reconcile.ts" }, "//exports": "Deliberately narrow. ./generate is the ONE public module entry (unified orchestrator); ./generate-blocks stays only because @decocms/tanstack's vite plugin tsImports it (readBlockDelta + programmatic generateBlocks) — each surviving entry must name its consumer here. The other scripts remain shipped FILES (the orchestrator spawns them; sites' existing `tsx node_modules/@decocms/blocks-cli/scripts/generate-*.ts` invocations keep working) but are internal implementation details of ./generate, not module subpaths. CLIs are exposed via bin, not exports.", "exports": { diff --git a/packages/blocks-cli/scripts/reconcile.test.ts b/packages/blocks-cli/scripts/reconcile.test.ts new file mode 100644 index 00000000..f68c67a4 --- /dev/null +++ b/packages/blocks-cli/scripts/reconcile.test.ts @@ -0,0 +1,135 @@ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + isSkipped, + parseNameStatus, + type ReconcileManifest, + targetCandidates, +} from "./reconcile"; + +describe("parseNameStatus", () => { + it("splits plain and rename entries", () => { + expect(parseNameStatus("M\tsections/A.tsx\nR094\tislands/B.tsx\tislands/C.tsx\n")) + .toEqual([ + { status: "M", path: "sections/A.tsx" }, + { status: "R094", path: "islands/C.tsx", oldPath: "islands/B.tsx" }, + ]); + }); +}); + +describe("isSkipped", () => { + it("drops CMS content, lockfiles and binaries but keeps source", () => { + expect(isSkipped(".deco/blocks/pages-home.json")).toBe(true); + expect(isSkipped("deno.lock")).toBe(true); + expect(isSkipped("static/logo.png")).toBe(true); + expect(isSkipped("sections/Header.tsx")).toBe(false); + }); +}); + +describe("targetCandidates", () => { + it("ranks the conventional guess first, keeps other basename matches", () => { + const byBasename = new Map([ + ["Cart.tsx", ["src/sections/Cart.tsx", "src/components/Cart.tsx"]], + ]); + expect(targetCandidates("islands/Cart.tsx", byBasename)).toEqual([ + "src/components/Cart.tsx", + "src/sections/Cart.tsx", + ]); + }); + + it("returns nothing when the file does not exist on the target", () => { + expect(targetCandidates("sections/New.tsx", new Map())).toEqual([]); + }); +}); + +describe("reconcile end to end", () => { + let tmp: string; + const git = (cwd: string, ...args: string[]) => + execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8" }).trim(); + + const write = (repo: string, rel: string, body: string) => { + fs.mkdirSync(path.join(repo, path.dirname(rel)), { recursive: true }); + fs.writeFileSync(path.join(repo, rel), body); + }; + const commit = (repo: string, msg: string) => { + git(repo, "add", "-A"); + git(repo, "commit", "-m", msg); + return git(repo, "rev-parse", "HEAD"); + }; + const init = (name: string) => { + const repo = path.join(tmp, name); + fs.mkdirSync(repo, { recursive: true }); + git(repo, "init", "-q", "-b", "main"); + git(repo, "config", "user.email", "t@t.t"); + git(repo, "config", "user.name", "t"); + return repo; + }; + + beforeEach(() => { + // realpath: macOS /var → /private/var, which git resolves and we compare against. + tmp = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "reconcile-")); + }); + afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + + it("emits one patch per changed file and flags hand-fixed targets", () => { + const source = init("source"); + write(source, "sections/Header.tsx", "export default () =>

a

;\n"); + write(source, "sections/Footer.tsx", "export default () =>