@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.
Files changed (44) hide show
  1. package/package.json +5 -3
  2. package/protocol/qa-result.v1.json +62 -0
  3. package/protocol/qa-trail-report.v1.json +113 -0
  4. package/src/api/github-graphql.mjs +6 -1
  5. package/src/api/github-rest.mjs +21 -0
  6. package/src/cli.mjs +114 -9
  7. package/src/commands/decompose.mjs +29 -3
  8. package/src/commands/doctor.mjs +183 -3
  9. package/src/commands/generate-qa-plan.mjs +421 -0
  10. package/src/commands/implement.mjs +56 -44
  11. package/src/commands/merge.mjs +43 -14
  12. package/src/commands/order.mjs +350 -96
  13. package/src/commands/qa-lead.mjs +748 -0
  14. package/src/commands/qa-run.mjs +892 -0
  15. package/src/commands/run.mjs +5 -1
  16. package/src/config.mjs +32 -1
  17. package/src/lib/artifact-pr.mjs +2 -0
  18. package/src/lib/artifact-publish.mjs +5 -2
  19. package/src/lib/board.mjs +14 -0
  20. package/src/lib/critique.mjs +38 -9
  21. package/src/lib/decomposition-doc.mjs +5 -1
  22. package/src/lib/dependency-map.mjs +300 -0
  23. package/src/lib/doc-paths.mjs +9 -2
  24. package/src/lib/git-retry.mjs +82 -0
  25. package/src/lib/net-cache.mjs +142 -0
  26. package/src/lib/next-step.mjs +15 -3
  27. package/src/lib/qa-exec.mjs +335 -0
  28. package/src/lib/qa-lead-backend.mjs +213 -0
  29. package/src/lib/qa-lead.mjs +627 -0
  30. package/src/lib/qa-plan-doc.mjs +340 -0
  31. package/src/lib/qa-report.mjs +396 -0
  32. package/src/lib/skill-compose.mjs +234 -0
  33. package/src/lib/story-graph.mjs +256 -0
  34. package/src/plugin/.claude-plugin/plugin.json +1 -1
  35. package/src/plugin/skills/merge/SKILL.md +1 -0
  36. package/src/plugin/skills/order/SKILL.md +21 -5
  37. package/src/plugin/skills/qa/SKILL.md +107 -0
  38. package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
  39. package/src/plugin/skills/qa/model-prompt.md +68 -0
  40. package/src/plugin/skills/qa-executor/SKILL.md +76 -0
  41. package/src/plugin/skills/qa-lead/SKILL.md +89 -0
  42. package/src/templates/skill/SKILL.md +981 -279
  43. package/src/templates/skill/core.md +584 -0
  44. package/src/templates/workflows/generate-qa-plan.yml +64 -0
