@spec-wave/cli 0.27.0 → 0.29.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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/api/github-graphql.mjs +37 -0
  3. package/src/api/github-rest.mjs +48 -0
  4. package/src/cli.mjs +51 -2
  5. package/src/commands/audit.mjs +280 -0
  6. package/src/commands/doctor.mjs +40 -16
  7. package/src/commands/implement.mjs +19 -2
  8. package/src/commands/install-skill.mjs +18 -8
  9. package/src/commands/merge.mjs +292 -0
  10. package/src/commands/move.mjs +26 -11
  11. package/src/commands/order.mjs +42 -0
  12. package/src/commands/preflight.mjs +322 -0
  13. package/src/commands/run.mjs +4 -3
  14. package/src/commands/update.mjs +143 -12
  15. package/src/lib/board.mjs +18 -2
  16. package/src/lib/critique.mjs +64 -8
  17. package/src/lib/pr-branch.mjs +96 -7
  18. package/src/lib/pr-step.mjs +12 -7
  19. package/src/lib/spec-audit.mjs +372 -0
  20. package/src/lib/tech-context.mjs +20 -14
  21. package/src/plugin/.claude-plugin/plugin.json +1 -1
  22. package/src/plugin/README.md +5 -0
  23. package/src/plugin/skills/audit/SKILL.md +34 -0
  24. package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
  25. package/src/plugin/skills/merge/SKILL.md +34 -0
  26. package/src/plugin/skills/order/SKILL.md +1 -0
  27. package/src/plugin/skills/plan/model-prompt.md +1 -0
  28. package/src/plugin/skills/plan/reference/tech-context.md +6 -0
  29. package/src/plugin/skills/preparar-feature/SKILL.md +247 -0
  30. package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
  31. package/src/plugin/skills/preparar-specs/SKILL.md +191 -0
  32. package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
  33. package/src/plugin/skills/preparar-specs/reference/revisao.md +110 -0
  34. package/src/plugin/skills/update/SKILL.md +10 -4
  35. package/src/plugin/skills/workflow/SKILL.md +6 -1
  36. package/src/templates/config/tech_context.yml +13 -0
  37. package/src/templates/skill/SKILL.md +36 -7
  38. package/src/templates/workflows/qa.yml +9 -1
