@spec-wave/cli 0.6.0 → 0.7.1
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/bin/spec-wave.mjs +37 -0
- package/package.json +4 -1
- package/src/api/github-rest.mjs +60 -1
- package/src/commands/code-review.mjs +13 -52
- package/src/commands/decompose.mjs +288 -61
- package/src/commands/doctor.mjs +415 -0
- package/src/commands/generate-plan.mjs +95 -29
- package/src/commands/generate-spec.mjs +71 -28
- package/src/commands/implement.mjs +118 -3
- package/src/commands/init.mjs +2 -2
- package/src/commands/order.mjs +172 -0
- package/src/commands/qa.mjs +8 -46
- package/src/commands/story.mjs +128 -0
- package/src/commands/task.mjs +183 -0
- package/src/commands/validate.mjs +20 -3
- package/src/config.mjs +37 -0
- package/src/lib/board.mjs +106 -0
- package/src/lib/claude.mjs +138 -13
- package/src/lib/code-digest.mjs +183 -0
- package/src/lib/critique.mjs +160 -0
- package/src/lib/dependencies.mjs +92 -0
- package/src/lib/output-lint.mjs +92 -0
- package/src/lib/usage-report.mjs +167 -0
- package/src/setup/files.mjs +8 -20
- package/src/templates/skill/SKILL.md +104 -12
- package/src/templates/workflows/code-review.yml +4 -0
- package/src/templates/workflows/decompose.yml +7 -0
- package/src/templates/workflows/generate-plan.yml +4 -0
- package/src/templates/workflows/generate-spec.yml +4 -0
- package/src/templates/workflows/qa.yml +4 -0
- package/src/templates/workflows/validate.yml +4 -0
- package/src/ui/wizard.mjs +2 -2
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// Checklist de preflight do spec-wave: diagnostica token, escopos, conta ativa
|
|
2
|
+
// do gh, .spec-wave.json, acesso ao repo, configuração de IA e workflows.
|
|
3
|
+
// Best-effort: NUNCA lança — falha de rede/API vira "!" (não verificável);
|
|
4
|
+
// só problemas confirmados viram "✗". Sai com exit 1 se houver algum "✗".
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import * as p from '@clack/prompts';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { Octokit } from '@octokit/rest';
|
|
11
|
+
import { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
|
|
12
|
+
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
13
|
+
import { CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
|
|
14
|
+
|
|
15
|
+
// Mesmo padrão de instanciação de github-rest.mjs, mas com o logger mudo:
|
|
16
|
+
// aqui 404/403 são resultados esperados dos checks, não erros a logar.
|
|
17
|
+
const silentLog = { debug() {}, info() {}, warn() {}, error() {} };
|
|
18
|
+
function makeOctokit(token) {
|
|
19
|
+
return new Octokit({ auth: token, log: silentLog });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Formata o relatório do doctor (função PURA — testável sem rede).
|
|
24
|
+
*
|
|
25
|
+
* @param {Array<{name: string, status: 'ok'|'fail'|'warn', detail?: string}>} results
|
|
26
|
+
* @returns {string} relatório com ✓ (ok, verde), ✗ (problema, vermelho) e
|
|
27
|
+
* ! (não verificável, amarelo); detail é indentado sob o nome.
|
|
28
|
+
*/
|
|
29
|
+
export function renderDoctorReport(results) {
|
|
30
|
+
const SYMBOLS = {
|
|
31
|
+
ok: chalk.green('✓'),
|
|
32
|
+
fail: chalk.red('✗'),
|
|
33
|
+
warn: chalk.yellow('!'),
|
|
34
|
+
};
|
|
35
|
+
const lines = [];
|
|
36
|
+
for (const r of results || []) {
|
|
37
|
+
lines.push(`${SYMBOLS[r.status] || SYMBOLS.warn} ${chalk.bold(r.name)}`);
|
|
38
|
+
if (r.detail) {
|
|
39
|
+
for (const dl of String(r.detail).split('\n')) {
|
|
40
|
+
lines.push(` ${chalk.dim(dl)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Checks ──────────────────────────────────────────────────────────────────
|
|
48
|
+
// Cada check recebe o contexto compartilhado (ctx) e retorna
|
|
49
|
+
// { name, status, detail }. Resultados de checks anteriores (token, config,
|
|
50
|
+
// acesso ao repo) ficam no ctx para os seguintes reaproveitarem.
|
|
51
|
+
|
|
52
|
+
async function checkToken(ctx) {
|
|
53
|
+
const name = 'Token GitHub';
|
|
54
|
+
let source = 'gh CLI (`gh auth token`)';
|
|
55
|
+
if (process.env.GITHUB_TOKEN) source = 'variável de ambiente GITHUB_TOKEN';
|
|
56
|
+
else if (process.env.GH_TOKEN) source = 'variável de ambiente GH_TOKEN';
|
|
57
|
+
try {
|
|
58
|
+
ctx.token = await resolveToken();
|
|
59
|
+
return { name, status: 'ok', detail: `Token resolvido via ${source}.` };
|
|
60
|
+
} catch {
|
|
61
|
+
return {
|
|
62
|
+
name,
|
|
63
|
+
status: 'fail',
|
|
64
|
+
detail:
|
|
65
|
+
'Nenhum token encontrado. Rode `gh auth login` ou exporte GITHUB_TOKEN.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function checkScopes(ctx) {
|
|
71
|
+
const name = 'Escopos do token';
|
|
72
|
+
if (!ctx.token) {
|
|
73
|
+
return { name, status: 'warn', detail: 'Sem token — verificação de escopos pulada.' };
|
|
74
|
+
}
|
|
75
|
+
let info;
|
|
76
|
+
try {
|
|
77
|
+
info = await verifyTokenScopes(ctx.token);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { name, status: 'warn', detail: `Não foi possível consultar a API (GET /user): ${err.message}` };
|
|
80
|
+
}
|
|
81
|
+
ctx.tokenLogin = info.login;
|
|
82
|
+
|
|
83
|
+
const realScopes = (info.scopes || []).filter(Boolean);
|
|
84
|
+
if (realScopes.length > 0) {
|
|
85
|
+
// PAT clássico: o header x-oauth-scopes lista os escopos.
|
|
86
|
+
const missing = [];
|
|
87
|
+
if (!info.hasRepo) missing.push('repo');
|
|
88
|
+
if (!info.hasProject) missing.push('project');
|
|
89
|
+
if (!info.hasWorkflow) missing.push('workflow');
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
return {
|
|
92
|
+
name,
|
|
93
|
+
status: 'fail',
|
|
94
|
+
detail:
|
|
95
|
+
`Login: ${info.login}. Escopos: ${realScopes.join(', ')}.\n` +
|
|
96
|
+
`Faltando: ${missing.join(', ')}. Rode \`gh auth refresh --scopes ${missing.join(',')}\`.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { name, status: 'ok', detail: `Login: ${info.login}. Escopos: ${realScopes.join(', ')}.` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Header vazio/ausente: fine-grained PAT ou GITHUB_TOKEN de Actions — os
|
|
103
|
+
// escopos não são legíveis. Degrada para checks funcionais (repo + project).
|
|
104
|
+
const { cfg } = ctx;
|
|
105
|
+
const functional = [];
|
|
106
|
+
if (cfg?.owner && cfg?.repo) {
|
|
107
|
+
try {
|
|
108
|
+
await makeOctokit(ctx.token).rest.repos.get({ owner: cfg.owner, repo: cfg.repo });
|
|
109
|
+
ctx.repoAccess = 'ok';
|
|
110
|
+
functional.push(`repo ${cfg.owner}/${cfg.repo}`);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
ctx.repoAccess = err.status === 404 ? '404' : 'error';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (cfg?.project?.id) {
|
|
116
|
+
try {
|
|
117
|
+
const snapshot = await getProjectSnapshot(ctx.token, cfg.project.id);
|
|
118
|
+
if (snapshot) {
|
|
119
|
+
ctx.projectSnapshot = snapshot;
|
|
120
|
+
functional.push(`project "${snapshot.title}"`);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// acesso ao project não confirmado — segue sem ele
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (functional.length > 0) {
|
|
127
|
+
return {
|
|
128
|
+
name,
|
|
129
|
+
status: 'ok',
|
|
130
|
+
detail: `Escopos não legíveis (fine-grained PAT?), mas acesso confirmado: ${functional.join(' e ')}.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
name,
|
|
135
|
+
status: 'warn',
|
|
136
|
+
detail:
|
|
137
|
+
'Escopos não legíveis (fine-grained PAT?) e sem acesso confirmável a repo/project ' +
|
|
138
|
+
`(${CONFIG_FILE} ausente ou acesso negado). Confirme as permissões do token manualmente.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function checkGhAccount(ctx) {
|
|
143
|
+
const name = 'Conta ativa do gh';
|
|
144
|
+
let out;
|
|
145
|
+
try {
|
|
146
|
+
out = execSync('gh auth status', { stdio: ['pipe', 'pipe', 'pipe'] }).toString();
|
|
147
|
+
} catch {
|
|
148
|
+
return { name, status: 'warn', detail: 'gh não instalado ou não logado — verificação de conta pulada.' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Formato: "✓ Logged in to github.com account <login> (...)" seguido de
|
|
152
|
+
// "- Active account: true". Em versões antigas (uma conta só) não há a
|
|
153
|
+
// linha "Active account" — usa a primeira conta encontrada.
|
|
154
|
+
let active = null;
|
|
155
|
+
let last = null;
|
|
156
|
+
for (const line of out.split('\n')) {
|
|
157
|
+
const m = line.match(/account\s+(\S+)/);
|
|
158
|
+
if (m) {
|
|
159
|
+
last = m[1];
|
|
160
|
+
if (!active) active = last;
|
|
161
|
+
}
|
|
162
|
+
if (/Active account:\s*true/i.test(line) && last) active = last;
|
|
163
|
+
}
|
|
164
|
+
if (!active) {
|
|
165
|
+
return { name, status: 'warn', detail: 'Não foi possível identificar a conta ativa na saída de `gh auth status`.' };
|
|
166
|
+
}
|
|
167
|
+
ctx.ghLogin = active;
|
|
168
|
+
|
|
169
|
+
const owner = ctx.cfg?.owner;
|
|
170
|
+
if (!owner) {
|
|
171
|
+
return { name, status: 'ok', detail: `Conta ativa: ${active} (sem owner no ${CONFIG_FILE} para comparar).` };
|
|
172
|
+
}
|
|
173
|
+
if (active.toLowerCase() === owner.toLowerCase()) {
|
|
174
|
+
return { name, status: 'ok', detail: `Conta ativa: ${active} — coincide com o owner ${owner}.` };
|
|
175
|
+
}
|
|
176
|
+
// Conta ativa ≠ owner (caso real: moacsjr ativo com repo da org de moacir-k9).
|
|
177
|
+
// Se o owner for uma org, tenta confirmar membership com o token resolvido.
|
|
178
|
+
if (ctx.token) {
|
|
179
|
+
try {
|
|
180
|
+
const res = await makeOctokit(ctx.token).request('GET /user/memberships/orgs/{org}', { org: owner });
|
|
181
|
+
if (res.data?.state === 'active') {
|
|
182
|
+
return {
|
|
183
|
+
name,
|
|
184
|
+
status: 'ok',
|
|
185
|
+
detail: `Conta ativa ${active} ≠ owner ${owner}, mas o usuário do token é membro ativo da org ${owner}.`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// owner não é org, ou membership não consultável — cai no warn abaixo
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
name,
|
|
194
|
+
status: 'warn',
|
|
195
|
+
detail: `Conta ativa ${active} ≠ owner ${owner} — confirme que ${active} tem acesso a ${owner}.`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function checkConfig(ctx) {
|
|
200
|
+
const name = `Configuração (${CONFIG_FILE})`;
|
|
201
|
+
if (!existsSync(ctx.configPath)) {
|
|
202
|
+
return {
|
|
203
|
+
name,
|
|
204
|
+
status: 'fail',
|
|
205
|
+
detail: `${CONFIG_FILE} não encontrado em ${ctx.cwd}. Rode \`npx @spec-wave/cli init\`.`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (ctx.cfgError) {
|
|
209
|
+
return { name, status: 'fail', detail: `${CONFIG_FILE} existe mas está corrompido: ${ctx.cfgError}` };
|
|
210
|
+
}
|
|
211
|
+
const { cfg } = ctx;
|
|
212
|
+
const notes = [`Repositório: ${cfg.owner ?? '?'}/${cfg.repo ?? '?'}.`];
|
|
213
|
+
|
|
214
|
+
const fields = cfg.project?.fields || {};
|
|
215
|
+
const missingFields = ['Etapa', 'Status'].filter((f) => !fields[f]);
|
|
216
|
+
if (missingFields.length > 0) {
|
|
217
|
+
return {
|
|
218
|
+
name,
|
|
219
|
+
status: 'warn',
|
|
220
|
+
detail:
|
|
221
|
+
notes.join('\n') +
|
|
222
|
+
`\nproject.fields sem ${missingFields.map((f) => `"${f}"`).join(' e ')} — rode \`npx @spec-wave/cli refresh\`.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Com token e project.id, confere se as opções de Etapa do config ainda
|
|
227
|
+
// existem no project real (alguém pode ter renomeado/apagado colunas).
|
|
228
|
+
if (ctx.token && cfg.project?.id) {
|
|
229
|
+
try {
|
|
230
|
+
const snapshot = ctx.projectSnapshot || await getProjectSnapshot(ctx.token, cfg.project.id);
|
|
231
|
+
if (!snapshot) {
|
|
232
|
+
return { name, status: 'warn', detail: notes.join('\n') + '\nProject do config não encontrado no GitHub (id inválido ou sem acesso).' };
|
|
233
|
+
}
|
|
234
|
+
ctx.projectSnapshot = snapshot;
|
|
235
|
+
const realEtapa = snapshot.fields?.['Etapa']?.options || {};
|
|
236
|
+
const diverged = Object.keys(fields['Etapa'].options || {}).filter((opt) => !(opt in realEtapa));
|
|
237
|
+
if (diverged.length > 0) {
|
|
238
|
+
return {
|
|
239
|
+
name,
|
|
240
|
+
status: 'warn',
|
|
241
|
+
detail:
|
|
242
|
+
notes.join('\n') +
|
|
243
|
+
`\nOpções de Etapa do config ausentes no project real: ${diverged.join(', ')} — rode \`npx @spec-wave/cli refresh\`.`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
notes.push(`Project "${snapshot.title}" verificado — campos Etapa/Status em sincronia.`);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return { name, status: 'warn', detail: notes.join('\n') + `\nProject não verificável agora: ${err.message}` };
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
notes.push('Campos Etapa/Status presentes no config (project real não verificado — sem token ou sem project.id).');
|
|
252
|
+
}
|
|
253
|
+
return { name, status: 'ok', detail: notes.join('\n') };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function checkRepoAccess(ctx) {
|
|
257
|
+
const name = 'Acesso ao repositório';
|
|
258
|
+
const { cfg } = ctx;
|
|
259
|
+
if (!ctx.token) {
|
|
260
|
+
return { name, status: 'warn', detail: 'Sem token — acesso ao repositório não verificável.' };
|
|
261
|
+
}
|
|
262
|
+
if (!cfg?.owner || !cfg?.repo) {
|
|
263
|
+
return { name, status: 'warn', detail: `Sem owner/repo no ${CONFIG_FILE} — acesso não verificável.` };
|
|
264
|
+
}
|
|
265
|
+
// Reaproveita o resultado do check funcional de escopos, se já rodou.
|
|
266
|
+
if (ctx.repoAccess === 'ok') {
|
|
267
|
+
return { name, status: 'ok', detail: `Token enxerga ${cfg.owner}/${cfg.repo} (já confirmado no check de escopos).` };
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const res = await makeOctokit(ctx.token).rest.repos.get({ owner: cfg.owner, repo: cfg.repo });
|
|
271
|
+
return {
|
|
272
|
+
name,
|
|
273
|
+
status: 'ok',
|
|
274
|
+
detail: `Token enxerga ${res.data.full_name} (${res.data.private ? 'privado' : 'público'}).`,
|
|
275
|
+
};
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (err.status === 404 || ctx.repoAccess === '404') {
|
|
278
|
+
return {
|
|
279
|
+
name,
|
|
280
|
+
status: 'fail',
|
|
281
|
+
detail:
|
|
282
|
+
`Token não enxerga ${cfg.owner}/${cfg.repo} (404) — causa típica do erro silencioso na criação de issues.\n` +
|
|
283
|
+
'Verifique se o token tem acesso à org/repo (SSO autorizado, fine-grained com o repo selecionado).',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return { name, status: 'warn', detail: `Acesso não verificável agora: ${err.message}` };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function checkAi(ctx) {
|
|
291
|
+
const name = 'IA (provider, modelo e chaves)';
|
|
292
|
+
const { cfg } = ctx;
|
|
293
|
+
const fileAi = cfg?.ai || {};
|
|
294
|
+
const provider = getProvider(fileAi.provider) || getProvider(DEFAULT_PROVIDER);
|
|
295
|
+
const model = fileAi.model || provider.defaultModel;
|
|
296
|
+
const notes = [`Provider: ${provider.value} · modelo: ${model}${fileAi.provider ? '' : ' (default — sem bloco `ai` no config)'}.`];
|
|
297
|
+
if (fileAi.models && Object.keys(fileAi.models).length > 0) {
|
|
298
|
+
notes.push(`Modelos por ação (ai.models): ${Object.entries(fileAi.models).map(([a, m]) => `${a}=${m}`).join(', ')}.`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let status = 'ok';
|
|
302
|
+
if (process.env[provider.secret]) {
|
|
303
|
+
notes.push(`${provider.secret} presente no ambiente local.`);
|
|
304
|
+
} else {
|
|
305
|
+
status = 'warn';
|
|
306
|
+
notes.push(`${provider.secret} ausente no ambiente local — necessária apenas para rodar geração localmente; nos Actions vem dos secrets.`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Se o token permitir, confere os secrets do Actions no repo.
|
|
310
|
+
if (ctx.token && cfg?.owner && cfg?.repo) {
|
|
311
|
+
try {
|
|
312
|
+
const res = await makeOctokit(ctx.token).request('GET /repos/{owner}/{repo}/actions/secrets', {
|
|
313
|
+
owner: cfg.owner,
|
|
314
|
+
repo: cfg.repo,
|
|
315
|
+
});
|
|
316
|
+
const secretNames = (res.data.secrets || []).map((s) => s.name);
|
|
317
|
+
const required = [provider.secret, 'GH_PROJECT_TOKEN'];
|
|
318
|
+
const missing = required.filter((s) => !secretNames.includes(s));
|
|
319
|
+
if (missing.length > 0) {
|
|
320
|
+
status = 'warn';
|
|
321
|
+
notes.push(`Secrets do Actions faltando em ${cfg.owner}/${cfg.repo}: ${missing.join(', ')} — configure em Settings → Secrets.`);
|
|
322
|
+
} else {
|
|
323
|
+
notes.push(`Secrets do Actions presentes: ${required.join(', ')}.`);
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (err.status === 403) {
|
|
327
|
+
notes.push('Secrets do Actions não verificáveis com este token (403 — requer admin no repo).');
|
|
328
|
+
} else {
|
|
329
|
+
notes.push(`Secrets do Actions não verificáveis agora: ${err.message}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { name, status, detail: notes.join('\n') };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function checkWorkflows(ctx) {
|
|
337
|
+
const name = 'Workflows do Actions';
|
|
338
|
+
const dir = path.join(ctx.cwd, '.github', 'workflows');
|
|
339
|
+
if (!existsSync(dir)) {
|
|
340
|
+
return {
|
|
341
|
+
name,
|
|
342
|
+
status: 'warn',
|
|
343
|
+
detail: '.github/workflows/ não encontrado — rode `npx @spec-wave/cli init` (ou `update`) para instalar os workflows.',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const present = readdirSync(dir);
|
|
347
|
+
const missing = WORKFLOW_FILES.filter((f) => !present.includes(f));
|
|
348
|
+
if (missing.length > 0) {
|
|
349
|
+
return {
|
|
350
|
+
name,
|
|
351
|
+
status: 'warn',
|
|
352
|
+
detail: `Workflows faltando em .github/workflows/: ${missing.join(', ')} — rode \`npx @spec-wave/cli update\`.`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return { name, status: 'ok', detail: `Os ${WORKFLOW_FILES.length} workflows do spec-wave estão presentes.` };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Comando ─────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
export async function doctor() {
|
|
361
|
+
p.intro(chalk.bold('spec-wave doctor'));
|
|
362
|
+
|
|
363
|
+
// Contexto compartilhado entre os checks (token, config, resultados parciais).
|
|
364
|
+
const ctx = { cwd: process.cwd() };
|
|
365
|
+
ctx.configPath = path.join(ctx.cwd, CONFIG_FILE);
|
|
366
|
+
ctx.cfg = null;
|
|
367
|
+
ctx.cfgError = null;
|
|
368
|
+
try {
|
|
369
|
+
if (existsSync(ctx.configPath)) {
|
|
370
|
+
ctx.cfg = JSON.parse(readFileSync(ctx.configPath, 'utf-8'));
|
|
371
|
+
}
|
|
372
|
+
} catch (err) {
|
|
373
|
+
ctx.cfgError = err.message;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const checks = [
|
|
377
|
+
checkToken,
|
|
378
|
+
checkScopes,
|
|
379
|
+
checkGhAccount,
|
|
380
|
+
checkConfig,
|
|
381
|
+
checkRepoAccess,
|
|
382
|
+
checkAi,
|
|
383
|
+
checkWorkflows,
|
|
384
|
+
];
|
|
385
|
+
const results = [];
|
|
386
|
+
const spinner = p.spinner();
|
|
387
|
+
spinner.start('Rodando checks de preflight...');
|
|
388
|
+
for (const check of checks) {
|
|
389
|
+
try {
|
|
390
|
+
results.push(await check(ctx));
|
|
391
|
+
} catch (err) {
|
|
392
|
+
// Best-effort: nenhum check derruba o doctor — vira "não verificável".
|
|
393
|
+
results.push({
|
|
394
|
+
name: check.name.replace(/^check/, ''),
|
|
395
|
+
status: 'warn',
|
|
396
|
+
detail: `Check falhou inesperadamente: ${err.message}`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
spinner.stop('Checks concluídos.');
|
|
401
|
+
|
|
402
|
+
console.log('\n' + renderDoctorReport(results) + '\n');
|
|
403
|
+
|
|
404
|
+
const fails = results.filter((r) => r.status === 'fail').length;
|
|
405
|
+
const warns = results.filter((r) => r.status === 'warn').length;
|
|
406
|
+
if (fails > 0) {
|
|
407
|
+
p.outro(chalk.red(`${fails} problema(s) encontrado(s)`) + (warns > 0 ? chalk.yellow(` e ${warns} aviso(s)`) : '') + '. Corrija os itens ✗ acima.');
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
410
|
+
if (warns > 0) {
|
|
411
|
+
p.outro(chalk.yellow(`Nenhum problema bloqueante; ${warns} aviso(s) não verificável(is).`));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
p.outro(chalk.green('Tudo certo! Ambiente pronto para o spec-wave.'));
|
|
415
|
+
}
|
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
2
|
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
3
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
|
-
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
4
|
+
import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
6
|
+
import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE, LABEL_CRITIQUE_FAILED } from '../config.mjs';
|
|
5
7
|
import { generateDocument } from '../lib/claude.mjs';
|
|
8
|
+
import { runCritique } from '../lib/critique.mjs';
|
|
9
|
+
import { recordUsage } from '../lib/usage-report.mjs';
|
|
6
10
|
import { slugify } from '../lib/slugify.mjs';
|
|
7
11
|
import { buildTechContext } from '../lib/tech-context.mjs';
|
|
8
12
|
|
|
13
|
+
// Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
|
|
14
|
+
// retry automático do generateDocument (excertos ao redor de cada vazamento).
|
|
15
|
+
function formatLintWarning(lintFindings) {
|
|
16
|
+
if (!lintFindings || lintFindings.length === 0) return '';
|
|
17
|
+
const excerpts = lintFindings
|
|
18
|
+
.slice(0, 5)
|
|
19
|
+
.map(f => `\`${f.excerpt.replace(/\s+/g, ' ').trim()}\``)
|
|
20
|
+
.join(', ');
|
|
21
|
+
return `\n\n⚠️ possíveis artefatos de idioma no documento: ${excerpts}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
const SYSTEM_PROMPT = `Você é um Tech Lead experiente. Gere um plano técnico (plan.md) completo e detalhado, baseado ESTRITAMENTE no spec.md fornecido.
|
|
10
25
|
|
|
11
26
|
O plano deve conter EXATAMENTE estas seções em português, nesta ordem:
|
|
@@ -39,6 +54,20 @@ export async function generatePlan({ issueNumber }) {
|
|
|
39
54
|
|
|
40
55
|
console.log(`Buscando issue #${issueNumber}...`);
|
|
41
56
|
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
57
|
+
|
|
58
|
+
// plan.md é artefato de Feature — não se aplica a Spike/RFC/Bug.
|
|
59
|
+
const type = detectIssueType(issue);
|
|
60
|
+
if (!allowsSpecPlan(type)) {
|
|
61
|
+
console.log(`Issue #${issueNumber} é ${type}: plan.md não é gerado para ${SPEC_PLAN_EXCLUDED_TYPES.join('/')}.`);
|
|
62
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
|
|
63
|
+
await commentOnIssue(
|
|
64
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
65
|
+
`ℹ️ **plan.md não gerado:** o tipo **${type}** não usa spec/plan no fluxo spec-wave ` +
|
|
66
|
+
`(esses artefatos são exclusivos de Features). Nenhum arquivo foi criado.`
|
|
67
|
+
).catch(() => {});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
const slug = slugify(issue.title);
|
|
43
72
|
const featureDir = `docs/features/${slug}`;
|
|
44
73
|
const filePath = `${featureDir}/plan.md`;
|
|
@@ -63,32 +92,69 @@ export async function generatePlan({ issueNumber }) {
|
|
|
63
92
|
};
|
|
64
93
|
const userContent = `Gere o plan.md a partir deste payload JSON:\n\n${JSON.stringify(payload, null, 2)}`;
|
|
65
94
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
// Coletor de uso de IA — o finally registra o custo já incorrido (geração +
|
|
96
|
+
// crítica) mesmo se o fluxo falhar no meio.
|
|
97
|
+
const usageEntries = [];
|
|
98
|
+
try {
|
|
99
|
+
console.log(`Gerando plan.md para: ${issue.title}`);
|
|
100
|
+
const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
|
|
101
|
+
action: 'plan',
|
|
102
|
+
lint: { lang: TARGET_LANGUAGE },
|
|
103
|
+
withReport: true,
|
|
104
|
+
usage: usageEntries,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
mkdirSync(featureDir, { recursive: true });
|
|
108
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
109
|
+
|
|
110
|
+
// Commit and push
|
|
111
|
+
const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
|
|
112
|
+
git(`git config user.email "spec-wave[bot]@github.com"`);
|
|
113
|
+
git(`git config user.name "spec-wave[bot]"`);
|
|
114
|
+
git(`git add "${filePath}"`);
|
|
115
|
+
git(`git commit -m "docs: generate plan.md for ${slug} [spec-wave]"`);
|
|
116
|
+
git('git pull --rebase');
|
|
117
|
+
git('git push');
|
|
118
|
+
|
|
119
|
+
// Remove trigger label
|
|
120
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
|
|
121
|
+
|
|
122
|
+
// Comment on issue
|
|
123
|
+
await commentOnIssue(
|
|
124
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
125
|
+
`📋 **plan.md gerado automaticamente!**\n\n` +
|
|
126
|
+
`📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
|
|
127
|
+
`Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
|
|
128
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
|
|
129
|
+
formatLintWarning(lintFindings)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Crítica adversarial: audita o plan recém-comitado contra spec +
|
|
133
|
+
// tech_context. NUNCA desfaz o plan — falha da crítica vira só um aviso.
|
|
134
|
+
try {
|
|
135
|
+
const critique = await runCritique({
|
|
136
|
+
kind: 'plan',
|
|
137
|
+
spec: specContent,
|
|
138
|
+
plan: content,
|
|
139
|
+
techContextYaml: tech.yaml,
|
|
140
|
+
usage: usageEntries,
|
|
141
|
+
});
|
|
142
|
+
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown);
|
|
143
|
+
if (critique.grave) {
|
|
144
|
+
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
|
|
145
|
+
console.log(`Crítica adversarial apontou findings GRAVES — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.warn(`Crítica adversarial indisponível: ${err.message}`);
|
|
149
|
+
await commentOnIssue(
|
|
150
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
151
|
+
`⚠️ crítica adversarial indisponível (erro: ${err.message})`
|
|
152
|
+
).catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(`plan.md criado em: ${filePath}`);
|
|
156
|
+
} finally {
|
|
157
|
+
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
158
|
+
await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
|
|
159
|
+
}
|
|
94
160
|
}
|
|
@@ -3,7 +3,21 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
4
|
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
5
|
import { generateDocument } from '../lib/claude.mjs';
|
|
6
|
+
import { recordUsage } from '../lib/usage-report.mjs';
|
|
6
7
|
import { slugify } from '../lib/slugify.mjs';
|
|
8
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
9
|
+
import { allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE } from '../config.mjs';
|
|
10
|
+
|
|
11
|
+
// Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
|
|
12
|
+
// retry automático do generateDocument (excertos ao redor de cada vazamento).
|
|
13
|
+
function formatLintWarning(lintFindings) {
|
|
14
|
+
if (!lintFindings || lintFindings.length === 0) return '';
|
|
15
|
+
const excerpts = lintFindings
|
|
16
|
+
.slice(0, 5)
|
|
17
|
+
.map(f => `\`${f.excerpt.replace(/\s+/g, ' ').trim()}\``)
|
|
18
|
+
.join(', ');
|
|
19
|
+
return `\n\n⚠️ possíveis artefatos de idioma no documento: ${excerpts}`;
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
const SYSTEM_PROMPT = `Você é um Product Manager experiente. Gere uma especificação funcional (spec.md) completa para a Feature descrita pelo usuário.
|
|
9
23
|
|
|
@@ -39,6 +53,21 @@ export async function generateSpec({ issueNumber }) {
|
|
|
39
53
|
|
|
40
54
|
console.log(`Buscando issue #${issueNumber}...`);
|
|
41
55
|
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
56
|
+
|
|
57
|
+
// spec.md/plan.md são artefatos funcionais de Feature — não se aplicam a
|
|
58
|
+
// Spike/RFC/Bug. Pula a geração, remove o trigger e avisa na issue.
|
|
59
|
+
const type = detectIssueType(issue);
|
|
60
|
+
if (!allowsSpecPlan(type)) {
|
|
61
|
+
console.log(`Issue #${issueNumber} é ${type}: spec.md não é gerada para ${SPEC_PLAN_EXCLUDED_TYPES.join('/')}.`);
|
|
62
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
|
|
63
|
+
await commentOnIssue(
|
|
64
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
65
|
+
`ℹ️ **spec.md não gerada:** o tipo **${type}** não usa spec/plan no fluxo spec-wave ` +
|
|
66
|
+
`(esses artefatos são exclusivos de Features). Nenhum arquivo foi criado.`
|
|
67
|
+
).catch(() => {});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
const slug = slugify(issue.title);
|
|
43
72
|
const featureDir = `docs/features/${slug}`;
|
|
44
73
|
const filePath = `${featureDir}/spec.md`;
|
|
@@ -56,32 +85,46 @@ export async function generateSpec({ issueNumber }) {
|
|
|
56
85
|
};
|
|
57
86
|
const userContent = `Gere o spec.md a partir deste payload JSON:\n\n${JSON.stringify(payload, null, 2)}`;
|
|
58
87
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
// Coletor de uso de IA — o finally registra o custo já incorrido mesmo se o
|
|
89
|
+
// fluxo falhar depois da geração.
|
|
90
|
+
const usageEntries = [];
|
|
91
|
+
try {
|
|
92
|
+
console.log(`Gerando spec.md para: ${issue.title}`);
|
|
93
|
+
const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
|
|
94
|
+
action: 'spec',
|
|
95
|
+
lint: { lang: TARGET_LANGUAGE },
|
|
96
|
+
withReport: true,
|
|
97
|
+
usage: usageEntries,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
mkdirSync(featureDir, { recursive: true });
|
|
101
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
102
|
+
|
|
103
|
+
// Commit and push
|
|
104
|
+
const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
|
|
105
|
+
git(`git config user.email "spec-wave[bot]@github.com"`);
|
|
106
|
+
git(`git config user.name "spec-wave[bot]"`);
|
|
107
|
+
git(`git add "${filePath}"`);
|
|
108
|
+
git(`git commit -m "docs: generate spec.md for ${slug} [spec-wave]"`);
|
|
109
|
+
git('git pull --rebase');
|
|
110
|
+
git('git push');
|
|
111
|
+
|
|
112
|
+
// Remove trigger label
|
|
113
|
+
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
|
|
114
|
+
|
|
115
|
+
// Comment on issue
|
|
116
|
+
await commentOnIssue(
|
|
117
|
+
token, owner, repo, parseInt(issueNumber, 10),
|
|
118
|
+
`📋 **spec.md gerado automaticamente!**\n\n` +
|
|
119
|
+
`📄 Arquivo: [\`${filePath}\`](https://github.com/${owner}/${repo}/blob/main/${filePath})\n\n` +
|
|
120
|
+
`Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
|
|
121
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
|
|
122
|
+
formatLintWarning(lintFindings)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
console.log(`spec.md criado em: ${filePath}`);
|
|
126
|
+
} finally {
|
|
127
|
+
// Best-effort: nunca propaga erro (ver recordUsage).
|
|
128
|
+
await recordUsage({ token, owner, repo, issueNumber: parseInt(issueNumber, 10), entries: usageEntries });
|
|
129
|
+
}
|
|
87
130
|
}
|