@spec-wave/cli 0.29.0 → 0.32.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 (44) hide show
  1. package/package.json +5 -3
  2. package/protocol/qa-result.v1.json +62 -0
  3. package/protocol/qa-trail-report.v1.json +113 -0
  4. package/src/api/github-graphql.mjs +6 -1
  5. package/src/api/github-rest.mjs +21 -0
  6. package/src/cli.mjs +114 -9
  7. package/src/commands/decompose.mjs +29 -3
  8. package/src/commands/doctor.mjs +183 -3
  9. package/src/commands/generate-qa-plan.mjs +421 -0
  10. package/src/commands/implement.mjs +56 -44
  11. package/src/commands/merge.mjs +43 -14
  12. package/src/commands/order.mjs +350 -96
  13. package/src/commands/qa-lead.mjs +748 -0
  14. package/src/commands/qa-run.mjs +892 -0
  15. package/src/commands/run.mjs +5 -1
  16. package/src/config.mjs +32 -1
  17. package/src/lib/artifact-pr.mjs +2 -0
  18. package/src/lib/artifact-publish.mjs +5 -2
  19. package/src/lib/board.mjs +14 -0
  20. package/src/lib/critique.mjs +38 -9
  21. package/src/lib/decomposition-doc.mjs +5 -1
  22. package/src/lib/dependency-map.mjs +300 -0
  23. package/src/lib/doc-paths.mjs +9 -2
  24. package/src/lib/git-retry.mjs +82 -0
  25. package/src/lib/net-cache.mjs +142 -0
  26. package/src/lib/next-step.mjs +15 -3
  27. package/src/lib/qa-exec.mjs +335 -0
  28. package/src/lib/qa-lead-backend.mjs +213 -0
  29. package/src/lib/qa-lead.mjs +627 -0
  30. package/src/lib/qa-plan-doc.mjs +340 -0
  31. package/src/lib/qa-report.mjs +396 -0
  32. package/src/lib/skill-compose.mjs +234 -0
  33. package/src/lib/story-graph.mjs +256 -0
  34. package/src/plugin/.claude-plugin/plugin.json +1 -1
  35. package/src/plugin/skills/merge/SKILL.md +1 -0
  36. package/src/plugin/skills/order/SKILL.md +21 -5
  37. package/src/plugin/skills/qa/SKILL.md +107 -0
  38. package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
  39. package/src/plugin/skills/qa/model-prompt.md +68 -0
  40. package/src/plugin/skills/qa-executor/SKILL.md +76 -0
  41. package/src/plugin/skills/qa-lead/SKILL.md +89 -0
  42. package/src/templates/skill/SKILL.md +981 -279
  43. package/src/templates/skill/core.md +584 -0
  44. package/src/templates/workflows/generate-qa-plan.yml +64 -0
