@spec-wave/cli 0.24.0 → 0.26.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.
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ // Onde o fluxo roda: no GitHub Actions ou nesta máquina.
2
+ //
3
+ // Duas fontes, de propósito, porque são dois leitores diferentes:
4
+ //
5
+ // • `.spec-wave.json` → `execution.mode` — é o que a CLI e a skill leem para
6
+ // saber se devem aplicar a label (Actions) ou chamar `spec-wave run` (local);
7
+ // • variável de repositório `SPEC_WAVE_EXECUTION` — é o que o GitHub avalia no
8
+ // `if:` de cada job. Sem ela, o workflow roda mesmo com o config dizendo
9
+ // "local", e o minuto é cobrado do mesmo jeito.
10
+ //
11
+ // Divergir é o estado perigoso, não um detalhe: o usuário acha que desligou e
12
+ // não desligou. Por isso a comparação vira função PURA aqui, e tanto o comando
13
+ // `mode` quanto o `doctor` dizem a mesma coisa.
14
+ //
15
+ // NÃO confundir com `executionMode()` de `lib/flow-run.mjs`: aquele responde
16
+ // "onde este processo está rodando AGORA" (GITHUB_ACTIONS); este responde "onde
17
+ // o repositório decidiu que o fluxo roda".
18
+
19
+ export const EXECUTION_MODES = ['actions', 'local'];
20
+ export const DEFAULT_EXECUTION_MODE = 'actions';
21
+
22
+ /** Nome da variável de repositório consultada pelo `if:` dos workflows. */
23
+ export const EXECUTION_VARIABLE = 'SPEC_WAVE_EXECUTION';
24
+
25
+ /** Expressão que os templates de workflow carregam — usada pelo doctor e pelos testes. */
26
+ export const EXECUTION_GUARD = `vars.${EXECUTION_VARIABLE} != 'local'`;
27
+
28
+ /**
29
+ * Modo declarado no `.spec-wave.json` (função PURA).
30
+ *
31
+ * @param {object|null} config
32
+ * @returns {'actions'|'local'}
33
+ */
34
+ export function configuredMode(config) {
35
+ const mode = String(config?.execution?.mode || '').toLowerCase();
36
+ return EXECUTION_MODES.includes(mode) ? mode : DEFAULT_EXECUTION_MODE;
37
+ }
38
+
39
+ /**
40
+ * Valor que a variável de repositório deve ter para um modo (função PURA).
41
+ *
42
+ * `null` significa "a variável não deve existir" — ausência é o estado default
43
+ * de qualquer repo, e é o que mantém os workflows armados sem depender de nada.
44
+ *
45
+ * @param {'actions'|'local'} mode
46
+ * @returns {string|null}
47
+ */
48
+ export function variableValueFor(mode) {
49
+ return mode === 'local' ? 'local' : null;
50
+ }
51
+
52
+ /**
53
+ * A variável do repositório precisa ser escrita? (função PURA)
54
+ *
55
+ * Três estados entram, não dois: `string` (valor lido), `null` (lida, e não
56
+ * existe) e `undefined` (NÃO deu para ler — 403, a variável exige admin).
57
+ *
58
+ * `undefined` conta como "precisa escrever". Tratá-lo como "já está certo"
59
+ * fazia o comando pular a escrita e ainda anunciar que config e variável
60
+ * coincidiam — o usuário saía achando que desligou o CI, com os workflows
61
+ * armados. Não conseguir ler quase sempre significa não conseguir escrever;
62
+ * tentar e falhar com a mensagem certa é honesto, não tentar e declarar
63
+ * sucesso não é.
64
+ *
65
+ * @param {object} params
66
+ * @param {string|null|undefined} params.variable valor remoto
67
+ * @param {string|null} params.expected valor que o modo-alvo exige (null = ausente)
68
+ * @returns {boolean}
69
+ */
70
+ export function shouldWriteVariable({ variable, expected }) {
71
+ return variable !== expected;
72
+ }
73
+
74
+ /**
75
+ * Diagnóstico do modo de execução (função PURA).
76
+ *
77
+ * @param {object} params
78
+ * @param {'actions'|'local'} params.configured modo no .spec-wave.json
79
+ * @param {string|null|undefined} params.variable valor remoto (undefined = não verificável)
80
+ * @param {string[]} [params.unguardedWorkflows] workflows instalados SEM a guarda
81
+ * @returns {{status: 'ok'|'warn'|'problem', summary: string, notes: string[], fixes: string[]}}
82
+ */
83
+ export function describeModeState({ configured, variable, unguardedWorkflows = [] }) {
84
+ const esperado = variableValueFor(configured);
85
+ const notes = [];
86
+ const fixes = [];
87
+ let status = 'ok';
88
+
89
+ notes.push(`Modo declarado no .spec-wave.json: **${configured}**.`);
90
+
91
+ if (variable === undefined) {
92
+ status = 'warn';
93
+ notes.push(
94
+ `Variável \`${EXECUTION_VARIABLE}\` não verificável (requer admin no repo) — ` +
95
+ 'não dá para afirmar daqui se os workflows estão armados.'
96
+ );
97
+ } else if (variable === esperado) {
98
+ notes.push(esperado === null
99
+ ? `Variável \`${EXECUTION_VARIABLE}\` ausente, como deve ser no modo actions.`
100
+ : `Variável \`${EXECUTION_VARIABLE}\`=${variable} no repositório.`);
101
+ } else if (configured === 'local') {
102
+ // O caso que custa dinheiro: config diz local, GitHub continua disparando.
103
+ status = 'problem';
104
+ notes.push(
105
+ `Modo local, mas \`${EXECUTION_VARIABLE}\` ${variable === null ? 'não existe' : `vale "${variable}"`} ` +
106
+ 'no repositório — os workflows CONTINUAM disparando e consumindo minutos.'
107
+ );
108
+ fixes.push('Rode `npx @spec-wave/cli@latest mode local` (ou defina a variável em Settings → Actions → Variables).');
109
+ } else {
110
+ status = 'problem';
111
+ notes.push(
112
+ `Modo actions, mas \`${EXECUTION_VARIABLE}\`="${variable}" está setada — os workflows ` +
113
+ 'são pulados e nada roda até alguém rodar o comando local.'
114
+ );
115
+ fixes.push('Rode `npx @spec-wave/cli@latest mode actions` para remover a variável.');
116
+ }
117
+
118
+ if (configured === 'local' && unguardedWorkflows.length > 0) {
119
+ status = 'problem';
120
+ notes.push(
121
+ `Workflows instalados sem a guarda \`${EXECUTION_GUARD}\`: ${unguardedWorkflows.join(', ')} — ` +
122
+ 'esses vão rodar de qualquer jeito.'
123
+ );
124
+ fixes.push('Rode `npx @spec-wave/cli@latest update` para publicar os workflows com a guarda.');
125
+ }
126
+
127
+ const summary = configured === 'local'
128
+ ? 'Execução local: os comandos rodam nesta máquina (`spec-wave run <issue>`).'
129
+ : 'Execução no GitHub Actions: as labels de gatilho disparam os workflows.';
130
+
131
+ return { status, summary, notes, fixes };
132
+ }