@@ -0,0 +1,292 @@
1
+ // Mergeia os PRs empilhados das Stories de uma Feature, na ordem topológica —
2
+ // o passo final do fluxo, que até aqui era inteiramente manual e frágil.
3
+ //
4
+ // O implement empilha de propósito (cada Story revisável sozinha, diff limpo),
5
+ // mas a pilha torna o merge ordem-dependente: `--delete-branch` no primeiro PR
6
+ // já fechou o segundo sem volta. A sequência segura, que este comando encapsula:
7
+ //
8
+ // para cada PR, na ordem das dependências:
9
+ // 1. reaponta a base para a default (o anterior já mergeou; com merge
10
+ // commit o retarget é limpo — por isso o método é `merge`, não squash)
11
+ // 2. mergeia
12
+ // 3. atualiza o board (code-review + qa — merge move até 🧪 QA)
13
+ // e SÓ NO FIM apaga as branches, quando nenhum PR aberto depende delas.
14
+ //
15
+ // PR em rascunho BLOQUEIA o plano inteiro: marcar pronto é o ato de revisão
16
+ // humana (e o que dispara o CI) — mergear rascunho por cima seria pular a única
17
+ // aprovação que o fluxo tem. Falha no meio para a fila e explica: os merges já
18
+ // feitos ficam, rodar de novo retoma de onde parou (PR mergeado sai do plano).
19
+ import * as p from '@clack/prompts';
20
+ import chalk from 'chalk';
21
+
22
+ import { resolveToken } from '../api/auth.mjs';
23
+ import {
24
+ getIssue, getPR, getRepoDefaultBranch, updatePRBase, mergePR, deleteBranch, listBlockedBy,
25
+ } from '../api/github-rest.mjs';
26
+ import { listSubIssues, listIssuePullRequests } from '../api/github-graphql.mjs';
27
+ import { detectIssueType } from '../lib/issue-type.mjs';
28
+ import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
29
+ import { resolveRepoContext } from '../lib/project-root.mjs';
30
+ import { CONFIG_FILE } from '../config.mjs';
31
+ import { acquireLock, releaseLock } from './run.mjs';
32
+ import { codeReview } from './code-review.mjs';
33
+ import { qa } from './qa.mjs';
34
+
35
+ /**
36
+ * O plano de merge (função PURA — é onde mora toda a decisão).
37
+ *
38
+ * Separa cada Story em um destino: `fila` (PR aberto, pronto, entra na ordem),
39
+ * `concluidas` (PR já mergeado — é o que torna rodar de novo uma RETOMADA),
40
+ * `bloqueios` (rascunho, mais de um PR aberto — parar antes de mergear
41
+ * qualquer coisa, porque merge parcial de uma pilha é o pior estado) e
42
+ * `avisos` (Story sem PR nenhum — fica de fora, mas quem depende dela pode
43
+ * carregar commits que não existem na base).
44
+ *
45
+ * @param {object} params
46
+ * @param {number[]} params.sorted ordem topológica (números de Story)
47
+ * @param {Map<number, {title: string}>} params.byNumber
48
+ * @param {Map<number, Array<{number, state, merged, isDraft, baseRefName, headRefName}>>} params.prsByStory
49
+ * @param {string} params.defaultBranch
50
+ * @returns {{ fila: Array<{story:number, pr:object}>, concluidas: Array<{story:number, pr:object}>,
51
+ * semPr: number[], bloqueios: string[], avisos: string[] }}
52
+ */
53
+ export function planMerge({ sorted = [], byNumber = new Map(), prsByStory = new Map(), defaultBranch } = {}) {
54
+ const fila = [];
55
+ const concluidas = [];
56
+ const semPr = [];
57
+ const bloqueios = [];
58
+ const avisos = [];
59
+ const headsNoPlano = new Set();
60
+
61
+ for (const story of sorted) {
62
+ const prs = prsByStory.get(story) || [];
63
+ const mergeados = prs.filter(pr => pr.merged);
64
+ const abertos = prs.filter(pr => pr.state === 'open');
65
+
66
+ if (mergeados.length > 0 && abertos.length === 0) {
67
+ concluidas.push({ story, pr: mergeados[0] });
68
+ continue;
69
+ }
70
+ if (abertos.length === 0) {
71
+ semPr.push(story);
72
+ continue;
73
+ }
74
+ if (abertos.length > 1) {
75
+ bloqueios.push(
76
+ `Story #${story} tem ${abertos.length} PRs abertos (${abertos.map(x => `#${x.number}`).join(', ')}) — ` +
77
+ 'ambíguo; feche o que não vale antes de mergear.'
78
+ );
79
+ continue;
80
+ }
81
+ const pr = abertos[0];
82
+ if (pr.isDraft) {
83
+ bloqueios.push(
84
+ `PR #${pr.number} (Story #${story}) está em RASCUNHO. Marcar pronto é a revisão humana ` +
85
+ 'e o que dispara o CI — revise e marque pronto antes de mergear.'
86
+ );
87
+ continue;
88
+ }
89
+ // Base que não é a default nem a head de alguém que mergeia antes: ou a
90
+ // pilha está fora de ordem, ou aponta para algo que este plano não conhece.
91
+ if (pr.baseRefName !== defaultBranch && !headsNoPlano.has(pr.baseRefName)) {
92
+ avisos.push(
93
+ `PR #${pr.number} (Story #${story}) tem base "${pr.baseRefName}", que não é a default ` +
94
+ 'nem a branch de um PR anterior da fila — confira a pilha antes de confirmar.'
95
+ );
96
+ }
97
+ headsNoPlano.add(pr.headRefName);
98
+ fila.push({ story, pr });
99
+ }
100
+
101
+ if (semPr.length > 0) {
102
+ avisos.push(
103
+ `Story(ies) sem PR: ${semPr.map(n => `#${n}`).join(', ')} — ficam de fora; ` +
104
+ 'se um PR da fila depender do código delas, o merge vai levar esse código junto.'
105
+ );
106
+ }
107
+
108
+ return { fila, concluidas, semPr, bloqueios, avisos };
109
+ }
110
+
111
+ export async function merge({ feature: featureArg, yes = false, dryRun = false, keepBranches = false } = {}) {
112
+ if (process.env.GITHUB_ACTIONS === 'true') {
113
+ p.log.error('`spec-wave merge` é um comando local — dentro do Actions o merge é decisão humana.');
114
+ process.exitCode = 1;
115
+ return;
116
+ }
117
+
118
+ const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
119
+ if (!Number.isInteger(featureNumber) || featureNumber <= 0) {
120
+ p.log.error(`Feature inválida: "${featureArg}". Use o número da issue, ex.: 12 ou #12.`);
121
+ process.exitCode = 1;
122
+ return;
123
+ }
124
+
125
+ const { owner, repo, root } = resolveRepoContext();
126
+ if (!owner || !repo) {
127
+ p.log.error(
128
+ 'Não foi possível determinar owner/repo.\n' +
129
+ `Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
130
+ );
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+
135
+ let token;
136
+ try {
137
+ token = await resolveToken();
138
+ } catch (err) {
139
+ p.log.error(err.message);
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+
144
+ p.intro(chalk.bold(`spec-wave merge #${featureNumber}`));
145
+ const s = p.spinner();
146
+ s.start('Montando o plano de merge...');
147
+
148
+ let featureIssue;
149
+ let defaultBranch;
150
+ try {
151
+ featureIssue = await getIssue(token, owner, repo, featureNumber);
152
+ defaultBranch = await getRepoDefaultBranch(token, owner, repo);
153
+ } catch (err) {
154
+ s.stop('');
155
+ p.log.error(`Não foi possível ler ${owner}/${repo}#${featureNumber}: ${err.message}`);
156
+ process.exitCode = 1;
157
+ return;
158
+ }
159
+ if (detectIssueType(featureIssue) !== 'Feature') {
160
+ s.stop('');
161
+ p.log.error(`\`spec-wave merge\` só aceita Features. Issue #${featureNumber} é ${detectIssueType(featureIssue) || 'de tipo desconhecido'}.`);
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+
166
+ // As Stories e a ordem — as mesmas fontes do `order`.
167
+ let subs;
168
+ try {
169
+ subs = await listSubIssues(token, featureIssue.node_id);
170
+ } catch (err) {
171
+ s.stop('');
172
+ p.log.error(`Não foi possível listar as sub-issues: ${err.message}`);
173
+ process.exitCode = 1;
174
+ return;
175
+ }
176
+ const stories = subs.filter(x => detectIssueType({ title: x.title, labels: x.labels }) === 'Story');
177
+ if (stories.length === 0) {
178
+ s.stop('');
179
+ p.log.info(`Feature #${featureNumber} não tem Stories — nada a mergear.`);
180
+ p.outro('Nada a fazer.');
181
+ return;
182
+ }
183
+
184
+ const enriched = await Promise.all(stories.map(async (st) => {
185
+ const fromBody = parseDependencies(st.body);
186
+ const fromBlockedBy = (await listBlockedBy(token, owner, repo, st.number).catch(() => []))
187
+ .map(b => b.number);
188
+ return { number: st.number, title: st.title, nodeId: st.nodeId, dependsOn: [...new Set([...fromBody, ...fromBlockedBy])] };
189
+ }));
190
+ const byNumber = new Map(enriched.map(x => [x.number, x]));
191
+ const { order: sorted, cycle } = orderStories(
192
+ enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
193
+ if (cycle.length > 0) {
194
+ s.stop('');
195
+ p.log.error(
196
+ `Ciclo de dependências entre Stories (${cycle.map(n => `#${n}`).join(', ')}) — ` +
197
+ 'não existe ordem de merge. Corrija as linhas "Depende de" e rode de novo.'
198
+ );
199
+ process.exitCode = 1;
200
+ return;
201
+ }
202
+
203
+ const prsByStory = new Map();
204
+ for (const story of sorted) {
205
+ prsByStory.set(story, await listIssuePullRequests(token, byNumber.get(story).nodeId).catch(() => []));
206
+ }
207
+
208
+ const plano = planMerge({ sorted, byNumber, prsByStory, defaultBranch });
209
+ s.stop(`${plano.fila.length} PR(s) a mergear · ${plano.concluidas.length} já mergeado(s).`);
210
+
211
+ const linha = ({ story, pr }) => {
212
+ const retarget = pr.baseRefName !== defaultBranch ? ` ${chalk.dim(`base ${pr.baseRefName} → ${defaultBranch}`)}` : '';
213
+ return ` PR #${pr.number} · Story #${story} ${byNumber.get(story)?.title || ''}${retarget}`;
214
+ };
215
+ if (plano.fila.length > 0) {
216
+ p.note(plano.fila.map(linha).join('\n'), `Ordem de merge (base: ${defaultBranch}, método: merge commit)`);
217
+ }
218
+ for (const a of plano.avisos) p.log.warn(a);
219
+ for (const b of plano.bloqueios) p.log.error(b);
220
+
221
+ if (plano.bloqueios.length > 0) {
222
+ p.outro('Bloqueado — merge parcial de pilha é o pior estado; resolva e rode de novo.');
223
+ process.exitCode = 1;
224
+ return;
225
+ }
226
+ if (plano.fila.length === 0) {
227
+ p.outro(plano.concluidas.length > 0 ? 'Tudo já mergeado.' : 'Nenhum PR aberto para mergear.');
228
+ return;
229
+ }
230
+ if (dryRun || !yes) {
231
+ p.outro(dryRun ? 'Dry-run: nada foi mergeado.' : 'Confirme com `--yes` para mergear nesta ordem.');
232
+ return;
233
+ }
234
+
235
+ // ---------- Execução ----------
236
+ const lock = acquireLock(root, `merge-${featureNumber}`);
237
+ const mergedHeads = [];
238
+ try {
239
+ for (const { story, pr } of plano.fila) {
240
+ // Estado fresco: a fila pode ter envelhecido entre o plano e este ponto.
241
+ const atual = await getPR(token, owner, repo, pr.number);
242
+ if (atual.merged_at) {
243
+ p.log.info(`PR #${pr.number} já estava mergeado — seguindo.`);
244
+ continue;
245
+ }
246
+ if (atual.base?.ref !== defaultBranch) {
247
+ p.log.step(`PR #${pr.number}: base ${atual.base?.ref} → ${defaultBranch}`);
248
+ await updatePRBase(token, owner, repo, pr.number, defaultBranch);
249
+ }
250
+ try {
251
+ await mergePR(token, owner, repo, pr.number, { method: 'merge' });
252
+ } catch (err) {
253
+ // 405 = draft/check pendente/conflito. Parar AQUI preserva a ordem: os
254
+ // dependentes continuam com branch e PR intactos, e rodar de novo retoma.
255
+ p.log.error(
256
+ `Merge do PR #${pr.number} (Story #${story}) recusado: ${err.message}\n` +
257
+ 'Nada depois dele foi mergeado. Resolva (checks, conflito) e rode o comando de novo — ' +
258
+ 'os já mergeados saem do plano sozinhos.'
259
+ );
260
+ process.exitCode = 1;
261
+ return;
262
+ }
263
+ p.log.success(`PR #${pr.number} mergeado (Story #${story}).`);
264
+ mergedHeads.push(pr.headRefName);
265
+
266
+ // Board: merge move até 🧪 QA. Falha aqui não desfaz merge — avisa e segue.
267
+ try {
268
+ await codeReview({ prNumber: String(pr.number) });
269
+ await qa({ prNumber: String(pr.number) });
270
+ } catch (err) {
271
+ p.log.warn(`Board não atualizado para o PR #${pr.number}: ${err.message} — rode \`spec-wave run --pr ${pr.number}\` depois.`);
272
+ }
273
+ }
274
+
275
+ // Branches só no FIM, quando nenhum PR aberto depende delas — apagar antes
276
+ // do retarget do dependente foi o que fechou um PR empilhado sem volta.
277
+ if (!keepBranches) {
278
+ for (const head of mergedHeads) {
279
+ if (!head) continue;
280
+ await deleteBranch(token, owner, repo, head).catch(err =>
281
+ p.log.warn(`Branch ${head} não apagada: ${err.message}`));
282
+ }
283
+ }
284
+ } finally {
285
+ releaseLock(lock);
286
+ }
287
+
288
+ p.outro(
289
+ `${chalk.green('✓')} ${mergedHeads.length} PR(s) mergeado(s) na ordem` +
290
+ (keepBranches ? ' (branches mantidas).' : ', branches apagadas.')
291
+ );
292
+ }
@@ -191,6 +191,14 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
191
191
  // mesmo quando a Etapa não avança — ver ensureWorkItemType.
192
192
  const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
193
193
 
194
+ // A Etapa de PARTIDA, lida antes de escrever: a mensagem de sucesso imprime a
195
+ // transição real ("✅ Ready → 🧪 QA"), não a intenção do comando.
196
+ const lerEtapa = async () => {
197
+ const itemId = await addProjectItem(token, project.id, issue.node_id);
198
+ return etapaField?.id ? await getItemSingleSelectValue(token, itemId, etapaField.id) : null;
199
+ };
200
+ const antes = await lerEtapa().catch(() => null);
201
+
194
202
  let moved;
195
203
  try {
196
204
  moved = await advanceToStage(
@@ -203,24 +211,31 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
203
211
  }
204
212
 
205
213
  if (moved) {
214
+ // Confirmação por leitura — era o usuário quem tinha que "confirmar lendo o
215
+ // board de volta"; agora o comando lê, e uma escrita que não pegou vira
216
+ // erro visível em vez de ✅ mentiroso.
217
+ const depois = await lerEtapa().catch(() => null);
218
+ if (depois !== null && depois !== stage) {
219
+ p.log.error(
220
+ `A escrita não confirmou: o board ainda mostra ${chalk.bold(depois)} ` +
221
+ `(esperado ${chalk.bold(stage)}). Verifique permissões do token no Project.`
222
+ );
223
+ process.exitCode = 1;
224
+ return;
225
+ }
226
+ const confirmada = depois !== null;
206
227
  p.log.success(
207
- `${type || 'Issue'} #${issueNumber} → Etapa ${chalk.bold(stage)} / Status ${chalk.bold(status)}.`
228
+ `${type || 'Issue'} #${issueNumber}: ${chalk.bold(antes || '—')} ${chalk.bold(stage)}` +
229
+ ` / Status ${chalk.bold(status)}` +
230
+ (confirmada ? ' (confirmado por leitura do board)' : ' (leitura de confirmação falhou — confira o board)')
208
231
  );
209
232
  p.outro(`${chalk.green('✓')} ${issue.title}`);
210
233
  return;
211
234
  }
212
235
 
213
236
  // false = já está nessa Etapa ou adiante (ou numa coluna fora da ordem
214
- // canônica). A Etapa NUNCA retrocede — a atual para informar.
215
- let current = null;
216
- if (etapaField?.id) {
217
- try {
218
- const itemId = await addProjectItem(token, project.id, issue.node_id);
219
- current = await getItemSingleSelectValue(token, itemId, etapaField.id);
220
- } catch {
221
- // sem leitura da Etapa — segue com o aviso genérico
222
- }
223
- }
237
+ // canônica). A Etapa NUNCA retrocede — informa a atual, lida antes.
238
+ const current = antes ?? await lerEtapa().catch(() => null);
224
239
  p.log.info(
225
240
  `#${issueNumber} não foi movida: a Etapa nunca retrocede, e ela já está em ` +
226
241
  `${chalk.bold(current || `"${stage}" ou etapa posterior`)}.`
@@ -39,6 +39,37 @@ export function featureLabel(feature) {
39
39
  return `#${feature?.number} ${curto}`.trim();
40
40
  }
41
41
 
42
+ /**
43
+ * Divergências de milestone entre a Feature e suas Stories (função PURA).
44
+ *
45
+ * O `order` é o detector do pós-apply, e até aqui ele só checava a Etapa
46
+ * (`Etapa: —`). Milestone tem o mesmo modo de falha: o apply herda a do pai
47
+ * (resolveInheritedMilestone), mas issues criadas antes dessa herança — ou com
48
+ * ela falhando — nascem sem milestone, invisíveis em qualquer visão de release,
49
+ * e ninguém percebe porque o passo sai verde. Foi assim que ~230 issues
50
+ * nasceram órfãs.
51
+ *
52
+ * Feature sem milestone não gera aviso: não há referência de comparação, e o
53
+ * repositório pode simplesmente não usar milestones.
54
+ *
55
+ * @param {{number?: number, title?: string}|null} featureMilestone
56
+ * @param {Array<{number: number, milestone?: {number: number, title: string}|null}>} stories
57
+ * @returns {string[]} uma linha de aviso por Story divergente
58
+ */
59
+ export function milestoneMismatches(featureMilestone, stories = []) {
60
+ if (!Number.isInteger(featureMilestone?.number)) return [];
61
+ const ref = featureMilestone.title || `#${featureMilestone.number}`;
62
+ const out = [];
63
+ for (const s of stories) {
64
+ if (!Number.isInteger(s?.milestone?.number)) {
65
+ out.push(`#${s.number} está SEM milestone — a Feature está em "${ref}".`);
66
+ } else if (s.milestone.number !== featureMilestone.number) {
67
+ out.push(`#${s.number} está em "${s.milestone.title || `#${s.milestone.number}`}", mas a Feature está em "${ref}".`);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
42
73
  /**
43
74
  * O mapa de execução de várias Features (função PURA).
44
75
  *
@@ -350,5 +381,16 @@ export async function order({ feature: featureArg } = {}) {
350
381
  p.log.warn('Dependências fora de ordem:\n' + outOfOrder.map(w => ` • ${w}`).join('\n'));
351
382
  }
352
383
 
384
+ // 7. Milestone das Stories contra a da Feature — a outra órfã do pós-apply.
385
+ const milestoneWarns = milestoneMismatches(featureIssue.milestone, stories);
386
+ if (milestoneWarns.length > 0) {
387
+ p.log.warn(
388
+ chalk.yellow.bold('⚠ Milestone divergente da Feature:') + '\n' +
389
+ milestoneWarns.map(w => ` • ${w}`).join('\n') + '\n' +
390
+ 'Story fora do milestone da Feature some de toda visão de release. ' +
391
+ 'Corrija na issue (gh issue edit <n> --milestone "<título>").'
392
+ );
393
+ }
394
+
353
395
  p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
354
396
  }