@spec-wave/cli 0.12.0 → 0.14.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 -27
- package/bin/spec-wave.mjs +14 -4
- package/package.json +1 -1
- package/src/api/github-graphql.mjs +0 -4
- package/src/api/github-rest.mjs +0 -13
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +410 -251
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +111 -51
- package/src/commands/generate-spec.mjs +20 -22
- package/src/commands/implement.mjs +46 -24
- 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 +43 -19
- package/src/commands/validate.mjs +47 -35
- package/src/config.mjs +40 -6
- 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/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 +137 -61
- 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
- package/src/lib/feature-docs.mjs +0 -89
- package/src/lib/force.mjs +0 -34
|
@@ -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) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Descoberta da raiz do projeto spec-wave (o diretório com .spec-wave.json).
|
|
2
|
+
//
|
|
3
|
+
// Antes, cada comando fazia `path.join(process.cwd(), CONFIG_FILE)` — mais de
|
|
4
|
+
// dez cópias da mesma linha, e nenhuma subia na árvore. O efeito prático era que
|
|
5
|
+
// qualquer comando rodado de dentro de `apps/web` falhava com "repositório não
|
|
6
|
+
// inicializado", mesmo com o config uma pasta acima. Aqui o config é procurado
|
|
7
|
+
// subindo até a raiz do filesystem, como o git faz com o .git.
|
|
8
|
+
//
|
|
9
|
+
// Achar o config é metade do problema: os documentos gerados vivem em
|
|
10
|
+
// `docs/features/<slug>/`, relativo à RAIZ, não ao cwd. Por isso `loadConfig`
|
|
11
|
+
// devolve `root` e existe `resolveFromRoot` — sem eles, rodar de um
|
|
12
|
+
// subdiretório acharia o config e erraria todos os caminhos de documento.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Procura o .spec-wave.json em `cwd` e nos diretórios acima (função quase pura:
|
|
20
|
+
* só toca o filesystem via existsSync).
|
|
21
|
+
*
|
|
22
|
+
* @param {string} [cwd=process.cwd()] diretório onde começar a busca
|
|
23
|
+
* @returns {string|null} caminho absoluto do arquivo, ou null se não houver
|
|
24
|
+
*/
|
|
25
|
+
export function findConfigPath(cwd = process.cwd()) {
|
|
26
|
+
let dir = path.resolve(cwd);
|
|
27
|
+
// path.dirname('/') === '/': a igualdade é o que encerra o laço na raiz.
|
|
28
|
+
for (;;) {
|
|
29
|
+
const candidate = path.join(dir, CONFIG_FILE);
|
|
30
|
+
if (existsSync(candidate)) return candidate;
|
|
31
|
+
const parent = path.dirname(dir);
|
|
32
|
+
if (parent === dir) return null;
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Localiza e lê o .spec-wave.json.
|
|
39
|
+
*
|
|
40
|
+
* Nunca lança: devolve `error` legível para o chamador compor a mensagem que já
|
|
41
|
+
* exibia ("… — board não atualizado.", "Rode spec-wave init primeiro.").
|
|
42
|
+
*
|
|
43
|
+
* @param {string} [cwd=process.cwd()]
|
|
44
|
+
* @returns {{ config: object|null, root: string|null, configPath: string|null, error: string|null }}
|
|
45
|
+
* root = diretório que contém o config (âncora dos caminhos de documento)
|
|
46
|
+
*/
|
|
47
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
48
|
+
const configPath = findConfigPath(cwd);
|
|
49
|
+
if (!configPath) {
|
|
50
|
+
return { config: null, root: null, configPath: null, error: `${CONFIG_FILE} não encontrado` };
|
|
51
|
+
}
|
|
52
|
+
const root = path.dirname(configPath);
|
|
53
|
+
try {
|
|
54
|
+
return { config: JSON.parse(readFileSync(configPath, 'utf-8')), root, configPath, error: null };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { config: null, root, configPath, error: `${CONFIG_FILE} corrompido (${err.message})` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve um caminho relativo do projeto (ex.: `docs/features/<slug>`) contra a
|
|
62
|
+
* raiz descoberta, com fallback para o cwd quando não há config — assim os
|
|
63
|
+
* comandos que rodam sem `.spec-wave.json` (ou em teste) seguem funcionando.
|
|
64
|
+
*
|
|
65
|
+
* @param {string|null} root raiz devolvida por loadConfig
|
|
66
|
+
* @param {...string} parts segmentos relativos
|
|
67
|
+
* @returns {string} caminho absoluto
|
|
68
|
+
*/
|
|
69
|
+
export function resolveFromRoot(root, ...parts) {
|
|
70
|
+
return path.resolve(root || process.cwd(), ...parts);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos rodados em
|
|
75
|
+
* Action) com fallback no .spec-wave.json — comandos locais rodam sem essa env.
|
|
76
|
+
*
|
|
77
|
+
* Era o mesmo bloco copiado em story/task/order/qa/code-review/validate.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} [cwd=process.cwd()]
|
|
80
|
+
* @returns {{ owner: string|undefined, repo: string|undefined,
|
|
81
|
+
* root: string|null, config: object|null, error: string|null }}
|
|
82
|
+
*/
|
|
83
|
+
export function resolveRepoContext(cwd = process.cwd()) {
|
|
84
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
85
|
+
const { config, root, error } = loadConfig(cwd);
|
|
86
|
+
return {
|
|
87
|
+
owner: envOwner || config?.owner,
|
|
88
|
+
repo: envRepo || config?.repo,
|
|
89
|
+
root,
|
|
90
|
+
config,
|
|
91
|
+
error,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Leitura dos templates empacotados, com resolução de placeholders.
|
|
2
|
+
//
|
|
3
|
+
// Os workflows chamavam `npx @spec-wave/cli@latest`, o que significava que uma
|
|
4
|
+
// release da CLI mudava o comportamento de pipelines já em andamento — foi o que
|
|
5
|
+
// aconteceu quando a 0.13.0 removeu o `--force` sem que nada no repositório
|
|
6
|
+
// mudasse. Agora o template traz `{{CLI_VERSION}}` e o `init`/`update` gravam a
|
|
7
|
+
// versão fixa, tornando o bump um diff explícito e revisável.
|
|
8
|
+
//
|
|
9
|
+
// A substituição PRECISA passar por aqui nos dois lados: o `init`/`setupFiles`
|
|
10
|
+
// que escreve, e o `update` que compara byte a byte com o remoto. Se só um lado
|
|
11
|
+
// resolvesse o placeholder, todo `update` veria os seis workflows como
|
|
12
|
+
// "desatualizados" para sempre.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
export const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
|
|
20
|
+
|
|
21
|
+
const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
|
|
22
|
+
export const CLI_VERSION = pkg.version;
|
|
23
|
+
|
|
24
|
+
const PLACEHOLDERS = { CLI_VERSION };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve os placeholders `{{NOME}}` de um template (função PURA).
|
|
28
|
+
*
|
|
29
|
+
* Placeholder desconhecido é deixado intacto em vez de virar string vazia: um
|
|
30
|
+
* `{{TYPO}}` visível no arquivo gerado é muito mais fácil de diagnosticar do que
|
|
31
|
+
* um trecho que simplesmente desapareceu.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} content conteúdo cru do template
|
|
34
|
+
* @param {object} [values] sobrescreve/estende os placeholders padrão
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function renderTemplate(content, values = {}) {
|
|
38
|
+
const table = { ...PLACEHOLDERS, ...values };
|
|
39
|
+
return String(content ?? '').replace(
|
|
40
|
+
/\{\{(\w+)\}\}/g,
|
|
41
|
+
(match, key) => (key in table ? String(table[key]) : match)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lê um template empacotado com os placeholders já resolvidos.
|
|
47
|
+
*
|
|
48
|
+
* @param {...string} parts caminho relativo dentro de src/templates
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function readTemplate(...parts) {
|
|
52
|
+
return renderTemplate(readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8'));
|
|
53
|
+
}
|
package/src/setup/files.mjs
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import path from 'node:path';
|
|
4
1
|
import { upsertFile, getFileContent, isRepoInitialized } from '../api/github-rest.mjs';
|
|
5
2
|
import { WORKFLOW_FILES } from '../config.mjs';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
function readTemplate(...parts) {
|
|
11
|
-
return readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8');
|
|
12
|
-
}
|
|
3
|
+
// readTemplate resolve {{CLI_VERSION}} — é o que fixa a versão da CLI nos
|
|
4
|
+
// workflows gravados no repo-alvo (ver src/lib/templates.mjs).
|
|
5
|
+
import { readTemplate } from '../lib/templates.mjs';
|
|
13
6
|
|
|
14
7
|
export async function setupFiles(token, owner, repo, spinner) {
|
|
15
8
|
spinner.message('Verificando repositório...');
|