@spec-wave/cli 0.26.0 → 0.28.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/package.json +1 -1
- package/src/api/github-rest.mjs +52 -0
- package/src/cli.mjs +12 -0
- package/src/commands/decompose.mjs +166 -39
- package/src/commands/doctor.mjs +214 -3
- package/src/commands/generate-bug.mjs +22 -16
- package/src/commands/generate-plan.mjs +72 -23
- package/src/commands/generate-spec.mjs +19 -15
- package/src/commands/implement.mjs +47 -22
- package/src/commands/install-skill.mjs +18 -8
- package/src/commands/preflight.mjs +322 -0
- package/src/commands/run.mjs +51 -30
- package/src/commands/update.mjs +143 -12
- package/src/commands/validate.mjs +84 -17
- package/src/config.mjs +18 -0
- package/src/lib/artifact-pr.mjs +272 -0
- package/src/lib/artifact-publish.mjs +169 -0
- package/src/lib/doc-availability.mjs +23 -1
- package/src/lib/doc-source.mjs +162 -0
- package/src/lib/flow-run.mjs +9 -218
- package/src/lib/next-step.mjs +27 -4
- package/src/lib/pr-branch.mjs +106 -7
- package/src/lib/repo-links.mjs +8 -2
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/README.md +5 -0
- package/src/plugin/skills/bug/SKILL.md +2 -2
- package/src/plugin/skills/decompose/SKILL.md +4 -4
- package/src/plugin/skills/plan/SKILL.md +1 -1
- package/src/plugin/skills/preparar-feature/SKILL.md +245 -0
- package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
- package/src/plugin/skills/preparar-specs/SKILL.md +171 -0
- package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
- package/src/plugin/skills/preparar-specs/reference/revisao.md +107 -0
- package/src/plugin/skills/run/SKILL.md +3 -1
- package/src/plugin/skills/spec/SKILL.md +4 -4
- package/src/plugin/skills/update/SKILL.md +10 -4
- package/src/plugin/skills/workflow/SKILL.md +8 -3
- package/src/templates/skill/SKILL.md +13 -10
- package/src/templates/workflows/code-review.yml +13 -2
- package/src/templates/workflows/critique.yml +1 -1
- package/src/templates/workflows/decompose.yml +13 -2
- package/src/templates/workflows/generate-bug.yml +17 -6
- package/src/templates/workflows/generate-plan.yml +20 -7
- package/src/templates/workflows/generate-spec.yml +20 -7
- package/src/templates/workflows/qa.yml +13 -0
package/src/commands/update.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from '../api/github-rest.mjs';
|
|
13
13
|
import {
|
|
14
14
|
resolveBranchName, composePrTitle, composePrBody, buildCommitMessage,
|
|
15
|
-
decideConfigInPr, explainGitWriteError,
|
|
15
|
+
decideConfigInPr, decideSkillInPr, composeConfigCleanupHint, explainGitWriteError,
|
|
16
16
|
} from '../lib/pr-branch.mjs';
|
|
17
17
|
// MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
|
|
18
18
|
// byte com o remoto. Se só o init resolvesse, todo update veria os workflows
|
|
@@ -20,10 +20,11 @@ import {
|
|
|
20
20
|
import { readTemplate } from '../lib/templates.mjs';
|
|
21
21
|
import {
|
|
22
22
|
TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
|
|
23
|
-
mergeAgentsFile, resolveDest, isDetected, skillCopyReason, versionBanner,
|
|
23
|
+
mergeAgentsFile, mergeAgentsContent, resolveDest, isDetected, skillCopyReason, versionBanner,
|
|
24
24
|
} from './install-skill.mjs';
|
|
25
25
|
import { planPluginSkillFiles } from '../lib/plugin-skills.mjs';
|
|
26
26
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
27
|
+
import { currentGitBranch } from '../lib/repo-links.mjs';
|
|
27
28
|
|
|
28
29
|
// Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
|
|
29
30
|
const REPO_FILES = [
|
|
@@ -68,6 +69,41 @@ function applySkill(job) {
|
|
|
68
69
|
writeFileSync(job.dest.path, content, 'utf-8');
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Arquivos que uma atualização de skill grava, em caminhos RELATIVOS à raiz do
|
|
74
|
+
* repositório — a chave para perguntar à base se aquela cópia é versionada.
|
|
75
|
+
*
|
|
76
|
+
* Devolve [] quando o destino cai FORA da raiz, e é o que dispensa um caso
|
|
77
|
+
* especial para `--global`: `~/.claude/skills/...` e o `~/.gemini/AGENTS.md` do
|
|
78
|
+
* Antigravity jamais são conteúdo de repositório, e `path.relative` já diz isso.
|
|
79
|
+
*
|
|
80
|
+
* O formato `agents` (AGENTS.md) devolve `block` em vez de `content`: o conteúdo
|
|
81
|
+
* final depende do que existe na BASE — mesclar no arquivo local arrastaria para
|
|
82
|
+
* dentro do PR as edições não commitadas do desenvolvedor.
|
|
83
|
+
*
|
|
84
|
+
* @param {{dest: {path: string, format: string}, desired?: string}} job
|
|
85
|
+
* @param {string} repoRoot
|
|
86
|
+
* @returns {Array<{ relPath: string, content?: string, block?: string }>}
|
|
87
|
+
*/
|
|
88
|
+
export function skillRepoTargets(job, repoRoot) {
|
|
89
|
+
const items = job.dest.format === 'skills-dir'
|
|
90
|
+
? planPluginSkillFiles(job.dest.path, CLI_VERSION, versionBanner)
|
|
91
|
+
.map(f => ({ absPath: f.path, content: f.content }))
|
|
92
|
+
: [{
|
|
93
|
+
absPath: job.dest.path,
|
|
94
|
+
...(job.dest.format === 'agents' ? { block: job.desired } : { content: job.desired }),
|
|
95
|
+
}];
|
|
96
|
+
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const { absPath, ...rest } of items) {
|
|
99
|
+
const rel = path.relative(repoRoot, absPath);
|
|
100
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
101
|
+
// A API do GitHub fala POSIX; path.relative devolve no separador do SO.
|
|
102
|
+
out.push({ ...rest, relPath: rel.split(path.sep).join('/') });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
71
107
|
/**
|
|
72
108
|
* Compara ALL_LABELS com as labels do repo (função PURA — testável).
|
|
73
109
|
*
|
|
@@ -325,6 +361,70 @@ export async function update(options = {}) {
|
|
|
325
361
|
}
|
|
326
362
|
}
|
|
327
363
|
|
|
364
|
+
// 2d) Skill: a BASE versiona alguma destas cópias?
|
|
365
|
+
//
|
|
366
|
+
// Até aqui o update gravava a skill em disco e o corpo do PR AFIRMAVA ao
|
|
367
|
+
// revisor que ela "não faz parte do repositório" — premissa fixa. Em repos que
|
|
368
|
+
// versionam `.claude/skills/spec-wave/SKILL.md` (ou `.agents/skills/`, ou o
|
|
369
|
+
// AGENTS.md) isso era falso duas vezes: sobrava um commit manual a cada bump, e
|
|
370
|
+
// até ele o repositório seguia distribuindo a skill da versão anterior para
|
|
371
|
+
// quem clonasse. A checagem é a MESMA do .spec-wave.json — perguntar à base —,
|
|
372
|
+
// e a política também: só entra no PR o que o projeto já versiona.
|
|
373
|
+
let skillPrFiles = []; // [{ relPath, content, reason, agent }]
|
|
374
|
+
// agente → motivo da PRIMEIRA cópia incluída. É o motivo que o resumo mostra:
|
|
375
|
+
// dizer "versionada no repo" para uma cópia que entrou por `--skill-in-pr`
|
|
376
|
+
// seria repetir, em miniatura, a premissa que este código veio corrigir.
|
|
377
|
+
const skillPrAgents = new Map();
|
|
378
|
+
// `skillRepoTargets` já devolve vazio para o que mora fora da raiz, o que cobre
|
|
379
|
+
// o `--global` inteiro sem um caso especial: nada a perguntar, nada a gastar.
|
|
380
|
+
const skillCandidates = prMode
|
|
381
|
+
? skillJobs
|
|
382
|
+
.map(job => ({ job, targets: skillRepoTargets(job, path.dirname(configPath)) }))
|
|
383
|
+
.filter(c => c.targets.length)
|
|
384
|
+
: [];
|
|
385
|
+
if (skillCandidates.length) {
|
|
386
|
+
const tk = await getToken();
|
|
387
|
+
const s = p.spinner();
|
|
388
|
+
s.start('Verificando se o repositório versiona a skill...');
|
|
389
|
+
try {
|
|
390
|
+
for (const { job, targets } of skillCandidates) {
|
|
391
|
+
// Em paralelo: o destino do Codex são 20+ arquivos, e um GET por vez
|
|
392
|
+
// transformaria a checagem na parte mais lenta do comando.
|
|
393
|
+
const remotes = await Promise.all(
|
|
394
|
+
targets.map(t => getFileContent(tk, owner, repo, t.relPath, base))
|
|
395
|
+
);
|
|
396
|
+
targets.forEach((t, i) => {
|
|
397
|
+
const remote = remotes[i];
|
|
398
|
+
const desired = t.block !== undefined
|
|
399
|
+
? mergeAgentsContent(remote ?? '', t.block)
|
|
400
|
+
: t.content;
|
|
401
|
+
const d = decideSkillInPr({ remote, desired, force: options.skillInPr });
|
|
402
|
+
if (!d.included) return;
|
|
403
|
+
skillPrFiles.push({
|
|
404
|
+
relPath: t.relPath,
|
|
405
|
+
content: desired,
|
|
406
|
+
reason: remote == null ? 'ausente' : 'desatualizado',
|
|
407
|
+
agent: job.target.name,
|
|
408
|
+
});
|
|
409
|
+
if (!skillPrAgents.has(job.target.name)) skillPrAgents.set(job.target.name, d.reason);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
s.stop(skillPrFiles.length
|
|
413
|
+
? `Skill versionada no repositório: ${skillPrFiles.length} arquivo(s) vão no Pull Request.`
|
|
414
|
+
: 'A skill não é versionada neste repositório — segue apenas local.');
|
|
415
|
+
} catch (err) {
|
|
416
|
+
// Falhar aqui não pode custar o update inteiro: sem a resposta da base, a
|
|
417
|
+
// skill volta a ser só local — o comportamento anterior, agora explícito.
|
|
418
|
+
s.stop('');
|
|
419
|
+
p.log.warn(`Não foi possível checar a skill na base: ${err.message} — ela seguirá apenas local.`);
|
|
420
|
+
skillPrFiles = [];
|
|
421
|
+
skillPrAgents.clear();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const skillLocalAgents = skillJobs
|
|
425
|
+
.map(j => j.target.name)
|
|
426
|
+
.filter(n => !skillPrAgents.has(n));
|
|
427
|
+
|
|
328
428
|
// Decisão preliminar sobre o config. O conteúdo regenerado ainda não existe;
|
|
329
429
|
// `willRegenerate` cobre isso, porque o regenerado SEMPRE difere do remoto
|
|
330
430
|
// (refreshedAt muda a cada execução). Na aplicação a decisão é recalculada com
|
|
@@ -355,21 +455,39 @@ export async function update(options = {}) {
|
|
|
355
455
|
const lines = [];
|
|
356
456
|
if (skillJobs.length) {
|
|
357
457
|
lines.push(chalk.bold('Skill:'));
|
|
358
|
-
for (const j of skillJobs)
|
|
458
|
+
for (const j of skillJobs) {
|
|
459
|
+
const destino = skillPrAgents.has(j.target.name)
|
|
460
|
+
? chalk.dim(` → vai no PR: ${skillPrAgents.get(j.target.name)}`)
|
|
461
|
+
: '';
|
|
462
|
+
lines.push(` ${chalk.yellow('↻')} ${j.target.name} (${j.reason})${destino}\n ${chalk.dim(j.dest.path)}`);
|
|
463
|
+
}
|
|
359
464
|
}
|
|
360
465
|
if (configStale) {
|
|
361
466
|
lines.push(chalk.bold('Config local:'));
|
|
362
467
|
lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} — ${configStale.reason}` +
|
|
363
468
|
(configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
|
|
364
469
|
}
|
|
470
|
+
// Cabeçalho ÚNICO no modo PR: workflows, skill e config viajam no mesmo commit,
|
|
471
|
+
// e três títulos para um commit só sugeriam três destinos diferentes.
|
|
472
|
+
let prHeaderShown = false;
|
|
473
|
+
const prHeader = () => {
|
|
474
|
+
if (prHeaderShown) return;
|
|
475
|
+
prHeaderShown = true;
|
|
476
|
+
lines.push(chalk.bold(`Pull Request (${branch} → ${base}):`));
|
|
477
|
+
};
|
|
365
478
|
if (repoFiles.length) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
: chalk.bold('Arquivos do repo:'));
|
|
479
|
+
if (prMode) prHeader();
|
|
480
|
+
else lines.push(chalk.bold('Arquivos do repo:'));
|
|
369
481
|
for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
|
|
370
482
|
}
|
|
483
|
+
if (skillPrFiles.length) {
|
|
484
|
+
prHeader();
|
|
485
|
+
for (const f of skillPrFiles) {
|
|
486
|
+
lines.push(` ${chalk.yellow('↻')} ${f.relPath} (${f.reason} — skill ${f.agent})`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
371
489
|
if (prMode && configDecision.included) {
|
|
372
|
-
|
|
490
|
+
prHeader();
|
|
373
491
|
lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} (${configDecision.reason})`);
|
|
374
492
|
}
|
|
375
493
|
if (prMode && doConfig && !configDecision.included && configDecision.reason) {
|
|
@@ -388,7 +506,8 @@ export async function update(options = {}) {
|
|
|
388
506
|
p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
|
|
389
507
|
|
|
390
508
|
// --branch sem efeito: melhor dizer POR QUE do que criar uma branch inútil.
|
|
391
|
-
const prHasPayload = prMode
|
|
509
|
+
const prHasPayload = prMode
|
|
510
|
+
&& (repoFiles.length > 0 || skillPrFiles.length > 0 || configDecision.included);
|
|
392
511
|
if (prRequested && options.skipRepo) {
|
|
393
512
|
p.log.warn(
|
|
394
513
|
'--branch ignorado junto com --skip-repo: o Pull Request existe justamente para ' +
|
|
@@ -408,7 +527,7 @@ export async function update(options = {}) {
|
|
|
408
527
|
|
|
409
528
|
if (options.dryRun) {
|
|
410
529
|
if (prHasPayload) {
|
|
411
|
-
const n = repoFiles.length + (configDecision.included ? 1 : 0);
|
|
530
|
+
const n = repoFiles.length + skillPrFiles.length + (configDecision.included ? 1 : 0);
|
|
412
531
|
p.note(
|
|
413
532
|
`Branch: ${branch}${branchExists ? ' (já existe — o commit seria empilhado nela)' : ' (seria criada)'}\n` +
|
|
414
533
|
`Base: ${base}\n` +
|
|
@@ -516,6 +635,11 @@ export async function update(options = {}) {
|
|
|
516
635
|
const tk = await getToken();
|
|
517
636
|
const treeFiles = [
|
|
518
637
|
...repoFiles.map(f => ({ path: f.repoPath, reason: f.reason, content: f.local })),
|
|
638
|
+
...skillPrFiles.map(f => ({
|
|
639
|
+
path: f.relPath,
|
|
640
|
+
reason: `${f.reason} — skill ${f.agent}`,
|
|
641
|
+
content: f.content,
|
|
642
|
+
})),
|
|
519
643
|
...(finalConfigDecision.included
|
|
520
644
|
? [{
|
|
521
645
|
path: CONFIG_FILE,
|
|
@@ -562,7 +686,7 @@ export async function update(options = {}) {
|
|
|
562
686
|
files: treeFiles,
|
|
563
687
|
config: doConfig ? finalConfigDecision : null,
|
|
564
688
|
labels: labelResult,
|
|
565
|
-
skill:
|
|
689
|
+
skill: { inPr: [...skillPrAgents.keys()], local: skillLocalAgents },
|
|
566
690
|
}),
|
|
567
691
|
});
|
|
568
692
|
if (!pr) {
|
|
@@ -605,9 +729,16 @@ export async function update(options = {}) {
|
|
|
605
729
|
(prUrl ? ` Revise e faça o merge do Pull Request: ${prUrl}` : '') +
|
|
606
730
|
(!prMode && repoFiles.length ? ' Arquivos do repo foram commitados no remoto.' : '') +
|
|
607
731
|
(configPending ? ` COMMITE o ${CONFIG_FILE} — quem clona o repo (dev-agent, Actions) lê a versão commitada.` : '') +
|
|
732
|
+
// A dica de descarte depende da branch do CHECKOUT, não de premissa:
|
|
733
|
+
// `git checkout -- <arquivo>` restaura o que está no índice, e rodado de uma
|
|
734
|
+
// branch de trabalho reverte o arquivo para a versão ANTIGA dela — o oposto
|
|
735
|
+
// do que a mensagem promete.
|
|
608
736
|
(finalConfigDecision.included
|
|
609
|
-
? `
|
|
610
|
-
|
|
737
|
+
? ` ${composeConfigCleanupHint({
|
|
738
|
+
file: CONFIG_FILE,
|
|
739
|
+
base,
|
|
740
|
+
current: currentGitBranch(path.dirname(configPath)),
|
|
741
|
+
})}`
|
|
611
742
|
: '')
|
|
612
743
|
);
|
|
613
744
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
2
|
import { getIssue, removeLabel, addLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
3
|
import { slugify } from '../lib/slugify.mjs';
|
|
@@ -14,6 +12,48 @@ import { detectIssueType } from '../lib/issue-type.mjs';
|
|
|
14
12
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
15
13
|
import { executionMode } from '../lib/flow-run.mjs';
|
|
16
14
|
import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
15
|
+
import { loadArtifact } from '../lib/doc-source.mjs';
|
|
16
|
+
import { awaitingMergeBlock } from '../lib/artifact-pr.mjs';
|
|
17
|
+
import { isAwaitingMerge } from '../lib/doc-source.mjs';
|
|
18
|
+
import { getRepoDefaultBranch } from '../api/github-rest.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Aborto por documento ainda em Pull Request.
|
|
22
|
+
*
|
|
23
|
+
* Este é o portão mais importante do `validate`, e existe por um motivo
|
|
24
|
+
* assimétrico: a reprova NÃO é inócua. Ela remove `spec-wave:ready` (o gatilho,
|
|
25
|
+
* que `issues: [labeled]` não redispara sozinho) e comenta a falha na issue —
|
|
26
|
+
* ou seja, danifica estado COMPARTILHADO. Fazer isso porque o documento está num
|
|
27
|
+
* PR não mergeado seria reprovar por uma condição que não é do documento.
|
|
28
|
+
*
|
|
29
|
+
* É exatamente o incidente que o comentário do `readsByType` em
|
|
30
|
+
* lib/next-step.mjs já registra ter acontecido antes, com clone desatualizado.
|
|
31
|
+
*
|
|
32
|
+
* Aborta ANTES do removeLabel: a label fica, e reaplicá-la depois do merge
|
|
33
|
+
* simplesmente funciona.
|
|
34
|
+
*/
|
|
35
|
+
async function abortIfPendingPr({ token, owner, repo, n, docs }) {
|
|
36
|
+
const pendentes = docs.filter(d => isAwaitingMerge(d.state));
|
|
37
|
+
if (pendentes.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
// Cada documento carrega o SEU desbloqueio: um está esperando merge, outro
|
|
40
|
+
// esperando alguém abrir o PR. Um texto único ("faça o merge do PR") mandaria
|
|
41
|
+
// metade dos casos procurar uma tela que não existe.
|
|
42
|
+
const blocos = pendentes.map(d => awaitingMergeBlock({
|
|
43
|
+
pathRel: d.pathRel, state: d.state, pr: d.pr, branch: d.ref,
|
|
44
|
+
}));
|
|
45
|
+
const linhas = blocos.map(b => `- ${b.message}\n ${b.unblock}`);
|
|
46
|
+
|
|
47
|
+
await commentOnIssue(token, owner, repo, n,
|
|
48
|
+
'⏸️ **Validação adiada — documento ainda não está na branch base.**\n\n' +
|
|
49
|
+
`${linhas.join('\n')}\n\n` +
|
|
50
|
+
'Resolvido isso, reaplique `spec-wave:ready` — a label foi **mantida** na issue, ' +
|
|
51
|
+
'e nada foi reprovado.'
|
|
52
|
+
).catch(() => {});
|
|
53
|
+
|
|
54
|
+
console.error(`Validação adiada: ${pendentes.map(d => d.pathRel).join(', ')} fora da base.`);
|
|
55
|
+
return { ok: false, errors: linhas, awaitingMerge: true };
|
|
56
|
+
}
|
|
17
57
|
|
|
18
58
|
/**
|
|
19
59
|
* Validação do bug.md (RFC-004 §5).
|
|
@@ -23,11 +63,19 @@ import { docBlobUrl } from '../lib/repo-links.mjs';
|
|
|
23
63
|
* um único arquivo, e a falha NÃO devolve o item para a etapa de spec (Bug não
|
|
24
64
|
* tem etapa de spec). Reaplicar `spec-wave:bug` é o caminho de retomada.
|
|
25
65
|
*/
|
|
26
|
-
async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
66
|
+
async function validateBug({ token, owner, repo, issue, issueNumber, root, base }) {
|
|
27
67
|
const n = parseInt(issueNumber, 10);
|
|
28
|
-
const { fileRel
|
|
68
|
+
const { fileRel } = bugDocPaths(issue.title, root);
|
|
29
69
|
const errors = [];
|
|
30
70
|
|
|
71
|
+
const bug = await loadArtifact({
|
|
72
|
+
token, owner, repo, root, pathRel: fileRel, doc: 'bug', issueNumber: n, base,
|
|
73
|
+
});
|
|
74
|
+
const adiado = await abortIfPendingPr({
|
|
75
|
+
token, owner, repo, n, docs: [{ ...bug, pathRel: fileRel }],
|
|
76
|
+
});
|
|
77
|
+
if (adiado) return adiado;
|
|
78
|
+
|
|
31
79
|
const names = labelNames(issue);
|
|
32
80
|
const critiqueFailed = names.includes(LABEL_CRITIQUE_FAILED);
|
|
33
81
|
const needsHuman = names.includes(LABEL_NEEDS_HUMAN);
|
|
@@ -44,12 +92,12 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
44
92
|
);
|
|
45
93
|
}
|
|
46
94
|
|
|
47
|
-
if (
|
|
95
|
+
if (bug.content == null) {
|
|
48
96
|
errors.push(
|
|
49
97
|
`❌ \`bug.md\` não encontrado em \`${fileRel}\` — aplique \`${LABEL_BUG}\` para gerá-lo.`
|
|
50
98
|
);
|
|
51
99
|
} else {
|
|
52
|
-
const content =
|
|
100
|
+
const content = bug.content;
|
|
53
101
|
for (const faltante of describeMissingSections(content, REQUIRED_BUG_SECTIONS)) {
|
|
54
102
|
errors.push(renderMissingSection('bug.md', faltante));
|
|
55
103
|
}
|
|
@@ -124,19 +172,23 @@ export async function validate({ issueNumber }) {
|
|
|
124
172
|
);
|
|
125
173
|
}
|
|
126
174
|
|
|
127
|
-
const
|
|
175
|
+
const n = parseInt(issueNumber, 10);
|
|
176
|
+
const issue = await getIssue(token, owner, repo, n);
|
|
177
|
+
|
|
178
|
+
// Branch base para a leitura em camadas. Best-effort: sem ela o resolvedor cai
|
|
179
|
+
// no default da API, que é a mesma branch.
|
|
180
|
+
const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
|
|
128
181
|
|
|
129
182
|
// Bug tem artefato próprio (bug.md) e caminho próprio de validação — não passa
|
|
130
183
|
// pelo par spec.md + plan.md, que é exclusivo de Feature.
|
|
131
184
|
if (detectIssueType(issue) === 'Bug') {
|
|
132
|
-
return await validateBug({ token, owner, repo, issue, issueNumber, root });
|
|
185
|
+
return await validateBug({ token, owner, repo, issue, issueNumber, root, base });
|
|
133
186
|
}
|
|
134
187
|
|
|
135
188
|
const slug = slugify(issue.title);
|
|
136
189
|
// Ancorado na RAIZ do repo, não no cwd: rodar de um subdiretório encontra o
|
|
137
190
|
// config subindo na árvore e precisa encontrar os documentos no mesmo lugar.
|
|
138
191
|
const featureRel = `docs/features/${slug}`;
|
|
139
|
-
const featureDir = path.resolve(root || process.cwd(), featureRel);
|
|
140
192
|
|
|
141
193
|
const errors = [];
|
|
142
194
|
|
|
@@ -158,12 +210,28 @@ export async function validate({ issueNumber }) {
|
|
|
158
210
|
);
|
|
159
211
|
}
|
|
160
212
|
|
|
213
|
+
// Os dois documentos vêm do resolvedor em camadas (disco → base → PR aberto):
|
|
214
|
+
// ler só o disco reprovaria uma Feature cujo plan.md está publicado, o que
|
|
215
|
+
// custa a label de gatilho e um comentário de falha na issue.
|
|
216
|
+
const planRel = `${featureRel}/plan.md`;
|
|
217
|
+
const specRel = `${featureRel}/spec.md`;
|
|
218
|
+
const ler = (doc, pathRel) => loadArtifact({
|
|
219
|
+
token, owner, repo, root, pathRel, doc, issueNumber: n, base,
|
|
220
|
+
});
|
|
221
|
+
const plan = await ler('plan', planRel);
|
|
222
|
+
const spec = await ler('spec', specRel);
|
|
223
|
+
|
|
224
|
+
const adiado = await abortIfPendingPr({
|
|
225
|
+
token, owner, repo, n,
|
|
226
|
+
docs: [{ ...plan, pathRel: planRel }, { ...spec, pathRel: specRel }],
|
|
227
|
+
});
|
|
228
|
+
if (adiado) return adiado;
|
|
229
|
+
|
|
161
230
|
// Check plan.md
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
|
|
231
|
+
if (plan.content == null) {
|
|
232
|
+
errors.push('❌ `plan.md` não encontrado em `' + planRel + '`');
|
|
165
233
|
} else {
|
|
166
|
-
const planContent =
|
|
234
|
+
const planContent = plan.content;
|
|
167
235
|
for (const faltante of describeMissingSections(planContent, REQUIRED_PLAN_SECTIONS)) {
|
|
168
236
|
errors.push(renderMissingSection('plan.md', faltante));
|
|
169
237
|
}
|
|
@@ -173,11 +241,10 @@ export async function validate({ issueNumber }) {
|
|
|
173
241
|
}
|
|
174
242
|
|
|
175
243
|
// Check spec.md
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
|
|
244
|
+
if (spec.content == null) {
|
|
245
|
+
errors.push('❌ `spec.md` não encontrado em `' + specRel + '`');
|
|
179
246
|
} else {
|
|
180
|
-
const specContent =
|
|
247
|
+
const specContent = spec.content;
|
|
181
248
|
for (const faltante of describeMissingSections(specContent, REQUIRED_SPEC_SECTIONS)) {
|
|
182
249
|
errors.push(renderMissingSection('spec.md', faltante));
|
|
183
250
|
}
|
package/src/config.mjs
CHANGED
|
@@ -519,6 +519,24 @@ export const WORKFLOW_FILES = [
|
|
|
519
519
|
'qa.yml',
|
|
520
520
|
];
|
|
521
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Workflows que PUBLICAM um documento gerado (branch própria + Pull Request).
|
|
524
|
+
*
|
|
525
|
+
* Lista explícita de propósito. O teste que verifica permissões selecionava os
|
|
526
|
+
* workflows por `content.includes('contents: write')` — um predicado que passa
|
|
527
|
+
* VACUAMENTE se o marcador sumir do YAML: nenhum arquivo é selecionado, o laço
|
|
528
|
+
* não roda, e o teste fica verde justamente no cenário que ele deveria pegar.
|
|
529
|
+
*
|
|
530
|
+
* Estes quatro precisam de `contents: write` (o commit vai por Git Data API) E
|
|
531
|
+
* de `pull-requests: write` (sem ela o commit existe e o PR não).
|
|
532
|
+
*/
|
|
533
|
+
export const ARTIFACT_WORKFLOW_FILES = [
|
|
534
|
+
'generate-spec.yml',
|
|
535
|
+
'generate-plan.yml',
|
|
536
|
+
'generate-bug.yml',
|
|
537
|
+
'decompose.yml',
|
|
538
|
+
];
|
|
539
|
+
|
|
522
540
|
export const ISSUE_TEMPLATE_FILES = [
|
|
523
541
|
'plan-template.md',
|
|
524
542
|
'spec-template.md',
|