@spec-wave/cli 0.25.0 → 0.27.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.
Files changed (38) hide show
  1. package/bin/spec-wave.mjs +4 -374
  2. package/package.json +1 -1
  3. package/src/api/github-rest.mjs +25 -0
  4. package/src/cli.mjs +400 -0
  5. package/src/commands/decompose.mjs +186 -45
  6. package/src/commands/doctor.mjs +190 -3
  7. package/src/commands/generate-bug.mjs +22 -16
  8. package/src/commands/generate-plan.mjs +72 -23
  9. package/src/commands/generate-spec.mjs +19 -15
  10. package/src/commands/implement.mjs +40 -20
  11. package/src/commands/mode.mjs +16 -5
  12. package/src/commands/run.mjs +156 -40
  13. package/src/commands/validate.mjs +84 -17
  14. package/src/config.mjs +18 -0
  15. package/src/lib/artifact-pr.mjs +272 -0
  16. package/src/lib/artifact-publish.mjs +169 -0
  17. package/src/lib/doc-availability.mjs +23 -1
  18. package/src/lib/doc-source.mjs +162 -0
  19. package/src/lib/execution-mode.mjs +22 -0
  20. package/src/lib/flow-run.mjs +9 -218
  21. package/src/lib/next-step.mjs +87 -8
  22. package/src/lib/pr-branch.mjs +10 -0
  23. package/src/lib/repo-links.mjs +8 -2
  24. package/src/plugin/.claude-plugin/plugin.json +1 -1
  25. package/src/plugin/skills/bug/SKILL.md +2 -2
  26. package/src/plugin/skills/decompose/SKILL.md +4 -4
  27. package/src/plugin/skills/plan/SKILL.md +1 -1
  28. package/src/plugin/skills/run/SKILL.md +4 -2
  29. package/src/plugin/skills/spec/SKILL.md +4 -4
  30. package/src/plugin/skills/workflow/SKILL.md +2 -2
  31. package/src/templates/skill/SKILL.md +7 -7
  32. package/src/templates/workflows/code-review.yml +13 -2
  33. package/src/templates/workflows/critique.yml +1 -1
  34. package/src/templates/workflows/decompose.yml +13 -2
  35. package/src/templates/workflows/generate-bug.yml +17 -6
  36. package/src/templates/workflows/generate-plan.yml +20 -7
  37. package/src/templates/workflows/generate-spec.yml +20 -7
  38. package/src/templates/workflows/qa.yml +13 -0
