diff --git a/.agents/skills/deco-reconcile-snapshot/SKILL.md b/.agents/skills/deco-reconcile-snapshot/SKILL.md new file mode 100644 index 00000000..23a85d69 --- /dev/null +++ b/.agents/skills/deco-reconcile-snapshot/SKILL.md @@ -0,0 +1,142 @@ +--- +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 --target \ + --snapshot --verbose +``` + +Um hash só. `--target-snapshot` é opcional: por padrão o script acha o commit de +migração pelo `MIGRATION_REPORT.md` que o `deco-migrate` deixou, e imprime qual +escolheu. Passe explícito se a migração veio por squash ou rebase e o marcador +não bater — tudo depois desse commit conta como correção manual, então valor +errado esvazia a lista de colisão em silêncio. + +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 `--snapshot` 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 o commit do snapshot 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 `--snapshot` 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/migrate/delete-sets.ts b/packages/blocks-cli/scripts/migrate/delete-sets.ts new file mode 100644 index 00000000..06520e16 --- /dev/null +++ b/packages/blocks-cli/scripts/migrate/delete-sets.ts @@ -0,0 +1,75 @@ +/** + * The paths the migration deletes outright. Single source of truth: `decideAction` + * (phase-analyze) decides with them, and `deco-reconcile` filters with them — an + * upstream change to a file the migration deleted has no target equivalent, so + * reporting it just makes the agent consider migrating a file that must not exist. + * + * Only the unambiguous sets live here. Rules that are conditional on context + * (routes/ and apps/ are rescaffolded, root-level docs) stay in `decideAction`: + * reconcile must stay conservative, since dropping a real upstream change is a + * worse failure than showing one file too many. + */ + +/** Files that are generated and should be deleted */ +export const GENERATED_FILES = new Set([ + "fresh.gen.ts", + "manifest.gen.ts", + "fresh.config.ts", +]); + +/** SDK files that have framework equivalents or are scaffolded fresh */ +export const SDK_DELETE = new Set([ + "sdk/clx.ts", + "sdk/useId.ts", + // sdk/useOffer.ts — kept: sites often customize offer logic + // sdk/useVariantPossiblities.ts — kept: sites often customize variant logic + "sdk/usePlatform.tsx", + "sdk/signal.ts", + "sdk/format.ts", +]); + +/** Component files that are scaffolded fresh (old versions must not overwrite) */ +export const COMPONENT_DELETE = new Set([ + "components/ui/Image.tsx", + "components/ui/Picture.tsx", + "components/ui/Video.tsx", +]); + +/** Loaders that depend on deleted admin tooling */ +export const LOADER_DELETE = new Set([ + "loaders/availableIcons.ts", + "loaders/icons.ts", +]); + +/** Root config/infra files to delete */ +export const ROOT_DELETE = new Set([ + "main.ts", + "dev.ts", + "deno.json", + "deno.lock", + "tailwind.css", + "tailwind.config.ts", + "runtime.ts", + "constants.ts", + "fresh.gen.ts", + "manifest.gen.ts", + "fresh.config.ts", + "browserslist", + "bw_stats.json", + "islands.ts", +]); + +/** Static files that are code/tooling, not assets — should be deleted */ +export const STATIC_DELETE = new Set([ + "static/adminIcons.ts", + "static/generate-icons.ts", + "static/tailwind.css", +]); + +/** True when the migration deletes this source path outright. */ +export function isDeletedByMigration(relPath: string): boolean { + return GENERATED_FILES.has(relPath) || ROOT_DELETE.has(relPath) || + SDK_DELETE.has(relPath) || COMPONENT_DELETE.has(relPath) || + LOADER_DELETE.has(relPath) || STATIC_DELETE.has(relPath) || + relPath.startsWith("sdk/cart/") || relPath.startsWith("apps/deco/"); +} diff --git a/packages/blocks-cli/scripts/migrate/phase-analyze.ts b/packages/blocks-cli/scripts/migrate/phase-analyze.ts index cbf7b7bb..2629f563 100644 --- a/packages/blocks-cli/scripts/migrate/phase-analyze.ts +++ b/packages/blocks-cli/scripts/migrate/phase-analyze.ts @@ -11,6 +11,14 @@ import { extractSectionMetadata } from "./analyzers/section-metadata"; import { classifyIslands } from "./analyzers/island-classifier"; import { inventoryLoaders } from "./analyzers/loader-inventory"; import { extractTailwindConfig } from "./analyzers/tailwind-config"; +import { + COMPONENT_DELETE, + GENERATED_FILES, + LOADER_DELETE, + ROOT_DELETE, + SDK_DELETE, + STATIC_DELETE, +} from "./delete-sets"; const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [ ["preact-hooks", /from\s+["']preact\/hooks["']/], @@ -78,61 +86,6 @@ const SKIP_FILES = new Set([ "bun.lockb", ]); -/** Files that are generated and should be deleted */ -const GENERATED_FILES = new Set([ - "fresh.gen.ts", - "manifest.gen.ts", - "fresh.config.ts", -]); - -/** SDK files that have framework equivalents or are scaffolded fresh */ -const SDK_DELETE = new Set([ - "sdk/clx.ts", - "sdk/useId.ts", - // sdk/useOffer.ts — kept: sites often customize offer logic - // sdk/useVariantPossiblities.ts — kept: sites often customize variant logic - "sdk/usePlatform.tsx", - "sdk/signal.ts", - "sdk/format.ts", -]); - -/** Component files that are scaffolded fresh (old versions must not overwrite) */ -const COMPONENT_DELETE = new Set([ - "components/ui/Image.tsx", - "components/ui/Picture.tsx", - "components/ui/Video.tsx", -]); - -/** Loaders that depend on deleted admin tooling */ -const LOADER_DELETE = new Set([ - "loaders/availableIcons.ts", - "loaders/icons.ts", -]); - -/** Root config/infra files to delete */ -const ROOT_DELETE = new Set([ - "main.ts", - "dev.ts", - "deno.json", - "deno.lock", - "tailwind.css", - "tailwind.config.ts", - "runtime.ts", - "constants.ts", - "fresh.gen.ts", - "manifest.gen.ts", - "fresh.config.ts", - "browserslist", - "bw_stats.json", - "islands.ts", -]); - -/** Static files that are code/tooling, not assets — should be deleted */ -const STATIC_DELETE = new Set([ - "static/adminIcons.ts", - "static/generate-icons.ts", - "static/tailwind.css", -]); /** * Scan file content for inline npm: imports and return { name: version } pairs. diff --git a/packages/blocks-cli/scripts/reconcile.test.ts b/packages/blocks-cli/scripts/reconcile.test.ts new file mode 100644 index 00000000..01b0f8c5 --- /dev/null +++ b/packages/blocks-cli/scripts/reconcile.test.ts @@ -0,0 +1,162 @@ +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 { + detectTargetSnapshot, + 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); + }); + + it("drops what the migration deletes — no target file to port into", () => { + expect(isSkipped("fresh.gen.ts")).toBe(true); + expect(isSkipped("manifest.gen.ts")).toBe(true); + expect(isSkipped("deno.json")).toBe(true); + expect(isSkipped("sdk/cart/vtex.ts")).toBe(true); + // routes/ is rescaffolded but a NEW upstream route is a real change — keep it. + expect(isSkipped("routes/blog.tsx")).toBe(false); + expect(isSkipped("redirects.csv")).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("detectTargetSnapshot", () => { + it("takes the OLDEST add — git log is newest-first, re-adds must not win", () => { + expect(detectTargetSnapshot("bbb\naaa\n")).toBe("aaa"); + expect(detectTargetSnapshot("")).toBeUndefined(); + }); +}); + +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("auto-detects the target snapshot, emits one patch per file, flags hand-fixes", () => { + const source = init("source"); + write(source, "sections/Header.tsx", "export default () =>

a

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