@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
package/src/commands/run.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export function lockPath(root, key) {
|
|
|
81
81
|
return path.join(gitCommonDir(root), 'spec-wave', `run-${key}.lock`);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function acquireLock(root, key) {
|
|
84
|
+
export function acquireLock(root, key) {
|
|
85
85
|
const file = lockPath(root, key);
|
|
86
86
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
87
87
|
const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
@@ -108,7 +108,7 @@ function acquireLock(root, key) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
function releaseLock(file) {
|
|
111
|
+
export function releaseLock(file) {
|
|
112
112
|
try {
|
|
113
113
|
if (file) unlinkSync(file);
|
|
114
114
|
} catch { /* já removido */ }
|
|
@@ -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
|
}
|
|
@@ -348,7 +352,8 @@ async function runForPr({ prNumber, dryRun, yes, only, json }) {
|
|
|
348
352
|
process.exitCode = decision.blocked ? 2 : 0;
|
|
349
353
|
return decision;
|
|
350
354
|
}
|
|
351
|
-
|
|
355
|
+
// Mergeado, o pedido de mudanças pré-merge já foi decidido por quem mergeou.
|
|
356
|
+
if (verdict.changesRequestedAfterApproval && !pr.merged_at && !yes) {
|
|
352
357
|
console.log(chalk.yellow('\n⛔ needs-confirmation: há pedido de mudanças além da aprovação. Confirme com `--yes`.'));
|
|
353
358
|
process.exitCode = 2;
|
|
354
359
|
return decision;
|
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/board.mjs
CHANGED
|
@@ -218,11 +218,27 @@ export async function advanceToStage(
|
|
|
218
218
|
const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
219
219
|
if (!shouldAdvanceStage(current, targetStage)) return false;
|
|
220
220
|
const optionId = etapaField.options?.[targetStage];
|
|
221
|
-
|
|
221
|
+
// Opção que não resolve é board divergente (coluna renomeada, init de outra
|
|
222
|
+
// versão) — pular a escrita e devolver true fazia o chamador imprimir ✅
|
|
223
|
+
// sem nada ter sido escrito. Melhor falhar nomeando o que faltou.
|
|
224
|
+
if (!optionId) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`A Etapa "${targetStage}" não existe no board (opções: ` +
|
|
227
|
+
`${Object.keys(etapaField.options || {}).join(', ') || 'nenhuma'}). ` +
|
|
228
|
+
'O board divergiu da config — rode `spec-wave refresh` ou renomeie a coluna de volta.'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
222
232
|
}
|
|
223
233
|
if (statusField?.id && targetStatus) {
|
|
224
234
|
const optionId = statusField.options?.[targetStatus];
|
|
225
|
-
if (optionId)
|
|
235
|
+
if (!optionId) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`O Status "${targetStatus}" não existe no board (opções: ` +
|
|
238
|
+
`${Object.keys(statusField.options || {}).join(', ') || 'nenhuma'}).`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
|
|
226
242
|
}
|
|
227
243
|
return true;
|
|
228
244
|
}
|
package/src/lib/critique.mjs
CHANGED
|
@@ -44,12 +44,16 @@ 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)', qa: 'qa-plan.md',
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
// Prompt por tipo de auditoria. 'plan' audita o plan.md contra a spec;
|
|
50
|
-
// 'stories' audita a decomposição proposta contra spec + plan
|
|
51
|
+
// 'stories' audita a decomposição proposta contra spec + plan; 'conjunto'
|
|
52
|
+
// audita TODAS as specs de uma milestone entre si — é a única cujo objeto é a
|
|
53
|
+
// relação entre documentos, não um documento.
|
|
51
54
|
const KIND_PROMPT = {
|
|
52
55
|
plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique', spec: 'spec/critique',
|
|
56
|
+
conjunto: 'audit/critique', qa: 'qa/critique',
|
|
53
57
|
};
|
|
54
58
|
|
|
55
59
|
// A decomposição virou arquivo revisável (decomposition.md): um finding só é
|
|
@@ -62,6 +66,20 @@ const ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho audita
|
|
|
62
66
|
- "geral" quando o problema for da decomposição como um todo (ex.: requisito da spec que nenhuma Story cobre).
|
|
63
67
|
Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "### Task N.M — ..." do documento.`;
|
|
64
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
|
+
|
|
76
|
+
// No conjunto o achado vive ENTRE documentos: sem dizer quais, o leitor relê a
|
|
77
|
+
// milestone inteira procurando o par. O campo é validado como array de números
|
|
78
|
+
// de issue — é o equivalente da âncora "Story N" para este contexto.
|
|
79
|
+
const FEATURES_RULE = `Cada finding DEVE listar, no campo "features", os números das issues das Features envolvidas
|
|
80
|
+
(ex.: [412, 415] para uma contradição entre as duas; [412] quando o problema é de uma spec só,
|
|
81
|
+
visto à luz das outras). Use EXATAMENTE os números que aparecem nos títulos "## spec.md — #N ..." fornecidos.`;
|
|
82
|
+
|
|
65
83
|
/**
|
|
66
84
|
* Monta o system prompt da crítica: corpo editável + contrato de máquina.
|
|
67
85
|
*
|
|
@@ -79,7 +97,9 @@ Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "###
|
|
|
79
97
|
*/
|
|
80
98
|
function buildSystemPrompt(kind, cwd) {
|
|
81
99
|
const prompt = loadPrompt(KIND_PROMPT[kind] || KIND_PROMPT.plan, ...(cwd ? [{ cwd }] : []));
|
|
82
|
-
const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}`
|
|
100
|
+
const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}`
|
|
101
|
+
: kind === 'qa' ? `\n\n${QA_ANCHOR_RULE}`
|
|
102
|
+
: kind === 'conjunto' ? `\n\n${FEATURES_RULE}` : '';
|
|
83
103
|
|
|
84
104
|
const contract = `## Contrato de saída
|
|
85
105
|
|
|
@@ -134,6 +154,24 @@ function critiqueJsonSchema(kind) {
|
|
|
134
154
|
};
|
|
135
155
|
required.push('anchor');
|
|
136
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
|
+
}
|
|
164
|
+
if (kind === 'conjunto') {
|
|
165
|
+
properties.features = {
|
|
166
|
+
type: 'array',
|
|
167
|
+
items: { type: 'integer' },
|
|
168
|
+
minItems: 1,
|
|
169
|
+
description:
|
|
170
|
+
'Números das issues das Features envolvidas no finding — os "#N" dos títulos ' +
|
|
171
|
+
'"## spec.md — #N ..." fornecidos.',
|
|
172
|
+
};
|
|
173
|
+
required.push('features');
|
|
174
|
+
}
|
|
137
175
|
return {
|
|
138
176
|
type: 'object',
|
|
139
177
|
properties: {
|
|
@@ -172,14 +210,14 @@ function describe(value) {
|
|
|
172
210
|
return typeof value === 'object' ? 'um objeto' : `${typeof value} (${JSON.stringify(value).slice(0, 60)})`;
|
|
173
211
|
}
|
|
174
212
|
|
|
175
|
-
const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
|
|
213
|
+
const ANCHOR_RE = /^(story|task|cen[aá]rio)[ \t]*(\d+(?:\.\d+)?)$/i;
|
|
176
214
|
|
|
177
215
|
/**
|
|
178
216
|
* Normaliza a âncora de um finding (função PURA).
|
|
179
217
|
*
|
|
180
|
-
* Aceita "Story 3", "story3", "TASK 3.2"
|
|
181
|
-
* "geral" — vira ausência de âncora: melhor
|
|
182
|
-
* 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.
|
|
183
221
|
*
|
|
184
222
|
* @param {*} value valor cru do campo `anchor`
|
|
185
223
|
* @returns {string} âncora normalizada, ou '' quando não há
|
|
@@ -187,7 +225,9 @@ const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
|
|
|
187
225
|
export function normalizeAnchor(value) {
|
|
188
226
|
const m = ANCHOR_RE.exec(String(value ?? '').trim());
|
|
189
227
|
if (!m) return '';
|
|
190
|
-
|
|
228
|
+
const kind = m[1].toLowerCase();
|
|
229
|
+
const nome = kind === 'task' ? 'Task' : kind === 'story' ? 'Story' : 'Cenário';
|
|
230
|
+
return `${nome} ${m[2]}`;
|
|
191
231
|
}
|
|
192
232
|
|
|
193
233
|
/**
|
|
@@ -245,10 +285,23 @@ export function validateCritiquePayload(payload) {
|
|
|
245
285
|
);
|
|
246
286
|
}
|
|
247
287
|
const extra = itemKeys.filter(
|
|
248
|
-
k => k !== 'severity' && k !== 'text' && k !== 'anchor' && k !== 'quote');
|
|
288
|
+
k => k !== 'severity' && k !== 'text' && k !== 'anchor' && k !== 'quote' && k !== 'features');
|
|
249
289
|
if (extra.length > 0) {
|
|
250
290
|
throw new CritiqueSchemaError(`${at} tem campo(s) não reconhecido(s): ${extra.join(', ')}.`);
|
|
251
291
|
}
|
|
292
|
+
// `features` (kind 'conjunto'): quais issues o finding atravessa. Fora do
|
|
293
|
+
// formato é erro de schema — a localização é a metade do valor do achado.
|
|
294
|
+
let features = null;
|
|
295
|
+
if ('features' in item) {
|
|
296
|
+
if (!Array.isArray(item.features)
|
|
297
|
+
|| item.features.length === 0
|
|
298
|
+
|| !item.features.every(n => Number.isInteger(n) && n > 0)) {
|
|
299
|
+
throw new CritiqueSchemaError(
|
|
300
|
+
`${at}.features deveria ser um array não-vazio de números de issue, veio ${describe(item.features)}.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
features = [...new Set(item.features)];
|
|
304
|
+
}
|
|
252
305
|
// Normaliza só caixa e espaço — isso não é ambiguidade semântica. "GRAVE"
|
|
253
306
|
// passa; "gravíssimo", "critical" e "high" NÃO.
|
|
254
307
|
const severity = typeof item.severity === 'string' ? item.severity.trim().toLowerCase() : null;
|
|
@@ -268,6 +321,7 @@ export function validateCritiquePayload(payload) {
|
|
|
268
321
|
return {
|
|
269
322
|
severity,
|
|
270
323
|
...(anchor ? { anchor } : {}),
|
|
324
|
+
...(features ? { features } : {}),
|
|
271
325
|
...(quote ? { quote } : {}),
|
|
272
326
|
text: item.text.trim(),
|
|
273
327
|
};
|
|
@@ -721,6 +775,17 @@ const KIND_TRAILER = {
|
|
|
721
775
|
'até ser removida. Um bug com causa raiz errada produz correção errada — corrija o ' +
|
|
722
776
|
'`bug.md` e reaplique `spec-wave:bug`.'
|
|
723
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._'),
|
|
783
|
+
// O conjunto roda fora do ciclo de tentativas e não aplica label: o achado é
|
|
784
|
+
// decisão de PO entre duas specs, e o destino dele é a issue das duas pontas.
|
|
785
|
+
conjunto: (graves) => (graves
|
|
786
|
+
? '⛔ Há findings **graves** entre specs: comente nas issues DOS DOIS lados, apontando ' +
|
|
787
|
+
'uma para a outra, e resolva antes de gerar os planos — cada spec sozinha parece certa.'
|
|
788
|
+
: '_Nenhuma contradição entre as specs que exija decisão antes dos planos._'),
|
|
724
789
|
};
|
|
725
790
|
|
|
726
791
|
/**
|
|
@@ -782,8 +847,12 @@ export function renderCritiqueMarkdown({
|
|
|
782
847
|
return parts.join('\n\n');
|
|
783
848
|
}
|
|
784
849
|
|
|
850
|
+
// Localização do finding: a âncora ("Story N") ou, no conjunto, as Features
|
|
851
|
+
// que ele atravessa ("#412 × #415").
|
|
852
|
+
const onde = f => f.anchor
|
|
853
|
+
|| (f.features?.length ? f.features.map(n => `#${n}`).join(' × ') : '');
|
|
785
854
|
const bullets = list => list
|
|
786
|
-
.map(f => `- ${f
|
|
855
|
+
.map(f => `- ${onde(f) ? `**${onde(f)}** — ` : ''}${sanitizeFindingText(f.text)}`)
|
|
787
856
|
.join('\n');
|
|
788
857
|
if (graves.length > 0) parts.push(`### ❌ Graves\n\n${bullets(graves)}`);
|
|
789
858
|
if (menores.length > 0) parts.push(`### ⚠️ Menores\n\n${bullets(menores)}`);
|
|
@@ -791,7 +860,7 @@ export function renderCritiqueMarkdown({
|
|
|
791
860
|
parts.push(
|
|
792
861
|
'### ↘️ Rebaixados (vieram como graves, não bloqueiam)\n\n' +
|
|
793
862
|
rebaixados
|
|
794
|
-
.map(f => `- ${f
|
|
863
|
+
.map(f => `- ${onde(f) ? `**${onde(f)}** — ` : ''}${sanitizeFindingText(f.text)}\n` +
|
|
795
864
|
` - _Rebaixado: ${sanitizeFindingText(f.downgradeReason || 'não se sustenta como bloqueio')}._`)
|
|
796
865
|
.join('\n') +
|
|
797
866
|
'\n\n_Continuam valendo como observação. Se algum for mesmo grave, corrija o documento ' +
|
|
@@ -811,6 +880,7 @@ export function renderCritiqueMarkdown({
|
|
|
811
880
|
fingerprint: findingFingerprint({ kind, anchor: f.anchor, text: f.text }),
|
|
812
881
|
severity: f.severity,
|
|
813
882
|
anchor: f.anchor || null,
|
|
883
|
+
...(f.features?.length ? { features: f.features } : {}),
|
|
814
884
|
text: sanitizeFindingText(f.text),
|
|
815
885
|
...(f.downgraded ? { downgradedFrom: f.downgraded, downgradeReason: f.downgradeReason } : {}),
|
|
816
886
|
})),
|
|
@@ -832,8 +902,10 @@ export function renderCritiqueMarkdown({
|
|
|
832
902
|
* chamador decidir entre seguir com aviso e abortar.
|
|
833
903
|
*
|
|
834
904
|
* @param {object} params
|
|
835
|
-
* @param {'plan'|'stories'|'bug'} params.kind o que está sendo auditado
|
|
905
|
+
* @param {'plan'|'stories'|'bug'|'spec'|'conjunto'} params.kind o que está sendo auditado
|
|
836
906
|
* @param {string} [params.spec] conteúdo do spec.md
|
|
907
|
+
* @param {Array<{number:number, title:string, content:string}>} [params.specs]
|
|
908
|
+
* kind 'conjunto': TODAS as specs da milestone, uma seção por Feature
|
|
837
909
|
* @param {string} [params.plan] conteúdo do plan.md
|
|
838
910
|
* @param {string} [params.techContextYaml] tech_context serializado em YAML
|
|
839
911
|
* @param {string} [params.decomposition] conteúdo do decomposition.md
|
|
@@ -847,12 +919,17 @@ export function renderCritiqueMarkdown({
|
|
|
847
919
|
* @returns {Promise<{grave, findings, markdown, attempt, model}>}
|
|
848
920
|
*/
|
|
849
921
|
export async function runCritique({
|
|
850
|
-
kind, spec, plan, techContextYaml, decomposition, bugDoc, bugReport,
|
|
922
|
+
kind, spec, specs, plan, techContextYaml, decomposition, qaPlan, bugDoc, bugReport,
|
|
851
923
|
attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
852
924
|
model, labels = [], usage, cwd, decisions = null, standalone = false,
|
|
853
925
|
} = {}) {
|
|
854
926
|
const sections = [];
|
|
855
927
|
if (spec) sections.push(`## spec.md\n\n${spec}`);
|
|
928
|
+
// Conjunto: o título de cada seção carrega o "#N" que o campo `features` dos
|
|
929
|
+
// findings cita de volta — é o contrato de localização deste kind.
|
|
930
|
+
for (const s of specs || []) {
|
|
931
|
+
sections.push(`## spec.md — #${s.number} ${s.title}\n\n${s.content}`);
|
|
932
|
+
}
|
|
856
933
|
if (plan) sections.push(`## plan.md\n\n${plan}`);
|
|
857
934
|
if (techContextYaml) sections.push(`## tech_context\n\n\`\`\`yaml\n${techContextYaml}\n\`\`\``);
|
|
858
935
|
// Cerca de QUATRO crases: o decomposition.md contém cercas de três, e uma
|
|
@@ -862,6 +939,13 @@ export async function runCritique({
|
|
|
862
939
|
`## Decomposição proposta (decomposition.md)\n\n\`\`\`\`markdown\n${decomposition}\n\`\`\`\``
|
|
863
940
|
);
|
|
864
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
|
+
}
|
|
865
949
|
// O relato é a fonte contra a qual o bug.md é auditado: a causa raiz proposta
|
|
866
950
|
// tem que explicar OS SINTOMAS RELATADOS, não sintomas plausíveis quaisquer.
|
|
867
951
|
if (bugReport) sections.push(`## Relato original (issue e comentários)\n\n${bugReport}`);
|
|
@@ -898,7 +982,8 @@ export async function runCritique({
|
|
|
898
982
|
// sustenta não deve entrar no contador de tentativas nem bloquear o Action.
|
|
899
983
|
const findings = downgradeUnsupportedFindings(
|
|
900
984
|
report.value.findings,
|
|
901
|
-
[spec, plan, decomposition, bugDoc, techContextYaml]
|
|
985
|
+
[spec, ...(specs || []).map(s => s.content), plan, decomposition, qaPlan, bugDoc, techContextYaml]
|
|
986
|
+
.filter(Boolean),
|
|
902
987
|
);
|
|
903
988
|
const grave = findings.some(f => f.severity === 'grave');
|
|
904
989
|
const rebaixados = findings.filter(f => f.downgraded).length;
|
|
@@ -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
|
}
|
package/src/lib/pr-step.mjs
CHANGED
|
@@ -82,21 +82,26 @@ export function nextPrStep({
|
|
|
82
82
|
return { steps: [], warnings, reason: 'PR fechado sem merge.', blocked: null };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// Merge move até 🧪 QA mesmo sem aprovação formal: o autor não consegue
|
|
86
|
+
// aprovar o próprio PR (fluxo solo nunca teria QA por construção), e QA de
|
|
87
|
+
// verdade começa com o código integrado. O qa.yml tem o mesmo gatilho de
|
|
88
|
+
// merge — os dois modos continuam idênticos.
|
|
89
|
+
if (merged) warnings.push('PR já mergeado — merge move até 🧪 QA, com ou sem aprovação formal.');
|
|
90
|
+
if (changesRequestedAfterApproval && !merged) {
|
|
88
91
|
warnings.push('Há aprovação E pedido de mudanças: o Actions moveria assim mesmo — confirme com `--yes`.');
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
let steps = approved ? ['code-review', 'qa'] : ['code-review'];
|
|
94
|
+
let steps = (approved || merged) ? ['code-review', 'qa'] : ['code-review'];
|
|
92
95
|
if (only) steps = steps.filter(s => s === only);
|
|
93
96
|
|
|
94
97
|
return {
|
|
95
98
|
steps,
|
|
96
99
|
warnings,
|
|
97
|
-
reason:
|
|
98
|
-
? 'PR
|
|
99
|
-
:
|
|
100
|
+
reason: merged
|
|
101
|
+
? 'PR mergeado: board até 🧪 QA.'
|
|
102
|
+
: approved
|
|
103
|
+
? 'PR com review aprovada: board até 🧪 QA.'
|
|
104
|
+
: 'PR sem review aprovada: board até 👀 Code Review.',
|
|
100
105
|
blocked: null,
|
|
101
106
|
};
|
|
102
107
|
}
|