@spec-wave/cli 0.13.0 → 0.15.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 +39 -6
- package/bin/spec-wave.mjs +17 -1
- package/package.json +1 -1
- package/src/api/github-rest.mjs +198 -2
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +412 -130
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +105 -30
- package/src/commands/generate-spec.mjs +17 -5
- package/src/commands/implement.mjs +39 -15
- package/src/commands/info.mjs +4 -3
- package/src/commands/issue.mjs +4 -4
- package/src/commands/move.mjs +162 -0
- package/src/commands/order.mjs +1 -12
- package/src/commands/qa.mjs +5 -8
- package/src/commands/refresh.mjs +4 -3
- package/src/commands/story.mjs +1 -12
- package/src/commands/task.mjs +1 -11
- package/src/commands/update.mjs +372 -71
- package/src/commands/validate.mjs +37 -22
- package/src/config.mjs +40 -1
- package/src/lib/board.mjs +88 -26
- package/src/lib/claude.mjs +315 -70
- package/src/lib/critique.mjs +391 -91
- package/src/lib/decomposition-doc.mjs +451 -0
- package/src/lib/implement-board.mjs +14 -1
- package/src/lib/pr-branch.mjs +267 -0
- package/src/lib/project-root.mjs +93 -0
- package/src/lib/templates.mjs +53 -0
- package/src/setup/files.mjs +3 -10
- package/src/templates/skill/SKILL.md +158 -30
- package/src/templates/workflows/code-review.yml +1 -1
- package/src/templates/workflows/decompose.yml +20 -6
- package/src/templates/workflows/generate-plan.yml +1 -1
- package/src/templates/workflows/generate-spec.yml +1 -1
- package/src/templates/workflows/qa.yml +1 -1
- package/src/templates/workflows/validate.yml +1 -1
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// Documento de decomposição (docs/features/<slug>/decomposition.md) — módulo
|
|
2
|
+
// PURO, sem I/O e sem dependências.
|
|
3
|
+
//
|
|
4
|
+
// O decompose deixou de criar issues direto do JSON do modelo. Antes, quando a
|
|
5
|
+
// crítica reprovava, o rascunho era descartado e os achados citavam uma "Story 5"
|
|
6
|
+
// que não existia em lugar nenhum — nem na issue, nem no repo, nem no log da
|
|
7
|
+
// Action. Agora o rascunho é gravado aqui, o humano revisa e corrige, e só a
|
|
8
|
+
// label spec-wave:decompose-apply cria as issues.
|
|
9
|
+
//
|
|
10
|
+
// Por isso o formato precisa ser ao mesmo tempo (a) legível e editável à mão e
|
|
11
|
+
// (b) reversível: render(parse(render(x))) === render(x). Sem a reversibilidade,
|
|
12
|
+
// uma edição humana desaparece silenciosamente no apply — o pior erro possível
|
|
13
|
+
// aqui, porque ninguém percebe.
|
|
14
|
+
//
|
|
15
|
+
// Gramática (v1):
|
|
16
|
+
//
|
|
17
|
+
// # Decomposição — <título da issue>
|
|
18
|
+
// <!-- spec-wave:decomposition v1 issue=360 kind=stories -->
|
|
19
|
+
//
|
|
20
|
+
// ## Story 1 — <título curto>
|
|
21
|
+
//
|
|
22
|
+
// **User story:** Como <perfil>, quero <objetivo>, para <benefício>
|
|
23
|
+
// **Depende de:** Story 1, Story 2 (ou "—" para nenhuma)
|
|
24
|
+
//
|
|
25
|
+
// <corpo livre, multi-linha>
|
|
26
|
+
//
|
|
27
|
+
// ### Task 1.1 — <título curto>
|
|
28
|
+
//
|
|
29
|
+
// <corpo livre, multi-linha>
|
|
30
|
+
//
|
|
31
|
+
// kind=tasks (RFC) não tem Stories — as tasks ficam em "## Task N — <título>".
|
|
32
|
+
|
|
33
|
+
// Nome do arquivo dentro do diretório da feature/RFC.
|
|
34
|
+
export const DECOMPOSITION_FILE = 'decomposition.md';
|
|
35
|
+
|
|
36
|
+
// Versão do formato, gravada no marcador. Um arquivo de versão MAIOR faz o parse
|
|
37
|
+
// falhar pedindo update da CLI, em vez de interpretar errado em silêncio.
|
|
38
|
+
export const DOC_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
// Só estas três formas são estrutura. "## Backend" ou "### Detalhes" no corpo de
|
|
41
|
+
// uma story NÃO são título de seção — é o que permite corpo com markdown livre.
|
|
42
|
+
const STORY_RE = /^##[ \t]+Story[ \t]+(\d+)[ \t]*(?:[—–:-][ \t]*)?(.*)$/i;
|
|
43
|
+
const RFC_TASK_RE = /^##[ \t]+Task[ \t]+(\d+)[ \t]*(?:[—–:-][ \t]*)?(.*)$/i;
|
|
44
|
+
const TASK_RE = /^###[ \t]+Task[ \t]+(\d+(?:\.\d+)*)[ \t]*(?:[—–:-][ \t]*)?(.*)$/i;
|
|
45
|
+
// Espelho das três acima, tolerando as barras de escape já aplicadas.
|
|
46
|
+
const STRUCTURAL_RE = /^\\*#{2,3}[ \t]+(?:Story|Task)[ \t]+\d/i;
|
|
47
|
+
|
|
48
|
+
const H1_RE = /^#[ \t]+(.*)$/;
|
|
49
|
+
const MARKER_RE = /^<!--[ \t]*spec-wave:decomposition[ \t]+(.*?)-->[ \t]*$/i;
|
|
50
|
+
const USER_STORY_RE = /^\*\*User story:?\*\*:?[ \t]*(.*)$/i;
|
|
51
|
+
const DEPENDS_RE = /^\*\*Depende de:?\*\*:?[ \t]*(.*)$/i;
|
|
52
|
+
const EMPTY_VALUE_RE = /^(—|–|-|nenhuma|nenhum|none|n\/a)$/i;
|
|
53
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
54
|
+
|
|
55
|
+
function invalid(reason) {
|
|
56
|
+
return new Error(`decomposition.md inválido: ${reason}.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rastreador de blocos de código. O MESMO rastreador roda no parse e no escape
|
|
61
|
+
* do render — é isso que garante que uma linha "## Story 2 — x" dentro de um
|
|
62
|
+
* bloco ```…``` não vire seção nem receba barra de escape.
|
|
63
|
+
*
|
|
64
|
+
* Regras do CommonMark que importam aqui: a cerca tem 3+ caracteres, o
|
|
65
|
+
* fechamento usa o mesmo caractere e comprimento >= o da abertura, e uma cerca
|
|
66
|
+
* de crase não aceita crase na info string.
|
|
67
|
+
*/
|
|
68
|
+
function fenceScanner() {
|
|
69
|
+
let open = null;
|
|
70
|
+
return {
|
|
71
|
+
// true quando a linha pertence a um bloco de código (abertura, conteúdo ou
|
|
72
|
+
// fechamento): nela nada é interpretado como estrutura.
|
|
73
|
+
inFence(line) {
|
|
74
|
+
const m = FENCE_RE.exec(line);
|
|
75
|
+
if (m) {
|
|
76
|
+
const char = m[1][0];
|
|
77
|
+
const len = m[1].length;
|
|
78
|
+
if (!open) {
|
|
79
|
+
if (!(char === '`' && m[2].includes('`'))) {
|
|
80
|
+
open = { char, len };
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
} else if (char === open.char && len >= open.len && m[2].trim() === '') {
|
|
84
|
+
open = null;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return open !== null;
|
|
89
|
+
},
|
|
90
|
+
isOpen() { return open !== null; },
|
|
91
|
+
marker() { return open ? open.char.repeat(open.len) : ''; },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Um corpo que abre bloco de código e não fecha engoliria todos os títulos
|
|
96
|
+
// seguintes. Fechamos na ESCRITA para o arquivo nascer sempre parseável; no
|
|
97
|
+
// parse, esse mesmo desbalanceamento vira erro — lá ele veio de edição humana.
|
|
98
|
+
function balanceFences(text) {
|
|
99
|
+
const scan = fenceScanner();
|
|
100
|
+
for (const line of text.split('\n')) scan.inFence(line);
|
|
101
|
+
return scan.isOpen() ? `${text}\n${scan.marker()}` : text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Barra de escape em linhas do corpo que imitariam um título de seção. `\#` é
|
|
105
|
+
// escape válido de CommonMark: o GitHub renderiza "## Story 9 — x" literal, e o
|
|
106
|
+
// parse remove exatamente uma barra — reversível mesmo em texto já escapado.
|
|
107
|
+
function escapeStructural(text) {
|
|
108
|
+
const scan = fenceScanner();
|
|
109
|
+
return text.split('\n')
|
|
110
|
+
.map(line => (scan.inFence(line) || !STRUCTURAL_RE.test(line) ? line : `\\${line}`))
|
|
111
|
+
.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function unescapeStructural(text) {
|
|
115
|
+
const scan = fenceScanner();
|
|
116
|
+
return text.split('\n')
|
|
117
|
+
.map(line => (
|
|
118
|
+
!scan.inFence(line) && line.startsWith('\\') && STRUCTURAL_RE.test(line)
|
|
119
|
+
? line.slice(1)
|
|
120
|
+
: line
|
|
121
|
+
))
|
|
122
|
+
.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Título e user story ocupam UMA linha na gramática; texto multi-linha vindo do
|
|
126
|
+
// modelo é achatado aqui, senão quebraria o seccionamento.
|
|
127
|
+
function flatten(value) {
|
|
128
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Apara as linhas em branco das PONTAS mantendo o miolo intacto — linha em
|
|
132
|
+
// branco no meio do corpo é conteúdo.
|
|
133
|
+
function trimBlankEdges(lines) {
|
|
134
|
+
const out = [...lines];
|
|
135
|
+
while (out.length && !out[0].trim()) out.shift();
|
|
136
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderBody(value) {
|
|
141
|
+
const lines = trimBlankEdges(String(value ?? '').replace(/\r\n?/g, '\n').split('\n'));
|
|
142
|
+
if (lines.length === 0) return '';
|
|
143
|
+
return escapeStructural(balanceFences(lines.join('\n')));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readBody(lines) {
|
|
147
|
+
return unescapeStructural(trimBlankEdges(lines).join('\n'));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Mesma regra que o loop de criação aplicava sobre o JSON do modelo: índices
|
|
151
|
+
// 0-based, sem duplicatas, apenas para stories ANTERIORES; campo ausente =
|
|
152
|
+
// dependência sequencial da story anterior; [] explícito = sem dependências.
|
|
153
|
+
// Aplicada no RENDER, para o arquivo já nascer com a decisão materializada.
|
|
154
|
+
function normalizeDependsOn(dependsOn, index) {
|
|
155
|
+
if (!Array.isArray(dependsOn)) return index > 0 ? [index - 1] : [];
|
|
156
|
+
return [...new Set(dependsOn.filter(d => Number.isInteger(d) && d >= 0 && d < index))]
|
|
157
|
+
.sort((a, b) => a - b);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Renderiza o decomposition.md (função PURA — testável).
|
|
162
|
+
*
|
|
163
|
+
* A saída é CANÔNICA: numeração recalculada por posição, dependências resolvidas
|
|
164
|
+
* e ordenadas, títulos achatados, corpos com cercas balanceadas e linhas
|
|
165
|
+
* ambíguas escapadas. Aceita direto a shape do JSON do modelo, então o
|
|
166
|
+
* decompose não precisa de conversão intermediária.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} model
|
|
169
|
+
* @param {string} [model.title] título da issue (vai para o H1)
|
|
170
|
+
* @param {number|null} [model.issueNumber] número da issue (marcador)
|
|
171
|
+
* @param {'stories'|'tasks'} [model.kind] Feature → stories; RFC → tasks
|
|
172
|
+
* @param {string} [model.preamble] nota livre entre o marcador e a 1ª seção
|
|
173
|
+
* @param {Array<object>} [model.stories] stories (kind=stories)
|
|
174
|
+
* @param {Array<object>} [model.tasks] tasks (kind=tasks)
|
|
175
|
+
* @returns {string} markdown terminado em \n
|
|
176
|
+
*/
|
|
177
|
+
export function renderDecompositionDoc({
|
|
178
|
+
title = '', issueNumber = null, kind = 'stories', preamble = '', stories = [], tasks = [],
|
|
179
|
+
} = {}) {
|
|
180
|
+
if (kind !== 'stories' && kind !== 'tasks') {
|
|
181
|
+
throw new Error(`decomposition.md: kind inválido "${kind}" (use "stories" ou "tasks").`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const attrs = [`v${DOC_VERSION}`];
|
|
185
|
+
const issue = Number(issueNumber);
|
|
186
|
+
if (Number.isInteger(issue) && issue > 0) attrs.push(`issue=${issue}`);
|
|
187
|
+
attrs.push(`kind=${kind}`);
|
|
188
|
+
|
|
189
|
+
const head = flatten(title) ? `# Decomposição — ${flatten(title)}` : '# Decomposição';
|
|
190
|
+
const blocks = [`${head}\n<!-- spec-wave:decomposition ${attrs.join(' ')} -->`];
|
|
191
|
+
|
|
192
|
+
const note = renderBody(preamble);
|
|
193
|
+
if (note) blocks.push(note);
|
|
194
|
+
|
|
195
|
+
const pushItem = (heading, item) => {
|
|
196
|
+
blocks.push(`${heading} — ${flatten(item?.title) || '(sem título)'}`);
|
|
197
|
+
const body = renderBody(item?.body);
|
|
198
|
+
if (body) blocks.push(body);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (kind === 'tasks') {
|
|
202
|
+
(tasks || []).forEach((task, i) => pushItem(`## Task ${i + 1}`, task));
|
|
203
|
+
} else {
|
|
204
|
+
(stories || []).forEach((story, i) => {
|
|
205
|
+
blocks.push(`## Story ${i + 1} — ${flatten(story?.title) || '(sem título)'}`);
|
|
206
|
+
const deps = normalizeDependsOn(story?.dependsOn, i);
|
|
207
|
+
// As DUAS linhas de campo saem SEMPRE, seguidas de linha em branco: é essa
|
|
208
|
+
// linha em branco que impede um corpo começando com "**Depende de:** …" de
|
|
209
|
+
// ser confundido com o campo.
|
|
210
|
+
blocks.push([
|
|
211
|
+
`**User story:** ${flatten(story?.userStory) || '—'}`,
|
|
212
|
+
`**Depende de:** ${deps.length ? deps.map(d => `Story ${d + 1}`).join(', ') : '—'}`,
|
|
213
|
+
].join('\n'));
|
|
214
|
+
const body = renderBody(story?.body);
|
|
215
|
+
if (body) blocks.push(body);
|
|
216
|
+
(story?.tasks || []).forEach((task, j) => pushItem(`### Task ${i + 1}.${j + 1}`, task));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return `${blocks.join('\n\n')}\n`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseMarker(attrs) {
|
|
224
|
+
const text = String(attrs || '');
|
|
225
|
+
const version = /(?:^|\s)v(\d+)(?:\s|$)/.exec(text);
|
|
226
|
+
const issue = /\bissue=(\d+)\b/.exec(text);
|
|
227
|
+
const kind = /\bkind=(stories|tasks)\b/i.exec(text);
|
|
228
|
+
return {
|
|
229
|
+
version: version ? parseInt(version[1], 10) : DOC_VERSION,
|
|
230
|
+
issueNumber: issue ? parseInt(issue[1], 10) : null,
|
|
231
|
+
kind: kind ? kind[1].toLowerCase() : null,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// "# Decomposição — X" → "X"; "# Decomposição" → "". Sem esta segunda parte, o
|
|
236
|
+
// próprio prefixo viraria o título na segunda passada do round-trip.
|
|
237
|
+
function stripDocTitle(raw) {
|
|
238
|
+
const text = flatten(raw);
|
|
239
|
+
const m = /^Decomposição(?:[ \t]*[—–:-][ \t]*(.*))?$/i.exec(text);
|
|
240
|
+
return m ? (m[1] || '').trim() : text;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function requireTitle(value, anchor) {
|
|
244
|
+
const title = flatten(value);
|
|
245
|
+
if (!title) {
|
|
246
|
+
const hashes = /^Story \d+$|^Task \d+$/.test(anchor) ? '##' : '###';
|
|
247
|
+
throw invalid(`${anchor} está sem título — use "${hashes} ${anchor} — título curto"`);
|
|
248
|
+
}
|
|
249
|
+
return title;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// "Story 1, Story 3" → [0, 2]. Linha ausente ou "—" → []. Referência a si mesma
|
|
253
|
+
// ou a uma story POSTERIOR é erro: num arquivo editado à mão, filtrar em
|
|
254
|
+
// silêncio (como se fazia com o JSON do modelo) esconderia o engano do humano.
|
|
255
|
+
function parseDependsValue(raw, index, anchor) {
|
|
256
|
+
if (raw === null || raw === undefined) return [];
|
|
257
|
+
const value = raw.trim();
|
|
258
|
+
if (!value || EMPTY_VALUE_RE.test(value)) return [];
|
|
259
|
+
const matches = [...value.matchAll(/story[ \t]*(\d+)/gi)];
|
|
260
|
+
if (matches.length === 0) {
|
|
261
|
+
throw invalid(
|
|
262
|
+
`não entendi "**Depende de:** ${value}" em ${anchor} — use "Story 1, Story 2" ou "—"`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const m of matches) {
|
|
267
|
+
const n = parseInt(m[1], 10);
|
|
268
|
+
if (n < 1 || n - 1 >= index) {
|
|
269
|
+
throw invalid(
|
|
270
|
+
`${anchor} depende de "Story ${n}", que não é uma Story anterior a ela — ` +
|
|
271
|
+
'dependências só apontam para trás'
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (!out.includes(n - 1)) out.push(n - 1);
|
|
275
|
+
}
|
|
276
|
+
return out.sort((a, b) => a - b);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Os campos valem SÓ no primeiro bloco não-vazio abaixo do título. É o que deixa
|
|
280
|
+
// o corpo conter uma linha "**Depende de:** #12" sem que ela vire campo.
|
|
281
|
+
function splitStorySection(lines, anchor) {
|
|
282
|
+
let i = 0;
|
|
283
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
284
|
+
let userStory = null;
|
|
285
|
+
let depends = null;
|
|
286
|
+
while (i < lines.length && lines[i].trim()) {
|
|
287
|
+
const us = USER_STORY_RE.exec(lines[i]);
|
|
288
|
+
const dep = us ? null : DEPENDS_RE.exec(lines[i]);
|
|
289
|
+
if (!us && !dep) break;
|
|
290
|
+
if (us) {
|
|
291
|
+
if (userStory !== null) throw invalid(`${anchor} tem duas linhas "**User story:**"`);
|
|
292
|
+
userStory = us[1].trim();
|
|
293
|
+
} else {
|
|
294
|
+
if (depends !== null) throw invalid(`${anchor} tem duas linhas "**Depende de:**"`);
|
|
295
|
+
depends = dep[1].trim();
|
|
296
|
+
}
|
|
297
|
+
i++;
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
userStory: !userStory || EMPTY_VALUE_RE.test(userStory) ? '' : userStory,
|
|
301
|
+
depends,
|
|
302
|
+
body: readBody(lines.slice(i)),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Interpreta o decomposition.md (função PURA — testável).
|
|
308
|
+
*
|
|
309
|
+
* Devolve exatamente a shape que o loop de criação de issues consome
|
|
310
|
+
* (`stories[].{title,userStory,body,dependsOn,tasks[]}`, com dependsOn 0-based),
|
|
311
|
+
* mais os metadados do marcador e a âncora estável de cada item ("Story 3",
|
|
312
|
+
* "Task 3.2") — é a âncora que a crítica cita no comentário da issue.
|
|
313
|
+
*
|
|
314
|
+
* A numeração ESCRITA no arquivo é ignorada: a posição manda. Assim um humano que
|
|
315
|
+
* insere uma Story no meio sem renumerar não quebra nada (o render devolve a
|
|
316
|
+
* numeração corrigida no ciclo seguinte).
|
|
317
|
+
*
|
|
318
|
+
* Lança Error em pt-BR sempre citando a âncora do item problemático — a mensagem
|
|
319
|
+
* vai para o log da Action e para o comentário da issue.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} markdown conteúdo do arquivo
|
|
322
|
+
* @returns {{version:number, issueNumber:number|null, kind:'stories'|'tasks',
|
|
323
|
+
* title:string, preamble:string, stories:object[], tasks:object[]}}
|
|
324
|
+
*/
|
|
325
|
+
export function parseDecompositionDoc(markdown) {
|
|
326
|
+
const text = String(markdown ?? '').replace(/\r\n?/g, '\n');
|
|
327
|
+
if (!text.trim()) throw invalid('o arquivo está vazio');
|
|
328
|
+
|
|
329
|
+
const scan = fenceScanner();
|
|
330
|
+
const sections = [];
|
|
331
|
+
const preamble = [];
|
|
332
|
+
let current = null;
|
|
333
|
+
let marker = null;
|
|
334
|
+
let title = '';
|
|
335
|
+
let sawH1 = false;
|
|
336
|
+
|
|
337
|
+
for (const line of text.split('\n')) {
|
|
338
|
+
// inFence PRECISA ser chamado uma vez por linha, na ordem — é ele que
|
|
339
|
+
// mantém o estado do bloco de código.
|
|
340
|
+
if (!scan.inFence(line)) {
|
|
341
|
+
const story = STORY_RE.exec(line);
|
|
342
|
+
const rfcTask = story ? null : RFC_TASK_RE.exec(line);
|
|
343
|
+
const task = story || rfcTask ? null : TASK_RE.exec(line);
|
|
344
|
+
const m = story || rfcTask || task;
|
|
345
|
+
if (m) {
|
|
346
|
+
current = {
|
|
347
|
+
type: story ? 'story' : rfcTask ? 'rfc-task' : 'task',
|
|
348
|
+
num: m[1],
|
|
349
|
+
title: m[2],
|
|
350
|
+
lines: [],
|
|
351
|
+
};
|
|
352
|
+
sections.push(current);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (!current) {
|
|
356
|
+
const mk = MARKER_RE.exec(line.trim());
|
|
357
|
+
if (mk && !marker) {
|
|
358
|
+
marker = parseMarker(mk[1]);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const h1 = H1_RE.exec(line);
|
|
362
|
+
if (h1 && !sawH1) {
|
|
363
|
+
title = stripDocTitle(h1[1]);
|
|
364
|
+
sawH1 = true;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
(current ? current.lines : preamble).push(line);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (scan.isOpen()) {
|
|
373
|
+
throw invalid(
|
|
374
|
+
'há um bloco de código (```) aberto e nunca fechado — feche-o para que os ' +
|
|
375
|
+
'títulos seguintes voltem a ser reconhecidos'
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (!marker) {
|
|
379
|
+
throw invalid(
|
|
380
|
+
`não encontrei o marcador \`<!-- spec-wave:decomposition v${DOC_VERSION} … -->\` — ` +
|
|
381
|
+
'o arquivo não parece um decomposition.md do spec-wave'
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
if (marker.version > DOC_VERSION) {
|
|
385
|
+
throw invalid(
|
|
386
|
+
`está no formato v${marker.version} e esta CLI entende até v${DOC_VERSION} — ` +
|
|
387
|
+
'atualize o @spec-wave/cli'
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const kind = marker.kind || (sections.some(s => s.type === 'story') ? 'stories' : 'tasks');
|
|
392
|
+
const doc = {
|
|
393
|
+
version: marker.version,
|
|
394
|
+
issueNumber: marker.issueNumber,
|
|
395
|
+
kind,
|
|
396
|
+
title,
|
|
397
|
+
preamble: readBody(preamble),
|
|
398
|
+
stories: [],
|
|
399
|
+
tasks: [],
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
if (kind === 'tasks') {
|
|
403
|
+
const alien = sections.find(s => s.type !== 'rfc-task');
|
|
404
|
+
if (alien) {
|
|
405
|
+
throw invalid(
|
|
406
|
+
'em um documento kind=tasks só cabem títulos "## Task N — …"; encontrei ' +
|
|
407
|
+
`"${alien.type === 'story' ? `## Story ${alien.num}` : `### Task ${alien.num}`}"`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
doc.tasks = sections.map((s, i) => {
|
|
411
|
+
const anchor = `Task ${i + 1}`;
|
|
412
|
+
return { anchor, title: requireTitle(s.title, anchor), body: readBody(s.lines) };
|
|
413
|
+
});
|
|
414
|
+
if (doc.tasks.length === 0) throw invalid('não encontrei nenhuma Task ("## Task 1 — …")');
|
|
415
|
+
return doc;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
for (const section of sections) {
|
|
419
|
+
if (section.type === 'story') {
|
|
420
|
+
const anchor = `Story ${doc.stories.length + 1}`;
|
|
421
|
+
const { userStory, depends, body } = splitStorySection(section.lines, anchor);
|
|
422
|
+
doc.stories.push({
|
|
423
|
+
anchor,
|
|
424
|
+
title: requireTitle(section.title, anchor),
|
|
425
|
+
userStory,
|
|
426
|
+
body,
|
|
427
|
+
dependsOn: parseDependsValue(depends, doc.stories.length, anchor),
|
|
428
|
+
tasks: [],
|
|
429
|
+
});
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (section.type === 'rfc-task') {
|
|
433
|
+
throw invalid(
|
|
434
|
+
`"## Task ${section.num}" aparece em um documento de Stories — ` +
|
|
435
|
+
'Tasks de Story usam "### Task N.M — …"'
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (doc.stories.length === 0) {
|
|
439
|
+
throw invalid(
|
|
440
|
+
`a Task "${flatten(section.title) || section.num}" aparece antes de qualquer ` +
|
|
441
|
+
'"## Story N — …"'
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const story = doc.stories[doc.stories.length - 1];
|
|
445
|
+
const anchor = `Task ${doc.stories.length}.${story.tasks.length + 1}`;
|
|
446
|
+
story.tasks.push({ anchor, title: requireTitle(section.title, anchor), body: readBody(section.lines) });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (doc.stories.length === 0) throw invalid('não encontrei nenhuma Story ("## Story 1 — …")');
|
|
450
|
+
return doc;
|
|
451
|
+
}
|
|
@@ -67,9 +67,22 @@ export function planBoardMoves(phase, {
|
|
|
67
67
|
* Executa os movimentos no Projects v2. Best-effort: qualquer falha vira
|
|
68
68
|
* warn e a implementação continua. Usa PROJECT_TOKEN quando definido (PAT
|
|
69
69
|
* com scope de Project em org — mesmo padrão do code-review/qa).
|
|
70
|
+
*
|
|
71
|
+
* Com `dryRun`, apenas ANUNCIA os movimentos: nada é escrito no GitHub. Sem
|
|
72
|
+
* isso, `implement --dry-run` movia cards de verdade (foi o caso da #239, que
|
|
73
|
+
* saiu de Todo para In Progress numa execução em que nada foi executado) —
|
|
74
|
+
* um dry-run não pode ter efeito colateral remoto.
|
|
70
75
|
*/
|
|
71
|
-
export async function applyBoardMoves({
|
|
76
|
+
export async function applyBoardMoves({
|
|
77
|
+
token, moves, cwd = process.cwd(), log = p.log, dryRun = false,
|
|
78
|
+
}) {
|
|
72
79
|
if (!moves || moves.length === 0) return;
|
|
80
|
+
if (dryRun) {
|
|
81
|
+
for (const m of moves) {
|
|
82
|
+
log.info(`board (dry-run): ${m.label} → ${m.stage} (${m.status}) — não aplicado`);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
73
86
|
try {
|
|
74
87
|
const { project, error } = loadProjectConfig({ cwd });
|
|
75
88
|
if (error) {
|