@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,627 @@
1
+ // Decisões PURAS do orquestrador de trilha de QA (`spec-wave qa-lead`,
2
+ // rfc/spec-qa-lead.md).
3
+ //
4
+ // O comando (commands/qa-lead.mjs) coleta o estado — milestone, Features,
5
+ // labels, Etapas, relatórios — e as decisões moram aqui, testáveis sem rede:
6
+ // como classificar cada Feature nas duas fases, quando um ciclo termina
7
+ // (D-QAL7), o que re-testar, e como compor o relatório de trilha (§4.3) e o
8
+ // bloco da descrição do milestone (§4.4).
9
+ //
10
+ // Superfície de escrita do Lead (§2.1 — QUALQUER outra escrita é bug):
11
+ // 1. a label de GATILHO `spec-wave:qa` na fase A;
12
+ // 2. os arquivos docs/qa/<slug-milestone>/cycle-<n>/{report.md,report.json};
13
+ // 3. o bloco delimitado na descrição do milestone.
14
+ // Etapa, `qa-approved`, Bugs e comentários de veredito são do `qa`.
15
+
16
+ import {
17
+ STAGE_ORDER, STAGE_QA,
18
+ LABEL_QA, LABEL_QA_READY, LABEL_QA_APPROVED, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN,
19
+ QA_BLOCKED_REASONS, labelNames,
20
+ } from '../config.mjs';
21
+ import { QA_ORIGIN_MARKER_RE } from './qa-report.mjs';
22
+ import { slugify } from './slugify.mjs';
23
+
24
+ export const QA_TRAIL_REPORT_VERSION = 1;
25
+
26
+ /** Defaults do bloco `qa.lead` do .spec-wave.json (§4.5). */
27
+ export const QA_LEAD_DEFAULTS = {
28
+ maxParallel: 4,
29
+ maxCycles: 3,
30
+ featureTimeoutMin: 45,
31
+ pollIntervalSec: 30,
32
+ planWaitTimeoutMin: 20,
33
+ };
34
+
35
+ /**
36
+ * Configuração efetiva do Lead (função PURA) — §4.5.
37
+ *
38
+ * Valor inválido (não inteiro positivo) cai no default em silêncio; a ausência
39
+ * de `container.image` NÃO é resolvida aqui — a fase B recusa orientando,
40
+ * porque só ela precisa de container.
41
+ *
42
+ * @param {object} [qaLead] bloco `qa.lead` do .spec-wave.json
43
+ */
44
+ export function resolveLeadConfig(qaLead = {}) {
45
+ const cfg = qaLead && typeof qaLead === 'object' ? qaLead : {};
46
+ const out = { ...QA_LEAD_DEFAULTS };
47
+ for (const key of Object.keys(QA_LEAD_DEFAULTS)) {
48
+ if (Number.isInteger(cfg[key]) && cfg[key] > 0) out[key] = cfg[key];
49
+ }
50
+ return {
51
+ ...out,
52
+ backend: cfg.backend === 'sandbox' ? 'sandbox' : 'docker',
53
+ container: {
54
+ image: typeof cfg.container?.image === 'string' && cfg.container.image.trim()
55
+ ? cfg.container.image.trim()
56
+ : null,
57
+ env: cfg.container?.env && typeof cfg.container.env === 'object' ? cfg.container.env : {},
58
+ },
59
+ };
60
+ }
61
+
62
+ /** Diretório (relativo à raiz) do relatório de um ciclo — D-QAL4. */
63
+ export function qaTrailDir(milestoneTitle, cycle) {
64
+ // Ponto de versão vira separador ("v1.4" → "v1-4", como no exemplo da spec):
65
+ // o slugify sozinho o descartaria e "v1.4"/"v14" colidiriam no mesmo diretório.
66
+ const title = String(milestoneTitle ?? '').replace(/\./g, '-');
67
+ return `docs/qa/${slugify(title)}/cycle-${cycle}`;
68
+ }
69
+
70
+ /**
71
+ * Resolve a referência de milestone (número ou título) — função PURA.
72
+ *
73
+ * Título ambíguo ou inexistente → erro listando os candidatos (§6.1): mandar o
74
+ * usuário adivinhar entre nome errado e API fora do ar é o anti-padrão.
75
+ *
76
+ * @param {Array<{number:number, title:string, state?:string}>} milestones
77
+ * @param {string|number} ref
78
+ * @returns {{ milestone?: object, error?: string }}
79
+ */
80
+ export function resolveMilestoneRef(milestones = [], ref) {
81
+ const text = String(ref ?? '').trim();
82
+ if (/^#?\d+$/.test(text)) {
83
+ const number = parseInt(text.replace('#', ''), 10);
84
+ const found = milestones.find(m => m.number === number);
85
+ return found
86
+ ? { milestone: found }
87
+ : { error: `Milestone #${number} não existe. Existem: ${listMilestoneTitles(milestones)}.` };
88
+ }
89
+ const matches = milestones.filter(m => String(m.title).toLowerCase() === text.toLowerCase());
90
+ if (matches.length === 1) return { milestone: matches[0] };
91
+ if (matches.length > 1) {
92
+ return {
93
+ error: `Título "${text}" é ambíguo: ${matches.map(m => `#${m.number}`).join(', ')}. Use o número.`,
94
+ };
95
+ }
96
+ return { error: `Milestone "${text}" não encontrada. Existem: ${listMilestoneTitles(milestones)}.` };
97
+ }
98
+
99
+ function listMilestoneTitles(milestones) {
100
+ if (milestones.length === 0) return '(nenhuma)';
101
+ return milestones.map(m => `#${m.number} "${m.title}"`).join(', ');
102
+ }
103
+
104
+ // ── Fase A — prontidão dos planos ────────────────────────────────────────────
105
+
106
+ /**
107
+ * Classifica uma Feature para a fase de planos (função PURA) — §5/§6.1.
108
+ *
109
+ * • `pronta` — qa-ready (ou já qa-approved): nada a fazer;
110
+ * • `portao-humano` — critique-failed/needs-human: NUNCA aplicar gatilho;
111
+ * • `em-voo` — `spec-wave:qa` ainda na issue: geração/crítica rodando;
112
+ * • `sem-spec-plan` — sem spec.md+plan.md na base: não há de onde gerar;
113
+ * • `gerar` — aplicar `spec-wave:qa` (gera, ou re-critica o existente).
114
+ *
115
+ * @param {object} params
116
+ * @param {Array<string|{name:string}>} [params.labels]
117
+ * @param {boolean} [params.hasSpecPlan] spec.md e plan.md existem
118
+ * @returns {{ state: string, apply: boolean }}
119
+ */
120
+ export function classifyForPlan({ labels = [], hasSpecPlan = true } = {}) {
121
+ const names = new Set(labelNames(labels));
122
+ if (names.has(LABEL_QA_READY) || names.has(LABEL_QA_APPROVED)) {
123
+ return { state: 'pronta', apply: false };
124
+ }
125
+ if (names.has(LABEL_CRITIQUE_FAILED) || names.has(LABEL_NEEDS_HUMAN)) {
126
+ return { state: 'portao-humano', apply: false };
127
+ }
128
+ if (names.has(LABEL_QA)) {
129
+ return { state: 'em-voo', apply: false };
130
+ }
131
+ if (!hasSpecPlan) {
132
+ return { state: 'sem-spec-plan', apply: false };
133
+ }
134
+ return { state: 'gerar', apply: true };
135
+ }
136
+
137
+ /**
138
+ * Exit code da fase A (função PURA): 0 só se TODA Feature elegível está pronta.
139
+ *
140
+ * `sem-spec-plan` conta como não-pronta de propósito — uma Feature do milestone
141
+ * sem spec/plan é pendência da trilha, não caso fora do escopo.
142
+ *
143
+ * @param {Array<{state:string}>} classified
144
+ * @returns {number}
145
+ */
146
+ export function planExitCode(classified = []) {
147
+ return classified.every(c => c.state === 'pronta') ? 0 : 1;
148
+ }
149
+
150
+ // ── Fase B — escopo, portão e despacho ───────────────────────────────────────
151
+
152
+ /**
153
+ * Escopo de uma Feature no ciclo pela Etapa (função PURA) — §6.2.
154
+ *
155
+ * Etapa desconhecida/ilegível conta como `fora-do-ciclo`: na dúvida, o Lead não
156
+ * abre container — subir ambiente para uma Feature que talvez nem esteja em QA
157
+ * é custo sem veredito possível.
158
+ */
159
+ export function classifyScope({ stage = null } = {}) {
160
+ const idx = stage ? STAGE_ORDER.indexOf(stage) : -1;
161
+ const qaIdx = STAGE_ORDER.indexOf(STAGE_QA);
162
+ if (idx === -1 || idx < qaIdx) return 'fora-do-ciclo';
163
+ if (idx === qaIdx) return 'no-ciclo';
164
+ return 'ja-aprovada';
165
+ }
166
+
167
+ /**
168
+ * Portão de entrada da fase B (função PURA) — D-QAL2.
169
+ *
170
+ * TODA Feature em 🧪 QA precisa de `qa-ready`; faltando qualquer uma, a fase B
171
+ * recusa listando-as. Feature fora do ciclo não bloqueia (é contabilizada).
172
+ *
173
+ * @param {Array<{number:number, labels?:Array, scope:string}>} features
174
+ * @returns {{ ok: boolean, missing: number[] }}
175
+ */
176
+ export function runGate(features = []) {
177
+ const missing = features
178
+ .filter(f => f.scope === 'no-ciclo' && !labelNames(f.labels || []).includes(LABEL_QA_READY))
179
+ .map(f => f.number);
180
+ return { ok: missing.length === 0, missing };
181
+ }
182
+
183
+ /**
184
+ * Ordem das Features derivada da ordem topológica das Stories (função PURA).
185
+ *
186
+ * D-QAL1: a trilha usa a MESMA ordenação do `spec-wave order` sem argumento —
187
+ * uma Feature vem antes de outra quando alguma Story dela aparece antes na
188
+ * ordem topológica. Features sem Story ordenada ficam no fim, na ordem de
189
+ * entrada (a do milestone).
190
+ *
191
+ * @param {object} params
192
+ * @param {number[]} params.featureNumbers na ordem de entrada
193
+ * @param {number[]} params.storyOrder ordem topológica (números de Story)
194
+ * @param {Map<number, number>} params.featureOfStory Story → Feature dona
195
+ * @returns {number[]}
196
+ */
197
+ export function featureOrderFromStories({ featureNumbers = [], storyOrder = [], featureOfStory = new Map() } = {}) {
198
+ const known = new Set(featureNumbers);
199
+ const ordered = [];
200
+ const seen = new Set();
201
+ for (const story of storyOrder) {
202
+ const feature = featureOfStory.get(story);
203
+ if (feature != null && known.has(feature) && !seen.has(feature)) {
204
+ seen.add(feature);
205
+ ordered.push(feature);
206
+ }
207
+ }
208
+ for (const feature of featureNumbers) {
209
+ if (!seen.has(feature)) {
210
+ seen.add(feature);
211
+ ordered.push(feature);
212
+ }
213
+ }
214
+ return ordered;
215
+ }
216
+
217
+ // ── Coleta e veredito ────────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Veredito de Feature a partir do veredito do qa-report (função PURA).
221
+ *
222
+ * `null` (nenhum relatório novo apareceu) é `execucao-abortada` — §3.3: uma
223
+ * Feature sem NENHUM veredito nunca pode ser lida como verde, e o silêncio de
224
+ * um container morto viraria exatamente isso.
225
+ */
226
+ export function featureVerdictFromRun(reportVerdict) {
227
+ if (reportVerdict === 'pass') return 'verde';
228
+ if (reportVerdict === 'fail') return 'vermelho';
229
+ if (reportVerdict === 'blocked') return 'inconclusivo';
230
+ return 'execucao-abortada';
231
+ }
232
+
233
+ /**
234
+ * Contagem de cenários de um conjunto de resultados (função PURA).
235
+ *
236
+ * @param {Array<{verdict:string, blockedReason?:string|null}>} results
237
+ * @returns {{ pass:number, fail:number, blocked:number, total:number, blockedReasons:Record<string,number> }}
238
+ */
239
+ export function scenarioCounts(results = []) {
240
+ const out = { pass: 0, fail: 0, blocked: 0, total: 0, blockedReasons: {} };
241
+ for (const r of results) {
242
+ if (r.verdict === 'pass') out.pass += 1;
243
+ else if (r.verdict === 'fail') out.fail += 1;
244
+ else if (r.verdict === 'blocked') {
245
+ out.blocked += 1;
246
+ const reason = QA_BLOCKED_REASONS.includes(r.blockedReason) ? r.blockedReason : 'outro';
247
+ out.blockedReasons[reason] = (out.blockedReasons[reason] || 0) + 1;
248
+ }
249
+ out.total += 1;
250
+ }
251
+ return out;
252
+ }
253
+
254
+ /**
255
+ * Entrada de Feature abortada (função PURA) — §3.3: os cenários PLANEJADOS
256
+ * contam como blocked/ambiente, registrados pelo LEAD (o `qa` nunca soube da
257
+ * morte do container).
258
+ */
259
+ export function abortedFeatureEntry({ issue, title, plannedScenarios = 0, durationSec = null }) {
260
+ return {
261
+ issue,
262
+ title,
263
+ verdict: 'execucao-abortada',
264
+ scenarios: { pass: 0, fail: 0, blocked: plannedScenarios, total: plannedScenarios },
265
+ blockedReasons: plannedScenarios > 0 ? { ambiente: plannedScenarios } : {},
266
+ bugsOpened: [],
267
+ durationSec,
268
+ };
269
+ }
270
+
271
+ // ── Relatório de trilha (§4.3) ───────────────────────────────────────────────
272
+
273
+ /**
274
+ * Compõe o report.json de um ciclo (função PURA) — contrato em
275
+ * protocol/qa-trail-report.v1.json.
276
+ *
277
+ * @param {object} params
278
+ * @param {{number:number, title:string}} params.milestone
279
+ * @param {number} params.cycle
280
+ * @param {string|null} [params.startedAt]
281
+ * @param {string|null} [params.finishedAt]
282
+ * @param {{ok:boolean, checks:Array}} [params.preflight]
283
+ * @param {Array} params.features entradas por Feature (ver abortedFeatureEntry)
284
+ * @param {{pass:number, blocked:number}|null} [params.previousTotals] do ciclo N-1
285
+ * @param {number} [params.closedSincePreviousCycle]
286
+ * @param {number[]} [params.openBlocking] Bugs abertos segurando a trilha
287
+ * @param {string|null} [params.terminationReason]
288
+ */
289
+ export function buildTrailReport({
290
+ milestone, cycle, startedAt = null, finishedAt = null,
291
+ preflight = { ok: true, checks: [] },
292
+ features = [], previousTotals = null, closedSincePreviousCycle = 0,
293
+ openBlocking = [], terminationReason = null,
294
+ } = {}) {
295
+ const totals = { pass: 0, fail: 0, blocked: 0, naoExecutado: 0, total: 0 };
296
+ const blockedByReason = {};
297
+ let opened = 0;
298
+ for (const f of features) {
299
+ totals.pass += f.scenarios?.pass || 0;
300
+ totals.fail += f.scenarios?.fail || 0;
301
+ totals.blocked += f.scenarios?.blocked || 0;
302
+ totals.naoExecutado += f.naoExecutado || 0;
303
+ for (const [reason, n] of Object.entries(f.blockedReasons || {})) {
304
+ blockedByReason[reason] = (blockedByReason[reason] || 0) + n;
305
+ }
306
+ opened += (f.bugsOpened || []).length;
307
+ }
308
+ totals.total = totals.pass + totals.fail + totals.blocked + totals.naoExecutado;
309
+
310
+ const inCycle = features.filter(f =>
311
+ ['verde', 'vermelho', 'inconclusivo', 'execucao-abortada'].includes(f.verdict));
312
+ let verdict;
313
+ if (preflight.ok === false) verdict = 'ciclo-abortado';
314
+ // Feature abortada NUNCA é lida como verde (§3.3) — está no filtro abaixo.
315
+ else if (inCycle.length > 0 && inCycle.every(f => f.verdict === 'verde')) verdict = 'liberada';
316
+ else verdict = 'nao-liberada';
317
+
318
+ return {
319
+ version: QA_TRAIL_REPORT_VERSION,
320
+ milestone: { number: milestone.number, title: milestone.title },
321
+ cycle,
322
+ startedAt,
323
+ finishedAt,
324
+ preflight,
325
+ features: features.map(f => ({
326
+ issue: f.issue,
327
+ title: f.title,
328
+ verdict: f.verdict,
329
+ scenarios: f.scenarios || { pass: 0, fail: 0, blocked: 0, total: 0 },
330
+ bugsOpened: f.bugsOpened || [],
331
+ durationSec: f.durationSec ?? null,
332
+ })),
333
+ totals,
334
+ blockedByReason,
335
+ bugs: {
336
+ opened,
337
+ closedSincePreviousCycle,
338
+ openBlocking,
339
+ },
340
+ delta: previousTotals
341
+ ? { pass: totals.pass - (previousTotals.pass || 0), blocked: totals.blocked - (previousTotals.blocked || 0) }
342
+ : null,
343
+ verdict,
344
+ terminationReason,
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Houve mudança de estado desde o ciclo anterior? (função PURA — D-QAL7)
350
+ *
351
+ * Mudança = algum Bug fechado desde o ciclo N, ou os totais de pass/fail/blocked
352
+ * se moveram. Sem ciclo anterior, a pergunta não se aplica (null).
353
+ */
354
+ export function changedSincePrevious({ previous = null, current, bugsClosedSince = 0 } = {}) {
355
+ if (!previous) return null;
356
+ if (bugsClosedSince > 0) return true;
357
+ const a = previous.totals || {};
358
+ const b = current.totals || {};
359
+ return a.pass !== b.pass || a.fail !== b.fail || a.blocked !== b.blocked;
360
+ }
361
+
362
+ /**
363
+ * terminationReason do ciclo recém-terminado (função PURA) — D-QAL7 + §4.3.
364
+ *
365
+ * Preenchido só quando este é o ÚLTIMO ciclo; null = outro ciclo pode vir.
366
+ */
367
+ export function terminationOf({
368
+ preflightOk = true, trailVerdict, cycle, maxCycles, changed = null,
369
+ } = {}) {
370
+ if (!preflightOk) return 'preflight-falhou';
371
+ if (trailVerdict === 'liberada') return 'todas-verdes';
372
+ if (changed === false) return 'sem-mudanca-de-estado';
373
+ if (cycle >= maxCycles) return 'teto-de-ciclos';
374
+ return null;
375
+ }
376
+
377
+ /**
378
+ * O ciclo N+1 pode começar? (função PURA — D-QAL7)
379
+ *
380
+ * Só se, desde o ciclo N, algum Bug foi fechado OU o ciclo N deixou cenários
381
+ * bloqueados (um bloqueio pode ter caído — só rodando se descobre; o pós-ciclo
382
+ * detecta a passada vazia e para com `sem-mudanca-de-estado`).
383
+ *
384
+ * @param {object} params
385
+ * @param {object|null} params.previousReport report.json do ciclo N
386
+ * @param {number} [params.bugsClosedSince]
387
+ * @param {number} [params.maxCycles]
388
+ */
389
+ export function nextCycleGate({ previousReport = null, bugsClosedSince = 0, maxCycles = QA_LEAD_DEFAULTS.maxCycles } = {}) {
390
+ if (!previousReport) return { ok: true, reason: null }; // ciclo 1
391
+ if (previousReport.verdict === 'liberada') {
392
+ return { ok: false, reason: 'trilha já liberada no ciclo anterior — nada a re-testar' };
393
+ }
394
+ if (previousReport.cycle >= maxCycles) {
395
+ return {
396
+ ok: false,
397
+ reason: `teto de ${maxCycles} ciclos atingido (D-QAL7) — escale para humano`,
398
+ };
399
+ }
400
+ if (previousReport.terminationReason === 'sem-mudanca-de-estado' && bugsClosedSince === 0) {
401
+ return {
402
+ ok: false,
403
+ reason: 'o ciclo anterior terminou sem mudança de estado e nenhum Bug foi fechado desde então',
404
+ };
405
+ }
406
+ if (bugsClosedSince > 0) return { ok: true, reason: null };
407
+ if ((previousReport.totals?.blocked || 0) > 0) return { ok: true, reason: null };
408
+ return {
409
+ ok: false,
410
+ reason:
411
+ 'nenhum Bug foi fechado desde o ciclo anterior e não havia cenário bloqueado — ' +
412
+ 're-executar produziria o mesmo resultado (D-QAL7)',
413
+ };
414
+ }
415
+
416
+ // ── Retestes (§5) ────────────────────────────────────────────────────────────
417
+
418
+ /** Origem de QA gravada no corpo de um Bug (função PURA). */
419
+ export function bugOriginOf(body) {
420
+ const m = QA_ORIGIN_MARKER_RE.exec(String(body || ''));
421
+ if (!m) return null;
422
+ return { issue: parseInt(m.groups.issue, 10), cenario: parseInt(m.groups.cenario, 10) };
423
+ }
424
+
425
+ /**
426
+ * Alvos do ciclo N+1 (função PURA) — retestes derivados do ciclo N:
427
+ *
428
+ * • Story com Bug filho FECHADO desde N → `qa <story> --only <cenários de origem>`;
429
+ * • Feature inconclusiva → re-executa só os cenários blocked;
430
+ * • Feature abortada → re-executa inteira (não há veredito parcial confiável);
431
+ * • Feature verde no ciclo N → NÃO re-executa (§8 — regressão de aprovadas é
432
+ * follow-up);
433
+ * • Feature vermelha sem Bug fechado → nada a re-testar nela ainda.
434
+ *
435
+ * @param {object} params
436
+ * @param {Array} params.previousFeatures entradas `features` do report N
437
+ * @param {Map<number, Array<{numero:number, verdict:string}>>} params.lastResults
438
+ * issue da Feature → resultados por cenário do último qa-report
439
+ * @param {Array<{number:number, body?:string}>} params.closedBugs Bugs (de QA)
440
+ * fechados desde o ciclo N
441
+ * @returns {{ targets: Array<{issue:number, feature:number|null, only:number[]|null, motivo:string}> }}
442
+ */
443
+ export function retestPlan({ previousFeatures = [], lastResults = new Map(), closedBugs = [] } = {}) {
444
+ const targets = [];
445
+ const seen = new Set();
446
+
447
+ // 1. Bugs fechados → re-teste dos cenários de origem, agrupados por Story.
448
+ const byStory = new Map();
449
+ for (const bug of closedBugs) {
450
+ const origin = bugOriginOf(bug.body);
451
+ if (!origin) continue;
452
+ if (!byStory.has(origin.issue)) byStory.set(origin.issue, new Set());
453
+ byStory.get(origin.issue).add(origin.cenario);
454
+ }
455
+ for (const [story, cenarios] of byStory) {
456
+ seen.add(`story:${story}`);
457
+ targets.push({
458
+ issue: story,
459
+ feature: null,
460
+ only: [...cenarios].sort((a, b) => a - b),
461
+ motivo: 'bug-fechado',
462
+ });
463
+ }
464
+
465
+ // 2. Features inconclusivas → só os cenários blocked; abortadas → inteiras.
466
+ for (const f of previousFeatures) {
467
+ if (f.verdict === 'inconclusivo') {
468
+ const blocked = (lastResults.get(f.issue) || [])
469
+ .filter(r => r.verdict === 'blocked')
470
+ .map(r => r.numero)
471
+ .sort((a, b) => a - b);
472
+ targets.push({
473
+ issue: f.issue,
474
+ feature: f.issue,
475
+ only: blocked.length > 0 ? blocked : null,
476
+ motivo: 'inconclusiva',
477
+ });
478
+ } else if (f.verdict === 'execucao-abortada') {
479
+ targets.push({ issue: f.issue, feature: f.issue, only: null, motivo: 'execucao-abortada' });
480
+ }
481
+ }
482
+ return { targets };
483
+ }
484
+
485
+ // ── Bloco da descrição do milestone (§4.4) ───────────────────────────────────
486
+
487
+ const TRAIL_BLOCK_RE =
488
+ /<!--\s*spec-wave:qa-trail\s+v\d+[^>]*-->[\s\S]*?<!--\s*\/spec-wave:qa-trail\s*-->/;
489
+
490
+ const TRAIL_VERDICT_LABEL = {
491
+ liberada: 'liberada',
492
+ 'nao-liberada': 'não liberada',
493
+ 'ciclo-abortado': 'ciclo abortado',
494
+ };
495
+
496
+ function ddmmyyyy(iso) {
497
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
498
+ return m ? `${m[3]}/${m[2]}/${m[1]}` : '';
499
+ }
500
+
501
+ /**
502
+ * O bloco delimitado do milestone (função PURA) — resumo CURTO de tamanho fixo;
503
+ * o detalhe vive no arquivo commitado.
504
+ *
505
+ * @param {object} report report.json do ciclo
506
+ * @param {string} reportMdRel caminho do report.md relativo à raiz do repo
507
+ * @returns {string}
508
+ */
509
+ export function milestoneBlock(report, reportMdRel) {
510
+ const openCount = countOpenBugs(report);
511
+ const linhas = [
512
+ `<!-- spec-wave:qa-trail v${QA_TRAIL_REPORT_VERSION} milestone=${report.milestone.number} cycle=${report.cycle} -->`,
513
+ `### 🧪 QA — ciclo ${report.cycle}${report.finishedAt ? ` (${ddmmyyyy(report.finishedAt)})` : ''}`,
514
+ `🟢 ${report.totals.pass} · 🔴 ${report.totals.fail} · ⛔ ${report.totals.blocked} · ` +
515
+ `Bugs abertos: ${openCount} · **Veredito: ${TRAIL_VERDICT_LABEL[report.verdict] || report.verdict}**`,
516
+ ];
517
+ const bloqueadores = report.bugs?.openBlocking || [];
518
+ const partes = [];
519
+ if (bloqueadores.length > 0) partes.push(`Bloqueadores: ${bloqueadores.map(n => `#${n}`).join(', ')}`);
520
+ partes.push(`[Relatório completo](${reportMdRel})`);
521
+ linhas.push(partes.join(' · '));
522
+ linhas.push('<!-- /spec-wave:qa-trail -->');
523
+ return linhas.join('\n');
524
+ }
525
+
526
+ function countOpenBugs(report) {
527
+ // "Bugs abertos" do resumo = os que seguram a trilha agora; na falta da
528
+ // lista, o total aberto no ciclo.
529
+ const blocking = report.bugs?.openBlocking;
530
+ if (Array.isArray(blocking) && blocking.length > 0) return blocking.length;
531
+ return report.bugs?.opened || 0;
532
+ }
533
+
534
+ /**
535
+ * Substitui o bloco entre os marcadores na descrição do milestone (função
536
+ * PURA). SUBSTITUI, nunca acrescenta um segundo bloco — a descrição também
537
+ * hospeda as Release Notes, e sobrescrever a descrição inteira apagaria
538
+ * trabalho de outra feature (§4.4).
539
+ *
540
+ * @param {string|null} description descrição atual do milestone
541
+ * @param {string} block bloco novo (milestoneBlock)
542
+ * @returns {string}
543
+ */
544
+ export function upsertMilestoneBlock(description, block) {
545
+ const atual = String(description ?? '');
546
+ if (TRAIL_BLOCK_RE.test(atual)) {
547
+ return atual.replace(TRAIL_BLOCK_RE, block);
548
+ }
549
+ if (!atual.trim()) return block;
550
+ return `${atual.replace(/\s+$/, '')}\n\n${block}`;
551
+ }
552
+
553
+ // ── report.md (§4.3 — o irmão legível do report.json) ────────────────────────
554
+
555
+ const FEATURE_VERDICT_ICON = {
556
+ verde: '🟢 verde',
557
+ vermelho: '🔴 vermelho',
558
+ inconclusivo: '⚪ inconclusivo',
559
+ 'execucao-abortada': '💥 execução abortada',
560
+ 'fora-do-ciclo': '⏭️ fora do ciclo',
561
+ 'ja-aprovada': '✅ já aprovada',
562
+ };
563
+
564
+ /**
565
+ * Renderiza o report.md de um ciclo (função PURA).
566
+ *
567
+ * @param {object} report report.json (buildTrailReport)
568
+ * @returns {string} markdown
569
+ */
570
+ export function renderTrailReportMd(report) {
571
+ const t = report.totals;
572
+ const parts = [
573
+ `# 🧪 QA da trilha — milestone "${report.milestone.title}" · ciclo ${report.cycle}`,
574
+ '',
575
+ `**Veredito da trilha:** ${TRAIL_VERDICT_LABEL[report.verdict] || report.verdict}` +
576
+ (report.terminationReason ? ` · **término:** ${report.terminationReason}` : ''),
577
+ '',
578
+ `**Cenários:** 🟢 ${t.pass} pass · 🔴 ${t.fail} fail · ⛔ ${t.blocked} blocked · ` +
579
+ `${t.naoExecutado} não executado(s) · ${t.total} no total`,
580
+ ];
581
+ if (report.startedAt || report.finishedAt) {
582
+ parts.push('', `Início: ${report.startedAt || '—'} · fim: ${report.finishedAt || '—'}`);
583
+ }
584
+
585
+ if (report.preflight && report.preflight.ok === false) {
586
+ parts.push('', '## ⛔ Preflight falhou — ciclo abortado', '');
587
+ for (const c of report.preflight.checks || []) {
588
+ parts.push(`- ${c.ok ? '✅' : '❌'} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
589
+ }
590
+ }
591
+
592
+ parts.push('', '## Features', '', '| Feature | Veredito | 🟢 | 🔴 | ⛔ | Bugs abertos | Duração |', '|---|---|---|---|---|---|---|');
593
+ for (const f of report.features) {
594
+ const s = f.scenarios || {};
595
+ const bugs = (f.bugsOpened || []).map(n => `#${n}`).join(', ') || '—';
596
+ const dur = f.durationSec != null ? `${Math.round(f.durationSec / 60)}min` : '—';
597
+ parts.push(
598
+ `| #${f.issue} ${f.title} | ${FEATURE_VERDICT_ICON[f.verdict] || f.verdict} | ` +
599
+ `${s.pass ?? 0} | ${s.fail ?? 0} | ${s.blocked ?? 0} | ${bugs} | ${dur} |`
600
+ );
601
+ }
602
+
603
+ const reasons = Object.entries(report.blockedByReason || {});
604
+ if (reasons.length > 0) {
605
+ parts.push('', '## Bloqueios por motivo', '');
606
+ for (const [reason, n] of reasons.sort((a, b) => b[1] - a[1])) {
607
+ parts.push(`- \`${reason}\`: ${n}`);
608
+ }
609
+ }
610
+
611
+ parts.push(
612
+ '',
613
+ '## Bugs',
614
+ '',
615
+ `- Abertos neste ciclo: ${report.bugs?.opened ?? 0}`,
616
+ `- Fechados desde o ciclo anterior: ${report.bugs?.closedSincePreviousCycle ?? 0}`,
617
+ `- Segurando a trilha: ${(report.bugs?.openBlocking || []).map(n => `#${n}`).join(', ') || 'nenhum'}`,
618
+ );
619
+
620
+ if (report.delta) {
621
+ const sig = n => (n > 0 ? `+${n}` : String(n));
622
+ parts.push('', `**Delta vs ciclo anterior:** pass ${sig(report.delta.pass)} · blocked ${sig(report.delta.blocked)}`);
623
+ }
624
+
625
+ parts.push('');
626
+ return parts.join('\n');
627
+ }