@spec-wave/cli 0.30.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ // Push com `pull --rebase` + retry — spec-qa-lead §3.2.
2
+ //
3
+ // O `qa` commita o bug.md no checkout e PUBLICA: com o `qa-lead` despachando N
4
+ // containers em paralelo, um commit que fica só no container morre com ele — e
5
+ // dois containers reprovando ao mesmo tempo fazem o segundo push ser rejeitado.
6
+ // O retry cobre exatamente a rejeição de corrida (`non-fast-forward`,
7
+ // `cannot lock ref`); recusa DELIBERADA do remoto (GH006/branch protegida,
8
+ // auth) falha na primeira, porque repetir uma recusa de política só multiplica
9
+ // o custo — a mesma divisão do isTransientProviderError do lado da IA.
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ import { setTimeout as sleep } from 'node:timers/promises';
13
+
14
+ /** Tentativas de pull+push antes de desistir (teto da spec: 5). */
15
+ export const PUSH_MAX_ATTEMPTS = 5;
16
+
17
+ // Rejeições que significam "outro push chegou antes" — repetir resolve.
18
+ const TRANSIENT_RE = /non-fast-forward|cannot lock ref|fetch first|failed to push some refs|cannot rebase onto multiple branches|shallow update not allowed/i;
19
+
20
+ // Recusas de política/credencial — repetir NUNCA resolve.
21
+ const PERMANENT_RE = /GH006|protected branch|permission denied|authentication|403|could not read Username|denied to|no upstream branch|not a git repository|could not resolve host|unable to access/i;
22
+
23
+ /**
24
+ * A rejeição de push parece uma corrida perdida? (função PURA)
25
+ *
26
+ * Na dúvida (mensagem que não casa com nenhum padrão), transitória: o custo de
27
+ * uma tentativa a mais é segundos; o de desistir cedo é perder um Bug cujo
28
+ * cenário já foi pago.
29
+ *
30
+ * @param {string} stderr saída de erro do git
31
+ * @returns {boolean}
32
+ */
33
+ export function isTransientPushRejection(stderr) {
34
+ const text = String(stderr || '');
35
+ if (PERMANENT_RE.test(text)) return false;
36
+ if (TRANSIENT_RE.test(text)) return true;
37
+ return true;
38
+ }
39
+
40
+ function git(cwd, args) {
41
+ // stderr capturado de propósito: a classificação transitória/permanente
42
+ // depende de LER a rejeição — `stdio: 'inherit'` a jogaria no terminal.
43
+ return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
44
+ }
45
+
46
+ /**
47
+ * `pull --rebase` + `push`, repetindo enquanto a rejeição for de corrida.
48
+ *
49
+ * Backoff linear com jitter (1s, 2s, 3s…): os perdedores da corrida se
50
+ * espalham em vez de colidirem de novo no mesmo instante.
51
+ *
52
+ * @param {object} params
53
+ * @param {string} params.cwd raiz do checkout
54
+ * @param {number} [params.attempts]
55
+ * @param {(ms: number) => Promise<void>} [params.delay] injetável nos testes
56
+ * @returns {Promise<{ ok: boolean, attempts: number, error: string|null }>}
57
+ */
58
+ export async function pushWithRebase({ cwd, attempts = PUSH_MAX_ATTEMPTS, delay = sleep } = {}) {
59
+ let lastError = null;
60
+ for (let attempt = 1; attempt <= attempts; attempt++) {
61
+ try {
62
+ try {
63
+ git(cwd, ['pull', '--rebase', '--autostash']);
64
+ } catch (err) {
65
+ // Sem upstream configurado o pull falha, mas o push ainda pode passar
66
+ // (primeiro push da branch) — deixa o push decidir.
67
+ lastError = String(err.stderr || err.message || err);
68
+ }
69
+ git(cwd, ['push']);
70
+ return { ok: true, attempts: attempt, error: null };
71
+ } catch (err) {
72
+ lastError = String(err.stderr || err.message || err);
73
+ if (!isTransientPushRejection(lastError)) {
74
+ return { ok: false, attempts: attempt, error: lastError.trim() };
75
+ }
76
+ if (attempt < attempts) {
77
+ await delay(attempt * 1000 + Math.floor(Math.random() * 500));
78
+ }
79
+ }
80
+ }
81
+ return { ok: false, attempts, error: (lastError || 'push rejeitado').trim() };
82
+ }
@@ -0,0 +1,142 @@
1
+ // Cache local de leituras do GitHub (I/O fino — as decisões de frescor são
2
+ // puras, em lib/dependency-map.mjs).
3
+ //
4
+ // Vive em `<root>/.spec-wave/cache/` — o diretório de scratch já gitignored:
5
+ // cache é POR CLONE, nunca commitado (o que é commitável é o
6
+ // dependency-map.json, que não expira). Regras que não se negociam:
7
+ //
8
+ // • best-effort nos dois sentidos: ler cache corrompido devolve null (e
9
+ // apaga), gravar NUNCA lança — cache indisponível vira refetch, não erro;
10
+ // • só SUCESSO entra: um `.catch(() => [])` de rede não pode virar uma lista
11
+ // vazia cacheada por 10 minutos — o dado errado com cara de fresco é pior
12
+ // que a chamada repetida que o cache existe para evitar;
13
+ // • entrada de outro owner/repo (worktree, config trocado) é ignorada;
14
+ // • mutação nunca lê cache e sempre INVALIDA o que tocou.
15
+
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ export const CACHE_VERSION = 1;
20
+
21
+ /** Default do TTL: 10 minutos — cobre um encadeamento order → implement → merge. */
22
+ export const DEFAULT_CACHE_TTL_SEC = 600;
23
+
24
+ const CACHE_DIR = ['.spec-wave', 'cache'];
25
+
26
+ /** Caminho absoluto de uma entrada. */
27
+ export function cachePath(root, key) {
28
+ return path.join(root || process.cwd(), ...CACHE_DIR, `${sanitizeKey(key)}.json`);
29
+ }
30
+
31
+ // A chave vira nome de arquivo: nada de separadores de caminho.
32
+ function sanitizeKey(key) {
33
+ return String(key).replace(/[^A-Za-z0-9._-]/g, '_');
34
+ }
35
+
36
+ /**
37
+ * TTL efetivo do cache (função PURA).
38
+ *
39
+ * Precedência: env `SPEC_WAVE_CACHE_TTL` (segundos; `0` desliga) →
40
+ * `cache.ttlSec` do .spec-wave.json → 600.
41
+ *
42
+ * @param {object|null} config o .spec-wave.json
43
+ * @param {object} [env]
44
+ * @returns {number} segundos (0 = desligado)
45
+ */
46
+ export function resolveCacheTtl(config, env = process.env) {
47
+ const fromEnv = env?.SPEC_WAVE_CACHE_TTL;
48
+ if (fromEnv !== undefined && fromEnv !== '') {
49
+ const n = Number(fromEnv);
50
+ if (Number.isFinite(n) && n >= 0) return n;
51
+ }
52
+ const fromConfig = config?.cache?.ttlSec;
53
+ if (Number.isFinite(fromConfig) && fromConfig >= 0) return fromConfig;
54
+ return DEFAULT_CACHE_TTL_SEC;
55
+ }
56
+
57
+ /**
58
+ * Lê uma entrada do cache. `null` em qualquer defeito: ausente, JSON inválido,
59
+ * versão desconhecida, owner/repo (ou extra, ex.: projectId) divergentes.
60
+ *
61
+ * @param {string} root raiz do projeto
62
+ * @param {string} key
63
+ * @param {{owner?:string, repo?:string, [k:string]: *}} [expect] campos que a
64
+ * entrada precisa bater para valer neste contexto
65
+ * @returns {{ kind:string, fetchedAt:string, data:* }|null}
66
+ */
67
+ export function readCacheEntry(root, key, expect = {}) {
68
+ const file = cachePath(root, key);
69
+ let entry;
70
+ try {
71
+ entry = JSON.parse(readFileSync(file, 'utf-8'));
72
+ } catch {
73
+ // Corrompida não volta a atrapalhar: apaga best-effort.
74
+ try { rmSync(file, { force: true }); } catch { /* melhor esforço */ }
75
+ return null;
76
+ }
77
+ if (entry?.v !== CACHE_VERSION || typeof entry.fetchedAt !== 'string') return null;
78
+ for (const [field, expected] of Object.entries(expect)) {
79
+ if (expected != null && entry[field] !== expected) return null;
80
+ }
81
+ return entry;
82
+ }
83
+
84
+ /**
85
+ * Grava uma entrada. NUNCA lança — disco cheio/sem permissão vira no-op.
86
+ *
87
+ * @param {string} root
88
+ * @param {string} key
89
+ * @param {string} kind ex.: 'board-items'
90
+ * @param {*} data resultado de uma chamada BEM-SUCEDIDA
91
+ * @param {object} [extra] campos de contexto (owner, repo, projectId…)
92
+ * @returns {boolean} true se gravou
93
+ */
94
+ export function writeCacheEntry(root, key, kind, data, extra = {}) {
95
+ try {
96
+ const dir = path.join(root || process.cwd(), ...CACHE_DIR);
97
+ mkdirSync(dir, { recursive: true });
98
+ writeFileSync(cachePath(root, key), `${JSON.stringify({
99
+ v: CACHE_VERSION,
100
+ kind,
101
+ key: sanitizeKey(key),
102
+ fetchedAt: new Date().toISOString(),
103
+ ...extra,
104
+ data,
105
+ })}\n`);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Remove entradas por chave exata ou prefixo (`'blockedby-*'`). Best-effort.
114
+ *
115
+ * Chamada após qualquer ESCRITA que invalide a leitura cacheada — mover board,
116
+ * mergear, criar issues.
117
+ *
118
+ * @param {string} root
119
+ * @param {...string} keysOrPrefixes
120
+ */
121
+ export function invalidateCache(root, ...keysOrPrefixes) {
122
+ const dir = path.join(root || process.cwd(), ...CACHE_DIR);
123
+ if (!existsSync(dir)) return;
124
+ let files;
125
+ try {
126
+ files = readdirSync(dir);
127
+ } catch {
128
+ return;
129
+ }
130
+ for (const spec of keysOrPrefixes) {
131
+ const raw = String(spec);
132
+ const isPrefix = raw.endsWith('*');
133
+ const base = sanitizeKey(isPrefix ? raw.slice(0, -1) : raw);
134
+ for (const file of files) {
135
+ const name = file.replace(/\.json$/, '');
136
+ const hit = isPrefix ? name.startsWith(base) : name === base;
137
+ if (hit) {
138
+ try { rmSync(path.join(dir, file), { force: true }); } catch { /* melhor esforço */ }
139
+ }
140
+ }
141
+ }
142
+ }
@@ -200,6 +200,23 @@ export function renderQaCommand(template, vars) {
200
200
  return String(template).replace(/\{(\w+)\}/g, (m, key) => (key in vars ? vars[key] : m));
201
201
  }
202
202
 
203
+ /**
204
+ * Ambiente entregue ao executor (função PURA) — spec-qa-lead §3.1.
205
+ *
206
+ * A env do PROCESSO tem precedência sobre `qa.env`, chave a chave. O motivo é o
207
+ * paralelismo do `qa-lead`: `qa.env` é versionado com o valor da execução
208
+ * manual (`BASE_URL: http://localhost:3000`), e N containers simultâneos
209
+ * precisam cada um do SEU endereço — o Lead injeta o valor real na sessão, sem
210
+ * placeholder no arquivo (a variação é de runtime, não de configuração).
211
+ *
212
+ * @param {Record<string,string>} [configEnv] bloco `qa.env` do .spec-wave.json
213
+ * @param {Record<string,string>} [processEnv] env do processo (vence)
214
+ * @returns {Record<string,string>}
215
+ */
216
+ export function qaProcessEnv(configEnv = {}, processEnv = {}) {
217
+ return { ...(configEnv || {}), ...(processEnv || {}) };
218
+ }
219
+
203
220
  /**
204
221
  * Monta o contexto entregue ao executor (função PURA).
205
222
  *
@@ -238,7 +255,8 @@ export function buildQaContext({
238
255
  lines.push('- Execute **um cenário por vez**, na ordem em que aparecem abaixo.');
239
256
  lines.push('- Registre a **evidência bruta** de cada cenário (comando executado, saída, código de status).');
240
257
  lines.push('- **NÃO corrija código.** QA não conserta: cenário reprovado vira Bug. Alterar o código durante a execução **invalida o veredito**.');
241
- lines.push('- Cenário que **não pôde ser executado** (ambiente quebrado, seed que falhou, dependência fora do ar) é `blocked`, **nunca** `fail`.');
258
+ lines.push('- Cenário que **não pôde ser executado** (ambiente quebrado, seed que falhou, dependência fora do ar) é `blocked`, **nunca** `fail` — `fail` falso cria um Bug falso e custa investigação de dev.');
259
+ lines.push('- **NÃO escreva no GitHub** (comentar, abrir issue, mover card) — o veredito volta pelo arquivo de resultados; quem escreve no GitHub é a CLI.');
242
260
  lines.push('');
243
261
  lines.push('### Como registrar o veredito');
244
262
  lines.push('');
@@ -252,7 +270,10 @@ export function buildQaContext({
252
270
  }, null, 2));
253
271
  lines.push('```');
254
272
  lines.push('');
255
- lines.push('Um objeto por cenário-alvo, com o número POSICIONAL do cenário. Nenhum pode ser omitido.');
273
+ lines.push('Um objeto por cenário-alvo, com o número POSICIONAL do cenário. Nenhum pode ser omitido — se abortar no meio, os cenários não alcançados entram como `blocked` (resultado parcial honesto vale mais que ausência de resultado). Regras duras (a CLI recusa o arquivo fora delas):');
274
+ lines.push('');
275
+ lines.push('- `fail` exige `evidencia` não vazia (ela vira o bug.md do Bug aberto);');
276
+ lines.push('- `blocked` exige `blockedReason`, um de: `ambiente` · `setup-falhou` · `massa-de-dados` · `dependencia-nao-entregue` · `bloqueado-por-bug` · `credencial` · `outro` (este exige `evidencia` com o motivo).');
256
277
 
257
278
  if (setup) {
258
279
  lines.push('');
@@ -0,0 +1,213 @@
1
+ // Backends de execução do `qa-lead run` (rfc/spec-qa-lead.md §6.4).
2
+ //
3
+ // Interface única, duas implementações, escolhidas por `qa.lead.backend`:
4
+ //
5
+ // • `docker` (default desta entrega): um `docker run` por Feature, com rede
6
+ // isolada, clone próprio do checkout e cleanup garantido — inclusive na
7
+ // morte do Lead (handler de sinal registrado pelo comando): container órfão
8
+ // consome recurso do cliente.
9
+ // • `sandbox`: a interface existe, a implementação é follow-up — a Tarefa Zero
10
+ // confirmou que o spec-wave-sandbox ainda não expõe API de criação de
11
+ // sessão. Escolhê-lo recusa orientando, em vez de fingir que roda.
12
+ //
13
+ // O container NÃO compartilha o checkout do host: ele CLONA `/repo` (montado
14
+ // read-only) para um diretório próprio. Dois `qa` paralelos commitando bug.md
15
+ // no MESMO working tree corromperiam o index um do outro; com clones isolados,
16
+ // a contenção fica onde a §3.2 já a resolve — no push, com retry+rebase.
17
+
18
+ import { spawn, execFileSync } from 'node:child_process';
19
+ import { setTimeout as sleep } from 'node:timers/promises';
20
+
21
+ function sh(args, opts = {}) {
22
+ return execFileSync('docker', args, {
23
+ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts,
24
+ }).trim();
25
+ }
26
+
27
+ /**
28
+ * Monta o script executado DENTRO do container (função PURA — testável).
29
+ *
30
+ * `qa.setup` roda antes do `qa` (§6.2: "Dentro do container: qa.setup →
31
+ * qa <feature>"); o endereço real do serviço chega por env injetada (§3.1).
32
+ *
33
+ * @param {object} params
34
+ * @param {number} params.issue
35
+ * @param {number[]|null} [params.only]
36
+ * @param {string|null} [params.setup] `qa.setup` do .spec-wave.json
37
+ * @param {string} params.cliVersion versão fixada da CLI
38
+ * @param {string|null} [params.originUrl] remoto real (o clone nasce apontando
39
+ * para /repo, que é read-only — sem isto o push do bug.md morre)
40
+ * @returns {string} script bash
41
+ */
42
+ export function containerScript({ issue, only = null, setup = null, cliVersion, originUrl = null }) {
43
+ const lines = [
44
+ 'set -euo pipefail',
45
+ 'git clone /repo /spec-wave-work',
46
+ 'cd /spec-wave-work',
47
+ 'git config user.name "spec-wave-qa"',
48
+ 'git config user.email "spec-wave-qa[bot]@users.noreply.github.com"',
49
+ ];
50
+ if (originUrl) lines.push(`git remote set-url origin ${JSON.stringify(originUrl)}`);
51
+ if (setup) lines.push(setup);
52
+ const onlyFlag = only && only.length > 0 ? ` --only ${only.join(',')}` : '';
53
+ lines.push(`npx -y @spec-wave/cli@${cliVersion} qa ${issue}${onlyFlag}`);
54
+ return lines.join('\n');
55
+ }
56
+
57
+ /** Backend Docker — um container e uma rede por execução. */
58
+ export class DockerBackend {
59
+ /**
60
+ * @param {object} params
61
+ * @param {string} params.image `qa.lead.container.image`
62
+ * @param {string} params.checkoutDir raiz do clone do host (montada em /repo)
63
+ */
64
+ constructor({ image, checkoutDir }) {
65
+ this.image = image;
66
+ this.checkoutDir = checkoutDir;
67
+ /** @type {Set<string>} containers vivos (para o cleanup de sinal) */
68
+ this.live = new Set();
69
+ /** @type {Set<string>} redes criadas */
70
+ this.networks = new Set();
71
+ }
72
+
73
+ /** O docker responde? (best-effort — o preflight consome) */
74
+ available() {
75
+ try {
76
+ sh(['info', '--format', '{{.ServerVersion}}']);
77
+ return { ok: true, detail: null };
78
+ } catch (err) {
79
+ return { ok: false, detail: String(err.stderr || err.message).split('\n')[0] };
80
+ }
81
+ }
82
+
83
+ /** A imagem existe localmente ou é puxável? (preflight) */
84
+ imageAvailable() {
85
+ try {
86
+ sh(['image', 'inspect', this.image]);
87
+ return { ok: true, detail: 'imagem presente localmente' };
88
+ } catch {
89
+ try {
90
+ sh(['pull', this.image], { stdio: ['ignore', 'pipe', 'pipe'] });
91
+ return { ok: true, detail: 'imagem puxada do registry' };
92
+ } catch (err) {
93
+ return { ok: false, detail: String(err.stderr || err.message).split('\n')[0] };
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Roda um script num container isolado até terminar ou estourar o timeout.
100
+ *
101
+ * @param {object} params
102
+ * @param {string} params.name sufixo estável (ex.: `qa-318`)
103
+ * @param {string} params.script bash de containerScript()
104
+ * @param {Record<string,string>} [params.env]
105
+ * @param {number} params.timeoutMin
106
+ * @param {(line: string) => void} [params.onLog]
107
+ * @returns {Promise<{ ok: boolean, timedOut: boolean, code: number|null, durationSec: number }>}
108
+ */
109
+ async run({ name, script, env = {}, timeoutMin, onLog = () => {} }) {
110
+ const id = `spec-wave-${name}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
111
+ const network = `${id}-net`;
112
+ const started = Date.now();
113
+
114
+ sh(['network', 'create', network]);
115
+ this.networks.add(network);
116
+
117
+ const args = [
118
+ 'run', '--rm', '--name', id, '--network', network,
119
+ '-v', `${this.checkoutDir}:/repo:ro`,
120
+ '-w', '/',
121
+ ];
122
+ for (const [key, value] of Object.entries(env)) {
123
+ if (value == null || value === '') continue; // credencial vazia não entra
124
+ args.push('-e', `${key}=${value}`);
125
+ }
126
+ args.push(this.image, 'bash', '-lc', script);
127
+
128
+ this.live.add(id);
129
+ let timedOut = false;
130
+ const code = await new Promise((resolve) => {
131
+ const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
132
+ const feed = (chunk) => String(chunk).split('\n').filter(Boolean).forEach(onLog);
133
+ child.stdout.on('data', feed);
134
+ child.stderr.on('data', feed);
135
+ const timer = setTimeout(() => {
136
+ timedOut = true;
137
+ try { sh(['rm', '-f', id]); } catch { /* já morreu */ }
138
+ }, timeoutMin * 60 * 1000);
139
+ child.on('close', (exitCode) => {
140
+ clearTimeout(timer);
141
+ resolve(exitCode);
142
+ });
143
+ child.on('error', () => {
144
+ clearTimeout(timer);
145
+ resolve(null);
146
+ });
147
+ });
148
+ this.live.delete(id);
149
+ try { sh(['network', 'rm', network]); this.networks.delete(network); } catch { /* fica para o cleanup */ }
150
+
151
+ return {
152
+ ok: !timedOut && code === 0,
153
+ timedOut,
154
+ code,
155
+ durationSec: Math.round((Date.now() - started) / 1000),
156
+ };
157
+ }
158
+
159
+ /** Mata containers vivos e remove redes — chamado no fim E no sinal. */
160
+ async cleanup() {
161
+ for (const id of [...this.live]) {
162
+ try { sh(['rm', '-f', id]); } catch { /* já removido */ }
163
+ this.live.delete(id);
164
+ }
165
+ // A rede só desmonta depois que o container solta — uma tentativa tardia.
166
+ await sleep(200);
167
+ for (const net of [...this.networks]) {
168
+ try { sh(['network', 'rm', net]); this.networks.delete(net); } catch { /* melhor esforço */ }
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Backend do spec-wave-sandbox — INTERFACE pronta, implementação follow-up.
175
+ *
176
+ * A Tarefa Zero (item 4) verificou que a API de sessão ainda não existe; até
177
+ * ela existir, escolher este backend é um erro orientado, não um stub que
178
+ * finge executar.
179
+ */
180
+ export class SandboxBackend {
181
+ constructor() {
182
+ this.reason =
183
+ 'O backend `sandbox` ainda não está disponível: o spec-wave-sandbox não expõe API de ' +
184
+ 'criação de sessão (Tarefa Zero, rfc/spec-qa-lead.md). Use `qa.lead.backend: "docker"`.';
185
+ }
186
+
187
+ available() {
188
+ return { ok: false, detail: this.reason };
189
+ }
190
+
191
+ imageAvailable() {
192
+ return { ok: false, detail: this.reason };
193
+ }
194
+
195
+ async run() {
196
+ throw new Error(this.reason);
197
+ }
198
+
199
+ async cleanup() { /* nada a limpar */ }
200
+ }
201
+
202
+ /**
203
+ * Fabrica o backend configurado.
204
+ *
205
+ * @param {object} params
206
+ * @param {'docker'|'sandbox'} params.backend
207
+ * @param {string|null} params.image
208
+ * @param {string} params.checkoutDir
209
+ */
210
+ export function createExecutionBackend({ backend, image, checkoutDir }) {
211
+ if (backend === 'sandbox') return new SandboxBackend();
212
+ return new DockerBackend({ image, checkoutDir });
213
+ }