@spec-wave/cli 0.5.11 → 0.7.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.
@@ -1,11 +1,15 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
- import { getIssue, createIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
5
- import { addSubIssue, addProjectItem, setItemSingleSelect, getSingleSelectField } from '../api/github-graphql.mjs';
4
+ import { getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy } from '../api/github-rest.mjs';
5
+ import { addSubIssue, addProjectItem, setItemSingleSelect, getSingleSelectField, listSubIssues } from '../api/github-graphql.mjs';
6
6
  import { generateDocument } from '../lib/claude.mjs';
7
+ import { runCritique } from '../lib/critique.mjs';
8
+ import { formatDependencyLine } from '../lib/dependencies.mjs';
9
+ import { lintLanguage } from '../lib/output-lint.mjs';
7
10
  import { slugify } from '../lib/slugify.mjs';
8
- import { CONFIG_FILE } from '../config.mjs';
11
+ import { detectIssueType } from '../lib/issue-type.mjs';
12
+ import { CONFIG_FILE, DECOMPOSE_TARGETS, LABEL_DECOMPOSED, LABEL_CRITIQUE_FAILED, TARGET_LANGUAGE } from '../config.mjs';
9
13
 
10
14
  const READY_STAGE = 'Todo';
11
15
 
@@ -35,7 +39,66 @@ async function moveToStage(token, project, statusField, nodeId, stageName) {
35
39
  await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
36
40
  }
37
41
 
38
- const SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
42
+ // Extrai JSON da resposta do modelo (tolera texto em volta).
43
+ function parseJson(raw) {
44
+ try {
45
+ return JSON.parse(raw);
46
+ } catch {
47
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
48
+ if (!jsonMatch) throw new Error('Claude did not return valid JSON');
49
+ return JSON.parse(jsonMatch[0]);
50
+ }
51
+ }
52
+
53
+ // Prefixo de título das sub-issues geradas por cada tipo decompoível.
54
+ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
55
+
56
+ /**
57
+ * Guard de idempotência do decompose (função PURA — testável).
58
+ *
59
+ * Skip se a issue já foi decomposta: label `spec-wave:decomposed` presente OU
60
+ * sub-issues já contêm um item do tipo-alvo (Feature → algum `[STORY]` no
61
+ * título; RFC → algum `[TASK]`). Sub-issues de outro tipo não contam.
62
+ *
63
+ * @param {object} params
64
+ * @param {Array<string|{name: string}>} [params.labels] labels da issue
65
+ * @param {Array<{ number?: number, title?: string }>} [params.subIssues] sub-issues existentes
66
+ * @param {string} params.type tipo da issue ('Feature' | 'RFC')
67
+ * @returns {{ skip: boolean, reason: string }}
68
+ */
69
+ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {}) {
70
+ const names = labels
71
+ .map(l => (typeof l === 'string' ? l : l?.name))
72
+ .filter(Boolean);
73
+ if (names.includes(LABEL_DECOMPOSED)) {
74
+ return { skip: true, reason: `a issue já tem a label \`${LABEL_DECOMPOSED}\`` };
75
+ }
76
+
77
+ const prefix = CHILD_PREFIX[type];
78
+ if (prefix) {
79
+ const existing = subIssues.find(s => (s.title || '').includes(prefix));
80
+ if (existing) {
81
+ const ref = existing.number ? ` (ex.: #${existing.number} — ${existing.title})` : '';
82
+ return { skip: true, reason: `a issue já tem sub-issues \`${prefix}\`${ref}` };
83
+ }
84
+ }
85
+
86
+ return { skip: false, reason: '' };
87
+ }
88
+
89
+ // Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
90
+ // anexar ao comentário final ('' se limpo).
91
+ function formatItemsLintWarning(texts) {
92
+ const result = lintLanguage(texts.join('\n\n'), { lang: TARGET_LANGUAGE });
93
+ if (result.ok) return '';
94
+ const excerpts = result.findings
95
+ .slice(0, 5)
96
+ .map(f => `\`${f.excerpt.replace(/\s+/g, ' ').trim()}\``)
97
+ .join(', ');
98
+ return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
99
+ }
100
+
101
+ const FEATURE_SYSTEM_PROMPT = `Você é um Tech Lead experiente em decomposição de trabalho ágil.
39
102
  A partir da Feature fornecida (com spec.md e plan.md), gere uma lista de Stories e Tasks.
40
103
 
41
104
  Responda APENAS com JSON válido neste formato:
@@ -45,6 +108,7 @@ Responda APENAS com JSON válido neste formato:
45
108
  "title": "Título curto da story (apenas a parte 'quero', sem prefixo)",
46
109
  "userStory": "Como <perfil>, quero <objetivo>, para <benefício>",
47
110
  "body": "Descrição complementar da story com contexto e critérios de aceite relevantes",
111
+ "dependsOn": [0],
48
112
  "tasks": [
49
113
  {
50
114
  "title": "Título técnico curto da task (sem prefixo)",
@@ -61,48 +125,41 @@ Regras:
61
125
  - "body" é texto complementar (contexto, critérios de aceite); não repita o título
62
126
  - Cada Story deve ter 2–5 Tasks associadas
63
127
  - Tasks devem ser atividades técnicas concretas, com "title" curto e "body" detalhado
64
- - Gere entre 3 e 7 Stories por Feature`;
128
+ - Gere entre 3 e 7 Stories por Feature
129
+ - Ordene as stories na sequência de implementação — a ORDEM da lista importa
130
+ - "dependsOn" (opcional): índices 0-based das stories ANTERIORES na lista das quais esta story depende. Referencie apenas índices menores que o da própria story. Use [] quando a story puder ser feita em paralelo (sem dependências); se omitido, assume-se dependência da story anterior (sequencial)`;
65
131
 
66
- export async function decompose({ issueNumber }) {
67
- const token = await resolveToken();
68
- // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
69
- // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
70
- const projectToken = process.env.PROJECT_TOKEN || token;
71
- const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
132
+ const RFC_SYSTEM_PROMPT = `Você é um Tech Lead experiente. A partir do RFC fornecido (proposta técnica/de processo), gere a lista de Tasks técnicas concretas necessárias para implementá-lo.
72
133
 
