mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
chore(OPS119+): risk map das libs de launch + pin do wiring do plan (Closes #1071)
This commit is contained in:
parent
b0582d5011
commit
40bb2e2b6f
9 changed files with 298 additions and 17 deletions
1
docs/changelog/2026-09-16-ops119plus.md
Normal file
1
docs/changelog/2026-09-16-ops119plus.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS119+ — launch do worktree: risk map das libs + pin do wiring do `plan` (2026-09-16):** fecha os dois achados de score 3 do `/simplify` da OPS119 (#1066) na superfície do launch. **F1:** `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs`) só marcava `scripts/worktree.mjs`; um diff que tocasse **apenas** `scripts/agent-session.mjs`, `scripts/lib/agent-session.mjs` ou `scripts/lib/worktree.mjs` classificava `classifyTestScope` como `none` e a suíte unit não rodava — as libs puras eram pinadas por specs que o próprio diff que as quebrava nunca exercitava. Os três entram no piso de blast radius (fail-closed), com pin de membership/classificação em `tests/unit/testAffected.unit.spec.ts` e um caso novo em `ciSkipInvariants.unit.spec.ts` que também confere a existência em disco (entrada high-risk morta por rename falha alto). **F2:** o repasse `argument: bag` de `cmdPlan` → `cmdNamespaceBranch` não tinha pin unitário — um `argument` dropado no caller passava silencioso. Extraído `namespaceLaunchDescriptor({ purpose, bag })` para `scripts/lib/worktree.mjs` (mapa puro purpose→`{ noun, sessionLabel, argument }`, purpose desconhecido lança) e `cmdNamespaceBranch` passou a **derivar** o descriptor internamente de `{ purpose, bag }`: o parâmetro `argument` deixou de existir, então a classe do bug some estruturalmente (não há o que dropar). `cmdPlan`/`cmdNew`/`cmdFix` passam só `purpose`+`bag`; labels (`lote "…"`/`bag "…"`/`bug "…"`/`sequencial`) e `noun` preservados byte a byte. Sem migration, sem Consent/access, sem UI (Impeccable A). Testes: 7 casos unit novos no `describe` do descriptor (plan/new/fix com e sem bag, bag em branco, bag com aspas/backslash cru — sanitização segue só nos donos, purpose `'constructor'`/desconhecido lança) + os pins de high-risk; `pnpm gate:fast` verde (lint + typecheck + suíte unit full). Débito residual (risk map das ~40 `scripts/lib/*.mjs` unit-pinadas + static scope de `scripts/`) registrado como OPS119++ (#1077). Impl plan: `docs/plans/escala-dry-pos-ops119-impl.md`.
|
||||
120
docs/plans/escala-dry-pos-ops119-impl.md
Normal file
120
docs/plans/escala-dry-pos-ops119-impl.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Impl: OPS119+ — launch do worktree: risk map das libs + pin do wiring do `plan`
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-09-16
|
||||
Issue: #1071
|
||||
Intenção: docs/plans/escala-dry-pos-ops119.md
|
||||
Appetite restante: herdado (~1h eng) — F1 ~30min + F2 ~30min, sem corte
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** fechar os dois achados de score 3 do `/simplify` da OPS119 na mesma superfície (launch do worktree ↔ agent-session). F1: um diff que toca só `scripts/lib/worktree.mjs`, `scripts/lib/agent-session.mjs` ou `scripts/agent-session.mjs` hoje classifica `none` (a unit não roda e o static scope também é `none`) — o risk map deve forçar `full`, fail-closed. F2: o repasse `argument: bag` de `cmdPlan` → `cmdNamespaceBranch` não pode mais ser dropado em silêncio — o wiring vira estrutura pinada por unit.
|
||||
- **O que NÃO negociar:** o outcome é integridade de guard (blast radius fail-closed) e pin do wiring, não refactor de arquitetura; Impeccable A (CLI/shell, sem superfície de produto); appetite ~1h; S1/S2/S3 e o prefill upstream ficam fora; a alternativa rejeitada na intenção (prefixo `scripts/lib/` inteiro em `HIGH_RISK_EXACT`) não pode voltar; migration/Consent/UI não se aplicam.
|
||||
- **O que reavaliar:** (1) “o F2 precisa tornar `cmdNamespaceBranch` testável” — não precisa: **derivar o descriptor dentro do runner elimina o parâmetro dropável por construção**, sem importar `scripts/worktree.mjs` (que hoje dispara o CLI no import — `:950`, `:1043-1062`); (2) “o descriptor deveria sanear o bag” — a sanitização já tem dois donos (`opencodeLaunchDirective:228`, `purposeInvocation:198`) e S2 é defer, então sanear de novo reintroduziria a duplicação; (3) “o descriptor pertence a `scripts/lib/agent-session.mjs`” — apesar de `SESSION_COMMAND_BY_PURPOSE` morar lá, `noun`/`sessionLabel` são vocabulário de branch/namespace do worktree (decisão abaixo).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
cmdPlan -->|purpose plan + bag| cmdNamespaceBranch
|
||||
cmdNew -->|purpose new + bag| cmdNamespaceBranch
|
||||
cmdFix -->|purpose fix + bag| cmdNamespaceBranch
|
||||
cmdNamespaceBranch -->|deriva| namespaceLaunchDescriptor
|
||||
namespaceLaunchDescriptor -->|noun, sessionLabel, argument| cmdNamespaceBranch
|
||||
cmdNamespaceBranch --> opencodeLaunchDirective
|
||||
cmdNamespaceBranch --> headlessDirective
|
||||
worktree.unit.spec --> namespaceLaunchDescriptor
|
||||
testAffected.unit.spec --> HIGH_RISK_EXACT
|
||||
ciSkipInvariants.unit.spec --> HIGH_RISK_EXACT
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — extrair `namespaceLaunchDescriptor({ purpose, bag })` para `scripts/lib/worktree.mjs` e fazer `cmdNamespaceBranch` **derivar o descriptor internamente** a partir de `{ purpose, bag }`, removendo `noun`/`sessionLabel`/`argument` da assinatura; os 3 callers passam só `purpose`+`bag` (+ `branchName`, `flags`, `headless`, `directivePath`). Porque a classe do S4 (parâmetro dropado no caller) deixa de existir estruturalmente: não há `argument` para dropar. O pin unitário cai na lib pura que o spec já importa (`tests/unit/worktree.unit.spec.ts:17-35`), sem exigir guard de importabilidade em `worktree.mjs`.
|
||||
**Rejeitadas:** B — tornar `cmdNamespaceBranch` injetável/importável e testar as opções com mocks: exigiria adicionar guard `isMain` a `scripts/worktree.mjs` e costurar seams de `git`/`fs`/`provision` que hoje são `execFileSync`/`readFileSync` diretos (`:696-746`) — refactor grande para ~0 teste novo além do descriptor; C — manter os parâmetros e exportar só o mapa: o pin pegaria o descriptor, mas o caller continuaria podendo dropar `argument`, ou seja, a classe do bug sobrevive; o teste que cria worktree de verdade segue rejeitado (lento, side effects, colide no slot, já descartado na intenção).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`namespaceLaunchDescriptor`** (`scripts/lib/worktree.mjs`, **novo**): mapa puro `purpose`+`bag` → `{ noun, sessionLabel, argument }`, absorvendo os literais hoje inline nos 3 callers (`noun` por purpose; `sessionLabel` = `lote "…"`/`bag "…"`/`bug "…"` com bag, senão `sequencial`; `argument` = bag para `plan`/`fix`, `null` para `new`). Não reimplementa regra de branch nem sanitização — reusa a convenção já existente no módulo (prefixos e `namespaceBranchName` ficam intocados).
|
||||
- **`cmdNamespaceBranch`** (`scripts/worktree.mjs:679-747`): remove `noun`/`sessionLabel`/`argument` da assinatura, recebe `bag`, e chama `namespaceLaunchDescriptor({ purpose, bag })` uma vez; os usos em `:709` (erro), `:716` (sessão exibida), `:737` (`headlessDirective({ report })`) e `:744` (`printLaunchDirective({ argument })`) passam a ler o descriptor.
|
||||
- **`cmdPlan`/`cmdNew`/`cmdFix`** (`scripts/worktree.mjs:759-769`, `:780-789`, `:806-818`): passam `purpose`+`bag`; `branchName`, `flags`, `headless`, `directivePath` seguem iguais.
|
||||
- **`HIGH_RISK_EXACT`** (`scripts/lib/test-affected-core.mjs:25-53`): acrescentar `scripts/agent-session.mjs`, `scripts/lib/agent-session.mjs`, `scripts/lib/worktree.mjs`.
|
||||
- **Pins de teste:** `tests/unit/testAffected.unit.spec.ts` (loop `:22-35`), `tests/unit/ciSkipInvariants.unit.spec.ts` (`:100-116`), `tests/unit/worktree.unit.spec.ts` (novo `describe` do descriptor).
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** n/a.
|
||||
- **UI:** Impeccable A — N/A.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
- **Dono do descriptor: `scripts/lib/worktree.mjs` vs `scripts/lib/agent-session.mjs`.** Opções: A) `worktree.mjs`; B) `agent-session.mjs` (junto de `SESSION_COMMAND_BY_PURPOSE`). Recomendação: A — `noun`/`sessionLabel` são vocabulário de branch/namespace do worktree (mesmo módulo de `planBranchName`/`workBranchName`/`fixBranchName` e do `opencodeLaunchDirective` que consome `argument`), e o spec que já importa o módulo (`worktree.unit.spec.ts:17-35`) pina sem import cruzado novo. Rejeitadas: B porque espalharia metade do contrato de launch (label de branch) para o dono do ciclo de vida de sessão, criando dependência invertida; o que é genuinamente compartilhado com `agent-session` é só o flag `--argument` na borda CLI, que os dois specs já pinam de cada lado.
|
||||
- **Bag no descriptor: cru vs saneado.** Opções: A) cru (trim só para decidir o label); B) aplicar `replace(/["\\]/g,'')` no descriptor. Recomendação: A — a sanitização é dos donos (`opencodeLaunchDirective:228` no caminho terminal, `purposeInvocation:198` no caminho do driver) e S2 é defer explícito. Rejeitadas: B porque criaria o **terceiro** call site da sanitização — exatamente o gatilho de S2 — e faria o `report` do `headlessDirective` divergir do comportamento atual (que sanitiza só na montagem do argv).
|
||||
- **`purpose` desconhecido: fail-high vs fail-safe.** Opções: A) `namespaceLaunchDescriptor` lança em purpose não mapeado; B) degradar para um descriptor neutro. Recomendação: A — os únicos callers são `plan`/`new`/`fix`; um purpose novo sem descriptor é erro de wiring e deve falhar alto (mesmo padrão de `resolveWorktreeModel`). Rejeitadas: B porque inventaria `noun`/label silenciosamente, que é a própria classe de bug que o pin fecha.
|
||||
- **Corte do pin de F1: membership-only vs membership+existência em disco.** Opções: A) só `HIGH_RISK_EXACT.has(path)`; B) membership + `fs` confirmando que cada um dos 3 paths resolve. Recomendação: B, no `it` dedicado de `ciSkipInvariants` — um literal high-risk morto (typo/rename) é exatamente a integridade que o S6 protege e não existe invariante de existência hoje. Rejeitadas: A deixa o valor do F1 degradar em silêncio; o invariante de existência **repo-wide** sobre toda a lista (que revelaria entradas antigas mortas) fica como follow-up nomeado, fora do appetite.
|
||||
- **Cross-pin `purposeInvocation` × descriptor (2ª perna).** Opções: A) pinar os dois juntos num spec; B) manter os dois pins independentes. Recomendação: B — o contrato entre worktree e agent-session é o flag `--argument`, já pinado nas duas pontas (`opencodeLaunchDirective` por purpose e `purposeInvocation` por argument); um teste combo só reafirmaria os dois. Rejeitadas: A por duplicação e por acoplar dois módulos puros num spec; gatilho de revisitação: se o flag mudar de transporte (env/stdin) ou se S2 consolidar a sanitização num único dono.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **F1 — risk map (~30min).** Adicionar os 3 arquivos a `HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs:25-53`); acrescentar os 3 ao loop de `tests/unit/testAffected.unit.spec.ts:22-35`; novo `it` em `tests/unit/ciSkipInvariants.unit.spec.ts` com nome de contrato (“launch/agent-session modules are high-risk — fail-closed blast radius”) pinando membership + presença em disco dos 3. Verificação: `classifyTestScope([{ path: 'scripts/lib/worktree.mjs', status: 'M' }]).mode === 'full'` para cada um dos 3 (coberto pelo loop) e `pnpm test:unit` verde.
|
||||
2. **F2 — descriptor + wiring (~30min).** Extrair `namespaceLaunchDescriptor` para `scripts/lib/worktree.mjs`; fazer `cmdNamespaceBranch` derivar internamente e remover `noun`/`sessionLabel`/`argument` da assinatura; simplificar os 3 callers para `purpose`+`bag`. Adicionar `describe('namespaceLaunchDescriptor …')` em `tests/unit/worktree.unit.spec.ts` cobrindo plan com/sem bag (argument threaded + label `lote "…"` vs `sequencial`), new com/sem bag (argument sempre `null`), fix com/sem bag, e purpose desconhecido → throw. Verificação: `pnpm test:unit` verde e `tsc --noEmit` limpo (assinatura nova sem sobras).
|
||||
3. **Gates.** `pnpm gate:fast` (lint + typecheck + unit); push via `pnpm push`. Sem migration e sem e2e novo (Impeccable A, sem superfície de produto).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Guard `isMain` em `scripts/worktree.mjs` (padrão de `scripts/check-test-locations.mjs:68-70`): só seria preciso na opção B; com o descriptor derivado internamente, não é necessário — se um dia `worktree.mjs` precisar de teste direto, é Issue própria.
|
||||
- Prefixo `scripts/lib/` inteiro em `HIGH_RISK_EXACT`: rejeitado na intenção (engoliria as ~40 libs `scripts/lib/*.mjs` sem harness e forçaria `full` em diffs triviais). O resíduo “lib-only diff → unit `none`” para as demais libs fica nomeado como follow-up, não implementado.
|
||||
- Invariante de existência em disco sobre a lista inteira de `HIGH_RISK_EXACT`/`HIGH_RISK_PREFIXES`: escopo maior que os 3 alvos; follow-up.
|
||||
- S1 (fallback fail-safe de `purposeInvocation:202`), S2 (sanitização duplicada, gatilho 3º call site), S3 (política driverless), prefill-sem-submit upstream: fora, conforme a intenção.
|
||||
- Cross-pin `purposeInvocation` × descriptor: fora (decisão acima).
|
||||
|
||||
## Adiado com gatilho (triage pós-simplify)
|
||||
|
||||
- **Generalização do risk map para as demais `scripts/lib/*.mjs` unit-pinadas**
|
||||
(ex.: `scripts/lib/worktree-env.mjs`, pinada por `tests/unit/worktree.unit.spec.ts:5-16`):
|
||||
a intenção só pediu os 3 arquivos de launch/agent-session e rejeitou o prefixo
|
||||
`scripts/lib/` inteiro. A classe remanescente (diff só-lib → unit `none`) virou
|
||||
a Issue `OPS119++` (#1077, `depends: [OPS119+]`), plano
|
||||
`docs/plans/escala-dry-pos-scripts-blast-radius.md`.
|
||||
- **3ª codificação de “purpose carrega bag”** (`entry.carriesBag` em
|
||||
`namespaceLaunchDescriptor` × `opencodeLaunchDirective` × `purposeInvocation`):
|
||||
defer. Gatilho: 4º encoding **ou** mudança de transporte do `--argument`
|
||||
(env/stdin) → consolidar membership+sanitização num dono único.
|
||||
- **`classifyStaticScope` segue `none` para `scripts/*.mjs`** (lint/typecheck/knip
|
||||
pulados num diff só-scripts): entrou como fase 2 da Issue `OPS119++`.
|
||||
|
||||
## Explicitamente fora (triage pós-simplify)
|
||||
|
||||
- Bag em branco → `argument` cru no descriptor × `opencodeHeadlessArgs` lançando
|
||||
“report vazio”: comportamento pré-existente e intencional (fail-high do headless);
|
||||
sem reuso bloqueado. Descartado.
|
||||
- Drift cosmético do docblock/reason de `HIGH_RISK_EXACT` (“schema/lockfile/test
|
||||
harness”): descartado (cheap_polish score ≤2, sem impacto funcional).
|
||||
- `noun` como misnomer (`'de planejamento'`/`'neutro'`/`'de correção de bug'` são
|
||||
qualificadores): rename de pureza mantido por continuidade com o código original.
|
||||
Descartado.
|
||||
- Duplicação dos pins de membership entre `testAffected` e `ciSkipInvariants`:
|
||||
já resolvido/aceito no plano (camadas distintas — classificação `full` ×
|
||||
membership+existência).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **O descriptor não cobre o caminho `next`.** `cmdNext` não usa `cmdNamespaceBranch`; `namespaceLaunchDescriptor` só recebe `plan`/`new`/`fix`. Mitigação: `purpose` desconhecido lança (fail-high), então um futuro uso indevido falha alto em vez de gerar label errado.
|
||||
- **Mudança de assinatura de `cmdNamespaceBranch` deixa parâmetro morto.** Mitigação: `tsc --noEmit` e a leitura dos 3 callers; nenhum outro caller existe (verificado — só `cmdPlan`/`cmdNew`/`cmdFix`).
|
||||
- **Label/id divergirem do texto atual exibido.** Mitigação: os casos do novo `describe` fixam os três labels e o `sequencial` byte a byte, contra os literais de `:765`/`:786`/`:812`.
|
||||
- **Entrada high-risk morta (path renomeado).** Mitigação: asserção de existência em disco no `it` novo de `ciSkipInvariants` para os 3 alvos.
|
||||
- **F1 forçar `full` em diffs envolvendo essas libs aumenta custo de CI.** Esperado e desejado: são o harness de launch/agent-session; fail-closed é o outcome.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: diff só-lib de launch/agent-session classifica `full` (F1) e o repasse do bag não pode mais ser dropado por parâmetro (F2).
|
||||
- [ ] Invariantes AGENTS/engineering-standards: identifiers em inglês, prosa pt-BR, sem comentários de código; nenhuma migration; nenhum acesso/Consent/UI tocado.
|
||||
- [ ] Testes de domínio previstos: `tests/unit/testAffected.unit.spec.ts`, `tests/unit/ciSkipInvariants.unit.spec.ts`, `tests/unit/worktree.unit.spec.ts` — todos unit, sem harness novo.
|
||||
- [ ] `pnpm gate:fast` verde; `pnpm push` no fluxo normal de PR.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
1. Decisões caras têm rejeitadas? **5/5** — dono do descriptor, cru-vs-saneado, fail-high-vs-neutro, corte do pin de F1 e cross-pin registraram Opções/Recomendação/Rejeitadas.
|
||||
2. Cabe no appetite (~1h)? **5/5** — F1 ~30min (3 literais + 2 pins) e F2 ~30min (extração pequena em lib pura + rewire de 3 callers + 1 describe); sem migration, sem e2e.
|
||||
3. Rabbit holes nomeados? **5/5** — guard `isMain`, prefixo `scripts/lib/` inteiro, existência repo-wide, S1/S2/S3, cross-pin e prefill.
|
||||
4. Depth check reusa shells/helpers? **4/5** — reusa o spec que já importa `scripts/lib/worktree.mjs`, os padrões de pin de `HIGH_RISK_EXACT` e a convenção existente do módulo; não cria pass-through (o descriptor é dono real da derivação, não repassa nada).
|
||||
5. Intenção (aceite de produto) intacta? **5/5** — a engenharia não reescreveu o outcome; o F2 foi feito _mais_ forte que a opção A original (o parâmetro some), sem ampliar escopo.
|
||||
|
||||
**Score: 24/25 — aprovado para execução.**
|
||||
47
docs/plans/escala-dry-pos-scripts-blast-radius.md
Normal file
47
docs/plans/escala-dry-pos-scripts-blast-radius.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# OPS119++ — blast radius de `scripts/`: risk map das libs restantes + static scope
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-09-16
|
||||
Issue: #1077 (`depends: [OPS119+]`)
|
||||
Intenção: débito pós-entrega da OPS119+ (#1071)
|
||||
Priority: P3
|
||||
Impeccable: A — N/A (CLI/CI, sem superfície de produto)
|
||||
Appetite: ~1h eng
|
||||
|
||||
## Origem
|
||||
|
||||
Triagem do `/simplify` da OPS119+ (#1071): a entrega fechou a classe S6
|
||||
(diff só-lib classificava `none` → unit não rodava) apenas para os 3 arquivos de
|
||||
launch/agent-session. O resíduo é a mesma classe nos demais módulos `scripts/`.
|
||||
|
||||
- **F1 — risk map das libs restantes (expensive_lock, score 3):**
|
||||
`HIGH_RISK_EXACT` (`scripts/lib/test-affected-core.mjs:25-56`) cobre só alguns
|
||||
dos ~40 módulos `scripts/lib/*.mjs` importados por specs unit.
|
||||
`scripts/lib/worktree-env.mjs` — mesma superfície de launch, pinada por
|
||||
`tests/unit/worktree.unit.spec.ts:5-16` e importada por
|
||||
`scripts/lib/auto-unblock.mjs:10` — segue classificando `none` num diff
|
||||
só-nele: a unit que a cobre não roda. **Opções:** A) invariante derivado
|
||||
(todo módulo `scripts/**` importado por um spec unit é high-risk) — fail-closed,
|
||||
mata a classe na raiz; B) estender o allowlist literal com os arquivos
|
||||
conhecidos — menor blast radius, mas reabre o buraco a cada lib nova.
|
||||
**Recomendação:** A, com allowlist de exceções explícitas se houver falso
|
||||
positivo. **Rejeitadas:** prefixo `scripts/lib/` inteiro (rejeitado na
|
||||
intenção-mãe: engole libs sem harness e força `full` em diffs triviais).
|
||||
- **F2 — static scope de `scripts/` (cheap_polish, score 2):** `isCodePath`
|
||||
(`scripts/lib/test-affected-core.mjs:131-140`) não inclui `scripts/`, então
|
||||
`classifyStaticScope` devolve `none` e lint/typecheck/knip ficam pulados num
|
||||
diff só-scripts. Estender `isCodePath` a `scripts/` (revisar o custo de CI de
|
||||
rodar o static num diff de `.mjs`).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **F1 — derivar/estender o risk map** e atualizar os pins do dono
|
||||
(`testAffected.unit.spec.ts` / `ciSkipInvariants.unit.spec.ts`). Verificação:
|
||||
um diff só-`scripts/lib/<lib>` devolve `full` em `classifyTestScope`.
|
||||
2. **F2 — `isCodePath` cobrir `scripts/`**; pin em `ciSkipInvariants`. Verificação:
|
||||
um diff só-scripts devolve `code` em `classifyStaticScope`.
|
||||
|
||||
## Rabbit holes / Não escopo
|
||||
|
||||
- Ampliar o escopo para qualquer superfície fora de `scripts/` (produto/UI): fora.
|
||||
- Reescrever o harness de classificação: fora — só o risk map e `isCodePath`.
|
||||
|
|
@ -34,6 +34,9 @@ export const HIGH_RISK_EXACT = new Set([
|
|||
'scripts/seed-minimal.mjs',
|
||||
'scripts/lib/seed-minimal-manifest.mjs',
|
||||
'scripts/worktree.mjs',
|
||||
'scripts/agent-session.mjs',
|
||||
'scripts/lib/agent-session.mjs',
|
||||
'scripts/lib/worktree.mjs',
|
||||
'scripts/lib/test-affected-core.mjs',
|
||||
'scripts/lib/e2e-affected-manifest.mjs',
|
||||
'scripts/test-affected.mjs',
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ export const branchNameForIssue = (issue, maxLength = 60) => {
|
|||
return `${code}-${slug.slice(0, keep)}`
|
||||
}
|
||||
|
||||
const hasBag = (bag) => typeof bag === 'string' && bag.trim().length > 0
|
||||
|
||||
/**
|
||||
* Shared branch naming for namespace worktrees NOT tied to the claim queue
|
||||
* (`plan`, `new`). Every invocation must land on a DIFFERENT branch so
|
||||
|
|
@ -148,9 +150,7 @@ export const branchNameForIssue = (issue, maxLength = 60) => {
|
|||
* `<Code>-<slug>` branches.
|
||||
*/
|
||||
const namespaceBranchName = ({ prefix, bag = '', taken = new Set(), fallback }) => {
|
||||
const hasBag = typeof bag === 'string' && bag.trim().length > 0
|
||||
|
||||
if (!hasBag) {
|
||||
if (!hasBag(bag)) {
|
||||
for (let n = 1; ; n += 1) {
|
||||
const candidate = `${prefix}${n}`
|
||||
if (!taken.has(candidate)) return candidate
|
||||
|
|
@ -290,3 +290,33 @@ export const workBranchName = ({ bag = '', taken = new Set() }) =>
|
|||
*/
|
||||
export const fixBranchName = ({ bag = '', taken = new Set() }) =>
|
||||
namespaceBranchName({ prefix: `${FIX_BRANCH_PREFIX}/`, bag, taken, fallback: 'fix' })
|
||||
|
||||
const NAMESPACE_LAUNCH = new Map([
|
||||
['plan', { noun: 'de planejamento', labelPrefix: 'lote', carriesBag: true }],
|
||||
['new', { noun: 'neutro', labelPrefix: 'bag', carriesBag: false }],
|
||||
['fix', { noun: 'de correção de bug', labelPrefix: 'bug', carriesBag: true }],
|
||||
])
|
||||
|
||||
/**
|
||||
* Launch descriptor for a namespace worktree (`plan`/`new`/`fix`) — the pure
|
||||
* purpose→options mapping the runner (`cmdNamespaceBranch`) derives from
|
||||
* `purpose`+`bag` instead of receiving as droppable parameters. `noun` and
|
||||
* `sessionLabel` are the human labels of the provisioning log (`lote "…"`/
|
||||
* `bag "…"`/`bug "…"`, or `sequencial` without a bag); `argument` rides the
|
||||
* launch directive only for the purposes whose skill accepts a bag (`plan`
|
||||
* opening message, `fix` bug description) — `new` is neutral and never carries
|
||||
* one. Unknown purposes throw: a purpose without a descriptor is a wiring
|
||||
* error, never a silently invented label.
|
||||
* @param {{ purpose?: string | null, bag?: string | null }} [options]
|
||||
*/
|
||||
export const namespaceLaunchDescriptor = ({ purpose = null, bag = null } = {}) => {
|
||||
const entry = NAMESPACE_LAUNCH.get(purpose)
|
||||
if (!entry) {
|
||||
throw new Error(`namespaceLaunchDescriptor: purpose desconhecido "${purpose}"`)
|
||||
}
|
||||
return {
|
||||
noun: entry.noun,
|
||||
sessionLabel: hasBag(bag) ? `${entry.labelPrefix} "${bag}"` : 'sequencial',
|
||||
argument: entry.carriesBag ? bag : null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ import {
|
|||
branchNameForIssue,
|
||||
fixBranchName,
|
||||
headlessDirective,
|
||||
namespaceLaunchDescriptor,
|
||||
OPENCODE_PRESET_MODEL,
|
||||
opencodeLaunchDirective,
|
||||
planBranchName,
|
||||
|
|
@ -672,25 +673,27 @@ const buildTakenBranchNames = () => {
|
|||
* `branchName`), provisions the same isolated env as `next` (with `purpose`)
|
||||
* and prints the `cd <dir>` line by default (`--stay` suppresses). Every
|
||||
* invocation creates a DIFFERENT worktree — parallel sessions never share one.
|
||||
* `argument` (optional) rides the launch prompt — for `fix`, the bug
|
||||
* The launch labels and the `argument` that rides the launch prompt are derived
|
||||
* from `purpose`+`bag` by `namespaceLaunchDescriptor` (single source of truth —
|
||||
* there is no droppable `argument` parameter). For `fix` the bag is the bug
|
||||
* description the `/bug-fix` skill receives; for `plan`, the opening message
|
||||
* the `/plan-issue` driver auto-submits (absent → driverless session).
|
||||
* the `/plan-issue` driver auto-submits (absent → driverless session); `new`
|
||||
* carries none.
|
||||
*/
|
||||
const cmdNamespaceBranch = async ({
|
||||
stay,
|
||||
skipMigrate,
|
||||
purpose,
|
||||
noun,
|
||||
sessionLabel,
|
||||
bag = null,
|
||||
branchName,
|
||||
flags = {},
|
||||
argument = null,
|
||||
headless = false,
|
||||
directivePath = null,
|
||||
}) => {
|
||||
if (headless && !directivePath) {
|
||||
die('`--headless` requer `--directive <path>` (a diretiva vai para arquivo, nunca stdout).')
|
||||
}
|
||||
const { noun, sessionLabel, argument } = namespaceLaunchDescriptor({ purpose, bag })
|
||||
// Fail-high on conflicting model flags before touching git.
|
||||
const launchModel = resolveLaunchModel(flags)
|
||||
git(['fetch', 'origin'])
|
||||
|
|
@ -761,11 +764,9 @@ const cmdPlan = async (stay, skipMigrate, bag, flags = {}) =>
|
|||
stay,
|
||||
skipMigrate,
|
||||
purpose: 'plan',
|
||||
noun: 'de planejamento',
|
||||
sessionLabel: bag && bag.trim() ? `lote "${bag}"` : 'sequencial',
|
||||
bag,
|
||||
branchName: (taken) => planBranchName({ bag, taken }),
|
||||
flags,
|
||||
argument: bag,
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -782,8 +783,7 @@ const cmdNew = async (stay, skipMigrate, bag, flags = {}) =>
|
|||
stay,
|
||||
skipMigrate,
|
||||
purpose: 'new',
|
||||
noun: 'neutro',
|
||||
sessionLabel: bag && bag.trim() ? `bag "${bag}"` : 'sequencial',
|
||||
bag,
|
||||
branchName: (taken) => workBranchName({ bag, taken }),
|
||||
flags,
|
||||
})
|
||||
|
|
@ -808,11 +808,9 @@ const cmdFix = async (stay, skipMigrate, bag, flags = {}, headless = false, dire
|
|||
stay,
|
||||
skipMigrate,
|
||||
purpose: 'fix',
|
||||
noun: 'de correção de bug',
|
||||
sessionLabel: bag && bag.trim() ? `bug "${bag}"` : 'sequencial',
|
||||
bag,
|
||||
branchName: (taken) => fixBranchName({ bag, taken }),
|
||||
flags,
|
||||
argument: bag,
|
||||
headless,
|
||||
directivePath,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
|
|
@ -115,6 +115,20 @@ describe('ciSkipInvariants', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('launch/agent-session modules are high-risk — a lib-only diff cannot skip unit (OPS119+)', () => {
|
||||
// The pure launch libs are unit-pinned, so a diff touching only them must
|
||||
// still run the unit suite; the existence check keeps the entry from going
|
||||
// stale on a rename.
|
||||
for (const path of [
|
||||
'scripts/agent-session.mjs',
|
||||
'scripts/lib/agent-session.mjs',
|
||||
'scripts/lib/worktree.mjs',
|
||||
]) {
|
||||
expect(HIGH_RISK_EXACT.has(path), path).toBe(true)
|
||||
expect(existsSync(join(repoRoot, path)), path).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('deploy starts on push to main with an active-run guard, dispatch kept (OPS104)', () => {
|
||||
// OPS104 supersedes the OPS71 manual-only pin: a push to main starts the
|
||||
// deploy run in the same workflow, and the preflight guard skips it when
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ describe('classifyTestScope (OPS5)', () => {
|
|||
'src/collections/Post.ts',
|
||||
'scripts/lib/seed-minimal-manifest.mjs',
|
||||
'scripts/worktree.mjs',
|
||||
'scripts/agent-session.mjs',
|
||||
'scripts/lib/agent-session.mjs',
|
||||
'scripts/lib/worktree.mjs',
|
||||
'scripts/lib/test-affected-core.mjs',
|
||||
'tests/helpers/campaignFixtures.ts',
|
||||
'tests/unit/ciSkipInvariants.unit.spec.ts',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
fixBranchName,
|
||||
headlessDirective,
|
||||
issueCodeAndSubject,
|
||||
namespaceLaunchDescriptor,
|
||||
OPENCODE_HEADLESS_COMMAND,
|
||||
OPENCODE_PRESET_MODEL,
|
||||
opencodeHeadlessArgs,
|
||||
|
|
@ -318,6 +319,70 @@ describe('opencodeLaunchDirective (terminal-only agent-session launch, OPS26 + O
|
|||
})
|
||||
})
|
||||
|
||||
describe('namespaceLaunchDescriptor (purpose→options single source, OPS119+)', () => {
|
||||
it('plan carries the bag as argument and labels the session with it', () => {
|
||||
expect(namespaceLaunchDescriptor({ purpose: 'plan', bag: 'revisa o gate' })).toEqual({
|
||||
noun: 'de planejamento',
|
||||
sessionLabel: 'lote "revisa o gate"',
|
||||
argument: 'revisa o gate',
|
||||
})
|
||||
})
|
||||
|
||||
it('plan without a bag is driverless: sequential label, no argument', () => {
|
||||
expect(namespaceLaunchDescriptor({ purpose: 'plan' })).toEqual({
|
||||
noun: 'de planejamento',
|
||||
sessionLabel: 'sequencial',
|
||||
argument: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('a blank bag is labeled sequential (trim decides the label) but still rides raw', () => {
|
||||
expect(namespaceLaunchDescriptor({ purpose: 'plan', bag: ' ' })).toEqual({
|
||||
noun: 'de planejamento',
|
||||
sessionLabel: 'sequencial',
|
||||
argument: ' ',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not sanitize the bag — quotes/backslashes ride raw (sanitization stays with the owners)', () => {
|
||||
const descriptor = namespaceLaunchDescriptor({ purpose: 'plan', bag: 'a "b"\\c ideia' })
|
||||
expect(descriptor.argument).toBe('a "b"\\c ideia')
|
||||
expect(descriptor.sessionLabel).toBe('lote "a "b"\\c ideia"')
|
||||
})
|
||||
|
||||
it('new is neutral: it never carries an argument, even with a bag', () => {
|
||||
expect(namespaceLaunchDescriptor({ purpose: 'new' })).toEqual({
|
||||
noun: 'neutro',
|
||||
sessionLabel: 'sequencial',
|
||||
argument: null,
|
||||
})
|
||||
expect(namespaceLaunchDescriptor({ purpose: 'new', bag: 'ideia solta' })).toEqual({
|
||||
noun: 'neutro',
|
||||
sessionLabel: 'bag "ideia solta"',
|
||||
argument: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('fix carries the bug description and labels it', () => {
|
||||
expect(namespaceLaunchDescriptor({ purpose: 'fix', bag: '500 no autosave' })).toEqual({
|
||||
noun: 'de correção de bug',
|
||||
sessionLabel: 'bug "500 no autosave"',
|
||||
argument: '500 no autosave',
|
||||
})
|
||||
expect(namespaceLaunchDescriptor({ purpose: 'fix' })).toEqual({
|
||||
noun: 'de correção de bug',
|
||||
sessionLabel: 'sequencial',
|
||||
argument: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on an unknown/absent purpose — a purpose without a descriptor is a wiring error', () => {
|
||||
for (const purpose of [null, undefined, 'next', 'bogus', 'constructor']) {
|
||||
expect(() => namespaceLaunchDescriptor({ purpose })).toThrow(/purpose desconhecido/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencodeHeadlessArgs + headlessDirective (OPS106 auto-unblock)', () => {
|
||||
it('builds the opencode run argv with the bug-fix command and the report as one arg', () => {
|
||||
expect(
|
||||
|
|
|
|||
Loading…
Reference in a new issue