@spec-wave/cli 0.28.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.
- 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 +39 -2
- package/src/commands/audit.mjs +280 -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/run.mjs +4 -3
- package/src/lib/board.mjs +18 -2
- package/src/lib/critique.mjs +64 -8
- package/src/lib/pr-step.mjs +12 -7
- 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/templates/config/tech_context.yml +13 -0
- package/src/templates/skill/SKILL.md +30 -4
- 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
|
+
}
|
package/src/commands/move.mjs
CHANGED
|
@@ -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}
|
|
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 —
|
|
215
|
-
|
|
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, já 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`)}.`
|
package/src/commands/order.mjs
CHANGED
|
@@ -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
|
}
|
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 */ }
|
|
@@ -348,7 +348,8 @@ async function runForPr({ prNumber, dryRun, yes, only, json }) {
|
|
|
348
348
|
process.exitCode = decision.blocked ? 2 : 0;
|
|
349
349
|
return decision;
|
|
350
350
|
}
|
|
351
|
-
|
|
351
|
+
// Mergeado, o pedido de mudanças pré-merge já foi decidido por quem mergeou.
|
|
352
|
+
if (verdict.changesRequestedAfterApproval && !pr.merged_at && !yes) {
|
|
352
353
|
console.log(chalk.yellow('\n⛔ needs-confirmation: há pedido de mudanças além da aprovação. Confirme com `--yes`.'));
|
|
353
354
|
process.exitCode = 2;
|
|
354
355
|
return decision;
|
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)',
|
|
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',
|
|
53
57
|
};
|
|
54
58
|
|
|
55
59
|
// A decomposição virou arquivo revisável (decomposition.md): um finding só é
|
|
@@ -62,6 +66,13 @@ 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
|
+
// No conjunto o achado vive ENTRE documentos: sem dizer quais, o leitor relê a
|
|
70
|
+
// milestone inteira procurando o par. O campo é validado como array de números
|
|
71
|
+
// de issue — é o equivalente da âncora "Story N" para este contexto.
|
|
72
|
+
const FEATURES_RULE = `Cada finding DEVE listar, no campo "features", os números das issues das Features envolvidas
|
|
73
|
+
(ex.: [412, 415] para uma contradição entre as duas; [412] quando o problema é de uma spec só,
|
|
74
|
+
visto à luz das outras). Use EXATAMENTE os números que aparecem nos títulos "## spec.md — #N ..." fornecidos.`;
|
|
75
|
+
|
|
65
76
|
/**
|
|
66
77
|
* Monta o system prompt da crítica: corpo editável + contrato de máquina.
|
|
67
78
|
*
|
|
@@ -79,7 +90,8 @@ Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "###
|
|
|
79
90
|
*/
|
|
80
91
|
function buildSystemPrompt(kind, cwd) {
|
|
81
92
|
const prompt = loadPrompt(KIND_PROMPT[kind] || KIND_PROMPT.plan, ...(cwd ? [{ cwd }] : []));
|
|
82
|
-
const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}`
|
|
93
|
+
const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}`
|
|
94
|
+
: kind === 'conjunto' ? `\n\n${FEATURES_RULE}` : '';
|
|
83
95
|
|
|
84
96
|
const contract = `## Contrato de saída
|
|
85
97
|
|
|
@@ -134,6 +146,17 @@ function critiqueJsonSchema(kind) {
|
|
|
134
146
|
};
|
|
135
147
|
required.push('anchor');
|
|
136
148
|
}
|
|
149
|
+
if (kind === 'conjunto') {
|
|
150
|
+
properties.features = {
|
|
151
|
+
type: 'array',
|
|
152
|
+
items: { type: 'integer' },
|
|
153
|
+
minItems: 1,
|
|
154
|
+
description:
|
|
155
|
+
'Números das issues das Features envolvidas no finding — os "#N" dos títulos ' +
|
|
156
|
+
'"## spec.md — #N ..." fornecidos.',
|
|
157
|
+
};
|
|
158
|
+
required.push('features');
|
|
159
|
+
}
|
|
137
160
|
return {
|
|
138
161
|
type: 'object',
|
|
139
162
|
properties: {
|
|
@@ -245,10 +268,23 @@ export function validateCritiquePayload(payload) {
|
|
|
245
268
|
);
|
|
246
269
|
}
|
|
247
270
|
const extra = itemKeys.filter(
|
|
248
|
-
k => k !== 'severity' && k !== 'text' && k !== 'anchor' && k !== 'quote');
|
|
271
|
+
k => k !== 'severity' && k !== 'text' && k !== 'anchor' && k !== 'quote' && k !== 'features');
|
|
249
272
|
if (extra.length > 0) {
|
|
250
273
|
throw new CritiqueSchemaError(`${at} tem campo(s) não reconhecido(s): ${extra.join(', ')}.`);
|
|
251
274
|
}
|
|
275
|
+
// `features` (kind 'conjunto'): quais issues o finding atravessa. Fora do
|
|
276
|
+
// formato é erro de schema — a localização é a metade do valor do achado.
|
|
277
|
+
let features = null;
|
|
278
|
+
if ('features' in item) {
|
|
279
|
+
if (!Array.isArray(item.features)
|
|
280
|
+
|| item.features.length === 0
|
|
281
|
+
|| !item.features.every(n => Number.isInteger(n) && n > 0)) {
|
|
282
|
+
throw new CritiqueSchemaError(
|
|
283
|
+
`${at}.features deveria ser um array não-vazio de números de issue, veio ${describe(item.features)}.`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
features = [...new Set(item.features)];
|
|
287
|
+
}
|
|
252
288
|
// Normaliza só caixa e espaço — isso não é ambiguidade semântica. "GRAVE"
|
|
253
289
|
// passa; "gravíssimo", "critical" e "high" NÃO.
|
|
254
290
|
const severity = typeof item.severity === 'string' ? item.severity.trim().toLowerCase() : null;
|
|
@@ -268,6 +304,7 @@ export function validateCritiquePayload(payload) {
|
|
|
268
304
|
return {
|
|
269
305
|
severity,
|
|
270
306
|
...(anchor ? { anchor } : {}),
|
|
307
|
+
...(features ? { features } : {}),
|
|
271
308
|
...(quote ? { quote } : {}),
|
|
272
309
|
text: item.text.trim(),
|
|
273
310
|
};
|
|
@@ -721,6 +758,12 @@ const KIND_TRAILER = {
|
|
|
721
758
|
'até ser removida. Um bug com causa raiz errada produz correção errada — corrija o ' +
|
|
722
759
|
'`bug.md` e reaplique `spec-wave:bug`.'
|
|
723
760
|
: '_Findings menores não bloqueiam a triagem._'),
|
|
761
|
+
// O conjunto roda fora do ciclo de tentativas e não aplica label: o achado é
|
|
762
|
+
// decisão de PO entre duas specs, e o destino dele é a issue das duas pontas.
|
|
763
|
+
conjunto: (graves) => (graves
|
|
764
|
+
? '⛔ Há findings **graves** entre specs: comente nas issues DOS DOIS lados, apontando ' +
|
|
765
|
+
'uma para a outra, e resolva antes de gerar os planos — cada spec sozinha parece certa.'
|
|
766
|
+
: '_Nenhuma contradição entre as specs que exija decisão antes dos planos._'),
|
|
724
767
|
};
|
|
725
768
|
|
|
726
769
|
/**
|
|
@@ -782,8 +825,12 @@ export function renderCritiqueMarkdown({
|
|
|
782
825
|
return parts.join('\n\n');
|
|
783
826
|
}
|
|
784
827
|
|
|
828
|
+
// Localização do finding: a âncora ("Story N") ou, no conjunto, as Features
|
|
829
|
+
// que ele atravessa ("#412 × #415").
|
|
830
|
+
const onde = f => f.anchor
|
|
831
|
+
|| (f.features?.length ? f.features.map(n => `#${n}`).join(' × ') : '');
|
|
785
832
|
const bullets = list => list
|
|
786
|
-
.map(f => `- ${f
|
|
833
|
+
.map(f => `- ${onde(f) ? `**${onde(f)}** — ` : ''}${sanitizeFindingText(f.text)}`)
|
|
787
834
|
.join('\n');
|
|
788
835
|
if (graves.length > 0) parts.push(`### ❌ Graves\n\n${bullets(graves)}`);
|
|
789
836
|
if (menores.length > 0) parts.push(`### ⚠️ Menores\n\n${bullets(menores)}`);
|
|
@@ -791,7 +838,7 @@ export function renderCritiqueMarkdown({
|
|
|
791
838
|
parts.push(
|
|
792
839
|
'### ↘️ Rebaixados (vieram como graves, não bloqueiam)\n\n' +
|
|
793
840
|
rebaixados
|
|
794
|
-
.map(f => `- ${f
|
|
841
|
+
.map(f => `- ${onde(f) ? `**${onde(f)}** — ` : ''}${sanitizeFindingText(f.text)}\n` +
|
|
795
842
|
` - _Rebaixado: ${sanitizeFindingText(f.downgradeReason || 'não se sustenta como bloqueio')}._`)
|
|
796
843
|
.join('\n') +
|
|
797
844
|
'\n\n_Continuam valendo como observação. Se algum for mesmo grave, corrija o documento ' +
|
|
@@ -811,6 +858,7 @@ export function renderCritiqueMarkdown({
|
|
|
811
858
|
fingerprint: findingFingerprint({ kind, anchor: f.anchor, text: f.text }),
|
|
812
859
|
severity: f.severity,
|
|
813
860
|
anchor: f.anchor || null,
|
|
861
|
+
...(f.features?.length ? { features: f.features } : {}),
|
|
814
862
|
text: sanitizeFindingText(f.text),
|
|
815
863
|
...(f.downgraded ? { downgradedFrom: f.downgraded, downgradeReason: f.downgradeReason } : {}),
|
|
816
864
|
})),
|
|
@@ -832,8 +880,10 @@ export function renderCritiqueMarkdown({
|
|
|
832
880
|
* chamador decidir entre seguir com aviso e abortar.
|
|
833
881
|
*
|
|
834
882
|
* @param {object} params
|
|
835
|
-
* @param {'plan'|'stories'|'bug'} params.kind o que está sendo auditado
|
|
883
|
+
* @param {'plan'|'stories'|'bug'|'spec'|'conjunto'} params.kind o que está sendo auditado
|
|
836
884
|
* @param {string} [params.spec] conteúdo do spec.md
|
|
885
|
+
* @param {Array<{number:number, title:string, content:string}>} [params.specs]
|
|
886
|
+
* kind 'conjunto': TODAS as specs da milestone, uma seção por Feature
|
|
837
887
|
* @param {string} [params.plan] conteúdo do plan.md
|
|
838
888
|
* @param {string} [params.techContextYaml] tech_context serializado em YAML
|
|
839
889
|
* @param {string} [params.decomposition] conteúdo do decomposition.md
|
|
@@ -847,12 +897,17 @@ export function renderCritiqueMarkdown({
|
|
|
847
897
|
* @returns {Promise<{grave, findings, markdown, attempt, model}>}
|
|
848
898
|
*/
|
|
849
899
|
export async function runCritique({
|
|
850
|
-
kind, spec, plan, techContextYaml, decomposition, bugDoc, bugReport,
|
|
900
|
+
kind, spec, specs, plan, techContextYaml, decomposition, bugDoc, bugReport,
|
|
851
901
|
attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
852
902
|
model, labels = [], usage, cwd, decisions = null, standalone = false,
|
|
853
903
|
} = {}) {
|
|
854
904
|
const sections = [];
|
|
855
905
|
if (spec) sections.push(`## spec.md\n\n${spec}`);
|
|
906
|
+
// Conjunto: o título de cada seção carrega o "#N" que o campo `features` dos
|
|
907
|
+
// findings cita de volta — é o contrato de localização deste kind.
|
|
908
|
+
for (const s of specs || []) {
|
|
909
|
+
sections.push(`## spec.md — #${s.number} ${s.title}\n\n${s.content}`);
|
|
910
|
+
}
|
|
856
911
|
if (plan) sections.push(`## plan.md\n\n${plan}`);
|
|
857
912
|
if (techContextYaml) sections.push(`## tech_context\n\n\`\`\`yaml\n${techContextYaml}\n\`\`\``);
|
|
858
913
|
// Cerca de QUATRO crases: o decomposition.md contém cercas de três, e uma
|
|
@@ -898,7 +953,8 @@ export async function runCritique({
|
|
|
898
953
|
// sustenta não deve entrar no contador de tentativas nem bloquear o Action.
|
|
899
954
|
const findings = downgradeUnsupportedFindings(
|
|
900
955
|
report.value.findings,
|
|
901
|
-
[spec, plan, decomposition, bugDoc, techContextYaml]
|
|
956
|
+
[spec, ...(specs || []).map(s => s.content), plan, decomposition, bugDoc, techContextYaml]
|
|
957
|
+
.filter(Boolean),
|
|
902
958
|
);
|
|
903
959
|
const grave = findings.some(f => f.severity === 'grave');
|
|
904
960
|
const rebaixados = findings.filter(f => f.downgraded).length;
|
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
|
}
|