@spec-wave/cli 0.27.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 +1 -1
- package/src/api/github-graphql.mjs +37 -0
- package/src/api/github-rest.mjs +48 -0
- package/src/cli.mjs +51 -2
- package/src/commands/audit.mjs +280 -0
- package/src/commands/doctor.mjs +40 -16
- package/src/commands/implement.mjs +19 -2
- package/src/commands/install-skill.mjs +18 -8
- package/src/commands/merge.mjs +292 -0
- package/src/commands/move.mjs +26 -11
- package/src/commands/order.mjs +42 -0
- package/src/commands/preflight.mjs +322 -0
- package/src/commands/run.mjs +4 -3
- package/src/commands/update.mjs +143 -12
- package/src/lib/board.mjs +18 -2
- package/src/lib/critique.mjs +64 -8
- package/src/lib/pr-branch.mjs +96 -7
- package/src/lib/pr-step.mjs +12 -7
- package/src/lib/spec-audit.mjs +372 -0
- package/src/lib/tech-context.mjs +20 -14
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/README.md +5 -0
- package/src/plugin/skills/audit/SKILL.md +34 -0
- package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
- package/src/plugin/skills/merge/SKILL.md +34 -0
- package/src/plugin/skills/order/SKILL.md +1 -0
- package/src/plugin/skills/plan/model-prompt.md +1 -0
- package/src/plugin/skills/plan/reference/tech-context.md +6 -0
- package/src/plugin/skills/preparar-feature/SKILL.md +247 -0
- package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
- package/src/plugin/skills/preparar-specs/SKILL.md +191 -0
- package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
- package/src/plugin/skills/preparar-specs/reference/revisao.md +110 -0
- package/src/plugin/skills/update/SKILL.md +10 -4
- package/src/plugin/skills/workflow/SKILL.md +6 -1
- package/src/templates/config/tech_context.yml +13 -0
- package/src/templates/skill/SKILL.md +36 -7
- package/src/templates/workflows/qa.yml +9 -1
package/package.json
CHANGED
|
@@ -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
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -294,6 +294,33 @@ export async function createIssue(token, owner, repo, title, body, labels, { mil
|
|
|
294
294
|
return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url, id: res.data.id };
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
// Milestones do repositório (abertas e fechadas).
|
|
298
|
+
//
|
|
299
|
+
// O usuário fala o TÍTULO da milestone ("v06"), a API de issues filtra pelo
|
|
300
|
+
// NÚMERO. A tradução mora aqui — e a lista completa também serve para a
|
|
301
|
+
// mensagem de erro: "milestone não encontrada" sem dizer quais existem manda o
|
|
302
|
+
// usuário adivinhar entre nome errado e API fora do ar.
|
|
303
|
+
export async function listMilestones(token, owner, repo) {
|
|
304
|
+
const octokit = makeOctokit(token);
|
|
305
|
+
return await octokit.paginate(octokit.rest.issues.listMilestones, {
|
|
306
|
+
owner, repo, state: 'all', per_page: 100,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Issues de uma milestone (pelo NÚMERO dela), abertas e fechadas.
|
|
311
|
+
//
|
|
312
|
+
// `state: 'all'` de propósito: uma Feature fechada continua contando para o
|
|
313
|
+
// inventário — o que muda é que ela não entra na lista do que gerar.
|
|
314
|
+
export async function listIssuesByMilestone(token, owner, repo, milestoneNumber) {
|
|
315
|
+
const octokit = makeOctokit(token);
|
|
316
|
+
const issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
|
|
317
|
+
owner, repo, milestone: String(milestoneNumber), state: 'all', per_page: 100,
|
|
318
|
+
});
|
|
319
|
+
// `listForRepo` devolve Pull Requests junto — eles são issues para a API, e
|
|
320
|
+
// não para o fluxo.
|
|
321
|
+
return issues.filter(i => !i.pull_request);
|
|
322
|
+
}
|
|
323
|
+
|
|
297
324
|
export async function getIssue(token, owner, repo, issueNumber) {
|
|
298
325
|
const octokit = makeOctokit(token);
|
|
299
326
|
const res = await octokit.rest.issues.get({ owner, repo, issue_number: issueNumber });
|
|
@@ -484,6 +511,27 @@ export async function getFileContent(token, owner, repo, path, ref) {
|
|
|
484
511
|
}
|
|
485
512
|
}
|
|
486
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
|
+
|
|
487
535
|
export async function listPullRequestReviews(token, owner, repo, prNumber) {
|
|
488
536
|
const octokit = makeOctokit(token);
|
|
489
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)')
|
|
@@ -162,6 +162,27 @@ export function buildProgram() {
|
|
|
162
162
|
await run(issue, options).catch(err => { console.error(err.message); process.exit(1); });
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
program
|
|
166
|
+
.command('preflight')
|
|
167
|
+
.description('Confere, antes de gerar, tudo que decide uma rodada de specs de uma milestone')
|
|
168
|
+
.requiredOption('--milestone <nome>', 'Título da milestone a inventariar')
|
|
169
|
+
.option('--json', 'Imprime o relatório em JSON')
|
|
170
|
+
.action(async (options) => {
|
|
171
|
+
const { preflight } = await import('./commands/preflight.mjs');
|
|
172
|
+
await preflight(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
173
|
+
});
|
|
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
|
+
|
|
165
186
|
program
|
|
166
187
|
.command('mode')
|
|
167
188
|
.description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
|
|
@@ -188,6 +209,8 @@ export function buildProgram() {
|
|
|
188
209
|
.option('--branch [nome]', 'Envia os arquivos do repo como Pull Request numa branch, em um único commit (sem valor: spec-wave/update-v<versão>)')
|
|
189
210
|
.option('--config-in-pr', 'Força incluir o .spec-wave.json no Pull Request')
|
|
190
211
|
.option('--no-config-in-pr', 'Força manter o .spec-wave.json fora do Pull Request')
|
|
212
|
+
.option('--skill-in-pr', 'Força incluir a skill dos agentes no Pull Request')
|
|
213
|
+
.option('--no-skill-in-pr', 'Força manter a skill dos agentes fora do Pull Request')
|
|
191
214
|
.option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
|
|
192
215
|
.option('--yes', 'Aplica sem pedir confirmação')
|
|
193
216
|
.action(async (options) => {
|
|
@@ -297,7 +320,7 @@ export function buildProgram() {
|
|
|
297
320
|
|
|
298
321
|
program
|
|
299
322
|
.command('qa')
|
|
300
|
-
.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)')
|
|
301
324
|
.requiredOption('--pr-number <n>', 'Número do Pull Request')
|
|
302
325
|
.action(async (options) => {
|
|
303
326
|
const { qa } = await import('./commands/qa.mjs');
|
|
@@ -324,6 +347,18 @@ export function buildProgram() {
|
|
|
324
347
|
await order({ feature }).catch(err => { console.error(err.message); process.exit(1); });
|
|
325
348
|
});
|
|
326
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
|
+
|
|
327
362
|
program
|
|
328
363
|
.command('task')
|
|
329
364
|
.description('Gerencia uma Task no board: start (Status "In Progress") ou done (Done)')
|
|
@@ -396,5 +431,19 @@ export function buildProgram() {
|
|
|
396
431
|
await doctor().catch(err => { console.error(err.message); process.exit(1); });
|
|
397
432
|
});
|
|
398
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
|
+
|
|
399
448
|
return program;
|
|
400
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
|
+
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -1083,10 +1083,15 @@ export function inspectPrPublishing({
|
|
|
1083
1083
|
return { status, notes };
|
|
1084
1084
|
}
|
|
1085
1085
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1086
|
+
/**
|
|
1087
|
+
* Permissões de PR declaradas nos workflows instalados (I/O — nunca lança).
|
|
1088
|
+
*
|
|
1089
|
+
* O job MAIS RESTRITIVO manda: basta um sem a permissão para o fluxo quebrar.
|
|
1090
|
+
*
|
|
1091
|
+
* @param {string} dir diretório `.github/workflows`
|
|
1092
|
+
* @returns {Record<string, {contents?: string, pullRequests?: string}>}
|
|
1093
|
+
*/
|
|
1094
|
+
export function readWorkflowPrPermissions(dir) {
|
|
1090
1095
|
const workflowPerms = {};
|
|
1091
1096
|
for (const file of ARTIFACT_WORKFLOW_FILES) {
|
|
1092
1097
|
const caminho = path.join(dir, file);
|
|
@@ -1097,7 +1102,6 @@ async function checkPrPublishing(ctx) {
|
|
|
1097
1102
|
} catch {
|
|
1098
1103
|
continue; // YAML ilegível é problema de outro check
|
|
1099
1104
|
}
|
|
1100
|
-
// O job mais restritivo manda: basta um sem a permissão para o fluxo quebrar.
|
|
1101
1105
|
for (const job of Object.values(wf?.jobs || {})) {
|
|
1102
1106
|
const atual = workflowPerms[file];
|
|
1103
1107
|
const perm = {
|
|
@@ -1109,20 +1113,29 @@ async function checkPrPublishing(ctx) {
|
|
|
1109
1113
|
}
|
|
1110
1114
|
}
|
|
1111
1115
|
}
|
|
1116
|
+
return workflowPerms;
|
|
1117
|
+
}
|
|
1112
1118
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1119
|
+
/**
|
|
1120
|
+
* Coleta o contexto que `inspectPrPublishing` julga (I/O — nunca lança).
|
|
1121
|
+
*
|
|
1122
|
+
* Separado do check para o `preflight` poder fazer a MESMA pergunta sem
|
|
1123
|
+
* reimplementá-la: as três consultas têm cada uma o seu motivo para falhar sem
|
|
1124
|
+
* que isso signifique "está errado", e duplicar esse tratamento em dois comandos
|
|
1125
|
+
* é como as duas respostas passam a divergir.
|
|
1126
|
+
*
|
|
1127
|
+
* @param {{token?: string, owner?: string, repo?: string, root?: string}} params
|
|
1128
|
+
* @returns {Promise<{canCreatePr: boolean|null, workflowPerms: object,
|
|
1129
|
+
* requiredChecks: string[]|null, prTokenPresent: boolean|null}>}
|
|
1130
|
+
*/
|
|
1131
|
+
export async function readPrPublishingContext({ token, owner, repo, root }) {
|
|
1132
|
+
const workflowPerms = readWorkflowPrPermissions(path.join(root || process.cwd(), '.github', 'workflows'));
|
|
1120
1133
|
|
|
1121
1134
|
let canCreatePr = null;
|
|
1122
1135
|
let requiredChecks = null;
|
|
1123
1136
|
let prTokenPresent = null;
|
|
1124
|
-
if (
|
|
1125
|
-
const octokit = makeOctokit(
|
|
1137
|
+
if (token && owner && repo) {
|
|
1138
|
+
const octokit = makeOctokit(token);
|
|
1126
1139
|
try {
|
|
1127
1140
|
const res = await octokit.request('GET /repos/{owner}/{repo}/actions/secrets',
|
|
1128
1141
|
{ owner, repo });
|
|
@@ -1146,10 +1159,21 @@ async function checkPrPublishing(ctx) {
|
|
|
1146
1159
|
requiredChecks = null; // sem proteção (404) ou sem permissão — nos dois casos, não afirmar
|
|
1147
1160
|
}
|
|
1148
1161
|
}
|
|
1162
|
+
return { canCreatePr, workflowPerms, requiredChecks, prTokenPresent };
|
|
1163
|
+
}
|
|
1149
1164
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1165
|
+
async function checkPrPublishing(ctx) {
|
|
1166
|
+
const name = 'Publicação por Pull Request';
|
|
1167
|
+
const cfg = ctx.cfg || {};
|
|
1168
|
+
const contexto = await readPrPublishingContext({
|
|
1169
|
+
token: ctx.token, owner: cfg.owner, repo: cfg.repo, root: ctx.root || ctx.cwd,
|
|
1152
1170
|
});
|
|
1171
|
+
|
|
1172
|
+
if (Object.keys(contexto.workflowPerms).length === 0) {
|
|
1173
|
+
return { name, status: 'warn', detail: 'Workflows não encontrados — rode `init` ou `update`.' };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const { status, notes } = inspectPrPublishing(contexto);
|
|
1153
1177
|
return { name, status, detail: notes.join('\n ') };
|
|
1154
1178
|
}
|
|
1155
1179
|
|
|
@@ -215,7 +215,12 @@ function buildContext({
|
|
|
215
215
|
lines.push('');
|
|
216
216
|
lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks na Etapa ${STAGE_DONE}):`);
|
|
217
217
|
lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
|
|
218
|
-
|
|
218
|
+
// Draft de propósito: numa pilha de Stories (um PR baseado no anterior),
|
|
219
|
+
// cada rebase em cascata dispararia o CI inteiro em PRs que ninguém vai
|
|
220
|
+
// mergear naquele estado. Rascunho não mergeia (o GitHub bloqueia) e o
|
|
221
|
+
// required check continua valendo: quem revisa marca o PR como pronto, o
|
|
222
|
+
// `ready_for_review` dispara o CI, e só então o merge destrava.
|
|
223
|
+
lines.push(` 2. Abra o **Pull Request** da Story #${issue.number} **como rascunho** (\`gh pr create --draft\`) — o CI não roda em rascunho; quem revisa marca o PR como pronto e é aí que os checks disparam.`);
|
|
219
224
|
lines.push(
|
|
220
225
|
` 3. **Avance a Etapa da Story #${issue.number} para ${STAGE_CODE_REVIEW}** ` +
|
|
221
226
|
`(reinicie o Status para ${PROGRESS_TODO}). As Tasks já estão em ${STAGE_DONE}.`
|
|
@@ -398,7 +403,7 @@ export function buildFeatureContext({
|
|
|
398
403
|
lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
|
|
399
404
|
lines.push(' 2. **Implemente** a Task por completo.');
|
|
400
405
|
lines.push(` 3. **Ao concluir:** **avance a Task para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
|
|
401
|
-
lines.push(`3. **Ao concluir TODAS as Tasks da Story:** faça o **commit**, abra o **Pull Request** da Story e **avance a Etapa da Story para ${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}).`);
|
|
406
|
+
lines.push(`3. **Ao concluir TODAS as Tasks da Story:** faça o **commit**, abra o **Pull Request** da Story **como rascunho** (\`gh pr create --draft\` — o CI não roda em rascunho; quem revisa marca o PR como pronto e os checks disparam) e **avance a Etapa da Story para ${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}).`);
|
|
402
407
|
lines.push('4. Só então inicie a próxima Story.');
|
|
403
408
|
lines.push('');
|
|
404
409
|
lines.push(
|
|
@@ -407,6 +412,18 @@ export function buildFeatureContext({
|
|
|
407
412
|
`Enquanto houver Story pendente, a Feature permanece em ${STAGE_DEVELOPMENT}.`
|
|
408
413
|
);
|
|
409
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('');
|
|
410
427
|
lines.push(boardRuleBlockquote());
|
|
411
428
|
|
|
412
429
|
if (skipped.length > 0) {
|
|
@@ -141,18 +141,28 @@ export function renderContent(format, parsed, version) {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// Insere/atualiza o bloco spec-wave num arquivo compartilhado
|
|
145
|
-
// preservando o restante do conteúdo. Idempotente via marcadores.
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
// Insere/atualiza o bloco spec-wave num texto de arquivo compartilhado
|
|
145
|
+
// (AGENTS.md), preservando o restante do conteúdo. Idempotente via marcadores.
|
|
146
|
+
//
|
|
147
|
+
// Versão PURA, separada da que lê o disco porque o modo `--branch` do update
|
|
148
|
+
// precisa mesclar o bloco no conteúdo da BASE, não no do arquivo local: o
|
|
149
|
+
// AGENTS.md do desenvolvedor pode ter edições ainda não commitadas, e arrastá-las
|
|
150
|
+
// para dentro do Pull Request seria enviar o que ninguém pediu para revisar.
|
|
151
|
+
export function mergeAgentsContent(existing, block) {
|
|
152
|
+
const atual = existing || '';
|
|
148
153
|
const blockRe = new RegExp(
|
|
149
154
|
`${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`,
|
|
150
155
|
);
|
|
151
|
-
if (blockRe.test(
|
|
152
|
-
return
|
|
156
|
+
if (blockRe.test(atual)) {
|
|
157
|
+
return atual.replace(blockRe, block);
|
|
153
158
|
}
|
|
154
|
-
if (
|
|
155
|
-
return `${
|
|
159
|
+
if (atual.trim() === '') return block;
|
|
160
|
+
return `${atual.trimEnd()}\n\n${block}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Mesmo merge, lendo o arquivo de destino do disco.
|
|
164
|
+
export function mergeAgentsFile(destPath, block) {
|
|
165
|
+
return mergeAgentsContent(existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '', block);
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
function escapeRe(s) {
|