From 35ace068faa14401808eef7e5bdc5dd6f8263cb6 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 16 Sep 2026 01:02:02 -0300 Subject: [PATCH] feat(OPS116): painel aceita -ui-design.html com retrocompat -ui-draft.html (Closes #1059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O painel `pnpm issues:tui` volta a enxergar o design UI depois do rename do OPS114: `siblingPlanPaths` deriva `-ui-design.html` (vigente) e `-ui-draft.html` (legado) e o novo `resolveUiDraft(planPath, exists)` — puro, com a existência injetada pelo CLI — é dono da precedência (design vence, draft é fallback, nenhum → design com `exists:false`). O `readPlans` sonda design→draft no disco e o caminho REAL flui por `plansByNumber`/`buildIssueRow` até o view model; o contrato `--json` segue `version: 1` com o shape `{ path, exists }` e a chave histórica `uiDraft`. As superfícies do painel trocam "rascunho UI" por "design UI". Retrocompat é só de leitura — o acervo legado (30 `*-ui-draft.html`) não é tocado. Unit dos dois specs cobre design-only, draft-only, ambos, nenhum, o caminho real, body linkando o impl e o shape do JSON. Sem migration. --- docs/changelog/2026-09-16-ops116.md | 1 + ...ps116-painel-ui-design-retrocompat-impl.md | 132 ++++++++++++++++++ scripts/issues-tui.mjs | 19 +-- scripts/lib/issues-panel.mjs | 60 ++++++-- tests/unit/issuesPanel.unit.spec.ts | 84 ++++++++++- tests/unit/issuesPanelRegression.unit.spec.ts | 10 +- 6 files changed, 277 insertions(+), 29 deletions(-) create mode 100644 docs/changelog/2026-09-16-ops116.md create mode 100644 docs/plans/ops116-painel-ui-design-retrocompat-impl.md diff --git a/docs/changelog/2026-09-16-ops116.md b/docs/changelog/2026-09-16-ops116.md new file mode 100644 index 00000000..e944776f --- /dev/null +++ b/docs/changelog/2026-09-16-ops116.md @@ -0,0 +1 @@ +**OPS116 — Painel/TUI: aceitar `-ui-design.html` com retrocompat `-ui-draft.html` (2026-09-16):** o painel de issues (`pnpm issues:tui`) volta a enxergar o artefato de design dos planos depois do rename do OPS114. `siblingPlanPaths` (lib pura) passa a derivar `-ui-design.html` (nome vigente) e `-ui-draft.html` (legado), e o novo `resolveUiDraft(planPath, exists)` — puro, existência injetada pelo CLI — é dono da precedência "design vence, draft é fallback, nenhum → design com `exists:false`". O `readPlans` do CLI sonda design→draft no disco e devolve o **caminho real**, que flui por `plansByNumber`/`buildIssueRow` até o view model; o contrato `--json` segue `version: 1` com o shape `{ path, exists }` e a chave histórica `uiDraft` (nenhum campo novo). As superfícies do painel trocam "rascunho UI" por "design UI" (ajuda, linha de ação, rodapé e mensagem da tecla `w`). Retrocompat é só de leitura — o acervo legado (30 `*-ui-draft.html` + PNGs) não é tocado; HTML-only, sem render de imagem. Unit dos dois specs do painel cobre design-only, draft-only, ambos (design vence), nenhum, o caminho real vindo do contexto e o shape do JSON. Sem migration, sem deps novas, sem tocar `src/`. diff --git a/docs/plans/ops116-painel-ui-design-retrocompat-impl.md b/docs/plans/ops116-painel-ui-design-retrocompat-impl.md new file mode 100644 index 00000000..fd692e80 --- /dev/null +++ b/docs/plans/ops116-painel-ui-design-retrocompat-impl.md @@ -0,0 +1,132 @@ +# Impl: OPS116 — Painel/TUI: aceitar `-ui-design.html` com retrocompat `-ui-draft.html` + +Status: aprovado (gate humano 2026-09-16) +Atualizado em: 2026-09-16 +Issue: #1059 +Intenção: docs/plans/ops116-painel-ui-design-retrocompat.md +Appetite restante: herdado (~0,5 dia eng) + +## Leitura da intenção + +- **Outcome:** o painel (`pnpm issues:tui`, OPS109) reconhece o artefato de design dos planos nos dois nomes — prefere `-ui-design.html`, cai para `-ui-draft.html` (legado, imutável) — mostra/abre o **caminho real** encontrado com a tecla `w` e passa a falar "design UI"; o contrato `--json` segue `version: 1` com o shape `{ path, exists }` carregando o caminho real. +- **O que NÃO negociar:** read-only de ponta a ponta; HTML-only (sem render de PNG/imagem); o acervo legado em `docs/plans/` não é tocado (retrocompat é de leitura); a chave `uiDraft` **não** é renomeada e `version: 1` não sobe; a existência continua checada no **CLI** (a lib é pura e não toca disco); as três strings exatas do painel; sem migration. +- **O que reavaliar (hipóteses da intenção):** + - "A lib deriva os dois candidatos e o CLI sonda" — confirmado, mas a passada base do `buildPanelViewModel` só expõe **um** caminho hoje (`row.uiDraft.path`); para o CLI sondar os dois sem quebrar a pureza, a ordem/precedência vira um **seletor puro com existência injetada** (`resolveUiDraft(planPath, exists)`), e é isso que torna "design vence" testável em unit (decisão 1). + - "Os unit tests cobrem design-only/draft-only/ambos/nenhum" — a lib não podia testar isso enquanto o `existsSync` morasse no CLI; com o predicado injetado, os quatro casos ficam puros e determinísticos (decisão 1), mantendo a sondagem de disco no CLI. + - "Basta estender `siblingPlanPaths`" — parcial: o nome dos dois irmãos mora nele (dono), mas a **precedência** (design > draft, e o default "nenhum → design") precisa de um dono único consumível; não é campo novo no view model (decisão 3). + +## Abordagem recomendada + +```mermaid +flowchart LR + BODY[issue.body] --> EX[extractPlanPath] --> SIB[siblingPlanPaths
impl · uiDesign · uiDraft] + SIB --> RUD + CLI[issues-tui.readPlans] -->|injeta existsOnDisk| RUD[resolveUiDraft planPath, exists
design → draft] + RUD --> REAL["{path, exists} real
design, senão draft; nenhum → design,false"] + BODY --> VM[buildPanelViewModel pass 2] + REAL --> MAP[plansByNumber] --> VM + VM --> ROW[buildIssueRow
view.uiDraft = path, exists] + ROW --> JSON[toJsonPayload
version 1 · path, exists — shape intacto] + ROW --> TUI[renderDetail · ajuda · handler w
w design UI] +``` + +**Opções consideradas:** A) `siblingPlanPaths` devolve `{ impl, uiDesign, uiDraft }` + contexto `uiDraft` de `buildIssueRow` aceita `{path,exists}`, com o CLI sondando os dois candidatos derivados de `row.plan.intention.path` e codificando a ordem design→draft inline; B) helper puro dedicado de candidatos (`uiDraftCandidates(planPath)`) consumido pelo CLI, `siblingPlanPaths` intocado; C) **A acrescido de um seletor puro** `resolveUiDraft(planPath, exists)` como dono único da precedência, com a existência **injetada** pelo CLI. +**Recomendação:** **C** — a ordem preferencial (design > draft) é conhecimento de nomes e deve viver num dono só; injetar o predicado de existência preserva a pureza da lib **e** deixa a regra "design vence" testável em unit sem tocar disco; não adiciona nenhum campo ao view model/JSON nem duplica a derivação de nomes. +**Rejeitadas:** + +- **A** — deixa a ordem design→draft hardcoded no CLI, então "design vence" só se verifica por smoke manual (o aceite pede unit para os 4 casos) e não há dono explícito da precedência; é o mesmo conhecimento em dois lugares na prática. +- **B** — parte o conhecimento: `uiDesign` num helper e `uiDraft` em `siblingPlanPaths`, dois donos para a mesma convenção; o helper vira pass-through raso que só agrega valor colado ao `siblingPlanPaths` (depth check: não criar). +- **D** (rejeitada na intenção) — novo campo `variant`, rename `uiDraft`→`uiDesign` ou `version: 2`: churn de contrato sem consumidor novo; o `--json` é superfície pública e o shape `{path, exists}` é o contrato. +- **E** — deixar a lib chamar `existsSync` dentro de `buildIssueRow`: quebra a pureza e a divisão lib-pura/CLI-I/O estabelecida no OPS109 (twin de I/O); a existência fica no CLI e entra na lib só como predicado. + +### Decisões de engenharia + +**1. Como os candidatos são expostos e como o `{path, exists}` real volta (tensão central).** +Opções: A) `siblingPlanPaths` ganha `uiDesign`; CLI deriva os dois de `row.plan.intention.path` e codifica a ordem; B) helper `uiDraftCandidates` dedicado; C) `siblingPlanPaths` ganha `uiDesign` **e** um `resolveUiDraft(planPath, exists = () => false)` puro encapsula ordem + fallback + default; CLI injeta `existsSync`. +Recomendação: **C.** O fluxo: `siblingPlanPaths` (dono dos nomes) → `resolveUiDraft` (dono da precedência) → `readPlans` no CLI chama `resolveUiDraft(row.plan.intention.path, isFileOnDisk)` e devolve `{ path, exists }` com o caminho **real** → `loadViewModel` coloca esse objeto no `plansByNumber` (`Map`) → a passada 2 do `buildPanelViewModel` passa adiante → `buildIssueRow` usa `context.uiDraft` quando presente e cai em `resolveUiDraft(planPath)` (design, `exists:false`) como default → `view.uiDraft = { path, exists }` (shape inalterado) → `toJsonPayload` mapeia só `{ path, exists }` (nenhum campo novo). `row.plan.intention.path` é equivalente ao `planPath` de `buildIssueRow` porque ambos saem do mesmo `extractPlanPath`/`siblingPlanPaths` (que já remove o sufixo `-impl`), então não há divergência mesmo quando o body linka o impl ou não linka plano. +Rejeitadas: A e B pelos motivos acima; expor a lista de candidatos no view model (novo campo interno, mesmo que removido do JSON) é rejeitado por não comprar nada além da re-derivação via o mesmo helper exportado — o seletor puro já é o dono. + +**2. Caminho exibido quando nenhum arquivo existe.** +Opções: A) candidato novo (`-ui-design.html`) com `exists: false`; B) manter o antigo (`-ui-draft.html`). +Recomendação: **A** — ensina o nome vigente; o fallback real continua valendo quando o legado existir. É exatamente o default de `resolveUiDraft` (design + `false`). _(assumido na intenção — validar no gate; se o gate preferir B, muda só o retorno do `resolveUiDraft`, um ponto.)_ + +**3. Onde comentar que `uiDraft` agora significa "design UI (novo ou legado)".** +Recomendação: **comentário no owner, sem campo novo** — JSDoc em `siblingPlanPaths` (nomes), `resolveUiDraft` (chave histórica + precedência), `buildIssueRow` (contexto `uiDraft` = verdict de design) e `toJsonPayload` (contrato `{path, exists}`, `version: 1`), mais um comentário curto em `readPlans`. Rejeitado duplicar campo `variant`/subir `version` (decisão D). + +**4. `readPlans` sonda design→draft e devolve o caminho real?** +Recomendação: **sim** — `resolveUiDraft` sonda na ordem design→draft via `isFileOnDisk = (p) => Boolean(p) && existsSync(resolve(process.cwd(), p))` e devolve `{ path: encontrado ?? design, exists: Boolean(encontrado) }`. O que entra no JSON é o caminho do arquivo que existe, não o template. + +### Componentes / mudanças + +- **`siblingPlanPaths`** (`scripts/lib/issues-panel.mjs:95`): passa a devolver `{ impl, uiDesign, uiDraft }`; mantém `impl`/`uiDraft` (retrocompat) e registra em JSDoc que `uiDraft` é a chave histórica do "design UI (novo ou legado)". +- **`resolveUiDraft`** (novo, `scripts/lib/issues-panel.mjs`): puro, `(planPath, exists = () => false) → { path, exists }`; dono da precedência design→draft e do default "nenhum → design, false". Reusa `siblingPlanPaths`; não toca disco. +- **`buildIssueRow`** (`:203`): contexto `uiDraft` aceita `{ path, exists }` (não mais boolean); default `resolveUiDraft(planPath)`; `view.uiDraft = { path, exists }` — shape idêntico ao atual. +- **`buildPanelViewModel`** (`:316`): o valor `uiDraft` de `plansByNumber` passa a ser `{ path, exists }` repassado direto (sem `Boolean(...)`); a JSDoc do input atualiza o tipo. +- **`toJsonPayload`** (`:359`): **inalterado no shape** (`uiDraft: { path: row.uiDraft.path, exists: row.uiDraft.exists }`, `version: 1`); só comentário no owner. +- **`readPlans`** (`scripts/issues-tui.mjs:104`): usa `resolveUiDraft(row.plan.intention.path, isFileOnDisk)`; devolve o `{path, exists}` real (o resto do corpo fica igual). +- **Strings** (`scripts/issues-tui.mjs:19,339,340,359,637`): `w design UI` (ajuda e rodapé) · `w abrir design UI no browser · ` (linha de ação) · `w design UI: não tem (classe A)` (handler e estado ausente). +- **Testes** (`tests/unit/issuesPanel.unit.spec.ts`, `tests/unit/issuesPanelRegression.unit.spec.ts`): atualizados para o novo shape e para os 4 casos de `resolveUiDraft`. +- **Changelog:** `docs/changelog/2026-09-16-ops116.md` (uma entrada curta, convenção do repo). +- **Migration:** sem migration (nenhum schema). +- **Access / Consent:** N/A — tooling de terminal, sem DB/PII/Consent. +- **UI:** N/A — classe A; tooling de terminal, sem UI de produto; não há ciclo shape→craft→critique→polish. + +### Dados → forma (se aplicável) + +N/A — ferramenta de operação; não apresenta dado de produto. A "forma" aqui é o view model/JSON, já coberto acima. + +## Fases verificáveis + +1. **Camada pura + unit tests (quota ~50% ≈ 0,25 dia)** — `siblingPlanPaths` com `uiDesign`; `resolveUiDraft` puro; `buildIssueRow`/`buildPanelViewModel` com o shape `{path, exists}`. Escrever primeiro os testes: `siblingPlanPaths`, e `resolveUiDraft` nos 4 casos (design-only → design; draft-only → draft; ambos → design vence; nenhum → design, `exists:false`) com predicado fake; ajustar `buildIssueRow`/`buildPanelViewModel`/`toJsonPayload`. Prova: `pnpm test:unit -- tests/unit/issuesPanel.unit.spec.ts tests/unit/issuesPanelRegression.unit.spec.ts`. +2. **CLI (quota ~35% ≈ 0,17 dia)** — `readPlans` via `resolveUiDraft` + `isFileOnDisk`; strings "design UI"; comentário no `readPlans`. Prova: smoke `pnpm issues:tui --json` (exige `GITHUB_TOKEN`) conferindo Issue legada → `exists:true` com caminho `-ui-draft.html`, Issue com design → `-ui-design.html`, e Issue sem nenhum → `-ui-design.html`/`exists:false`; abrir `pnpm issues:tui` e conferir `w design UI` no detalhe. +3. **Fechamento (quota ~15% ≈ 0,08 dia)** — entrada `docs/changelog/2026-09-16-ops116.md`; `pnpm gate:fast`; `node scripts/check-test-locations.mjs`; revisão do aceite de engenharia; push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- **Renderizar HTML/imagem no painel** — vira viewer embutido; corta em abrir browser/imprimir caminho (como hoje). +- **Migrar/renomear os 30 `*-ui-draft.html` (e PNGs) de `docs/plans/`** — acervo legado imutável; retrocompat é de leitura. +- **Limpar o vocabulário no repo inteiro** (skill/template/docs) — dono é OPS114; o diff deste item é só o painel/testes/changelog. +- **Mexer em `E2E_AFFECTED_MANIFEST`/`HIGH_RISK_EXACT`/`ci-scope.mjs`** — o manifest mapeia só `src/` e o risco curado não inclui os scripts do painel; não há mapping a acrescentar e mexer nisso é fora do appetite. +- **`check:cycles`** — roda só em `src/`; irrelevante aqui. +- **Exportar `uiDraftCandidates`/helper sem consumidor** — `knip` trata `scripts/*.mjs` como entry com `exports:"error"`; export novo só entra se for usado (o CLI e/ou os specs usam `resolveUiDraft`; `siblingPlanPaths` já é usado). +- **Renomear a chave `uiDraft` ou subir `version`** — contrato público sem consumidor novo; rejeitado. +- **Tocar em claim/registro/sessões/schema/`push`** — fora do item. + +## Riscos e mitigação + +- **Regressão silenciosa do `--json`** — manter `version: 1` e o shape `{ path, exists }`; nenhum campo novo; o spec já faz `JSON.parse(JSON.stringify(payload))` e asserts de shape — estender para garantir que a linha carrega o caminho real do arquivo existente. +- **Teste que fixa o nome antigo** — os dois specs têm expectativas literais `-ui-draft.html`; atualizar junto (senão o gate fica vermelho). +- **Divergência entre o `planPath` do `buildIssueRow` e o de `readPlans`** — ambos derivam do mesmo `extractPlanPath`+`siblingPlanPaths` (que remove `-impl`); cobrir com regressão: body linkando o impl e body sem link. +- **Ambos os arquivos existem** — precedência design coberta pelo unit com predicado fake (`exists: () => true`) e confirmada no smoke. +- **Nenhum arquivo existe** — JSON expõe o candidato novo com `exists:false` (assumido — validar no gate; ponto único de mudança no `resolveUiDraft`). +- **Acervo legado** — só leitura; nenhuma escrita em `docs/plans/`. + +## Débitos (triagem `/simplify`) + +Dois revisores (estrutural + qualidade) rodaram no diff; nada alto/médio sobreviveu (a regressão do body linkando o `-impl.md` — risco declarado acima — foi adicionada, e o `?? null` redundante do `buildPanelViewModel` foi removido). Nenhum achado virou Issue nova. Defers com gatilho (reavaliar quando o gatilho aparecer): + +- **`readPlans`/`resolveUiDraft` sem teste de I-O** — a sonda real de disco só é exercitada no smoke manual (`GITHUB_TOKEN`); o glue `readPlans`↔`resolveUiDraft` não tem unit. Gatilho: `readPlans` ganhar lógica própria ou surgir teste de int do CLI. +- **Default fail-silent `exists = () => false`** em `resolveUiDraft` — exigido pelo fallback puro do `buildIssueRow` (a lib não toca disco). Gatilho: novo caller fora da lib que possa omitir o predicado. +- **`buildIssueRow` não normaliza `uiDraft: boolean` legado** — nenhum caller passa boolean hoje. Gatilho: surgir consumidor externo com o shape antigo. + +Descartados por baixo valor (documentados, sem consumidor runtime): dead code pré-existente do fallback `siblings.impl`, `uiDraft` sem consumidor de produção, `@typedef` do shape `{ path, exists }`, mudança de semântica do `uiDraft.path` sob `version: 1` (decisão travada da intenção), as normalizações de fronteira `Boolean(...)`/`?? null` (contrato `--json`), o `Boolean(path)` defensivo do CLI, o prefixo `w` da mensagem do handler (literal aprovado) e o pin de `uiDesign`/`uiDraft` a partir de `-impl.md` (coberto por `resolveUiDraft`). + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto: design-only aparece e abre; draft-only segue funcionando; ambos usa design; superfícies falam "design UI"; `--json` `version:1`/`{path, exists}` com o caminho real; sem PNG. +- [x] Invariantes AGENTS/engineering-standards: strings pt / identificadores em inglês; **edit the owner, don't twin** (estende `siblingPlanPaths`, adiciona `resolveUiDraft` no mesmo dono, ajusta `buildIssueRow`/`buildPanelViewModel`/`toJsonPayload`/`readPlans`; sem segundo caminho de derivação); lib pura não toca disco (existência injetada); sem dep nova; sem migration/`push`; sem PII/DB. +- [x] Testes de domínio previstos: unit (`// @vitest-environment node`, import direto do `.mjs`) para `siblingPlanPaths` (3 nomes), `resolveUiDraft` nos 4 casos, `buildIssueRow` com `{path,exists}` e default design, e regressão do shape do `toJsonPayload`; sem int test (sem access/write path). + +## Autoscore de decision-quality + +**Score: 4.5 / 5.** + +| Critério | Nota | Justificativa | +| ----------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Decisões caras com rejeitadas | 1.0 | A tensão central (como expor candidatos / fluir o `{path,exists}` / manter o JSON) tem Opções A–E, recomendação C e rejeitadas com porquê (pureza, dono único, contrato público). | +| Cabe no appetite | 1.0 | ~0,5 dia, tooling puro + CLI fina; as fases somam ~0,5 dia sem inflar escopo. | +| Rabbit holes nomeados | 1.0 | Render de imagem, migração do acervo, rename global, ci-scope/e2e/cycles, export morto no knip. | +| Depth check de reuso | 1.0 | Reusa `siblingPlanPaths`/`buildIssueRow`/`buildPanelViewModel`/`toJsonPayload`/`readPlans`; não cria módulo/collection/campo; existência injetada em vez de I/O na lib. | +| Intenção permanece satisfeita | 0.5 | Engenharia não reescreve o outcome; perde meio ponto porque "nenhum → design" é uma assunção da intenção ainda a validar no gate (ponto único de reversão). | + +**Total: 4.5/5** (≥4 — pronto para o gate humano do impl plan). diff --git a/scripts/issues-tui.mjs b/scripts/issues-tui.mjs index a593b1d8..ee94389e 100644 --- a/scripts/issues-tui.mjs +++ b/scripts/issues-tui.mjs @@ -16,7 +16,7 @@ * clara, nunca stack trace; sair nunca deixa o terminal em raw mode. * * Teclas na lista: ↑↓ mover · enter abrir · 1-4 filtrar · r recarregar · q sair - * No detalhe: esc voltar · enter abrir plano · w rascunho UI · s sessão · o GitHub + * No detalhe: esc voltar · enter abrir plano · w design UI · s sessão · o GitHub * No leitor: ↑↓ rolar · pgup/pgdn página · esc voltar */ @@ -48,6 +48,7 @@ import { buildPanelViewModel, filterByState, planLabel, + resolveUiDraft, toJsonPayload, visibleWindow, } from './lib/issues-panel.mjs' @@ -99,7 +100,9 @@ const classificationTone = (classification) => /** * Read the plan files for one row from disk (repo root = cwd). Missing files * come back null — “sem plano” / “impl não criado” are first-class states. - * The UI draft only needs its existence verdict (content never rendered). + * The design UI only needs its existence verdict and real path (content never + * rendered): `resolveUiDraft` probes current `-ui-design.html` first and falls + * back to the legacy `-ui-draft.html`, keeping the precedence in the pure lib. */ const readPlans = (row) => { const readIfExists = (path) => { @@ -111,11 +114,11 @@ const readPlans = (row) => { return null } } - const uiDraftPath = row.uiDraft?.path + const isFileOnDisk = (path) => Boolean(path) && existsSync(resolve(process.cwd(), path)) return { intention: readIfExists(row.plan.intention?.path), impl: readIfExists(row.plan.impl?.path), - uiDraft: Boolean(uiDraftPath) && existsSync(resolve(process.cwd(), uiDraftPath)), + uiDraft: resolveUiDraft(row.plan.intention?.path ?? null, isFileOnDisk), } } @@ -336,8 +339,8 @@ const renderDetail = (row, { width, height, message }) => { lines.push(dim('ações')) push( row.uiDraft.exists - ? `${style(SGR.cyan, 'w')} abrir rascunho UI no browser · ${row.uiDraft.path}` - : dim('w rascunho UI: não tem (classe A)'), + ? `${style(SGR.cyan, 'w')} abrir design UI no browser · ${row.uiDraft.path}` + : dim('w design UI: não tem (classe A)'), ) if (row.session) { push( @@ -356,7 +359,7 @@ const renderDetail = (row, { width, height, message }) => { ? tone('ok', truncateToWidth(message, width)) : dim( truncateToWidth( - 'esc voltar · enter abrir plano · w rascunho UI · s sessão · o GitHub', + 'esc voltar · enter abrir plano · w design UI · s sessão · o GitHub', width, ), ), @@ -634,7 +637,7 @@ const runTui = async ({ limit }) => { if (key === 'esc') state.screen = 'list' else if (key === 'o') message = openGithub(state.row).message else if (key === 'w') { - if (!state.row.uiDraft.exists) message = 'rascunho UI: não tem (classe A)' + if (!state.row.uiDraft.exists) message = 'w design UI: não tem (classe A)' else message = openInBrowser(state.row.uiDraft.path).message } else if (key === 's') { message = attachSession(state.row).message diff --git a/scripts/lib/issues-panel.mjs b/scripts/lib/issues-panel.mjs index bdd3e656..66d366cf 100644 --- a/scripts/lib/issues-panel.mjs +++ b/scripts/lib/issues-panel.mjs @@ -2,7 +2,7 @@ * OPS109 — derivação pura do painel de issues (`pnpm issues:tui`). * * Todo o conhecimento do painel mora aqui: estado/ação da Issue, leitura do - * `Status:` dos planos, descoberta do plano de intenção/impl/rascunho UI e o + * `Status:` dos planos, descoberta do plano de intenção/impl/design UI e o * casamento com a sessão do agente do OPS110. Puro por contrato — recebe os * insumos (issues normalizadas do `github-api`, sessões do * `agent-session list --json` e os planos já lidos do disco) e devolve o view @@ -88,16 +88,43 @@ export const classifyPlanStatus = (status) => { /** * Sibling-file conventions of the plans (`docs/plans/.md`, - * `docs/plans/-impl.md`, `docs/plans/-ui-draft.html`). Given the - * intent path, derive the other two; the caller confirms existence on disk. + * `docs/plans/-impl.md`, `docs/plans/-ui-design.html`, + * `docs/plans/-ui-draft.html`). Given the intent path, derive the other + * three; the caller confirms existence on disk. `uiDesign` is the current + * name (OPS114) and `uiDraft` is the legacy name (OPS109) — the two are + * alternative names for the SAME artifact, never two artifacts. The + * historical `uiDraft` key stays for retrocompat; `resolveUiDraft` owns the + * precedence between them. * @param {string | null} planPath */ export const siblingPlanPaths = (planPath) => { const path = typeof planPath === 'string' ? planPath.trim() : '' - if (!/\.md$/.test(path)) return { impl: null, uiDraft: null } + if (!/\.md$/.test(path)) return { impl: null, uiDesign: null, uiDraft: null } const stem = path.replace(/\.md$/, '') const base = stem.endsWith('-impl') ? stem.slice(0, -'-impl'.length) : stem - return { impl: `${base}-impl.md`, uiDraft: `${base}-ui-draft.html` } + return { + impl: `${base}-impl.md`, + uiDesign: `${base}-ui-design.html`, + uiDraft: `${base}-ui-draft.html`, + } +} + +/** + * Pick the design artifact of a plan: `-ui-design.html` (current name, + * OPS114) wins over `-ui-draft.html` (legacy, immutable acervo). Pure by + * contract — the caller injects the existence predicate, so the lib never + * touches disk. When neither file exists the verdict is the current name with + * `exists: false`: the panel teaches the live convention and the legacy + * fallback still applies on disk. + * @param {string | null} planPath + * @param {(path: string) => boolean} [exists] + * @returns {{ path: string | null, exists: boolean }} + */ +export const resolveUiDraft = (planPath, exists = () => false) => { + const { uiDesign, uiDraft } = siblingPlanPaths(planPath) + if (uiDesign && exists(uiDesign)) return { path: uiDesign, exists: true } + if (uiDraft && exists(uiDraft)) return { path: uiDraft, exists: true } + return { path: uiDesign, exists: false } } /** Which issue state label the panel shows (closed wins as `done`). */ @@ -195,16 +222,18 @@ const planView = (kind, path, markdown) => { /** * Build one row of the panel from an issue and its already-read artifacts. * `intention`/`impl` carry the file contents, with `null`/missing meaning - * “file does not exist”; `uiDraft` is the existence verdict (the CLI checks - * the sibling `.html`; the panel never reads its content). + * “file does not exist”; `uiDraft` is the design verdict — the CLI checks the + * sibling `.html` (current `-ui-design` first, legacy `-ui-draft` as fallback) + * and hands back the real `{ path, exists }`; the panel never reads its + * content. The legacy key name stays: it means “design UI, new or legacy”. * @param {object} issue normalized GitHub issue - * @param {{ intention?: string|null, impl?: string|null, uiDraft?: boolean, sessions?: Array, doneIds?: Set, knownIds?: Set, repository?: string }} [context] + * @param {{ intention?: string|null, impl?: string|null, uiDraft?: { path: string|null, exists: boolean }|null, sessions?: Array, doneIds?: Set, knownIds?: Set, repository?: string }} [context] */ export const buildIssueRow = (issue, context = {}) => { const { intention = null, impl = null, - uiDraft = false, + uiDraft = null, sessions = [], doneIds = new Set(), knownIds = new Set(), @@ -215,7 +244,7 @@ export const buildIssueRow = (issue, context = {}) => { const siblings = siblingPlanPaths(planPath) const intentionPath = planPath ?? siblings.impl?.replace(/-impl\.md$/, '.md') ?? null const implPath = planPath && /-impl\.md$/.test(planPath) ? planPath : siblings.impl - const uiDraftPath = siblings.uiDraft + const designVerdict = uiDraft ?? resolveUiDraft(planPath) const plan = { intention: planView('intention', intentionPath, intention), @@ -237,7 +266,7 @@ export const buildIssueRow = (issue, context = {}) => { summary: issueSummary(issue), url: issueUrl(issue, repository), plan, - uiDraft: { path: uiDraftPath, exists: Boolean(uiDraft) }, + uiDraft: { path: designVerdict.path ?? null, exists: Boolean(designVerdict.exists) }, session: session ? { status: session.status ?? 'unknown', @@ -311,7 +340,7 @@ export const visibleWindow = ({ count, cursor, capacity }) => { * Assemble the full panel view model. Pure: the caller (CLI) does fetch, * spawn and disk reads, then hands the raw inputs here. `limit` is the * effective page size (GitHub caps `per_page` at 100 — the CLI clamps it). - * @param {{ issues: Array, sessions?: Array, plansByNumber?: Map, repository?: string, limit?: number }} input + * @param {{ issues: Array, sessions?: Array, plansByNumber?: Map, repository?: string, limit?: number }} input */ export const buildPanelViewModel = ({ issues = [], @@ -335,7 +364,7 @@ export const buildPanelViewModel = ({ return buildIssueRow(issue, { intention: plans.intention ?? null, impl: plans.impl ?? null, - uiDraft: Boolean(plans.uiDraft), + uiDraft: plans.uiDraft, sessions, doneIds, knownIds, @@ -353,7 +382,10 @@ export const buildPanelViewModel = ({ /** * Render one issue as JSON-safe line for `--json` (already plain objects). - * Kept as a helper so the CLI contract is explicit about the shape. + * Kept as a helper so the CLI contract is explicit about the shape. The + * `uiDraft` key is the historical name of the design artifact and carries the + * REAL path found on disk (current `-ui-design`, else legacy `-ui-draft`) — + * `version: 1` and the `{ path, exists }` shape are frozen. * @param {object} viewModel */ export const toJsonPayload = (viewModel) => ({ diff --git a/tests/unit/issuesPanel.unit.spec.ts b/tests/unit/issuesPanel.unit.spec.ts index 1aade858..501599c7 100644 --- a/tests/unit/issuesPanel.unit.spec.ts +++ b/tests/unit/issuesPanel.unit.spec.ts @@ -15,6 +15,7 @@ import { issueSummary, parsePlanStatus, planLabel, + resolveUiDraft, siblingPlanPaths, sortRows, toJsonPayload, @@ -87,10 +88,11 @@ describe('classifyPlanStatus (OPS109 — leitura de produto)', () => { }) }) -describe('siblingPlanPaths (OPS109)', () => { - it('derives impl and ui-draft from the intent path', () => { +describe('siblingPlanPaths (OPS109 / OPS116)', () => { + it('derives impl, ui-design and the legacy ui-draft from the intent path', () => { expect(siblingPlanPaths('docs/plans/ops109-x.md')).toEqual({ impl: 'docs/plans/ops109-x-impl.md', + uiDesign: 'docs/plans/ops109-x-ui-design.html', uiDraft: 'docs/plans/ops109-x-ui-draft.html', }) }) @@ -100,8 +102,46 @@ describe('siblingPlanPaths (OPS109)', () => { }) it('returns nulls for a non-markdown path', () => { - expect(siblingPlanPaths(null)).toEqual({ impl: null, uiDraft: null }) - expect(siblingPlanPaths('docs/plans/x.html')).toEqual({ impl: null, uiDraft: null }) + expect(siblingPlanPaths(null)).toEqual({ impl: null, uiDesign: null, uiDraft: null }) + expect(siblingPlanPaths('docs/plans/x.html')).toEqual({ + impl: null, + uiDesign: null, + uiDraft: null, + }) + }) +}) + +describe('resolveUiDraft (OPS116 — design wins over the legacy draft)', () => { + const intent = 'docs/plans/ops116-x.md' + const design = 'docs/plans/ops116-x-ui-design.html' + const draft = 'docs/plans/ops116-x-ui-draft.html' + const only = + (...existing: string[]) => + (path: string) => + existing.includes(path) + + it('prefers the current -ui-design.html when only it exists', () => { + expect(resolveUiDraft(intent, only(design))).toEqual({ path: design, exists: true }) + }) + + it('falls back to the legacy -ui-draft.html (retrocompat)', () => { + expect(resolveUiDraft(intent, only(draft))).toEqual({ path: draft, exists: true }) + }) + + it('uses the current name when both exist', () => { + expect(resolveUiDraft(intent, only(design, draft))).toEqual({ path: design, exists: true }) + }) + + it('shows the current name with exists:false when neither exists', () => { + expect(resolveUiDraft(intent, only())).toEqual({ path: design, exists: false }) + }) + + it('is pure — defaults to no file on disk', () => { + expect(resolveUiDraft(intent)).toEqual({ path: design, exists: false }) + }) + + it('returns a null path when the plan has no markdown path', () => { + expect(resolveUiDraft(null, only(draft))).toEqual({ path: null, exists: false }) }) }) @@ -150,8 +190,34 @@ describe('buildIssueRow (OPS109)', () => { exists: true, classification: 'andou', }) + expect(row.uiDraft).toEqual({ + path: 'docs/plans/ops109-painel-issues-no-terminal-ui-design.html', + exists: false, + }) + }) + + it('carries the real design path the CLI resolved on disk', () => { + const row = buildIssueRow(issue(), { + ...deps, + uiDraft: { path: 'docs/plans/ops109-painel-issues-no-terminal-ui-draft.html', exists: true }, + }) expect(row.uiDraft).toEqual({ path: 'docs/plans/ops109-painel-issues-no-terminal-ui-draft.html', + exists: true, + }) + }) + + it('returns a null design path when the plan is missing', () => { + const row = buildIssueRow(issue({ body: '---\nid: OPS999\n---\nsem plano' }), deps) + expect(row.uiDraft).toEqual({ path: null, exists: false }) + }) + + it('derives the same design artifact from an impl plan link', () => { + const implLinked = issue({ + body: '---\nid: OPS109\n---\nPlano: [`docs/plans/ops109-painel-issues-no-terminal-impl.md`](docs/plans/ops109-painel-issues-no-terminal-impl.md)', + }) + expect(buildIssueRow(implLinked, deps).uiDraft).toEqual({ + path: 'docs/plans/ops109-painel-issues-no-terminal-ui-design.html', exists: false, }) }) @@ -262,7 +328,10 @@ describe('buildPanelViewModel / toJsonPayload (OPS109)', () => { { intention: 'Status: aprovado\n', impl: 'Status: em execução\n', - uiDraft: true, + uiDraft: { + path: 'docs/plans/ops109-painel-issues-no-terminal-ui-design.html', + exists: true, + }, }, ], ]) @@ -287,7 +356,10 @@ describe('buildPanelViewModel / toJsonPayload (OPS109)', () => { const row = payload.rows.find((entry: { code?: string }) => entry.code === 'OPS109') expect(row.plan.intention).toMatchObject({ classification: 'aprovado', status: 'aprovado' }) expect(row.plan.impl).toMatchObject({ classification: 'andou', status: 'em execução' }) - expect(row.uiDraft.exists).toBe(true) + expect(row.uiDraft).toEqual({ + path: 'docs/plans/ops109-painel-issues-no-terminal-ui-design.html', + exists: true, + }) expect(row.session).toBeNull() expect(JSON.parse(JSON.stringify(payload))).toEqual(payload) }) diff --git a/tests/unit/issuesPanelRegression.unit.spec.ts b/tests/unit/issuesPanelRegression.unit.spec.ts index 17ff64b9..06a6dd02 100644 --- a/tests/unit/issuesPanelRegression.unit.spec.ts +++ b/tests/unit/issuesPanelRegression.unit.spec.ts @@ -149,7 +149,14 @@ describe('toJsonPayload / buildPanelViewModel (OPS109 — no regressions)', () = }), ], plansByNumber: new Map([ - [999, { intention: 'Status: aprovado\n', impl: null, uiDraft: false }], + [ + 999, + { + intention: 'Status: aprovado\n', + impl: null, + uiDraft: { path: 'docs/plans/test999-ui-draft.html', exists: true }, + }, + ], ]), limit: 200, }) @@ -157,5 +164,6 @@ describe('toJsonPayload / buildPanelViewModel (OPS109 — no regressions)', () = const row = payload.rows[0] expect(row.plan.intention).toMatchObject({ classification: 'aprovado', status: 'aprovado' }) expect(row.plan.impl).toMatchObject({ exists: false, classification: 'não criado' }) + expect(row.uiDraft).toEqual({ path: 'docs/plans/test999-ui-draft.html', exists: true }) }) })