@spec-wave/cli 0.24.0 → 0.25.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/README.md +84 -0
- package/bin/spec-wave.mjs +35 -2
- package/package.json +1 -1
- package/src/api/github-rest.mjs +63 -1
- package/src/commands/decompose.mjs +3 -10
- package/src/commands/doctor.mjs +32 -0
- package/src/commands/generate-bug.mjs +8 -14
- package/src/commands/generate-plan.mjs +2 -1
- package/src/commands/generate-spec.mjs +2 -1
- package/src/commands/mode.mjs +169 -0
- package/src/commands/run.mjs +396 -0
- package/src/commands/validate.mjs +22 -6
- package/src/config.mjs +4 -1
- package/src/lib/config-file.mjs +62 -0
- package/src/lib/doc-paths.mjs +51 -0
- package/src/lib/execution-mode.mjs +110 -0
- package/src/lib/next-step.mjs +356 -0
- package/src/lib/pr-step.mjs +102 -0
- package/src/lib/repo-links.mjs +84 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/doctor/SKILL.md +1 -0
- package/src/plugin/skills/run/SKILL.md +76 -0
- package/src/plugin/skills/spec/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +24 -1
- package/src/templates/workflows/code-review.yml +5 -1
- package/src/templates/workflows/critique.yml +4 -0
- package/src/templates/workflows/decompose.yml +4 -0
- package/src/templates/workflows/generate-bug.yml +4 -0
- package/src/templates/workflows/generate-plan.yml +4 -0
- package/src/templates/workflows/generate-spec.yml +4 -0
- package/src/templates/workflows/qa.yml +6 -1
- package/src/templates/workflows/validate.yml +4 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Executa LOCALMENTE o passo que a label dispararia no GitHub Actions.
|
|
2
|
+
//
|
|
3
|
+
// O YAML nunca fez trabalho nenhum: ele instala a CLI e chama um comando. Este
|
|
4
|
+
// comando fecha o buraco que sobra quando os workflows estão desarmados
|
|
5
|
+
// (`spec-wave mode local`) — decidir QUAL comando é o próximo e chamá-lo, sem
|
|
6
|
+
// aplicar label nenhuma.
|
|
7
|
+
//
|
|
8
|
+
// A decisão é uma função pura (`lib/next-step.mjs`), testada sem rede. Aqui só
|
|
9
|
+
// mora o que é impuro: coletar o estado, segurar o lock, despachar e relatar.
|
|
10
|
+
//
|
|
11
|
+
// Três coisas que este comando NUNCA faz, e o porquê:
|
|
12
|
+
// • aplicar label de gatilho — dispararia o Action e a execução aconteceria duas vezes;
|
|
13
|
+
// • rodar dentro do Actions — lá o contrato é a label (`assertNotInActions`);
|
|
14
|
+
// • encadear passos sem teto — cada passo de IA custa dinheiro (`--max-steps`).
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
|
|
17
|
+
import { execSync } from 'node:child_process';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
|
|
22
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
23
|
+
import { getIssue, getPR, getFileContent, listPullRequestReviews } from '../api/github-rest.mjs';
|
|
24
|
+
import { loadProjectConfig } from '../lib/board.mjs';
|
|
25
|
+
import { existsOnRemote } from '../lib/doc-availability.mjs';
|
|
26
|
+
import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
|
|
27
|
+
import { isActionsRun, resolveFlowContext } from '../lib/flow-run.mjs';
|
|
28
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
29
|
+
import { nextStep, resolveForcedStep, STEPS } from '../lib/next-step.mjs';
|
|
30
|
+
import { nextPrStep, reviewVerdict } from '../lib/pr-step.mjs';
|
|
31
|
+
import { configuredMode } from '../lib/execution-mode.mjs';
|
|
32
|
+
import { labelNames } from '../config.mjs';
|
|
33
|
+
|
|
34
|
+
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Lock — o substituto do `concurrency: spec-wave-item-<n>` dos workflows.
|
|
38
|
+
//
|
|
39
|
+
// Sem ele, dois `run` na mesma issue geram dois documentos, dois commits
|
|
40
|
+
// disputando a ponta da branch e leitura-modificação-escrita concorrente do
|
|
41
|
+
// comentário de uso. Fica em .git/ porque já é ignorado e é por clone.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function lockPath(root, key) {
|
|
45
|
+
return path.join(root || process.cwd(), '.git', 'spec-wave', `run-${key}.lock`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function acquireLock(root, key) {
|
|
49
|
+
const file = lockPath(root, key);
|
|
50
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(file, payload, { flag: 'wx' });
|
|
54
|
+
return file;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err.code !== 'EEXIST') throw err;
|
|
57
|
+
let idade = Infinity;
|
|
58
|
+
let dono = null;
|
|
59
|
+
try {
|
|
60
|
+
dono = JSON.parse(readFileSync(file, 'utf-8'));
|
|
61
|
+
idade = Date.now() - Date.parse(dono.startedAt);
|
|
62
|
+
} catch { /* lock ilegível conta como velho */ }
|
|
63
|
+
if (idade < LOCK_STALE_MS) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Já existe um \`spec-wave run\` para ${key} (pid ${dono?.pid ?? '?'}, desde ${dono?.startedAt ?? '?'}).\n` +
|
|
66
|
+
`Se tiver certeza de que morreu, apague ${file}.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
console.warn(`⚠️ Lock antigo encontrado (${file}) — assumindo processo morto e seguindo.`);
|
|
70
|
+
writeFileSync(file, payload);
|
|
71
|
+
return file;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function releaseLock(file) {
|
|
76
|
+
try {
|
|
77
|
+
if (file) unlinkSync(file);
|
|
78
|
+
} catch { /* já removido */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Commits locais ainda não publicados: gerar o próximo documento por cima de um
|
|
82
|
+
// anterior que ficou só no clone é execução parcial disfarçada de sucesso.
|
|
83
|
+
function unpushedCommits(root) {
|
|
84
|
+
try {
|
|
85
|
+
const out = execSync('git rev-list --count @{u}..HEAD', {
|
|
86
|
+
cwd: root || process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
87
|
+
}).trim();
|
|
88
|
+
return Number.parseInt(out, 10) || 0;
|
|
89
|
+
} catch {
|
|
90
|
+
return 0; // sem upstream configurado: não dá para afirmar nada
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertNotInActions() {
|
|
95
|
+
if (!isActionsRun()) return;
|
|
96
|
+
throw new Error(
|
|
97
|
+
'O `run` é o gatilho do modo LOCAL — dentro do GitHub Actions o gatilho é a label, ' +
|
|
98
|
+
'e o workflow já chama o comando certo. Rodar os dois seria executar o passo duas vezes.'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Coleta do estado
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function localDocStates(issue, type, root) {
|
|
107
|
+
if (type === 'Bug') {
|
|
108
|
+
const { fileRel, fileAbs } = bugDocPaths(issue.title, root);
|
|
109
|
+
return {
|
|
110
|
+
docs: { bug: existsSync(fileAbs) ? 'local' : 'missing' },
|
|
111
|
+
docPaths: { bug: fileRel },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const paths = featureDocPaths(root, issue, type);
|
|
115
|
+
const docs = {};
|
|
116
|
+
const docPaths = {};
|
|
117
|
+
for (const nome of ['spec', 'plan', 'decomposition']) {
|
|
118
|
+
docs[nome] = existsSync(paths[nome].abs) ? 'local' : 'missing';
|
|
119
|
+
docPaths[nome] = paths[nome].rel;
|
|
120
|
+
}
|
|
121
|
+
return { docs, docPaths };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Sonda o remoto SÓ pelos documentos que o passo escolhido usa e que faltam no
|
|
126
|
+
* clone. É a diferença entre "ainda não foi gerado" e "foi gerado e você não
|
|
127
|
+
* puxou" — a segunda leva a sobrescrever trabalho publicado.
|
|
128
|
+
*/
|
|
129
|
+
async function probeRemote({ decision, docs, docPaths, token, owner, repo }) {
|
|
130
|
+
const step = STEPS[decision.action];
|
|
131
|
+
if (!step) return docs;
|
|
132
|
+
const alvos = [step.writes, ...step.reads]
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.filter(doc => docs[doc] === 'missing');
|
|
135
|
+
if (alvos.length === 0) return docs;
|
|
136
|
+
|
|
137
|
+
const atualizado = { ...docs };
|
|
138
|
+
for (const doc of alvos) {
|
|
139
|
+
const onRemote = await existsOnRemote({
|
|
140
|
+
getFileContent, token, owner, repo, pathRel: docPaths[doc],
|
|
141
|
+
});
|
|
142
|
+
if (onRemote === true) atualizado[doc] = 'remote';
|
|
143
|
+
else if (onRemote === null) atualizado[doc] = 'unknown';
|
|
144
|
+
}
|
|
145
|
+
return atualizado;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Relato
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function reportDecision(decision, { issueNumber, type, title, warnings }) {
|
|
153
|
+
console.log(chalk.bold(`\nIssue #${issueNumber} · ${type || 'tipo desconhecido'} · ${title}`));
|
|
154
|
+
console.log(`Próximo passo: ${chalk.cyan(decision.action)}`);
|
|
155
|
+
console.log(`Motivo: ${decision.reason}`);
|
|
156
|
+
if (decision.command) console.log(`Comando: ${chalk.dim(decision.command)}`);
|
|
157
|
+
for (const aviso of warnings) console.warn(chalk.yellow(`⚠️ ${aviso}`));
|
|
158
|
+
if (decision.blocked) {
|
|
159
|
+
console.log(chalk.yellow(`\n⛔ ${decision.blocked.code}: ${decision.blocked.message}`));
|
|
160
|
+
console.log(` ${decision.blocked.unblock}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// O prefixo do título é o que os workflows filtram (`contains(title, '[FEATURE]')`),
|
|
165
|
+
// enquanto detectIssueType tem fallback por label: sem o aviso, uma issue rodaria
|
|
166
|
+
// local e nunca rodaria no runner, e a diferença só apareceria ao voltar o modo.
|
|
167
|
+
function parityWarnings(issue, type) {
|
|
168
|
+
const avisos = [];
|
|
169
|
+
const titulo = String(issue.title || '');
|
|
170
|
+
if (type && !titulo.includes(`[${type.toUpperCase()}]`)) {
|
|
171
|
+
avisos.push(
|
|
172
|
+
`O título não traz o prefixo [${type.toUpperCase()}] — o workflow correspondente NÃO ` +
|
|
173
|
+
'dispararia para esta issue no modo actions (ele filtra pelo título).'
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return avisos;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Despacho
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
async function dispatch(action, { issueNumber }) {
|
|
184
|
+
switch (action) {
|
|
185
|
+
case 'generate-spec': {
|
|
186
|
+
const { generateSpec } = await import('./generate-spec.mjs');
|
|
187
|
+
return await generateSpec({ issueNumber });
|
|
188
|
+
}
|
|
189
|
+
case 'generate-plan': {
|
|
190
|
+
const { generatePlan } = await import('./generate-plan.mjs');
|
|
191
|
+
return await generatePlan({ issueNumber });
|
|
192
|
+
}
|
|
193
|
+
case 'critique': {
|
|
194
|
+
const { critique } = await import('./generate-plan.mjs');
|
|
195
|
+
return await critique({ issueNumber });
|
|
196
|
+
}
|
|
197
|
+
case 'validate': {
|
|
198
|
+
const { validate } = await import('./validate.mjs');
|
|
199
|
+
return await validate({ issueNumber });
|
|
200
|
+
}
|
|
201
|
+
case 'decompose': {
|
|
202
|
+
const { decompose } = await import('./decompose.mjs');
|
|
203
|
+
return await decompose({ issueNumber, apply: false });
|
|
204
|
+
}
|
|
205
|
+
case 'decompose-apply': {
|
|
206
|
+
const { decompose } = await import('./decompose.mjs');
|
|
207
|
+
return await decompose({ issueNumber, apply: true });
|
|
208
|
+
}
|
|
209
|
+
case 'generate-bug': {
|
|
210
|
+
const { generateBug } = await import('./generate-bug.mjs');
|
|
211
|
+
return await generateBug({ issueNumber });
|
|
212
|
+
}
|
|
213
|
+
default:
|
|
214
|
+
throw new Error(`Passo sem despacho: ${action}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Modo PR
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
async function runForPr({ prNumber, dryRun, yes, only, json }) {
|
|
223
|
+
const { owner, repo, root } = resolveFlowContext({ command: 'run --pr' });
|
|
224
|
+
const token = await resolveToken();
|
|
225
|
+
const n = parseInt(prNumber, 10);
|
|
226
|
+
|
|
227
|
+
const pr = await getPR(token, owner, repo, n);
|
|
228
|
+
const reviews = await listPullRequestReviews(token, owner, repo, n);
|
|
229
|
+
const verdict = reviewVerdict(reviews);
|
|
230
|
+
const decision = nextPrStep({
|
|
231
|
+
state: pr.state,
|
|
232
|
+
draft: Boolean(pr.draft),
|
|
233
|
+
merged: Boolean(pr.merged_at),
|
|
234
|
+
approved: verdict.approved,
|
|
235
|
+
changesRequestedAfterApproval: verdict.changesRequestedAfterApproval,
|
|
236
|
+
only: only || null,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const { error: boardError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
240
|
+
if (boardError) decision.warnings.push(`${boardError} — o board não será atualizado.`);
|
|
241
|
+
|
|
242
|
+
if (json) {
|
|
243
|
+
console.log(JSON.stringify({ pr: n, ...decision, approvers: verdict.approvers }, null, 2));
|
|
244
|
+
} else {
|
|
245
|
+
console.log(chalk.bold(`\nPR #${n} · ${pr.title}`));
|
|
246
|
+
console.log(`Passos: ${decision.steps.length ? chalk.cyan(decision.steps.join(' → ')) : '(nenhum)'}`);
|
|
247
|
+
console.log(`Motivo: ${decision.reason}`);
|
|
248
|
+
for (const aviso of decision.warnings) console.warn(chalk.yellow(`⚠️ ${aviso}`));
|
|
249
|
+
if (decision.blocked) console.log(chalk.yellow(`\n⛔ ${decision.blocked.code}: ${decision.blocked.message}`));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (decision.blocked || decision.steps.length === 0) {
|
|
253
|
+
process.exitCode = decision.blocked ? 2 : 0;
|
|
254
|
+
return decision;
|
|
255
|
+
}
|
|
256
|
+
if (verdict.changesRequestedAfterApproval && !yes) {
|
|
257
|
+
console.log(chalk.yellow('\n⛔ needs-confirmation: há pedido de mudanças além da aprovação. Confirme com `--yes`.'));
|
|
258
|
+
process.exitCode = 2;
|
|
259
|
+
return decision;
|
|
260
|
+
}
|
|
261
|
+
if (dryRun) {
|
|
262
|
+
console.log(chalk.dim('\nDry-run: nada foi executado.'));
|
|
263
|
+
return decision;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const lock = acquireLock(root, `pr-${n}`);
|
|
267
|
+
try {
|
|
268
|
+
for (const step of decision.steps) {
|
|
269
|
+
console.log(chalk.bold(`\n▶ ${step} --pr-number ${n}`));
|
|
270
|
+
if (step === 'code-review') {
|
|
271
|
+
const { codeReview } = await import('./code-review.mjs');
|
|
272
|
+
await codeReview({ prNumber: String(n) });
|
|
273
|
+
} else {
|
|
274
|
+
const { qa } = await import('./qa.mjs');
|
|
275
|
+
await qa({ prNumber: String(n) });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
releaseLock(lock);
|
|
280
|
+
}
|
|
281
|
+
return decision;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Comando
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
export async function run(issueArg, options = {}) {
|
|
289
|
+
assertNotInActions();
|
|
290
|
+
|
|
291
|
+
const {
|
|
292
|
+
pr, dryRun = false, yes = false, apply = false, step: forced = null,
|
|
293
|
+
maxSteps = 1, force = false, remoteCheck = true, json = false, only = null,
|
|
294
|
+
} = options;
|
|
295
|
+
|
|
296
|
+
if (pr) return await runForPr({ prNumber: pr, dryRun, yes, only, json });
|
|
297
|
+
|
|
298
|
+
if (!issueArg) throw new Error('Informe o número da issue: `spec-wave run <issue>` (ou `--pr <n>`).');
|
|
299
|
+
|
|
300
|
+
const { owner, repo, root, config } = resolveFlowContext({ command: 'run' });
|
|
301
|
+
const token = await resolveToken();
|
|
302
|
+
const issueNumber = String(issueArg).replace(/^#/, '');
|
|
303
|
+
const teto = Math.max(1, parseInt(maxSteps, 10) || 1);
|
|
304
|
+
|
|
305
|
+
if (configuredMode(config) !== 'local') {
|
|
306
|
+
console.warn(chalk.yellow(
|
|
307
|
+
'⚠️ Este repositório está em modo `actions` — os workflows continuam armados e podem ' +
|
|
308
|
+
'rodar o mesmo passo. Use `spec-wave mode local` para desarmá-los.'
|
|
309
|
+
));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const lock = acquireLock(root, issueNumber);
|
|
313
|
+
// `critiqueFile` seta process.exitCode = 1; sem preservar, um run inteiro
|
|
314
|
+
// bem-sucedido sairia com código de erro.
|
|
315
|
+
const exitCodeAntes = process.exitCode;
|
|
316
|
+
try {
|
|
317
|
+
let ultimaDecisao = null;
|
|
318
|
+
|
|
319
|
+
for (let i = 0; i < teto; i++) {
|
|
320
|
+
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
321
|
+
const type = detectIssueType(issue);
|
|
322
|
+
const { docs, docPaths } = localDocStates(issue, type, root);
|
|
323
|
+
const { error: boardError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
324
|
+
|
|
325
|
+
let forcedStep = null;
|
|
326
|
+
if (forced && i === 0) {
|
|
327
|
+
const resolvido = resolveForcedStep(forced, { type });
|
|
328
|
+
if (resolvido.error) throw new Error(resolvido.error);
|
|
329
|
+
forcedStep = resolvido.action;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const entrada = {
|
|
333
|
+
type,
|
|
334
|
+
issueNumber,
|
|
335
|
+
state: issue.state,
|
|
336
|
+
labels: labelNames(issue),
|
|
337
|
+
docPaths,
|
|
338
|
+
forcedStep,
|
|
339
|
+
confirmed: yes || apply,
|
|
340
|
+
boardReady: !boardError,
|
|
341
|
+
force,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
let decision = nextStep({ ...entrada, docs });
|
|
345
|
+
if (remoteCheck && decision.action !== 'none' && !decision.blocked) {
|
|
346
|
+
const docsRemotos = await probeRemote({ decision, docs, docPaths, token, owner, repo });
|
|
347
|
+
decision = nextStep({ ...entrada, docs: docsRemotos });
|
|
348
|
+
}
|
|
349
|
+
ultimaDecisao = decision;
|
|
350
|
+
|
|
351
|
+
// `--apply` é uma asserção sobre o estado esperado, não um "faça o que for":
|
|
352
|
+
// se o passo pendente virou outro, errar é melhor que sobrescrever um documento.
|
|
353
|
+
if (apply && decision.action !== 'decompose-apply' && !yes) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`--apply autoriza especificamente o \`decompose-apply\`, mas o passo pendente é ` +
|
|
356
|
+
`\`${decision.action}\`. Use --yes se era isso mesmo que você queria.`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (json) console.log(JSON.stringify({ issue: Number(issueNumber), type, ...decision }, null, 2));
|
|
361
|
+
else reportDecision(decision, { issueNumber, type, title: issue.title, warnings: parityWarnings(issue, type) });
|
|
362
|
+
|
|
363
|
+
if (decision.blocked) { process.exitCode = 2; break; }
|
|
364
|
+
if (decision.action === 'none') break;
|
|
365
|
+
if (dryRun) {
|
|
366
|
+
console.log(chalk.dim('\nDry-run: nada foi executado.'));
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log(chalk.bold(`\n▶ ${decision.command}\n`));
|
|
371
|
+
const resultado = await dispatch(decision.action, { issueNumber });
|
|
372
|
+
process.exitCode = exitCodeAntes;
|
|
373
|
+
|
|
374
|
+
// Reprova do validate é desfecho esperado, não exceção — mas encadear em
|
|
375
|
+
// cima dela repetiria o mesmo passo até esgotar `--max-steps`.
|
|
376
|
+
if (resultado?.ok === false) {
|
|
377
|
+
console.warn(chalk.yellow('\n⚠️ O passo terminou reprovado — corrija os problemas acima e repita.'));
|
|
378
|
+
process.exitCode = 2;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const pendentes = unpushedCommits(root);
|
|
383
|
+
if (pendentes > 0) {
|
|
384
|
+
console.warn(chalk.yellow(
|
|
385
|
+
`\n⚠️ ${pendentes} commit(s) ainda não publicado(s). O próximo passo leria um documento ` +
|
|
386
|
+
'que só existe no seu clone — publique antes de continuar.'
|
|
387
|
+
));
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return ultimaDecisao;
|
|
393
|
+
} finally {
|
|
394
|
+
releaseLock(lock);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -5,12 +5,15 @@ import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-r
|
|
|
5
5
|
import { slugify } from '../lib/slugify.mjs';
|
|
6
6
|
import {
|
|
7
7
|
CONFIG_FILE, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_BUG, LABEL_BUG_APPROVED,
|
|
8
|
+
LABEL_READY, LABEL_PLAN_APPROVED,
|
|
8
9
|
REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS, REQUIRED_BUG_SECTIONS, labelNames,
|
|
9
10
|
} from '../config.mjs';
|
|
10
11
|
import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
|
|
11
12
|
import { bugDocPaths, describeMissingSections } from '../lib/bug-doc.mjs';
|
|
12
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
13
14
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
15
|
+
import { executionMode } from '../lib/flow-run.mjs';
|
|
16
|
+
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Validação do bug.md (RFC-004 §5).
|
|
@@ -55,7 +58,7 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
await removeLabel(token, owner, repo, n,
|
|
61
|
+
await removeLabel(token, owner, repo, n, LABEL_READY);
|
|
59
62
|
|
|
60
63
|
if (errors.length > 0) {
|
|
61
64
|
await commentOnIssue(
|
|
@@ -65,18 +68,19 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
65
68
|
`\n\nCorrija os problemas e adicione novamente a label \`spec-wave:ready\`.`
|
|
66
69
|
);
|
|
67
70
|
console.error('Validação falhou:', errors.join(', '));
|
|
68
|
-
|
|
71
|
+
return { ok: false, errors };
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
await addLabel(token, owner, repo, n, LABEL_BUG_APPROVED);
|
|
72
75
|
await commentOnIssue(
|
|
73
76
|
token, owner, repo, n,
|
|
74
77
|
'✅ **bug.md validado.**\n\n' +
|
|
75
|
-
`📄 [\`${fileRel}\`](
|
|
78
|
+
`📄 [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode: executionMode(), root })})\n\n` +
|
|
76
79
|
'As seis seções obrigatórias estão presentes — reprodução, causa raiz e teste de ' +
|
|
77
80
|
'regressão inclusive. O bug pode ser aceito na triagem.'
|
|
78
81
|
);
|
|
79
82
|
console.log('bug.md validado.');
|
|
83
|
+
return { ok: true, errors: [] };
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
/**
|
|
@@ -95,6 +99,17 @@ export function renderMissingSection(doc, { section, found }) {
|
|
|
95
99
|
: base;
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Valida os documentos de uma issue.
|
|
104
|
+
*
|
|
105
|
+
* Devolve o veredito em vez de chamar `process.exit(1)` na reprova: quem
|
|
106
|
+
* encerra o processo é o wrapper do `bin` (mesmo exit code no Actions), e assim
|
|
107
|
+
* o `run` consegue tratar a reprova como o desfecho ESPERADO que ela é — sem
|
|
108
|
+
* morrer no meio da cadeia, sem pular o `finally` e sem deixar o lock para trás.
|
|
109
|
+
*
|
|
110
|
+
* @param {{issueNumber: string|number}} params
|
|
111
|
+
* @returns {Promise<{ok: boolean, errors: string[]}>}
|
|
112
|
+
*/
|
|
98
113
|
export async function validate({ issueNumber }) {
|
|
99
114
|
const token = await resolveToken();
|
|
100
115
|
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
@@ -174,7 +189,7 @@ export async function validate({ issueNumber }) {
|
|
|
174
189
|
}
|
|
175
190
|
|
|
176
191
|
// Remove trigger label
|
|
177
|
-
await removeLabel(token, owner, repo, parseInt(issueNumber, 10),
|
|
192
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_READY);
|
|
178
193
|
|
|
179
194
|
if (errors.length > 0) {
|
|
180
195
|
await commentOnIssue(
|
|
@@ -199,11 +214,11 @@ export async function validate({ issueNumber }) {
|
|
|
199
214
|
//
|
|
200
215
|
// Regenerar é decisão humana, e o comentário acima diz como e o que custa.
|
|
201
216
|
console.error('Validação falhou:', errors.join(', '));
|
|
202
|
-
|
|
217
|
+
return { ok: false, errors };
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
// Validação passou: adiciona label plan-approved.
|
|
206
|
-
await addLabel(token, owner, repo, parseInt(issueNumber, 10),
|
|
221
|
+
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_PLAN_APPROVED);
|
|
207
222
|
|
|
208
223
|
await commentOnIssue(
|
|
209
224
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
@@ -216,4 +231,5 @@ export async function validate({ issueNumber }) {
|
|
|
216
231
|
);
|
|
217
232
|
|
|
218
233
|
console.log('Validação OK. Feature pronta para decomposição.');
|
|
234
|
+
return { ok: true, errors: [] };
|
|
219
235
|
}
|
package/src/config.mjs
CHANGED
|
@@ -407,6 +407,9 @@ export const LABEL_NEEDS_HUMAN = 'spec-wave:needs-human';
|
|
|
407
407
|
// pronta de uma que só foi movida à mão para ✅ Ready.
|
|
408
408
|
export const LABEL_PLAN_APPROVED = 'spec-wave:plan-approved';
|
|
409
409
|
// Gatilhos que REGERAM documento: presentes na issue, indicam etapa pendente.
|
|
410
|
+
// `spec-wave:ready` era o único gatilho sem constante — e por isso aparecia como
|
|
411
|
+
// string literal no validate, no YAML e em toda tabela que precisasse citá-lo.
|
|
412
|
+
export const LABEL_READY = 'spec-wave:ready';
|
|
410
413
|
export const LABEL_SPEC = 'spec-wave:spec';
|
|
411
414
|
export const LABEL_PLAN = 'spec-wave:plan';
|
|
412
415
|
// Achado GRAVE que o Tech Leader aceitou como risco conhecido. Fica na issue
|
|
@@ -422,7 +425,7 @@ export const TRIGGER_LABELS = [
|
|
|
422
425
|
{ name: LABEL_SPEC, color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
|
|
423
426
|
{ name: LABEL_PLAN, color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
|
|
424
427
|
{ name: LABEL_CRITIQUE, color: 'BFD4F2', description: 'Re-criticar o plan.md COMO ESTÁ, sem regerar' },
|
|
425
|
-
{ name:
|
|
428
|
+
{ name: LABEL_READY, color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
|
|
426
429
|
{ name: LABEL_PLAN_APPROVED, color: '0E8A16', description: 'Spec+plan validados com sucesso' },
|
|
427
430
|
{ name: LABEL_DECOMPOSE, color: 'BFD4F2', description: 'Gerar/re-criticar o rascunho da decomposição (decomposition.md)' },
|
|
428
431
|
{ name: LABEL_DECOMPOSE_APPLY, color: 'BFD4F2', description: 'Aplicar o decomposition.md revisado: criar Stories e Tasks' },
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Escrita do `.spec-wave.json` — um lugar só.
|
|
2
|
+
//
|
|
3
|
+
// Havia três escritores, cada um com a sua lógica: `init` monta o objeto do zero
|
|
4
|
+
// (e por isso descarta campos custom como `codeReview.linkMode` e `specKit`),
|
|
5
|
+
// `refresh` faz merge preservando o resto, e `update` tem a mesma lógica do
|
|
6
|
+
// refresh duplicada. Quem só precisa mexer em UM campo — o caso do `mode` — não
|
|
7
|
+
// tinha por onde.
|
|
8
|
+
//
|
|
9
|
+
// A serialização é parte do contrato, não detalhe: `update` compara o config
|
|
10
|
+
// byte a byte com o remoto para decidir se ele entra no PR (`lib/pr-branch.mjs`),
|
|
11
|
+
// então um `\n` a mais no fim faria o arquivo entrar em toda execução.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
16
|
+
import { findConfigPath } from './project-root.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Serialização canônica do config (função PURA).
|
|
20
|
+
*
|
|
21
|
+
* @param {object} config
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function serializeConfig(config) {
|
|
25
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Aplica uma mutação ao `.spec-wave.json` preservando todo o resto.
|
|
30
|
+
*
|
|
31
|
+
* O mutator recebe uma CÓPIA e pode alterá-la no lugar ou devolver outro objeto.
|
|
32
|
+
* Gravação idempotente: conteúdo idêntico não reescreve o arquivo (não suja o
|
|
33
|
+
* mtime nem o `git status` de quem só consultou).
|
|
34
|
+
*
|
|
35
|
+
* @param {(config: object) => object|void} mutator
|
|
36
|
+
* @param {{cwd?: string}} [options]
|
|
37
|
+
* @returns {{configPath: string, config: object, changed: boolean}}
|
|
38
|
+
*/
|
|
39
|
+
export function updateConfig(mutator, { cwd = process.cwd() } = {}) {
|
|
40
|
+
const configPath = findConfigPath(cwd);
|
|
41
|
+
if (!configPath) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`${CONFIG_FILE} não encontrado — rode \`npx @spec-wave/cli@latest init\` neste repositório.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
48
|
+
let current;
|
|
49
|
+
try {
|
|
50
|
+
current = JSON.parse(raw);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new Error(`${CONFIG_FILE} está corrompido (${err.message}) — corrija-o à mão antes.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const draft = structuredClone(current);
|
|
56
|
+
const next = mutator(draft) ?? draft;
|
|
57
|
+
const content = serializeConfig(next);
|
|
58
|
+
if (content === raw) return { configPath, config: next, changed: false };
|
|
59
|
+
|
|
60
|
+
writeFileSync(configPath, content);
|
|
61
|
+
return { configPath, config: next, changed: true };
|
|
62
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Onde mora cada documento do fluxo, a partir do TÍTULO da issue.
|
|
2
|
+
//
|
|
3
|
+
// O slug vem do título (`slugify`), então renomear a issue muda o diretório —
|
|
4
|
+
// motivo pelo qual o `run` compara o que as labels afirmam com o que existe no
|
|
5
|
+
// clone antes de decidir qualquer coisa.
|
|
6
|
+
//
|
|
7
|
+
// Funções PURAS: montam caminhos, não tocam o filesystem.
|
|
8
|
+
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { slugify } from './slugify.mjs';
|
|
12
|
+
|
|
13
|
+
export { bugDocPaths } from './bug-doc.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Diretório do documento por tipo de issue.
|
|
17
|
+
*
|
|
18
|
+
* Feature usa o mesmo `docs/features/<slug>` da spec/plan; RFC ganha o seu, já
|
|
19
|
+
* que não passa por spec/plan.
|
|
20
|
+
*
|
|
21
|
+
* @param {string|null} root raiz do repositório (ausente = process.cwd())
|
|
22
|
+
* @param {{title?: string}} issue
|
|
23
|
+
* @param {string|null} [type] tipo canônico ('Feature', 'RFC', ...)
|
|
24
|
+
* @returns {{slug: string, rel: string, dir: string}}
|
|
25
|
+
*/
|
|
26
|
+
export function resolveDocDir(root, issue, type) {
|
|
27
|
+
const slug = slugify(issue?.title || '');
|
|
28
|
+
const rel = type === 'RFC' ? `docs/rfcs/${slug}` : `docs/features/${slug}`;
|
|
29
|
+
return { slug, rel, dir: path.resolve(root || process.cwd(), rel) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Caminhos dos três documentos de uma Feature/RFC (função PURA).
|
|
34
|
+
*
|
|
35
|
+
* @param {string|null} root
|
|
36
|
+
* @param {{title?: string}} issue
|
|
37
|
+
* @param {string|null} [type]
|
|
38
|
+
* @returns {{slug, dirRel, dirAbs, spec, plan, decomposition}} cada documento com {rel, abs}
|
|
39
|
+
*/
|
|
40
|
+
export function featureDocPaths(root, issue, type) {
|
|
41
|
+
const { slug, rel, dir } = resolveDocDir(root, issue, type);
|
|
42
|
+
const doc = name => ({ rel: `${rel}/${name}`, abs: path.join(dir, name) });
|
|
43
|
+
return {
|
|
44
|
+
slug,
|
|
45
|
+
dirRel: rel,
|
|
46
|
+
dirAbs: dir,
|
|
47
|
+
spec: doc('spec.md'),
|
|
48
|
+
plan: doc('plan.md'),
|
|
49
|
+
decomposition: doc('decomposition.md'),
|
|
50
|
+
};
|
|
51
|
+
}
|