@spec-wave/cli 0.29.0 → 0.30.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,340 @@
1
+ // Plano de QA (docs/features/<slug>/qa-plan.md) — módulo PURO, sem I/O.
2
+ //
3
+ // A etapa 🧪 QA era a única do fluxo sem artefato: a validação funcional era
4
+ // manual e não deixava rastro. O qa-plan.md fecha esse buraco — um arquivo por
5
+ // FEATURE (D-QA1), derivado dos critérios de aceite da spec.md, com uma seção
6
+ // `## Cenário N — Story #X` por cenário. O comando `qa <n>` executa esses
7
+ // cenários e o veredito verde/vermelho move o board ou abre Bugs.
8
+ //
9
+ // As regras de parsing ESPELHAM as do decomposition.md de propósito (mesmo
10
+ // fenceScanner, mesma regra "a posição manda, não o número escrito"): quem já
11
+ // edita um, sabe editar o outro.
12
+ //
13
+ // Gramática (v1):
14
+ //
15
+ // # Plano de QA — <título da Feature>
16
+ // <!-- spec-wave:qa-plan v1 issue=360 -->
17
+ //
18
+ // ## Cenário 1 — Story #412
19
+ //
20
+ // **Critério:** AC-2 — cliente vê apenas os próprios pedidos
21
+ // **Pré-condições:** dois usuários autenticáveis
22
+ // **Passos:**
23
+ // 1. Autenticar como usuário A
24
+ // 2. `GET /pedidos`
25
+ // **Esperado:** resposta 200 contendo somente pedidos de A
26
+ //
27
+ // `**Critério:**` e `**Esperado:**` são obrigatórios; `**Pré-condições:**` e
28
+ // `**Passos:**` são opcionais. O corpo aceita markdown livre.
29
+
30
+ import { fenceScanner } from './decomposition-doc.mjs';
31
+ import { findIncompleteDocSigns } from './doc-completeness.mjs';
32
+
33
+ /** Nome do arquivo dentro do diretório da feature. */
34
+ export const QA_PLAN_FILE = 'qa-plan.md';
35
+
36
+ // Versão do formato, gravada no marcador. Versão MAIOR que a conhecida faz o
37
+ // parse falhar pedindo update da CLI — nunca interpretar errado em silêncio.
38
+ export const QA_PLAN_VERSION = 1;
39
+
40
+ // Só esta forma é estrutura; "## Detalhes" no corpo de um cenário não é seção.
41
+ // `Story #X` é obrigatório no título — cenário sem dono não tem para quem
42
+ // abrir Bug na reprova.
43
+ const SCENARIO_RE = /^##[ \t]+Cen[aá]rio[ \t]+(\d+)[ \t]*(?:[—–:-][ \t]*)?(?:Story[ \t]*#(\d+))?[ \t]*(.*)$/i;
44
+ const H1_RE = /^#[ \t]+(.*)$/;
45
+ const MARKER_RE = /^<!--[ \t]*spec-wave:qa-plan[ \t]+(.*?)-->[ \t]*$/i;
46
+
47
+ // Campos da gramática. Valem em qualquer ponto do corpo do cenário (não só no
48
+ // primeiro bloco): o exemplo canônico intercala `**Passos:**` multi-linha entre
49
+ // eles, e restringir ao primeiro bloco quebraria o formato que o próprio
50
+ // gerador emite.
51
+ const FIELD_RE = /^\*\*(Crit[eé]rio|Pr[eé]-?condi[çc][oõ]es|Passos|Esperado):?\*\*:?[ \t]*(.*)$/i;
52
+
53
+ function invalid(reason) {
54
+ return new Error(`qa-plan.md inválido: ${reason}.`);
55
+ }
56
+
57
+ function flatten(value) {
58
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
59
+ }
60
+
61
+ function trimBlankEdges(lines) {
62
+ const out = [...lines];
63
+ while (out.length && !out[0].trim()) out.shift();
64
+ while (out.length && !out[out.length - 1].trim()) out.pop();
65
+ return out;
66
+ }
67
+
68
+ // Canoniza o nome do campo (sem acento/caixa) para a chave do objeto.
69
+ function fieldKey(raw) {
70
+ const norm = String(raw).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
71
+ if (norm.startsWith('criterio')) return 'criterio';
72
+ if (norm.startsWith('pre')) return 'precondicoes';
73
+ if (norm === 'passos') return 'passos';
74
+ return 'esperado';
75
+ }
76
+
77
+ function parseMarker(attrs) {
78
+ const text = String(attrs || '');
79
+ const version = /(?:^|\s)v(\d+)(?:\s|$)/.exec(text);
80
+ const issue = /\bissue=(\d+)\b/.exec(text);
81
+ return {
82
+ version: version ? parseInt(version[1], 10) : QA_PLAN_VERSION,
83
+ issueNumber: issue ? parseInt(issue[1], 10) : null,
84
+ };
85
+ }
86
+
87
+ // "# Plano de QA — X" → "X".
88
+ function stripDocTitle(raw) {
89
+ const text = flatten(raw);
90
+ const m = /^Plano de QA(?:[ \t]*[—–:-][ \t]*(.*))?$/i.exec(text);
91
+ return m ? (m[1] || '').trim() : text;
92
+ }
93
+
94
+ /**
95
+ * Interpreta o qa-plan.md (função PURA — testável).
96
+ *
97
+ * A numeração ESCRITA é ignorada: a posição manda. Inserir um cenário no meio
98
+ * sem renumerar funciona — a âncora `Cenário N` sai da posição, e o render
99
+ * devolve a numeração corrigida no ciclo seguinte.
100
+ *
101
+ * `Story #X` ausente no título é ERRO de parse (o campo é obrigatório na
102
+ * gramática); referência a issue que não é sub-issue da Feature é erro de
103
+ * VALIDAÇÃO (validateQaPlan) — o parser não conhece a árvore de issues.
104
+ *
105
+ * @param {string} markdown conteúdo do arquivo
106
+ * @param {object} [opts]
107
+ * @param {boolean} [opts.requireMarker] default true; false para conteúdo
108
+ * recém-gerado pelo modelo, que ganha o marcador no render canônico
109
+ * @returns {{version:number, issueNumber:number|null, title:string,
110
+ * scenarios: Array<{anchor:string, numero:number, story:number,
111
+ * criterio:string, precondicoes:string, passos:string,
112
+ * esperado:string, body:string}>}}
113
+ */
114
+ export function parseQaPlanDoc(markdown, { requireMarker = true } = {}) {
115
+ const text = String(markdown ?? '').replace(/\r\n?/g, '\n');
116
+ if (!text.trim()) throw invalid('o arquivo está vazio');
117
+
118
+ const scan = fenceScanner();
119
+ const sections = [];
120
+ let current = null;
121
+ let marker = null;
122
+ let title = '';
123
+ let sawH1 = false;
124
+
125
+ for (const line of text.split('\n')) {
126
+ if (!scan.inFence(line)) {
127
+ const m = SCENARIO_RE.exec(line);
128
+ if (m) {
129
+ current = { num: m[1], story: m[2] ? parseInt(m[2], 10) : null, rest: m[3] || '', lines: [] };
130
+ sections.push(current);
131
+ continue;
132
+ }
133
+ if (!current) {
134
+ const mk = MARKER_RE.exec(line.trim());
135
+ if (mk && !marker) {
136
+ marker = parseMarker(mk[1]);
137
+ continue;
138
+ }
139
+ const h1 = H1_RE.exec(line);
140
+ if (h1 && !sawH1) {
141
+ title = stripDocTitle(h1[1]);
142
+ sawH1 = true;
143
+ continue;
144
+ }
145
+ }
146
+ }
147
+ if (current) current.lines.push(line);
148
+ }
149
+
150
+ if (scan.isOpen()) {
151
+ throw invalid(
152
+ 'há um bloco de código (```) aberto e nunca fechado — feche-o para que os ' +
153
+ 'títulos seguintes voltem a ser reconhecidos'
154
+ );
155
+ }
156
+ if (!marker && requireMarker) {
157
+ throw invalid(
158
+ `não encontrei o marcador \`<!-- spec-wave:qa-plan v${QA_PLAN_VERSION} … -->\` — ` +
159
+ 'o arquivo não parece um qa-plan.md do spec-wave'
160
+ );
161
+ }
162
+ if (marker && marker.version > QA_PLAN_VERSION) {
163
+ throw invalid(
164
+ `está no formato v${marker.version} e esta CLI entende até v${QA_PLAN_VERSION} — ` +
165
+ 'atualize o @spec-wave/cli'
166
+ );
167
+ }
168
+ if (sections.length === 0) {
169
+ throw invalid('não encontrei nenhum cenário ("## Cenário 1 — Story #<n>")');
170
+ }
171
+
172
+ const scenarios = sections.map((s, i) => {
173
+ const anchor = `Cenário ${i + 1}`;
174
+ if (!s.story) {
175
+ throw invalid(
176
+ `${anchor} está sem a Story dona — o título precisa ser ` +
177
+ `"## Cenário N — Story #<issue>" (encontrei "## Cenário ${s.num}${s.rest ? ` — ${s.rest}` : ''}")`
178
+ );
179
+ }
180
+ const fields = { criterio: '', precondicoes: '', passos: '', esperado: '' };
181
+ // `Passos` é o único campo multi-linha: acumula até o próximo campo.
182
+ let collecting = null;
183
+ const fieldScan = fenceScanner();
184
+ for (const line of s.lines) {
185
+ const inFence = fieldScan.inFence(line);
186
+ const f = inFence ? null : FIELD_RE.exec(line);
187
+ if (f) {
188
+ const key = fieldKey(f[1]);
189
+ fields[key] = (f[2] || '').trim();
190
+ collecting = key === 'passos' ? 'passos' : null;
191
+ continue;
192
+ }
193
+ if (collecting === 'passos' && line.trim()) {
194
+ fields.passos = fields.passos ? `${fields.passos}\n${line}` : line;
195
+ } else if (collecting && !line.trim()) {
196
+ collecting = null;
197
+ }
198
+ }
199
+ return {
200
+ anchor,
201
+ numero: i + 1,
202
+ story: s.story,
203
+ criterio: fields.criterio,
204
+ precondicoes: fields.precondicoes,
205
+ passos: fields.passos,
206
+ esperado: fields.esperado,
207
+ body: trimBlankEdges(s.lines).join('\n'),
208
+ };
209
+ });
210
+
211
+ return {
212
+ version: marker?.version ?? QA_PLAN_VERSION,
213
+ issueNumber: marker?.issueNumber ?? null,
214
+ title,
215
+ scenarios,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Renderiza o qa-plan.md CANÔNICO (função PURA).
221
+ *
222
+ * Numeração recalculada por posição e marcador sempre presente — é por isso que
223
+ * o conteúdo recém-gerado pelo modelo passa por parse + render antes de ser
224
+ * publicado: o arquivo nasce parseável mesmo que o modelo tenha errado a
225
+ * numeração ou omitido o marcador.
226
+ *
227
+ * @param {object} model
228
+ * @param {string} [model.title] título da Feature (H1)
229
+ * @param {number|null} [model.issueNumber]
230
+ * @param {Array<{story:number, body:string}>} [model.scenarios]
231
+ * @returns {string} markdown terminado em \n
232
+ */
233
+ export function renderQaPlanDoc({ title = '', issueNumber = null, scenarios = [] } = {}) {
234
+ const attrs = [`v${QA_PLAN_VERSION}`];
235
+ const issue = Number(issueNumber);
236
+ if (Number.isInteger(issue) && issue > 0) attrs.push(`issue=${issue}`);
237
+
238
+ const head = flatten(title) ? `# Plano de QA — ${flatten(title)}` : '# Plano de QA';
239
+ const blocks = [`${head}\n<!-- spec-wave:qa-plan ${attrs.join(' ')} -->`];
240
+
241
+ scenarios.forEach((s, i) => {
242
+ blocks.push(`## Cenário ${i + 1} — Story #${s.story}`);
243
+ const body = trimBlankEdges(String(s.body ?? '').replace(/\r\n?/g, '\n').split('\n')).join('\n');
244
+ if (body) blocks.push(body);
245
+ });
246
+
247
+ return `${blocks.join('\n\n')}\n`;
248
+ }
249
+
250
+ /**
251
+ * Validação DETERMINÍSTICA do plano, antes da crítica (função PURA).
252
+ *
253
+ * Barata e mais confiável que a IA para o que é checável sem julgamento:
254
+ * 1. toda Story sub-issue da Feature tem ≥ 1 cenário;
255
+ * 2. todo `Story #X` referenciado é sub-issue real da Feature;
256
+ * 3. todo cenário tem `**Critério:**` e `**Esperado:**` não vazios;
257
+ * 4. nenhum sinal objetivo de truncamento (mesmo helper do `validate`).
258
+ *
259
+ * Falha aqui evita gastar uma chamada de crítica com um arquivo quebrado.
260
+ *
261
+ * @param {object} params
262
+ * @param {ReturnType<typeof parseQaPlanDoc>} params.doc plano parseado
263
+ * @param {Array<{number:number, title?:string, state?:string|null,
264
+ * stateReason?:string|null}>} params.stories Stories sub-issues da Feature
265
+ * @param {string} [params.content] markdown bruto (para os sinais de truncamento)
266
+ * @returns {string[]} erros em pt-BR (vazio = plano válido)
267
+ */
268
+ export function validateQaPlan({ doc, stories = [], content = '' } = {}) {
269
+ const errors = [];
270
+ const byNumber = new Map(stories.map(s => [s.number, s]));
271
+
272
+ for (const s of doc?.scenarios || []) {
273
+ if (!byNumber.has(s.story)) {
274
+ errors.push(
275
+ `${s.anchor} referencia a Story #${s.story}, que não é sub-issue desta Feature — ` +
276
+ 'corrija o número (ou o plano está desatualizado em relação à decomposição).'
277
+ );
278
+ }
279
+ if (!s.criterio) {
280
+ errors.push(`${s.anchor} está sem \`**Critério:**\` — todo cenário precisa dizer QUAL critério de aceite valida.`);
281
+ }
282
+ if (!s.esperado) {
283
+ errors.push(`${s.anchor} está sem \`**Esperado:**\` — sem resultado esperado não há como dar veredito.`);
284
+ }
285
+ }
286
+
287
+ // Story descartada no rescopo não precisa de cenário; as demais, sim.
288
+ const covered = new Set((doc?.scenarios || []).map(s => s.story));
289
+ for (const story of stories) {
290
+ const descartada = story.state === 'closed' && story.stateReason === 'not_planned';
291
+ if (!descartada && !covered.has(story.number)) {
292
+ errors.push(
293
+ `A Story #${story.number}${story.title ? ` (${story.title})` : ''} não tem nenhum cenário — ` +
294
+ 'toda Story da Feature precisa de ao menos um.'
295
+ );
296
+ }
297
+ }
298
+
299
+ for (const problem of findIncompleteDocSigns(content || '')) {
300
+ errors.push(`O arquivo parece incompleto: ${problem}`);
301
+ }
302
+
303
+ return errors;
304
+ }
305
+
306
+ /**
307
+ * Cenários-alvo de uma execução (função PURA).
308
+ *
309
+ * `qa <feature>` → todos cujas Stories ainda não têm `qa-approved`;
310
+ * `qa <story>` → só os daquela Story;
311
+ * `--only` → filtra por número POSICIONAL (coerente com o parser).
312
+ *
313
+ * @param {object} params
314
+ * @param {ReturnType<typeof parseQaPlanDoc>} params.doc
315
+ * @param {number|null} [params.story] limita à Story (modo `qa <story>`)
316
+ * @param {number[]} [params.approvedStories] Stories com `spec-wave:qa-approved`
317
+ * @param {number[]|null} [params.only] números de cenário do `--only`
318
+ * @returns {{ targets: Array, skippedApproved: Array, unknownOnly: number[] }}
319
+ */
320
+ export function resolveTargetScenarios({ doc, story = null, approvedStories = [], only = null } = {}) {
321
+ const approved = new Set(approvedStories);
322
+ let targets = doc?.scenarios || [];
323
+ let skippedApproved = [];
324
+
325
+ if (story != null) {
326
+ targets = targets.filter(s => s.story === story);
327
+ } else {
328
+ skippedApproved = targets.filter(s => approved.has(s.story));
329
+ targets = targets.filter(s => !approved.has(s.story));
330
+ }
331
+
332
+ let unknownOnly = [];
333
+ if (Array.isArray(only) && only.length > 0) {
334
+ const wanted = new Set(only);
335
+ unknownOnly = only.filter(n => !targets.some(s => s.numero === n));
336
+ targets = targets.filter(s => wanted.has(s.numero));
337
+ }
338
+
339
+ return { targets, skippedApproved, unknownOnly };
340
+ }
@@ -0,0 +1,340 @@
1
+ // Relatório de execução do QA — módulo PURO, sem I/O.
2
+ //
3
+ // O comentário de veredito é a FONTE DE ESTADO entre execuções: rodar com
4
+ // `--only` lê o último relatório da issue para compor o estado acumulado dos
5
+ // cenários que não rodaram desta vez. O marcador HTML da primeira linha é
6
+ // CONTRATO com a UI do SpecWave — mudar o formato exige bump de versão
7
+ // (`v1` → `v2`).
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { REQUIRED_BUG_SECTIONS } from '../config.mjs';
11
+
12
+ export const QA_REPORT_VERSION = 1;
13
+
14
+ /** Vereditos aceitos por cenário. */
15
+ export const QA_VERDICTS = ['pass', 'fail', 'blocked'];
16
+
17
+ export const QA_REPORT_MARKER_RE =
18
+ /<!--\s*spec-wave:qa-report\s+v(?<version>\d+)\s+issue=(?<issue>\d+)\s+run=(?<run>\d+)\s+verdict=(?<verdict>pass|fail|blocked)\s+planSha=(?<planSha>[0-9a-f]+)\s+headSha=(?<headSha>[0-9a-f?]+)\s*-->/g;
19
+
20
+ // Origem de um Bug aberto por reprovação de QA — gravado no CORPO da issue do
21
+ // Bug. É a chave da idempotência: re-executar o mesmo cenário reprovado não
22
+ // abre um segundo Bug, comenta no que já existe.
23
+ export const QA_ORIGIN_MARKER_RE =
24
+ /<!--\s*spec-wave:qa-origin\s+issue=(?<issue>\d+)\s+cenario=(?<cenario>\d+)\s*-->/;
25
+
26
+ /** Monta o marcador do relatório (função PURA). */
27
+ export function qaReportMarker({ issue, run, verdict, planSha, headSha }) {
28
+ return `<!-- spec-wave:qa-report v${QA_REPORT_VERSION} issue=${issue} run=${run} ` +
29
+ `verdict=${verdict} planSha=${planSha} headSha=${headSha || '?'} -->`;
30
+ }
31
+
32
+ /** Monta o marcador de origem de um Bug de QA (função PURA). */
33
+ export function qaOriginMarker({ issue, cenario }) {
34
+ return `<!-- spec-wave:qa-origin issue=${issue} cenario=${cenario} -->`;
35
+ }
36
+
37
+ /**
38
+ * O corpo de um Bug aberto veio deste cenário? (função PURA)
39
+ *
40
+ * @param {string} body corpo da issue do Bug
41
+ * @param {{issue:number, cenario:number}} origem Story dona + número do cenário
42
+ * @returns {boolean}
43
+ */
44
+ export function matchesQaOrigin(body, { issue, cenario }) {
45
+ const m = QA_ORIGIN_MARKER_RE.exec(String(body || ''));
46
+ if (!m) return false;
47
+ return parseInt(m.groups.issue, 10) === issue && parseInt(m.groups.cenario, 10) === cenario;
48
+ }
49
+
50
+ /** SHA curto (7 hex) do conteúdo do plano — vai no marcador do relatório. */
51
+ export function shortSha(content) {
52
+ return createHash('sha1').update(String(content ?? ''), 'utf-8').digest('hex').slice(0, 7);
53
+ }
54
+
55
+ /**
56
+ * Veredito agregado da execução (função PURA).
57
+ *
58
+ * `fail` vence `blocked` de propósito: com um defeito confirmado, o desfecho é
59
+ * vermelho (abre Bug) mesmo que outro cenário tenha ficado bloqueado. `blocked`
60
+ * sem nenhum `fail` é INCONCLUSIVO — ambiente quebrado não é defeito de
61
+ * produto, então nada move e nenhum Bug nasce.
62
+ *
63
+ * @param {Array<{verdict:string}>} results
64
+ * @returns {'pass'|'fail'|'blocked'}
65
+ */
66
+ export function aggregateVerdict(results = []) {
67
+ if (results.some(r => r.verdict === 'fail')) return 'fail';
68
+ if (results.some(r => r.verdict === 'blocked')) return 'blocked';
69
+ return 'pass';
70
+ }
71
+
72
+ /**
73
+ * Valida o payload de resultados escrito pelo executor (função PURA).
74
+ *
75
+ * O executor (o agente acionado por `qa.command`) grava um JSON com o veredito
76
+ * de cada cenário. Fora do contrato → erro nomeando o campo: um resultado
77
+ * ilegível não pode virar aprovação nem reprovação.
78
+ *
79
+ * @param {*} payload objeto lido do arquivo de resultados
80
+ * @param {number[]} expected números dos cenários que DEVIAM ter sido executados
81
+ * @returns {Array<{numero:number, verdict:'pass'|'fail'|'blocked', evidencia:string}>}
82
+ */
83
+ export function validateQaResults(payload, expected = []) {
84
+ if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.scenarios)) {
85
+ throw new Error('o arquivo de resultados deveria ser {"scenarios": [...]}.');
86
+ }
87
+ const out = new Map();
88
+ payload.scenarios.forEach((item, i) => {
89
+ const at = `scenarios[${i}]`;
90
+ const numero = Number(item?.cenario);
91
+ if (!Number.isInteger(numero) || numero <= 0) {
92
+ throw new Error(`${at}.cenario deveria ser o número posicional do cenário, veio ${JSON.stringify(item?.cenario)}.`);
93
+ }
94
+ const verdict = String(item?.verdict || '').toLowerCase();
95
+ if (!QA_VERDICTS.includes(verdict)) {
96
+ throw new Error(
97
+ `${at}.verdict = ${JSON.stringify(item?.verdict)} não é aceito ` +
98
+ `(aceitos, exatamente: ${QA_VERDICTS.join(' | ')}).`
99
+ );
100
+ }
101
+ out.set(numero, {
102
+ numero,
103
+ verdict,
104
+ evidencia: String(item?.evidencia ?? '').trim(),
105
+ });
106
+ });
107
+ const missing = expected.filter(n => !out.has(n));
108
+ if (missing.length > 0) {
109
+ throw new Error(
110
+ `faltou o veredito do(s) cenário(s) ${missing.join(', ')} — cenário que não pôde ` +
111
+ 'ser executado deve vir como "blocked", nunca ser omitido.'
112
+ );
113
+ }
114
+ return expected.map(n => out.get(n));
115
+ }
116
+
117
+ /**
118
+ * Compõe o estado acumulado quando a execução foi parcial (`--only`) —
119
+ * função PURA.
120
+ *
121
+ * Cenários não executados nesta corrida herdam o veredito do ÚLTIMO relatório;
122
+ * sem relatório anterior, contam como pendentes (nunca como pass): a aprovação
123
+ * exige que todos os alvos tenham passado em ALGUMA corrida registrada.
124
+ *
125
+ * @param {object} params
126
+ * @param {Array<{numero:number, verdict:string, evidencia:string}>} params.executed
127
+ * @param {Map<number, {verdict:string}>} [params.previous] numero → resultado anterior
128
+ * @param {number[]} params.allNumbers todos os cenários do escopo
129
+ * @returns {{ combined: Array, pendingNumbers: number[] }}
130
+ */
131
+ export function combineWithPrevious({ executed = [], previous = new Map(), allNumbers = [] } = {}) {
132
+ const executedBy = new Map(executed.map(r => [r.numero, r]));
133
+ const combined = [];
134
+ const pendingNumbers = [];
135
+ for (const n of allNumbers) {
136
+ if (executedBy.has(n)) {
137
+ combined.push({ ...executedBy.get(n), carried: false });
138
+ continue;
139
+ }
140
+ const prev = previous.get(n);
141
+ if (prev) {
142
+ combined.push({ numero: n, verdict: prev.verdict, evidencia: prev.evidencia || '(corrida anterior)', carried: true });
143
+ } else {
144
+ pendingNumbers.push(n);
145
+ }
146
+ }
147
+ return { combined, pendingNumbers };
148
+ }
149
+
150
+ const RESULTS_JSON_RE = /```json spec-wave:qa-results\s*\n([\s\S]*?)\n```/;
151
+
152
+ /**
153
+ * Último relatório de QA de uma issue (função PURA).
154
+ *
155
+ * Lê os comentários em ordem cronológica; o mais recente vence. O estado por
156
+ * cenário sai do bloco JSON cercado — reparsear a tabela markdown seria frágil.
157
+ *
158
+ * @param {Array<{body?:string}>} comments comentários da issue, em ordem
159
+ * @returns {{ run: number, verdict: string|null, results: Map<number,object> }}
160
+ */
161
+ export function parseLastQaReport(comments = []) {
162
+ let run = 0;
163
+ let verdict = null;
164
+ let results = new Map();
165
+ for (const comment of comments) {
166
+ const body = String(comment?.body || '');
167
+ QA_REPORT_MARKER_RE.lastIndex = 0;
168
+ const m = QA_REPORT_MARKER_RE.exec(body);
169
+ if (!m) continue;
170
+ run = Math.max(run, parseInt(m.groups.run, 10));
171
+ verdict = m.groups.verdict;
172
+ const bloco = body.match(RESULTS_JSON_RE);
173
+ if (!bloco) continue;
174
+ try {
175
+ const payload = JSON.parse(bloco[1]);
176
+ if (Array.isArray(payload?.scenarios)) {
177
+ results = new Map(payload.scenarios
178
+ .filter(s => Number.isInteger(s?.cenario))
179
+ .map(s => [s.cenario, { verdict: s.verdict, evidencia: s.evidencia || '' }]));
180
+ }
181
+ } catch { /* bloco ilegível não vira estado */ }
182
+ }
183
+ return { run, verdict, results };
184
+ }
185
+
186
+ const VERDICT_ICON = { pass: '✅ pass', fail: '❌ fail', blocked: '⚪ blocked' };
187
+
188
+ // Evidência entra numa célula de tabela markdown: sem quebra de linha, sem pipe
189
+ // e sem fechar o marcador HTML antes da hora.
190
+ function sanitizeCell(text, max = 200) {
191
+ const clean = String(text ?? '')
192
+ .replace(/-->/g, '--‑>')
193
+ .replace(/\|/g, '\\|')
194
+ .replace(/\s+/g, ' ')
195
+ .trim();
196
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
197
+ }
198
+
199
+ /**
200
+ * Monta o comentário de relatório de QA (função PURA).
201
+ *
202
+ * Primeira linha = marcador (contrato com a UI). O bloco JSON no fim é o que a
203
+ * próxima execução com `--only` lê para compor o estado acumulado.
204
+ *
205
+ * @param {object} params
206
+ * @param {number} params.issue issue onde o relatório será postado
207
+ * @param {string} params.scope ex.: "Story #412" ou "Feature #360"
208
+ * @param {number} params.run número desta execução (1ª, 2ª…)
209
+ * @param {'pass'|'fail'|'blocked'} params.verdict veredito agregado
210
+ * @param {string} params.planSha sha curto do qa-plan.md executado
211
+ * @param {string} [params.headSha] sha curto do HEAD do checkout
212
+ * @param {Array<{numero, verdict, evidencia, carried?}>} params.results
213
+ * @param {Array<{number:number, cenario:number, existing?:boolean}>} [params.bugs]
214
+ * @param {number[]} [params.pendingNumbers] cenários sem veredito em corrida parcial
215
+ * @param {string} [params.trailer] linha final livre (o que acontece a seguir)
216
+ * @returns {string} markdown
217
+ */
218
+ export function renderQaReport({
219
+ issue, scope, run, verdict, planSha, headSha, results = [], bugs = [],
220
+ pendingNumbers = [], trailer = '',
221
+ } = {}) {
222
+ const parts = [
223
+ qaReportMarker({ issue, run, verdict, planSha, headSha }),
224
+ '## 🧪 Relatório de QA (spec-wave)',
225
+ `**Escopo:** ${scope} · **Cenários:** ${results.length} · **Execução:** ${run}ª`,
226
+ ];
227
+
228
+ const linhas = ['| Cenário | Veredito | Evidência |', '|---|---|---|'];
229
+ for (const r of results) {
230
+ const carried = r.carried ? ' _(corrida anterior)_' : '';
231
+ linhas.push(`| ${r.numero} | ${VERDICT_ICON[r.verdict] || r.verdict} | ${sanitizeCell(r.evidencia)}${carried} |`);
232
+ }
233
+ parts.push(linhas.join('\n'));
234
+
235
+ if (pendingNumbers.length > 0) {
236
+ parts.push(
237
+ `⚠️ Cenário(s) ainda **sem veredito** em nenhuma corrida: ${pendingNumbers.join(', ')} — ` +
238
+ 'a aprovação só sai quando todos tiverem passado.'
239
+ );
240
+ }
241
+ if (bugs.length > 0) {
242
+ parts.push(
243
+ '**Bugs abertos:** ' +
244
+ bugs.map(b => `#${b.number} (cenário ${b.cenario}${b.existing ? ', já existia' : ''})`).join(', ')
245
+ );
246
+ }
247
+ if (trailer) parts.push(trailer);
248
+
249
+ parts.push([
250
+ '```json spec-wave:qa-results',
251
+ JSON.stringify({
252
+ issue,
253
+ run,
254
+ verdict,
255
+ scenarios: results.map(r => ({
256
+ cenario: r.numero, verdict: r.verdict, evidencia: sanitizeCell(r.evidencia, 400),
257
+ })),
258
+ }, null, 2),
259
+ '```',
260
+ ].join('\n'));
261
+
262
+ return parts.join('\n\n');
263
+ }
264
+
265
+ /**
266
+ * bug.md DETERMINÍSTICO de um cenário reprovado (função PURA).
267
+ *
268
+ * Exceção documentada à regra "nunca escreva o bug.md à mão" (spec §2.1):
269
+ * quando o Bug nasce de um cenário de QA reprovado, a reprodução, o
270
+ * esperado/obtido e o teste de regressão JÁ EXISTEM e são determinísticos — são
271
+ * o próprio cenário e a saída real da execução. Regenerar por IA só
272
+ * introduziria alucinação sobre uma execução observada.
273
+ *
274
+ * Emite EXATAMENTE as seis seções de REQUIRED_BUG_SECTIONS (o validate compara
275
+ * byte a byte com `# <seção>`).
276
+ *
277
+ * @param {object} params
278
+ * @param {string} params.title título do Bug (sem o prefixo [BUG])
279
+ * @param {{anchor, numero, story, criterio, precondicoes, passos, esperado}} params.scenario
280
+ * @param {string} params.evidence evidência bruta da reprovação
281
+ * @param {string} params.severity P0–P3
282
+ * @param {string} [params.headSha] commit do checkout onde a reprova aconteceu
283
+ * @param {number|null} [params.featureNumber]
284
+ * @returns {string} markdown
285
+ */
286
+ export function renderQaBugDoc({
287
+ title, scenario, evidence, severity, headSha = null, featureNumber = null,
288
+ } = {}) {
289
+ const [
290
+ reproducao, esperadoObtido, impacto, causaRaiz, escopo, regressao,
291
+ ] = REQUIRED_BUG_SECTIONS;
292
+ const passos = scenario.passos
293
+ ? scenario.passos
294
+ : '_(o cenário não declara passos — ver o corpo do cenário no qa-plan.md)_';
295
+ const pre = scenario.precondicoes ? `**Pré-condições:** ${scenario.precondicoes}\n\n` : '';
296
+ return [
297
+ `# [BUG] ${title}`,
298
+ '',
299
+ `> Aberto automaticamente pela reprovação do **${scenario.anchor}** do plano de QA` +
300
+ `${featureNumber ? ` da Feature #${featureNumber}` : ''} (Story #${scenario.story})` +
301
+ `${headSha ? `, no commit \`${headSha}\`` : ''}. Conteúdo determinístico: cenário + saída real da execução.`,
302
+ '',
303
+ `## ${reproducao}`,
304
+ '',
305
+ pre + passos,
306
+ '',
307
+ `## ${esperadoObtido}`,
308
+ '',
309
+ `**Critério:** ${scenario.criterio || '—'}`,
310
+ '',
311
+ `**Esperado:** ${scenario.esperado || '—'}`,
312
+ '',
313
+ `**Obtido:** ${evidence || '(sem evidência registrada)'}`,
314
+ '',
315
+ `## ${impacto}`,
316
+ '',
317
+ `Severidade **${severity}**. O critério de aceite acima está descumprido na Story #${scenario.story} — ` +
318
+ 'a Story não avança de 🧪 QA enquanto este Bug estiver aberto.',
319
+ '',
320
+ `## ${causaRaiz}`,
321
+ '',
322
+ '_A investigar durante a correção — este documento registra uma execução observada, ' +
323
+ 'não uma hipótese de causa._',
324
+ '',
325
+ `## ${escopo}`,
326
+ '',
327
+ '_A definir na investigação. O fix precisa fazer o cenário abaixo passar sem alterar o critério de aceite._',
328
+ '',
329
+ `## ${regressao}`,
330
+ '',
331
+ `Re-executar o cenário reprovado após o fix:`,
332
+ '',
333
+ '```bash',
334
+ `npx @spec-wave/cli@latest qa ${scenario.story} --only ${scenario.numero}`,
335
+ '```',
336
+ '',
337
+ `O veredito precisa ser **pass** com o mesmo esperado: ${scenario.esperado || '—'}`,
338
+ '',
339
+ ].join('\n');
340
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spec-wave",
3
3
  "displayName": "Spec Wave",
4
- "version": "0.29.0",
4
+ "version": "0.30.0",
5
5
  "description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
6
6
  "author": {
7
7
  "name": "Astratech",