@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/package.json
CHANGED
|
@@ -343,6 +343,7 @@ export async function listSubIssues(token, issueNodeId) {
|
|
|
343
343
|
createdAt
|
|
344
344
|
state
|
|
345
345
|
stateReason
|
|
346
|
+
milestone { number title }
|
|
346
347
|
labels(first: 20) { nodes { name } }
|
|
347
348
|
}
|
|
348
349
|
}
|
|
@@ -363,10 +364,46 @@ export async function listSubIssues(token, issueNodeId) {
|
|
|
363
364
|
// distinguir "entregue" de "descartada no rescopo".
|
|
364
365
|
state: (n.state || '').toLowerCase() || null,
|
|
365
366
|
stateReason: (n.stateReason || '').toLowerCase() || null,
|
|
367
|
+
// O `order` compara com o milestone do pai: sub-issue sem milestone (ou em
|
|
368
|
+
// outro) é tão invisível numa visão de release quanto Etapa vazia no board.
|
|
369
|
+
milestone: n.milestone ? { number: n.milestone.number, title: n.milestone.title } : null,
|
|
366
370
|
labels: (n.labels?.nodes || []).map(l => l.name),
|
|
367
371
|
}));
|
|
368
372
|
}
|
|
369
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Os PRs que o GitHub reconhece como fechadores DESTA issue — o inverso de
|
|
376
|
+
* listLinkedIssuesForPR, pela mesma fonte de vínculo (`Closes #N` + UI). É como
|
|
377
|
+
* o `merge` descobre o PR de cada Story sem depender de convenção de nome de
|
|
378
|
+
* branch. `includeClosedPrs` de propósito: PR já mergeado é o que diz que a
|
|
379
|
+
* Story está entregue.
|
|
380
|
+
*
|
|
381
|
+
* @returns {Promise<Array<{number, state, merged, isDraft, baseRefName, headRefName}>>}
|
|
382
|
+
*/
|
|
383
|
+
export async function listIssuePullRequests(token, issueNodeId) {
|
|
384
|
+
const client = makeClient(token);
|
|
385
|
+
const result = await client(`
|
|
386
|
+
query IssuePRs($id: ID!) {
|
|
387
|
+
node(id: $id) {
|
|
388
|
+
... on Issue {
|
|
389
|
+
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
|
|
390
|
+
nodes { number state merged isDraft baseRefName headRefName }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
`, { id: issueNodeId });
|
|
396
|
+
const nodes = result.node?.closedByPullRequestsReferences?.nodes || [];
|
|
397
|
+
return nodes.map(n => ({
|
|
398
|
+
number: n.number,
|
|
399
|
+
state: (n.state || '').toLowerCase(), // open | closed | merged
|
|
400
|
+
merged: Boolean(n.merged),
|
|
401
|
+
isDraft: Boolean(n.isDraft),
|
|
402
|
+
baseRefName: n.baseRefName || null,
|
|
403
|
+
headRefName: n.headRefName || null,
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
|
|
370
407
|
/**
|
|
371
408
|
* Issues que o GitHub reconhece como fechadas por este PR
|
|
372
409
|
* (`Closes #N` e o vínculo feito pela UI). É a MESMA lista que fecha as issues
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -511,6 +511,27 @@ export async function getFileContent(token, owner, repo, path, ref) {
|
|
|
511
511
|
}
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
// Reaponta a BASE de um PR aberto. Usado pelo `merge` numa pilha de Stories:
|
|
515
|
+
// depois que o PR anterior mergeia, o dependente é reapontado para a default
|
|
516
|
+
// ANTES de qualquer branch ser apagada — apagar primeiro foi o que fechou um
|
|
517
|
+
// PR empilhado sem volta.
|
|
518
|
+
export async function updatePRBase(token, owner, repo, prNumber, base) {
|
|
519
|
+
const octokit = makeOctokit(token);
|
|
520
|
+
await octokit.rest.pulls.update({ owner, repo, pull_number: prNumber, base });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Mergeia um PR. `merge` (commit de merge) é o método da pilha de Stories:
|
|
524
|
+
// squash reescreveria o merge-base dos dependentes e o retarget deixaria de
|
|
525
|
+
// ser limpo. O GitHub recusa (405) draft, check obrigatório pendente e
|
|
526
|
+
// conflito — o chamador decide o que fazer com a fila.
|
|
527
|
+
export async function mergePR(token, owner, repo, prNumber, { method = 'merge' } = {}) {
|
|
528
|
+
const octokit = makeOctokit(token);
|
|
529
|
+
const res = await octokit.rest.pulls.merge({
|
|
530
|
+
owner, repo, pull_number: prNumber, merge_method: method,
|
|
531
|
+
});
|
|
532
|
+
return { merged: Boolean(res.data.merged), sha: res.data.sha || null };
|
|
533
|
+
}
|
|
534
|
+
|
|
514
535
|
export async function listPullRequestReviews(token, owner, repo, prNumber) {
|
|
515
536
|
const octokit = makeOctokit(token);
|
|
516
537
|
return await octokit.paginate(octokit.rest.pulls.listReviews, {
|
package/src/cli.mjs
CHANGED
|
@@ -147,11 +147,11 @@ export function buildProgram() {
|
|
|
147
147
|
.command('run')
|
|
148
148
|
.description('Executa LOCALMENTE o próximo passo do fluxo (o que a label dispararia no Actions)')
|
|
149
149
|
.argument('[issue]', 'Número da issue (Feature, Bug ou RFC)')
|
|
150
|
-
.option('--pr <n>', 'Modo PR: decide entre code-review e qa pelo estado das reviews')
|
|
150
|
+
.option('--pr <n>', 'Modo PR: decide entre code-review e qa pelo estado das reviews e do merge')
|
|
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)')
|
|
@@ -172,6 +172,17 @@ export function buildProgram() {
|
|
|
172
172
|
await preflight(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
program
|
|
176
|
+
.command('audit')
|
|
177
|
+
.description('Cruza as specs de uma milestone: dependência órfã, bloqueante em milestone posterior, sobreposição com código')
|
|
178
|
+
.requiredOption('--milestone <nome>', 'Título da milestone a auditar')
|
|
179
|
+
.option('--critique', 'Roda também a crítica adversarial de conjunto (uma chamada de modelo por milestone)')
|
|
180
|
+
.option('--json', 'Imprime o relatório em JSON')
|
|
181
|
+
.action(async (options) => {
|
|
182
|
+
const { audit } = await import('./commands/audit.mjs');
|
|
183
|
+
await audit(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
184
|
+
});
|
|
185
|
+
|
|
175
186
|
program
|
|
176
187
|
.command('mode')
|
|
177
188
|
.description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
|
|
@@ -309,11 +320,40 @@ export function buildProgram() {
|
|
|
309
320
|
|
|
310
321
|
program
|
|
311
322
|
.command('qa')
|
|
312
|
-
.description('
|
|
313
|
-
.
|
|
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')
|
|
314
354
|
.action(async (options) => {
|
|
315
|
-
const {
|
|
316
|
-
await
|
|
355
|
+
const { generateQaPlan } = await import('./commands/generate-qa-plan.mjs');
|
|
356
|
+
await generateQaPlan(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
317
357
|
});
|
|
318
358
|
|
|
319
359
|
program
|
|
@@ -336,6 +376,18 @@ export function buildProgram() {
|
|
|
336
376
|
await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
|
|
337
377
|
});
|
|
338
378
|
|
|
379
|
+
program
|
|
380
|
+
.command('merge')
|
|
381
|
+
.description('Mergeia os PRs empilhados das Stories de uma Feature na ordem topológica e move o board até QA')
|
|
382
|
+
.argument('<feature>', 'Número da issue da Feature, ex.: 12 ou #12')
|
|
383
|
+
.option('--yes', 'Executa os merges (sem isso, só mostra o plano)')
|
|
384
|
+
.option('--dry-run', 'Mostra o plano e sai')
|
|
385
|
+
.option('--keep-branches', 'Não apaga as branches das Stories depois do merge')
|
|
386
|
+
.action(async (feature, options) => {
|
|
387
|
+
const { merge } = await import('./commands/merge.mjs');
|
|
388
|
+
await merge({ feature, ...options }).catch(err => { console.error(err.message); process.exit(1); });
|
|
389
|
+
});
|
|
390
|
+
|
|
339
391
|
program
|
|
340
392
|
.command('task')
|
|
341
393
|
.description('Gerencia uma Task no board: start (Status "In Progress") ou done (Done)')
|
|
@@ -408,5 +460,20 @@ export function buildProgram() {
|
|
|
408
460
|
await doctor().catch(err => { console.error(err.message); process.exit(1); });
|
|
409
461
|
});
|
|
410
462
|
|
|
463
|
+
// O mapa por tema no fim do --help: 29 comandos em lista plana dizem O QUE
|
|
464
|
+
// cada um faz, mas não POR ONDE começar nem em que ordem o fluxo anda.
|
|
465
|
+
program.addHelpText('after', `
|
|
466
|
+
Fluxo típico (cada tema, na ordem):
|
|
467
|
+
configurar init · doctor · mode <actions|local> · update
|
|
468
|
+
especificar preflight --milestone → specs (label ou run) → audit --milestone [--critique]
|
|
469
|
+
planejar run <issue> (plan → critique → validate) → move <n> ready
|
|
470
|
+
decompor run <issue> (decompose → --apply) → order <feature>
|
|
471
|
+
implementar implement <issue> · task start/done · story review
|
|
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)
|
|
474
|
+
acompanhar info · order · move · repair-stage
|
|
475
|
+
|
|
476
|
+
Docs: https://astratech-net-br.github.io/spec-wave-cli/ · spec-wave <comando> --help`);
|
|
477
|
+
|
|
411
478
|
return program;
|
|
412
479
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Auditoria de dependências de uma milestone — o passo entre "as specs estão
|
|
2
|
+
// mergeadas" e "vamos gerar os planos".
|
|
3
|
+
//
|
|
4
|
+
// A crítica adversarial roda por Feature; este comando cruza as specs COMO
|
|
5
|
+
// CONJUNTO e responde as três perguntas que hoje só apareciam se alguém lesse
|
|
6
|
+
// as dez juntas: alguma dependência declarada não é criada por ninguém? Alguma
|
|
7
|
+
// bloqueante vive em milestone posterior à de quem depende dela? Alguma spec
|
|
8
|
+
// descreve o que já existe em código?
|
|
9
|
+
//
|
|
10
|
+
// Toda a decisão mora em `lib/spec-audit.mjs` (puro); aqui é só coleta: as
|
|
11
|
+
// Features de todas as milestones (o catálogo do que uma spec pode citar), o
|
|
12
|
+
// conteúdo da spec de cada Feature-alvo nas quatro camadas (loadArtifact — uma
|
|
13
|
+
// spec em PR aberto também é auditável), e a lista de arquivos do repositório
|
|
14
|
+
// para a heurística de código.
|
|
15
|
+
//
|
|
16
|
+
// Comando LOCAL, fora da tabela STEPS de propósito: ele é por milestone, não
|
|
17
|
+
// por issue — não há label que o dispare nem lugar para ela no fluxo.
|
|
18
|
+
|
|
19
|
+
import * as p from '@clack/prompts';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
import { readdirSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
25
|
+
import {
|
|
26
|
+
getIssue, getRepoDefaultBranch, listMilestones, listIssuesByMilestone,
|
|
27
|
+
} from '../api/github-rest.mjs';
|
|
28
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
29
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
30
|
+
import { featureDocPaths } from '../lib/doc-paths.mjs';
|
|
31
|
+
import { loadArtifact } from '../lib/doc-source.mjs';
|
|
32
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
33
|
+
import { runCritique } from '../lib/critique.mjs';
|
|
34
|
+
import { slugify } from '../lib/slugify.mjs';
|
|
35
|
+
import { loadStaticTechContext, TECH_CONTEXT_PATH } from '../lib/tech-context.mjs';
|
|
36
|
+
import {
|
|
37
|
+
auditMilestone, auditVerdict, dependencySection, issueRefs,
|
|
38
|
+
} from '../lib/spec-audit.mjs';
|
|
39
|
+
import { resolveMilestone } from './preflight.mjs';
|
|
40
|
+
|
|
41
|
+
// A heurística de código procura componente/rota, não texto: docs/ casaria com
|
|
42
|
+
// o próprio slug da spec, e um README citando a feature não é implementação.
|
|
43
|
+
const IGNORED_DIRS = new Set([
|
|
44
|
+
'node_modules', 'dist', 'build', 'coverage', 'vendor', 'target', 'tmp', 'docs',
|
|
45
|
+
]);
|
|
46
|
+
const IGNORED_EXTS = new Set(['.md', '.txt', '.lock', '.log', '.svg', '.png', '.jpg']);
|
|
47
|
+
const MAX_FILES = 20000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Caminhos relativos dos arquivos do repositório (I/O — nunca lança).
|
|
51
|
+
* Profundidade e volume limitados: é insumo de heurística, não um índice.
|
|
52
|
+
*/
|
|
53
|
+
export function listRepoFiles(root, { maxFiles = MAX_FILES, maxDepth = 8 } = {}) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const walk = (dir, depth) => {
|
|
56
|
+
if (depth > maxDepth || out.length >= maxFiles) return;
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
if (out.length >= maxFiles) return;
|
|
65
|
+
if (e.name.startsWith('.') || IGNORED_DIRS.has(e.name)) continue;
|
|
66
|
+
const abs = path.join(dir, e.name);
|
|
67
|
+
if (e.isDirectory()) walk(abs, depth + 1);
|
|
68
|
+
else if (e.isFile() && !IGNORED_EXTS.has(path.extname(e.name).toLowerCase())) {
|
|
69
|
+
out.push(path.relative(root, abs));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
walk(root, 0);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function audit({ milestone: milestoneArg, critique = false, json = false } = {}) {
|
|
78
|
+
const falhar = (msg) => {
|
|
79
|
+
if (json) console.log(JSON.stringify({ erro: msg }, null, 2));
|
|
80
|
+
else p.log.error(msg);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (!json) p.intro(chalk.bold('spec-wave audit — dependências da milestone'));
|
|
85
|
+
|
|
86
|
+
const { config, root, error: configError } = loadConfig();
|
|
87
|
+
if (configError || !config?.owner || !config?.repo) {
|
|
88
|
+
return falhar(`${configError || `${CONFIG_FILE} sem owner/repo`} — rode \`spec-wave init\` antes.`);
|
|
89
|
+
}
|
|
90
|
+
const { owner, repo } = config;
|
|
91
|
+
|
|
92
|
+
let token;
|
|
93
|
+
try {
|
|
94
|
+
token = await resolveToken();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return falhar(`Sem token utilizável para ${owner}/${repo}: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const s = json ? null : p.spinner();
|
|
100
|
+
s?.start('Montando o catálogo de Features...');
|
|
101
|
+
|
|
102
|
+
let base;
|
|
103
|
+
let milestones;
|
|
104
|
+
try {
|
|
105
|
+
base = await getRepoDefaultBranch(token, owner, repo);
|
|
106
|
+
milestones = await listMilestones(token, owner, repo);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
s?.stop('');
|
|
109
|
+
return falhar(`Não foi possível ler o repositório ${owner}/${repo}: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
const { milestone, error: msError } = resolveMilestone(milestones, milestoneArg);
|
|
112
|
+
if (msError) {
|
|
113
|
+
s?.stop('');
|
|
114
|
+
return falhar(msError);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// O catálogo vem de TODAS as milestones: a inversão de sequenciamento é
|
|
118
|
+
// exatamente uma dependência que resolve para FORA da milestone-alvo.
|
|
119
|
+
const catalog = [];
|
|
120
|
+
let targetIssues = [];
|
|
121
|
+
try {
|
|
122
|
+
for (const m of milestones) {
|
|
123
|
+
const issues = await listIssuesByMilestone(token, owner, repo, m.number);
|
|
124
|
+
if (m.number === milestone.number) targetIssues = issues;
|
|
125
|
+
for (const i of issues) {
|
|
126
|
+
if (detectIssueType(i) !== 'Feature') continue;
|
|
127
|
+
catalog.push({
|
|
128
|
+
number: i.number,
|
|
129
|
+
title: i.title,
|
|
130
|
+
slug: slugify(i.title),
|
|
131
|
+
closed: i.state === 'closed',
|
|
132
|
+
milestone: { number: m.number, title: m.title, due_on: m.due_on ?? null },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
s?.stop('');
|
|
138
|
+
return falhar(`Não foi possível listar as issues por milestone: ${err.message}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A spec de cada Feature-alvo, nas quatro camadas (disco, base, branch, PR) —
|
|
142
|
+
// em série pelo mesmo motivo do preflight: rate limit secundário.
|
|
143
|
+
const features = [];
|
|
144
|
+
for (const issue of targetIssues) {
|
|
145
|
+
if (detectIssueType(issue) !== 'Feature' || issue.state === 'closed') continue;
|
|
146
|
+
const docs = featureDocPaths(root, issue, 'Feature');
|
|
147
|
+
const { content } = await loadArtifact({
|
|
148
|
+
token, owner, repo, root, base,
|
|
149
|
+
pathRel: docs.spec.rel, doc: 'spec', issueNumber: issue.number,
|
|
150
|
+
});
|
|
151
|
+
features.push({
|
|
152
|
+
number: issue.number,
|
|
153
|
+
title: issue.title,
|
|
154
|
+
slug: slugify(issue.title),
|
|
155
|
+
milestone: { number: milestone.number, title: milestone.title, due_on: milestone.due_on ?? null },
|
|
156
|
+
spec: content,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (features.length === 0) {
|
|
161
|
+
s?.stop('');
|
|
162
|
+
return falhar(`Nenhuma Feature aberta na milestone "${milestone.title}".`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Referências `#N` que o catálogo não conhece: issue sem milestone, Story de
|
|
166
|
+
// outra Feature, ou número que não existe. A API é quem diferencia.
|
|
167
|
+
const conhecidas = new Set(catalog.map(f => f.number));
|
|
168
|
+
const desconhecidas = new Set();
|
|
169
|
+
for (const f of features) {
|
|
170
|
+
const section = f.spec ? dependencySection(f.spec) : null;
|
|
171
|
+
for (const n of issueRefs(section || '')) {
|
|
172
|
+
if (!conhecidas.has(n)) desconhecidas.add(n);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const missingRefs = [];
|
|
176
|
+
for (const n of desconhecidas) {
|
|
177
|
+
const issue = await getIssue(token, owner, repo, n).catch(() => null);
|
|
178
|
+
if (!issue) { missingRefs.push(n); continue; }
|
|
179
|
+
catalog.push({
|
|
180
|
+
number: issue.number,
|
|
181
|
+
title: issue.title,
|
|
182
|
+
slug: slugify(issue.title),
|
|
183
|
+
closed: issue.state === 'closed',
|
|
184
|
+
milestone: issue.milestone
|
|
185
|
+
? { number: issue.milestone.number, title: issue.milestone.title, due_on: issue.milestone.due_on ?? null }
|
|
186
|
+
: null,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// As decisões de modelagem do tech_context entram na auditoria: recurso
|
|
191
|
+
// compartilhado com `criada_por: SEM DONO` é pendência de planejamento, não
|
|
192
|
+
// nota perdida num YAML. Falha de parse é aviso — o arquivo é do usuário.
|
|
193
|
+
const { context: techContext, error: techError } = loadStaticTechContext(root);
|
|
194
|
+
if (techError && !json) {
|
|
195
|
+
p.log.warn(`${TECH_CONTEXT_PATH} não parseou (${techError}) — decisões de modelagem fora da auditoria.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const resultado = auditMilestone({
|
|
199
|
+
features, catalog, missingRefs, files: listRepoFiles(root), techContext,
|
|
200
|
+
});
|
|
201
|
+
const veredito = auditVerdict(resultado);
|
|
202
|
+
s?.stop(`Milestone "${milestone.title}": ${features.length} Feature(s) auditada(s).`);
|
|
203
|
+
|
|
204
|
+
// A crítica de conjunto: UMA chamada de modelo sobre todas as specs juntas,
|
|
205
|
+
// atrás do que o determinístico não pega — a mesma regra contada de dois
|
|
206
|
+
// jeitos. Grave é material (é contradição entre requisitos, por definição);
|
|
207
|
+
// e falhar aqui falha o comando: quem pediu --critique não pode receber um
|
|
208
|
+
// "ok" que só cobriu a metade barata.
|
|
209
|
+
let critica = null;
|
|
210
|
+
if (critique) {
|
|
211
|
+
const comSpec = features.filter(f => f.spec);
|
|
212
|
+
if (comSpec.length < 2) {
|
|
213
|
+
veredito.avisos.push(
|
|
214
|
+
`Crítica de conjunto não rodou: ${comSpec.length} spec(s) disponível(is) — conjunto de um não tem par.`
|
|
215
|
+
);
|
|
216
|
+
} else {
|
|
217
|
+
const sc = json ? null : p.spinner();
|
|
218
|
+
sc?.start(`Crítica de conjunto sobre ${comSpec.length} specs...`);
|
|
219
|
+
try {
|
|
220
|
+
critica = await runCritique({
|
|
221
|
+
kind: 'conjunto',
|
|
222
|
+
specs: comSpec.map(f => ({ number: f.number, title: f.title, content: f.spec })),
|
|
223
|
+
cwd: root,
|
|
224
|
+
standalone: true,
|
|
225
|
+
});
|
|
226
|
+
} catch (err) {
|
|
227
|
+
sc?.stop('');
|
|
228
|
+
return falhar(`A crítica de conjunto falhou: ${err.message}`);
|
|
229
|
+
}
|
|
230
|
+
sc?.stop(`Crítica de conjunto: ${critica.findings.length} finding(s) (modelo ${critica.model}).`);
|
|
231
|
+
for (const f of critica.findings) {
|
|
232
|
+
const onde = (f.features || []).map(n => `#${n}`).join(' × ');
|
|
233
|
+
const linha = `crítica de conjunto${onde ? ` [${onde}]` : ''}: ${f.text}`;
|
|
234
|
+
if (f.severity === 'grave') veredito.materiais.push(linha);
|
|
235
|
+
else veredito.avisos.push(linha);
|
|
236
|
+
}
|
|
237
|
+
if (veredito.materiais.length) veredito.status = 'problema';
|
|
238
|
+
else if (veredito.avisos.length) veredito.status = 'aviso';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const saida = {
|
|
243
|
+
milestone: { title: milestone.title, number: milestone.number },
|
|
244
|
+
features: features.map(f => ({ number: f.number, title: f.title, temSpec: !!f.spec })),
|
|
245
|
+
auditoria: resultado,
|
|
246
|
+
// `markdown` é o comentário pronto para a issue — quem posta (nas DUAS
|
|
247
|
+
// pontas de cada finding) é o agente que dirige o comando, não a CLI.
|
|
248
|
+
critica: critica
|
|
249
|
+
? { model: critica.model, findings: critica.findings, markdown: critica.markdown }
|
|
250
|
+
: null,
|
|
251
|
+
veredito,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
if (json) {
|
|
255
|
+
console.log(JSON.stringify(saida, null, 2));
|
|
256
|
+
if (veredito.status === 'problema') process.exitCode = 1;
|
|
257
|
+
return saida;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const grafoLinhas = resultado.grafo.map(g => {
|
|
261
|
+
const deps = g.dependsOn.length ? ` ← depende de ${g.dependsOn.map(d => `#${d}`).join(', ')}` : '';
|
|
262
|
+
return ` #${g.number}${deps}`;
|
|
263
|
+
});
|
|
264
|
+
if (grafoLinhas.length) {
|
|
265
|
+
p.note(grafoLinhas.join('\n'), 'Dependências entre Features (extraídas das specs)');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const a of veredito.avisos) p.log.warn(a);
|
|
269
|
+
for (const m of veredito.materiais) p.log.error(m);
|
|
270
|
+
|
|
271
|
+
if (veredito.status === 'problema') {
|
|
272
|
+
p.outro(`${veredito.materiais.length} achado(s) material(is). Decida antes de gerar os planos.`);
|
|
273
|
+
process.exitCode = 1;
|
|
274
|
+
return saida;
|
|
275
|
+
}
|
|
276
|
+
p.outro(veredito.status === 'aviso'
|
|
277
|
+
? 'Nada material — confira os avisos antes de seguir.'
|
|
278
|
+
: 'Dependências da milestone fecham. Siga para os planos.');
|
|
279
|
+
return saida;
|
|
280
|
+
}
|
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,
|