@spec-wave/cli 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -343,6 +343,7 @@ export async function listSubIssues(token, issueNodeId) {
343
343
  createdAt
344
344
  state
345
345
  stateReason
346
+ milestone { number title }
346
347
  labels(first: 20) { nodes { name } }
347
348
  }
348
349
  }
@@ -363,10 +364,46 @@ export async function listSubIssues(token, issueNodeId) {
363
364
  // distinguir "entregue" de "descartada no rescopo".
364
365
  state: (n.state || '').toLowerCase() || null,
365
366
  stateReason: (n.stateReason || '').toLowerCase() || null,
367
+ // O `order` compara com o milestone do pai: sub-issue sem milestone (ou em
368
+ // outro) é tão invisível numa visão de release quanto Etapa vazia no board.
369
+ milestone: n.milestone ? { number: n.milestone.number, title: n.milestone.title } : null,
366
370
  labels: (n.labels?.nodes || []).map(l => l.name),
367
371
  }));
368
372
  }
369
373
 
374
+ /**
375
+ * Os PRs que o GitHub reconhece como fechadores DESTA issue — o inverso de
376
+ * listLinkedIssuesForPR, pela mesma fonte de vínculo (`Closes #N` + UI). É como
377
+ * o `merge` descobre o PR de cada Story sem depender de convenção de nome de
378
+ * branch. `includeClosedPrs` de propósito: PR já mergeado é o que diz que a
379
+ * Story está entregue.
380
+ *
381
+ * @returns {Promise<Array<{number, state, merged, isDraft, baseRefName, headRefName}>>}
382
+ */
383
+ export async function listIssuePullRequests(token, issueNodeId) {
384
+ const client = makeClient(token);
385
+ const result = await client(`
386
+ query IssuePRs($id: ID!) {
387
+ node(id: $id) {
388
+ ... on Issue {
389
+ closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
390
+ nodes { number state merged isDraft baseRefName headRefName }
391
+ }
392
+ }
393
+ }
394
+ }
395
+ `, { id: issueNodeId });
396
+ const nodes = result.node?.closedByPullRequestsReferences?.nodes || [];
397
+ return nodes.map(n => ({
398
+ number: n.number,
399
+ state: (n.state || '').toLowerCase(), // open | closed | merged
400
+ merged: Boolean(n.merged),
401
+ isDraft: Boolean(n.isDraft),
402
+ baseRefName: n.baseRefName || null,
403
+ headRefName: n.headRefName || null,
404
+ }));
405
+ }
406
+
370
407
  /**
371
408
  * Issues que o GitHub reconhece como fechadas por este PR
372
409
  * (`Closes #N` e o vínculo feito pela UI). É a MESMA lista que fecha as issues
@@ -511,6 +511,27 @@ export async function getFileContent(token, owner, repo, path, ref) {
511
511
  }
512
512
  }
513
513
 
514
+ // Reaponta a BASE de um PR aberto. Usado pelo `merge` numa pilha de Stories:
515
+ // depois que o PR anterior mergeia, o dependente é reapontado para a default
516
+ // ANTES de qualquer branch ser apagada — apagar primeiro foi o que fechou um
517
+ // PR empilhado sem volta.
518
+ export async function updatePRBase(token, owner, repo, prNumber, base) {
519
+ const octokit = makeOctokit(token);
520
+ await octokit.rest.pulls.update({ owner, repo, pull_number: prNumber, base });
521
+ }
522
+
523
+ // Mergeia um PR. `merge` (commit de merge) é o método da pilha de Stories:
524
+ // squash reescreveria o merge-base dos dependentes e o retarget deixaria de
525
+ // ser limpo. O GitHub recusa (405) draft, check obrigatório pendente e
526
+ // conflito — o chamador decide o que fazer com a fila.
527
+ export async function mergePR(token, owner, repo, prNumber, { method = 'merge' } = {}) {
528
+ const octokit = makeOctokit(token);
529
+ const res = await octokit.rest.pulls.merge({
530
+ owner, repo, pull_number: prNumber, merge_method: method,
531
+ });
532
+ return { merged: Boolean(res.data.merged), sha: res.data.sha || null };
533
+ }
534
+
514
535
  export async function listPullRequestReviews(token, owner, repo, prNumber) {
515
536
  const octokit = makeOctokit(token);
516
537
  return await octokit.paginate(octokit.rest.pulls.listReviews, {
package/src/cli.mjs CHANGED
@@ -147,7 +147,7 @@ export function buildProgram() {
147
147
  .command('run')
148
148
  .description('Executa LOCALMENTE o próximo passo do fluxo (o que a label dispararia no Actions)')
149
149
  .argument('[issue]', 'Número da issue (Feature, Bug ou RFC)')
150
- .option('--pr <n>', 'Modo PR: decide entre code-review e qa pelo estado das reviews')
150
+ .option('--pr <n>', 'Modo PR: decide entre code-review e qa pelo estado das reviews e do merge')
151
151
  .option('--dry-run', 'Decide e explica sem executar nada')
152
152
  .option('--yes', 'Confirma o passo que exige confirmação')
153
153
  .option('--apply', 'Autoriza especificamente o decompose-apply (erra se o passo pendente for outro)')
@@ -172,6 +172,17 @@ export function buildProgram() {
172
172
  await preflight(options).catch(err => { console.error(err.message); process.exit(1); });
173
173
  });
174
174
 
175
+ program
176
+ .command('audit')
177
+ .description('Cruza as specs de uma milestone: dependência órfã, bloqueante em milestone posterior, sobreposição com código')
178
+ .requiredOption('--milestone <nome>', 'Título da milestone a auditar')
179
+ .option('--critique', 'Roda também a crítica adversarial de conjunto (uma chamada de modelo por milestone)')
180
+ .option('--json', 'Imprime o relatório em JSON')
181
+ .action(async (options) => {
182
+ const { audit } = await import('./commands/audit.mjs');
183
+ await audit(options).catch(err => { console.error(err.message); process.exit(1); });
184
+ });
185
+
175
186
  program
176
187
  .command('mode')
177
188
  .description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
@@ -309,7 +320,7 @@ export function buildProgram() {
309
320
 
310
321
  program
311
322
  .command('qa')
312
- .description('Move Feature para QA ao aprovar um PR (usado pelo GitHub Action)')
323
+ .description('Move Feature para QA ao aprovar ou mergear um PR (usado pelo GitHub Action)')
313
324
  .requiredOption('--pr-number <n>', 'Número do Pull Request')
314
325
  .action(async (options) => {
315
326
  const { qa } = await import('./commands/qa.mjs');
@@ -336,6 +347,18 @@ export function buildProgram() {
336
347
  await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
337
348
  });
338
349
 
350
+ program
351
+ .command('merge')
352
+ .description('Mergeia os PRs empilhados das Stories de uma Feature na ordem topológica e move o board até QA')
353
+ .argument('<feature>', 'Número da issue da Feature, ex.: 12 ou #12')
354
+ .option('--yes', 'Executa os merges (sem isso, só mostra o plano)')
355
+ .option('--dry-run', 'Mostra o plano e sai')
356
+ .option('--keep-branches', 'Não apaga as branches das Stories depois do merge')
357
+ .action(async (feature, options) => {
358
+ const { merge } = await import('./commands/merge.mjs');
359
+ await merge({ feature, ...options }).catch(err => { console.error(err.message); process.exit(1); });
360
+ });
361
+
339
362
  program
340
363
  .command('task')
341
364
  .description('Gerencia uma Task no board: start (Status "In Progress") ou done (Done)')
@@ -408,5 +431,19 @@ export function buildProgram() {
408
431
  await doctor().catch(err => { console.error(err.message); process.exit(1); });
409
432
  });
410
433
 
434
+ // O mapa por tema no fim do --help: 29 comandos em lista plana dizem O QUE
435
+ // cada um faz, mas não POR ONDE começar nem em que ordem o fluxo anda.
436
+ program.addHelpText('after', `
437
+ Fluxo típico (cada tema, na ordem):
438
+ configurar init · doctor · mode <actions|local> · update
439
+ especificar preflight --milestone → specs (label ou run) → audit --milestone [--critique]
440
+ planejar run <issue> (plan → critique → validate) → move <n> ready
441
+ decompor run <issue> (decompose → --apply) → order <feature>
442
+ implementar implement <issue> · task start/done · story review
443
+ entregar merge <feature> (PRs empilhados, na ordem) · run --pr <n> (board até QA)
444
+ acompanhar info · order · move · repair-stage
445
+
446
+ Docs: https://astratech-net-br.github.io/spec-wave-cli/ · spec-wave <comando> --help`);
447
+
411
448
  return program;
412
449
  }
@@ -0,0 +1,280 @@
1
+ // Auditoria de dependências de uma milestone — o passo entre "as specs estão
2
+ // mergeadas" e "vamos gerar os planos".
3
+ //
4
+ // A crítica adversarial roda por Feature; este comando cruza as specs COMO
5
+ // CONJUNTO e responde as três perguntas que hoje só apareciam se alguém lesse
6
+ // as dez juntas: alguma dependência declarada não é criada por ninguém? Alguma
7
+ // bloqueante vive em milestone posterior à de quem depende dela? Alguma spec
8
+ // descreve o que já existe em código?
9
+ //
10
+ // Toda a decisão mora em `lib/spec-audit.mjs` (puro); aqui é só coleta: as
11
+ // Features de todas as milestones (o catálogo do que uma spec pode citar), o
12
+ // conteúdo da spec de cada Feature-alvo nas quatro camadas (loadArtifact — uma
13
+ // spec em PR aberto também é auditável), e a lista de arquivos do repositório
14
+ // para a heurística de código.
15
+ //
16
+ // Comando LOCAL, fora da tabela STEPS de propósito: ele é por milestone, não
17
+ // por issue — não há label que o dispare nem lugar para ela no fluxo.
18
+
19
+ import * as p from '@clack/prompts';
20
+ import chalk from 'chalk';
21
+ import { readdirSync } from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ import { resolveToken } from '../api/auth.mjs';
25
+ import {
26
+ getIssue, getRepoDefaultBranch, listMilestones, listIssuesByMilestone,
27
+ } from '../api/github-rest.mjs';
28
+ import { CONFIG_FILE } from '../config.mjs';
29
+ import { detectIssueType } from '../lib/issue-type.mjs';
30
+ import { featureDocPaths } from '../lib/doc-paths.mjs';
31
+ import { loadArtifact } from '../lib/doc-source.mjs';
32
+ import { loadConfig } from '../lib/project-root.mjs';
33
+ import { runCritique } from '../lib/critique.mjs';
34
+ import { slugify } from '../lib/slugify.mjs';
35
+ import { loadStaticTechContext, TECH_CONTEXT_PATH } from '../lib/tech-context.mjs';
36
+ import {
37
+ auditMilestone, auditVerdict, dependencySection, issueRefs,
38
+ } from '../lib/spec-audit.mjs';
39
+ import { resolveMilestone } from './preflight.mjs';
40
+
41
+ // A heurística de código procura componente/rota, não texto: docs/ casaria com
42
+ // o próprio slug da spec, e um README citando a feature não é implementação.
43
+ const IGNORED_DIRS = new Set([
44
+ 'node_modules', 'dist', 'build', 'coverage', 'vendor', 'target', 'tmp', 'docs',
45
+ ]);
46
+ const IGNORED_EXTS = new Set(['.md', '.txt', '.lock', '.log', '.svg', '.png', '.jpg']);
47
+ const MAX_FILES = 20000;
48
+
49
+ /**
50
+ * Caminhos relativos dos arquivos do repositório (I/O — nunca lança).
51
+ * Profundidade e volume limitados: é insumo de heurística, não um índice.
52
+ */
53
+ export function listRepoFiles(root, { maxFiles = MAX_FILES, maxDepth = 8 } = {}) {
54
+ const out = [];
55
+ const walk = (dir, depth) => {
56
+ if (depth > maxDepth || out.length >= maxFiles) return;
57
+ let entries;
58
+ try {
59
+ entries = readdirSync(dir, { withFileTypes: true });
60
+ } catch {
61
+ return;
62
+ }
63
+ for (const e of entries) {
64
+ if (out.length >= maxFiles) return;
65
+ if (e.name.startsWith('.') || IGNORED_DIRS.has(e.name)) continue;
66
+ const abs = path.join(dir, e.name);
67
+ if (e.isDirectory()) walk(abs, depth + 1);
68
+ else if (e.isFile() && !IGNORED_EXTS.has(path.extname(e.name).toLowerCase())) {
69
+ out.push(path.relative(root, abs));
70
+ }
71
+ }
72
+ };
73
+ walk(root, 0);
74
+ return out;
75
+ }
76
+
77
+ export async function audit({ milestone: milestoneArg, critique = false, json = false } = {}) {
78
+ const falhar = (msg) => {
79
+ if (json) console.log(JSON.stringify({ erro: msg }, null, 2));
80
+ else p.log.error(msg);
81
+ process.exitCode = 1;
82
+ };
83
+
84
+ if (!json) p.intro(chalk.bold('spec-wave audit — dependências da milestone'));
85
+
86
+ const { config, root, error: configError } = loadConfig();
87
+ if (configError || !config?.owner || !config?.repo) {
88
+ return falhar(`${configError || `${CONFIG_FILE} sem owner/repo`} — rode \`spec-wave init\` antes.`);
89
+ }
90
+ const { owner, repo } = config;
91
+
92
+ let token;
93
+ try {
94
+ token = await resolveToken();
95
+ } catch (err) {
96
+ return falhar(`Sem token utilizável para ${owner}/${repo}: ${err.message}`);
97
+ }
98
+
99
+ const s = json ? null : p.spinner();
100
+ s?.start('Montando o catálogo de Features...');
101
+
102
+ let base;
103
+ let milestones;
104
+ try {
105
+ base = await getRepoDefaultBranch(token, owner, repo);
106
+ milestones = await listMilestones(token, owner, repo);
107
+ } catch (err) {
108
+ s?.stop('');
109
+ return falhar(`Não foi possível ler o repositório ${owner}/${repo}: ${err.message}`);
110
+ }
111
+ const { milestone, error: msError } = resolveMilestone(milestones, milestoneArg);
112
+ if (msError) {
113
+ s?.stop('');
114
+ return falhar(msError);
115
+ }
116
+
117
+ // O catálogo vem de TODAS as milestones: a inversão de sequenciamento é
118
+ // exatamente uma dependência que resolve para FORA da milestone-alvo.
119
+ const catalog = [];
120
+ let targetIssues = [];
121
+ try {
122
+ for (const m of milestones) {
123
+ const issues = await listIssuesByMilestone(token, owner, repo, m.number);
124
+ if (m.number === milestone.number) targetIssues = issues;
125
+ for (const i of issues) {
126
+ if (detectIssueType(i) !== 'Feature') continue;
127
+ catalog.push({
128
+ number: i.number,
129
+ title: i.title,
130
+ slug: slugify(i.title),
131
+ closed: i.state === 'closed',
132
+ milestone: { number: m.number, title: m.title, due_on: m.due_on ?? null },
133
+ });
134
+ }
135
+ }
136
+ } catch (err) {
137
+ s?.stop('');
138
+ return falhar(`Não foi possível listar as issues por milestone: ${err.message}`);
139
+ }
140
+
141
+ // A spec de cada Feature-alvo, nas quatro camadas (disco, base, branch, PR) —
142
+ // em série pelo mesmo motivo do preflight: rate limit secundário.
143
+ const features = [];
144
+ for (const issue of targetIssues) {
145
+ if (detectIssueType(issue) !== 'Feature' || issue.state === 'closed') continue;
146
+ const docs = featureDocPaths(root, issue, 'Feature');
147
+ const { content } = await loadArtifact({
148
+ token, owner, repo, root, base,
149
+ pathRel: docs.spec.rel, doc: 'spec', issueNumber: issue.number,
150
+ });
151
+ features.push({
152
+ number: issue.number,
153
+ title: issue.title,
154
+ slug: slugify(issue.title),
155
+ milestone: { number: milestone.number, title: milestone.title, due_on: milestone.due_on ?? null },
156
+ spec: content,
157
+ });
158
+ }
159
+
160
+ if (features.length === 0) {
161
+ s?.stop('');
162
+ return falhar(`Nenhuma Feature aberta na milestone "${milestone.title}".`);
163
+ }
164
+
165
+ // Referências `#N` que o catálogo não conhece: issue sem milestone, Story de
166
+ // outra Feature, ou número que não existe. A API é quem diferencia.
167
+ const conhecidas = new Set(catalog.map(f => f.number));
168
+ const desconhecidas = new Set();
169
+ for (const f of features) {
170
+ const section = f.spec ? dependencySection(f.spec) : null;
171
+ for (const n of issueRefs(section || '')) {
172
+ if (!conhecidas.has(n)) desconhecidas.add(n);
173
+ }
174
+ }
175
+ const missingRefs = [];
176
+ for (const n of desconhecidas) {
177
+ const issue = await getIssue(token, owner, repo, n).catch(() => null);
178
+ if (!issue) { missingRefs.push(n); continue; }
179
+ catalog.push({
180
+ number: issue.number,
181
+ title: issue.title,
182
+ slug: slugify(issue.title),
183
+ closed: issue.state === 'closed',
184
+ milestone: issue.milestone
185
+ ? { number: issue.milestone.number, title: issue.milestone.title, due_on: issue.milestone.due_on ?? null }
186
+ : null,
187
+ });
188
+ }
189
+
190
+ // As decisões de modelagem do tech_context entram na auditoria: recurso
191
+ // compartilhado com `criada_por: SEM DONO` é pendência de planejamento, não
192
+ // nota perdida num YAML. Falha de parse é aviso — o arquivo é do usuário.
193
+ const { context: techContext, error: techError } = loadStaticTechContext(root);
194
+ if (techError && !json) {
195
+ p.log.warn(`${TECH_CONTEXT_PATH} não parseou (${techError}) — decisões de modelagem fora da auditoria.`);
196
+ }
197
+
198
+ const resultado = auditMilestone({
199
+ features, catalog, missingRefs, files: listRepoFiles(root), techContext,
200
+ });
201
+ const veredito = auditVerdict(resultado);
202
+ s?.stop(`Milestone "${milestone.title}": ${features.length} Feature(s) auditada(s).`);
203
+
204
+ // A crítica de conjunto: UMA chamada de modelo sobre todas as specs juntas,
205
+ // atrás do que o determinístico não pega — a mesma regra contada de dois
206
+ // jeitos. Grave é material (é contradição entre requisitos, por definição);
207
+ // e falhar aqui falha o comando: quem pediu --critique não pode receber um
208
+ // "ok" que só cobriu a metade barata.
209
+ let critica = null;
210
+ if (critique) {
211
+ const comSpec = features.filter(f => f.spec);
212
+ if (comSpec.length < 2) {
213
+ veredito.avisos.push(
214
+ `Crítica de conjunto não rodou: ${comSpec.length} spec(s) disponível(is) — conjunto de um não tem par.`
215
+ );
216
+ } else {
217
+ const sc = json ? null : p.spinner();
218
+ sc?.start(`Crítica de conjunto sobre ${comSpec.length} specs...`);
219
+ try {
220
+ critica = await runCritique({
221
+ kind: 'conjunto',
222
+ specs: comSpec.map(f => ({ number: f.number, title: f.title, content: f.spec })),
223
+ cwd: root,
224
+ standalone: true,
225
+ });
226
+ } catch (err) {
227
+ sc?.stop('');
228
+ return falhar(`A crítica de conjunto falhou: ${err.message}`);
229
+ }
230
+ sc?.stop(`Crítica de conjunto: ${critica.findings.length} finding(s) (modelo ${critica.model}).`);
231
+ for (const f of critica.findings) {
232
+ const onde = (f.features || []).map(n => `#${n}`).join(' × ');
233
+ const linha = `crítica de conjunto${onde ? ` [${onde}]` : ''}: ${f.text}`;
234
+ if (f.severity === 'grave') veredito.materiais.push(linha);
235
+ else veredito.avisos.push(linha);
236
+ }
237
+ if (veredito.materiais.length) veredito.status = 'problema';
238
+ else if (veredito.avisos.length) veredito.status = 'aviso';
239
+ }
240
+ }
241
+
242
+ const saida = {
243
+ milestone: { title: milestone.title, number: milestone.number },
244
+ features: features.map(f => ({ number: f.number, title: f.title, temSpec: !!f.spec })),
245
+ auditoria: resultado,
246
+ // `markdown` é o comentário pronto para a issue — quem posta (nas DUAS
247
+ // pontas de cada finding) é o agente que dirige o comando, não a CLI.
248
+ critica: critica
249
+ ? { model: critica.model, findings: critica.findings, markdown: critica.markdown }
250
+ : null,
251
+ veredito,
252
+ };
253
+
254
+ if (json) {
255
+ console.log(JSON.stringify(saida, null, 2));
256
+ if (veredito.status === 'problema') process.exitCode = 1;
257
+ return saida;
258
+ }
259
+
260
+ const grafoLinhas = resultado.grafo.map(g => {
261
+ const deps = g.dependsOn.length ? ` ← depende de ${g.dependsOn.map(d => `#${d}`).join(', ')}` : '';
262
+ return ` #${g.number}${deps}`;
263
+ });
264
+ if (grafoLinhas.length) {
265
+ p.note(grafoLinhas.join('\n'), 'Dependências entre Features (extraídas das specs)');
266
+ }
267
+
268
+ for (const a of veredito.avisos) p.log.warn(a);
269
+ for (const m of veredito.materiais) p.log.error(m);
270
+
271
+ if (veredito.status === 'problema') {
272
+ p.outro(`${veredito.materiais.length} achado(s) material(is). Decida antes de gerar os planos.`);
273
+ process.exitCode = 1;
274
+ return saida;
275
+ }
276
+ p.outro(veredito.status === 'aviso'
277
+ ? 'Nada material — confira os avisos antes de seguir.'
278
+ : 'Dependências da milestone fecham. Siga para os planos.');
279
+ return saida;
280
+ }
@@ -412,6 +412,18 @@ export function buildFeatureContext({
412
412
  `Enquanto houver Story pendente, a Feature permanece em ${STAGE_DEVELOPMENT}.`
413
413
  );
414
414
  lines.push('');
415
+ // O trecho pós-implementação era o único do fluxo inteiramente manual — e o
416
+ // merge de PRs empilhados é ordem-dependente (apagar a branch do primeiro já
417
+ // fechou o segundo). O contexto termina apontando o comando que encapsula a
418
+ // sequência segura, em vez de deixar a mecânica por conta de quem mergeia.
419
+ lines.push(
420
+ '**Ao terminar, informe no relatório final:** os PRs ficam **empilhados** (cada um baseado no anterior) — ' +
421
+ 'o merge é **ordem-dependente**. Depois da revisão humana (marcar cada PR como pronto), o merge é ' +
422
+ `\`npx @spec-wave/cli@latest merge ${feature.number}\`: ele mergeia na ordem das dependências, ` +
423
+ 'reaponta as bases, move o board até 🧪 QA e só apaga as branches no fim. ' +
424
+ '**NUNCA** mergeie um PR da pilha com `--delete-branch` à mão — apagar a branch antes de reapontar o dependente fecha o PR seguinte.'
425
+ );
426
+ lines.push('');
415
427
  lines.push(boardRuleBlockquote());
416
428
 
417
429
  if (skipped.length > 0) {