@spec-wave/cli 0.24.0 → 0.26.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/README.md +84 -0
- package/bin/spec-wave.mjs +4 -341
- package/package.json +1 -1
- package/src/api/github-rest.mjs +63 -1
- package/src/cli.mjs +400 -0
- package/src/commands/decompose.mjs +23 -16
- package/src/commands/doctor.mjs +32 -0
- package/src/commands/generate-bug.mjs +8 -14
- package/src/commands/generate-plan.mjs +2 -1
- package/src/commands/generate-spec.mjs +2 -1
- package/src/commands/mode.mjs +180 -0
- package/src/commands/run.mjs +491 -0
- package/src/commands/validate.mjs +22 -6
- package/src/config.mjs +4 -1
- package/src/lib/config-file.mjs +62 -0
- package/src/lib/doc-paths.mjs +51 -0
- package/src/lib/execution-mode.mjs +132 -0
- package/src/lib/next-step.mjs +412 -0
- package/src/lib/pr-step.mjs +102 -0
- package/src/lib/repo-links.mjs +84 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/doctor/SKILL.md +1 -0
- package/src/plugin/skills/run/SKILL.md +76 -0
- package/src/plugin/skills/spec/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +24 -1
- package/src/templates/workflows/code-review.yml +5 -1
- package/src/templates/workflows/critique.yml +4 -0
- package/src/templates/workflows/decompose.yml +4 -0
- package/src/templates/workflows/generate-bug.yml +4 -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 +6 -1
- package/src/templates/workflows/validate.yml +4 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Alterna entre rodar o fluxo no GitHub Actions e rodar nesta máquina.
|
|
2
|
+
//
|
|
3
|
+
// O comando escreve os DOIS lados do interruptor — o `.spec-wave.json`, que a
|
|
4
|
+
// CLI e a skill leem, e a variável de repositório, que o `if:` de cada job
|
|
5
|
+
// avalia. Escrever só um deles é o defeito que o comando existe para evitar:
|
|
6
|
+
// config em "local" com a variável ausente significa workflow disparando e
|
|
7
|
+
// minuto sendo cobrado enquanto o usuário acha que desligou.
|
|
8
|
+
//
|
|
9
|
+
// A variável exige permissão de administração. Sem ela o comando NÃO finge que
|
|
10
|
+
// deu certo: grava o config, diz o que falta e aponta a alternativa manual.
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import * as p from '@clack/prompts';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
|
|
18
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
19
|
+
import { getRepoVariable, setRepoVariable, deleteRepoVariable } from '../api/github-rest.mjs';
|
|
20
|
+
import { CONFIG_FILE, WORKFLOW_FILES } from '../config.mjs';
|
|
21
|
+
import { updateConfig } from '../lib/config-file.mjs';
|
|
22
|
+
import {
|
|
23
|
+
EXECUTION_MODES, EXECUTION_VARIABLE, EXECUTION_GUARD,
|
|
24
|
+
configuredMode, variableValueFor, describeModeState, shouldWriteVariable,
|
|
25
|
+
} from '../lib/execution-mode.mjs';
|
|
26
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Workflows instalados que NÃO carregam a guarda (função quase pura — só lê o fs).
|
|
30
|
+
*
|
|
31
|
+
* @param {string|null} root
|
|
32
|
+
* @returns {string[]}
|
|
33
|
+
*/
|
|
34
|
+
export function unguardedWorkflows(root) {
|
|
35
|
+
const dir = path.join(root || process.cwd(), '.github', 'workflows');
|
|
36
|
+
if (!existsSync(dir)) return [];
|
|
37
|
+
const presentes = new Set(readdirSync(dir));
|
|
38
|
+
return WORKFLOW_FILES
|
|
39
|
+
.filter(file => presentes.has(file))
|
|
40
|
+
.filter(file => !readFileSync(path.join(dir, file), 'utf-8').includes(EXECUTION_GUARD));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A variável só é legível por quem administra o repo. 403 não é falha: é "não
|
|
44
|
+
// verificável", e quem chama distingue isso de "não existe" (null).
|
|
45
|
+
async function readVariable(token, owner, repo) {
|
|
46
|
+
try {
|
|
47
|
+
return await getRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err.status === 403 || err.status === 404) return undefined;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function mode({ target, dryRun = false } = {}) {
|
|
55
|
+
p.intro(chalk.bold('spec-wave mode'));
|
|
56
|
+
|
|
57
|
+
const { config, root, configPath } = loadConfig();
|
|
58
|
+
if (!config) {
|
|
59
|
+
p.log.error(`${CONFIG_FILE} não encontrado — rode \`npx @spec-wave/cli@latest init\` antes.`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const alvo = target ? String(target).toLowerCase() : null;
|
|
64
|
+
if (alvo && !EXECUTION_MODES.includes(alvo)) {
|
|
65
|
+
p.log.error(`Modo inválido: ${alvo}. Use um de: ${EXECUTION_MODES.join(', ')}.`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const atual = configuredMode(config);
|
|
70
|
+
const owner = config.owner;
|
|
71
|
+
const repo = config.repo;
|
|
72
|
+
|
|
73
|
+
let token = null;
|
|
74
|
+
let variavel;
|
|
75
|
+
if (owner && repo) {
|
|
76
|
+
try {
|
|
77
|
+
token = await resolveToken();
|
|
78
|
+
variavel = await readVariable(token, owner, repo);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
p.log.warn(`Não foi possível consultar a variável do repositório: ${err.message}`);
|
|
81
|
+
variavel = undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Sem argumento: só relatório.
|
|
86
|
+
if (!alvo) {
|
|
87
|
+
const estado = describeModeState({
|
|
88
|
+
configured: atual, variable: variavel, unguardedWorkflows: unguardedWorkflows(root),
|
|
89
|
+
});
|
|
90
|
+
p.log.info(estado.summary);
|
|
91
|
+
for (const nota of estado.notes) p.log.message(`• ${nota}`);
|
|
92
|
+
for (const fix of estado.fixes) p.log.warn(fix);
|
|
93
|
+
p.outro(
|
|
94
|
+
atual === 'local'
|
|
95
|
+
? `Próximo passo de uma issue: ${chalk.cyan('spec-wave run <issue>')}`
|
|
96
|
+
: `Para desligar o CI: ${chalk.cyan('spec-wave mode local')}`
|
|
97
|
+
);
|
|
98
|
+
return { mode: atual, variable: variavel, changed: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const esperado = variableValueFor(alvo);
|
|
102
|
+
const mudaConfig = atual !== alvo;
|
|
103
|
+
// `undefined` é "não deu para LER" (403 — a variável exige admin), e não
|
|
104
|
+
// "está como deveria". Tratar os dois iguais fazia o comando pular a escrita
|
|
105
|
+
// e ainda assim anunciar que config e variável coincidiam: o usuário saía
|
|
106
|
+
// achando que desligou o CI, com os workflows armados e o minuto sendo
|
|
107
|
+
// cobrado — exatamente o meio-caminho que este comando existe para evitar.
|
|
108
|
+
//
|
|
109
|
+
// Não conseguir ler quase sempre significa não conseguir escrever. Tentar e
|
|
110
|
+
// falhar com a mensagem certa é honesto; não tentar e dizer "coincidem" não.
|
|
111
|
+
const mudaVariavel = shouldWriteVariable({ variable: variavel, expected: esperado });
|
|
112
|
+
|
|
113
|
+
if (!mudaConfig && !mudaVariavel) {
|
|
114
|
+
p.log.success(`Já está em ${chalk.bold(alvo)} — config e variável do repositório coincidem.`);
|
|
115
|
+
p.outro('Nada a fazer.');
|
|
116
|
+
return { mode: alvo, variable: variavel, changed: false, variableApplied: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (dryRun) {
|
|
120
|
+
if (mudaConfig) p.log.info(`${CONFIG_FILE}: execution.mode ${atual} → ${alvo}`);
|
|
121
|
+
if (mudaVariavel) {
|
|
122
|
+
const atualDaVariavel = variavel === undefined
|
|
123
|
+
? 'valor atual desconhecido — sem permissão para ler'
|
|
124
|
+
: `valor atual: ${variavel === null ? 'ausente' : variavel}`;
|
|
125
|
+
p.log.info(esperado === null
|
|
126
|
+
? `Variável ${EXECUTION_VARIABLE}: remover (${atualDaVariavel})`
|
|
127
|
+
: `Variável ${EXECUTION_VARIABLE}: definir como "${esperado}" (${atualDaVariavel})`);
|
|
128
|
+
}
|
|
129
|
+
p.outro('Dry-run: nada foi alterado.');
|
|
130
|
+
return { mode: atual, variable: variavel, changed: false };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (mudaConfig) {
|
|
134
|
+
const { changed } = updateConfig(cfg => {
|
|
135
|
+
cfg.execution = { ...(cfg.execution || {}), mode: alvo };
|
|
136
|
+
}, { cwd: root || process.cwd() });
|
|
137
|
+
if (changed) p.log.success(`${CONFIG_FILE} atualizado: execution.mode = ${alvo}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let variavelOk = true;
|
|
141
|
+
if (token && owner && repo) {
|
|
142
|
+
try {
|
|
143
|
+
if (esperado === null) {
|
|
144
|
+
const removida = await deleteRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
|
|
145
|
+
p.log.success(removida
|
|
146
|
+
? `Variável ${EXECUTION_VARIABLE} removida — os workflows voltam a disparar.`
|
|
147
|
+
: `Variável ${EXECUTION_VARIABLE} já não existia.`);
|
|
148
|
+
} else {
|
|
149
|
+
await setRepoVariable(token, owner, repo, EXECUTION_VARIABLE, esperado);
|
|
150
|
+
p.log.success(`Variável ${EXECUTION_VARIABLE}=${esperado} — os jobs passam a ser pulados (0 minutos).`);
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
variavelOk = false;
|
|
154
|
+
p.log.error(
|
|
155
|
+
`Não foi possível escrever a variável ${EXECUTION_VARIABLE} (${err.status || ''} ${err.message}).\n` +
|
|
156
|
+
'Ela exige administração no repositório — peça a um admin, ou defina em ' +
|
|
157
|
+
'Settings → Secrets and variables → Actions → Variables.'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
variavelOk = false;
|
|
162
|
+
p.log.warn(`Sem owner/repo ou token: só o ${CONFIG_FILE} foi atualizado.`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const semGuarda = unguardedWorkflows(root);
|
|
166
|
+
if (alvo === 'local' && semGuarda.length > 0) {
|
|
167
|
+
p.log.warn(
|
|
168
|
+
`Estes workflows instalados ainda não têm a guarda \`${EXECUTION_GUARD}\` e vão rodar mesmo assim: ` +
|
|
169
|
+
`${semGuarda.join(', ')}. Rode \`npx @spec-wave/cli@latest update\`.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
p.log.message(chalk.dim(`Commite o ${configPath ? path.basename(configPath) : CONFIG_FILE} — quem clona o repo lê a versão versionada.`));
|
|
174
|
+
p.outro(
|
|
175
|
+
alvo === 'local'
|
|
176
|
+
? `Modo local. Conduza o fluxo com ${chalk.cyan('spec-wave run <issue>')}.`
|
|
177
|
+
: 'Modo actions. As labels de gatilho voltam a disparar os workflows.'
|
|
178
|
+
);
|
|
179
|
+
return { mode: alvo, variable: esperado, changed: true, variableApplied: variavelOk };
|
|
180
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// Executa LOCALMENTE o passo que a label dispararia no GitHub Actions.
|
|
2
|
+
//
|
|
3
|
+
// O YAML nunca fez trabalho nenhum: ele instala a CLI e chama um comando. Este
|
|
4
|
+
// comando fecha o buraco que sobra quando os workflows estão desarmados
|
|
5
|
+
// (`spec-wave mode local`) — decidir QUAL comando é o próximo e chamá-lo, sem
|
|
6
|
+
// aplicar label nenhuma.
|
|
7
|
+
//
|
|
8
|
+
// A decisão é uma função pura (`lib/next-step.mjs`), testada sem rede. Aqui só
|
|
9
|
+
// mora o que é impuro: coletar o estado, segurar o lock, despachar e relatar.
|
|
10
|
+
//
|
|
11
|
+
// Três coisas que este comando NUNCA faz, e o porquê:
|
|
12
|
+
// • aplicar label de gatilho — dispararia o Action e a execução aconteceria duas vezes;
|
|
13
|
+
// • rodar dentro do Actions — lá o contrato é a label (`assertNotInActions`);
|
|
14
|
+
// • encadear passos sem teto — cada passo de IA custa dinheiro (`--max-steps`).
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
|
|
17
|
+
import { execSync } from 'node:child_process';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
|
|
22
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
23
|
+
import { getIssue, getPR, getFileContent, listPullRequestReviews } from '../api/github-rest.mjs';
|
|
24
|
+
import { loadProjectConfig } from '../lib/board.mjs';
|
|
25
|
+
import { existsOnRemote } from '../lib/doc-availability.mjs';
|
|
26
|
+
import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
|
|
27
|
+
import { isActionsRun, resolveFlowContext } from '../lib/flow-run.mjs';
|
|
28
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
29
|
+
import { nextStep, resolveForcedStep, stepDocs, docsForType, STEPS } from '../lib/next-step.mjs';
|
|
30
|
+
import { nextPrStep, reviewVerdict } from '../lib/pr-step.mjs';
|
|
31
|
+
import { configuredMode } from '../lib/execution-mode.mjs';
|
|
32
|
+
import { labelNames } from '../config.mjs';
|
|
33
|
+
|
|
34
|
+
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Lock — o substituto do `concurrency: spec-wave-item-<n>` dos workflows.
|
|
38
|
+
//
|
|
39
|
+
// Sem ele, dois `run` na mesma issue geram dois documentos, dois commits
|
|
40
|
+
// disputando a ponta da branch e leitura-modificação-escrita concorrente do
|
|
41
|
+
// comentário de uso. Fica em .git/ porque já é ignorado e é por clone.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Diretório .git COMPARTILHADO do clone (função com I/O, isolada para teste).
|
|
46
|
+
*
|
|
47
|
+
* Montar `<root>/.git` à mão assume que `.git` é um diretório — e num git
|
|
48
|
+
* worktree ele é um ARQUIVO com `gitdir: <caminho real>`. O `mkdirSync` do
|
|
49
|
+
* acquireLock estourava ENOTDIR ali, derrubando TODO `spec-wave run` de dentro
|
|
50
|
+
* de um worktree, inclusive `--dry-run`, antes de qualquer trabalho.
|
|
51
|
+
*
|
|
52
|
+
* `--git-common-dir` e não `--git-dir`: num worktree o `--git-dir` é
|
|
53
|
+
* `.git/worktrees/<nome>`, o que tornaria o lock por WORKTREE. Dois `run` na
|
|
54
|
+
* mesma issue em worktrees diferentes rodariam em paralelo commitando na mesma
|
|
55
|
+
* branch — exatamente o que este lock existe para impedir. O comum preserva o
|
|
56
|
+
* "é por clone" que o comentário acima declara.
|
|
57
|
+
*
|
|
58
|
+
* O caminho devolvido é RELATIVO ao cwd quando se está no clone principal
|
|
59
|
+
* (`.git` na raiz, `../../.git` num subdiretório) e absoluto de dentro de um
|
|
60
|
+
* worktree — daí o `path.resolve`. Sem ele, rodar de um subdiretório criaria um
|
|
61
|
+
* `.git/` novo ali dentro, que é um estrago pior e mais silencioso que o ENOTDIR.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} [root] raiz do projeto (a do .spec-wave.json)
|
|
64
|
+
* @returns {string} caminho absoluto do diretório .git compartilhado
|
|
65
|
+
*/
|
|
66
|
+
export function gitCommonDir(root) {
|
|
67
|
+
const cwd = root || process.cwd();
|
|
68
|
+
try {
|
|
69
|
+
const out = execSync('git rev-parse --git-common-dir', {
|
|
70
|
+
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
71
|
+
}).trim();
|
|
72
|
+
if (out) return path.resolve(cwd, out);
|
|
73
|
+
} catch {
|
|
74
|
+
// Fora de um repositório git: cai no palpite antigo. O `run` vai falhar
|
|
75
|
+
// adiante de qualquer forma (ele commita e faz push), e com mensagem melhor.
|
|
76
|
+
}
|
|
77
|
+
return path.join(cwd, '.git');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function lockPath(root, key) {
|
|
81
|
+
return path.join(gitCommonDir(root), 'spec-wave', `run-${key}.lock`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function acquireLock(root, key) {
|
|
85
|
+
const file = lockPath(root, key);
|
|
86
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
87
|
+
const payload = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(file, payload, { flag: 'wx' });
|
|
90
|
+
return file;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err.code !== 'EEXIST') throw err;
|
|
93
|
+
let idade = Infinity;
|
|
94
|
+
let dono = null;
|
|
95
|
+
try {
|
|
96
|
+
dono = JSON.parse(readFileSync(file, 'utf-8'));
|
|
97
|
+
idade = Date.now() - Date.parse(dono.startedAt);
|
|
98
|
+
} catch { /* lock ilegível conta como velho */ }
|
|
99
|
+
if (idade < LOCK_STALE_MS) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Já existe um \`spec-wave run\` para ${key} (pid ${dono?.pid ?? '?'}, desde ${dono?.startedAt ?? '?'}).\n` +
|
|
102
|
+
`Se tiver certeza de que morreu, apague ${file}.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
console.warn(`⚠️ Lock antigo encontrado (${file}) — assumindo processo morto e seguindo.`);
|
|
106
|
+
writeFileSync(file, payload);
|
|
107
|
+
return file;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function releaseLock(file) {
|
|
112
|
+
try {
|
|
113
|
+
if (file) unlinkSync(file);
|
|
114
|
+
} catch { /* já removido */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Commits locais ainda não publicados: gerar o próximo documento por cima de um
|
|
118
|
+
// anterior que ficou só no clone é execução parcial disfarçada de sucesso.
|
|
119
|
+
function unpushedCommits(root) {
|
|
120
|
+
try {
|
|
121
|
+
const out = execSync('git rev-list --count @{u}..HEAD', {
|
|
122
|
+
cwd: root || process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
123
|
+
}).trim();
|
|
124
|
+
return Number.parseInt(out, 10) || 0;
|
|
125
|
+
} catch {
|
|
126
|
+
return 0; // sem upstream configurado: não dá para afirmar nada
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function assertNotInActions() {
|
|
131
|
+
if (!isActionsRun()) return;
|
|
132
|
+
throw new Error(
|
|
133
|
+
'O `run` é o gatilho do modo LOCAL — dentro do GitHub Actions o gatilho é a label, ' +
|
|
134
|
+
'e o workflow já chama o comando certo. Rodar os dois seria executar o passo duas vezes.'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Coleta do estado
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function localDocStates(issue, type, root) {
|
|
143
|
+
if (type === 'Bug') {
|
|
144
|
+
const { fileRel, fileAbs } = bugDocPaths(issue.title, root);
|
|
145
|
+
return {
|
|
146
|
+
docs: { bug: existsSync(fileAbs) ? 'local' : 'missing' },
|
|
147
|
+
docPaths: { bug: fileRel },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const paths = featureDocPaths(root, issue, type);
|
|
151
|
+
const docs = {};
|
|
152
|
+
const docPaths = {};
|
|
153
|
+
for (const nome of ['spec', 'plan', 'decomposition']) {
|
|
154
|
+
docs[nome] = existsSync(paths[nome].abs) ? 'local' : 'missing';
|
|
155
|
+
docPaths[nome] = paths[nome].rel;
|
|
156
|
+
}
|
|
157
|
+
return { docs, docPaths };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Sonda o remoto SÓ pelos documentos que o passo escolhido usa e que faltam no
|
|
162
|
+
* clone. É a diferença entre "ainda não foi gerado" e "foi gerado e você não
|
|
163
|
+
* puxou" — a segunda leva a sobrescrever trabalho publicado.
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* Documentos que vale sondar no remoto antes de acreditar na decisão (PURA).
|
|
167
|
+
*
|
|
168
|
+
* O G5 ("as labels afirmam um documento que não existe aqui") diz, com todas as
|
|
169
|
+
* letras, que o arquivo *não está no clone nem no remoto* — e manda renomear o
|
|
170
|
+
* diretório ou REGERAR. Enquanto a sonda só rodava para decisão não-bloqueada,
|
|
171
|
+
* essa frase era afirmada sem nunca ter perguntado ao remoto: num clone que
|
|
172
|
+
* está apenas atrás, o conselho levava a regerar por cima de um documento
|
|
173
|
+
* publicado, gastando uma chamada de IA para destruir o artefato bom. A
|
|
174
|
+
* correção certa era `git pull`.
|
|
175
|
+
*
|
|
176
|
+
* Nesse caso o passo é `none` e não há `writes`/`reads` de onde tirar a lista —
|
|
177
|
+
* sondamos os documentos do tipo.
|
|
178
|
+
*
|
|
179
|
+
* @param {{action: string, blocked: object|null}} decision
|
|
180
|
+
* @param {string|null} type
|
|
181
|
+
* @returns {string[]}
|
|
182
|
+
*/
|
|
183
|
+
export function docsToProbe(decision, type) {
|
|
184
|
+
if (decision.blocked?.code === 'inconsistent-state') return docsForType(type);
|
|
185
|
+
if (decision.blocked || !STEPS[decision.action]) return [];
|
|
186
|
+
// stepDocs e não `[writes, ...reads]`: `validate` e `decompose` leem
|
|
187
|
+
// documentos diferentes conforme o tipo, e sondar a lista errada devolve um
|
|
188
|
+
// `docs` que o G6 aprova por engano.
|
|
189
|
+
return stepDocs(decision.action, type);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function probeRemote({ alvos, docs, docPaths, token, owner, repo }) {
|
|
193
|
+
if (alvos.length === 0) return docs;
|
|
194
|
+
|
|
195
|
+
const atualizado = { ...docs };
|
|
196
|
+
for (const doc of alvos) {
|
|
197
|
+
const onRemote = await existsOnRemote({
|
|
198
|
+
getFileContent, token, owner, repo, pathRel: docPaths[doc],
|
|
199
|
+
});
|
|
200
|
+
if (onRemote === true) atualizado[doc] = 'remote';
|
|
201
|
+
else if (onRemote === null) atualizado[doc] = 'unknown';
|
|
202
|
+
}
|
|
203
|
+
return atualizado;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Relato
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
function reportDecision(decision, { issueNumber, type, title, warnings }) {
|
|
211
|
+
console.log(chalk.bold(`\nIssue #${issueNumber} · ${type || 'tipo desconhecido'} · ${title}`));
|
|
212
|
+
console.log(`Próximo passo: ${chalk.cyan(decision.action)}`);
|
|
213
|
+
console.log(`Motivo: ${decision.reason}`);
|
|
214
|
+
if (decision.command) console.log(`Comando: ${chalk.dim(decision.command)}`);
|
|
215
|
+
for (const aviso of warnings) console.warn(chalk.yellow(`⚠️ ${aviso}`));
|
|
216
|
+
if (decision.blocked) {
|
|
217
|
+
console.log(chalk.yellow(`\n⛔ ${decision.blocked.code}: ${decision.blocked.message}`));
|
|
218
|
+
console.log(` ${decision.blocked.unblock}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// O prefixo do título é o que os workflows filtram (`contains(title, '[FEATURE]')`),
|
|
223
|
+
// enquanto detectIssueType tem fallback por label: sem o aviso, uma issue rodaria
|
|
224
|
+
// local e nunca rodaria no runner, e a diferença só apareceria ao voltar o modo.
|
|
225
|
+
function parityWarnings(issue, type) {
|
|
226
|
+
const avisos = [];
|
|
227
|
+
const titulo = String(issue.title || '');
|
|
228
|
+
if (type && !titulo.includes(`[${type.toUpperCase()}]`)) {
|
|
229
|
+
avisos.push(
|
|
230
|
+
`O título não traz o prefixo [${type.toUpperCase()}] — o workflow correspondente NÃO ` +
|
|
231
|
+
'dispararia para esta issue no modo actions (ele filtra pelo título).'
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return avisos;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Despacho
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Saída de `--json` (função PURA — devolve o texto, não imprime).
|
|
243
|
+
*
|
|
244
|
+
* UM documento, sempre. A impressão morava dentro do laço de passos, então
|
|
245
|
+
* `--max-steps N` emitia N documentos JSON concatenados — saída que nenhum
|
|
246
|
+
* parser aceita, numa flag que a skill anuncia justamente para ramificar
|
|
247
|
+
* programaticamente.
|
|
248
|
+
*
|
|
249
|
+
* O desfecho fica no TOPO, e não só dentro de `steps`: é o que quase todo
|
|
250
|
+
* consumidor lê, e é exatamente o formato que a execução de um passo só já
|
|
251
|
+
* produzia. Assim a correção não quebra quem já lia `.action`.
|
|
252
|
+
*
|
|
253
|
+
* @param {object} params
|
|
254
|
+
* @param {string|number} params.issueNumber
|
|
255
|
+
* @param {string|null} params.type
|
|
256
|
+
* @param {object|null} params.ultimaDecisao
|
|
257
|
+
* @param {object[]} [params.passos]
|
|
258
|
+
* @returns {string} JSON indentado
|
|
259
|
+
*/
|
|
260
|
+
export function renderRunJson({ issueNumber, type, ultimaDecisao, passos = [] }) {
|
|
261
|
+
return JSON.stringify(
|
|
262
|
+
{ issue: Number(issueNumber), type: type ?? null, ...ultimaDecisao, steps: passos },
|
|
263
|
+
null, 2,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function dispatch(action, { issueNumber }) {
|
|
268
|
+
switch (action) {
|
|
269
|
+
case 'generate-spec': {
|
|
270
|
+
const { generateSpec } = await import('./generate-spec.mjs');
|
|
271
|
+
return await generateSpec({ issueNumber });
|
|
272
|
+
}
|
|
273
|
+
case 'generate-plan': {
|
|
274
|
+
const { generatePlan } = await import('./generate-plan.mjs');
|
|
275
|
+
return await generatePlan({ issueNumber });
|
|
276
|
+
}
|
|
277
|
+
case 'critique': {
|
|
278
|
+
const { critique } = await import('./generate-plan.mjs');
|
|
279
|
+
return await critique({ issueNumber });
|
|
280
|
+
}
|
|
281
|
+
case 'validate': {
|
|
282
|
+
const { validate } = await import('./validate.mjs');
|
|
283
|
+
return await validate({ issueNumber });
|
|
284
|
+
}
|
|
285
|
+
case 'decompose': {
|
|
286
|
+
const { decompose } = await import('./decompose.mjs');
|
|
287
|
+
return await decompose({ issueNumber, apply: false });
|
|
288
|
+
}
|
|
289
|
+
case 'decompose-apply': {
|
|
290
|
+
const { decompose } = await import('./decompose.mjs');
|
|
291
|
+
return await decompose({ issueNumber, apply: true });
|
|
292
|
+
}
|
|
293
|
+
case 'generate-bug': {
|
|
294
|
+
const { generateBug } = await import('./generate-bug.mjs');
|
|
295
|
+
return await generateBug({ issueNumber });
|
|
296
|
+
}
|
|
297
|
+
default:
|
|
298
|
+
throw new Error(`Passo sem despacho: ${action}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Modo PR
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
async function runForPr({ prNumber, dryRun, yes, only, json }) {
|
|
307
|
+
const { owner, repo, root } = resolveFlowContext({ command: 'run --pr' });
|
|
308
|
+
const token = await resolveToken();
|
|
309
|
+
const n = parseInt(prNumber, 10);
|
|
310
|
+
|
|
311
|
+
const pr = await getPR(token, owner, repo, n);
|
|
312
|
+
const reviews = await listPullRequestReviews(token, owner, repo, n);
|
|
313
|
+
const verdict = reviewVerdict(reviews);
|
|
314
|
+
const decision = nextPrStep({
|
|
315
|
+
state: pr.state,
|
|
316
|
+
draft: Boolean(pr.draft),
|
|
317
|
+
merged: Boolean(pr.merged_at),
|
|
318
|
+
approved: verdict.approved,
|
|
319
|
+
changesRequestedAfterApproval: verdict.changesRequestedAfterApproval,
|
|
320
|
+
only: only || null,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const { error: boardError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
324
|
+
if (boardError) decision.warnings.push(`${boardError} — o board não será atualizado.`);
|
|
325
|
+
|
|
326
|
+
if (json) {
|
|
327
|
+
console.log(JSON.stringify({ pr: n, ...decision, approvers: verdict.approvers }, null, 2));
|
|
328
|
+
} else {
|
|
329
|
+
console.log(chalk.bold(`\nPR #${n} · ${pr.title}`));
|
|
330
|
+
console.log(`Passos: ${decision.steps.length ? chalk.cyan(decision.steps.join(' → ')) : '(nenhum)'}`);
|
|
331
|
+
console.log(`Motivo: ${decision.reason}`);
|
|
332
|
+
for (const aviso of decision.warnings) console.warn(chalk.yellow(`⚠️ ${aviso}`));
|
|
333
|
+
if (decision.blocked) console.log(chalk.yellow(`\n⛔ ${decision.blocked.code}: ${decision.blocked.message}`));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (decision.blocked || decision.steps.length === 0) {
|
|
337
|
+
process.exitCode = decision.blocked ? 2 : 0;
|
|
338
|
+
return decision;
|
|
339
|
+
}
|
|
340
|
+
if (verdict.changesRequestedAfterApproval && !yes) {
|
|
341
|
+
console.log(chalk.yellow('\n⛔ needs-confirmation: há pedido de mudanças além da aprovação. Confirme com `--yes`.'));
|
|
342
|
+
process.exitCode = 2;
|
|
343
|
+
return decision;
|
|
344
|
+
}
|
|
345
|
+
if (dryRun) {
|
|
346
|
+
console.log(chalk.dim('\nDry-run: nada foi executado.'));
|
|
347
|
+
return decision;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const lock = acquireLock(root, `pr-${n}`);
|
|
351
|
+
try {
|
|
352
|
+
for (const step of decision.steps) {
|
|
353
|
+
console.log(chalk.bold(`\n▶ ${step} --pr-number ${n}`));
|
|
354
|
+
if (step === 'code-review') {
|
|
355
|
+
const { codeReview } = await import('./code-review.mjs');
|
|
356
|
+
await codeReview({ prNumber: String(n) });
|
|
357
|
+
} else {
|
|
358
|
+
const { qa } = await import('./qa.mjs');
|
|
359
|
+
await qa({ prNumber: String(n) });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} finally {
|
|
363
|
+
releaseLock(lock);
|
|
364
|
+
}
|
|
365
|
+
return decision;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Comando
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
export async function run(issueArg, options = {}) {
|
|
373
|
+
assertNotInActions();
|
|
374
|
+
|
|
375
|
+
const {
|
|
376
|
+
pr, dryRun = false, yes = false, apply = false, step: forced = null,
|
|
377
|
+
maxSteps = 1, force = false, remoteCheck = true, json = false, only = null,
|
|
378
|
+
} = options;
|
|
379
|
+
|
|
380
|
+
if (pr) return await runForPr({ prNumber: pr, dryRun, yes, only, json });
|
|
381
|
+
|
|
382
|
+
if (!issueArg) throw new Error('Informe o número da issue: `spec-wave run <issue>` (ou `--pr <n>`).');
|
|
383
|
+
|
|
384
|
+
const { owner, repo, root, config } = resolveFlowContext({ command: 'run' });
|
|
385
|
+
const token = await resolveToken();
|
|
386
|
+
const issueNumber = String(issueArg).replace(/^#/, '');
|
|
387
|
+
const teto = Math.max(1, parseInt(maxSteps, 10) || 1);
|
|
388
|
+
|
|
389
|
+
if (configuredMode(config) !== 'local') {
|
|
390
|
+
console.warn(chalk.yellow(
|
|
391
|
+
'⚠️ Este repositório está em modo `actions` — os workflows continuam armados e podem ' +
|
|
392
|
+
'rodar o mesmo passo. Use `spec-wave mode local` para desarmá-los.'
|
|
393
|
+
));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const lock = acquireLock(root, issueNumber);
|
|
397
|
+
// `critiqueFile` seta process.exitCode = 1; sem preservar, um run inteiro
|
|
398
|
+
// bem-sucedido sairia com código de erro.
|
|
399
|
+
const exitCodeAntes = process.exitCode;
|
|
400
|
+
try {
|
|
401
|
+
let ultimaDecisao = null;
|
|
402
|
+
// `--json` acumula e imprime UMA vez no fim (ver renderRunJson).
|
|
403
|
+
const passos = [];
|
|
404
|
+
let ultimoTipo = null;
|
|
405
|
+
|
|
406
|
+
for (let i = 0; i < teto; i++) {
|
|
407
|
+
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
408
|
+
const type = detectIssueType(issue);
|
|
409
|
+
const { docs, docPaths } = localDocStates(issue, type, root);
|
|
410
|
+
const { error: boardError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
411
|
+
|
|
412
|
+
let forcedStep = null;
|
|
413
|
+
if (forced && i === 0) {
|
|
414
|
+
const resolvido = resolveForcedStep(forced, { type });
|
|
415
|
+
if (resolvido.error) throw new Error(resolvido.error);
|
|
416
|
+
forcedStep = resolvido.action;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const entrada = {
|
|
420
|
+
type,
|
|
421
|
+
issueNumber,
|
|
422
|
+
state: issue.state,
|
|
423
|
+
labels: labelNames(issue),
|
|
424
|
+
docPaths,
|
|
425
|
+
forcedStep,
|
|
426
|
+
confirmed: yes || apply,
|
|
427
|
+
boardReady: !boardError,
|
|
428
|
+
force,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
let decision = nextStep({ ...entrada, docs });
|
|
432
|
+
if (remoteCheck) {
|
|
433
|
+
const alvos = docsToProbe(decision, type).filter(doc => docs[doc] === 'missing');
|
|
434
|
+
if (alvos.length > 0) {
|
|
435
|
+
const docsRemotos = await probeRemote({ alvos, docs, docPaths, token, owner, repo });
|
|
436
|
+
decision = nextStep({ ...entrada, docs: docsRemotos });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
ultimaDecisao = decision;
|
|
440
|
+
|
|
441
|
+
// `--apply` é uma asserção sobre o estado esperado, não um "faça o que for":
|
|
442
|
+
// se o passo pendente virou outro, errar é melhor que sobrescrever um documento.
|
|
443
|
+
if (apply && decision.action !== 'decompose-apply' && !yes) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`--apply autoriza especificamente o \`decompose-apply\`, mas o passo pendente é ` +
|
|
446
|
+
`\`${decision.action}\`. Use --yes se era isso mesmo que você queria.`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
ultimoTipo = type;
|
|
451
|
+
if (json) passos.push(decision);
|
|
452
|
+
else reportDecision(decision, { issueNumber, type, title: issue.title, warnings: parityWarnings(issue, type) });
|
|
453
|
+
|
|
454
|
+
if (decision.blocked) { process.exitCode = 2; break; }
|
|
455
|
+
if (decision.action === 'none') break;
|
|
456
|
+
if (dryRun) {
|
|
457
|
+
console.log(chalk.dim('\nDry-run: nada foi executado.'));
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
console.log(chalk.bold(`\n▶ ${decision.command}\n`));
|
|
462
|
+
const resultado = await dispatch(decision.action, { issueNumber });
|
|
463
|
+
process.exitCode = exitCodeAntes;
|
|
464
|
+
|
|
465
|
+
// Reprova do validate é desfecho esperado, não exceção — mas encadear em
|
|
466
|
+
// cima dela repetiria o mesmo passo até esgotar `--max-steps`.
|
|
467
|
+
if (resultado?.ok === false) {
|
|
468
|
+
console.warn(chalk.yellow('\n⚠️ O passo terminou reprovado — corrija os problemas acima e repita.'));
|
|
469
|
+
process.exitCode = 2;
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const pendentes = unpushedCommits(root);
|
|
474
|
+
if (pendentes > 0) {
|
|
475
|
+
console.warn(chalk.yellow(
|
|
476
|
+
`\n⚠️ ${pendentes} commit(s) ainda não publicado(s). O próximo passo leria um documento ` +
|
|
477
|
+
'que só existe no seu clone — publique antes de continuar.'
|
|
478
|
+
));
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (json) {
|
|
484
|
+
console.log(renderRunJson({ issueNumber, type: ultimoTipo, ultimaDecisao, passos }));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return ultimaDecisao;
|
|
488
|
+
} finally {
|
|
489
|
+
releaseLock(lock);
|
|
490
|
+
}
|
|
491
|
+
}
|