@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.
@@ -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, 'spec-wave:ready');
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
- process.exit(1);
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}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
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), 'spec-wave:ready');
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
- process.exit(1);
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), 'spec-wave:plan-approved');
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: 'spec-wave:ready', color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
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
+ }