@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 +1 -1
- package/src/api/github-graphql.mjs +37 -0
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +39 -2
- package/src/commands/audit.mjs +280 -0
- package/src/commands/implement.mjs +12 -0
- package/src/commands/merge.mjs +292 -0
- package/src/commands/move.mjs +26 -11
- package/src/commands/order.mjs +42 -0
- package/src/commands/run.mjs +4 -3
- package/src/lib/board.mjs +18 -2
- package/src/lib/critique.mjs +64 -8
- 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/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 +3 -1
- package/src/plugin/skills/preparar-specs/SKILL.md +21 -1
- package/src/plugin/skills/preparar-specs/reference/revisao.md +5 -2
- package/src/templates/config/tech_context.yml +13 -0
- package/src/templates/skill/SKILL.md +30 -4
- 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
|
@@ -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) {
|