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