73
- if (!owner || !repo) {
74
- throw new Error(
75
- 'GITHUB_REPOSITORY env var não definida.\n' +
76
- 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
77
- ' GITHUB_REPOSITORY=owner/repo spec-wave decompose --issue-number 1'
78
- );
79
- }
134
+ Responda APENAS com JSON válido neste formato:
135
+ {
136
+ "tasks": [
137
+ {
138
+ "title": "Título técnico curto da task (sem prefixo)",
139
+ "body": "Descrição técnica detalhada (o que fazer, áreas/arquivos afetados, critério de pronto)"
140
+ }
141
+ ]
142
+ }
80
143
 
81
- const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
82
- const slug = slugify(issue.title);
144
+ Regras:
145
+ - "title" CURTO (máx. ~60 caracteres), sem prefixo.
146
+ - "body" detalhado e acionável.
147
+ - Gere entre 3 e 10 Tasks concretas que, juntas, cubram o RFC.`;
83
148
 
84
- // Carrega projeto e resolve campo Status uma vez (reutilizado em todos os itens).
85
- const project = loadProject();
86
- let statusField = null;
87
- if (project?.id && READY_STAGE) {
88
- try {
89
- statusField = await resolveStatusField(projectToken, project);
90
- } catch (err) {
91
- console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
92
- }
93
- }
149
+ // Decompõe uma Feature em Stories (+ Tasks), cada uma vinculada como sub-issue.
150
+ async function decomposeFeature(ctx) {
151
+ const { token, projectToken, owner, repo, issue, issueNumber, project, statusField } = ctx;
152
+ const slug = slugify(issue.title);
94
153
  const featureDir = `docs/features/${slug}`;
95
154
 
96
155
  const planContent = existsSync(`${featureDir}/plan.md`)
97
156
  ? readFileSync(`${featureDir}/plan.md`, 'utf-8')
98
157
  : '(plan.md não encontrado)';
99
-
100
158
  const specContent = existsSync(`${featureDir}/spec.md`)
101
159
  ? readFileSync(`${featureDir}/spec.md`, 'utf-8')
102
160
  : '(spec.md não encontrado)';
103
161
 
104
- console.log(`Decompondo feature: ${issue.title}`);
105
-
162
+ console.log(`Decompondo Feature: ${issue.title}`);
106
163
  const userContent = [
107
164
  `Feature: ${issue.title}`,
108
165
  `Issue #${issueNumber}`,
