@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.
@@ -137,7 +137,7 @@ function localDocStates(issue, type, root) {
137
137
  const paths = featureDocPaths(root, issue, type);
138
138
  const docs = {};
139
139
  const docPaths = {};
140
- for (const nome of ['spec', 'plan', 'decomposition']) {
140
+ for (const nome of ['spec', 'plan', 'decomposition', 'qa-plan']) {
141
141
  docs[nome] = existsSync(paths[nome].abs) ? 'local' : 'missing';
142
142
  docPaths[nome] = paths[nome].rel;
143
143
  }
@@ -305,6 +305,10 @@ async function dispatch(action, { issueNumber }) {
305
305
  const { generateBug } = await import('./generate-bug.mjs');
306
306
  return await generateBug({ issueNumber });
307
307
  }
308
+ case 'generate-qa-plan': {
309
+ const { generateQaPlan } = await import('./generate-qa-plan.mjs');
310
+ return await generateQaPlan({ issueNumber });
311
+ }
308
312
  default:
309
313
  throw new Error(`Passo sem despacho: ${action}`);
310
314
  }
package/src/config.mjs CHANGED
@@ -54,7 +54,7 @@ export const DEFAULT_PROVIDER = 'anthropic';
54
54
  // Ações de IA que podem ter modelo próprio no .spec-wave.json (bloco
55
55
  // `ai.models`, ex.: { "critique": "claude-opus-4-1" }). Resolvidas em runtime
56
56
  // por resolveAiConfig() em src/lib/claude.mjs.
57
- export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug'];
57
+ export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug', 'qa'];
58
58
 
59
59
  // Override de modelo POR EXECUÇÃO: a label `spec-wave:model:<apelido>` na issue
60
60
  // aponta para uma entrada de `ai.modelAliases` do .spec-wave.json. Serve para
@@ -346,6 +346,17 @@ export const LABEL_DEV_AGENT = 'spec-wave:dev-agent';
346
346
  export const LABEL_BUG = 'spec-wave:bug';
347
347
  export const LABEL_BUG_APPROVED = 'spec-wave:bug-approved';
348
348
 
349
+ // Fluxo de QA (rfc/spec-qa-skill.md). O gatilho gera (ou re-critica) o
350
+ // qa-plan.md; as duas de estado são gravadas pelas automações, nunca à mão.
351
+ //
352
+ // ⚠️ `spec-wave:qa-ready` NÃO é `spec-wave:ready`: a primeira é ESTADO do plano
353
+ // de QA (passou na crítica, espera revisão humana — o portão do D-QA4); a
354
+ // segunda é o GATILHO da validação de spec/plan. Nenhuma checagem pode usar
355
+ // prefixo (`startsWith('spec-wave:ready')`) para distinguir as duas.
356
+ export const LABEL_QA = 'spec-wave:qa';
357
+ export const LABEL_QA_READY = 'spec-wave:qa-ready';
358
+ export const LABEL_QA_APPROVED = 'spec-wave:qa-approved';
359
+
349
360
  // Desfecho da triagem do PM (RFC-004 §4.1). São mutuamente exclusivas: um bug
350
361
  // triado foi aceito, rejeitado ou marcado como duplicata.
351
362
  export const LABEL_TRIAGED = 'spec-wave:triaged';
@@ -432,6 +443,9 @@ export const TRIGGER_LABELS = [
432
443
  { name: LABEL_DEV_AGENT, color: '5319E7', description: 'Enfileira a issue para o dev-agent autônomo' },
433
444
  { name: LABEL_BUG, color: 'BFD4F2', description: 'Gerar bug.md via GitHub Action' },
434
445
  { name: LABEL_BUG_APPROVED, color: '0E8A16', description: 'bug.md validado (reprodução, causa raiz e teste de regressão)' },
446
+ { name: LABEL_QA, color: 'BFD4F2', description: 'Gerar/re-criticar o plano de QA (qa-plan.md) via GitHub Action' },
447
+ { name: LABEL_QA_READY, color: '0E8A16', description: 'Plano de QA aprovado pela crítica — revise-o e rode `spec-wave qa <n>`' },
448
+ { name: LABEL_QA_APPROVED, color: '0E8A16', description: 'Execução do QA passou em todos os cenários' },
435
449
  { name: LABEL_TRIAGED, color: '0E8A16', description: 'Bug triado pelo PM (severidade, origem e pai definidos)' },
436
450
  { name: LABEL_DUPLICATE, color: 'EDEDED', description: 'Duplicata de outra issue (o corpo aponta qual)' },
437
451
  { name: LABEL_WONT_FIX, color: 'EDEDED', description: 'Rejeitado na triagem — não será corrigido' },
@@ -515,6 +529,7 @@ export const WORKFLOW_FILES = [
515
529
  'generate-spec.yml',
516
530
  'validate.yml',
517
531
  'decompose.yml',
532
+ 'generate-qa-plan.yml',
518
533
  'code-review.yml',
519
534
  'qa.yml',
520
535
  ];
@@ -535,6 +550,7 @@ export const ARTIFACT_WORKFLOW_FILES = [
535
550
  'generate-plan.yml',
536
551
  'generate-bug.yml',
537
552
  'decompose.yml',
553
+ 'generate-qa-plan.yml',
538
554
  ];
539
555
 
540
556
  export const ISSUE_TEMPLATE_FILES = [
@@ -34,6 +34,7 @@ export const ARTIFACT_DOCS = {
34
34
  bug: { suffix: 'bug', file: 'bug.md' },
35
35
  decomposition: { suffix: 'decompose', file: 'decomposition.md' },
36
36
  'decomposition-apply': { suffix: 'decompose-apply', file: 'decomposition.md' },
37
+ 'qa-plan': { suffix: 'qa-plan', file: 'qa-plan.md' },
37
38
  };
38
39
 
39
40
  /** Nomes aceitos em `doc`, na ordem do fluxo. */
@@ -102,6 +103,7 @@ const DOC_LABEL = {
102
103
  bug: 'documento de bug',
103
104
  decomposition: 'rascunho de decomposição',
104
105
  'decomposition-apply': 'registro das issues criadas',
106
+ 'qa-plan': 'plano de QA',
105
107
  };
106
108
 
107
109
  /**
@@ -44,7 +44,7 @@ export const CRITIQUE_TOOL_NAME = 'registrar_findings';
44
44
  // Rótulo do artefato auditado, por contexto — usado no cabeçalho do comentário.
45
45
  const KIND_LABEL = {
46
46
  plan: 'plan.md', stories: 'decomposition.md', bug: 'bug.md', spec: 'spec.md',
47
- conjunto: 'specs da milestone (conjunto)',
47
+ conjunto: 'specs da milestone (conjunto)', qa: 'qa-plan.md',
48
48
  };
49
49
 
50
50
  // Prompt por tipo de auditoria. 'plan' audita o plan.md contra a spec;
@@ -53,7 +53,7 @@ const KIND_LABEL = {
53
53
  // relação entre documentos, não um documento.
54
54
  const KIND_PROMPT = {
55
55
  plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique', spec: 'spec/critique',
56
- conjunto: 'audit/critique',
56
+ conjunto: 'audit/critique', qa: 'qa/critique',
57
57
  };
58
58
 
59
59
  // A decomposição virou arquivo revisável (decomposition.md): um finding só é
@@ -66,6 +66,13 @@ const ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho audita
66
66
  - "geral" quando o problema for da decomposição como um todo (ex.: requisito da spec que nenhuma Story cobre).
67
67
  Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "### Task N.M — ..." do documento.`;
68
68
 
69
+ // O qa-plan.md tem a mesma necessidade da decomposição: o finding precisa dizer
70
+ // QUAL cenário está errado, e o título "## Cenário N" é a âncora estável.
71
+ const QA_ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho auditado:
72
+ - "Cenário N" para um problema no Cenário N (ex.: "Cenário 3");
73
+ - "geral" quando o problema for do plano como um todo (ex.: critério de aceite da spec que nenhum cenário cobre).
74
+ Use EXATAMENTE os números que aparecem nos títulos "## Cenário N — Story #X" do documento.`;
75
+
69
76
  // No conjunto o achado vive ENTRE documentos: sem dizer quais, o leitor relê a
70
77
  // milestone inteira procurando o par. O campo é validado como array de números
71
78
  // de issue — é o equivalente da âncora "Story N" para este contexto.
@@ -91,6 +98,7 @@ visto à luz das outras). Use EXATAMENTE os números que aparecem nos títulos "
91
98
  function buildSystemPrompt(kind, cwd) {
92
99
  const prompt = loadPrompt(KIND_PROMPT[kind] || KIND_PROMPT.plan, ...(cwd ? [{ cwd }] : []));
93
100
  const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}`
101
+ : kind === 'qa' ? `\n\n${QA_ANCHOR_RULE}`
94
102
  : kind === 'conjunto' ? `\n\n${FEATURES_RULE}` : '';
95
103
 
96
104
  const contract = `## Contrato de saída
@@ -146,6 +154,13 @@ function critiqueJsonSchema(kind) {
146
154
  };
147
155
  required.push('anchor');
148
156
  }
157
+ if (kind === 'qa') {
158
+ properties.anchor = {
159
+ type: 'string',
160
+ description: 'Âncora do trecho auditado: "Cenário N" ou "geral".',
161
+ };
162
+ required.push('anchor');
163
+ }
149
164
  if (kind === 'conjunto') {
150
165
  properties.features = {
151
166
  type: 'array',
@@ -195,14 +210,14 @@ function describe(value) {
195
210
  return typeof value === 'object' ? 'um objeto' : `${typeof value} (${JSON.stringify(value).slice(0, 60)})`;
196
211
  }
197
212
 
198
- const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
213
+ const ANCHOR_RE = /^(story|task|cen[aá]rio)[ \t]*(\d+(?:\.\d+)?)$/i;
199
214
 
200
215
  /**
201
216
  * Normaliza a âncora de um finding (função PURA).
202
217
  *
203
- * Aceita "Story 3", "story3", "TASK 3.2". Qualquer outra coisa inclusive
204
- * "geral" — vira ausência de âncora: melhor não citar do que citar errado e
205
- * mandar o humano para o trecho errado do documento.
218
+ * Aceita "Story 3", "story3", "TASK 3.2", "Cenário 2" (com ou sem acento).
219
+ * Qualquer outra coisa — inclusive "geral" — vira ausência de âncora: melhor
220
+ * não citar do que citar errado e mandar o humano para o trecho errado.
206
221
  *
207
222
  * @param {*} value valor cru do campo `anchor`
208
223
  * @returns {string} âncora normalizada, ou '' quando não há
@@ -210,7 +225,9 @@ const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
210
225
  export function normalizeAnchor(value) {
211
226
  const m = ANCHOR_RE.exec(String(value ?? '').trim());
212
227
  if (!m) return '';
213
- return `${m[1].toLowerCase() === 'task' ? 'Task' : 'Story'} ${m[2]}`;
228
+ const kind = m[1].toLowerCase();
229
+ const nome = kind === 'task' ? 'Task' : kind === 'story' ? 'Story' : 'Cenário';
230
+ return `${nome} ${m[2]}`;
214
231
  }
215
232
 
216
233
  /**
@@ -758,6 +775,11 @@ const KIND_TRAILER = {
758
775
  'até ser removida. Um bug com causa raiz errada produz correção errada — corrija o ' +
759
776
  '`bug.md` e reaplique `spec-wave:bug`.'
760
777
  : '_Findings menores não bloqueiam a triagem._'),
778
+ qa: (graves) => (graves
779
+ ? '⛔ Há findings **graves**: a label `spec-wave:critique-failed` impede o `qa run` ' +
780
+ 'até ser removida. Corrija o `qa-plan.md` (as âncoras acima apontam para ele), ' +
781
+ 'remova a label e reaplique `spec-wave:qa` para uma nova crítica.'
782
+ : '_Findings menores não bloqueiam a execução do QA._'),
761
783
  // O conjunto roda fora do ciclo de tentativas e não aplica label: o achado é
762
784
  // decisão de PO entre duas specs, e o destino dele é a issue das duas pontas.
763
785
  conjunto: (graves) => (graves
@@ -897,7 +919,7 @@ export function renderCritiqueMarkdown({
897
919
  * @returns {Promise<{grave, findings, markdown, attempt, model}>}
898
920
  */
899
921
  export async function runCritique({
900
- kind, spec, specs, plan, techContextYaml, decomposition, bugDoc, bugReport,
922
+ kind, spec, specs, plan, techContextYaml, decomposition, qaPlan, bugDoc, bugReport,
901
923
  attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
902
924
  model, labels = [], usage, cwd, decisions = null, standalone = false,
903
925
  } = {}) {
@@ -917,6 +939,13 @@ export async function runCritique({
917
939
  `## Decomposição proposta (decomposition.md)\n\n\`\`\`\`markdown\n${decomposition}\n\`\`\`\``
918
940
  );
919
941
  }
942
+ // Mesma cerca de quatro crases da decomposição, pelo mesmo motivo: o plano
943
+ // pode conter blocos de código de três.
944
+ if (qaPlan) {
945
+ sections.push(
946
+ `## Plano de QA auditado (qa-plan.md)\n\n\`\`\`\`markdown\n${qaPlan}\n\`\`\`\``
947
+ );
948
+ }
920
949
  // O relato é a fonte contra a qual o bug.md é auditado: a causa raiz proposta
921
950
  // tem que explicar OS SINTOMAS RELATADOS, não sintomas plausíveis quaisquer.
922
951
  if (bugReport) sections.push(`## Relato original (issue e comentários)\n\n${bugReport}`);
@@ -953,7 +982,7 @@ export async function runCritique({
953
982
  // sustenta não deve entrar no contador de tentativas nem bloquear o Action.
954
983
  const findings = downgradeUnsupportedFindings(
955
984
  report.value.findings,
956
- [spec, ...(specs || []).map(s => s.content), plan, decomposition, bugDoc, techContextYaml]
985
+ [spec, ...(specs || []).map(s => s.content), plan, decomposition, qaPlan, bugDoc, techContextYaml]
957
986
  .filter(Boolean),
958
987
  );
959
988
  const grave = findings.some(f => f.severity === 'grave');
@@ -69,8 +69,12 @@ function invalid(reason) {
69
69
  * Regras do CommonMark que importam aqui: a cerca tem 3+ caracteres, o
70
70
  * fechamento usa o mesmo caractere e comprimento >= o da abertura, e uma cerca
71
71
  * de crase não aceita crase na info string.
72
+ *
73
+ * Exportado porque o qa-plan.md (lib/qa-plan-doc.mjs) espelha as MESMAS regras
74
+ * de parsing deste documento — duplicar o rastreador seria duas cópias para
75
+ * divergir.
72
76
  */
73
- function fenceScanner() {
77
+ export function fenceScanner() {
74
78
  let open = null;
75
79
  return {
76
80
  // true quando a linha pertence a um bloco de código (abertura, conteúdo ou
@@ -30,12 +30,12 @@ export function resolveDocDir(root, issue, type) {
30
30
  }
31
31
 
32
32
  /**
33
- * Caminhos dos três documentos de uma Feature/RFC (função PURA).
33
+ * Caminhos dos documentos de uma Feature/RFC (função PURA).
34
34
  *
35
35
  * @param {string|null} root
36
36
  * @param {{title?: string}} issue
37
37
  * @param {string|null} [type]
38
- * @returns {{slug, dirRel, dirAbs, spec, plan, decomposition}} cada documento com {rel, abs}
38
+ * @returns {{slug, dirRel, dirAbs, spec, plan, decomposition, 'qa-plan'}} cada documento com {rel, abs}
39
39
  */
40
40
  export function featureDocPaths(root, issue, type) {
41
41
  const { slug, rel, dir } = resolveDocDir(root, issue, type);
@@ -47,5 +47,8 @@ export function featureDocPaths(root, issue, type) {
47
47
  spec: doc('spec.md'),
48
48
  plan: doc('plan.md'),
49
49
  decomposition: doc('decomposition.md'),
50
+ // A chave leva o hífen do nome do documento (ARTIFACT_DOCS) de propósito:
51
+ // é o mesmo id que o resto do fluxo usa para este artefato.
52
+ 'qa-plan': doc('qa-plan.md'),
50
53
  };
51
54
  }
@@ -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,314 @@
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
+ * Monta o contexto entregue ao executor (função PURA).
205
+ *
206
+ * As instruções de execução são OBRIGATÓRIAS (spec §6.2): um cenário por vez,
207
+ * evidência bruta, proibição de corrigir código, `blocked` ≠ `fail` — e o
208
+ * contrato do arquivo de resultados, que é como o veredito volta para a CLI.
209
+ *
210
+ * @param {object} params
211
+ * @param {string} params.type
212
+ * @param {{number:number, title:string}} params.issue
213
+ * @param {string|null} [params.stage]
214
+ * @param {Array} params.scenarios cenários-alvo, em ordem
215
+ * @param {string|null} [params.specRel] caminho do spec.md (ponteiro)
216
+ * @param {string|null} [params.qaPlanRel] caminho do qa-plan.md
217
+ * @param {Array<{issueNumber:number, kind:string, total:number, items:Array}>} [params.comments]
218
+ * @param {Array<{number:number, state:string, merged:boolean}>} [params.pullRequests]
219
+ * @param {string|null} [params.setup] `qa.setup` do .spec-wave.json
220
+ * @param {string} params.resultFile caminho do JSON de resultados
221
+ * @returns {string} markdown
222
+ */
223
+ export function buildQaContext({
224
+ type, issue, stage = null, scenarios = [], specRel = null, qaPlanRel = null,
225
+ comments = [], pullRequests = [], setup = null, resultFile,
226
+ } = {}) {
227
+ const lines = [];
228
+ lines.push(`# Contexto de QA — ${type} #${issue.number}`);
229
+ lines.push('');
230
+ lines.push(`**${type}:** ${issue.title}`);
231
+ if (stage) lines.push(`**Etapa atual no board:** ${stage}`);
232
+ if (specRel) lines.push(`**Especificação:** \`${specRel}\` (leia-a para entender os critérios de aceite)`);
233
+ if (qaPlanRel) lines.push(`**Plano de QA:** \`${qaPlanRel}\``);
234
+
235
+ lines.push('');
236
+ lines.push('## Instruções de execução (OBRIGATÓRIAS)');
237
+ lines.push('');
238
+ lines.push('- Execute **um cenário por vez**, na ordem em que aparecem abaixo.');
239
+ lines.push('- Registre a **evidência bruta** de cada cenário (comando executado, saída, código de status).');
240
+ lines.push('- **NÃO corrija código.** QA não conserta: cenário reprovado vira Bug. Alterar o código durante a execução **invalida o veredito**.');
241
+ lines.push('- Cenário que **não pôde ser executado** (ambiente quebrado, seed que falhou, dependência fora do ar) é `blocked`, **nunca** `fail`.');
242
+ lines.push('');
243
+ lines.push('### Como registrar o veredito');
244
+ lines.push('');
245
+ lines.push(`Ao terminar, grave o resultado em \`${resultFile}\` — é deste arquivo que a CLI lê o veredito:`);
246
+ lines.push('');
247
+ lines.push('```json');
248
+ lines.push(JSON.stringify({
249
+ scenarios: scenarios.slice(0, 1).map(s => ({
250
+ cenario: s.numero, verdict: 'pass | fail | blocked', evidencia: 'comando + saída + status',
251
+ })),
252
+ }, null, 2));
253
+ lines.push('```');
254
+ lines.push('');
255
+ lines.push('Um objeto por cenário-alvo, com o número POSICIONAL do cenário. Nenhum pode ser omitido.');
256
+
257
+ if (setup) {
258
+ lines.push('');
259
+ lines.push('## Setup do ambiente (rode antes do primeiro cenário)');
260
+ lines.push('');
261
+ lines.push('```bash');
262
+ lines.push(setup);
263
+ lines.push('```');
264
+ lines.push('');
265
+ lines.push('Se o setup falhar, TODOS os cenários são `blocked` — registre a falha como evidência.');
266
+ }
267
+
268
+ lines.push('');
269
+ lines.push(`## Cenários a executar — NESTA ORDEM (${scenarios.length})`);
270
+ for (const s of scenarios) {
271
+ lines.push('');
272
+ lines.push(`### ${s.anchor} — Story #${s.story}`);
273
+ lines.push('');
274
+ lines.push(s.body || [
275
+ s.criterio ? `**Critério:** ${s.criterio}` : null,
276
+ s.precondicoes ? `**Pré-condições:** ${s.precondicoes}` : null,
277
+ s.passos ? `**Passos:**\n${s.passos}` : null,
278
+ s.esperado ? `**Esperado:** ${s.esperado}` : null,
279
+ ].filter(Boolean).join('\n'));
280
+ }
281
+
282
+ if (pullRequests.length > 0) {
283
+ lines.push('');
284
+ lines.push('## Pull Requests vinculados');
285
+ lines.push('');
286
+ for (const pr of pullRequests) {
287
+ lines.push(`- PR #${pr.number} — ${pr.merged ? 'mergeado' : pr.state}`);
288
+ }
289
+ }
290
+
291
+ if (comments.length > 0) {
292
+ lines.push('');
293
+ lines.push('## Comentários das issues (revisões e correções)');
294
+ lines.push('');
295
+ lines.push('> Em conflito com os documentos, o comentário mais recente prevalece.');
296
+ for (const group of comments) {
297
+ lines.push('');
298
+ lines.push(`### Comentários da ${group.kind} #${group.issueNumber}`);
299
+ if (group.total > group.items.length) {
300
+ lines.push('');
301
+ lines.push(`_(mostrando os ${group.items.length} mais recentes de ${group.total})_`);
302
+ }
303
+ for (const c of group.items) {
304
+ lines.push('');
305
+ lines.push(`**${c.author || c.user?.login || 'desconhecido'}** (${c.createdAt || c.created_at || ''}):`);
306
+ lines.push('');
307
+ lines.push(String(c.body || '').trim());
308
+ }
309
+ }
310
+ }
311
+
312
+ lines.push('');
313
+ return lines.join('\n');
314
+ }