@spec-wave/cli 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.mjs +35 -5
- package/src/commands/doctor.mjs +83 -2
- package/src/commands/generate-qa-plan.mjs +421 -0
- package/src/commands/qa-run.mjs +813 -0
- package/src/commands/run.mjs +5 -1
- package/src/config.mjs +17 -1
- package/src/lib/artifact-pr.mjs +2 -0
- package/src/lib/critique.mjs +38 -9
- package/src/lib/decomposition-doc.mjs +5 -1
- package/src/lib/doc-paths.mjs +5 -2
- package/src/lib/next-step.mjs +15 -3
- package/src/lib/qa-exec.mjs +314 -0
- package/src/lib/qa-plan-doc.mjs +340 -0
- package/src/lib/qa-report.mjs +340 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/qa/SKILL.md +105 -0
- package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
- package/src/plugin/skills/qa/model-prompt.md +68 -0
- package/src/templates/skill/SKILL.md +48 -1
- package/src/templates/workflows/generate-qa-plan.yml +64 -0
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -151,7 +151,7 @@ export function buildProgram() {
|
|
|
151
151
|
.option('--dry-run', 'Decide e explica sem executar nada')
|
|
152
152
|
.option('--yes', 'Confirma o passo que exige confirmação')
|
|
153
153
|
.option('--apply', 'Autoriza especificamente o decompose-apply (erra se o passo pendente for outro)')
|
|
154
|
-
.option('--step <nome>', 'Força um passo: spec | plan | critique | validate | decompose | decompose-apply | bug')
|
|
154
|
+
.option('--step <nome>', 'Força um passo: spec | plan | critique | validate | decompose | decompose-apply | bug | qa-plan')
|
|
155
155
|
.option('--max-steps <n>', 'Encadeia até N passos (padrão: 1)', '1')
|
|
156
156
|
.option('--force', 'Ignora o portão de label de gatilho pendente')
|
|
157
157
|
.option('--no-remote-check', 'Não consulta o remoto pelos documentos ausentes (offline)')
|
|
@@ -320,11 +320,40 @@ export function buildProgram() {
|
|
|
320
320
|
|
|
321
321
|
program
|
|
322
322
|
.command('qa')
|
|
323
|
-
.description('
|
|
324
|
-
.
|
|
323
|
+
.description('Executa o plano de QA de uma issue (Feature/Story/Bug) localmente; com --pr-number, move a Feature para QA (usado pelo GitHub Action)')
|
|
324
|
+
.argument('[issue]', 'Número da issue (Feature, Story ou Bug), ex.: 12 ou #12')
|
|
325
|
+
.option('--pr-number <n>', 'Modo Action: move a Feature/Bug do PR aprovado ou mergeado para 🧪 QA')
|
|
326
|
+
.option('--only <n[,m]>', 'Executa só o(s) cenário(s) indicado(s) — número POSICIONAL no plano')
|
|
327
|
+
.option('--severity <p>', 'Severidade dos Bugs abertos na reprova: P0, P1, P2 ou P3 (default: qa.defaultBugPriority ou P2)')
|
|
328
|
+
.option('--dry-run', 'Monta o contexto e imprime cenários e comando — ZERO escrita no GitHub')
|
|
329
|
+
.action(async (issueArg, options) => {
|
|
330
|
+
// Dois modos, mutuamente exclusivos: `--pr-number` é a transição Code
|
|
331
|
+
// Review → 🧪 QA (Action); o argumento posicional é a EXECUÇÃO do plano.
|
|
332
|
+
if (issueArg && options.prNumber) {
|
|
333
|
+
console.error('Use OU `qa <issue>` (executa o plano) OU `qa --pr-number <n>` (move o board) — nunca os dois.');
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
if (!issueArg && !options.prNumber) {
|
|
337
|
+
console.error('Informe a issue (`spec-wave qa <issue>`) ou o PR (`spec-wave qa --pr-number <n>`).');
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
if (options.prNumber) {
|
|
341
|
+
const { qa } = await import('./commands/qa.mjs');
|
|
342
|
+
await qa(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const { qaRun } = await import('./commands/qa-run.mjs');
|
|
346
|
+
await qaRun({ issue: issueArg, ...options })
|
|
347
|
+
.catch(err => { console.error(err.message); process.exit(1); });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
program
|
|
351
|
+
.command('generate-qa-plan')
|
|
352
|
+
.description('Gera (ou re-critica) o plano de QA de uma Feature em qa-plan.md — roda no GitHub Action ou localmente')
|
|
353
|
+
.requiredOption('--issue-number <n>', 'Número da issue no GitHub')
|
|
325
354
|
.action(async (options) => {
|
|
326
|
-
const {
|
|
327
|
-
await
|
|
355
|
+
const { generateQaPlan } = await import('./commands/generate-qa-plan.mjs');
|
|
356
|
+
await generateQaPlan(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
328
357
|
});
|
|
329
358
|
|
|
330
359
|
program
|
|
@@ -441,6 +470,7 @@ Fluxo típico (cada tema, na ordem):
|
|
|
441
470
|
decompor run <issue> (decompose → --apply) → order <feature>
|
|
442
471
|
implementar implement <issue> · task start/done · story review
|
|
443
472
|
entregar merge <feature> (PRs empilhados, na ordem) · run --pr <n> (board até QA)
|
|
473
|
+
validar (QA) label spec-wave:qa (gera qa-plan.md) → qa <issue> [--only n] (executa e dá o veredito)
|
|
444
474
|
acompanhar info · order · move · repair-stage
|
|
445
475
|
|
|
446
476
|
Docs: https://astratech-net-br.github.io/spec-wave-cli/ · spec-wave <comando> --help`);
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -13,15 +13,17 @@ import {
|
|
|
13
13
|
resolveToken, verifyTokenScopes, describeTokenSource, activeGhAccount,
|
|
14
14
|
tokenMismatchWarning, parseActiveAccount,
|
|
15
15
|
} from '../api/auth.mjs';
|
|
16
|
-
import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
|
|
16
|
+
import { getProjectSnapshot, listSubIssues, listProjectItems } from '../api/github-graphql.mjs';
|
|
17
17
|
import { getRepoVariable } from '../api/github-rest.mjs';
|
|
18
18
|
import {
|
|
19
19
|
CONFIG_FILE, WORKFLOW_FILES, ARTIFACT_WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER,
|
|
20
20
|
AI_PROVIDERS, STATUS_OPTIONS,
|
|
21
21
|
RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
22
22
|
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
|
|
23
|
-
modelLabels,
|
|
23
|
+
modelLabels, STAGE_QA,
|
|
24
24
|
} from '../config.mjs';
|
|
25
|
+
import { slugify } from '../lib/slugify.mjs';
|
|
26
|
+
import { QA_PLAN_FILE } from '../lib/qa-plan-doc.mjs';
|
|
25
27
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
26
28
|
import { configuredMode, describeModeState, EXECUTION_VARIABLE } from '../lib/execution-mode.mjs';
|
|
27
29
|
import { unguardedWorkflows } from './mode.mjs';
|
|
@@ -952,6 +954,84 @@ export function checkSpecKit(ctx) {
|
|
|
952
954
|
};
|
|
953
955
|
}
|
|
954
956
|
|
|
957
|
+
/**
|
|
958
|
+
* Features em 🧪 QA sem qa-plan.md no disco (função PURA — testável sem rede).
|
|
959
|
+
*
|
|
960
|
+
* Uma Feature parada em QA sem plano é validação manual sem rastro — o buraco
|
|
961
|
+
* que o fluxo de QA existe para fechar. Só aviso: gerar o plano é um passo, não
|
|
962
|
+
* um defeito de configuração.
|
|
963
|
+
*
|
|
964
|
+
* @param {object} params
|
|
965
|
+
* @param {Array<{number, title, state, fields}>} [params.items] itens do board
|
|
966
|
+
* @param {(rel: string) => boolean} params.exists sonda de existência no disco
|
|
967
|
+
* @returns {Array<{number: number, rel: string}>}
|
|
968
|
+
*/
|
|
969
|
+
export function featuresInQaWithoutPlan({ items = [], exists = () => false } = {}) {
|
|
970
|
+
return items
|
|
971
|
+
.filter(i => i?.number
|
|
972
|
+
&& String(i.state || '').toUpperCase() !== 'CLOSED'
|
|
973
|
+
&& (i.fields?.['Work Item Type'] === 'Feature' || /^\s*\[FEATURE\]/i.test(i.title || ''))
|
|
974
|
+
&& i.fields?.Etapa === STAGE_QA)
|
|
975
|
+
.map(i => ({ number: i.number, rel: `docs/features/${slugify(i.title || '')}/${QA_PLAN_FILE}` }))
|
|
976
|
+
.filter(f => !exists(f.rel));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// QA (rfc/spec-qa-skill.md): executor configurado + Features em 🧪 QA com plano.
|
|
980
|
+
// As labels novas e o workflow generate-qa-plan.yml são cobertos pelos checks
|
|
981
|
+
// existentes (higiene de labels e workflows), que leem as listas do config.mjs.
|
|
982
|
+
async function checkQa(ctx) {
|
|
983
|
+
const name = 'QA (qa.command e planos das Features em 🧪 QA)';
|
|
984
|
+
const notes = [];
|
|
985
|
+
let status = 'ok';
|
|
986
|
+
|
|
987
|
+
const fromEnv = process.env.SPEC_WAVE_QA_CMD;
|
|
988
|
+
const fromConfig = ctx.cfg?.qa?.command;
|
|
989
|
+
if (fromEnv) {
|
|
990
|
+
notes.push(`Executor definido via env SPEC_WAVE_QA_CMD${fromConfig ? ' (sobrepõe o qa.command do config)' : ''}: ${fromEnv}`);
|
|
991
|
+
} else if (fromConfig) {
|
|
992
|
+
notes.push(`Executor definido no ${CONFIG_FILE}: ${fromConfig}`);
|
|
993
|
+
} else {
|
|
994
|
+
// "!" e não "✗" de propósito: sem executor o `qa` ainda monta o contexto e
|
|
995
|
+
// orienta — é configuração pendente, não ambiente quebrado.
|
|
996
|
+
status = 'warn';
|
|
997
|
+
notes.push(
|
|
998
|
+
'Nenhum executor configurado — `qa <issue>` só monta o contexto, sem executar os cenários.\n' +
|
|
999
|
+
`Defina "qa": { "command": "..." } no ${CONFIG_FILE} (ou a env SPEC_WAVE_QA_CMD).\n` +
|
|
1000
|
+
'Placeholders: {contextFile} {qaPlanFile} {specFile} {issue} {type} {title}. Exemplos por agente:\n' +
|
|
1001
|
+
' Claude Code: claude -p "Execute o QA descrito em {contextFile}"\n' +
|
|
1002
|
+
' opencode: opencode run "Execute o QA descrito em {contextFile}"\n' +
|
|
1003
|
+
' Codex: codex exec "Execute o QA descrito em {contextFile}"\n' +
|
|
1004
|
+
' Copilot CLI: copilot -p "Execute o QA descrito em {contextFile}" --allow-all-tools'
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (ctx.token && ctx.cfg?.project?.id && ctx.root) {
|
|
1009
|
+
try {
|
|
1010
|
+
const items = await listProjectItems(ctx.token, ctx.cfg.project.id);
|
|
1011
|
+
const semPlano = featuresInQaWithoutPlan({
|
|
1012
|
+
items,
|
|
1013
|
+
exists: rel => existsSync(path.join(ctx.root, rel)),
|
|
1014
|
+
});
|
|
1015
|
+
if (semPlano.length > 0) {
|
|
1016
|
+
status = 'warn';
|
|
1017
|
+
notes.push(
|
|
1018
|
+
`Feature(s) em ${STAGE_QA} sem qa-plan.md no clone: ` +
|
|
1019
|
+
semPlano.map(f => `#${f.number} (${f.rel})`).join(', ') +
|
|
1020
|
+
' — gere com a label `spec-wave:qa` (ou rode `git pull` se o plano já foi mergeado).'
|
|
1021
|
+
);
|
|
1022
|
+
} else {
|
|
1023
|
+
notes.push(`Nenhuma Feature em ${STAGE_QA} sem plano de QA.`);
|
|
1024
|
+
}
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
notes.push(`Features em ${STAGE_QA} não verificáveis agora: ${err.message}`);
|
|
1027
|
+
}
|
|
1028
|
+
} else {
|
|
1029
|
+
notes.push('Features em 🧪 QA não verificadas (sem token, project ou raiz do repo).');
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return { name, status, detail: notes.join('\n') };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
955
1035
|
// Modo de execução: o config e a variável do repositório precisam concordar.
|
|
956
1036
|
// Discordar não é detalhe — é o usuário achando que desligou os workflows e
|
|
957
1037
|
// continuando a pagar minutos (ou o contrário: tudo pulado e nada rodando).
|
|
@@ -1277,6 +1357,7 @@ export async function doctor() {
|
|
|
1277
1357
|
checkAi,
|
|
1278
1358
|
checkDecompositions,
|
|
1279
1359
|
checkSpecKit,
|
|
1360
|
+
checkQa,
|
|
1280
1361
|
checkWorkflows,
|
|
1281
1362
|
checkPrPublishing,
|
|
1282
1363
|
checkExecutionMode,
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Gera (ou re-critica) o plano de QA — docs/features/<slug>/qa-plan.md.
|
|
2
|
+
//
|
|
3
|
+
// Espelho do rascunho do decompose, com a mesma semântica de preservação:
|
|
4
|
+
// arquivo AUSENTE → gera via IA, publica em PR e critica; arquivo PRESENTE →
|
|
5
|
+
// valida + critica COMO ESTÁ, sem regerar (edições manuais são o esperado, não
|
|
6
|
+
// a exceção). Para gerar outro do zero: apagar o arquivo e reaplicar
|
|
7
|
+
// `spec-wave:qa`.
|
|
8
|
+
//
|
|
9
|
+
// A ordem interna importa (spec §6.1): a validação DETERMINÍSTICA roda ANTES da
|
|
10
|
+
// crítica — é barata e pega o que não precisa de julgamento (Story descoberta,
|
|
11
|
+
// referência a issue que não é sub-issue, campo obrigatório vazio, truncamento).
|
|
12
|
+
// Só um plano estruturalmente válido gasta uma chamada de crítica.
|
|
13
|
+
|
|
14
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
15
|
+
import {
|
|
16
|
+
getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments, getRepoDefaultBranch,
|
|
17
|
+
} from '../api/github-rest.mjs';
|
|
18
|
+
import { getIssueParent, listSubIssues } from '../api/github-graphql.mjs';
|
|
19
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
20
|
+
import { generateDocument } from '../lib/claude.mjs';
|
|
21
|
+
import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
|
|
22
|
+
import {
|
|
23
|
+
runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
|
|
24
|
+
parseCritiqueDecisions, applyCritiqueDecisions, renderRiskAcceptedComment,
|
|
25
|
+
} from '../lib/critique.mjs';
|
|
26
|
+
import { recordUsage } from '../lib/usage-report.mjs';
|
|
27
|
+
import { resolveDocDir } from '../lib/doc-paths.mjs';
|
|
28
|
+
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
29
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
30
|
+
import { resolveFlowContext } from '../lib/flow-run.mjs';
|
|
31
|
+
import { publishArtifact } from '../lib/artifact-publish.mjs';
|
|
32
|
+
import { loadArtifact, isAwaitingMerge } from '../lib/doc-source.mjs';
|
|
33
|
+
import { awaitingMergeBlock, renderPrLine } from '../lib/artifact-pr.mjs';
|
|
34
|
+
import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
|
|
35
|
+
import { buildTechContext } from '../lib/tech-context.mjs';
|
|
36
|
+
import {
|
|
37
|
+
QA_PLAN_FILE, parseQaPlanDoc, renderQaPlanDoc, validateQaPlan,
|
|
38
|
+
} from '../lib/qa-plan-doc.mjs';
|
|
39
|
+
import {
|
|
40
|
+
LABEL_QA, LABEL_QA_READY, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN,
|
|
41
|
+
LABEL_RISK_ACCEPTED, DEFAULT_MAX_CRITIQUE_ATTEMPTS, TARGET_LANGUAGE, labelNames,
|
|
42
|
+
} from '../config.mjs';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parada por decisão do fluxo: o comentário já foi postado; o erro precisa
|
|
46
|
+
* propagar para o Action ficar vermelho — mesmo contrato do decompose.
|
|
47
|
+
*/
|
|
48
|
+
class QaPlanBlockedError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'QaPlanBlockedError';
|
|
52
|
+
this.blocked = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Sobe a cadeia de pais até achar a Feature (mesma mecânica do implement).
|
|
57
|
+
async function resolveParentFeature(token, startNodeId) {
|
|
58
|
+
let current = startNodeId;
|
|
59
|
+
for (let depth = 0; depth < 5 && current; depth++) {
|
|
60
|
+
const parent = await getIssueParent(token, current);
|
|
61
|
+
if (!parent) return null;
|
|
62
|
+
if (detectIssueType({ title: parent.title }) === 'Feature') return parent;
|
|
63
|
+
current = parent.nodeId;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function generateQaPlan({ issueNumber }) {
|
|
69
|
+
const token = await resolveToken();
|
|
70
|
+
const { owner, repo, mode: runMode } = resolveFlowContext({ command: 'generate-qa-plan' });
|
|
71
|
+
console.log(`Modo de execução: ${runMode}`);
|
|
72
|
+
|
|
73
|
+
const number = parseInt(issueNumber, 10);
|
|
74
|
+
const issue = await getIssue(token, owner, repo, number);
|
|
75
|
+
const type = detectIssueType(issue);
|
|
76
|
+
const labels = labelNames(issue);
|
|
77
|
+
|
|
78
|
+
// ── Escopo por tipo (spec §3): o plano é POR FEATURE, arquivo único ────────
|
|
79
|
+
if (type !== 'Feature') {
|
|
80
|
+
console.log(`Issue #${number} é ${type || 'desconhecido'} — qa-plan é gerado só para Feature.`);
|
|
81
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
82
|
+
if (type === 'Story') {
|
|
83
|
+
const feature = await resolveParentFeature(token, issue.node_id).catch(() => null);
|
|
84
|
+
await commentOnIssue(token, owner, repo, number,
|
|
85
|
+
`ℹ️ **O plano de QA é por Feature, não por Story** (D-QA1). ` +
|
|
86
|
+
(feature
|
|
87
|
+
? `Aplique \`${LABEL_QA}\` na Feature-pai **#${feature.number}** — o plano dela ` +
|
|
88
|
+
`cobre esta Story com seções \`## Cenário N — Story #${number}\`.`
|
|
89
|
+
: 'Não encontrei a Feature-pai desta Story — vincule-a como sub-issue de uma Feature e ' +
|
|
90
|
+
`aplique \`${LABEL_QA}\` lá.`) +
|
|
91
|
+
`\n\nPara **executar** os cenários desta Story: \`npx @spec-wave/cli@latest qa ${number}\`.`
|
|
92
|
+
).catch(() => {});
|
|
93
|
+
} else if (type === 'Bug') {
|
|
94
|
+
await commentOnIssue(token, owner, repo, number,
|
|
95
|
+
`ℹ️ **Bug não gera plano de QA:** o QA de um Bug usa a seção ` +
|
|
96
|
+
'`Teste de Regressão` do próprio `bug.md`. ' +
|
|
97
|
+
`Execute direto: \`npx @spec-wave/cli@latest qa ${number}\`.`
|
|
98
|
+
).catch(() => {});
|
|
99
|
+
} else {
|
|
100
|
+
await commentOnIssue(token, owner, repo, number,
|
|
101
|
+
`ℹ️ **qa-plan não se aplica a ${type || 'este tipo'}.** ` +
|
|
102
|
+
'O plano de QA é gerado para **Features** (um arquivo, com cenários por Story).'
|
|
103
|
+
).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Portões humanos (spec §6.1 passo 2): falha imediata, com remediação ────
|
|
109
|
+
for (const [label, remedio] of [
|
|
110
|
+
[LABEL_NEEDS_HUMAN, 'revise os documentos e remova a label (e a `spec-wave:critique-failed`, se houver)'],
|
|
111
|
+
[LABEL_CRITIQUE_FAILED, 'corrija o documento apontado no comentário 🔎 e remova a label'],
|
|
112
|
+
]) {
|
|
113
|
+
if (labels.includes(label)) {
|
|
114
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
115
|
+
await commentOnIssue(token, owner, repo, number,
|
|
116
|
+
`⏸️ **generate-qa-plan parado:** a issue tem a label \`${label}\` — ${remedio}, ` +
|
|
117
|
+
`e reaplique \`${LABEL_QA}\`.`
|
|
118
|
+
).catch(() => {});
|
|
119
|
+
throw new QaPlanBlockedError(`portão humano pendente: ${label}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { config, root } = loadConfig();
|
|
124
|
+
const { rel: dirRel } = resolveDocDir(root, issue, type);
|
|
125
|
+
const docRel = `${dirRel}/${QA_PLAN_FILE}`;
|
|
126
|
+
const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
|
|
127
|
+
|
|
128
|
+
const ler = (doc, pathRel) => loadArtifact({
|
|
129
|
+
token, owner, repo, root, pathRel, doc, issueNumber: number, base,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── spec.md e plan.md são a matéria-prima (spec §6.1 passo 3) ──────────────
|
|
133
|
+
const spec = await ler('spec', `${dirRel}/spec.md`);
|
|
134
|
+
const plan = await ler('plan', `${dirRel}/plan.md`);
|
|
135
|
+
for (const [nome, doc] of [['spec.md', spec], ['plan.md', plan]]) {
|
|
136
|
+
if (isAwaitingMerge(doc.state)) {
|
|
137
|
+
const bloqueio = awaitingMergeBlock({
|
|
138
|
+
pathRel: `${dirRel}/${nome}`, state: doc.state, pr: doc.pr, branch: doc.ref,
|
|
139
|
+
});
|
|
140
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
141
|
+
await commentOnIssue(token, owner, repo, number,
|
|
142
|
+
`⏸️ **generate-qa-plan parado:** ${bloqueio.message}\n\n${bloqueio.unblock}\n\n` +
|
|
143
|
+
`Reaplique \`${LABEL_QA}\` depois do merge.`
|
|
144
|
+
).catch(() => {});
|
|
145
|
+
throw new QaPlanBlockedError(bloqueio.message);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const ausentes = [['spec.md', spec], ['plan.md', plan]]
|
|
149
|
+
.filter(([, doc]) => doc.content == null).map(([nome]) => nome);
|
|
150
|
+
if (ausentes.length > 0) {
|
|
151
|
+
console.log(`Documentos ausentes: ${ausentes.join(', ')} — nada gerado.`);
|
|
152
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
153
|
+
await commentOnIssue(token, owner, repo, number,
|
|
154
|
+
`ℹ️ **Plano de QA não gerado:** falta ${ausentes.map(a => `\`${a}\``).join(' e ')} em \`${dirRel}/\`. ` +
|
|
155
|
+
'O plano deriva dos critérios de aceite da spec — gere e valide spec/plan primeiro, ' +
|
|
156
|
+
`depois reaplique \`${LABEL_QA}\`.`
|
|
157
|
+
).catch(() => {});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Stories da Feature: o plano mapeia cenário → Story real ────────────────
|
|
162
|
+
let subIssues = [];
|
|
163
|
+
try {
|
|
164
|
+
subIssues = await listSubIssues(token, issue.node_id);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Sem a árvore não há como validar as referências `Story #X` — e um plano
|
|
167
|
+
// publicado sem essa checagem é o que a validação determinística existe
|
|
168
|
+
// para impedir.
|
|
169
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
170
|
+
await commentOnIssue(token, owner, repo, number,
|
|
171
|
+
`❌ **generate-qa-plan falhou:** não consegui listar as sub-issues da Feature ` +
|
|
172
|
+
`(${err.message}). Reaplique \`${LABEL_QA}\` para tentar de novo.`
|
|
173
|
+
).catch(() => {});
|
|
174
|
+
throw new QaPlanBlockedError(`sub-issues não legíveis: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
const stories = subIssues.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
|
|
177
|
+
if (stories.length === 0) {
|
|
178
|
+
console.log('Feature sem Stories — nada a planejar.');
|
|
179
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
180
|
+
await commentOnIssue(token, owner, repo, number,
|
|
181
|
+
'ℹ️ **Plano de QA não gerado:** a Feature não tem Stories (sub-issues). ' +
|
|
182
|
+
`Decomponha primeiro (\`spec-wave:decompose\`) e reaplique \`${LABEL_QA}\`.`
|
|
183
|
+
).catch(() => {});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const usageEntries = [];
|
|
188
|
+
try {
|
|
189
|
+
await runDraftOrCritique({
|
|
190
|
+
token, owner, repo, issue, number, labels, config, root, runMode,
|
|
191
|
+
dirRel, docRel, base, spec, plan, stories, usage: usageEntries, ler,
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (!err.blocked) {
|
|
195
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
196
|
+
await commentOnIssue(token, owner, repo, number,
|
|
197
|
+
'❌ **Falha ao gerar o plano de QA**\n\n' +
|
|
198
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
199
|
+
`A label \`${LABEL_QA}\` foi removida para destravar o gatilho — ` +
|
|
200
|
+
'adicione-a de novo para tentar outra vez.'
|
|
201
|
+
).catch(() => {});
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
} finally {
|
|
205
|
+
await recordUsage({ token, owner, repo, issueNumber: number, entries: usageEntries });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function runDraftOrCritique(ctx) {
|
|
210
|
+
const {
|
|
211
|
+
token, owner, repo, issue, number, labels, config, root, runMode,
|
|
212
|
+
dirRel, docRel, base, spec, plan, stories, usage, ler,
|
|
213
|
+
} = ctx;
|
|
214
|
+
|
|
215
|
+
// Plano existente é preservado COMO ESTÁ — inclusive o que vive num PR ainda
|
|
216
|
+
// não mergeado (é lá que o revisor edita). Mesma invariante do decompose.
|
|
217
|
+
const existente = await ler('qa-plan', docRel);
|
|
218
|
+
|
|
219
|
+
let markdown;
|
|
220
|
+
let publicado = null;
|
|
221
|
+
if (existente.content != null) {
|
|
222
|
+
const onde = existente.state === 'pending-pr'
|
|
223
|
+
? `no PR #${existente.pr?.number} (ainda não mergeado)`
|
|
224
|
+
: existente.state === 'branch-only'
|
|
225
|
+
? `na branch ${existente.ref} (sem PR aberto)`
|
|
226
|
+
: docRel;
|
|
227
|
+
console.log(`Plano encontrado ${onde} — validando e criticando o arquivo como está (sem regerar).`);
|
|
228
|
+
markdown = existente.content;
|
|
229
|
+
if (isAwaitingMerge(existente.state)) {
|
|
230
|
+
publicado = { pr: existente.pr, branch: existente.ref, unchanged: true };
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
console.log(`Gerando plano de QA para: ${issue.title}`);
|
|
234
|
+
const payload = {
|
|
235
|
+
feature: { number, title: issue.title },
|
|
236
|
+
stories: stories.map(s => ({ number: s.number, title: s.title, body: s.body || '' })),
|
|
237
|
+
spec: spec.content,
|
|
238
|
+
plan: plan.content,
|
|
239
|
+
};
|
|
240
|
+
const userContent =
|
|
241
|
+
'Gere o qa-plan.md a partir deste payload JSON. Os números de Story dos títulos ' +
|
|
242
|
+
'"## Cenário N — Story #X" DEVEM sair da lista `stories` abaixo — nunca invente números.\n\n' +
|
|
243
|
+
JSON.stringify(payload, null, 2);
|
|
244
|
+
|
|
245
|
+
const qaPrompt = loadPrompt('qa', { cwd: root });
|
|
246
|
+
const { content: bruto } = await generateDocument(
|
|
247
|
+
systemPromptWithTools(qaPrompt),
|
|
248
|
+
userContent,
|
|
249
|
+
{
|
|
250
|
+
action: 'qa', maxTurns: qaPrompt.maxTurns, labels, usage,
|
|
251
|
+
lint: { lang: TARGET_LANGUAGE }, withReport: true,
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// parse + render canônico: o arquivo nasce com marcador, numeração por
|
|
256
|
+
// posição e H1 — mesmo que o modelo tenha errado qualquer um dos três.
|
|
257
|
+
let gerado;
|
|
258
|
+
try {
|
|
259
|
+
gerado = parseQaPlanDoc(unwrapGeneratedDoc(bruto), { requireMarker: false });
|
|
260
|
+
} catch (err) {
|
|
261
|
+
throw new Error(`o modelo devolveu um plano fora do formato: ${err.message}`);
|
|
262
|
+
}
|
|
263
|
+
markdown = renderQaPlanDoc({
|
|
264
|
+
title: issue.title,
|
|
265
|
+
issueNumber: number,
|
|
266
|
+
scenarios: gerado.scenarios.map(s => ({ story: s.story, body: s.body })),
|
|
267
|
+
});
|
|
268
|
+
publicado = await publishArtifact({
|
|
269
|
+
token, owner, repo, doc: 'qa-plan',
|
|
270
|
+
issueNumber: number, issueTitle: issue.title, issueUrl: issue.html_url,
|
|
271
|
+
pathRel: docRel, content: markdown, base,
|
|
272
|
+
});
|
|
273
|
+
if (publicado.warning) console.warn(`⚠️ ${publicado.warning}`);
|
|
274
|
+
console.log(`Plano publicado em ${publicado.branch} (${docRel}).`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const refDoDocumento = publicado?.branch || existente.ref || undefined;
|
|
278
|
+
const blobUrl = docBlobUrl({
|
|
279
|
+
owner, repo, pathRel: docRel, mode: runMode, root, ref: refDoDocumento,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── Estrutura antes de qualquer gasto: parse do arquivo como está ──────────
|
|
283
|
+
let doc;
|
|
284
|
+
try {
|
|
285
|
+
doc = parseQaPlanDoc(markdown);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
await commentOnIssue(token, owner, repo, number,
|
|
288
|
+
`❌ **Não consegui ler o plano de QA.**\n\n` +
|
|
289
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
290
|
+
`Corrija [\`${docRel}\`](${blobUrl}) e reaplique \`${LABEL_QA}\`. ` +
|
|
291
|
+
'Para começar de novo do zero, apague o arquivo.'
|
|
292
|
+
).catch(() => {});
|
|
293
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
294
|
+
throw new QaPlanBlockedError(err.message);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Validação determinística (spec §6.1 passo 5) — antes da crítica ────────
|
|
298
|
+
const errosDeterministicos = validateQaPlan({ doc, stories, content: markdown });
|
|
299
|
+
if (errosDeterministicos.length > 0) {
|
|
300
|
+
console.log(`Validação determinística reprovou: ${errosDeterministicos.length} problema(s) — crítica não executada.`);
|
|
301
|
+
await commentOnIssue(token, owner, repo, number,
|
|
302
|
+
'⚠️ **Plano de QA reprovado na validação determinística** (a crítica adversarial ' +
|
|
303
|
+
'nem chegou a rodar — nada de IA foi gasto):\n\n' +
|
|
304
|
+
errosDeterministicos.map(e => `- ${e}`).join('\n') +
|
|
305
|
+
`\n\nCorrija [\`${docRel}\`](${blobUrl}) e reaplique \`${LABEL_QA}\`.`
|
|
306
|
+
).catch(() => {});
|
|
307
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
308
|
+
throw new QaPlanBlockedError(
|
|
309
|
+
`validação determinística reprovou o plano: ${errosDeterministicos[0]}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
console.log(`Plano válido: ${doc.scenarios.length} cenário(s) cobrindo ${stories.length} Story(ies).`);
|
|
313
|
+
|
|
314
|
+
// ── Crítica adversarial (kind 'qa') — mesma máquina de tentativas ──────────
|
|
315
|
+
const comments = await listIssueComments(token, owner, repo, number).catch(err => {
|
|
316
|
+
console.warn(`Não foi possível listar comentários: ${err.message} — contador de tentativas em 1.`);
|
|
317
|
+
return [];
|
|
318
|
+
});
|
|
319
|
+
const maxAttempts = Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
|
|
320
|
+
? config.ai.maxCritiqueAttempts
|
|
321
|
+
: DEFAULT_MAX_CRITIQUE_ATTEMPTS;
|
|
322
|
+
const escalationModel = config?.ai?.escalationModel || null;
|
|
323
|
+
const { attempt, blocked } = resolveCritiqueAttempt({ comments, labels, kind: 'qa', maxAttempts });
|
|
324
|
+
const decisions = parseCritiqueDecisions(comments, 'qa');
|
|
325
|
+
|
|
326
|
+
if (blocked) {
|
|
327
|
+
console.log(`Teto de ${maxAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
|
|
328
|
+
await commentOnIssue(token, owner, repo, number,
|
|
329
|
+
renderNeedsHumanComment({ kind: 'qa', attempt, maxAttempts, escalationModel })).catch(() => {});
|
|
330
|
+
await addLabel(token, owner, repo, number, LABEL_NEEDS_HUMAN);
|
|
331
|
+
await removeLabel(token, owner, repo, number, LABEL_QA);
|
|
332
|
+
throw new QaPlanBlockedError(
|
|
333
|
+
`A crítica reprovou ${maxAttempts - 1}x seguidas. Label ${LABEL_NEEDS_HUMAN} aplicada.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const model = attempt > 1 ? (escalationModel || undefined) : undefined;
|
|
338
|
+
if (model) console.log(`Tentativa ${attempt}: escalando a crítica para ${model}.`);
|
|
339
|
+
|
|
340
|
+
// Contexto da crítica (spec §6.1 passo 6): spec, plan, decomposition e
|
|
341
|
+
// tech_context — os documentos contra os quais o plano precisa se sustentar.
|
|
342
|
+
const decomposition = await ler('decomposition', `${dirRel}/decomposition.md`).catch(() => null);
|
|
343
|
+
const techContextYaml = buildTechContext({ issueBody: issue.body || '', cwd: root || process.cwd() }).yaml;
|
|
344
|
+
|
|
345
|
+
let critique;
|
|
346
|
+
try {
|
|
347
|
+
critique = await runCritique({
|
|
348
|
+
kind: 'qa',
|
|
349
|
+
spec: spec.content,
|
|
350
|
+
plan: plan.content,
|
|
351
|
+
decomposition: decomposition?.content || undefined,
|
|
352
|
+
techContextYaml,
|
|
353
|
+
qaPlan: markdown,
|
|
354
|
+
decisions,
|
|
355
|
+
attempt, maxAttempts, model, labels, usage, cwd: root || undefined,
|
|
356
|
+
});
|
|
357
|
+
} catch (err) {
|
|
358
|
+
// O verde do QA é automático (D-QA3), então o portão do plano não pode ser
|
|
359
|
+
// pulado: sem crítica não há `qa-ready`, e o run fica vermelho.
|
|
360
|
+
await commentOnIssue(token, owner, repo, number,
|
|
361
|
+
`❌ **A crítica adversarial do plano de QA não concluiu.**\n\n` +
|
|
362
|
+
`\`\`\`\n${err.message}\n\`\`\`\n\n` +
|
|
363
|
+
`O plano está em [\`${docRel}\`](${blobUrl}), mas **sem crítica não há liberação** — ` +
|
|
364
|
+
`o verde do QA avança o board sozinho, e este é o portão. Reaplique \`${LABEL_QA}\`.`
|
|
365
|
+
).catch(() => {});
|
|
366
|
+
await removeLabel(token, owner, repo, number, LABEL_QA).catch(() => {});
|
|
367
|
+
throw new QaPlanBlockedError(`Crítica adversarial não concluiu: ${err.message}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
await commentOnIssue(token, owner, repo, number, critique.markdown)
|
|
371
|
+
.catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
|
|
372
|
+
|
|
373
|
+
const { blocking, accepted } = applyCritiqueDecisions(critique.findings, decisions, 'qa');
|
|
374
|
+
if (accepted.length > 0) {
|
|
375
|
+
await addLabel(token, owner, repo, number, LABEL_RISK_ACCEPTED).catch(() => {});
|
|
376
|
+
await commentOnIssue(token, owner, repo, number,
|
|
377
|
+
renderRiskAcceptedComment({ kind: 'qa', accepted })).catch(() => {});
|
|
378
|
+
}
|
|
379
|
+
if (blocking.length > 0) {
|
|
380
|
+
console.log(`Crítica apontou ${blocking.length} finding(s) GRAVE(s) sem decisão.`);
|
|
381
|
+
await addLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED);
|
|
382
|
+
await removeLabel(token, owner, repo, number, LABEL_QA_READY).catch(() => {});
|
|
383
|
+
await removeLabel(token, owner, repo, number, LABEL_QA);
|
|
384
|
+
throw new QaPlanBlockedError(
|
|
385
|
+
`A crítica adversarial apontou findings graves no plano (tentativa ${attempt}). ` +
|
|
386
|
+
`Corrija ${docRel} e reaplique ${LABEL_QA}.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
await removeLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED).catch(() => {});
|
|
390
|
+
|
|
391
|
+
// ── Liberação: qa-ready = passou na validação + crítica, espera humano ─────
|
|
392
|
+
await addLabel(token, owner, repo, number, LABEL_QA_READY);
|
|
393
|
+
await removeLabel(token, owner, repo, number, LABEL_QA);
|
|
394
|
+
|
|
395
|
+
const porStory = new Map();
|
|
396
|
+
for (const s of doc.scenarios) porStory.set(s.story, (porStory.get(s.story) || 0) + 1);
|
|
397
|
+
const resumo = [...porStory.entries()]
|
|
398
|
+
.map(([story, n]) => `- Story #${story}: ${n} cenário(s)`)
|
|
399
|
+
.join('\n');
|
|
400
|
+
|
|
401
|
+
const pr = publicado?.pr;
|
|
402
|
+
const revisao = pr?.number
|
|
403
|
+
? `${renderPrLine(publicado)}\n\n**Revise o plano no PR** (edite o que precisar — as edições ` +
|
|
404
|
+
'são preservadas) e faça o merge. '
|
|
405
|
+
: '**Revise o plano** (edite o que precisar — as edições são preservadas). ';
|
|
406
|
+
|
|
407
|
+
await commentOnIssue(token, owner, repo, number,
|
|
408
|
+
`🧪 **Plano de QA pronto para revisão humana** (${doc.scenarios.length} cenário(s)).\n\n` +
|
|
409
|
+
`📄 Arquivo: [\`${docRel}\`](${blobUrl})\n\n${resumo}\n\n` +
|
|
410
|
+
'A crítica adversarial não encontrou problemas graves e a label ' +
|
|
411
|
+
`\`${LABEL_QA_READY}\` foi aplicada — **ela é o portão humano**: o veredito verde da ` +
|
|
412
|
+
'execução avança a Etapa sozinho, então revise os cenários ANTES de rodar.\n\n' +
|
|
413
|
+
revisao +
|
|
414
|
+
'Depois execute localmente:\n' +
|
|
415
|
+
`\`\`\`\nnpx @spec-wave/cli@latest qa ${number} --dry-run\nnpx @spec-wave/cli@latest qa ${number}\n\`\`\`\n` +
|
|
416
|
+
`Para uma nova crítica depois de editar, reaplique \`${LABEL_QA}\` — o arquivo é ` +
|
|
417
|
+
'criticado como está, sem ser regerado. Para gerar outro do zero, apague o arquivo.'
|
|
418
|
+
).catch(err => console.warn(`Falha ao comentar a liberação: ${err.message}`));
|
|
419
|
+
console.log(`Plano de QA liberado para revisão (${doc.scenarios.length} cenário(s)).`);
|
|
420
|
+
return { pr: pr || null };
|
|
421
|
+
}
|