@@ -110,42 +167,79 @@ export async function decompose({ issueNumber }) {
110
167
  `\n## plan.md\n${planContent}`,
111
168
  ].join('\n');
112
169
 
113
- const raw = await generateDocument(SYSTEM_PROMPT, userContent);
170
+ const decomposition = parseJson(await generateDocument(FEATURE_SYSTEM_PROMPT, userContent));
114
171
 
115
- let decomposition;
172
+ // Crítica adversarial ANTES de criar qualquer issue: stories que contradizem
173
+ // a spec/plan não devem virar trabalho. Crítica indisponível → só avisa.
174
+ let critique = null;
116
175
  try {
117
- decomposition = JSON.parse(raw);
118
- } catch {
119
- // Try to extract JSON from the response
120
- const jsonMatch = raw.match(/\{[\s\S]*\}/);
121
- if (!jsonMatch) throw new Error('Claude did not return valid JSON');
122
- decomposition = JSON.parse(jsonMatch[0]);
176
+ critique = await runCritique({
177
+ kind: 'stories',
178
+ spec: existsSync(`${featureDir}/spec.md`) ? specContent : null,
179
+ plan: existsSync(`${featureDir}/plan.md`) ? planContent : null,
180
+ stories: decomposition.stories,
181
+ });
182
+ } catch (err) {
183
+ console.warn(`Crítica adversarial indisponível: ${err.message}`);
184
+ await commentOnIssue(
185
+ token, owner, repo, parseInt(issueNumber, 10),
186
+ `⚠️ crítica adversarial indisponível (erro: ${err.message}) — prosseguindo com a decomposição.`
187
+ ).catch(() => {});
188
+ }
189
+ if (critique) {
190
+ await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown)
191
+ .catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
192
+ if (critique.grave) {
193
+ console.log('Crítica adversarial apontou findings GRAVES — nenhuma story foi criada.');
194
+ await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
195
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
196
+ return;
197
+ }
123
198
  }
124
199
 
125
- // node id da Feature — necessário para vincular as stories como sub-issues.
126
200
  const featureNodeId = issue.node_id;
127
-
128
201
  const created = [];
202
+ const createdStories = []; // issues criadas, na ordem dos índices das stories
203
+ const generatedTexts = []; // títulos+corpos para o lint de idioma final
129
204
 