@@ -0,0 +1,396 @@
1
+ // Relatório de execução do QA — módulo PURO, sem I/O.
2
+ //
3
+ // O comentário de veredito é a FONTE DE ESTADO entre execuções: rodar com
4
+ // `--only` lê o último relatório da issue para compor o estado acumulado dos
5
+ // cenários que não rodaram desta vez. O marcador HTML da primeira linha é
6
+ // CONTRATO com a UI do SpecWave — mudar o formato exige bump de versão
7
+ // (`v1` → `v2`).
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { REQUIRED_BUG_SECTIONS, QA_BLOCKED_REASONS } from '../config.mjs';
11
+
12
+ export const QA_REPORT_VERSION = 1;
13
+
14
+ /** Vereditos aceitos por cenário. */
15
+ export const QA_VERDICTS = ['pass', 'fail', 'blocked'];
16
+
17
+ export const QA_REPORT_MARKER_RE =
18
+ /<!--\s*spec-wave:qa-report\s+v(?<version>\d+)\s+issue=(?<issue>\d+)\s+run=(?<run>\d+)\s+verdict=(?<verdict>pass|fail|blocked)\s+planSha=(?<planSha>[0-9a-f]+)\s+headSha=(?<headSha>[0-9a-f?]+)\s*-->/g;
19
+
20
+ // Origem de um Bug aberto por reprovação de QA — gravado no CORPO da issue do
21
+ // Bug. É a chave da idempotência: re-executar o mesmo cenário reprovado não
22
+ // abre um segundo Bug, comenta no que já existe.
23
+ export const QA_ORIGIN_MARKER_RE =
24
+ /<!--\s*spec-wave:qa-origin\s+issue=(?<issue>\d+)\s+cenario=(?<cenario>\d+)\s*-->/;
25
+
26
+ /** Monta o marcador do relatório (função PURA). */
27
+ export function qaReportMarker({ issue, run, verdict, planSha, headSha }) {
28
+ return `<!-- spec-wave:qa-report v${QA_REPORT_VERSION} issue=${issue} run=${run} ` +
29
+ `verdict=${verdict} planSha=${planSha} headSha=${headSha || '?'} -->`;
30
+ }
31
+
32
+ /** Monta o marcador de origem de um Bug de QA (função PURA). */
33
+ export function qaOriginMarker({ issue, cenario }) {
34
+ return `<!-- spec-wave:qa-origin issue=${issue} cenario=${cenario} -->`;
35
+ }
36
+
37
+ /**
38
+ * O corpo de um Bug aberto veio deste cenário? (função PURA)
39
+ *
40
+ * @param {string} body corpo da issue do Bug
41
+ * @param {{issue:number, cenario:number}} origem Story dona + número do cenário
42
+ * @returns {boolean}
43
+ */
44
+ export function matchesQaOrigin(body, { issue, cenario }) {
45
+ const m = QA_ORIGIN_MARKER_RE.exec(String(body || ''));
46
+ if (!m) return false;
47
+ return parseInt(m.groups.issue, 10) === issue && parseInt(m.groups.cenario, 10) === cenario;
48
+ }
49
+
50
+ /** SHA curto (7 hex) do conteúdo do plano — vai no marcador do relatório. */
51
+ export function shortSha(content) {
52
+ return createHash('sha1').update(String(content ?? ''), 'utf-8').digest('hex').slice(0, 7);
53
+ }
54
+
55
+ /**
56
+ * Veredito agregado da execução (função PURA).
57
+ *
58
+ * `fail` vence `blocked` de propósito: com um defeito confirmado, o desfecho é
59
+ * vermelho (abre Bug) mesmo que outro cenário tenha ficado bloqueado. `blocked`
60
+ * sem nenhum `fail` é INCONCLUSIVO — ambiente quebrado não é defeito de
61
+ * produto, então nada move e nenhum Bug nasce.
62
+ *
63
+ * @param {Array<{verdict:string}>} results
64
+ * @returns {'pass'|'fail'|'blocked'}
65
+ */
66
+ export function aggregateVerdict(results = []) {
67
+ if (results.some(r => r.verdict === 'fail')) return 'fail';
68
+ if (results.some(r => r.verdict === 'blocked')) return 'blocked';
69
+ return 'pass';
70
+ }
71
+
72
+ /**
73
+ * Valida o payload de resultados escrito pelo executor (função PURA).
74
+ *
75
+ * O executor (o agente acionado por `qa.command`) grava um JSON com o veredito
76
+ * de cada cenário. Fora do contrato → erro nomeando o campo: um resultado
77
+ * ilegível não pode virar aprovação nem reprovação (D-QAL5 — falha alta, nunca
78
+ * inferência).
79
+ *
80
+ * O contrato é o formato ADOTADO da implementação original (`cenario` /
81
+ * `evidencia` — ver `protocol/qa-result.v1.json`), estendido pela
82
+ * rfc/spec-qa-lead.md:
83
+ * • `blocked` exige `blockedReason` do enum fechado (D-QAL6) — o agregado por
84
+ * motivo do relatório de trilha depende dele;
85
+ * • `fail` exige `evidencia` não vazia — sem evidência não há bug.md
86
+ * determinístico;
87
+ * • `blockedReason: outro` exige `evidencia` não vazia (é o texto livre).
88
+ *
89
+ * @param {*} payload objeto lido do arquivo de resultados
90
+ * @param {number[]} expected números dos cenários que DEVIAM ter sido executados
91
+ * @returns {Array<{numero:number, verdict:'pass'|'fail'|'blocked', evidencia:string, blockedReason:string|null}>}
92
+ */
93
+ export function validateQaResults(payload, expected = []) {
94
+ if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.scenarios)) {
95
+ throw new Error('o arquivo de resultados deveria ser {"scenarios": [...]}.');
96
+ }
97
+ const out = new Map();
98
+ payload.scenarios.forEach((item, i) => {
99
+ const at = `scenarios[${i}]`;
100
+ const numero = Number(item?.cenario);
101
+ if (!Number.isInteger(numero) || numero <= 0) {
102
+ throw new Error(`${at}.cenario deveria ser o número posicional do cenário, veio ${JSON.stringify(item?.cenario)}.`);
103
+ }
104
+ const verdict = String(item?.verdict || '').toLowerCase();
105
+ if (!QA_VERDICTS.includes(verdict)) {
106
+ throw new Error(
107
+ `${at}.verdict = ${JSON.stringify(item?.verdict)} não é aceito ` +
108
+ `(aceitos, exatamente: ${QA_VERDICTS.join(' | ')}).`
109
+ );
110
+ }
111
+ const evidencia = String(item?.evidencia ?? '').trim();
112
+ const blockedReason = item?.blockedReason == null ? null : String(item.blockedReason).trim();
113
+ if (verdict === 'blocked') {
114
+ if (!blockedReason || !QA_BLOCKED_REASONS.includes(blockedReason)) {
115
+ throw new Error(
116
+ `${at}: verdict "blocked" exige blockedReason do enum ` +
117
+ `(${QA_BLOCKED_REASONS.join(' | ')}), veio ${JSON.stringify(item?.blockedReason)}.`
118
+ );
119
+ }
120
+ if (blockedReason === 'outro' && !evidencia) {
121
+ throw new Error(`${at}: blockedReason "outro" exige evidencia com o motivo em texto livre.`);
122
+ }
123
+ }
124
+ if (verdict === 'fail' && !evidencia) {
125
+ throw new Error(
126
+ `${at}: verdict "fail" sem evidencia — sem a evidência bruta não há bug.md ` +
127
+ 'determinístico, e reprovação sem registro não abre Bug.'
128
+ );
129
+ }
130
+ out.set(numero, {
131
+ numero,
132
+ verdict,
133
+ evidencia,
134
+ blockedReason: verdict === 'blocked' ? blockedReason : null,
135
+ });
136
+ });
137
+ const missing = expected.filter(n => !out.has(n));
138
+ if (missing.length > 0) {
139
+ throw new Error(
140
+ `faltou o veredito do(s) cenário(s) ${missing.join(', ')} — cenário que não pôde ` +
141
+ 'ser executado deve vir como "blocked", nunca ser omitido.'
142
+ );
143
+ }
144
+ const extra = [...out.keys()].filter(n => !expected.includes(n));
145
+ if (extra.length > 0) {
146
+ throw new Error(
147
+ `veredito para cenário(s) fora do alvo: ${extra.join(', ')} — o escopo desta corrida é ` +
148
+ `${expected.join(', ') || '(vazio)'}. Resultado sobrando indica plano trocado ou corrida velha.`
149
+ );
150
+ }
151
+ return expected.map(n => out.get(n));
152
+ }
153
+
154
+ /**
155
+ * Compõe o estado acumulado quando a execução foi parcial (`--only`) —
156
+ * função PURA.
157
+ *
158
+ * Cenários não executados nesta corrida herdam o veredito do ÚLTIMO relatório;
159
+ * sem relatório anterior, contam como pendentes (nunca como pass): a aprovação
160
+ * exige que todos os alvos tenham passado em ALGUMA corrida registrada.
161
+ *
162
+ * @param {object} params
163
+ * @param {Array<{numero:number, verdict:string, evidencia:string}>} params.executed
164
+ * @param {Map<number, {verdict:string}>} [params.previous] numero → resultado anterior
165
+ * @param {number[]} params.allNumbers todos os cenários do escopo
166
+ * @returns {{ combined: Array, pendingNumbers: number[] }}
167
+ */
168
+ export function combineWithPrevious({ executed = [], previous = new Map(), allNumbers = [] } = {}) {
169
+ const executedBy = new Map(executed.map(r => [r.numero, r]));
170
+ const combined = [];
171
+ const pendingNumbers = [];
172
+ for (const n of allNumbers) {
173
+ if (executedBy.has(n)) {
174
+ combined.push({ ...executedBy.get(n), carried: false });
175
+ continue;
176
+ }
177
+ const prev = previous.get(n);
178
+ if (prev) {
179
+ combined.push({
180
+ numero: n, verdict: prev.verdict, evidencia: prev.evidencia || '(corrida anterior)',
181
+ blockedReason: prev.blockedReason || null, carried: true,
182
+ });
183
+ } else {
184
+ pendingNumbers.push(n);
185
+ }
186
+ }
187
+ return { combined, pendingNumbers };
188
+ }
189
+
190
+ const RESULTS_JSON_RE = /```json spec-wave:qa-results\s*\n([\s\S]*?)\n```/;
191
+
192
+ /**
193
+ * Último relatório de QA de uma issue (função PURA).
194
+ *
195
+ * Lê os comentários em ordem cronológica; o mais recente vence. O estado por
196
+ * cenário sai do bloco JSON cercado — reparsear a tabela markdown seria frágil.
197
+ *
198
+ * @param {Array<{body?:string}>} comments comentários da issue, em ordem
199
+ * @returns {{ run: number, verdict: string|null, results: Map<number,object> }}
200
+ */
201
+ export function parseLastQaReport(comments = []) {
202
+ let run = 0;
203
+ let verdict = null;
204
+ let results = new Map();
205
+ let bugs = [];
206
+ for (const comment of comments) {
207
+ const body = String(comment?.body || '');
208
+ QA_REPORT_MARKER_RE.lastIndex = 0;
209
+ const m = QA_REPORT_MARKER_RE.exec(body);
210
+ if (!m) continue;
211
+ run = Math.max(run, parseInt(m.groups.run, 10));
212
+ verdict = m.groups.verdict;
213
+ const bloco = body.match(RESULTS_JSON_RE);
214
+ if (!bloco) continue;
215
+ try {
216
+ const payload = JSON.parse(bloco[1]);
217
+ if (Array.isArray(payload?.scenarios)) {
218
+ results = new Map(payload.scenarios
219
+ .filter(s => Number.isInteger(s?.cenario))
220
+ .map(s => [s.cenario, {
221
+ verdict: s.verdict,
222
+ evidencia: s.evidencia || '',
223
+ blockedReason: s.blockedReason || null,
224
+ }]));
225
+ }
226
+ // Bugs abertos pela corrida — o `qa-lead` compõe o `bugsOpened` do
227
+ // relatório de trilha a partir daqui, nunca parseando o markdown.
228
+ bugs = Array.isArray(payload?.bugs)
229
+ ? payload.bugs.filter(b => Number.isInteger(b?.number))
230
+ : [];
231
+ } catch { /* bloco ilegível não vira estado */ }
232
+ }
233
+ return { run, verdict, results, bugs };
234
+ }
235
+
236
+ const VERDICT_ICON = { pass: '✅ pass', fail: '❌ fail', blocked: '⚪ blocked' };
237
+
238
+ // Evidência entra numa célula de tabela markdown: sem quebra de linha, sem pipe
239
+ // e sem fechar o marcador HTML antes da hora.
240
+ function sanitizeCell(text, max = 200) {
241
+ const clean = String(text ?? '')
242
+ .replace(/-->/g, '--‑>')
243
+ .replace(/\|/g, '\\|')
244
+ .replace(/\s+/g, ' ')
245
+ .trim();
246
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
247
+ }
248
+
249
+ /**
250
+ * Monta o comentário de relatório de QA (função PURA).
251
+ *
252
+ * Primeira linha = marcador (contrato com a UI). O bloco JSON no fim é o que a
253
+ * próxima execução com `--only` lê para compor o estado acumulado.
254
+ *
255
+ * @param {object} params
256
+ * @param {number} params.issue issue onde o relatório será postado
257
+ * @param {string} params.scope ex.: "Story #412" ou "Feature #360"
258
+ * @param {number} params.run número desta execução (1ª, 2ª…)
259
+ * @param {'pass'|'fail'|'blocked'} params.verdict veredito agregado
260
+ * @param {string} params.planSha sha curto do qa-plan.md executado
261
+ * @param {string} [params.headSha] sha curto do HEAD do checkout
262
+ * @param {Array<{numero, verdict, evidencia, carried?}>} params.results
263
+ * @param {Array<{number:number, cenario:number, existing?:boolean}>} [params.bugs]
264
+ * @param {number[]} [params.pendingNumbers] cenários sem veredito em corrida parcial
265
+ * @param {string} [params.trailer] linha final livre (o que acontece a seguir)
266
+ * @returns {string} markdown
267
+ */
268
+ export function renderQaReport({
269
+ issue, scope, run, verdict, planSha, headSha, results = [], bugs = [],
270
+ pendingNumbers = [], trailer = '',
271
+ } = {}) {
272
+ const parts = [
273
+ qaReportMarker({ issue, run, verdict, planSha, headSha }),
274
+ '## 🧪 Relatório de QA (spec-wave)',
275
+ `**Escopo:** ${scope} · **Cenários:** ${results.length} · **Execução:** ${run}ª`,
276
+ ];
277
+
278
+ const linhas = ['| Cenário | Veredito | Evidência |', '|---|---|---|'];
279
+ for (const r of results) {
280
+ const carried = r.carried ? ' _(corrida anterior)_' : '';
281
+ const reason = r.verdict === 'blocked' && r.blockedReason ? ` (${r.blockedReason})` : '';
282
+ linhas.push(`| ${r.numero} | ${VERDICT_ICON[r.verdict] || r.verdict}${reason} | ${sanitizeCell(r.evidencia)}${carried} |`);
283
+ }
284
+ parts.push(linhas.join('\n'));
285
+
286
+ if (pendingNumbers.length > 0) {
287
+ parts.push(
288
+ `⚠️ Cenário(s) ainda **sem veredito** em nenhuma corrida: ${pendingNumbers.join(', ')} — ` +
289
+ 'a aprovação só sai quando todos tiverem passado.'
290
+ );
291
+ }
292
+ if (bugs.length > 0) {
293
+ parts.push(
294
+ '**Bugs abertos:** ' +
295
+ bugs.map(b => `#${b.number} (cenário ${b.cenario}${b.existing ? ', já existia' : ''})`).join(', ')
296
+ );
297
+ }
298
+ if (trailer) parts.push(trailer);
299
+
300
+ parts.push([
301
+ '```json spec-wave:qa-results',
302
+ JSON.stringify({
303
+ issue,
304
+ run,
305
+ verdict,
306
+ scenarios: results.map(r => ({
307
+ cenario: r.numero,
308
+ verdict: r.verdict,
309
+ evidencia: sanitizeCell(r.evidencia, 400),
310
+ ...(r.verdict === 'blocked' && r.blockedReason ? { blockedReason: r.blockedReason } : {}),
311
+ })),
312
+ // Aditivo ao v1: o `qa-lead` lê daqui os Bugs da corrida.
313
+ bugs: bugs.map(b => ({ number: b.number, cenario: b.cenario, existing: !!b.existing })),
314
+ }, null, 2),
315
+ '```',
316
+ ].join('\n'));
317
+
318
+ return parts.join('\n\n');
319
+ }
320
+
321
+ /**
322
+ * bug.md DETERMINÍSTICO de um cenário reprovado (função PURA).
323
+ *
324
+ * Exceção documentada à regra "nunca escreva o bug.md à mão" (spec §2.1):
325
+ * quando o Bug nasce de um cenário de QA reprovado, a reprodução, o
326
+ * esperado/obtido e o teste de regressão JÁ EXISTEM e são determinísticos — são
327
+ * o próprio cenário e a saída real da execução. Regenerar por IA só
328
+ * introduziria alucinação sobre uma execução observada.
329
+ *
330
+ * Emite EXATAMENTE as seis seções de REQUIRED_BUG_SECTIONS (o validate compara
331
+ * byte a byte com `# <seção>`).
332
+ *
333
+ * @param {object} params
334
+ * @param {string} params.title título do Bug (sem o prefixo [BUG])
335
+ * @param {{anchor, numero, story, criterio, precondicoes, passos, esperado}} params.scenario
336
+ * @param {string} params.evidence evidência bruta da reprovação
337
+ * @param {string} params.severity P0–P3
338
+ * @param {string} [params.headSha] commit do checkout onde a reprova aconteceu
339
+ * @param {number|null} [params.featureNumber]
340
+ * @returns {string} markdown
341
+ */
342
+ export function renderQaBugDoc({
343
+ title, scenario, evidence, severity, headSha = null, featureNumber = null,
344
+ } = {}) {
345
+ const [
346
+ reproducao, esperadoObtido, impacto, causaRaiz, escopo, regressao,
347
+ ] = REQUIRED_BUG_SECTIONS;
348
+ const passos = scenario.passos
349
+ ? scenario.passos
350
+ : '_(o cenário não declara passos — ver o corpo do cenário no qa-plan.md)_';
351
+ const pre = scenario.precondicoes ? `**Pré-condições:** ${scenario.precondicoes}\n\n` : '';
352
+ return [
353
+ `# [BUG] ${title}`,
354
+ '',
355
+ `> Aberto automaticamente pela reprovação do **${scenario.anchor}** do plano de QA` +
356
+ `${featureNumber ? ` da Feature #${featureNumber}` : ''} (Story #${scenario.story})` +
357
+ `${headSha ? `, no commit \`${headSha}\`` : ''}. Conteúdo determinístico: cenário + saída real da execução.`,
358
+ '',
359
+ `## ${reproducao}`,
360
+ '',
361
+ pre + passos,
362
+ '',
363
+ `## ${esperadoObtido}`,
364
+ '',
365
+ `**Critério:** ${scenario.criterio || '—'}`,
366
+ '',
367
+ `**Esperado:** ${scenario.esperado || '—'}`,
368
+ '',
369
+ `**Obtido:** ${evidence || '(sem evidência registrada)'}`,
370
+ '',
371
+ `## ${impacto}`,
372
+ '',
373
+ `Severidade **${severity}**. O critério de aceite acima está descumprido na Story #${scenario.story} — ` +
374
+ 'a Story não avança de 🧪 QA enquanto este Bug estiver aberto.',
375
+ '',
376
+ `## ${causaRaiz}`,
377
+ '',
378
+ '_A investigar durante a correção — este documento registra uma execução observada, ' +
379
+ 'não uma hipótese de causa._',
380
+ '',
381
+ `## ${escopo}`,
382
+ '',
383
+ '_A definir na investigação. O fix precisa fazer o cenário abaixo passar sem alterar o critério de aceite._',
384
+ '',
385
+ `## ${regressao}`,
386
+ '',
387
+ `Re-executar o cenário reprovado após o fix:`,
388
+ '',
389
+ '```bash',
390
+ `npx @spec-wave/cli@latest qa ${scenario.story} --only ${scenario.numero}`,
391
+ '```',
392
+ '',
393
+ `O veredito precisa ser **pass** com o mesmo esperado: ${scenario.esperado || '—'}`,
394
+ '',
395
+ ].join('\n');
396
+ }
@@ -0,0 +1,234 @@
1
+ // Composição da skill MONOLÍTICA a partir das skills do plugin.
2
+ //
3
+ // A monolítica (`src/templates/skill/SKILL.md`) e as skills por comando
4
+ // (`src/plugin/skills/<cmd>/SKILL.md`) diziam a mesma coisa em dois lugares, e
5
+ // toda mudança era feita duas vezes — o PR do QA editou as duas, e nada
6
+ // impedia as cópias de divergirem. Agora a monolítica é GERADA: o preâmbulo
7
+ // fixo mora em `core.md` (visão de conjunto: fluxo, labels, Regra fundamental,
8
+ // referência da CLI) e as seções de sub-comando saem das skills do plugin, que
9
+ // viram a única fonte de verdade por comando.
10
+ //
11
+ // Mesmo padrão do docs:gen: o arquivo é gerado, COMMITADO, e um teste de
12
+ // paridade (test/skill-compose.test.mjs) regenera em memória e compara com o
13
+ // disco — a divergência vira falha de teste, não descoberta em produção.
14
+ //
15
+ // Dois níveis, e a escolha é deliberada: compor TODAS as 26 skills verbatim
16
+ // dobraria o tamanho do arquivo (que vai inteiro para AGENTS.md e afins).
17
+ // Os SUB-COMANDOS que o roteador despacha entram com o corpo completo; os
18
+ // fluxos de orquestração e utilitários de board entram como lista com a
19
+ // descrição (quem os usa direto tem a skill dedicada do plugin ou o --help).
20
+
21
+ import { readFileSync } from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ import { TEMPLATES_DIR } from './templates.mjs';
25
+ import { listPluginSkills } from './plugin-skills.mjs';
26
+ import { parseSkill } from './skill-file.mjs';
27
+ import { fenceScanner } from './decomposition-doc.mjs';
28
+
29
+ /** Preâmbulo fixo, editado à mão — a única metade autoral da monolítica. */
30
+ export const SKILL_CORE_FILE = path.join(TEMPLATES_DIR, 'skill', 'core.md');
31
+
32
+ /** Saída gerada — o que o install-skill instala em todos os agentes. */
33
+ export const SKILL_OUTPUT_FILE = path.join(TEMPLATES_DIR, 'skill', 'SKILL.md');
34
+
35
+ /** Placeholder do core.md substituído pelas seções compostas. */
36
+ export const SUBCOMMANDS_PLACEHOLDER = '{{SUBCOMANDOS}}';
37
+
38
+ // Aviso de arquivo gerado, logo após o frontmatter. O install-skill insere o
39
+ // banner de versão no mesmo ponto na INSTALAÇÃO — os dois coexistem.
40
+ export const GENERATED_MARKER =
41
+ '<!-- GERADO por scripts/generate-skill.mjs a partir de core.md + src/plugin/skills/*/SKILL.md.\n' +
42
+ ' NÃO edite este arquivo à mão: edite o core.md (visão de conjunto) ou a skill do\n' +
43
+ ' comando no plugin, e rode `npm run skill:gen`. -->';
44
+
45
+ /**
46
+ * Sub-comandos com corpo COMPLETO, na ordem do fluxo. São os que o roteador
47
+ * (`/spec-wave <cmd>`) despacha — o leitor de arquivo único precisa do passo a
48
+ * passo deles sem ter as skills granulares por perto.
49
+ */
50
+ export const SKILL_FULL_ORDER = [
51
+ 'info', 'setup', 'update', 'doctor',
52
+ 'issue',
53
+ 'spec', 'plan', 'ready',
54
+ 'decompose', 'implement', 'qa',
55
+ 'bug', 'triage',
56
+ 'rfc', 'fix-pr', 'uninstall',
57
+ ];
58
+
59
+ /**
60
+ * Skills que entram como LISTA (nome + descrição + ponteiro): fluxos de
61
+ * orquestração (grandes demais para um arquivo único) e utilitários de board
62
+ * que a Referência da CLI do core já documenta flag a flag.
63
+ */
64
+ export const SKILL_SUMMARY_ORDER = [
65
+ 'workflow', 'preparar-feature', 'preparar-specs',
66
+ 'audit', 'order', 'merge', 'run',
67
+ 'qa-lead', 'qa-executor',
68
+ 'task', 'story', 'move',
69
+ ];
70
+
71
+ /**
72
+ * Reescritas para o contexto de ARQUIVO ÚNICO (função PURA via applyRewrites).
73
+ *
74
+ * Uma skill do plugin pode apontar para um arquivo de apoio "ao lado" dela
75
+ * (ex.: `reference/tech-context.md` da skill plan) — ao lado da monolítica
76
+ * esse arquivo não existe, mas o conteúdo correspondente existe como seção do
77
+ * core. Substituição por string EXATA, de propósito: se a frase de origem
78
+ * mudar, a reescrita vira no-op (o ponteiro fica só impreciso) em vez de
79
+ * corromper texto parecido.
80
+ */
81
+ export const SINGLE_FILE_REWRITES = [
82
+ [
83
+ 'o passo a passo está em `reference/tech-context.md`, ao lado deste arquivo',
84
+ 'o passo a passo está na seção **Tech Context** deste documento',
85
+ ],
86
+ [
87
+ 'o passo a passo está na skill **plan** (`reference/tech-context.md`)',
88
+ 'o passo a passo está na seção **Tech Context** deste documento',
89
+ ],
90
+ ];
91
+
92
+ /** Aplica as reescritas de arquivo único (função PURA). */
93
+ export function applyRewrites(body, rewrites = SINGLE_FILE_REWRITES) {
94
+ let out = String(body ?? '');
95
+ for (const [from, to] of rewrites) out = out.split(from).join(to);
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * Rebaixa os headings de um corpo markdown (função PURA, ciente de fences).
101
+ *
102
+ * `## Passos` dentro de uma skill vira `#### Passos` dentro da seção
103
+ * `### /spec-wave <cmd>` da monolítica. Linhas dentro de blocos de código não
104
+ * são tocadas (o mesmo rastreador do decomposition.md decide o que é código).
105
+ *
106
+ * @param {string} body
107
+ * @param {number} [by] níveis a rebaixar (teto em ######)
108
+ * @returns {string}
109
+ */
110
+ export function demoteHeadings(body, by = 2) {
111
+ const scan = fenceScanner();
112
+ return String(body ?? '').split('\n').map((line) => {
113
+ if (scan.inFence(line)) return line;
114
+ const m = /^(#{1,6})([ \t].*)$/.exec(line);
115
+ if (!m) return line;
116
+ const level = Math.min(6, m[1].length + by);
117
+ return '#'.repeat(level) + m[2];
118
+ }).join('\n');
119
+ }
120
+
121
+ // "spec-wave implement — etapa 🚧 Desenvolvimento" → "etapa 🚧 Desenvolvimento".
122
+ function h1Tail(h1Text, name) {
123
+ const text = String(h1Text ?? '').trim();
124
+ const m = new RegExp(`^spec-wave[ \\t]+${name}[ \\t]*(?:[—–:-][ \\t]*(.*))?$`, 'i').exec(text);
125
+ if (m) return (m[1] || '').trim();
126
+ return text; // H1 fora do padrão: vira o subtítulo inteiro
127
+ }
128
+
129
+ // Separa o H1 do restante do corpo (o H1 vira o título da seção composta).
130
+ function splitH1(body) {
131
+ const lines = String(body ?? '').replace(/\r\n?/g, '\n').split('\n');
132
+ const scan = fenceScanner();
133
+ for (let i = 0; i < lines.length; i++) {
134
+ if (scan.inFence(lines[i])) continue;
135
+ const m = /^#[ \t]+(.*)$/.exec(lines[i]);
136
+ if (m) return { h1: m[1].trim(), rest: lines.slice(i + 1).join('\n').trim() };
137
+ if (lines[i].trim()) break; // primeiro conteúdo não é H1 — não há o que separar
138
+ }
139
+ return { h1: null, rest: String(body ?? '').trim() };
140
+ }
141
+
142
+ /**
143
+ * Renderiza a seção de UM sub-comando de corpo completo (função PURA).
144
+ *
145
+ * @param {{name: string, meta: object, body: string}} skill
146
+ * @returns {string}
147
+ */
148
+ export function renderFullSection({ name, meta, body }) {
149
+ const { h1, rest } = splitH1(body);
150
+ const tail = h1 ? h1Tail(h1, name) : '';
151
+ const header = `### \`/spec-wave ${name}\`${tail ? ` — ${tail}` : ''}`;
152
+ const quando = meta?.description
153
+ ? `> **Quando usar:** ${String(meta.description).replace(/\s+/g, ' ').trim()}`
154
+ : '';
155
+ return [header, quando, applyRewrites(demoteHeadings(rest, 2))].filter(Boolean).join('\n\n');
156
+ }
157
+
158
+ /**
159
+ * Renderiza a lista das skills resumidas (função PURA).
160
+ *
161
+ * @param {Array<{name: string, meta: object}>} skills na ordem de SKILL_SUMMARY_ORDER
162
+ * @returns {string}
163
+ */
164
+ export function renderSummarySection(skills) {
165
+ const linhas = [
166
+ '### Outras skills (fluxos e utilitários)',
167
+ '',
168
+ 'Cada uma existe como skill dedicada do plugin (`/spec-wave:<nome>`) — e os',
169
+ 'utilitários de board estão flag a flag na *Referência da CLI* acima. Rode',
170
+ '`npx @spec-wave/cli@latest <comando> --help` para os parâmetros.',
171
+ '',
172
+ ];
173
+ for (const { name, meta } of skills) {
174
+ const desc = String(meta?.description || '').replace(/\s+/g, ' ').trim();
175
+ linhas.push(`- **\`${name}\`** — ${desc}`);
176
+ }
177
+ return linhas.join('\n');
178
+ }
179
+
180
+ /**
181
+ * Compõe a monolítica inteira (função PURA — testável sem fs).
182
+ *
183
+ * @param {object} params
184
+ * @param {string} params.core conteúdo do core.md (com o placeholder)
185
+ * @param {Map<string, {meta: object, body: string}>} params.skills por nome de diretório
186
+ * @returns {string} SKILL.md final
187
+ */
188
+ export function renderMonolithicSkill({ core, skills }) {
189
+ const raw = String(core ?? '');
190
+ if (!raw.includes(SUBCOMMANDS_PLACEHOLDER)) {
191
+ throw new Error(`core.md não contém o placeholder ${SUBCOMMANDS_PLACEHOLDER}.`);
192
+ }
193
+
194
+ const faltando = [...SKILL_FULL_ORDER, ...SKILL_SUMMARY_ORDER].filter(n => !skills.has(n));
195
+ if (faltando.length > 0) {
196
+ throw new Error(`skill(s) declaradas na ordem mas ausentes do plugin: ${faltando.join(', ')}.`);
197
+ }
198
+
199
+ const cheias = SKILL_FULL_ORDER
200
+ .map(name => renderFullSection({ name, ...skills.get(name) }))
201
+ .join('\n\n---\n\n');
202
+ const resumidas = renderSummarySection(
203
+ SKILL_SUMMARY_ORDER.map(name => ({ name, meta: skills.get(name).meta })),
204
+ );
205
+ const sections = `${cheias}\n\n---\n\n${resumidas}`;
206
+
207
+ let out = raw.replace(SUBCOMMANDS_PLACEHOLDER, sections);
208
+
209
+ // Marcador de "gerado" logo após o frontmatter — quem abrir o arquivo para
210
+ // editar precisa ser mandado de volta para a fonte antes da primeira linha útil.
211
+ const fm = /^---\n[\s\S]*?\n---\n/.exec(out);
212
+ if (!fm) throw new Error('core.md sem frontmatter YAML — a monolítica precisa dele.');
213
+ out = `${fm[0]}\n${GENERATED_MARKER}\n${out.slice(fm[0].length)}`;
214
+
215
+ return out.endsWith('\n') ? out : `${out}\n`;
216
+ }
217
+
218
+ /**
219
+ * Lê as fontes do disco e compõe (I/O). É o que o gerador e o teste de
220
+ * paridade chamam — os dois produzem exatamente o mesmo texto.
221
+ *
222
+ * @returns {{ content: string, skillNames: string[] }}
223
+ */
224
+ export function composeMonolithicSkill() {
225
+ const core = readFileSync(SKILL_CORE_FILE, 'utf-8');
226
+ const skills = new Map();
227
+ const skillNames = [];
228
+ for (const skill of listPluginSkills()) {
229
+ const { meta, body } = parseSkill(readFileSync(path.join(skill.dir, 'SKILL.md'), 'utf-8'));
230
+ skills.set(skill.name, { meta, body });
231
+ skillNames.push(skill.name);
232
+ }
233
+ return { content: renderMonolithicSkill({ core, skills }), skillNames };
234
+ }