@spec-wave/cli 0.28.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/api/github-graphql.mjs +37 -0
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +73 -6
- package/src/commands/audit.mjs +280 -0
- package/src/commands/doctor.mjs +83 -2
- package/src/commands/generate-qa-plan.mjs +421 -0
- package/src/commands/implement.mjs +12 -0
- package/src/commands/merge.mjs +292 -0
- package/src/commands/move.mjs +26 -11
- package/src/commands/order.mjs +42 -0
- package/src/commands/qa-run.mjs +813 -0
- package/src/commands/run.mjs +9 -4
- package/src/config.mjs +17 -1
- package/src/lib/artifact-pr.mjs +2 -0
- package/src/lib/board.mjs +18 -2
- package/src/lib/critique.mjs +98 -13
- 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/pr-step.mjs +12 -7
- 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/lib/spec-audit.mjs +372 -0
- package/src/lib/tech-context.mjs +20 -14
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/audit/SKILL.md +34 -0
- package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
- package/src/plugin/skills/merge/SKILL.md +34 -0
- package/src/plugin/skills/order/SKILL.md +1 -0
- package/src/plugin/skills/plan/model-prompt.md +1 -0
- package/src/plugin/skills/plan/reference/tech-context.md +6 -0
- package/src/plugin/skills/preparar-feature/SKILL.md +3 -1
- package/src/plugin/skills/preparar-specs/SKILL.md +21 -1
- package/src/plugin/skills/preparar-specs/reference/revisao.md +5 -2
- 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/config/tech_context.yml +13 -0
- package/src/templates/skill/SKILL.md +77 -4
- package/src/templates/workflows/generate-qa-plan.yml +64 -0
- package/src/templates/workflows/qa.yml +9 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// Plano de QA (docs/features/<slug>/qa-plan.md) — módulo PURO, sem I/O.
|
|
2
|
+
//
|
|
3
|
+
// A etapa 🧪 QA era a única do fluxo sem artefato: a validação funcional era
|
|
4
|
+
// manual e não deixava rastro. O qa-plan.md fecha esse buraco — um arquivo por
|
|
5
|
+
// FEATURE (D-QA1), derivado dos critérios de aceite da spec.md, com uma seção
|
|
6
|
+
// `## Cenário N — Story #X` por cenário. O comando `qa <n>` executa esses
|
|
7
|
+
// cenários e o veredito verde/vermelho move o board ou abre Bugs.
|
|
8
|
+
//
|
|
9
|
+
// As regras de parsing ESPELHAM as do decomposition.md de propósito (mesmo
|
|
10
|
+
// fenceScanner, mesma regra "a posição manda, não o número escrito"): quem já
|
|
11
|
+
// edita um, sabe editar o outro.
|
|
12
|
+
//
|
|
13
|
+
// Gramática (v1):
|
|
14
|
+
//
|
|
15
|
+
// # Plano de QA — <título da Feature>
|
|
16
|
+
// <!-- spec-wave:qa-plan v1 issue=360 -->
|
|
17
|
+
//
|
|
18
|
+
// ## Cenário 1 — Story #412
|
|
19
|
+
//
|
|
20
|
+
// **Critério:** AC-2 — cliente vê apenas os próprios pedidos
|
|
21
|
+
// **Pré-condições:** dois usuários autenticáveis
|
|
22
|
+
// **Passos:**
|
|
23
|
+
// 1. Autenticar como usuário A
|
|
24
|
+
// 2. `GET /pedidos`
|
|
25
|
+
// **Esperado:** resposta 200 contendo somente pedidos de A
|
|
26
|
+
//
|
|
27
|
+
// `**Critério:**` e `**Esperado:**` são obrigatórios; `**Pré-condições:**` e
|
|
28
|
+
// `**Passos:**` são opcionais. O corpo aceita markdown livre.
|
|
29
|
+
|
|
30
|
+
import { fenceScanner } from './decomposition-doc.mjs';
|
|
31
|
+
import { findIncompleteDocSigns } from './doc-completeness.mjs';
|
|
32
|
+
|
|
33
|
+
/** Nome do arquivo dentro do diretório da feature. */
|
|
34
|
+
export const QA_PLAN_FILE = 'qa-plan.md';
|
|
35
|
+
|
|
36
|
+
// Versão do formato, gravada no marcador. Versão MAIOR que a conhecida faz o
|
|
37
|
+
// parse falhar pedindo update da CLI — nunca interpretar errado em silêncio.
|
|
38
|
+
export const QA_PLAN_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
// Só esta forma é estrutura; "## Detalhes" no corpo de um cenário não é seção.
|
|
41
|
+
// `Story #X` é obrigatório no título — cenário sem dono não tem para quem
|
|
42
|
+
// abrir Bug na reprova.
|
|
43
|
+
const SCENARIO_RE = /^##[ \t]+Cen[aá]rio[ \t]+(\d+)[ \t]*(?:[—–:-][ \t]*)?(?:Story[ \t]*#(\d+))?[ \t]*(.*)$/i;
|
|
44
|
+
const H1_RE = /^#[ \t]+(.*)$/;
|
|
45
|
+
const MARKER_RE = /^<!--[ \t]*spec-wave:qa-plan[ \t]+(.*?)-->[ \t]*$/i;
|
|
46
|
+
|
|
47
|
+
// Campos da gramática. Valem em qualquer ponto do corpo do cenário (não só no
|
|
48
|
+
// primeiro bloco): o exemplo canônico intercala `**Passos:**` multi-linha entre
|
|
49
|
+
// eles, e restringir ao primeiro bloco quebraria o formato que o próprio
|
|
50
|
+
// gerador emite.
|
|
51
|
+
const FIELD_RE = /^\*\*(Crit[eé]rio|Pr[eé]-?condi[çc][oõ]es|Passos|Esperado):?\*\*:?[ \t]*(.*)$/i;
|
|
52
|
+
|
|
53
|
+
function invalid(reason) {
|
|
54
|
+
return new Error(`qa-plan.md inválido: ${reason}.`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function flatten(value) {
|
|
58
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function trimBlankEdges(lines) {
|
|
62
|
+
const out = [...lines];
|
|
63
|
+
while (out.length && !out[0].trim()) out.shift();
|
|
64
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Canoniza o nome do campo (sem acento/caixa) para a chave do objeto.
|
|
69
|
+
function fieldKey(raw) {
|
|
70
|
+
const norm = String(raw).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
71
|
+
if (norm.startsWith('criterio')) return 'criterio';
|
|
72
|
+
if (norm.startsWith('pre')) return 'precondicoes';
|
|
73
|
+
if (norm === 'passos') return 'passos';
|
|
74
|
+
return 'esperado';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseMarker(attrs) {
|
|
78
|
+
const text = String(attrs || '');
|
|
79
|
+
const version = /(?:^|\s)v(\d+)(?:\s|$)/.exec(text);
|
|
80
|
+
const issue = /\bissue=(\d+)\b/.exec(text);
|
|
81
|
+
return {
|
|
82
|
+
version: version ? parseInt(version[1], 10) : QA_PLAN_VERSION,
|
|
83
|
+
issueNumber: issue ? parseInt(issue[1], 10) : null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// "# Plano de QA — X" → "X".
|
|
88
|
+
function stripDocTitle(raw) {
|
|
89
|
+
const text = flatten(raw);
|
|
90
|
+
const m = /^Plano de QA(?:[ \t]*[—–:-][ \t]*(.*))?$/i.exec(text);
|
|
91
|
+
return m ? (m[1] || '').trim() : text;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Interpreta o qa-plan.md (função PURA — testável).
|
|
96
|
+
*
|
|
97
|
+
* A numeração ESCRITA é ignorada: a posição manda. Inserir um cenário no meio
|
|
98
|
+
* sem renumerar funciona — a âncora `Cenário N` sai da posição, e o render
|
|
99
|
+
* devolve a numeração corrigida no ciclo seguinte.
|
|
100
|
+
*
|
|
101
|
+
* `Story #X` ausente no título é ERRO de parse (o campo é obrigatório na
|
|
102
|
+
* gramática); referência a issue que não é sub-issue da Feature é erro de
|
|
103
|
+
* VALIDAÇÃO (validateQaPlan) — o parser não conhece a árvore de issues.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} markdown conteúdo do arquivo
|
|
106
|
+
* @param {object} [opts]
|
|
107
|
+
* @param {boolean} [opts.requireMarker] default true; false para conteúdo
|
|
108
|
+
* recém-gerado pelo modelo, que ganha o marcador no render canônico
|
|
109
|
+
* @returns {{version:number, issueNumber:number|null, title:string,
|
|
110
|
+
* scenarios: Array<{anchor:string, numero:number, story:number,
|
|
111
|
+
* criterio:string, precondicoes:string, passos:string,
|
|
112
|
+
* esperado:string, body:string}>}}
|
|
113
|
+
*/
|
|
114
|
+
export function parseQaPlanDoc(markdown, { requireMarker = true } = {}) {
|
|
115
|
+
const text = String(markdown ?? '').replace(/\r\n?/g, '\n');
|
|
116
|
+
if (!text.trim()) throw invalid('o arquivo está vazio');
|
|
117
|
+
|
|
118
|
+
const scan = fenceScanner();
|
|
119
|
+
const sections = [];
|
|
120
|
+
let current = null;
|
|
121
|
+
let marker = null;
|
|
122
|
+
let title = '';
|
|
123
|
+
let sawH1 = false;
|
|
124
|
+
|
|
125
|
+
for (const line of text.split('\n')) {
|
|
126
|
+
if (!scan.inFence(line)) {
|
|
127
|
+
const m = SCENARIO_RE.exec(line);
|
|
128
|
+
if (m) {
|
|
129
|
+
current = { num: m[1], story: m[2] ? parseInt(m[2], 10) : null, rest: m[3] || '', lines: [] };
|
|
130
|
+
sections.push(current);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!current) {
|
|
134
|
+
const mk = MARKER_RE.exec(line.trim());
|
|
135
|
+
if (mk && !marker) {
|
|
136
|
+
marker = parseMarker(mk[1]);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const h1 = H1_RE.exec(line);
|
|
140
|
+
if (h1 && !sawH1) {
|
|
141
|
+
title = stripDocTitle(h1[1]);
|
|
142
|
+
sawH1 = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (current) current.lines.push(line);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (scan.isOpen()) {
|
|
151
|
+
throw invalid(
|
|
152
|
+
'há um bloco de código (```) aberto e nunca fechado — feche-o para que os ' +
|
|
153
|
+
'títulos seguintes voltem a ser reconhecidos'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (!marker && requireMarker) {
|
|
157
|
+
throw invalid(
|
|
158
|
+
`não encontrei o marcador \`<!-- spec-wave:qa-plan v${QA_PLAN_VERSION} … -->\` — ` +
|
|
159
|
+
'o arquivo não parece um qa-plan.md do spec-wave'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (marker && marker.version > QA_PLAN_VERSION) {
|
|
163
|
+
throw invalid(
|
|
164
|
+
`está no formato v${marker.version} e esta CLI entende até v${QA_PLAN_VERSION} — ` +
|
|
165
|
+
'atualize o @spec-wave/cli'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (sections.length === 0) {
|
|
169
|
+
throw invalid('não encontrei nenhum cenário ("## Cenário 1 — Story #<n>")');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const scenarios = sections.map((s, i) => {
|
|
173
|
+
const anchor = `Cenário ${i + 1}`;
|
|
174
|
+
if (!s.story) {
|
|
175
|
+
throw invalid(
|
|
176
|
+
`${anchor} está sem a Story dona — o título precisa ser ` +
|
|
177
|
+
`"## Cenário N — Story #<issue>" (encontrei "## Cenário ${s.num}${s.rest ? ` — ${s.rest}` : ''}")`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const fields = { criterio: '', precondicoes: '', passos: '', esperado: '' };
|
|
181
|
+
// `Passos` é o único campo multi-linha: acumula até o próximo campo.
|
|
182
|
+
let collecting = null;
|
|
183
|
+
const fieldScan = fenceScanner();
|
|
184
|
+
for (const line of s.lines) {
|
|
185
|
+
const inFence = fieldScan.inFence(line);
|
|
186
|
+
const f = inFence ? null : FIELD_RE.exec(line);
|
|
187
|
+
if (f) {
|
|
188
|
+
const key = fieldKey(f[1]);
|
|
189
|
+
fields[key] = (f[2] || '').trim();
|
|
190
|
+
collecting = key === 'passos' ? 'passos' : null;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (collecting === 'passos' && line.trim()) {
|
|
194
|
+
fields.passos = fields.passos ? `${fields.passos}\n${line}` : line;
|
|
195
|
+
} else if (collecting && !line.trim()) {
|
|
196
|
+
collecting = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
anchor,
|
|
201
|
+
numero: i + 1,
|
|
202
|
+
story: s.story,
|
|
203
|
+
criterio: fields.criterio,
|
|
204
|
+
precondicoes: fields.precondicoes,
|
|
205
|
+
passos: fields.passos,
|
|
206
|
+
esperado: fields.esperado,
|
|
207
|
+
body: trimBlankEdges(s.lines).join('\n'),
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
version: marker?.version ?? QA_PLAN_VERSION,
|
|
213
|
+
issueNumber: marker?.issueNumber ?? null,
|
|
214
|
+
title,
|
|
215
|
+
scenarios,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Renderiza o qa-plan.md CANÔNICO (função PURA).
|
|
221
|
+
*
|
|
222
|
+
* Numeração recalculada por posição e marcador sempre presente — é por isso que
|
|
223
|
+
* o conteúdo recém-gerado pelo modelo passa por parse + render antes de ser
|
|
224
|
+
* publicado: o arquivo nasce parseável mesmo que o modelo tenha errado a
|
|
225
|
+
* numeração ou omitido o marcador.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} model
|
|
228
|
+
* @param {string} [model.title] título da Feature (H1)
|
|
229
|
+
* @param {number|null} [model.issueNumber]
|
|
230
|
+
* @param {Array<{story:number, body:string}>} [model.scenarios]
|
|
231
|
+
* @returns {string} markdown terminado em \n
|
|
232
|
+
*/
|
|
233
|
+
export function renderQaPlanDoc({ title = '', issueNumber = null, scenarios = [] } = {}) {
|
|
234
|
+
const attrs = [`v${QA_PLAN_VERSION}`];
|
|
235
|
+
const issue = Number(issueNumber);
|
|
236
|
+
if (Number.isInteger(issue) && issue > 0) attrs.push(`issue=${issue}`);
|
|
237
|
+
|
|
238
|
+
const head = flatten(title) ? `# Plano de QA — ${flatten(title)}` : '# Plano de QA';
|
|
239
|
+
const blocks = [`${head}\n<!-- spec-wave:qa-plan ${attrs.join(' ')} -->`];
|
|
240
|
+
|
|
241
|
+
scenarios.forEach((s, i) => {
|
|
242
|
+
blocks.push(`## Cenário ${i + 1} — Story #${s.story}`);
|
|
243
|
+
const body = trimBlankEdges(String(s.body ?? '').replace(/\r\n?/g, '\n').split('\n')).join('\n');
|
|
244
|
+
if (body) blocks.push(body);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return `${blocks.join('\n\n')}\n`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Validação DETERMINÍSTICA do plano, antes da crítica (função PURA).
|
|
252
|
+
*
|
|
253
|
+
* Barata e mais confiável que a IA para o que é checável sem julgamento:
|
|
254
|
+
* 1. toda Story sub-issue da Feature tem ≥ 1 cenário;
|
|
255
|
+
* 2. todo `Story #X` referenciado é sub-issue real da Feature;
|
|
256
|
+
* 3. todo cenário tem `**Critério:**` e `**Esperado:**` não vazios;
|
|
257
|
+
* 4. nenhum sinal objetivo de truncamento (mesmo helper do `validate`).
|
|
258
|
+
*
|
|
259
|
+
* Falha aqui evita gastar uma chamada de crítica com um arquivo quebrado.
|
|
260
|
+
*
|
|
261
|
+
* @param {object} params
|
|
262
|
+
* @param {ReturnType<typeof parseQaPlanDoc>} params.doc plano parseado
|
|
263
|
+
* @param {Array<{number:number, title?:string, state?:string|null,
|
|
264
|
+
* stateReason?:string|null}>} params.stories Stories sub-issues da Feature
|
|
265
|
+
* @param {string} [params.content] markdown bruto (para os sinais de truncamento)
|
|
266
|
+
* @returns {string[]} erros em pt-BR (vazio = plano válido)
|
|
267
|
+
*/
|
|
268
|
+
export function validateQaPlan({ doc, stories = [], content = '' } = {}) {
|
|
269
|
+
const errors = [];
|
|
270
|
+
const byNumber = new Map(stories.map(s => [s.number, s]));
|
|
271
|
+
|
|
272
|
+
for (const s of doc?.scenarios || []) {
|
|
273
|
+
if (!byNumber.has(s.story)) {
|
|
274
|
+
errors.push(
|
|
275
|
+
`${s.anchor} referencia a Story #${s.story}, que não é sub-issue desta Feature — ` +
|
|
276
|
+
'corrija o número (ou o plano está desatualizado em relação à decomposição).'
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (!s.criterio) {
|
|
280
|
+
errors.push(`${s.anchor} está sem \`**Critério:**\` — todo cenário precisa dizer QUAL critério de aceite valida.`);
|
|
281
|
+
}
|
|
282
|
+
if (!s.esperado) {
|
|
283
|
+
errors.push(`${s.anchor} está sem \`**Esperado:**\` — sem resultado esperado não há como dar veredito.`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Story descartada no rescopo não precisa de cenário; as demais, sim.
|
|
288
|
+
const covered = new Set((doc?.scenarios || []).map(s => s.story));
|
|
289
|
+
for (const story of stories) {
|
|
290
|
+
const descartada = story.state === 'closed' && story.stateReason === 'not_planned';
|
|
291
|
+
if (!descartada && !covered.has(story.number)) {
|
|
292
|
+
errors.push(
|
|
293
|
+
`A Story #${story.number}${story.title ? ` (${story.title})` : ''} não tem nenhum cenário — ` +
|
|
294
|
+
'toda Story da Feature precisa de ao menos um.'
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const problem of findIncompleteDocSigns(content || '')) {
|
|
300
|
+
errors.push(`O arquivo parece incompleto: ${problem}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return errors;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Cenários-alvo de uma execução (função PURA).
|
|
308
|
+
*
|
|
309
|
+
* `qa <feature>` → todos cujas Stories ainda não têm `qa-approved`;
|
|
310
|
+
* `qa <story>` → só os daquela Story;
|
|
311
|
+
* `--only` → filtra por número POSICIONAL (coerente com o parser).
|
|
312
|
+
*
|
|
313
|
+
* @param {object} params
|
|
314
|
+
* @param {ReturnType<typeof parseQaPlanDoc>} params.doc
|
|
315
|
+
* @param {number|null} [params.story] limita à Story (modo `qa <story>`)
|
|
316
|
+
* @param {number[]} [params.approvedStories] Stories com `spec-wave:qa-approved`
|
|
317
|
+
* @param {number[]|null} [params.only] números de cenário do `--only`
|
|
318
|
+
* @returns {{ targets: Array, skippedApproved: Array, unknownOnly: number[] }}
|
|
319
|
+
*/
|
|
320
|
+
export function resolveTargetScenarios({ doc, story = null, approvedStories = [], only = null } = {}) {
|
|
321
|
+
const approved = new Set(approvedStories);
|
|
322
|
+
let targets = doc?.scenarios || [];
|
|
323
|
+
let skippedApproved = [];
|
|
324
|
+
|
|
325
|
+
if (story != null) {
|
|
326
|
+
targets = targets.filter(s => s.story === story);
|
|
327
|
+
} else {
|
|
328
|
+
skippedApproved = targets.filter(s => approved.has(s.story));
|
|
329
|
+
targets = targets.filter(s => !approved.has(s.story));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let unknownOnly = [];
|
|
333
|
+
if (Array.isArray(only) && only.length > 0) {
|
|
334
|
+
const wanted = new Set(only);
|
|
335
|
+
unknownOnly = only.filter(n => !targets.some(s => s.numero === n));
|
|
336
|
+
targets = targets.filter(s => wanted.has(s.numero));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { targets, skippedApproved, unknownOnly };
|
|
340
|
+
}
|