@spec-wave/cli 0.29.0 → 0.32.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.
- package/package.json +5 -3
- package/protocol/qa-result.v1.json +62 -0
- package/protocol/qa-trail-report.v1.json +113 -0
- package/src/api/github-graphql.mjs +6 -1
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +114 -9
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +183 -3
- package/src/commands/generate-qa-plan.mjs +421 -0
- package/src/commands/implement.mjs +56 -44
- package/src/commands/merge.mjs +43 -14
- package/src/commands/order.mjs +350 -96
- package/src/commands/qa-lead.mjs +748 -0
- package/src/commands/qa-run.mjs +892 -0
- package/src/commands/run.mjs +5 -1
- package/src/config.mjs +32 -1
- package/src/lib/artifact-pr.mjs +2 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/critique.mjs +38 -9
- package/src/lib/decomposition-doc.mjs +5 -1
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +9 -2
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/next-step.mjs +15 -3
- package/src/lib/qa-exec.mjs +335 -0
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-plan-doc.mjs +340 -0
- package/src/lib/qa-report.mjs +396 -0
- package/src/lib/skill-compose.mjs +234 -0
- package/src/lib/story-graph.mjs +256 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/order/SKILL.md +21 -5
- package/src/plugin/skills/qa/SKILL.md +107 -0
- package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
- package/src/plugin/skills/qa/model-prompt.md +68 -0
- package/src/plugin/skills/qa-executor/SKILL.md +76 -0
- package/src/plugin/skills/qa-lead/SKILL.md +89 -0
- package/src/templates/skill/SKILL.md +981 -279
- package/src/templates/skill/core.md +584 -0
- package/src/templates/workflows/generate-qa-plan.yml +64 -0
|
@@ -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
|
+
}
|
package/src/lib/next-step.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
LABEL_SPEC, LABEL_PLAN, LABEL_CRITIQUE, LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY,
|
|
18
18
|
LABEL_DECOMPOSE_READY, LABEL_DECOMPOSED, LABEL_BUG, LABEL_BUG_APPROVED,
|
|
19
19
|
LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_PLAN_APPROVED, LABEL_TRIAGED,
|
|
20
|
-
LABEL_DUPLICATE, LABEL_WONT_FIX, LABEL_READY, labelNames,
|
|
20
|
+
LABEL_DUPLICATE, LABEL_WONT_FIX, LABEL_READY, LABEL_QA, LABEL_QA_READY, labelNames,
|
|
21
21
|
} from '../config.mjs';
|
|
22
22
|
import { awaitingMergeBlock } from './artifact-pr.mjs';
|
|
23
23
|
import { isAwaitingMerge } from './doc-source.mjs';
|
|
@@ -71,6 +71,15 @@ export const STEPS = {
|
|
|
71
71
|
trigger: LABEL_BUG, cli: 'generate-bug', writes: 'bug', reads: [],
|
|
72
72
|
types: ['Bug'], creates: false, ai: true,
|
|
73
73
|
},
|
|
74
|
+
// Fora do pipeline linear de propósito (a geração do plano de QA acontece com
|
|
75
|
+
// a Feature já implementada, não antes do decompose) — o run só chega aqui
|
|
76
|
+
// por `--step qa-plan`. A entrada existe pela paridade: `spec-wave:qa` é
|
|
77
|
+
// label de gatilho de workflow, e toda label de gatilho precisa de EXATAMENTE
|
|
78
|
+
// uma ação (é o teste de paridade que impede os dois modos de divergirem).
|
|
79
|
+
'generate-qa-plan': {
|
|
80
|
+
trigger: LABEL_QA, cli: 'generate-qa-plan', writes: 'qa-plan', reads: ['spec', 'plan'],
|
|
81
|
+
types: ['Feature'], creates: false, ai: true,
|
|
82
|
+
},
|
|
74
83
|
// Sem gatilho por label: são decisões humanas, listadas aqui para o `run`
|
|
75
84
|
// saber dizer qual é o próximo movimento em vez de só "nada pendente".
|
|
76
85
|
triage: {
|
|
@@ -137,13 +146,14 @@ const TRIGGERS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
|
|
|
137
146
|
|
|
138
147
|
/** Documentos que cada tipo usa, na ordem em que o fluxo os produz. */
|
|
139
148
|
export const DOCS_BY_TYPE = {
|
|
140
|
-
Feature: ['spec', 'plan', 'decomposition'],
|
|
149
|
+
Feature: ['spec', 'plan', 'decomposition', 'qa-plan'],
|
|
141
150
|
RFC: ['decomposition'],
|
|
142
151
|
Bug: ['bug'],
|
|
143
152
|
};
|
|
144
153
|
|
|
145
154
|
const DEFAULT_DOC_PATHS = {
|
|
146
155
|
spec: 'spec.md', plan: 'plan.md', decomposition: 'decomposition.md', bug: 'bug.md',
|
|
156
|
+
'qa-plan': 'qa-plan.md',
|
|
147
157
|
};
|
|
148
158
|
|
|
149
159
|
function stepCommand(action, { issueNumber }) {
|
|
@@ -184,7 +194,7 @@ function block(code, message, unblock) {
|
|
|
184
194
|
export function resolveForcedStep(step, { type }) {
|
|
185
195
|
const alias = {
|
|
186
196
|
spec: 'generate-spec', plan: 'generate-plan', bug: 'generate-bug',
|
|
187
|
-
ready: 'validate', apply: 'decompose-apply',
|
|
197
|
+
ready: 'validate', apply: 'decompose-apply', 'qa-plan': 'generate-qa-plan',
|
|
188
198
|
};
|
|
189
199
|
const action = alias[step] || step;
|
|
190
200
|
if (!STEPS[action] || STEPS[action].manual) {
|
|
@@ -279,6 +289,7 @@ export function nextStep({
|
|
|
279
289
|
// muda o slug (e o diretório); clone velho não tem o arquivo.
|
|
280
290
|
const claims = [
|
|
281
291
|
[LABEL_PLAN_APPROVED, 'plan'], [LABEL_DECOMPOSED, 'decomposition'], [LABEL_BUG_APPROVED, 'bug'],
|
|
292
|
+
[LABEL_QA_READY, 'qa-plan'],
|
|
282
293
|
];
|
|
283
294
|
for (const [label, doc] of claims) {
|
|
284
295
|
if (has(label) && docsOfType.includes(doc) && docState(docs, doc) === 'missing') {
|
|
@@ -411,6 +422,7 @@ function pipelineReason(action, { pathOf }) {
|
|
|
411
422
|
case 'decompose': return `O rascunho \`${pathOf('decomposition')}\` ainda não existe.`;
|
|
412
423
|
case 'decompose-apply': return 'O rascunho foi aprovado pela crítica e espera revisão humana.';
|
|
413
424
|
case 'generate-bug': return `\`${pathOf('bug')}\` ainda não existe.`;
|
|
425
|
+
case 'generate-qa-plan': return `Gera (ou re-critica) o plano de QA em \`${pathOf('qa-plan')}\`.`;
|
|
414
426
|
default: return 'Próximo passo do fluxo.';
|
|
415
427
|
}
|
|
416
428
|
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Decisões PURAS da execução local do QA (`spec-wave qa <n>`).
|
|
2
|
+
//
|
|
3
|
+
// O comando (commands/qa-run.mjs) coleta o estado — issue, labels, Etapa,
|
|
4
|
+
// plano, resultados — e as decisões moram aqui, testáveis sem rede: quem pode
|
|
5
|
+
// executar (portões do D-QA4), qual o desfecho, e o que entra no contexto que o
|
|
6
|
+
// executor recebe.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
STAGE_ORDER, STAGE_QA, STAGE_UAT, STAGE_DEPLOY,
|
|
10
|
+
LABEL_QA, LABEL_QA_READY, LABEL_QA_APPROVED, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN,
|
|
11
|
+
labelNames,
|
|
12
|
+
} from '../config.mjs';
|
|
13
|
+
|
|
14
|
+
/** Tipos que passam pela execução de QA (spec §3). */
|
|
15
|
+
export const QA_RUNNABLE_TYPES = ['Feature', 'Story', 'Bug'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Portões de execução (função PURA) — a tabela de recusas do spec §6.2.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} params
|
|
21
|
+
* @param {string|null} params.type tipo canônico da issue-alvo
|
|
22
|
+
* @param {Array<string|{name:string}>} [params.labels] labels da issue-alvo
|
|
23
|
+
* @param {Array<string|{name:string}>|null} [params.featureLabels] labels da
|
|
24
|
+
* Feature dona do plano (a própria issue quando o alvo é a Feature;
|
|
25
|
+
* null quando o alvo é Bug — Bug não passa pelo portão do plano)
|
|
26
|
+
* @param {string|null} [params.stage] Etapa atual no board (null = não lida)
|
|
27
|
+
* @returns {{ ok: boolean, exitZero?: boolean, code?: string, message?: string }}
|
|
28
|
+
*/
|
|
29
|
+
export function qaExecutionGate({ type, labels = [], featureLabels = null, stage = null } = {}) {
|
|
30
|
+
if (!type || !QA_RUNNABLE_TYPES.includes(type)) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
code: 'unsupported-type',
|
|
34
|
+
message:
|
|
35
|
+
`O \`qa\` executa Feature, Story ou Bug — esta issue é **${type || 'de tipo desconhecido'}**. ` +
|
|
36
|
+
(type === 'Task'
|
|
37
|
+
? 'Task não passa por QA (vai de 🚧 Desenvolvimento direto a 🎉 Done).'
|
|
38
|
+
: type === 'Spike'
|
|
39
|
+
? 'A Etapa de um Spike é movida só à mão.'
|
|
40
|
+
: 'RFC, Epic e Initiative não têm validação funcional própria.'),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const names = new Set(labelNames(labels));
|
|
45
|
+
const featureNames = featureLabels === null ? null : new Set(labelNames(featureLabels));
|
|
46
|
+
|
|
47
|
+
// Geração em voo: a label de gatilho ainda está na issue (ou na Feature dona
|
|
48
|
+
// do plano) — rodar agora executaria um plano que está sendo (re)gerado.
|
|
49
|
+
if (names.has(LABEL_QA) || featureNames?.has(LABEL_QA)) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
code: 'trigger-pending',
|
|
53
|
+
message:
|
|
54
|
+
`A label \`${LABEL_QA}\` ainda está pendente — a geração/crítica do plano está em voo ` +
|
|
55
|
+
'(ou falhou deixando a label). Aguarde o run terminar, ou remova a label e reaplique.',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Portão humano da crítica — na Feature dona do plano (Feature/Story) ou na
|
|
60
|
+
// própria issue (Bug, cuja crítica é a do bug.md).
|
|
61
|
+
for (const conjunto of [featureNames, names].filter(Boolean)) {
|
|
62
|
+
for (const label of [LABEL_NEEDS_HUMAN, LABEL_CRITIQUE_FAILED]) {
|
|
63
|
+
if (conjunto.has(label)) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
code: 'human-gate',
|
|
67
|
+
message:
|
|
68
|
+
`A label \`${label}\` está aplicada — a crítica adversarial parou o fluxo. ` +
|
|
69
|
+
'Corrija o documento apontado no comentário 🔎, remova a label e tente de novo.',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// D-QA4: como o verde avança a Etapa sozinho, o portão humano é a REVISÃO DO
|
|
76
|
+
// PLANO — sem `qa-ready` na Feature, nada roda. Não vale para Bug: o "plano"
|
|
77
|
+
// dele é a seção Teste de Regressão do bug.md.
|
|
78
|
+
if (type !== 'Bug' && featureNames !== null && !featureNames.has(LABEL_QA_READY)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
code: 'plan-not-ready',
|
|
82
|
+
message:
|
|
83
|
+
`A Feature dona do plano não tem \`${LABEL_QA_READY}\` — este é o **portão humano** do QA ` +
|
|
84
|
+
'(D-QA4): o veredito verde avança a Etapa sozinho, então o plano precisa ter passado na ' +
|
|
85
|
+
`validação + crítica antes. Aplique \`${LABEL_QA}\` na Feature para gerar/criticar o plano.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Etapa: o `qa` não promove item para QA, e a Etapa nunca retrocede.
|
|
90
|
+
if (stage) {
|
|
91
|
+
const cur = STAGE_ORDER.indexOf(stage);
|
|
92
|
+
const qaIdx = STAGE_ORDER.indexOf(STAGE_QA);
|
|
93
|
+
if (cur !== -1 && cur < qaIdx) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
code: 'stage-before-qa',
|
|
97
|
+
message:
|
|
98
|
+
`A issue está em **${stage}**, antes de **${STAGE_QA}** — o \`qa\` não promove item ` +
|
|
99
|
+
'para QA. Quem move até lá é o merge do PR (`spec-wave merge` / `run --pr`).',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (cur !== -1 && cur > qaIdx) {
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
exitZero: true,
|
|
106
|
+
code: 'stage-after-qa',
|
|
107
|
+
message:
|
|
108
|
+
`A issue já está em **${stage}**, depois de **${STAGE_QA}** — a Etapa nunca retrocede, ` +
|
|
109
|
+
'nada a executar.',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Para onde o item vai no verde (função PURA).
|
|
119
|
+
*
|
|
120
|
+
* Story → 📋 Homologação (aprovação humana de negócio segue existindo);
|
|
121
|
+
* Bug → 🚀 Deploy (D-QA6: Bug não passa por Homologação);
|
|
122
|
+
* Feature → 📋 Homologação, mas só quando todas as Stories liberarem (o
|
|
123
|
+
* chamador decide o "quando" — aqui só o destino).
|
|
124
|
+
*
|
|
125
|
+
* @param {string} type
|
|
126
|
+
* @returns {string} nome da Etapa de destino
|
|
127
|
+
*/
|
|
128
|
+
export function greenTargetStage(type) {
|
|
129
|
+
return type === 'Bug' ? STAGE_DEPLOY : STAGE_UAT;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A Story pode avançar no verde? (função PURA)
|
|
134
|
+
*
|
|
135
|
+
* Guarda DURA do verde: Bug filho ABERTO segura a Story mesmo com todos os
|
|
136
|
+
* cenários passando — sem isso, um `--only` reaprovaria prematuramente uma
|
|
137
|
+
* Story cujo defeito ainda não foi corrigido.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} params
|
|
140
|
+
* @param {Array<{number:number, state?:string|null, type?:string|null}>} [params.children]
|
|
141
|
+
* sub-issues da Story (o chamador já detectou o tipo de cada uma)
|
|
142
|
+
* @returns {{ ok: boolean, openBugs: number[] }}
|
|
143
|
+
*/
|
|
144
|
+
export function storyCanAdvance({ children = [] } = {}) {
|
|
145
|
+
const openBugs = children
|
|
146
|
+
.filter(c => c.type === 'Bug' && c.state !== 'closed')
|
|
147
|
+
.map(c => c.number);
|
|
148
|
+
return { ok: openBugs.length === 0, openBugs };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A Feature pode avançar para 📋 Homologação? (função PURA)
|
|
153
|
+
*
|
|
154
|
+
* Mesma regra do Code Review: TODAS as Stories precisam ter `qa-approved` ou já
|
|
155
|
+
* estar em Homologação+ na ordem canônica. Etapa desconhecida conta como
|
|
156
|
+
* pendente — na dúvida, a Feature não avança.
|
|
157
|
+
*
|
|
158
|
+
* @param {Array<{number:number, labels?:Array, stage?:string|null}>} stories
|
|
159
|
+
* @returns {{ ok: boolean, pending: number[] }}
|
|
160
|
+
*/
|
|
161
|
+
export function featureCanAdvanceQa(stories = []) {
|
|
162
|
+
const uatIdx = STAGE_ORDER.indexOf(STAGE_UAT);
|
|
163
|
+
const pending = stories
|
|
164
|
+
.filter((s) => {
|
|
165
|
+
if (labelNames(s.labels || []).includes(LABEL_QA_APPROVED)) return false;
|
|
166
|
+
const idx = s.stage ? STAGE_ORDER.indexOf(s.stage) : -1;
|
|
167
|
+
return idx < uatIdx || idx === -1;
|
|
168
|
+
})
|
|
169
|
+
.map(s => s.number);
|
|
170
|
+
return { ok: pending.length === 0, pending };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Extrai a seção "Teste de Regressão" de um bug.md (função PURA).
|
|
175
|
+
*
|
|
176
|
+
* É o "plano de QA" de um Bug (spec §3): o cenário único que o `qa <bug>`
|
|
177
|
+
* executa. Aceita qualquer nível de heading, mesma tolerância do validate.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} content bug.md
|
|
180
|
+
* @returns {string|null} corpo da seção, ou null se ausente/vazia
|
|
181
|
+
*/
|
|
182
|
+
export function extractRegressionSection(content) {
|
|
183
|
+
const text = String(content ?? '').replace(/\r\n?/g, '\n');
|
|
184
|
+
const lines = text.split('\n');
|
|
185
|
+
const start = lines.findIndex(l => /^#{1,6}[ \t]+Teste de Regressão[ \t]*$/i.test(l));
|
|
186
|
+
if (start === -1) return null;
|
|
187
|
+
const startLevel = (lines[start].match(/^#+/) || ['#'])[0].length;
|
|
188
|
+
const body = [];
|
|
189
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
190
|
+
const h = lines[i].match(/^(#{1,6})[ \t]+/);
|
|
191
|
+
if (h && h[1].length <= startLevel) break;
|
|
192
|
+
body.push(lines[i]);
|
|
193
|
+
}
|
|
194
|
+
const trimmed = body.join('\n').trim();
|
|
195
|
+
return trimmed || null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Substitui os placeholders do comando configurado (mesma regra do implement).
|
|
199
|
+
export function renderQaCommand(template, vars) {
|
|
200
|
+
return String(template).replace(/\{(\w+)\}/g, (m, key) => (key in vars ? vars[key] : m));
|
|
201
|
+
}
|
|
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
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Monta o contexto entregue ao executor (função PURA).
|
|
222
|
+
*
|
|
223
|
+
* As instruções de execução são OBRIGATÓRIAS (spec §6.2): um cenário por vez,
|
|
224
|
+
* evidência bruta, proibição de corrigir código, `blocked` ≠ `fail` — e o
|
|
225
|
+
* contrato do arquivo de resultados, que é como o veredito volta para a CLI.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} params
|
|
228
|
+
* @param {string} params.type
|
|
229
|
+
* @param {{number:number, title:string}} params.issue
|
|
230
|
+
* @param {string|null} [params.stage]
|
|
231
|
+
* @param {Array} params.scenarios cenários-alvo, em ordem
|
|
232
|
+
* @param {string|null} [params.specRel] caminho do spec.md (ponteiro)
|
|
233
|
+
* @param {string|null} [params.qaPlanRel] caminho do qa-plan.md
|
|
234
|
+
* @param {Array<{issueNumber:number, kind:string, total:number, items:Array}>} [params.comments]
|
|
235
|
+
* @param {Array<{number:number, state:string, merged:boolean}>} [params.pullRequests]
|
|
236
|
+
* @param {string|null} [params.setup] `qa.setup` do .spec-wave.json
|
|
237
|
+
* @param {string} params.resultFile caminho do JSON de resultados
|
|
238
|
+
* @returns {string} markdown
|
|
239
|
+
*/
|
|
240
|
+
export function buildQaContext({
|
|
241
|
+
type, issue, stage = null, scenarios = [], specRel = null, qaPlanRel = null,
|
|
242
|
+
comments = [], pullRequests = [], setup = null, resultFile,
|
|
243
|
+
} = {}) {
|
|
244
|
+
const lines = [];
|
|
245
|
+
lines.push(`# Contexto de QA — ${type} #${issue.number}`);
|
|
246
|
+
lines.push('');
|
|
247
|
+
lines.push(`**${type}:** ${issue.title}`);
|
|
248
|
+
if (stage) lines.push(`**Etapa atual no board:** ${stage}`);
|
|
249
|
+
if (specRel) lines.push(`**Especificação:** \`${specRel}\` (leia-a para entender os critérios de aceite)`);
|
|
250
|
+
if (qaPlanRel) lines.push(`**Plano de QA:** \`${qaPlanRel}\``);
|
|
251
|
+
|
|
252
|
+
lines.push('');
|
|
253
|
+
lines.push('## Instruções de execução (OBRIGATÓRIAS)');
|
|
254
|
+
lines.push('');
|
|
255
|
+
lines.push('- Execute **um cenário por vez**, na ordem em que aparecem abaixo.');
|
|
256
|
+
lines.push('- Registre a **evidência bruta** de cada cenário (comando executado, saída, código de status).');
|
|
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**.');
|
|
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.');
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push('### Como registrar o veredito');
|
|
262
|
+
lines.push('');
|
|
263
|
+
lines.push(`Ao terminar, grave o resultado em \`${resultFile}\` — é deste arquivo que a CLI lê o veredito:`);
|
|
264
|
+
lines.push('');
|
|
265
|
+
lines.push('```json');
|
|
266
|
+
lines.push(JSON.stringify({
|
|
267
|
+
scenarios: scenarios.slice(0, 1).map(s => ({
|
|
268
|
+
cenario: s.numero, verdict: 'pass | fail | blocked', evidencia: 'comando + saída + status',
|
|
269
|
+
})),
|
|
270
|
+
}, null, 2));
|
|
271
|
+
lines.push('```');
|
|
272
|
+
lines.push('');
|
|
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).');
|
|
277
|
+
|
|
278
|
+
if (setup) {
|
|
279
|
+
lines.push('');
|
|
280
|
+
lines.push('## Setup do ambiente (rode antes do primeiro cenário)');
|
|
281
|
+
lines.push('');
|
|
282
|
+
lines.push('```bash');
|
|
283
|
+
lines.push(setup);
|
|
284
|
+
lines.push('```');
|
|
285
|
+
lines.push('');
|
|
286
|
+
lines.push('Se o setup falhar, TODOS os cenários são `blocked` — registre a falha como evidência.');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
lines.push('');
|
|
290
|
+
lines.push(`## Cenários a executar — NESTA ORDEM (${scenarios.length})`);
|
|
291
|
+
for (const s of scenarios) {
|
|
292
|
+
lines.push('');
|
|
293
|
+
lines.push(`### ${s.anchor} — Story #${s.story}`);
|
|
294
|
+
lines.push('');
|
|
295
|
+
lines.push(s.body || [
|
|
296
|
+
s.criterio ? `**Critério:** ${s.criterio}` : null,
|
|
297
|
+
s.precondicoes ? `**Pré-condições:** ${s.precondicoes}` : null,
|
|
298
|
+
s.passos ? `**Passos:**\n${s.passos}` : null,
|
|
299
|
+
s.esperado ? `**Esperado:** ${s.esperado}` : null,
|
|
300
|
+
].filter(Boolean).join('\n'));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (pullRequests.length > 0) {
|
|
304
|
+
lines.push('');
|
|
305
|
+
lines.push('## Pull Requests vinculados');
|
|
306
|
+
lines.push('');
|
|
307
|
+
for (const pr of pullRequests) {
|
|
308
|
+
lines.push(`- PR #${pr.number} — ${pr.merged ? 'mergeado' : pr.state}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (comments.length > 0) {
|
|
313
|
+
lines.push('');
|
|
314
|
+
lines.push('## Comentários das issues (revisões e correções)');
|
|
315
|
+
lines.push('');
|
|
316
|
+
lines.push('> Em conflito com os documentos, o comentário mais recente prevalece.');
|
|
317
|
+
for (const group of comments) {
|
|
318
|
+
lines.push('');
|
|
319
|
+
lines.push(`### Comentários da ${group.kind} #${group.issueNumber}`);
|
|
320
|
+
if (group.total > group.items.length) {
|
|
321
|
+
lines.push('');
|
|
322
|
+
lines.push(`_(mostrando os ${group.items.length} mais recentes de ${group.total})_`);
|
|
323
|
+
}
|
|
324
|
+
for (const c of group.items) {
|
|
325
|
+
lines.push('');
|
|
326
|
+
lines.push(`**${c.author || c.user?.login || 'desconhecido'}** (${c.createdAt || c.created_at || ''}):`);
|
|
327
|
+
lines.push('');
|
|
328
|
+
lines.push(String(c.body || '').trim());
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
lines.push('');
|
|
334
|
+
return lines.join('\n');
|
|
335
|
+
}
|