@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.
- package/package.json +1 -1
- package/src/cli.mjs +35 -5
- package/src/commands/doctor.mjs +83 -2
- package/src/commands/generate-qa-plan.mjs +421 -0
- package/src/commands/qa-run.mjs +813 -0
- package/src/commands/run.mjs +5 -1
- package/src/config.mjs +17 -1
- package/src/lib/artifact-pr.mjs +2 -0
- package/src/lib/critique.mjs +38 -9
- package/src/lib/decomposition-doc.mjs +5 -1
- package/src/lib/doc-paths.mjs +5 -2
- package/src/lib/next-step.mjs +15 -3
- package/src/lib/qa-exec.mjs +314 -0
- package/src/lib/qa-plan-doc.mjs +340 -0
- package/src/lib/qa-report.mjs +340 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/qa/SKILL.md +105 -0
- package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
- package/src/plugin/skills/qa/model-prompt.md +68 -0
- package/src/templates/skill/SKILL.md +48 -1
- package/src/templates/workflows/generate-qa-plan.yml +64 -0
package/src/commands/run.mjs
CHANGED
|
@@ -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 = [
|
package/src/lib/artifact-pr.mjs
CHANGED
|
@@ -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
|
/**
|
package/src/lib/critique.mjs
CHANGED
|
@@ -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"
|
|
204
|
-
* "geral" — vira ausência de âncora: melhor
|
|
205
|
-
* mandar o humano para o trecho errado
|
|
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
|
-
|
|
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
|
package/src/lib/doc-paths.mjs
CHANGED
|
@@ -30,12 +30,12 @@ export function resolveDocDir(root, issue, type) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Caminhos dos
|
|
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
|
}
|
package/src/lib/next-step.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
LABEL_SPEC, LABEL_PLAN, LABEL_CRITIQUE, LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY,
|
|
18
18
|
LABEL_DECOMPOSE_READY, LABEL_DECOMPOSED, LABEL_BUG, LABEL_BUG_APPROVED,
|
|
19
19
|
LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_PLAN_APPROVED, LABEL_TRIAGED,
|
|
20
|
-
LABEL_DUPLICATE, LABEL_WONT_FIX, LABEL_READY, labelNames,
|
|
20
|
+
LABEL_DUPLICATE, LABEL_WONT_FIX, LABEL_READY, LABEL_QA, LABEL_QA_READY, labelNames,
|
|
21
21
|
} from '../config.mjs';
|
|
22
22
|
import { awaitingMergeBlock } from './artifact-pr.mjs';
|
|
23
23
|
import { isAwaitingMerge } from './doc-source.mjs';
|
|
@@ -71,6 +71,15 @@ export const STEPS = {
|
|
|
71
71
|
trigger: LABEL_BUG, cli: 'generate-bug', writes: 'bug', reads: [],
|
|
72
72
|
types: ['Bug'], creates: false, ai: true,
|
|
73
73
|
},
|
|
74
|
+
// Fora do pipeline linear de propósito (a geração do plano de QA acontece com
|
|
75
|
+
// a Feature já implementada, não antes do decompose) — o run só chega aqui
|
|
76
|
+
// por `--step qa-plan`. A entrada existe pela paridade: `spec-wave:qa` é
|
|
77
|
+
// label de gatilho de workflow, e toda label de gatilho precisa de EXATAMENTE
|
|
78
|
+
// uma ação (é o teste de paridade que impede os dois modos de divergirem).
|
|
79
|
+
'generate-qa-plan': {
|
|
80
|
+
trigger: LABEL_QA, cli: 'generate-qa-plan', writes: 'qa-plan', reads: ['spec', 'plan'],
|
|
81
|
+
types: ['Feature'], creates: false, ai: true,
|
|
82
|
+
},
|
|
74
83
|
// Sem gatilho por label: são decisões humanas, listadas aqui para o `run`
|
|
75
84
|
// saber dizer qual é o próximo movimento em vez de só "nada pendente".
|
|
76
85
|
triage: {
|
|
@@ -137,13 +146,14 @@ const TRIGGERS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
|
|
|
137
146
|
|
|
138
147
|
/** Documentos que cada tipo usa, na ordem em que o fluxo os produz. */
|
|
139
148
|
export const DOCS_BY_TYPE = {
|
|
140
|
-
Feature: ['spec', 'plan', 'decomposition'],
|
|
149
|
+
Feature: ['spec', 'plan', 'decomposition', 'qa-plan'],
|
|
141
150
|
RFC: ['decomposition'],
|
|
142
151
|
Bug: ['bug'],
|
|
143
152
|
};
|
|
144
153
|
|
|
145
154
|
const DEFAULT_DOC_PATHS = {
|
|
146
155
|
spec: 'spec.md', plan: 'plan.md', decomposition: 'decomposition.md', bug: 'bug.md',
|
|
156
|
+
'qa-plan': 'qa-plan.md',
|
|
147
157
|
};
|
|
148
158
|
|
|
149
159
|
function stepCommand(action, { issueNumber }) {
|
|
@@ -184,7 +194,7 @@ function block(code, message, unblock) {
|
|
|
184
194
|
export function resolveForcedStep(step, { type }) {
|
|
185
195
|
const alias = {
|
|
186
196
|
spec: 'generate-spec', plan: 'generate-plan', bug: 'generate-bug',
|
|
187
|
-
ready: 'validate', apply: 'decompose-apply',
|
|
197
|
+
ready: 'validate', apply: 'decompose-apply', 'qa-plan': 'generate-qa-plan',
|
|
188
198
|
};
|
|
189
199
|
const action = alias[step] || step;
|
|
190
200
|
if (!STEPS[action] || STEPS[action].manual) {
|
|
@@ -279,6 +289,7 @@ export function nextStep({
|
|
|
279
289
|
// muda o slug (e o diretório); clone velho não tem o arquivo.
|
|
280
290
|
const claims = [
|
|
281
291
|
[LABEL_PLAN_APPROVED, 'plan'], [LABEL_DECOMPOSED, 'decomposition'], [LABEL_BUG_APPROVED, 'bug'],
|
|
292
|
+
[LABEL_QA_READY, 'qa-plan'],
|
|
282
293
|
];
|
|
283
294
|
for (const [label, doc] of claims) {
|
|
284
295
|
if (has(label) && docsOfType.includes(doc) && docState(docs, doc) === 'missing') {
|
|
@@ -411,6 +422,7 @@ function pipelineReason(action, { pathOf }) {
|
|
|
411
422
|
case 'decompose': return `O rascunho \`${pathOf('decomposition')}\` ainda não existe.`;
|
|
412
423
|
case 'decompose-apply': return 'O rascunho foi aprovado pela crítica e espera revisão humana.';
|
|
413
424
|
case 'generate-bug': return `\`${pathOf('bug')}\` ainda não existe.`;
|
|
425
|
+
case 'generate-qa-plan': return `Gera (ou re-critica) o plano de QA em \`${pathOf('qa-plan')}\`.`;
|
|
414
426
|
default: return 'Próximo passo do fluxo.';
|
|
415
427
|
}
|
|
416
428
|
}
|
|
@@ -0,0 +1,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
|
+
}
|