130
- for (const story of decomposition.stories) {
205
+ for (let i = 0; i < decomposition.stories.length; i++) {
206
+ const story = decomposition.stories[i];
131
207
  console.log(`Criando story: ${story.title}`);
132
208
  const storyTitle = `[STORY] ${story.title}`;
133
- // Corpo: user story completa (Como/quero/para) + texto complementar.
134
- const storyBody = [story.userStory, story.body]
209
+ let storyBody = [story.userStory, story.body]
135
210
  .map(s => (s || '').trim())
136
211
  .filter(Boolean)
137
212
  .join('\n\n') || '_(sem descrição)_';
213
+
214
+ // Dependências: índices 0-based de stories anteriores. Default quando o
215
+ // campo está ausente: sequencial (story i depende da i-1). [] explícito =
216
+ // sem dependências. Índices inválidos/futuros são ignorados.
217
+ const depIndexes = Array.isArray(story.dependsOn)
218
+ ? [...new Set(story.dependsOn.filter(d => Number.isInteger(d) && d >= 0 && d < i))]
219
+ : (i > 0 ? [i - 1] : []);
220
+ const depIssues = depIndexes.map(idx => createdStories[idx]).filter(Boolean);
221
+ const depLine = formatDependencyLine(depIssues.map(d => d.number));
222
+ if (depLine) storyBody += `\n\n${depLine}`;
223
+
138
224
  const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
139
- created.push({ type: 'story', title: storyTitle, url: createdStory.url });
225
+ created.push({ title: storyTitle, url: createdStory.url });
226
+ createdStories.push(createdStory);
227
+ generatedTexts.push(storyTitle, storyBody);
228
+
229
+ // Relação nativa blocked_by (best-effort — a linha "Depende de" já basta).
230
+ for (const dep of depIssues) {
231
+ try {
232
+ await addBlockedBy(token, owner, repo, createdStory.number, dep.id);
233
+ } catch (err) {
234
+ console.warn(` Falha ao marcar story #${createdStory.number} como bloqueada por #${dep.number}: ${err.message}`);
235
+ }
236
+ }
140
237
 
141
- // Vincula a story como sub-issue da Feature (relação nativa do GitHub).
142
238
  try {
143
239
  await addSubIssue(token, featureNodeId, createdStory.nodeId);
144
240
  } catch (err) {
145
241
  console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
146
242
  }
147
-
148
- // Move story para Ready no board.
149
243
  try {
150
244
  await moveToStage(projectToken, project, statusField, createdStory.nodeId, READY_STAGE);
151
245
  } catch (err) {
@@ -157,15 +251,12 @@ export async function decompose({ issueNumber }) {
157
251
  const taskTitle = `[TASK] ${task.title}`;
158
252
  const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
159
253
  const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
160
-
161
- // Vincula a task como sub-issue da Story.
254
+ generatedTexts.push(taskTitle, taskBody);
162
255
  try {
163
256
  await addSubIssue(token, createdStory.nodeId, createdTask.nodeId);
164
257
  } catch (err) {
165
258
  console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
166
259
  }
167
-
168
- // Move task para Ready no board.
169
260
  try {
170
261
  await moveToStage(projectToken, project, statusField, createdTask.nodeId, READY_STAGE);
171
262
  } catch (err) {
@@ -174,7 +265,6 @@ export async function decompose({ issueNumber }) {
174
265
  }
175
266
  }
176
267
 
177
- // Move a própria Feature para Ready no board.
178
268
  try {
179
269
  await moveToStage(projectToken, project, statusField, featureNodeId, READY_STAGE);
180
270
  if (project?.id && statusField) console.log(`Feature movida para "${READY_STAGE}" no board.`);
@@ -182,16 +272,143 @@ export async function decompose({ issueNumber }) {
182
272
  console.warn(`Falha ao mover Feature para "${READY_STAGE}": ${err.message}`);
183
273
  }
184
274
 
185
- // Remove trigger label
275
+ // Marca a Feature como decomposta (guard de idempotência em runs futuros).
276
+ try {
277
+ await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
278
+ } catch (err) {
279
+ console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
280
+ }
186
281
  await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
187
-
188
- const storyList = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
282
+ const list = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
189
283
  await commentOnIssue(
190
284
  token, owner, repo, parseInt(issueNumber, 10),
191
285
  `🔀 **Decomposição concluída!**\n\n` +
192
- `Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${storyList}\n\n` +
193
- `Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.`
286
+ `Foram criados ${decomposition.stories.length} stories e suas tasks:\n\n${list}\n\n` +
287
+ `Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.` +
288
+ formatItemsLintWarning(generatedTexts)
194
289
  );
195
-
196
290
  console.log(`Decomposição concluída: ${decomposition.stories.length} stories criadas.`);
197
291
  }
292
+
293
+ // Decompõe um RFC diretamente em Tasks (sem Stories), cada uma vinculada como
294
+ // sub-issue do RFC.
295
+ async function decomposeRFC(ctx) {
296
+ const { token, projectToken, owner, repo, issue, issueNumber, project, statusField } = ctx;
297
+ console.log(`Decompondo RFC: ${issue.title}`);
298
+
299
+ const userContent = [
300
+ `RFC: ${issue.title}`,
301
+ `Issue #${issueNumber}`,
302
+ `\n## Descrição\n${issue.body || '(sem descrição)'}`,
303
+ ].join('\n');
304
+
305
+ const decomposition = parseJson(await generateDocument(RFC_SYSTEM_PROMPT, userContent));
306
+ const rfcNodeId = issue.node_id;
307
+ const tasks = decomposition.tasks || [];
308
+ const created = [];
309
+ const generatedTexts = []; // títulos+corpos para o lint de idioma final
310
+
311
+ for (const task of tasks) {
312
+ console.log(`Criando task: ${task.title}`);
313
+ const taskTitle = `[TASK] ${task.title}`;
314
+ const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
315
+ const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
316
+ created.push({ title: taskTitle, url: createdTask.url });
317
+ generatedTexts.push(taskTitle, taskBody);
318
+
319
+ try {
320
+ await addSubIssue(token, rfcNodeId, createdTask.nodeId);
321
+ } catch (err) {
322
+ console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
323
+ }
324
+ try {
325
+ await moveToStage(projectToken, project, statusField, createdTask.nodeId, READY_STAGE);
326
+ } catch (err) {
327
+ console.warn(` Falha ao mover task #${createdTask.number} para "${READY_STAGE}": ${err.message}`);
328
+ }
329
+ }
330
+
331
+ // Marca o RFC como decomposto (guard de idempotência em runs futuros).
332
+ try {
333
+ await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_DECOMPOSED);
334
+ } catch (err) {
335
+ console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`);
336
+ }
337
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
338
+ const list = created.map(t => `- ${t.url} — ${t.title}`).join('\n');
339
+ await commentOnIssue(
340
+ token, owner, repo, parseInt(issueNumber, 10),
341
+ `🔀 **Decomposição do RFC concluída!**\n\n` +
342
+ `Foram criadas ${created.length} tasks:\n\n${list}\n\n` +
343
+ `Mova o card para **📋 Backlog Técnico** para iniciar o desenvolvimento.` +
344
+ formatItemsLintWarning(generatedTexts)
345
+ );
346
+ console.log(`Decomposição concluída: ${created.length} tasks criadas.`);
347
+ }
348
+
349
+ export async function decompose({ issueNumber }) {
350
+ const token = await resolveToken();
351
+ // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
352
+ // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
353
+ const projectToken = process.env.PROJECT_TOKEN || token;
354
+ const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
355
+
356
+ if (!owner || !repo) {
357
+ throw new Error(
358
+ 'GITHUB_REPOSITORY env var não definida.\n' +
359
+ 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
360
+ ' GITHUB_REPOSITORY=owner/repo spec-wave decompose --issue-number 1'
361
+ );
362
+ }
363
+
364
+ const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
365
+ const type = detectIssueType(issue);
366
+
367
+ // Só Feature (→ Stories) e RFC (→ Tasks) podem ser decompostos.
368
+ if (!DECOMPOSE_TARGETS[type]) {
369
+ console.log(`Issue #${issueNumber} é ${type || 'desconhecido'} — decompose não se aplica.`);
370
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
371
+ await commentOnIssue(
372
+ token, owner, repo, parseInt(issueNumber, 10),
373
+ `ℹ️ **decompose não se aplica a ${type || 'este tipo'}.** ` +
374
+ `Use em **Features** (gera Stories + Tasks) ou **RFCs** (gera Tasks).`
375
+ ).catch(() => {});
376
+ return;
377
+ }
378
+
379
+ // Guard de idempotência: label spec-wave:decomposed ou sub-issues do
380
+ // tipo-alvo já existentes → não re-decompõe (evita duplicar stories/tasks).
381
+ let subIssues = [];
382
+ try {
383
+ subIssues = await listSubIssues(token, issue.node_id);
384
+ } catch (err) {
385
+ console.warn(`Não foi possível listar sub-issues: ${err.message} — seguindo sem o guard de sub-issues.`);
386
+ subIssues = [];
387
+ }
388
+ const guard = shouldSkipDecompose({ labels: issue.labels || [], subIssues, type });
389
+ if (guard.skip) {
390
+ console.log(`Decompose ignorado: ${guard.reason}.`);
391
+ await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:decompose');
392
+ await commentOnIssue(
393
+ token, owner, repo, parseInt(issueNumber, 10),
394
+ `⏭️ **decompose ignorado:** ${guard.reason}. Para forçar, remova a label ` +
395
+ `\`${LABEL_DECOMPOSED}\` (e apague as sub-issues antigas se quiser re-gerar).`
396
+ ).catch(() => {});
397
+ return;
398
+ }
399
+
400
+ // Projeto + campo Status (reutilizado em todos os itens).
401
+ const project = loadProject();
402
+ let statusField = null;
403
+ if (project?.id) {
404
+ try {
405
+ statusField = await resolveStatusField(projectToken, project);
406
+ } catch (err) {
407
+ console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
408
+ }
409
+ }
410
+
411
+ const ctx = { token, projectToken, owner, repo, issue, issueNumber, project, statusField };
412
+ if (type === 'Feature') await decomposeFeature(ctx);
413
+ else if (type === 'RFC') await decomposeRFC(ctx);
414
+ }