@spec-wave/cli 0.30.0 → 0.33.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 +5 -3
- package/protocol/qa-result.v1.json +62 -0
- package/protocol/qa-trail-report.v1.json +113 -0
- package/src/api/github-graphql.mjs +6 -1
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +80 -5
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +102 -3
- package/src/commands/implement.mjs +56 -44
- package/src/commands/merge.mjs +43 -14
- package/src/commands/order.mjs +350 -96
- package/src/commands/qa-lead.mjs +748 -0
- package/src/commands/qa-run.mjs +104 -25
- package/src/config.mjs +15 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +4 -0
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/qa-exec.mjs +23 -2
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-report.mjs +65 -9
- package/src/lib/skill-compose.mjs +234 -0
- package/src/lib/story-graph.mjs +256 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/order/SKILL.md +21 -5
- package/src/plugin/skills/qa/SKILL.md +3 -1
- package/src/plugin/skills/qa-executor/SKILL.md +76 -0
- package/src/plugin/skills/qa-lead/SKILL.md +89 -0
- package/src/templates/skill/SKILL.md +953 -298
- package/src/templates/skill/core.md +584 -0
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE, labelNames,
|
|
10
10
|
} from '../config.mjs';
|
|
11
11
|
import { getIssue, listIssueComments, listBlockedBy, getFileContent } from '../api/github-rest.mjs';
|
|
12
|
-
import { listSubIssues, getIssueParent
|
|
12
|
+
import { listSubIssues, getIssueParent } from '../api/github-graphql.mjs';
|
|
13
13
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
14
14
|
import { bugDocPaths } from '../lib/bug-doc.mjs';
|
|
15
15
|
import { missingDocMessage, existsOnRemote } from '../lib/doc-availability.mjs';
|
|
@@ -17,10 +17,12 @@ import { loadArtifact } from '../lib/doc-source.mjs';
|
|
|
17
17
|
import { buildBugContext } from '../lib/bug-context.mjs';
|
|
18
18
|
import { slugify } from '../lib/slugify.mjs';
|
|
19
19
|
import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
|
|
20
|
-
import { loadProjectConfig
|
|
20
|
+
import { loadProjectConfig } from '../lib/board.mjs';
|
|
21
21
|
import { planBoardMoves, applyBoardMoves } from '../lib/implement-board.mjs';
|
|
22
22
|
import { extractPathsFromPlan, buildCodeDigest } from '../lib/code-digest.mjs';
|
|
23
|
-
import { findConfigPath, resolveFromRoot } from '../lib/project-root.mjs';
|
|
23
|
+
import { findConfigPath, resolveFromRoot, loadConfig } from '../lib/project-root.mjs';
|
|
24
|
+
import { loadFeatureStories, loadBoardSnapshot, cachedSubIssues } from '../lib/story-graph.mjs';
|
|
25
|
+
import { resolveCacheTtl } from '../lib/net-cache.mjs';
|
|
24
26
|
|
|
25
27
|
// Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
|
|
26
28
|
const WORK_DIR = '.spec-wave';
|
|
@@ -263,44 +265,41 @@ function buildContext({
|
|
|
263
265
|
* Etapa de cada item no board, por número. Best-effort: qualquer falha vira
|
|
264
266
|
* `null` para aquele item (tratado como "não implementado" por quem consome).
|
|
265
267
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
268
|
+
* UM `listProjectItems` paginado (leitura PURA) no lugar do par
|
|
269
|
+
* addProjectItem+getItemSingleSelectValue por item — que custava 2 chamadas e
|
|
270
|
+
* era MUTAÇÃO em caminho de leitura. Duas consequências:
|
|
271
|
+
* • dry-run agora VÊ as Etapas reais (a leitura deixou de ter efeito remoto);
|
|
272
|
+
* • `fresh: true` sempre — o que sai daqui alimenta gates de ESCRITA
|
|
273
|
+
* (`skipped` do plano, `featureCanAdvance`), e gate não decide com cache.
|
|
274
|
+
* O snapshot fresco é gravado no cache mesmo assim: o `order`/`merge`
|
|
275
|
+
* seguinte da sessão o reaproveita de graça.
|
|
269
276
|
*
|
|
270
277
|
* @param {string} token
|
|
271
278
|
* @param {Array<{number:number, nodeId:string}>} items
|
|
272
|
-
* @param {{
|
|
279
|
+
* @param {{warnOnSkip?:boolean}} [opts]
|
|
273
280
|
* @returns {Promise<Map<number, string|null>>}
|
|
274
281
|
*/
|
|
275
|
-
async function fetchStagesOf(token, items, {
|
|
282
|
+
async function fetchStagesOf(token, items, { warnOnSkip = false } = {}) {
|
|
276
283
|
const stageOf = new Map();
|
|
277
284
|
if (items.length === 0) return stageOf;
|
|
278
285
|
const { project, error: projectError } = loadProjectConfig();
|
|
279
|
-
if (dryRun) {
|
|
280
|
-
if (warnOnSkip) {
|
|
281
|
-
p.log.warn(
|
|
282
|
-
'Dry-run: Etapas do board não consultadas (a consulta adicionaria itens ao Project); ' +
|
|
283
|
-
'nenhuma Story será considerada implementada.'
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
return stageOf;
|
|
287
|
-
}
|
|
288
286
|
if (projectError) {
|
|
289
287
|
if (warnOnSkip) {
|
|
290
288
|
p.log.warn(`${projectError} — Etapas do board não consultadas; nenhuma Story será considerada implementada.`);
|
|
291
289
|
}
|
|
292
290
|
return stageOf;
|
|
293
291
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
stageOf.set(s.number, await getItemSingleSelectValue(token, itemId, etapaField.id));
|
|
300
|
-
} catch {
|
|
301
|
-
stageOf.set(s.number, null);
|
|
292
|
+
try {
|
|
293
|
+
const { root } = loadConfig();
|
|
294
|
+
const snapshot = await loadBoardSnapshot({ token, project, root, fresh: true });
|
|
295
|
+
for (const s of items) {
|
|
296
|
+
stageOf.set(s.number, snapshot.index.get(s.number)?.fields?.Etapa ?? null);
|
|
302
297
|
}
|
|
303
|
-
})
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (warnOnSkip) {
|
|
300
|
+
p.log.warn(`Etapas do board não consultáveis agora (${err.message}); nenhuma Story será considerada implementada.`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
304
303
|
return stageOf;
|
|
305
304
|
}
|
|
306
305
|
|
|
@@ -576,10 +575,25 @@ async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot
|
|
|
576
575
|
});
|
|
577
576
|
}
|
|
578
577
|
|
|
579
|
-
async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun, repoRoot }) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
578
|
+
async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun, repoRoot, refresh = false }) {
|
|
579
|
+
const ttlSec = resolveCacheTtl(config);
|
|
580
|
+
|
|
581
|
+
// F1-F2. Stories da Feature com as arestas já resolvidas (lib/story-graph):
|
|
582
|
+
// dependency-map.json/decomposition.md ∪ linhas "Depende de:" do body — zero
|
|
583
|
+
// chamada por Story (o blocked_by só-de-UI entra via `order --remote`/`--sync`).
|
|
584
|
+
let carga;
|
|
585
|
+
try {
|
|
586
|
+
carga = await loadFeatureStories({
|
|
587
|
+
token, owner, repo, root: repoRoot,
|
|
588
|
+
feature: { number: feature.number, nodeId: feature.node_id, title: feature.title },
|
|
589
|
+
refresh, ttlSec,
|
|
590
|
+
});
|
|
591
|
+
} catch (err) {
|
|
592
|
+
p.log.error(`Não foi possível listar as Stories da Feature #${feature.number}: ${err.message}`);
|
|
593
|
+
process.exitCode = 1;
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const stories = carga.stories;
|
|
583
597
|
if (stories.length === 0) {
|
|
584
598
|
p.log.error(
|
|
585
599
|
`Feature #${feature.number} não tem Stories (sub-issues). ` +
|
|
@@ -589,24 +603,19 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
589
603
|
return;
|
|
590
604
|
}
|
|
591
605
|
p.log.info(`Feature com ${stories.length} story(ies): ${stories.map(s => `#${s.number}`).join(', ')}`);
|
|
606
|
+
for (const w of carga.warnings) p.log.warn(w);
|
|
592
607
|
|
|
593
608
|
// F1-board. Feature → Desenvolvimento (In Progress) já no início.
|
|
594
609
|
await applyBoardMoves({ token, dryRun, moves: planBoardMoves('start', {
|
|
595
610
|
feature: { nodeId: feature.node_id, number: feature.number },
|
|
596
611
|
}) });
|
|
597
612
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
let body = s.body;
|
|
601
|
-
if (!body) body = (await getIssue(token, owner, repo, s.number).catch(() => null))?.body || '';
|
|
602
|
-
const deps = new Set(parseDependencies(body));
|
|
603
|
-
const blocked = await listBlockedBy(token, owner, repo, s.number).catch(() => []);
|
|
604
|
-
for (const b of blocked) deps.add(b.number);
|
|
605
|
-
return { number: s.number, title: s.title, nodeId: s.nodeId, body: body || '', dependsOn: [...deps] };
|
|
613
|
+
const enriched = stories.map(s => ({
|
|
614
|
+
number: s.number, title: s.title, nodeId: s.nodeId, body: s.body || '', dependsOn: s.dependsOn,
|
|
606
615
|
}));
|
|
607
616
|
|
|
608
617
|
// F3. Etapa de cada Story no board (best-effort — sem board, nada é pulado).
|
|
609
|
-
const stageOf = await fetchStagesOf(token, enriched, {
|
|
618
|
+
const stageOf = await fetchStagesOf(token, enriched, { warnOnSkip: true });
|
|
610
619
|
|
|
611
620
|
// F4. Planejamento: pendentes em ordem topológica, puladas, ciclos.
|
|
612
621
|
const { pending, skipped, cycle } = planFeatureImplementation(
|
|
@@ -641,10 +650,13 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
641
650
|
}
|
|
642
651
|
p.log.info(`Ordem de implementação: ${pending.map(s => `#${s.number}`).join(' → ')}`);
|
|
643
652
|
|
|
644
|
-
// F5. Tasks de cada Story pendente
|
|
653
|
+
// F5. Tasks de cada Story pendente (cache `subissues-<story>` com TTL —
|
|
654
|
+
// título/corpo de Task não mudam no meio de uma sessão de implement).
|
|
645
655
|
const noTasks = [];
|
|
646
656
|
for (const s of pending) {
|
|
647
|
-
const storySubs = await
|
|
657
|
+
const { subs: storySubs } = await cachedSubIssues({
|
|
658
|
+
token, owner, repo, root: repoRoot, parent: { number: s.number, nodeId: s.nodeId }, refresh, ttlSec,
|
|
659
|
+
}).catch(() => ({ subs: [] }));
|
|
648
660
|
s.tasks = storySubs
|
|
649
661
|
.filter(t => detectIssueType({ title: t.title, labels: t.labels }) === 'Task')
|
|
650
662
|
.map(t => ({ number: t.number, title: t.title, body: t.body || '', nodeId: t.nodeId }));
|
|
@@ -775,7 +787,7 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
|
|
|
775
787
|
});
|
|
776
788
|
}
|
|
777
789
|
|
|
778
|
-
export async function implement({ issue: issueArg, featureDir: featureDirOpt, dryRun }) {
|
|
790
|
+
export async function implement({ issue: issueArg, featureDir: featureDirOpt, dryRun, refresh = false }) {
|
|
779
791
|
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
780
792
|
if (!Number.isInteger(issueNumber)) {
|
|
781
793
|
p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
|
|
@@ -846,7 +858,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
846
858
|
p.log.info(`Task única #${issueNumber}.`);
|
|
847
859
|
} else if (type === 'Feature') {
|
|
848
860
|
// Modo Feature: Stories pendentes em ordem de dependência, contexto único.
|
|
849
|
-
await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun, repoRoot });
|
|
861
|
+
await implementFeature({ token, owner, repo, config, feature: issue, featureDirOpt, dryRun, repoRoot, refresh });
|
|
850
862
|
return;
|
|
851
863
|
} else if (type === 'Bug') {
|
|
852
864
|
// Modo Bug: sem tasks e sem spec/plan — o trabalho é investigar antes de
|
|
@@ -996,7 +1008,7 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
|
|
|
996
1008
|
// Sem irmãs, esta era a única Story: a Feature avança direto (nada a
|
|
997
1009
|
// consultar). Com irmãs, todas precisam já estar em Code Review+.
|
|
998
1010
|
if (siblingStories.length > 0) {
|
|
999
|
-
const stageOf = await fetchStagesOf(token, siblingStories, {
|
|
1011
|
+
const stageOf = await fetchStagesOf(token, siblingStories, {});
|
|
1000
1012
|
if (!featureCanAdvance(siblingStories, stageOf)) return;
|
|
1001
1013
|
}
|
|
1002
1014
|
await applyBoardMoves({ token, dryRun, moves: planBoardMoves('success', {
|
package/src/commands/merge.mjs
CHANGED
|
@@ -21,12 +21,14 @@ import chalk from 'chalk';
|
|
|
21
21
|
|
|
22
22
|
import { resolveToken } from '../api/auth.mjs';
|
|
23
23
|
import {
|
|
24
|
-
getIssue, getPR, getRepoDefaultBranch, updatePRBase, mergePR, deleteBranch,
|
|
24
|
+
getIssue, getPR, getRepoDefaultBranch, updatePRBase, mergePR, deleteBranch,
|
|
25
25
|
} from '../api/github-rest.mjs';
|
|
26
|
-
import {
|
|
26
|
+
import { listIssuePullRequests } from '../api/github-graphql.mjs';
|
|
27
27
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
28
|
-
import {
|
|
29
|
-
import { resolveRepoContext } from '../lib/project-root.mjs';
|
|
28
|
+
import { orderStories } from '../lib/dependencies.mjs';
|
|
29
|
+
import { resolveRepoContext, loadConfig } from '../lib/project-root.mjs';
|
|
30
|
+
import { loadFeatureStories } from '../lib/story-graph.mjs';
|
|
31
|
+
import { resolveCacheTtl, invalidateCache } from '../lib/net-cache.mjs';
|
|
30
32
|
import { CONFIG_FILE } from '../config.mjs';
|
|
31
33
|
import { acquireLock, releaseLock } from './run.mjs';
|
|
32
34
|
import { codeReview } from './code-review.mjs';
|
|
@@ -108,7 +110,12 @@ export function planMerge({ sorted = [], byNumber = new Map(), prsByStory = new
|
|
|
108
110
|
return { fila, concluidas, semPr, bloqueios, avisos };
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
export async function merge({
|
|
113
|
+
export async function merge({
|
|
114
|
+
feature: featureArg, yes = false, dryRun = false, keepBranches = false,
|
|
115
|
+
remote = false, refresh = false,
|
|
116
|
+
} = {}) {
|
|
117
|
+
const { config: fileConfig, root: repoRoot } = loadConfig();
|
|
118
|
+
const ttlSec = resolveCacheTtl(fileConfig);
|
|
112
119
|
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
113
120
|
p.log.error('`spec-wave merge` é um comando local — dentro do Actions o merge é decisão humana.');
|
|
114
121
|
process.exitCode = 1;
|
|
@@ -163,29 +170,46 @@ export async function merge({ feature: featureArg, yes = false, dryRun = false,
|
|
|
163
170
|
return;
|
|
164
171
|
}
|
|
165
172
|
|
|
166
|
-
// As Stories e a ordem — as mesmas fontes do `order
|
|
167
|
-
|
|
173
|
+
// As Stories e a ordem — as mesmas fontes do `order` (lib/story-graph).
|
|
174
|
+
//
|
|
175
|
+
// Política de coerência: no PLANO (sem --yes), as arestas locais bastam —
|
|
176
|
+
// mostrar a fila não muda nada. Na EXECUÇÃO, blocked_by remoto FRESCO é
|
|
177
|
+
// obrigatório: o merge de PRs empilhados é irreversível ("merge parcial de
|
|
178
|
+
// pilha é o pior estado"), e a aresta criada só pela UI é exatamente a que as
|
|
179
|
+
// fontes locais não enxergam. S chamadas num comando raro é o preço certo.
|
|
180
|
+
const executa = Boolean(yes) && !dryRun;
|
|
181
|
+
let carga;
|
|
168
182
|
try {
|
|
169
|
-
|
|
183
|
+
carga = await loadFeatureStories({
|
|
184
|
+
token, owner, repo, root: repoRoot,
|
|
185
|
+
feature: { number: featureNumber, nodeId: featureIssue.node_id, title: featureIssue.title },
|
|
186
|
+
remote: executa || remote,
|
|
187
|
+
refresh: executa || refresh,
|
|
188
|
+
ttlSec,
|
|
189
|
+
});
|
|
170
190
|
} catch (err) {
|
|
171
191
|
s.stop('');
|
|
172
192
|
p.log.error(`Não foi possível listar as sub-issues: ${err.message}`);
|
|
173
193
|
process.exitCode = 1;
|
|
174
194
|
return;
|
|
175
195
|
}
|
|
176
|
-
const stories =
|
|
196
|
+
const stories = carga.stories;
|
|
177
197
|
if (stories.length === 0) {
|
|
178
198
|
s.stop('');
|
|
179
199
|
p.log.info(`Feature #${featureNumber} não tem Stories — nada a mergear.`);
|
|
180
200
|
p.outro('Nada a fazer.');
|
|
181
201
|
return;
|
|
182
202
|
}
|
|
203
|
+
for (const w of carga.warnings) p.log.warn(w);
|
|
204
|
+
if (!executa && !remote) {
|
|
205
|
+
p.log.info(chalk.dim(
|
|
206
|
+
`Ordem calculada de ${carga.origin.edges} — blocked_by criado só pela UI não entra no plano ` +
|
|
207
|
+
'(a execução com --yes reconsulta tudo fresco).'
|
|
208
|
+
));
|
|
209
|
+
}
|
|
183
210
|
|
|
184
|
-
const enriched =
|
|
185
|
-
|
|
186
|
-
const fromBlockedBy = (await listBlockedBy(token, owner, repo, st.number).catch(() => []))
|
|
187
|
-
.map(b => b.number);
|
|
188
|
-
return { number: st.number, title: st.title, nodeId: st.nodeId, dependsOn: [...new Set([...fromBody, ...fromBlockedBy])] };
|
|
211
|
+
const enriched = stories.map(st => ({
|
|
212
|
+
number: st.number, title: st.title, nodeId: st.nodeId, dependsOn: st.dependsOn,
|
|
189
213
|
}));
|
|
190
214
|
const byNumber = new Map(enriched.map(x => [x.number, x]));
|
|
191
215
|
const { order: sorted, cycle } = orderStories(
|
|
@@ -283,6 +307,11 @@ export async function merge({ feature: featureArg, yes = false, dryRun = false,
|
|
|
283
307
|
}
|
|
284
308
|
} finally {
|
|
285
309
|
releaseLock(lock);
|
|
310
|
+
// Merges fecham issues e movem o board: todo cache de leitura desta sessão
|
|
311
|
+
// envelheceu de uma vez (o de board já cai dentro do advanceToStage).
|
|
312
|
+
if (mergedHeads.length > 0) {
|
|
313
|
+
invalidateCache(repoRoot, 'board-items', 'blockedby-*', 'subissues-*', 'issue-*');
|
|
314
|
+
}
|
|
286
315
|
}
|
|
287
316
|
|
|
288
317
|
p.outro(
|