@spec-wave/cli 0.32.0 → 0.34.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-graphql.mjs +28 -0
- package/src/api/github-rest.mjs +24 -0
- package/src/cli.mjs +6 -2
- package/src/commands/audit.mjs +10 -1
- package/src/commands/dev-agent.mjs +20 -4
- package/src/commands/doctor.mjs +107 -13
- package/src/commands/implement.mjs +167 -34
- package/src/commands/merge.mjs +18 -0
- package/src/commands/update.mjs +55 -10
- package/src/commands/validate.mjs +14 -0
- package/src/config.mjs +57 -4
- package/src/lib/bug-context.mjs +17 -6
- package/src/lib/commit-trailers.mjs +134 -0
- package/src/lib/delivery-mode.mjs +26 -0
- package/src/lib/rate-budget.mjs +36 -0
- package/src/lib/spec-audit.mjs +135 -19
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/audit/SKILL.md +2 -2
- package/src/plugin/skills/doctor/SKILL.md +6 -2
- package/src/plugin/skills/implement/SKILL.md +8 -2
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/spec/model-prompt.md +1 -1
- package/src/setup/labels.mjs +6 -5
- package/src/templates/issue/spec-template.md +7 -2
- package/src/templates/skill/SKILL.md +14 -4
package/package.json
CHANGED
|
@@ -528,3 +528,31 @@ export async function linkProjectToRepo(token, projectId, repositoryId) {
|
|
|
528
528
|
}
|
|
529
529
|
`, { projectId, repositoryId });
|
|
530
530
|
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Cota do GraphQL restante (item 5 do rfc/plano-hardening-agentes-2026-08.md).
|
|
534
|
+
*
|
|
535
|
+
* `rateLimit` é o único campo da API que consultar **não custa pontos** — é
|
|
536
|
+
* assim que o `doctor` consegue medir ANTES de gastar, em vez de descobrir o
|
|
537
|
+
* esgotamento no meio de uma varredura de sub-issues.
|
|
538
|
+
*
|
|
539
|
+
* @returns {Promise<{limit:number, remaining:number, resetAt:string}|null>}
|
|
540
|
+
* null em qualquer falha (rede, token sem escopo) — best-effort.
|
|
541
|
+
*/
|
|
542
|
+
export async function getRateLimit(token) {
|
|
543
|
+
const client = makeClient(token);
|
|
544
|
+
try {
|
|
545
|
+
const result = await client(`
|
|
546
|
+
query RateLimit {
|
|
547
|
+
rateLimit {
|
|
548
|
+
limit
|
|
549
|
+
remaining
|
|
550
|
+
resetAt
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
`);
|
|
554
|
+
return result.rateLimit || null;
|
|
555
|
+
} catch {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
}
|
package/src/api/github-rest.mjs
CHANGED
|
@@ -359,6 +359,19 @@ export async function deleteLabel(token, owner, repo, name) {
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
// Quantas issues ABERTAS têm uma label — usado pelo `update` para não apagar
|
|
363
|
+
// uma label `spec-wave:*` órfã que ainda está em uso (item 7 do
|
|
364
|
+
// rfc/plano-hardening-agentes-2026-08.md): apagar uma label no GitHub a
|
|
365
|
+
// remove de TODA issue que a tem, o que é irreversível. PRs entram na mesma
|
|
366
|
+
// listagem de issues da API REST — filtrados fora, não são o que se quer contar.
|
|
367
|
+
export async function countOpenIssuesWithLabel(token, owner, repo, labelName) {
|
|
368
|
+
const octokit = makeOctokit(token);
|
|
369
|
+
const issues = await octokit.paginate('GET /repos/{owner}/{repo}/issues', {
|
|
370
|
+
owner, repo, labels: labelName, state: 'open', per_page: 100,
|
|
371
|
+
});
|
|
372
|
+
return issues.filter(i => !i.pull_request).length;
|
|
373
|
+
}
|
|
374
|
+
|
|
362
375
|
/**
|
|
363
376
|
* Apaga a ref de uma branch.
|
|
364
377
|
*
|
|
@@ -515,6 +528,17 @@ export async function getPR(token, owner, repo, prNumber) {
|
|
|
515
528
|
return res.data;
|
|
516
529
|
}
|
|
517
530
|
|
|
531
|
+
// Mensagens de commit (assunto + corpo) de um PR — usado por `--verify-commits`
|
|
532
|
+
// (implement) e pela conferência do `merge` para ler os git trailer de
|
|
533
|
+
// Story/Task sem precisar de checkout local (lib/commit-trailers.mjs).
|
|
534
|
+
export async function listPullRequestCommitMessages(token, owner, repo, prNumber) {
|
|
535
|
+
const octokit = makeOctokit(token);
|
|
536
|
+
const commits = await octokit.paginate(octokit.rest.pulls.listCommits, {
|
|
537
|
+
owner, repo, pull_number: prNumber, per_page: 100,
|
|
538
|
+
});
|
|
539
|
+
return commits.map(c => c.commit.message);
|
|
540
|
+
}
|
|
541
|
+
|
|
518
542
|
// `ref` é opcional (compatível com os chamadores de 4 argumentos): o modo PR do
|
|
519
543
|
// `update` precisa ler o .spec-wave.json na BASE, não no que a API escolher.
|
|
520
544
|
export async function getFileContent(token, owner, repo, path, ref) {
|
package/src/cli.mjs
CHANGED
|
@@ -213,6 +213,7 @@ export function buildProgram() {
|
|
|
213
213
|
.option('--no-skill-in-pr', 'Força manter a skill dos agentes fora do Pull Request')
|
|
214
214
|
.option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
|
|
215
215
|
.option('--yes', 'Aplica sem pedir confirmação')
|
|
216
|
+
.option('--force-labels', 'Remove labels spec-wave:* órfãs mesmo com issues abertas usando-as (padrão: mantém)')
|
|
216
217
|
.action(async (options) => {
|
|
217
218
|
const { update } = await import('./commands/update.mjs');
|
|
218
219
|
await update(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
@@ -423,6 +424,7 @@ Docs: https://astratech-net-br.github.io/spec-wave-cli/guia/qa/`);
|
|
|
423
424
|
.option('--feature-dir <path>', 'Caminho do docs/features/<slug> (sobrescreve a resolução automática)')
|
|
424
425
|
.option('--dry-run', 'Monta o contexto e imprime o comando sem executar o spec-kit')
|
|
425
426
|
.option('--refresh', 'Ignora o cache local (sub-issues/dependências) e reconsulta a API')
|
|
427
|
+
.option('--verify-commits', 'Confere se os commits recentes trazem o trailer Spec-Wave-Story/Tasks (best-effort, nunca bloqueia)')
|
|
426
428
|
.action(async (issue, options) => {
|
|
427
429
|
const { implement } = await import('./commands/implement.mjs');
|
|
428
430
|
await implement({ issue, ...options }).catch(err => { console.error(err.message); process.exit(1); });
|
|
@@ -521,6 +523,7 @@ para incluí-lo na consulta, ou --sync para gravá-lo em definitivo nos artefato
|
|
|
521
523
|
.option('--dry-run', 'Mostra o que seria instalado sem gravar')
|
|
522
524
|
.option('--force', 'Reinstala o binário e regrava a config')
|
|
523
525
|
.option('--yes', 'Modo não-interativo')
|
|
526
|
+
.option('--queue <nome>', 'No --install/--build: fila deste daemon (padrão: dev-agent). Para uma trilha extra, declare-a em devAgent.queues do .spec-wave.json antes — senão update/doctor não reconhecem a label')
|
|
524
527
|
.action(async (options) => {
|
|
525
528
|
const { devAgent } = await import('./commands/dev-agent.mjs');
|
|
526
529
|
await devAgent(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
@@ -529,9 +532,10 @@ para incluí-lo na consulta, ou --sync para gravá-lo em definitivo nos artefato
|
|
|
529
532
|
program
|
|
530
533
|
.command('doctor')
|
|
531
534
|
.description('Diagnostica a configuração do spec-wave no repositório atual')
|
|
532
|
-
.
|
|
535
|
+
.option('--deep', 'Confere também as Tasks de cada decomposição aplicada (1 chamada GraphQL a mais por Story; padrão: só Stories)')
|
|
536
|
+
.action(async (options) => {
|
|
533
537
|
const { doctor } = await import('./commands/doctor.mjs');
|
|
534
|
-
await doctor().catch(err => { console.error(err.message); process.exit(1); });
|
|
538
|
+
await doctor(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
535
539
|
});
|
|
536
540
|
|
|
537
541
|
// O mapa por tema no fim do --help: 29 comandos em lista plana dizem O QUE
|
package/src/commands/audit.mjs
CHANGED
|
@@ -144,7 +144,11 @@ export async function audit({ milestone: milestoneArg, critique = false, json =
|
|
|
144
144
|
for (const issue of targetIssues) {
|
|
145
145
|
if (detectIssueType(issue) !== 'Feature' || issue.state === 'closed') continue;
|
|
146
146
|
const docs = featureDocPaths(root, issue, 'Feature');
|
|
147
|
-
|
|
147
|
+
// `state` distingue "não existe" ('missing') de "não deu para verificar
|
|
148
|
+
// agora" ('unknown' — rede/permissão) — item 8 do rfc/plano-hardening-
|
|
149
|
+
// agentes-2026-08.md: descartar isso é a mesma lacuna que o preflight já
|
|
150
|
+
// corrigiu (PRs #65/#66) e que este comando herdou sem herdar a distinção.
|
|
151
|
+
const { content, state } = await loadArtifact({
|
|
148
152
|
token, owner, repo, root, base,
|
|
149
153
|
pathRel: docs.spec.rel, doc: 'spec', issueNumber: issue.number,
|
|
150
154
|
});
|
|
@@ -154,6 +158,7 @@ export async function audit({ milestone: milestoneArg, critique = false, json =
|
|
|
154
158
|
slug: slugify(issue.title),
|
|
155
159
|
milestone: { number: milestone.number, title: milestone.title, due_on: milestone.due_on ?? null },
|
|
156
160
|
spec: content,
|
|
161
|
+
specState: state,
|
|
157
162
|
});
|
|
158
163
|
}
|
|
159
164
|
|
|
@@ -257,7 +262,11 @@ export async function audit({ milestone: milestoneArg, critique = false, json =
|
|
|
257
262
|
return saida;
|
|
258
263
|
}
|
|
259
264
|
|
|
265
|
+
// 'sem-alvo' ganha marca própria: sem ela, `dependsOn: []` fica idêntico ao
|
|
266
|
+
// de uma Feature 'vazia' (que diz "Nenhuma" de propósito) — item 3a do rfc/
|
|
267
|
+
// plano-hardening-agentes-2026-08.md.
|
|
260
268
|
const grafoLinhas = resultado.grafo.map(g => {
|
|
269
|
+
if (g.secao === 'sem-alvo') return ` #${g.number} ⟨sem arestas legíveis⟩`;
|
|
261
270
|
const deps = g.dependsOn.length ? ` ← depende de ${g.dependsOn.map(d => `#${d}`).join(', ')}` : '';
|
|
262
271
|
return ` #${g.number}${deps}`;
|
|
263
272
|
});
|
|
@@ -76,13 +76,21 @@ function render(template, vars) {
|
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
78
|
* config.toml do agente a partir do .spec-wave.json. Nunca escreve token.
|
|
79
|
+
*
|
|
80
|
+
* `queue` é o SUFIXO da trilha (sem `spec-wave:`) — `dev-agent` (default) ou
|
|
81
|
+
* uma trilha extra (`dev-agent-b`, `dev-agent-c`, …) para rodar mais de um
|
|
82
|
+
* daemon em paralelo. A label correspondente só é criada/preservada pelo
|
|
83
|
+
* `update`/`doctor` se estiver declarada em `devAgent.queues` do
|
|
84
|
+
* `.spec-wave.json` — ver `queueLabels` em `config.mjs` e o item 7 do
|
|
85
|
+
* rfc/plano-hardening-agentes-2026-08.md.
|
|
79
86
|
*/
|
|
80
|
-
export function renderAgentConfig({ owner, repo }) {
|
|
87
|
+
export function renderAgentConfig({ owner, repo, queue = 'dev-agent' }) {
|
|
88
|
+
const queueLabel = queue === 'dev-agent' ? LABEL_DEV_AGENT : `spec-wave:${queue}`;
|
|
81
89
|
return `# spec-wave-agent — gerado por \`spec-wave dev-agent --install\`
|
|
82
90
|
# Schema completo: https://github.com/${AGENT_REPO}#configuração
|
|
83
91
|
|
|
84
92
|
repo = "${owner}/${repo}"
|
|
85
|
-
queue_label = "${
|
|
93
|
+
queue_label = "${queueLabel}"
|
|
86
94
|
|
|
87
95
|
# Defaults do agente (descomente para ajustar):
|
|
88
96
|
#poll_interval_secs = 60 # consulta à fila quando ocioso
|
|
@@ -362,6 +370,14 @@ async function install(options) {
|
|
|
362
370
|
|
|
363
371
|
/** Config + serviço + avisos finais — comum a --install e --build. */
|
|
364
372
|
async function finishSetup({ cfg, home, binPath, configPath, options, configAction, servicePath }) {
|
|
373
|
+
const queue = options.queue || 'dev-agent';
|
|
374
|
+
if (queue !== 'dev-agent' && !(cfg.devAgent?.queues || []).includes(queue)) {
|
|
375
|
+
p.log.warn(
|
|
376
|
+
`Fila "${queue}" não está em devAgent.queues no ${CONFIG_FILE} deste repo — ` +
|
|
377
|
+
`\`spec-wave update\`/\`doctor\` não vão reconhecer a label spec-wave:${queue} e um ` +
|
|
378
|
+
'update de rotina pode removê-la, esvaziando esta trilha. Adicione-a e rode `spec-wave update` antes.'
|
|
379
|
+
);
|
|
380
|
+
}
|
|
365
381
|
if (configAction === 'manter') {
|
|
366
382
|
p.log.info(`Config mantida: ${configPath} já existe (use --force para regerar).`);
|
|
367
383
|
} else if (existsSync(configPath) && !options.yes) {
|
|
@@ -370,12 +386,12 @@ async function finishSetup({ cfg, home, binPath, configPath, options, configActi
|
|
|
370
386
|
p.log.info('Config mantida.');
|
|
371
387
|
} else {
|
|
372
388
|
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
373
|
-
writeFileSync(configPath, renderAgentConfig(cfg), 'utf-8');
|
|
389
|
+
writeFileSync(configPath, renderAgentConfig({ ...cfg, queue: options.queue || 'dev-agent' }), 'utf-8');
|
|
374
390
|
p.log.success(`Config regravada em ${configPath}.`);
|
|
375
391
|
}
|
|
376
392
|
} else {
|
|
377
393
|
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
378
|
-
writeFileSync(configPath, renderAgentConfig(cfg), 'utf-8');
|
|
394
|
+
writeFileSync(configPath, renderAgentConfig({ ...cfg, queue: options.queue || 'dev-agent' }), 'utf-8');
|
|
379
395
|
p.log.success(`Config criada em ${chalk.cyan(configPath)} (repo ${cfg.owner}/${cfg.repo}).`);
|
|
380
396
|
}
|
|
381
397
|
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -14,16 +14,20 @@ import {
|
|
|
14
14
|
resolveToken, verifyTokenScopes, describeTokenSource, activeGhAccount,
|
|
15
15
|
tokenMismatchWarning, parseActiveAccount,
|
|
16
16
|
} from '../api/auth.mjs';
|
|
17
|
-
import { getProjectSnapshot,
|
|
17
|
+
import { getProjectSnapshot, listProjectItems, getRateLimit } from '../api/github-graphql.mjs';
|
|
18
18
|
import { getRepoVariable } from '../api/github-rest.mjs';
|
|
19
19
|
import {
|
|
20
20
|
CONFIG_FILE, WORKFLOW_FILES, ARTIFACT_WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER,
|
|
21
21
|
AI_PROVIDERS, STATUS_OPTIONS,
|
|
22
22
|
RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, LABEL_QA_READY, MODEL_LABEL_PREFIX,
|
|
23
|
+
DEV_AGENT_QUEUE_PREFIX,
|
|
23
24
|
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
|
|
24
25
|
modelLabels, STAGE_QA,
|
|
25
26
|
} from '../config.mjs';
|
|
26
27
|
import { slugify } from '../lib/slugify.mjs';
|
|
28
|
+
import { cachedSubIssues } from '../lib/story-graph.mjs';
|
|
29
|
+
import { affordCheck } from '../lib/rate-budget.mjs';
|
|
30
|
+
import { resolveCacheTtl } from '../lib/net-cache.mjs';
|
|
27
31
|
import { QA_PLAN_FILE } from '../lib/qa-plan-doc.mjs';
|
|
28
32
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
29
33
|
import { configuredMode, describeModeState, EXECUTION_VARIABLE } from '../lib/execution-mode.mjs';
|
|
@@ -98,6 +102,31 @@ async function checkToken(ctx) {
|
|
|
98
102
|
}
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
// Item 5 do rfc/plano-hardening-agentes-2026-08.md: medir a cota ANTES de
|
|
106
|
+
// gastar. `rateLimit` não custa pontos, então isto é de graça — e é o que
|
|
107
|
+
// falta para o doctor explicar "pulei este check por causa da cota" em vez de
|
|
108
|
+
// piorar um esgotamento em silêncio (seis "!" sem dizer a causa comum).
|
|
109
|
+
// Guardada em `ctx.rateLimit` para os checks caros consultarem depois.
|
|
110
|
+
async function checkRateLimit(ctx) {
|
|
111
|
+
const name = 'Cota da API (GraphQL)';
|
|
112
|
+
if (!ctx.token) {
|
|
113
|
+
return { name, status: 'warn', detail: 'Sem token — cota não verificável.' };
|
|
114
|
+
}
|
|
115
|
+
ctx.rateLimit = await getRateLimit(ctx.token);
|
|
116
|
+
if (!ctx.rateLimit) {
|
|
117
|
+
return { name, status: 'warn', detail: 'Não foi possível consultar a cota agora.' };
|
|
118
|
+
}
|
|
119
|
+
const { remaining, limit, resetAt } = ctx.rateLimit;
|
|
120
|
+
const reset = new Date(resetAt).toLocaleTimeString('pt-BR');
|
|
121
|
+
const status = remaining < 500 ? 'warn' : 'ok';
|
|
122
|
+
return {
|
|
123
|
+
name,
|
|
124
|
+
status,
|
|
125
|
+
detail: `${remaining}/${limit} pontos restantes (reset ${reset}).` +
|
|
126
|
+
(status === 'warn' ? ' Cota baixa — checks caros do doctor podem ser pulados; veja as notas deles.' : ''),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
async function checkScopes(ctx) {
|
|
102
131
|
const name = 'Escopos do token';
|
|
103
132
|
if (!ctx.token) {
|
|
@@ -355,7 +384,9 @@ export function suggestModelAliases({ provider = undefined, aliases = null } = {
|
|
|
355
384
|
return { missing, snippet: `"modelAliases": ${JSON.stringify(merged, null, 2)}` };
|
|
356
385
|
}
|
|
357
386
|
|
|
358
|
-
export function inspectBoardHygiene({
|
|
387
|
+
export function inspectBoardHygiene({
|
|
388
|
+
boardStages = null, repoLabels = null, modelAliases = null, devAgentQueues = null,
|
|
389
|
+
} = {}) {
|
|
359
390
|
const canonical = STATUS_OPTIONS.map(s => s.name);
|
|
360
391
|
const known = new Set(canonical);
|
|
361
392
|
const retired = new Map(RETIRED_STAGES.map(s => [s.name, s]));
|
|
@@ -377,11 +408,21 @@ export function inspectBoardHygiene({ boardStages = null, repoLabels = null, mod
|
|
|
377
408
|
// ai.modelAliases — aí o aviso é verdadeiro (label aponta para apelido que não
|
|
378
409
|
// existe mais). Sem config (doctor sem acesso ao arquivo), nenhuma label de
|
|
379
410
|
// modelo é acusada: melhor calar do que mandar apagar o que funciona.
|
|
380
|
-
|
|
411
|
+
//
|
|
412
|
+
// Mesma regra para `spec-wave:dev-agent-<sufixo>` (trilha extra do
|
|
413
|
+
// dev-agent, item 7 do rfc/plano-hardening-agentes-2026-08.md): sem
|
|
414
|
+
// devAgentQueues em mãos, nenhuma label desse padrão é acusada — apagá-la
|
|
415
|
+
// esvazia a fila inteira. A fila DEFAULT (`spec-wave:dev-agent`, sem
|
|
416
|
+
// sufixo) já está em ALL_LABELS e nunca cai neste ramo.
|
|
417
|
+
const wantedLabels = allLabelsFor(
|
|
418
|
+
modelAliases ? { modelAliases } : undefined,
|
|
419
|
+
devAgentQueues ? { queues: devAgentQueues } : undefined
|
|
420
|
+
);
|
|
381
421
|
const knownLabels = new Set(wantedLabels.map(l => l.name));
|
|
382
422
|
const orphanLabels = repoLabels
|
|
383
423
|
? repoLabels.filter(n => n.startsWith('spec-wave:') && !knownLabels.has(n)
|
|
384
|
-
&& (modelAliases !== null || !n.startsWith(MODEL_LABEL_PREFIX))
|
|
424
|
+
&& (modelAliases !== null || !n.startsWith(MODEL_LABEL_PREFIX))
|
|
425
|
+
&& (devAgentQueues !== null || !n.startsWith(DEV_AGENT_QUEUE_PREFIX)))
|
|
385
426
|
: [];
|
|
386
427
|
// Ausentes saem em duas listas: uma label do fluxo que falta é um repo
|
|
387
428
|
// desatualizado; uma `spec-wave:model:<apelido>` que falta é um apelido
|
|
@@ -457,7 +498,11 @@ async function checkBoardHygiene(ctx) {
|
|
|
457
498
|
}
|
|
458
499
|
|
|
459
500
|
const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels, missingModelLabels } =
|
|
460
|
-
inspectBoardHygiene({
|
|
501
|
+
inspectBoardHygiene({
|
|
502
|
+
boardStages, repoLabels,
|
|
503
|
+
modelAliases: cfg?.ai?.modelAliases ?? null,
|
|
504
|
+
devAgentQueues: cfg?.devAgent?.queues ?? null,
|
|
505
|
+
});
|
|
461
506
|
|
|
462
507
|
const notes = [];
|
|
463
508
|
let status = 'ok';
|
|
@@ -831,6 +876,26 @@ export function decompositionDrift(doc, atual = new Map()) {
|
|
|
831
876
|
return out;
|
|
832
877
|
}
|
|
833
878
|
|
|
879
|
+
/**
|
|
880
|
+
* Prepara o doc para `decompositionDrift` quando a árvore fetchada não desceu
|
|
881
|
+
* ao nível de Task (função PURA — item 5 do rfc/plano-hardening-agentes-
|
|
882
|
+
* 2026-08.md, a checagem sem `--deep` só busca as Stories).
|
|
883
|
+
*
|
|
884
|
+
* Comparar um doc com Tasks contra uma árvore sem Tasks reportaria CADA Task
|
|
885
|
+
* como "rescopada ou apagada" por engano — o dado não foi buscado, não é que
|
|
886
|
+
* ele sumiu. Tirar as Tasks dos dois lados evita o falso positivo sem mentir
|
|
887
|
+
* sobre profundidade: quem lê a nota do doctor sabe que só Stories foram
|
|
888
|
+
* conferidas.
|
|
889
|
+
*
|
|
890
|
+
* @param {object} doc documento parseado (parseDecompositionDoc)
|
|
891
|
+
* @param {{deep: boolean}} params
|
|
892
|
+
* @returns {object} doc, ou uma cópia com `stories[].tasks` esvaziado
|
|
893
|
+
*/
|
|
894
|
+
export function decompositionDocForDrift(doc, { deep }) {
|
|
895
|
+
if (deep) return doc;
|
|
896
|
+
return { ...doc, stories: (doc.stories || []).map(s => ({ ...s, tasks: [] })) };
|
|
897
|
+
}
|
|
898
|
+
|
|
834
899
|
// Lê os decomposition.md aplicados e confere se ainda descrevem a árvore real.
|
|
835
900
|
// Best-effort e sempre warn: divergência aqui é informação para o humano, não
|
|
836
901
|
// falha de configuração — rescopar issues à mão é legítimo, esquecer de dizer
|
|
@@ -876,16 +941,43 @@ async function checkDecompositions(ctx) {
|
|
|
876
941
|
continue;
|
|
877
942
|
}
|
|
878
943
|
|
|
879
|
-
//
|
|
944
|
+
// Orçamento: 1 GraphQL para as Stories da Feature + (só com --deep) 1 por
|
|
945
|
+
// Story, para as Tasks. Sem isso este check sozinho podia custar 1 REST +
|
|
946
|
+
// 1 GraphQL por Feature + 1 GraphQL POR STORY da milestone inteira, sem
|
|
947
|
+
// teto nem cache — o padrão que dependency-map.mjs existe para eliminar.
|
|
948
|
+
const storyCount = (doc.stories || []).length;
|
|
949
|
+
const estimated = 1 + (ctx.deep ? storyCount : 0);
|
|
950
|
+
const budget = affordCheck({
|
|
951
|
+
remaining: ctx.rateLimit?.remaining ?? null,
|
|
952
|
+
estimated,
|
|
953
|
+
resetAt: ctx.rateLimit?.resetAt ?? null,
|
|
954
|
+
});
|
|
955
|
+
if (!budget.run) {
|
|
956
|
+
status = 'warn';
|
|
957
|
+
notes.push(`${rel}: árvore de #${doc.issueNumber} não verificada — ${budget.motivo}`);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Monta a árvore real: sub-issues da Feature (cacheadas, TTL de
|
|
962
|
+
// cache.ttlSec) e, só com --deep, as de cada Story também.
|
|
880
963
|
const atual = new Map();
|
|
881
964
|
try {
|
|
882
965
|
const issue = await makeOctokit(ctx.token).rest.issues.get({
|
|
883
966
|
owner: ctx.cfg.owner, repo: ctx.cfg.repo, issue_number: doc.issueNumber,
|
|
884
967
|
});
|
|
885
|
-
const
|
|
968
|
+
const ttlSec = resolveCacheTtl(ctx.cfg);
|
|
969
|
+
const { subs } = await cachedSubIssues({
|
|
970
|
+
token: ctx.token, owner: ctx.cfg.owner, repo: ctx.cfg.repo, root, ttlSec,
|
|
971
|
+
parent: { number: doc.issueNumber, nodeId: issue.data.node_id },
|
|
972
|
+
});
|
|
886
973
|
for (const s of subs) {
|
|
887
974
|
atual.set(s.number, { title: s.title, state: s.state, stateReason: s.stateReason });
|
|
888
|
-
|
|
975
|
+
if (!ctx.deep) continue;
|
|
976
|
+
const { subs: tasks } = await cachedSubIssues({
|
|
977
|
+
token: ctx.token, owner: ctx.cfg.owner, repo: ctx.cfg.repo, root, ttlSec,
|
|
978
|
+
parent: { number: s.number, nodeId: s.nodeId },
|
|
979
|
+
}).catch(() => ({ subs: [] }));
|
|
980
|
+
for (const t of tasks) {
|
|
889
981
|
atual.set(t.number, { title: t.title, state: t.state, stateReason: t.stateReason });
|
|
890
982
|
}
|
|
891
983
|
}
|
|
@@ -894,14 +986,15 @@ async function checkDecompositions(ctx) {
|
|
|
894
986
|
continue;
|
|
895
987
|
}
|
|
896
988
|
|
|
897
|
-
const drift = decompositionDrift(doc, atual);
|
|
989
|
+
const drift = decompositionDrift(decompositionDocForDrift(doc, { deep: ctx.deep }), atual);
|
|
990
|
+
const profundidade = ctx.deep ? '' : ' (só Stories — rode com --deep para conferir as Tasks também)';
|
|
898
991
|
if (drift.length === 0) {
|
|
899
|
-
notes.push(`${rel}: confere com as ${atual.size} issue(s) de #${doc.issueNumber}.`);
|
|
992
|
+
notes.push(`${rel}: confere com as ${atual.size} issue(s) de #${doc.issueNumber}${profundidade}.`);
|
|
900
993
|
continue;
|
|
901
994
|
}
|
|
902
995
|
status = 'warn';
|
|
903
996
|
notes.push(
|
|
904
|
-
`${rel} descreve um desenho diferente do que existe hoje:\n` +
|
|
997
|
+
`${rel} descreve um desenho diferente do que existe hoje${profundidade}:\n` +
|
|
905
998
|
drift.map(d => ` - ${d}`).join('\n') + '\n' +
|
|
906
999
|
' Atualize o arquivo ou marque-o como histórico — aplicado ele é registro, e registro ' +
|
|
907
1000
|
'errado parece confiável.'
|
|
@@ -1427,11 +1520,11 @@ async function checkWorkflows(ctx) {
|
|
|
1427
1520
|
|
|
1428
1521
|
// ── Comando ─────────────────────────────────────────────────────────────────
|
|
1429
1522
|
|
|
1430
|
-
export async function doctor() {
|
|
1523
|
+
export async function doctor(options = {}) {
|
|
1431
1524
|
p.intro(chalk.bold('spec-wave doctor'));
|
|
1432
1525
|
|
|
1433
1526
|
// Contexto compartilhado entre os checks (token, config, resultados parciais).
|
|
1434
|
-
const ctx = { cwd: process.cwd() };
|
|
1527
|
+
const ctx = { cwd: process.cwd(), deep: !!options.deep };
|
|
1435
1528
|
// Procurado subindo na árvore: rodar o doctor de um subdiretório deve
|
|
1436
1529
|
// diagnosticar o repositório, não reportar "não inicializado".
|
|
1437
1530
|
ctx.configPath = findConfigPath(ctx.cwd);
|
|
@@ -1448,6 +1541,7 @@ export async function doctor() {
|
|
|
1448
1541
|
|
|
1449
1542
|
const checks = [
|
|
1450
1543
|
checkToken,
|
|
1544
|
+
checkRateLimit,
|
|
1451
1545
|
checkScopes,
|
|
1452
1546
|
checkGhAccount,
|
|
1453
1547
|
checkConfig,
|