@@ -0,0 +1,748 @@
1
+ // Orquestrador da trilha de QA — `spec-wave qa-lead <plan|run|report>`
2
+ // (rfc/spec-qa-lead.md).
3
+ //
4
+ // O `qa` valida UMA issue por vez; o `qa-lead` percorre as Features de um
5
+ // MILESTONE (D-QAL1) em duas fases separadas por um portão humano (D-QAL2):
6
+ //
7
+ // plan → classifica cada Feature e aplica o gatilho `spec-wave:qa` nas que
8
+ // não têm plano. PARA no portão de revisão humana (D-QA4).
9
+ // run → com a trilha inteira em `qa-ready`, despacha um container isolado
10
+ // por Feature (D-QAL3), coleta os vereditos dos comentários
11
+ // `spec-wave:qa-report` e grava o relatório do ciclo (D-QAL4).
12
+ // report → reimprime o relatório de um ciclo já executado, sem executar nada.
13
+ //
14
+ // Superfície de escrita (§2.1) — exatamente três coisas: a label de GATILHO,
15
+ // os arquivos docs/qa/<slug>/cycle-<n>/ e o bloco na descrição do milestone.
16
+ // Toda mutação de board, Bug e comentário de veredito é do `qa`, que roda
17
+ // DENTRO dos containers.
18
+
19
+ import { execSync } from 'node:child_process';
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ import { setTimeout as sleep } from 'node:timers/promises';
23
+ import chalk from 'chalk';
24
+
25
+ import { resolveToken } from '../api/auth.mjs';
26
+ import {
27
+ addLabel, getIssue, listMilestones, listIssuesByMilestone,
28
+ getMilestone, updateMilestoneDescription, listIssueComments,
29
+ } from '../api/github-rest.mjs';
30
+ import { detectIssueType } from '../lib/issue-type.mjs';
31
+ import { loadConfig, findConfigPath } from '../lib/project-root.mjs';
32
+ import { loadProjectConfig } from '../lib/board.mjs';
33
+ import { featureDocPaths } from '../lib/doc-paths.mjs';
34
+ import { orderStories } from '../lib/dependencies.mjs';
35
+ import { loadFeatureStories, loadBoardSnapshot } from '../lib/story-graph.mjs';
36
+ import { resolveCacheTtl } from '../lib/net-cache.mjs';
37
+ import { parseQaPlanDoc } from '../lib/qa-plan-doc.mjs';
38
+ import { parseLastQaReport } from '../lib/qa-report.mjs';
39
+ import {
40
+ resolveLeadConfig, resolveMilestoneRef, qaTrailDir,
41
+ classifyForPlan, planExitCode, classifyScope, runGate, featureOrderFromStories,
42
+ featureVerdictFromRun, scenarioCounts, abortedFeatureEntry,
43
+ buildTrailReport, changedSincePrevious, terminationOf, nextCycleGate, retestPlan,
44
+ milestoneBlock, upsertMilestoneBlock, renderTrailReportMd,
45
+ } from '../lib/qa-lead.mjs';
46
+ import { createExecutionBackend, containerScript } from '../lib/qa-lead-backend.mjs';
47
+ import { pushWithRebase } from '../lib/git-retry.mjs';
48
+ import { CLI_VERSION } from '../lib/templates.mjs';
49
+ import { CONFIG_FILE, LABEL_QA, LABEL_QA_READY, LABEL_CRITIQUE_FAILED, labelNames } from '../config.mjs';
50
+
51
+ // Intervalo mínimo entre aplicações de label (§6.1): cada uma dispara um run
52
+ // do Actions, e uma rajada satura a fila.
53
+ const TRIGGER_INTERVAL_MS = 3000;
54
+
55
+ // Recusa "esperada": mensagem, exit 1, sem stack trace.
56
+ class QaLeadRefusal extends Error {
57
+ constructor(message) {
58
+ super(message);
59
+ this.name = 'QaLeadRefusal';
60
+ }
61
+ }
62
+
63
+ export async function qaLead({ action, milestone: milestoneArg, ...options }) {
64
+ const acoes = { plan: qaLeadPlan, run: qaLeadRun, report: qaLeadReport };
65
+ const fn = acoes[action];
66
+ if (!fn) {
67
+ throw new QaLeadRefusal(`Ação inválida: "${action}". Use: qa-lead plan | run | report <milestone>.`);
68
+ }
69
+ if (!milestoneArg) {
70
+ throw new QaLeadRefusal('Informe a milestone (número ou título): `spec-wave qa-lead ' + action + ' <milestone>`.');
71
+ }
72
+ return await fn(milestoneArg, options);
73
+ }
74
+
75
+ // ── bootstrap compartilhado ──────────────────────────────────────────────────
76
+
77
+ async function bootstrap(milestoneArg) {
78
+ const configPath = findConfigPath();
79
+ if (!configPath) {
80
+ throw new QaLeadRefusal(
81
+ `Repositório não inicializado (sem ${CONFIG_FILE}). ` +
82
+ 'Rode `npx @spec-wave/cli@latest init` (ou a skill setup) primeiro.'
83
+ );
84
+ }
85
+ const { config, root } = loadConfig();
86
+ const { owner, repo } = config || {};
87
+ if (!owner || !repo) {
88
+ throw new QaLeadRefusal(`${CONFIG_FILE} não contém owner/repo. Rode \`spec-wave init\` novamente.`);
89
+ }
90
+ const token = await resolveToken();
91
+
92
+ const milestones = await listMilestones(token, owner, repo);
93
+ const resolved = resolveMilestoneRef(milestones, milestoneArg);
94
+ if (resolved.error) throw new QaLeadRefusal(resolved.error);
95
+
96
+ const lead = resolveLeadConfig(config?.qa?.lead);
97
+ return { token, owner, repo, root, config, lead, milestone: resolved.milestone };
98
+ }
99
+
100
+ // Itens do milestone separados por tipo — SEMPRE por Work Item Type (§4.1):
101
+ // Stories e Bugs herdam o milestone do pai (D5), então filtrar por milestone
102
+ // puro mandaria o Lead abrir container para Task.
103
+ async function milestoneItems({ token, owner, repo, milestone }) {
104
+ const issues = await listIssuesByMilestone(token, owner, repo, milestone.number);
105
+ const byType = { Feature: [], Story: [], Bug: [] };
106
+ for (const issue of issues) {
107
+ const type = detectIssueType(issue);
108
+ if (byType[type]) byType[type].push(issue);
109
+ }
110
+ return byType;
111
+ }
112
+
113
+ // Ordem das Features = a do `spec-wave order` sem argumento (D-QAL1): ordem
114
+ // topológica das Stories, projetada nas Features donas. Arestas das fontes
115
+ // LOCAIS (dependency-map/doc/body — lib/story-graph): sequenciar a trilha não
116
+ // exige o blocked_by remoto, e era este laço que pagava F+S chamadas por fase.
117
+ async function orderFeatures({ token, owner, repo, root, features, ttlSec }) {
118
+ const stories = [];
119
+ const featureOfStory = new Map();
120
+ const storiesOfFeature = new Map();
121
+ for (const feature of features) {
122
+ const carga = await loadFeatureStories({
123
+ token, owner, repo, root,
124
+ feature: { number: feature.number, nodeId: feature.node_id, title: feature.title },
125
+ ttlSec,
126
+ }).catch(() => ({ stories: [] }));
127
+ storiesOfFeature.set(feature.number, carga.stories);
128
+ for (const story of carga.stories) {
129
+ stories.push({ number: story.number, dependsOn: story.dependsOn });
130
+ featureOfStory.set(story.number, feature.number);
131
+ }
132
+ }
133
+ const { order } = orderStories(stories);
134
+ const orderedNumbers = featureOrderFromStories({
135
+ featureNumbers: features.map(f => f.number),
136
+ storyOrder: order,
137
+ featureOfStory,
138
+ });
139
+ const byNumber = new Map(features.map(f => [f.number, f]));
140
+ return {
141
+ ordered: orderedNumbers.map(n => byNumber.get(n)),
142
+ featureOfStory,
143
+ storiesOfFeature,
144
+ };
145
+ }
146
+
147
+ function plannedScenarioCount(root, featureTitle) {
148
+ try {
149
+ const rel = featureDocPaths(root, { title: featureTitle }, 'Feature')['qa-plan'].rel;
150
+ const doc = parseQaPlanDoc(readFileSync(path.join(root || process.cwd(), rel), 'utf-8'));
151
+ return doc.scenarios.length;
152
+ } catch {
153
+ return 0;
154
+ }
155
+ }
156
+
157
+ // ── fase A — qa-lead plan ────────────────────────────────────────────────────
158
+
159
+ const PLAN_STATE_LABEL = {
160
+ pronta: '✅ pronta (qa-ready)',
161
+ 'portao-humano': '⛔ portão humano (critique-failed/needs-human)',
162
+ 'em-voo': '🛫 geração em voo (spec-wave:qa aplicada)',
163
+ 'sem-spec-plan': '📄 sem spec.md+plan.md',
164
+ gerar: '🏷️ gatilho aplicado agora',
165
+ };
166
+
167
+ async function qaLeadPlan(milestoneArg, { watch = false, dryRun = false } = {}) {
168
+ const ctx = await bootstrap(milestoneArg);
169
+ const { token, owner, repo, root, lead, milestone } = ctx;
170
+
171
+ const { Feature: features } = await milestoneItems(ctx);
172
+ if (features.length === 0) {
173
+ console.log(`Milestone "${milestone.title}" não tem nenhuma Feature — nada a preparar.`);
174
+ return;
175
+ }
176
+
177
+ const { ordered } = await orderFeatures({ token, owner, repo, root, features, ttlSec: resolveCacheTtl(ctx.config) });
178
+
179
+ const classified = [];
180
+ for (const feature of ordered) {
181
+ const paths = featureDocPaths(root, { title: feature.title }, 'Feature');
182
+ const hasSpecPlan = existsSync(path.join(root, paths.spec.rel))
183
+ && existsSync(path.join(root, paths.plan.rel));
184
+ const { state, apply } = classifyForPlan({ labels: labelNames(feature), hasSpecPlan });
185
+ classified.push({ feature, state, apply });
186
+ }
187
+
188
+ // Aplica o gatilho, uma Feature por vez, com intervalo (§6.1).
189
+ let applied = 0;
190
+ for (const item of classified) {
191
+ if (!item.apply) continue;
192
+ if (dryRun) {
193
+ console.log(chalk.dim(`dry-run: aplicaria \`${LABEL_QA}\` na Feature #${item.feature.number}.`));
194
+ continue;
195
+ }
196
+ if (applied > 0) await sleep(TRIGGER_INTERVAL_MS);
197
+ await addLabel(token, owner, repo, item.feature.number, LABEL_QA);
198
+ applied += 1;
199
+ item.state = 'em-voo';
200
+ console.log(`Feature #${item.feature.number}: \`${LABEL_QA}\` aplicada — plano em geração.`);
201
+ }
202
+
203
+ // --watch: poll até toda disparada resolver, com teto (timeout NÃO é erro
204
+ // não-tratado — relata as pendentes e sai 1).
205
+ if (watch && !dryRun && classified.some(c => c.state === 'em-voo')) {
206
+ const deadline = Date.now() + lead.planWaitTimeoutMin * 60 * 1000;
207
+ while (Date.now() < deadline && classified.some(c => c.state === 'em-voo')) {
208
+ await sleep(lead.pollIntervalSec * 1000);
209
+ for (const item of classified) {
210
+ if (item.state !== 'em-voo') continue;
211
+ const issue = await getIssue(token, owner, repo, item.feature.number).catch(() => null);
212
+ if (!issue) continue;
213
+ const { state } = classifyForPlan({ labels: labelNames(issue), hasSpecPlan: true });
214
+ if (state !== 'em-voo') {
215
+ item.state = state;
216
+ console.log(`Feature #${item.feature.number}: ${PLAN_STATE_LABEL[state]}.`);
217
+ }
218
+ }
219
+ }
220
+ const pendentes = classified.filter(c => c.state === 'em-voo');
221
+ if (pendentes.length > 0) {
222
+ console.log(chalk.yellow(
223
+ `--watch estourou ${lead.planWaitTimeoutMin}min com plano(s) ainda em voo: ` +
224
+ `${pendentes.map(c => `#${c.feature.number}`).join(', ')}.`
225
+ ));
226
+ }
227
+ }
228
+
229
+ // Relatório de prontidão — stdout, sem arquivo (§6.1: prontidão é estado
230
+ // momentâneo, não artefato).
231
+ console.log(`\nProntidão da trilha — milestone "${milestone.title}" (${classified.length} Feature(s), na ordem da trilha):\n`);
232
+ for (const item of classified) {
233
+ console.log(` #${item.feature.number} ${item.feature.title}`);
234
+ console.log(` ${PLAN_STATE_LABEL[item.state] || item.state}`);
235
+ }
236
+
237
+ const humanGate = classified.filter(c => c.state === 'portao-humano');
238
+ if (humanGate.length > 0) {
239
+ console.log(chalk.yellow(
240
+ `\n⛔ Portão humano em: ${humanGate.map(c => `#${c.feature.number}`).join(', ')} — ` +
241
+ `corrija o documento apontado no comentário 🔎 e remova a label (\`${LABEL_CRITIQUE_FAILED}\`/needs-human).`
242
+ ));
243
+ }
244
+ const prontas = classified.filter(c => c.state === 'pronta').length;
245
+ console.log(
246
+ `\n${prontas}/${classified.length} pronta(s). ` +
247
+ (prontas === classified.length
248
+ ? chalk.green('Trilha pronta — revise os qa-plan.md e rode `qa-lead run`.')
249
+ : 'Quando toda Feature estiver `qa-ready`, rode `qa-lead run`.')
250
+ );
251
+ process.exitCode = dryRun ? 0 : planExitCode(classified);
252
+ return { classified: classified.map(c => ({ number: c.feature.number, state: c.state })) };
253
+ }
254
+
255
+ // ── fase B — qa-lead run ─────────────────────────────────────────────────────
256
+
257
+ async function qaLeadRun(milestoneArg, {
258
+ only: onlyArg, maxCycles: maxCyclesArg, dryRun = false,
259
+ } = {}) {
260
+ const ctx = await bootstrap(milestoneArg);
261
+ const { token, owner, repo, root, config, lead, milestone } = ctx;
262
+ const maxCycles = maxCyclesArg ? parseInt(maxCyclesArg, 10) : lead.maxCycles;
263
+ if (!Number.isInteger(maxCycles) || maxCycles <= 0) {
264
+ throw new QaLeadRefusal(`--max-cycles inválido: "${maxCyclesArg}".`);
265
+ }
266
+
267
+ if (lead.backend === 'docker' && !lead.container.image) {
268
+ throw new QaLeadRefusal(
269
+ 'A fase B precisa de `qa.lead.container.image` no .spec-wave.json (§4.5) — é a imagem que ' +
270
+ 'sobe um ambiente isolado por Feature (D-QAL3). Exemplo:\n' +
271
+ ' "qa": { "lead": { "container": { "image": "ghcr.io/acme/qa-runner:1.4" } } }'
272
+ );
273
+ }
274
+
275
+ const { Feature: features } = await milestoneItems(ctx);
276
+ if (features.length === 0) {
277
+ console.log(`Milestone "${milestone.title}" não tem nenhuma Feature — nada a executar.`);
278
+ return;
279
+ }
280
+ const { ordered, featureOfStory } = await orderFeatures({ token, owner, repo, root, features, ttlSec: resolveCacheTtl(config) });
281
+
282
+ // Etapa via snapshot do board (leitura pura — nada de addProjectItem aqui:
283
+ // o dry-run promete zero escrita, e o run não precisa mutar para ler).
284
+ const { project, error: projectError } = loadProjectConfig({ cwd: root });
285
+ let boardIndex = new Map();
286
+ if (!projectError && project?.id) {
287
+ // `fresh: true`: o escopo decide O QUE EXECUTA em container — gate de
288
+ // execução não decide com cache. O snapshot fresco fica gravado para os
289
+ // comandos seguintes da sessão.
290
+ boardIndex = (await loadBoardSnapshot({ token, project, root, fresh: true })
291
+ .catch(() => ({ index: new Map() }))).index;
292
+ } else {
293
+ console.warn(chalk.yellow(`⚠️ ${projectError || 'Project não configurado'} — Etapas não legíveis; Features contarão como fora-do-ciclo.`));
294
+ }
295
+
296
+ let trail = ordered.map(f => ({
297
+ feature: f,
298
+ scope: classifyScope({ stage: boardIndex.get(f.number)?.fields?.Etapa || null }),
299
+ }));
300
+
301
+ // --only <features>: sub-trilha explícita (decisão humana declarada).
302
+ if (onlyArg) {
303
+ const wanted = new Set(String(onlyArg).split(',').map(s => parseInt(s.trim().replace('#', ''), 10)));
304
+ const unknown = [...wanted].filter(n => !trail.some(t => t.feature.number === n));
305
+ if (unknown.length > 0) {
306
+ throw new QaLeadRefusal(`--only cita Feature(s) fora do milestone: ${unknown.map(n => `#${n}`).join(', ')}.`);
307
+ }
308
+ trail = trail.filter(t => wanted.has(t.feature.number));
309
+ }
310
+
311
+ // Portão de entrada (D-QAL2): toda Feature em 🧪 QA precisa de qa-ready.
312
+ const gate = runGate(trail.map(t => ({ number: t.feature.number, labels: t.feature.labels, scope: t.scope })));
313
+ if (!gate.ok) {
314
+ throw new QaLeadRefusal(
315
+ `Feature(s) em 🧪 QA sem \`${LABEL_QA_READY}\`: ${gate.missing.map(n => `#${n}`).join(', ')} — ` +
316
+ 'a fase B só inicia com a trilha inteira revisada (D-QAL2). Rode `qa-lead plan` e revise os planos.'
317
+ );
318
+ }
319
+
320
+ const inCycle = trail.filter(t => t.scope === 'no-ciclo');
321
+ const outOfCycle = trail.filter(t => t.scope !== 'no-ciclo');
322
+
323
+ // ── ciclo N: estado dos ciclos anteriores ──────────────────────────────────
324
+ const trailRoot = path.join(root, 'docs', 'qa');
325
+ const previousReport = lastCycleReport(root, milestone.title);
326
+ const cycle = (previousReport?.cycle || 0) + 1;
327
+
328
+ // Bugs do ciclo anterior que fecharam desde então — o combustível do D-QAL7.
329
+ const previousBugNumbers = previousReport
330
+ ? [...new Set([
331
+ ...previousReport.features.flatMap(f => f.bugsOpened || []),
332
+ ...(previousReport.bugs?.openBlocking || []),
333
+ ])]
334
+ : [];
335
+ const previousBugs = [];
336
+ for (const n of previousBugNumbers) {
337
+ const issue = await getIssue(token, owner, repo, n).catch(() => null);
338
+ if (issue) previousBugs.push(issue);
339
+ }
340
+ const closedBugs = previousBugs.filter(b => b.state === 'closed');
341
+
342
+ const cycleGate = nextCycleGate({
343
+ previousReport, bugsClosedSince: closedBugs.length, maxCycles,
344
+ });
345
+ if (!cycleGate.ok) {
346
+ throw new QaLeadRefusal(`Ciclo ${cycle} não inicia: ${cycleGate.reason}.`);
347
+ }
348
+
349
+ // ── alvos do ciclo ─────────────────────────────────────────────────────────
350
+ let targets;
351
+ if (!previousReport) {
352
+ targets = inCycle.map(t => ({
353
+ issue: t.feature.number, feature: t.feature.number, only: null, motivo: 'ciclo-1',
354
+ }));
355
+ } else {
356
+ const lastResults = new Map();
357
+ for (const t of inCycle) {
358
+ const comments = await listIssueComments(token, owner, repo, t.feature.number).catch(() => []);
359
+ const parsed = parseLastQaReport(comments);
360
+ lastResults.set(t.feature.number, [...parsed.results.entries()]
361
+ .map(([numero, r]) => ({ numero, verdict: r.verdict })));
362
+ }
363
+ const plano = retestPlan({
364
+ previousFeatures: previousReport.features.filter(f =>
365
+ inCycle.some(t => t.feature.number === f.issue)),
366
+ lastResults,
367
+ closedBugs,
368
+ });
369
+ targets = plano.targets;
370
+ }
371
+
372
+ // Feature dona de cada alvo (Story herda via sub-issue).
373
+ const featureOfTarget = (target) => target.feature ?? featureOfStory.get(target.issue) ?? null;
374
+
375
+ console.log(`\n🧪 qa-lead run — milestone "${milestone.title}" · ciclo ${cycle}/${maxCycles} · backend ${lead.backend}`);
376
+ console.log(`Features no ciclo: ${inCycle.map(t => `#${t.feature.number}`).join(', ') || 'nenhuma'}.`);
377
+ if (outOfCycle.length > 0) {
378
+ console.log(chalk.dim(
379
+ outOfCycle.map(t => ` #${t.feature.number}: ${t.scope}`).join('\n')
380
+ ));
381
+ }
382
+ console.log(`Despacho: ${targets.length} alvo(s), até ${lead.maxParallel} container(s) simultâneo(s):`);
383
+ for (const t of targets) {
384
+ console.log(` - qa ${t.issue}${t.only ? ` --only ${t.only.join(',')}` : ''} (${t.motivo})`);
385
+ }
386
+
387
+ if (dryRun) {
388
+ console.log(chalk.dim('\nDry-run: ZERO container, ZERO escrita.'));
389
+ return { dryRun: true, cycle, targets };
390
+ }
391
+
392
+ if (targets.length === 0 && inCycle.length === 0) {
393
+ console.log('Nenhuma Feature em 🧪 QA neste milestone — nada a executar.');
394
+ return { cycle, targets: [] };
395
+ }
396
+
397
+ // ── backend + preflight global ─────────────────────────────────────────────
398
+ const backend = createExecutionBackend({
399
+ backend: lead.backend, image: lead.container.image, checkoutDir: root,
400
+ });
401
+ const onSignal = async () => {
402
+ console.error(chalk.yellow('\nInterrompido — removendo containers do ciclo...'));
403
+ await backend.cleanup();
404
+ process.exit(130);
405
+ };
406
+ process.on('SIGINT', onSignal);
407
+ process.on('SIGTERM', onSignal);
408
+
409
+ const startedAt = new Date().toISOString();
410
+ const preflight = await runPreflight({ backend, lead, root });
411
+ if (!preflight.ok) {
412
+ // Ambiente quebrado é RESULTADO, não crash (§6.2): o relatório sai mesmo
413
+ // assim, com todos os cenários planejados como blocked/ambiente.
414
+ const entries = inCycle.map(t => abortedFeatureEntry({
415
+ issue: t.feature.number,
416
+ title: t.feature.title,
417
+ plannedScenarios: plannedScenarioCount(root, t.feature.title),
418
+ }));
419
+ entries.push(...outOfCycleEntries(outOfCycle));
420
+ const report = buildTrailReport({
421
+ milestone, cycle, startedAt, finishedAt: new Date().toISOString(),
422
+ preflight, features: entries,
423
+ previousTotals: previousReport?.totals || null,
424
+ closedSincePreviousCycle: closedBugs.length,
425
+ openBlocking: previousBugs.filter(b => b.state !== 'closed').map(b => b.number),
426
+ terminationReason: 'preflight-falhou',
427
+ });
428
+ await publishCycle({ ...ctx, report });
429
+ process.exitCode = 1;
430
+ console.error(chalk.red('\nPreflight falhou — ciclo abortado (relatório emitido).'));
431
+ for (const c of preflight.checks.filter(x => !x.ok)) {
432
+ console.error(chalk.red(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`));
433
+ }
434
+ return { cycle, report };
435
+ }
436
+
437
+ // ── despacho (até maxParallel simultâneos) ─────────────────────────────────
438
+ const originUrl = gitOriginUrl(root);
439
+ const containerEnv = resolveContainerEnv({ lead, token });
440
+ const outcomes = new Map(); // target.issue → { verdictReport, durationSec, aborted }
441
+
442
+ const queue = [...targets];
443
+ const workers = Array.from({ length: Math.min(lead.maxParallel, queue.length) }, async () => {
444
+ while (queue.length > 0) {
445
+ const target = queue.shift();
446
+ const before = parseLastQaReport(
447
+ await listIssueComments(token, owner, repo, target.issue).catch(() => [])).run;
448
+ console.log(`▶ despachando container: qa ${target.issue}${target.only ? ` --only ${target.only.join(',')}` : ''}`);
449
+ const result = await backend.run({
450
+ name: `qa-${target.issue}`,
451
+ script: containerScript({
452
+ issue: target.issue,
453
+ only: target.only,
454
+ setup: config?.qa?.setup || null,
455
+ cliVersion: CLI_VERSION,
456
+ originUrl,
457
+ }),
458
+ env: containerEnv,
459
+ timeoutMin: lead.featureTimeoutMin,
460
+ onLog: line => console.log(chalk.dim(` [#${target.issue}] ${line}`)),
461
+ }).catch(err => ({ ok: false, timedOut: false, code: null, durationSec: 0, error: err.message }));
462
+
463
+ // Coleta pelo COMENTÁRIO da issue (§6.2): o Lead não mantém canal com o
464
+ // container — quem morreu depois de comentar não perde resultado; quem
465
+ // morreu antes vira execucao-abortada (§3.3).
466
+ const parsed = parseLastQaReport(
467
+ await listIssueComments(token, owner, repo, target.issue).catch(() => []));
468
+ const fresh = parsed.run > before ? parsed : null;
469
+ outcomes.set(target.issue, {
470
+ target,
471
+ report: fresh,
472
+ durationSec: result.durationSec ?? null,
473
+ timedOut: Boolean(result.timedOut),
474
+ });
475
+ const status = fresh ? fresh.verdict : (result.timedOut ? 'timeout' : 'sem relatório');
476
+ console.log(`◀ qa ${target.issue}: ${status} (${result.durationSec ?? '?'}s)`);
477
+ }
478
+ });
479
+ await Promise.all(workers);
480
+ process.off('SIGINT', onSignal);
481
+ process.off('SIGTERM', onSignal);
482
+ await backend.cleanup();
483
+
484
+ // ── consolidação por Feature ───────────────────────────────────────────────
485
+ const entries = [];
486
+ for (const t of inCycle) {
487
+ const featureTargets = targets.filter(x => featureOfTarget(x) === t.feature.number);
488
+ if (featureTargets.length === 0) {
489
+ // Sem alvo neste ciclo (ex.: vermelha sem Bug fechado): estado herdado.
490
+ const prev = previousReport?.features.find(f => f.issue === t.feature.number);
491
+ if (prev) entries.push({ ...prev, durationSec: null });
492
+ continue;
493
+ }
494
+ const merged = { pass: 0, fail: 0, blocked: 0, total: 0, blockedReasons: {} };
495
+ const bugsOpened = [];
496
+ let durationSec = 0;
497
+ let aborted = false;
498
+ let worst = 'verde';
499
+ for (const target of featureTargets) {
500
+ const outcome = outcomes.get(target.issue);
501
+ durationSec += outcome?.durationSec || 0;
502
+ if (!outcome?.report) {
503
+ aborted = true;
504
+ continue;
505
+ }
506
+ const results = [...outcome.report.results.entries()]
507
+ .map(([numero, r]) => ({ numero, verdict: r.verdict, blockedReason: r.blockedReason }));
508
+ const counts = scenarioCounts(results);
509
+ merged.pass += counts.pass;
510
+ merged.fail += counts.fail;
511
+ merged.blocked += counts.blocked;
512
+ merged.total += counts.total;
513
+ for (const [reason, n] of Object.entries(counts.blockedReasons)) {
514
+ merged.blockedReasons[reason] = (merged.blockedReasons[reason] || 0) + n;
515
+ }
516
+ bugsOpened.push(...outcome.report.bugs.filter(b => !b.existing).map(b => b.number));
517
+ const verdict = featureVerdictFromRun(outcome.report.verdict);
518
+ worst = worstOf(worst, verdict);
519
+ }
520
+ if (aborted) {
521
+ // §3.3: container sem veredito → Feature abortada, cenários planejados
522
+ // como blocked/ambiente. NUNCA lida como verde.
523
+ const planned = plannedScenarioCount(root, t.feature.title) || merged.total;
524
+ const entry = abortedFeatureEntry({
525
+ issue: t.feature.number, title: t.feature.title,
526
+ plannedScenarios: Math.max(planned - merged.pass - merged.fail - merged.blocked, 0),
527
+ durationSec,
528
+ });
529
+ entry.scenarios.pass = merged.pass;
530
+ entry.scenarios.fail = merged.fail;
531
+ entry.scenarios.blocked += merged.blocked;
532
+ entry.scenarios.total = entry.scenarios.pass + entry.scenarios.fail + entry.scenarios.blocked;
533
+ entry.blockedReasons = { ...merged.blockedReasons, ambiente: (merged.blockedReasons.ambiente || 0) + (entry.scenarios.blocked - merged.blocked) };
534
+ entry.bugsOpened = bugsOpened;
535
+ entries.push(entry);
536
+ continue;
537
+ }
538
+ entries.push({
539
+ issue: t.feature.number,
540
+ title: t.feature.title,
541
+ verdict: worst,
542
+ scenarios: { pass: merged.pass, fail: merged.fail, blocked: merged.blocked, total: merged.total },
543
+ blockedReasons: merged.blockedReasons,
544
+ bugsOpened,
545
+ durationSec,
546
+ });
547
+ }
548
+ entries.push(...outOfCycleEntries(outOfCycle));
549
+
550
+ // Bugs ainda segurando a trilha: os do ciclo anterior que continuam abertos
551
+ // + os abertos agora.
552
+ const openBlocking = [...new Set([
553
+ ...previousBugs.filter(b => b.state !== 'closed').map(b => b.number),
554
+ ...entries.flatMap(e => e.bugsOpened || []),
555
+ ])];
556
+
557
+ const draft = buildTrailReport({
558
+ milestone, cycle, startedAt, finishedAt: new Date().toISOString(),
559
+ preflight, features: entries,
560
+ previousTotals: previousReport?.totals || null,
561
+ closedSincePreviousCycle: closedBugs.length,
562
+ openBlocking,
563
+ });
564
+ const changed = changedSincePrevious({
565
+ previous: previousReport, current: draft, bugsClosedSince: closedBugs.length,
566
+ });
567
+ const report = {
568
+ ...draft,
569
+ terminationReason: terminationOf({
570
+ preflightOk: true, trailVerdict: draft.verdict, cycle, maxCycles, changed,
571
+ }),
572
+ };
573
+
574
+ await publishCycle({ ...ctx, report });
575
+
576
+ console.log(`\nVeredito da trilha: ${chalk.bold(report.verdict)}` +
577
+ (report.terminationReason ? ` · término: ${report.terminationReason}` : ' · outro ciclo possível'));
578
+ if (report.verdict !== 'liberada') process.exitCode = 1;
579
+ return { cycle, report };
580
+ }
581
+
582
+ function worstOf(a, b) {
583
+ const ordem = ['verde', 'inconclusivo', 'vermelho', 'execucao-abortada'];
584
+ return ordem.indexOf(b) > ordem.indexOf(a) ? b : a;
585
+ }
586
+
587
+ function outOfCycleEntries(outOfCycle) {
588
+ return outOfCycle.map(t => ({
589
+ issue: t.feature.number,
590
+ title: t.feature.title,
591
+ verdict: t.scope === 'ja-aprovada' ? 'ja-aprovada' : 'fora-do-ciclo',
592
+ scenarios: { pass: 0, fail: 0, blocked: 0, total: 0 },
593
+ blockedReasons: {},
594
+ bugsOpened: [],
595
+ durationSec: null,
596
+ }));
597
+ }
598
+
599
+ function gitOriginUrl(root) {
600
+ try {
601
+ return execSync('git config --get remote.origin.url', {
602
+ cwd: root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
603
+ }).trim() || null;
604
+ } catch {
605
+ return null;
606
+ }
607
+ }
608
+
609
+ // Env dos containers: o token do GitHub (o `qa` lá dentro comenta e move o
610
+ // board) + `qa.lead.container.env`. Valor VAZIO no config significa "vem do
611
+ // ambiente do Lead" — e credencial vazia não entra (withoutBlankCredentials do
612
+ // lado dos Actions tem o mesmo motivo).
613
+ function resolveContainerEnv({ lead, token }) {
614
+ const out = { GITHUB_TOKEN: token, GH_TOKEN: token };
615
+ if (process.env.SPEC_WAVE_QA_CMD) out.SPEC_WAVE_QA_CMD = process.env.SPEC_WAVE_QA_CMD;
616
+ for (const [key, value] of Object.entries(lead.container.env || {})) {
617
+ const resolved = value === '' || value == null ? process.env[key] : value;
618
+ if (resolved != null && resolved !== '') out[key] = resolved;
619
+ }
620
+ return out;
621
+ }
622
+
623
+ // Preflight global (§6.2): tudo que quebraria TODOS os containers, testado
624
+ // antes de pagar por qualquer um.
625
+ async function runPreflight({ backend, lead, root }) {
626
+ const checks = [];
627
+
628
+ const avail = backend.available();
629
+ checks.push({ name: `backend ${lead.backend} acessível`, ok: avail.ok, detail: avail.detail || '' });
630
+
631
+ if (avail.ok) {
632
+ const image = backend.imageAvailable();
633
+ checks.push({
634
+ name: `imagem ${lead.container.image || '(não configurada)'}`,
635
+ ok: image.ok,
636
+ detail: image.detail || '',
637
+ });
638
+ }
639
+
640
+ const missingCreds = Object.entries(lead.container.env || {})
641
+ .filter(([key, value]) => (value === '' || value == null) && !process.env[key])
642
+ .map(([key]) => key);
643
+ checks.push({
644
+ name: 'credenciais de qa.lead.container.env',
645
+ ok: missingCreds.length === 0,
646
+ detail: missingCreds.length > 0
647
+ ? `ausentes no ambiente do Lead: ${missingCreds.join(', ')}`
648
+ : '',
649
+ });
650
+
651
+ // Espaço em disco: best-effort — ilegível NÃO derruba o preflight.
652
+ try {
653
+ const df = execSync(`df -Pk ${JSON.stringify(root)}`, { encoding: 'utf-8' });
654
+ const availKb = parseInt(df.trim().split('\n').pop().split(/\s+/)[3], 10);
655
+ checks.push({
656
+ name: 'espaço em disco',
657
+ ok: !Number.isInteger(availKb) || availKb > 1024 * 1024,
658
+ detail: Number.isInteger(availKb) ? `${Math.round(availKb / 1024)}MB livres` : 'não verificável',
659
+ });
660
+ } catch {
661
+ checks.push({ name: 'espaço em disco', ok: true, detail: 'não verificável' });
662
+ }
663
+
664
+ return { ok: checks.every(c => c.ok), checks };
665
+ }
666
+
667
+ // ── publicação do ciclo (as escritas 2 e 3 do §2.1) ──────────────────────────
668
+
669
+ async function publishCycle({ token, owner, repo, root, milestone, report }) {
670
+ const dirRel = qaTrailDir(milestone.title, report.cycle);
671
+ const dirAbs = path.join(root, dirRel);
672
+ mkdirSync(dirAbs, { recursive: true });
673
+ const jsonRel = `${dirRel}/report.json`;
674
+ const mdRel = `${dirRel}/report.md`;
675
+ writeFileSync(path.join(root, jsonRel), `${JSON.stringify(report, null, 2)}\n`);
676
+ writeFileSync(path.join(root, mdRel), renderTrailReportMd(report));
677
+ console.log(`Relatório do ciclo ${report.cycle} gravado em ${chalk.cyan(dirRel)}/.`);
678
+
679
+ // Commit escopado ao diretório do ciclo + push best-effort: o relatório é do
680
+ // repositório (D-QAL4), mas uma falha de push não pode engolir o veredito.
681
+ try {
682
+ execSync(`git add -- ${JSON.stringify(dirRel)}`, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
683
+ execSync(
684
+ `git commit -m ${JSON.stringify(`docs: relatório de QA da trilha "${milestone.title}" ciclo ${report.cycle} [spec-wave]`)} -- ${JSON.stringify(dirRel)}`,
685
+ { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
686
+ const push = await pushWithRebase({ cwd: root });
687
+ if (!push.ok) {
688
+ console.warn(chalk.yellow(`⚠️ Relatório commitado, mas o push falhou: ${String(push.error).split('\n')[0]} — pushe manualmente.`));
689
+ }
690
+ } catch (err) {
691
+ console.warn(chalk.yellow(`⚠️ Relatório escrito, mas o commit falhou (${String(err.message).split('\n')[0]}) — commite ${dirRel}/ você mesmo.`));
692
+ }
693
+
694
+ // Bloco delimitado na descrição do milestone — SUBSTITUI o bloco, nunca a
695
+ // descrição (as Release Notes moram no mesmo campo — §4.4).
696
+ try {
697
+ const atual = await getMilestone(token, owner, repo, milestone.number);
698
+ const description = upsertMilestoneBlock(atual.description, milestoneBlock(report, mdRel));
699
+ await updateMilestoneDescription(token, owner, repo, milestone.number, description);
700
+ console.log('Bloco de trilha atualizado na descrição do milestone.');
701
+ } catch (err) {
702
+ console.warn(chalk.yellow(`⚠️ Não consegui atualizar a descrição do milestone: ${err.message}`));
703
+ }
704
+ }
705
+
706
+ // ── qa-lead report ───────────────────────────────────────────────────────────
707
+
708
+ function lastCycleReport(root, milestoneTitle, cycle = null) {
709
+ const dirAbs = path.join(root, path.dirname(qaTrailDir(milestoneTitle, 1)));
710
+ if (!existsSync(dirAbs)) return null;
711
+ const cycles = readdirSync(dirAbs)
712
+ .map(n => /^cycle-(\d+)$/.exec(n)?.[1])
713
+ .filter(Boolean)
714
+ .map(Number)
715
+ .sort((a, b) => a - b);
716
+ const chosen = cycle ?? cycles[cycles.length - 1];
717
+ if (!chosen || (cycle && !cycles.includes(cycle))) return null;
718
+ const file = path.join(root, qaTrailDir(milestoneTitle, chosen), 'report.json');
719
+ if (!existsSync(file)) return null;
720
+ try {
721
+ const raw = JSON.parse(readFileSync(file, 'utf-8'));
722
+ return {
723
+ ...raw,
724
+ // resultados como Map não sobrevivem ao JSON — nada a reidratar aqui.
725
+ };
726
+ } catch {
727
+ return null;
728
+ }
729
+ }
730
+
731
+ async function qaLeadReport(milestoneArg, { cycle: cycleArg } = {}) {
732
+ const ctx = await bootstrap(milestoneArg);
733
+ const { root, milestone } = ctx;
734
+ const cycle = cycleArg ? parseInt(cycleArg, 10) : null;
735
+ if (cycleArg && (!Number.isInteger(cycle) || cycle <= 0)) {
736
+ throw new QaLeadRefusal(`--cycle inválido: "${cycleArg}".`);
737
+ }
738
+ const report = lastCycleReport(root, milestone.title, cycle);
739
+ if (!report) {
740
+ throw new QaLeadRefusal(
741
+ cycle
742
+ ? `Ciclo ${cycle} não encontrado em docs/qa/ para a milestone "${milestone.title}".`
743
+ : `Nenhum ciclo executado para a milestone "${milestone.title}" — rode \`qa-lead run\` primeiro.`
744
+ );
745
+ }
746
+ console.log(renderTrailReportMd(report));
747
+ return { report };
748
+ }