@@ -0,0 +1,272 @@
1
+ // Regras PURAS da publicação de artefatos por Pull Request.
2
+ //
3
+ // Os documentos gerados (spec.md, plan.md, bug.md, decomposition.md) iam para a
4
+ // branch default por commit direto. Em repositório com proteção de branch isso
5
+ // nem chega a rodar — `flow-run.mjs` classificava a recusa (GH006) como
6
+ // definitiva e o fluxo morria com a chamada de IA já paga. E, mesmo sem
7
+ // proteção, contornava a revisão que todo o resto do processo tem.
8
+ //
9
+ // Um PR por DOCUMENTO, não por issue: cada documento é revisado e mergeado
10
+ // isoladamente, e o passo seguinte só roda com o anterior na base. É a mesma
11
+ // política do `update --branch` (ver lib/pr-branch.mjs), aplicada ao fluxo.
12
+ //
13
+ // Tudo aqui é puro de propósito, pelo mesmo motivo do pr-branch.mjs: a suíte não
14
+ // tem infraestrutura de mock de HTTP, então as DECISÕES (nome da branch, o que o
15
+ // PR conta a quem revisa) ficam separadas das chamadas de rede, que moram em
16
+ // api/github-rest.mjs.
17
+
18
+ import { resolveBranchName } from './pr-branch.mjs';
19
+
20
+ /** Prefixo de toda branch criada pelo spec-wave — inclusive as do `update --branch`. */
21
+ export const ARTIFACT_BRANCH_PREFIX = 'spec-wave/';
22
+
23
+ /**
24
+ * Documento → sufixo da branch e nome de exibição.
25
+ *
26
+ * `decomposition-apply` é um documento à parte de propósito: o `--apply`
27
+ * reescreve o decomposition.md anotado com as issues criadas, e essa anotação
28
+ * NÃO pode ir para a branch do rascunho — mutar um PR que alguém está revisando
29
+ * é o desfecho mais surpreendente disponível aqui.
30
+ */
31
+ export const ARTIFACT_DOCS = {
32
+ spec: { suffix: 'spec', file: 'spec.md' },
33
+ plan: { suffix: 'plan', file: 'plan.md' },
34
+ bug: { suffix: 'bug', file: 'bug.md' },
35
+ decomposition: { suffix: 'decompose', file: 'decomposition.md' },
36
+ 'decomposition-apply': { suffix: 'decompose-apply', file: 'decomposition.md' },
37
+ };
38
+
39
+ /** Nomes aceitos em `doc`, na ordem do fluxo. */
40
+ export const ARTIFACT_DOC_NAMES = Object.keys(ARTIFACT_DOCS);
41
+
42
+ function docSpec(doc) {
43
+ const spec = ARTIFACT_DOCS[doc];
44
+ if (!spec) {
45
+ throw new Error(
46
+ `Documento desconhecido: "${doc}". Use um de: ${ARTIFACT_DOC_NAMES.join(', ')}.`
47
+ );
48
+ }
49
+ return spec;
50
+ }
51
+
52
+ /**
53
+ * Branch do artefato (função PURA).
54
+ *
55
+ * Deliberadamente SEM timestamp e SEM slug do título: rodar o mesmo passo duas
56
+ * vezes tem que reaproveitar o mesmo PR, não espalhar uma branch por tentativa.
57
+ * O par (issue, documento) já identifica o trabalho de forma única — e o slug
58
+ * mudaria se alguém renomeasse a issue no meio, deixando um PR órfão.
59
+ *
60
+ * @param {object} params
61
+ * @param {number|string} params.issueNumber
62
+ * @param {string} params.doc chave de ARTIFACT_DOCS
63
+ * @returns {string}
64
+ */
65
+ export function artifactBranch({ issueNumber, doc }) {
66
+ const { suffix } = docSpec(doc);
67
+ // Estrito de propósito: `parseInt` transformaria 1.5 em 1 e "42abc" em 42, ou
68
+ // seja, publicaria numa branch de OUTRA issue sem nenhum sinal. O número vem
69
+ // de argumento de CLI e de payload de evento — os dois chegam como texto.
70
+ const bruto = String(issueNumber ?? '').trim();
71
+ if (!/^\d+$/.test(bruto) || Number.parseInt(bruto, 10) <= 0) {
72
+ throw new Error(`Número de issue inválido para a branch do artefato: "${issueNumber}".`);
73
+ }
74
+ const n = Number.parseInt(bruto, 10);
75
+ const branch = `${ARTIFACT_BRANCH_PREFIX}${n}-${suffix}`;
76
+ // Passa pela mesma validação do `update --branch`: o nome é montado aqui, mas
77
+ // validá-lo é barato e protege contra um sufixo novo entrar quebrado.
78
+ const { error } = resolveBranchName(branch);
79
+ if (error) throw new Error(error);
80
+ return branch;
81
+ }
82
+
83
+ /**
84
+ * A branch é de uma publicação do spec-wave? (função PURA)
85
+ *
86
+ * Usado pela guarda do code-review.yml e do qa.yml: um PR de documento não é um
87
+ * PR de implementação, e deixar os dois workflows rodarem em cima dele move o
88
+ * board por engano. Cobre também as branches do `update --branch`
89
+ * (`spec-wave/update-v*`), o que é desejável — elas também não implementam nada.
90
+ *
91
+ * @param {string} name
92
+ * @returns {boolean}
93
+ */
94
+ export function isArtifactBranch(name) {
95
+ return String(name || '').startsWith(ARTIFACT_BRANCH_PREFIX);
96
+ }
97
+
98
+ // Rótulo humano de cada documento, para título e corpo do PR.
99
+ const DOC_LABEL = {
100
+ spec: 'especificação funcional',
101
+ plan: 'plano técnico',
102
+ bug: 'documento de bug',
103
+ decomposition: 'rascunho de decomposição',
104
+ 'decomposition-apply': 'registro das issues criadas',
105
+ };
106
+
107
+ /**
108
+ * Título do PR — e assunto do commit único (função PURA).
109
+ *
110
+ * A mesma frase nos dois lugares: no modo PR existe um commit só, e ver textos
111
+ * diferentes para a mesma mudança na lista de commits e no título do PR só gera
112
+ * dúvida. Mesmo critério do composePrTitle do update (lib/pr-branch.mjs).
113
+ *
114
+ * Sem `#N` no título de propósito — ver a nota sobre vínculo em
115
+ * `composeArtifactPrBody`.
116
+ *
117
+ * @returns {string}
118
+ */
119
+ export function composeArtifactPrTitle({ doc, issueNumber, issueTitle = '' } = {}) {
120
+ docSpec(doc);
121
+ const n = Number.parseInt(issueNumber, 10);
122
+ const titulo = String(issueTitle).trim();
123
+ const alvo = titulo ? `${titulo} (issue ${n})` : `issue ${n}`;
124
+ return `docs(spec-wave): ${DOC_LABEL[doc]} de ${alvo}`;
125
+ }
126
+
127
+ /**
128
+ * Mensagem do commit ÚNICO da branch (função PURA).
129
+ *
130
+ * @returns {string}
131
+ */
132
+ export function artifactCommitMessage({ doc, issueNumber, pathRel } = {}) {
133
+ docSpec(doc);
134
+ const n = Number.parseInt(issueNumber, 10);
135
+ const acao = doc === 'decomposition-apply'
136
+ ? 'registra as issues criadas em'
137
+ : 'gera';
138
+ return `docs: ${acao} ${pathRel} (issue ${n}) [spec-wave]\n`;
139
+ }
140
+
141
+ /**
142
+ * Corpo do PR (função PURA).
143
+ *
144
+ * **A issue é referenciada SÓ pela URL completa.** Não é preciosismo: o
145
+ * `code-review` extrai o vínculo do corpo do PR e MOVE O BOARD a partir dele
146
+ * (`extractIssueNumbers`, commands/code-review.mjs). São duas regexes:
147
+ *
148
+ * • `EXPLICIT_LINK_RE` exige um verbo (`closes|fixes|resolves|implements`);
149
+ * • `ANY_MENTION_RE`, ativa sob `codeReview.linkMode: "any-mention"`, casa
150
+ * `#N` CRU — então um inocente "issue #42" já bastaria.
151
+ *
152
+ * E o próprio GitHub fecha a issue sozinho diante de `Closes #N`, independente
153
+ * das nossas regexes. A URL completa não casa com nenhuma das duas e não aciona
154
+ * o fechamento automático: um PR de documentação não fecha a Feature nem a move
155
+ * de coluna. A guarda de branch no code-review.yml/qa.yml é a segunda linha;
156
+ * esta é a primeira.
157
+ *
158
+ * @param {object} params
159
+ * @param {string} params.doc
160
+ * @param {string} params.issueUrl URL completa da issue (nunca "#N")
161
+ * @param {string} [params.issueTitle]
162
+ * @param {string} params.pathRel caminho do documento no repositório
163
+ * @param {string} params.base branch de destino
164
+ * @param {string} params.branch branch do PR
165
+ * @param {string} [params.nextLabel] label que destrava o passo seguinte
166
+ * @returns {string} markdown
167
+ */
168
+ export function composeArtifactPrBody({
169
+ doc, issueUrl = '', issueTitle = '', pathRel = '', base = '?', branch = '?', nextLabel = null,
170
+ } = {}) {
171
+ docSpec(doc);
172
+ const l = [];
173
+ l.push(`Documento gerado pelo spec-wave: **${DOC_LABEL[doc]}**.`);
174
+ l.push('');
175
+ l.push(`- Arquivo: \`${pathRel}\``);
176
+ l.push(`- Issue: ${issueUrl}${issueTitle ? ` — ${issueTitle}` : ''}`);
177
+ l.push(`- Base: \`${base}\` · Branch: \`${branch}\``);
178
+ l.push('');
179
+ l.push('## Como revisar');
180
+ l.push('');
181
+ l.push(
182
+ 'Edite o arquivo **neste PR** se precisar corrigir — as edições são ' +
183
+ 'preservadas. Regerar o documento por cima descartaria a revisão.'
184
+ );
185
+ if (nextLabel) {
186
+ l.push('');
187
+ l.push(
188
+ `O passo seguinte do fluxo (\`${nextLabel}\`) lê este documento da branch ` +
189
+ `\`${base}\`, então ele só roda **depois do merge** deste PR.`
190
+ );
191
+ }
192
+ l.push('');
193
+ l.push('---');
194
+ l.push(
195
+ '_A issue é citada por URL, sem `#número` e sem palavra de fechamento, de ' +
196
+ 'propósito: assim este PR não fecha a issue nem move o board._'
197
+ );
198
+ return `${l.join('\n')}\n`;
199
+ }
200
+
201
+ /**
202
+ * Bloqueio "o documento existe, mas ainda não chegou à base" (função PURA).
203
+ *
204
+ * Mesmo formato dos bloqueios de lib/next-step.mjs: `{code, message, unblock}`.
205
+ * Sem isto, o `run` veria "o documento não está na base" e REGERARIA — pagando
206
+ * a IA de novo a cada execução até alguém mergear.
207
+ *
208
+ * Dois casos, com remédios diferentes — e é por isso que não são um só:
209
+ *
210
+ * • `pending-pr`: há PR aberto. Revisar e mergear.
211
+ * • `branch-only`: o commit passou e o PR NÃO foi aberto. Acontece quando a
212
+ * organização proíbe o Actions de criar PRs ou o `GH_PR_TOKEN` está errado —
213
+ * ou quando alguém fechou o PR sem mergear. Mandar "faça o merge do PR" aqui
214
+ * seria mandar procurar uma tela que não existe.
215
+ *
216
+ * @param {object} params
217
+ * @param {string} params.pathRel
218
+ * @param {string} [params.state] estado devolvido por `loadArtifact`
219
+ * @param {{number: number|string, url?: string}|null} [params.pr]
220
+ * @param {string|null} [params.branch]
221
+ * @returns {{code: 'pr-pending'|'branch-without-pr', message: string, unblock: string}}
222
+ */
223
+ export function awaitingMergeBlock({ pathRel, state = 'pending-pr', pr = null, branch = null } = {}) {
224
+ // Sem caminho, o texto vira "`undefined` foi commitado" — mensagem de erro que
225
+ // acusa a ferramenta em vez de orientar quem lê.
226
+ const alvo = pathRel ? `\`${pathRel}\`` : 'O documento';
227
+ if (state === 'branch-only' || !pr?.number) {
228
+ const onde = branch ? `\`${branch}\`` : 'uma branch do spec-wave';
229
+ return {
230
+ code: 'branch-without-pr',
231
+ message:
232
+ `${alvo} foi commitado em ${onde}, mas nenhum Pull Request está aberto ` +
233
+ 'para ela — na branch base o documento não está.',
234
+ unblock:
235
+ `Abra o PR de ${onde} para a branch base e faça o merge. Se o PR falhou ao ser ` +
236
+ 'aberto pelo Actions, rode `spec-wave doctor`: quase sempre é a opção "Allow ' +
237
+ 'GitHub Actions to create and approve pull requests" desligada, ou o secret ' +
238
+ '`GH_PR_TOKEN` ausente. Reaplicar o gatilho REGERA o documento.',
239
+ };
240
+ }
241
+ return {
242
+ code: 'pr-pending',
243
+ message:
244
+ `${alvo} existe no PR #${pr.number}, que ainda não foi mergeado — ` +
245
+ 'na branch base ele não está.',
246
+ unblock:
247
+ `Revise e faça o merge do PR #${pr.number}${pr.url ? ` (${pr.url})` : ''}. ` +
248
+ 'Gerar de novo por cima descartaria o que foi revisado.',
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Linha do comentário da issue que aponta o Pull Request (função PURA).
254
+ *
255
+ * Três desfechos possíveis, e os três precisam ser distinguíveis por quem lê a
256
+ * issue — dizer "PR aberto" quando não houve PR nenhum manda a pessoa procurar
257
+ * uma tela que não existe.
258
+ *
259
+ * @param {{pr: object|null, branch: string, unchanged: boolean}} published
260
+ * @returns {string}
261
+ */
262
+ export function renderPrLine({ pr = null, branch = '?', unchanged = false } = {}) {
263
+ if (pr?.number) {
264
+ return pr.created === false
265
+ ? `🔀 Pull Request **atualizado**: #${pr.number} — ${pr.url}`
266
+ : `🔀 Pull Request: #${pr.number} — ${pr.url}`;
267
+ }
268
+ if (unchanged) {
269
+ return `♻️ O conteúdo gerado é idêntico ao já publicado em \`${branch}\` — nenhum commit novo.`;
270
+ }
271
+ return `⚠️ Commit enviado para \`${branch}\`, mas o Pull Request não pôde ser aberto — veja o log do run.`;
272
+ }
@@ -0,0 +1,169 @@
1
+ // Publicação de um documento gerado: branch + commit único + Pull Request.
2
+ //
3
+ // Substitui o `commitGenerated` do lib/flow-run.mjs, que commitava direto na
4
+ // branch default. Em repositório com proteção de branch aquilo nem rodava — a
5
+ // recusa (GH006) era classificada como definitiva e o job morria com a chamada
6
+ // de IA já paga; e, sem proteção, contornava a revisão que todo o resto do
7
+ // fluxo tem.
8
+ //
9
+ // **Por que via API e não via git local.** Os três motivos são independentes:
10
+ //
11
+ // 1. Roda igual nos dois modos. O runner tem checkout; o clone do usuário tem o
12
+ // trabalho DELE. Criar branch, commitar e voltar no clone alheio é uma
13
+ // coreografia que falha com working tree suja — e o `flow-run` já carregava
14
+ // duas exceções (identidade do git, falha de push) só por mexer em git local.
15
+ // 2. Some a corrida de publicação. Cada documento tem branch própria, o pai do
16
+ // commit é a ponta dela, e `updateRef` é fast-forward. O laço de pull+push
17
+ // com backoff deixa de ser necessário.
18
+ // 3. Commit da Git Data API vem verificado, o que satisfaz ruleset que exige
19
+ // assinatura — coisa que o git do runner não conseguiria.
20
+ //
21
+ // O preço: nada é gravado no working tree. É deliberado. Gravar sem commitar
22
+ // deixaria no clone um arquivo que o usuário não pediu e que colide no `git pull`
23
+ // depois do merge — justamente porque o merge pode trazer as edições do revisor.
24
+
25
+ import {
26
+ getRepoDefaultBranch, compareBranches, findOpenPR, commitFilesToBranch,
27
+ ensurePullRequest, deleteBranch,
28
+ } from '../api/github-rest.mjs';
29
+ import { resolveBranchName, explainGitWriteError } from './pr-branch.mjs';
30
+ import {
31
+ artifactBranch, composeArtifactPrTitle, composeArtifactPrBody, artifactCommitMessage,
32
+ } from './artifact-pr.mjs';
33
+ import { blobUrl } from './repo-links.mjs';
34
+
35
+ /**
36
+ * Token usado para abrir o PR.
37
+ *
38
+ * Nos Actions o `GITHUB_TOKEN` só abre Pull Request se a opção "Allow GitHub
39
+ * Actions to create and approve pull requests" estiver ligada — desligada por
40
+ * padrão em muitas organizações. `GH_PR_TOKEN` é a saída sem depender de uma
41
+ * configuração de organização que nem todo time controla.
42
+ *
43
+ * Mesmo formato do `PROJECT_TOKEN` (commands/qa.mjs, code-review.mjs): variável
44
+ * específica com fallback para o token da execução.
45
+ */
46
+ export function resolvePrToken(token, env = process.env) {
47
+ return env.SPEC_WAVE_PR_TOKEN || env.GH_PR_TOKEN || token;
48
+ }
49
+
50
+ /**
51
+ * A branch é resquício de um PR já mergeado? (função PURA)
52
+ *
53
+ * `identical`/`behind` significam que ela não tem NADA que a base já não tenha.
54
+ * Com PR aberto isso é normal (acabou de ser criada, ou o merge está em curso) e
55
+ * mexer nela seria mutar revisão alheia. Sem PR aberto, é lixo de um merge com
56
+ * squash: empilhar o próximo commit ali produziria um PR que reintroduz estado
57
+ * antigo, porque a ponta não é ancestral da base.
58
+ *
59
+ * @param {'identical'|'ahead'|'behind'|'diverged'|null} status
60
+ * @param {object|null} openPr
61
+ * @returns {boolean}
62
+ */
63
+ export function isStaleArtifactBranch(status, openPr) {
64
+ if (openPr) return false;
65
+ return status === 'identical' || status === 'behind';
66
+ }
67
+
68
+ /**
69
+ * Publica o documento e devolve o PR.
70
+ *
71
+ * @param {object} params
72
+ * @param {string} params.token
73
+ * @param {string} params.owner
74
+ * @param {string} params.repo
75
+ * @param {string} params.doc chave de ARTIFACT_DOCS
76
+ * @param {number|string} params.issueNumber
77
+ * @param {string} [params.issueTitle]
78
+ * @param {string} [params.issueUrl] URL da issue (NUNCA "#N" — ver artifact-pr.mjs)
79
+ * @param {string} params.pathRel caminho do documento no repositório
80
+ * @param {string} params.content conteúdo FINAL do documento
81
+ * @param {string|null} [params.base] branch de destino (null = default do repo)
82
+ * @param {string|null} [params.nextLabel] label do passo seguinte, para o corpo do PR
83
+ * @param {object} [params.deps] injeção para teste
84
+ * @returns {Promise<{branch, base, commitSha, unchanged, pr, blobUrl, warning}>}
85
+ */
86
+ export async function publishArtifact({
87
+ token, owner, repo, doc, issueNumber, issueTitle = '', issueUrl = '',
88
+ pathRel, content, base = null, nextLabel = null, deps = {},
89
+ }) {
90
+ const api = {
91
+ getRepoDefaultBranch, compareBranches, findOpenPR, commitFilesToBranch,
92
+ ensurePullRequest, deleteBranch, ...deps,
93
+ };
94
+
95
+ const branch = artifactBranch({ issueNumber, doc });
96
+ const { error: branchError } = resolveBranchName(branch);
97
+ if (branchError) throw new Error(branchError);
98
+
99
+ const alvo = base || await api.getRepoDefaultBranch(token, owner, repo);
100
+ if (branch === alvo) {
101
+ throw new Error(
102
+ `A branch do artefato ("${branch}") é a própria base — publicar nela seria ` +
103
+ 'exatamente o commit direto que este fluxo existe para evitar.'
104
+ );
105
+ }
106
+
107
+ // Branch obsoleta ANTES de commitar: depois já seria tarde, o commit teria um
108
+ // pai errado.
109
+ const [status, prAberto] = await Promise.all([
110
+ api.compareBranches(token, owner, repo, alvo, branch),
111
+ api.findOpenPR(token, owner, repo, branch).catch(() => null),
112
+ ]);
113
+ if (isStaleArtifactBranch(status, prAberto)) {
114
+ console.warn(
115
+ `Branch "${branch}" sobrou de um merge anterior e não tem PR aberto — ` +
116
+ 'recriando a partir da base.'
117
+ );
118
+ await api.deleteBranch(token, owner, repo, branch).catch(() => {});
119
+ }
120
+
121
+ let commit;
122
+ try {
123
+ commit = await api.commitFilesToBranch(token, owner, repo, {
124
+ branch,
125
+ base: alvo,
126
+ files: [{ path: pathRel, content }],
127
+ message: artifactCommitMessage({ doc, issueNumber, pathRel }),
128
+ });
129
+ } catch (err) {
130
+ throw new Error(`Falha ao publicar ${pathRel} em "${branch}": ${explainGitWriteError(err)}`);
131
+ }
132
+
133
+ // O PR fica num try SEPARADO, pelo mesmo motivo do `update --branch`: um token
134
+ // com "Contents: write" pode não ter "Pull requests: write", e essa falha não
135
+ // invalida o commit — a mensagem precisa dizer que a branch existe.
136
+ let pr = null;
137
+ let warning = null;
138
+ try {
139
+ pr = await api.ensurePullRequest(resolvePrToken(token), owner, repo, {
140
+ branch,
141
+ base: alvo,
142
+ title: composeArtifactPrTitle({ doc, issueNumber, issueTitle }),
143
+ body: composeArtifactPrBody({
144
+ doc, issueUrl, issueTitle, pathRel, base: alvo, branch, nextLabel,
145
+ }),
146
+ });
147
+ if (!pr && !commit.unchanged) {
148
+ warning =
149
+ `A branch "${branch}" não tem nada novo em relação a "${alvo}" e não há PR ` +
150
+ 'aberto para ela — nada a revisar.';
151
+ }
152
+ } catch (err) {
153
+ warning =
154
+ `Commit ${commit.commitSha?.slice(0, 7)} enviado para "${branch}", mas o Pull Request ` +
155
+ `não pôde ser aberto: ${explainGitWriteError(err)}. ` +
156
+ `Abra manualmente: https://github.com/${owner}/${repo}/compare/` +
157
+ `${alvo}...${encodeURIComponent(branch)}?expand=1`;
158
+ }
159
+
160
+ return {
161
+ branch,
162
+ base: alvo,
163
+ commitSha: commit.commitSha,
164
+ unchanged: commit.unchanged,
165
+ pr,
166
+ blobUrl: blobUrl({ owner, repo, ref: branch, pathRel }),
167
+ warning,
168
+ };
169
+ }
@@ -19,9 +19,31 @@
19
19
  * @param {boolean|null} params.onRemote true = existe no remoto; false = não
20
20
  * existe; null = não foi possível consultar (sem token/rede)
21
21
  * @param {string} params.fallback o que o comando fará sem o documento
22
+ * @param {string|null} [params.branch] branch do artefato quando NÃO há PR aberto
23
+ * @param {{number: number|string, url?: string}|null} [params.pr] PR ABERTO que
24
+ * contém o documento — a publicação por Pull Request criou este quarto caso, e
25
+ * sem ele a mensagem afirma "não existe no repositório" sobre um documento que
26
+ * está gerado e esperando revisão. Mentira cara: manda o executor refazer o
27
+ * trabalho do zero.
22
28
  * @returns {string}
23
29
  */
24
- export function missingDocMessage({ pathRel, onRemote, fallback }) {
30
+ export function missingDocMessage({ pathRel, onRemote, fallback, pr = null, branch = null }) {
31
+ if (pr?.number) {
32
+ return (
33
+ `${pathRel} está no PR #${pr.number}, ainda NÃO mergeado` +
34
+ `${pr.url ? ` (${pr.url})` : ''} — revise e faça o merge, ou rode ` +
35
+ `\`gh pr checkout ${pr.number}\`. Seguir agora ignoraria o documento já gerado.`
36
+ );
37
+ }
38
+ // Commit publicado, PR não aberto. Sem este ramo a mensagem cairia no "não
39
+ // existe no repositório" — e o documento está lá, numa branch.
40
+ if (branch) {
41
+ return (
42
+ `${pathRel} está na branch \`${branch}\`, sem Pull Request aberto e fora da base — ` +
43
+ `rode \`git fetch origin ${branch} && git checkout ${branch}\` para vê-lo. ` +
44
+ 'Seguir agora ignoraria o documento já gerado.'
45
+ );
46
+ }
25
47
  if (onRemote === true) {
26
48
  return (
27
49
  `${pathRel} existe no repositório mas NÃO no seu clone — rode \`git pull\` e ` +
@@ -0,0 +1,162 @@
1
+ // De ONDE vem o conteúdo de um documento do fluxo.
2
+ //
3
+ // Enquanto os artefatos eram commitados na branch default, "o documento existe"
4
+ // e "o arquivo está no checkout" eram a mesma pergunta, e todo mundo respondia
5
+ // com `existsSync`. Com a publicação por Pull Request deixam de ser: um
6
+ // documento recém-gerado vive numa branch que ninguém mergeou ainda.
7
+ //
8
+ // Ignorar essa diferença não dá erro — dá coisa pior, em silêncio:
9
+ //
10
+ // • o `decompose` REGENERA um rascunho que um humano já editou (a invariante
11
+ // "um rascunho existente nunca é regenerado" é literalmente um `existsSync`);
12
+ // • o `validate` REPROVA por arquivo ausente e consome a label de gatilho;
13
+ // • o `generate-plan` gera o plano com o placeholder no lugar da spec.
14
+ //
15
+ // Por isso a resolução é uma só, em camadas, e todo leitor passa por aqui.
16
+ //
17
+ // I/O com dependências INJETADAS: é o que permite testar as quatro camadas sem
18
+ // mock de HTTP, que a suíte não tem.
19
+
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ import { getFileContent, findOpenPR } from '../api/github-rest.mjs';
24
+ import { artifactBranch } from './artifact-pr.mjs';
25
+
26
+ /**
27
+ * @typedef {'local'|'remote'|'pending-pr'|'branch-only'|'missing'|'unknown'} DocState
28
+ */
29
+
30
+ /**
31
+ * Estados em que o documento EXISTE mas ainda não chegou à branch base (PURA).
32
+ *
33
+ * Os dois significam a mesma coisa para quem depende do documento — o passo
34
+ * seguinte não pode rodar — e coisas diferentes para quem precisa destravar:
35
+ * `pending-pr` espera revisão e merge; `branch-only` espera alguém abrir o PR.
36
+ *
37
+ * Existe como predicado, e não como comparação solta, porque a lista é
38
+ * consultada em seis lugares (validate, generate-plan, decompose, apply,
39
+ * next-step, run) e um deles esquecido é uma porta aberta: foi assim que o
40
+ * `--apply` quase criou issues a partir de um rascunho que ninguém revisou.
41
+ *
42
+ * @param {DocState} state
43
+ * @returns {boolean}
44
+ */
45
+ export function isAwaitingMerge(state) {
46
+ return state === 'pending-pr' || state === 'branch-only';
47
+ }
48
+
49
+ /**
50
+ * Onde está o documento, e o seu conteúdo (I/O — NUNCA lança).
51
+ *
52
+ * A ordem das camadas é deliberada:
53
+ *
54
+ * 1. **disco** (`local`) — o que o usuário tem à mão vence; é o que ele está
55
+ * editando, e é também o caso do runner logo após o checkout.
56
+ * 2. **branch base** (`remote`) — mantém exatamente o significado que o G6 do
57
+ * next-step já dava a este estado: publicado, mas não no seu clone.
58
+ * 3. **branch do artefato** — e só então se há PR: com PR aberto, `pending-pr`;
59
+ * sem PR, `branch-only`. A ordem é essa porque perguntar pelo PR primeiro
60
+ * tornava invisível o documento cujo commit passou e cujo PR falhou (403 de
61
+ * organização, GH_PR_TOKEN errado): ele era reportado como inexistente, e a
62
+ * execução seguinte REGERAVA, pagando a IA de novo. Consultar a branch antes
63
+ * não custa chamada nenhuma — são duas requisições nos dois desfechos.
64
+ * 4. nada (`missing`); falha de rede em qualquer consulta → `unknown`.
65
+ *
66
+ * `unknown` nunca vira bloqueio: ficar offline não pode travar o fluxo. Quem
67
+ * decide o que fazer com cada estado é o chamador.
68
+ *
69
+ * @param {object} params
70
+ * @param {string} params.token
71
+ * @param {string} params.owner
72
+ * @param {string} params.repo
73
+ * @param {string|null} params.root raiz do repositório (para o caminho absoluto)
74
+ * @param {string} params.pathRel caminho do documento no repositório
75
+ * @param {string} params.doc chave de ARTIFACT_DOCS
76
+ * @param {number|string} params.issueNumber
77
+ * @param {string|null} [params.base] branch base (null = deixa a API escolher)
78
+ * @param {object} [params.deps] injeção para teste
79
+ * @returns {Promise<{content: string|null, state: DocState, ref: string|null,
80
+ * pr: {number: number, url: string}|null}>}
81
+ */
82
+ export async function loadArtifact({
83
+ token, owner, repo, root, pathRel, doc, issueNumber, base = null, deps = {},
84
+ }) {
85
+ const api = { getFileContent, findOpenPR, ...deps };
86
+
87
+ const abs = path.resolve(root || process.cwd(), pathRel);
88
+ if (existsSync(abs)) {
89
+ return { content: readFileSync(abs, 'utf-8'), state: 'local', ref: null, pr: null };
90
+ }
91
+
92
+ // Sem token não há como consultar nada — mas "não consegui olhar" não é "não
93
+ // existe": afirmar `missing` aqui faria o chamador regerar por cima.
94
+ if (!token || !owner || !repo) {
95
+ return { content: null, state: 'unknown', ref: null, pr: null };
96
+ }
97
+
98
+ let naBase;
99
+ try {
100
+ naBase = await api.getFileContent(token, owner, repo, pathRel, base || undefined);
101
+ } catch {
102
+ return { content: null, state: 'unknown', ref: null, pr: null };
103
+ }
104
+ if (naBase != null) {
105
+ return { content: naBase, state: 'remote', ref: base, pr: null };
106
+ }
107
+
108
+ let branch;
109
+ try {
110
+ branch = artifactBranch({ issueNumber, doc });
111
+ } catch {
112
+ // Documento sem branch de artefato (ou número inválido): não há onde olhar.
113
+ return { content: null, state: 'missing', ref: null, pr: null };
114
+ }
115
+
116
+ let naBranch;
117
+ try {
118
+ naBranch = await api.getFileContent(token, owner, repo, pathRel, branch);
119
+ } catch {
120
+ return { content: null, state: 'unknown', ref: null, pr: null };
121
+ }
122
+ // Branch inexistente e arquivo ausente dão o mesmo 404, e é o mesmo desfecho.
123
+ if (naBranch == null) return { content: null, state: 'missing', ref: null, pr: null };
124
+
125
+ // O PR só importa depois de saber que o documento está lá — e o que ele decide
126
+ // é o REMÉDIO, não a existência.
127
+ let pr;
128
+ try {
129
+ pr = await api.findOpenPR(token, owner, repo, branch);
130
+ } catch {
131
+ // O documento existe; não saber se há PR não pode rebaixá-lo a inexistente,
132
+ // que é o estado que autoriza regerar por cima.
133
+ return { content: naBranch, state: 'branch-only', ref: branch, pr: null };
134
+ }
135
+
136
+ return pr
137
+ ? { content: naBranch, state: 'pending-pr', ref: branch, pr: { number: pr.number, url: pr.url } }
138
+ : { content: naBranch, state: 'branch-only', ref: branch, pr: null };
139
+ }
140
+
141
+ /**
142
+ * Estado de VÁRIOS documentos de uma issue (I/O — nunca lança).
143
+ *
144
+ * Devolve o mapa que `nextStep` consome, sem o conteúdo: quem decide o próximo
145
+ * passo não precisa carregar três documentos inteiros na memória.
146
+ *
147
+ * @param {object} params
148
+ * @param {Array<{doc: string, pathRel: string}>} params.docs
149
+ * @returns {Promise<Record<string, {state: DocState, pr: object|null}>>}
150
+ */
151
+ export async function artifactStates({
152
+ token, owner, repo, root, docs = [], issueNumber, base = null, deps = {},
153
+ }) {
154
+ const out = {};
155
+ for (const { doc, pathRel } of docs) {
156
+ const { state, pr } = await loadArtifact({
157
+ token, owner, repo, root, pathRel, doc, issueNumber, base, deps,
158
+ });
159
+ out[doc] = { state, pr };
160
+ }
161
+ return out;
162
+ }
@@ -49,6 +49,28 @@ export function variableValueFor(mode) {
49
49
  return mode === 'local' ? 'local' : null;
50
50
  }
51
51
 
52
+ /**
53
+ * A variável do repositório precisa ser escrita? (função PURA)
54
+ *
55
+ * Três estados entram, não dois: `string` (valor lido), `null` (lida, e não
56
+ * existe) e `undefined` (NÃO deu para ler — 403, a variável exige admin).
57
+ *
58
+ * `undefined` conta como "precisa escrever". Tratá-lo como "já está certo"
59
+ * fazia o comando pular a escrita e ainda anunciar que config e variável
60
+ * coincidiam — o usuário saía achando que desligou o CI, com os workflows
61
+ * armados. Não conseguir ler quase sempre significa não conseguir escrever;
62
+ * tentar e falhar com a mensagem certa é honesto, não tentar e declarar
63
+ * sucesso não é.
64
+ *
65
+ * @param {object} params
66
+ * @param {string|null|undefined} params.variable valor remoto
67
+ * @param {string|null} params.expected valor que o modo-alvo exige (null = ausente)
68
+ * @returns {boolean}
69
+ */
70
+ export function shouldWriteVariable({ variable, expected }) {
71
+ return variable !== expected;
72
+ }
73
+
52
74
  /**
53
75
  * Diagnóstico do modo de execução (função PURA).
54
76
  *