@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.
@@ -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, addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
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, resolveField } from '../lib/board.mjs';
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
- * A leitura passa por `addProjectItem`, que é MUTAÇÃO adiciona o item ao
267
- * Project se ainda não estiver lá. Por isso a consulta inteira é pulada em
268
- * dry-run: um dry-run não pode ter efeito remoto.
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 {{dryRun?:boolean, warnOnSkip?:boolean}} [opts]
279
+ * @param {{warnOnSkip?:boolean}} [opts]
273
280
  * @returns {Promise<Map<number, string|null>>}
274
281
  */
275
- async function fetchStagesOf(token, items, { dryRun = false, warnOnSkip = false } = {}) {
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
- const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
295
- if (!etapaField?.id) return stageOf;
296
- await Promise.all(items.map(async (s) => {
297
- try {
298
- const itemId = await addProjectItem(token, project.id, s.nodeId);
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
- // F1. Stories (sub-issues) da Feature.
581
- const subs = await listSubIssues(token, feature.node_id).catch(() => []);
582
- const stories = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
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 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
- // F2. Dependências de cada Story: linha "Depende de:" do body ∪ blocked_by nativo.
599
- const enriched = await Promise.all(stories.map(async (s) => {
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, { dryRun, warnOnSkip: true });
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 listSubIssues(token, s.nodeId).catch(() => []);
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, { dryRun });
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', {
@@ -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, listBlockedBy,
24
+ getIssue, getPR, getRepoDefaultBranch, updatePRBase, mergePR, deleteBranch,
25
25
  } from '../api/github-rest.mjs';
26
- import { listSubIssues, listIssuePullRequests } from '../api/github-graphql.mjs';
26
+ import { listIssuePullRequests } from '../api/github-graphql.mjs';
27
27
  import { detectIssueType } from '../lib/issue-type.mjs';
28
- import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
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({ feature: featureArg, yes = false, dryRun = false, keepBranches = false } = {}) {
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
- let subs;
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
- subs = await listSubIssues(token, featureIssue.node_id);
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 = subs.filter(x => detectIssueType({ title: x.title, labels: x.labels }) === 'Story');
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 = await Promise.all(stories.map(async (st) => {
185
- const fromBody = parseDependencies(st.body);
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(