@spec-wave/cli 0.30.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.
@@ -1,33 +1,44 @@
1
1
  // Ordena as Stories pelas dependências (topológica, Kahn —
2
- // ver orderStories em src/lib/dependencies.mjs). As dependências vêm de duas
3
- // fontes, mescladas: a linha "Depende de: #N" no corpo da Story e a relação
4
- // nativa blocked_by do GitHub.
2
+ // ver orderStories em src/lib/dependencies.mjs).
5
3
  //
6
- // Dependência para FORA da Feature não entra na ordenação (não como saber
7
- // onde a Story de outra Feature entra nesta sequência), mas é EXIBIDA: era lida,
8
- // descartada em silêncio por orderStories e ignorada de novo no aviso de
9
- // fora-de-ordem. Quem montava o mapa de trabalho de dois devs tinha que
10
- // reconstruir isso à mão, lendo os decomposition.md.
4
+ // De onde vêm as ARESTAS (lib/story-graph.mjs): do dependency-map.json
5
+ // commitado (escrito pelo decompose --apply), do decomposition.md aplicado e
6
+ // das linhas "Depende de: #N" do corpo das sub-issues fontes locais/grátis,
7
+ // sempre unidas. `--remote` acrescenta o blocked_by nativo da API (1 chamada
8
+ // por Story a única fonte que enxerga aresta criada só pela UI); `--sync`
9
+ // grava essa leitura de volta no decomposition.md e regenera o mapa, para que
10
+ // as consultas seguintes voltem a ser locais.
11
11
  //
12
- // Dois escopos: `order <feature>` (uma Feature) e `order` sem argumento — o
13
- // mapa de TODAS as Features com trabalho no board, onde as dependências entre
14
- // Features deixam de ser "externas" e entram no mesmo grafo.
12
+ // A Etapa de cada Story vem de UM `listProjectItems` paginado, cacheado com
13
+ // TTL (staleness sempre visível) nunca mais do par addProjectItem +
14
+ // getItemSingleSelectValue por Story, que além de custar 2 chamadas por item
15
+ // era MUTAÇÃO em caminho de leitura.
15
16
  //
16
- // Comando LOCAL rodado pelo dev no terminal. owner/repo vêm da env
17
- // GITHUB_REPOSITORY quando existir, senão do .spec-wave.json (gravado pelo init).
17
+ // Dois escopos: `order <feature>` (uma Feature) e `order` sem argumento o
18
+ // mapa de TODAAS as Features com trabalho no board (`--milestone` filtra).
19
+ // `--json` emite o contrato estável de lib/dependency-map.mjs para consumo
20
+ // programático (dev-agent e afins), sem ANSI para filtrar.
21
+ import { execSync } from 'node:child_process';
22
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import path from 'node:path';
18
24
  import * as p from '@clack/prompts';
19
25
  import chalk from 'chalk';
20
26
  import { resolveToken } from '../api/auth.mjs';
21
27
  import { getIssue, listBlockedBy } from '../api/github-rest.mjs';
22
- import {
23
- addProjectItem, listSubIssues, getItemSingleSelectValue, listProjectItems,
24
- } from '../api/github-graphql.mjs';
28
+ import { listSubIssues } from '../api/github-graphql.mjs';
25
29
  import { detectIssueType } from '../lib/issue-type.mjs';
26
30
  import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
31
+ import { loadProjectConfig, selectOpenFeatures } from '../lib/board.mjs';
32
+ import { resolveRepoContext, loadConfig } from '../lib/project-root.mjs';
33
+ import { featureDocPaths } from '../lib/doc-paths.mjs';
34
+ import { parseDecompositionDoc, renderDecompositionDoc } from '../lib/decomposition-doc.mjs';
27
35
  import {
28
- loadProjectConfig, resolveField, indexBoardItems, selectOpenFeatures,
29
- } from '../lib/board.mjs';
30
- import { resolveRepoContext } from '../lib/project-root.mjs';
36
+ buildDependencyMap, syncDocDependencies, mergeDependencyEdges,
37
+ filterItemsByMilestone, renderOrderJson, stalenessNotice,
38
+ } from '../lib/dependency-map.mjs';
39
+ import { loadFeatureStories, loadBoardSnapshot, externalIssueInfo } from '../lib/story-graph.mjs';
40
+ import { resolveCacheTtl } from '../lib/net-cache.mjs';
41
+ import { pushWithRebase } from '../lib/git-retry.mjs';
31
42
  import { CONFIG_FILE, STAGE_ORDER, STAGE_DEVELOPMENT, STAGE_DONE } from '../config.mjs';
32
43
 
33
44
  /**
@@ -96,18 +107,26 @@ export function renderBoardOrder({ sorted, byNumber, featureOf, stageOf }) {
96
107
  }).join('\n');
97
108
  }
98
109
 
110
+ // A nota de origem das arestas: quem lê a saída precisa saber SE o blocked_by
111
+ // só-de-UI entrou — é a única aresta que as fontes locais não enxergam.
112
+ function edgesNote(origins, { remote }) {
113
+ const fontes = [...new Set(origins)].join(' · ');
114
+ if (remote) return `Arestas: ${fontes} (inclui blocked_by remoto).`;
115
+ return `Arestas: ${fontes} — blocked_by criado SÓ pela UI não entra (use --remote, ou --sync para gravá-lo no doc).`;
116
+ }
117
+
99
118
  /**
100
119
  * `spec-wave order` sem argumento: o grafo de TODAS as Features com trabalho.
101
120
  *
102
121
  * Fonte do conjunto é o BOARD, não os arquivos — `decomposition.md` registra o
103
- * que foi proposto, a issue é o que existe. Uma query paginada de itens do
104
- * project resolve Etapa e tipo de todo mundo de uma vez, no lugar do laço de
105
- * duas chamadas por Story que o modo de uma Feature ainda usa.
122
+ * que foi proposto, a issue é o que existe. (As ARESTAS, essas sim, vêm dos
123
+ * artefatos locais ver o cabeçalho.) Uma query paginada de itens do project
124
+ * resolve Etapa e tipo de todo mundo de uma vez.
106
125
  */
107
- async function orderBoard({ token, owner, repo }) {
126
+ async function orderBoard({ token, owner, repo, root, ttlSec, opts }) {
108
127
  const { project, error: projectError } = loadProjectConfig();
109
128
  if (projectError) {
110
- p.log.error(
129
+ emitError(opts,
111
130
  `${projectError} — sem board não há como saber quais Features têm trabalho. ` +
112
131
  'Use `spec-wave order <feature>` para ordenar uma Feature específica.'
113
132
  );
@@ -115,50 +134,60 @@ async function orderBoard({ token, owner, repo }) {
115
134
  return;
116
135
  }
117
136
 
118
- let itens;
137
+ let snapshot;
119
138
  try {
120
- itens = await listProjectItems(token, project.id);
139
+ snapshot = await loadBoardSnapshot({ token, project, root, refresh: opts.refresh, ttlSec });
121
140
  } catch (err) {
122
- p.log.error(`Não foi possível ler os itens do Project: ${err.message}`);
141
+ emitError(opts, `Não foi possível ler os itens do Project: ${err.message}`);
123
142
  process.exitCode = 1;
124
143
  return;
125
144
  }
126
- const indice = indexBoardItems(itens);
127
- const features = selectOpenFeatures(itens);
145
+
146
+ let features = selectOpenFeatures(snapshot.items);
147
+ if (opts.milestone) {
148
+ features = filterItemsByMilestone(features, opts.milestone);
149
+ if (features.length === 0) {
150
+ emitInfo(opts, `Nenhuma Feature aberta na milestone "${opts.milestone}" — nada a ordenar.`);
151
+ return;
152
+ }
153
+ }
128
154
  if (features.length === 0) {
129
- p.log.info(`Nenhuma Feature aberta fora de "${STAGE_DONE}" no board — nada a ordenar.`);
130
- p.outro('Nada a fazer.');
155
+ emitInfo(opts, `Nenhuma Feature aberta fora de "${STAGE_DONE}" no board — nada a ordenar.`);
131
156
  return;
132
157
  }
133
158
 
159
+ const indice = snapshot.index;
134
160
  const enriched = [];
135
161
  const featureOf = new Map();
136
162
  const stageOf = new Map();
137
163
  const semStories = [];
164
+ const origins = new Set();
165
+ const warnings = [];
138
166
  for (const feature of features) {
139
- const subs = await listSubIssues(token, feature.nodeId).catch(() => []);
140
- const stories = subs.filter(sub =>
141
- detectIssueType({ title: sub.title, labels: sub.labels }) === 'Story');
142
- if (stories.length === 0) { semStories.push(feature); continue; }
143
- for (const story of stories) {
167
+ let carga;
168
+ try {
169
+ carga = await loadFeatureStories({
170
+ token, owner, repo, root, feature,
171
+ remote: opts.remote, refresh: opts.refresh, ttlSec,
172
+ });
173
+ } catch (err) {
174
+ warnings.push(`Stories da Feature #${feature.number} não legíveis: ${err.message}.`);
175
+ continue;
176
+ }
177
+ origins.add(carga.origin.edges);
178
+ warnings.push(...carga.warnings);
179
+ if (carga.stories.length === 0) { semStories.push(feature); continue; }
180
+ for (const story of carga.stories) {
144
181
  // Story concluída não entra: o mapa é do que falta fazer.
145
182
  if (indice.get(story.number)?.fields?.Etapa === STAGE_DONE) continue;
146
- const fromBody = parseDependencies(story.body);
147
- const fromBlockedBy = (await listBlockedBy(token, owner, repo, story.number).catch(() => []))
148
- .map(b => b.number);
149
- enriched.push({
150
- number: story.number,
151
- title: story.title,
152
- dependsOn: [...new Set([...fromBody, ...fromBlockedBy])],
153
- });
183
+ enriched.push({ number: story.number, title: story.title, dependsOn: story.dependsOn });
154
184
  featureOf.set(story.number, feature);
155
185
  stageOf.set(story.number, indice.get(story.number)?.fields?.Etapa || null);
156
186
  }
157
187
  }
158
188
 
159
189
  if (enriched.length === 0) {
160
- p.log.info(`${features.length} Feature(s) aberta(s), nenhuma com Story pendente.`);
161
- p.outro('Nada a fazer.');
190
+ emitInfo(opts, `${features.length} Feature(s) aberta(s), nenhuma com Story pendente.`);
162
191
  return;
163
192
  }
164
193
 
@@ -166,6 +195,21 @@ async function orderBoard({ token, owner, repo }) {
166
195
  const { order: sorted, cycle, external } = orderStories(
167
196
  enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
168
197
 
198
+ const stale = snapshot.fromCache ? stalenessNotice(snapshot.fetchedAt) : null;
199
+
200
+ if (opts.json) {
201
+ console.log(JSON.stringify(renderOrderJson({
202
+ sorted, byNumber, featureOf, stageOf, external, cycle,
203
+ meta: {
204
+ edges: [...origins].join(','),
205
+ stages: snapshot.fromCache ? 'cache' : 'board',
206
+ fetchedAt: snapshot.fetchedAt,
207
+ warnings: [...warnings, ...(stale ? [stale] : [])],
208
+ },
209
+ }), null, 2));
210
+ return;
211
+ }
212
+
169
213
  if (cycle.length > 0) {
170
214
  p.log.warn(
171
215
  chalk.yellow.bold('⚠ CICLO DE DEPENDÊNCIAS detectado!') + '\n' +
@@ -194,57 +238,94 @@ async function orderBoard({ token, owner, repo }) {
194
238
  if (semStories.length > 0) {
195
239
  p.log.info(`Sem Stories (ainda não decompostas): ${semStories.map(f => `#${f.number}`).join(', ')}.`);
196
240
  }
241
+ for (const w of warnings) p.log.warn(w);
242
+ p.log.info(chalk.dim(edgesNote(origins, opts)));
243
+ if (stale) p.log.warn(chalk.yellow(stale));
197
244
  p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
198
245
  }
199
246
 
200
- export async function order({ feature: featureArg } = {}) {
247
+ // Saída de erro/info compatível com --json (que não pode misturar clack com o
248
+ // contrato): em json, erros vão para stderr e o stdout fica só com o JSON.
249
+ function emitError(opts, message) {
250
+ if (opts.json) console.error(message);
251
+ else p.log.error(message);
252
+ }
253
+ function emitInfo(opts, message) {
254
+ if (opts.json) {
255
+ console.log(JSON.stringify(renderOrderJson({ meta: { warnings: [message] } }), null, 2));
256
+ } else {
257
+ p.log.info(message);
258
+ p.outro('Nada a fazer.');
259
+ }
260
+ }
261
+
262
+ export async function order({
263
+ feature: featureArg, json = false, milestone = null, remote = false,
264
+ refresh = false, sync = false,
265
+ } = {}) {
201
266
  const semArgumento = featureArg === undefined || featureArg === null
202
267
  || String(featureArg).trim() === '';
203
268
  const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
269
+ const opts = { json, milestone, remote, refresh, sync };
204
270
  if (!semArgumento && (!Number.isInteger(featureNumber) || featureNumber <= 0)) {
205
- p.log.error(`Feature inválida: "${featureArg}". Use o número da issue, ex.: 12 ou #12.`);
271
+ emitError(opts, `Feature inválida: "${featureArg}". Use o número da issue, ex.: 12 ou #12.`);
206
272
  process.exitCode = 1;
207
273
  return;
208
274
  }
209
275
 
210
276
  const { owner, repo } = resolveRepoContext();
211
277
  if (!owner || !repo) {
212
- p.log.error(
278
+ emitError(opts,
213
279
  'Não foi possível determinar owner/repo.\n' +
214
280
  `Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
215
281
  );
216
282
  process.exitCode = 1;
217
283
  return;
218
284
  }
285
+ const { config, root } = loadConfig();
286
+ const ttlSec = resolveCacheTtl(config);
219
287
 
220
288
  let token;
221
289
  try {
222
290
  token = await resolveToken();
223
291
  } catch (err) {
224
- p.log.error(err.message);
292
+ emitError(opts, err.message);
225
293
  process.exitCode = 1;
226
294
  return;
227
295
  }
228
296
 
297
+ if (sync) {
298
+ if (!root) {
299
+ emitError(opts, `--sync precisa de um clone com ${CONFIG_FILE} (o doc é reescrito no disco).`);
300
+ process.exitCode = 1;
301
+ return;
302
+ }
303
+ if (!json) p.intro(chalk.bold('spec-wave order --sync — dependências da API de volta ao doc'));
304
+ return await syncDependencies({
305
+ token, owner, repo, root, opts,
306
+ featureNumber: semArgumento ? null : featureNumber,
307
+ });
308
+ }
309
+
229
310
  if (semArgumento) {
230
- p.intro(chalk.bold('spec-wave order — todas as Features com trabalho'));
231
- return await orderBoard({ token, owner, repo });
311
+ if (!json) p.intro(chalk.bold('spec-wave order — todas as Features com trabalho'));
312
+ return await orderBoard({ token, owner, repo, root, ttlSec, opts });
232
313
  }
233
314
 
234
- p.intro(chalk.bold(`spec-wave order #${featureNumber}`));
315
+ if (!json) p.intro(chalk.bold(`spec-wave order #${featureNumber}`));
235
316
 
236
- // 1. Lê a Feature e valida o tipo.
317
+ // 1. Lê a Feature e valida o tipo (1 chamada — a issue-alvo nunca vem de cache).
237
318
  let featureIssue;
238
319
  try {
239
320
  featureIssue = await getIssue(token, owner, repo, featureNumber);
240
321
  } catch (err) {
241
- p.log.error(`Não foi possível ler a issue #${featureNumber}: ${err.message}`);
322
+ emitError(opts, `Não foi possível ler a issue #${featureNumber}: ${err.message}`);
242
323
  process.exitCode = 1;
243
324
  return;
244
325
  }
245
326
  const type = detectIssueType(featureIssue);
246
327
  if (type !== 'Feature') {
247
- p.log.error(
328
+ emitError(opts,
248
329
  `\`spec-wave order\` só aceita issues do tipo Feature. ` +
249
330
  `Issue #${featureNumber} é do tipo ${type || 'desconhecido'} (${featureIssue.title}).`
250
331
  );
@@ -252,53 +333,47 @@ export async function order({ feature: featureArg } = {}) {
252
333
  return;
253
334
  }
254
335
 
255
- // 2. Sub-issues da Feature as Stories entram na ordenação.
256
- let subs;
336
+ // 2-3. Stories + arestas fontes locais (mapa/doc/body), `--remote` opcional.
337
+ let carga;
257
338
  try {
258
- subs = await listSubIssues(token, featureIssue.node_id);
339
+ carga = await loadFeatureStories({
340
+ token, owner, repo, root,
341
+ feature: { number: featureNumber, nodeId: featureIssue.node_id, title: featureIssue.title },
342
+ remote, refresh, ttlSec,
343
+ });
259
344
  } catch (err) {
260
- p.log.error(`Não foi possível listar as sub-issues da Feature #${featureNumber}: ${err.message}`);
345
+ emitError(opts, `Não foi possível listar as sub-issues da Feature #${featureNumber}: ${err.message}`);
261
346
  process.exitCode = 1;
262
347
  return;
263
348
  }
264
- const stories = subs.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
349
+ const stories = carga.stories;
265
350
  if (stories.length === 0) {
266
- p.log.info(`Feature #${featureNumber} não tem Stories (sub-issues) — nada a ordenar. Rode \`spec-wave decompose\` antes.`);
267
- p.outro('Nada a fazer.');
351
+ emitInfo(opts, `Feature #${featureNumber} não tem Stories (sub-issues) — nada a ordenar. Rode \`spec-wave decompose\` antes.`);
268
352
  return;
269
353
  }
270
-
271
- // 3. Dependências de cada Story: linha "Depende de: #N" do corpo (buscando o
272
- // corpo via getIssue quando listSubIssues não o trouxer) mesclada com a
273
- // relação nativa blocked_by (falha na API → sem bloqueios, não interrompe).
274
- const enriched = await Promise.all(stories.map(async (s) => {
275
- let body = s.body;
276
- if (!body) {
277
- body = (await getIssue(token, owner, repo, s.number).catch(() => null))?.body || '';
278
- }
279
- const fromBody = parseDependencies(body);
280
- const fromBlockedBy = (await listBlockedBy(token, owner, repo, s.number).catch(() => []))
281
- .map(b => b.number);
282
- const dependsOn = [...new Set([...fromBody, ...fromBlockedBy])];
283
- return { number: s.number, title: s.title, nodeId: s.nodeId, dependsOn };
354
+ const enriched = stories.map(s => ({
355
+ number: s.number, title: s.title, nodeId: s.nodeId, dependsOn: s.dependsOn,
284
356
  }));
285
357
 
286
- // 4. Etapa atual de cada Story no board (falha em qualquer leitura → '—').
358
+ // 4. Etapa de cada Story snapshot único do board, com TTL (nunca mais a
359
+ // mutação addProjectItem por Story: além do custo, ADICIONAVA a issue ao
360
+ // Project como efeito colateral de uma leitura).
287
361
  const stageOf = new Map();
362
+ let boardMeta = { stages: null, fetchedAt: null, stale: null };
288
363
  const { project, error: projectError } = loadProjectConfig();
289
364
  if (projectError) {
290
- p.log.warn(`${projectError} — Etapas do board não consultadas.`);
365
+ if (!json) p.log.warn(`${projectError} — Etapas do board não consultadas.`);
291
366
  } else {
292
- const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
293
- if (etapaField?.id) {
294
- await Promise.all(enriched.map(async (s) => {
295
- try {
296
- const itemId = await addProjectItem(token, project.id, s.nodeId);
297
- stageOf.set(s.number, await getItemSingleSelectValue(token, itemId, etapaField.id));
298
- } catch {
299
- stageOf.set(s.number, null);
300
- }
301
- }));
367
+ try {
368
+ const snapshot = await loadBoardSnapshot({ token, project, root, refresh, ttlSec });
369
+ for (const s of enriched) stageOf.set(s.number, snapshot.index.get(s.number)?.fields?.Etapa ?? null);
370
+ boardMeta = {
371
+ stages: snapshot.fromCache ? 'cache' : 'board',
372
+ fetchedAt: snapshot.fetchedAt,
373
+ stale: snapshot.fromCache ? stalenessNotice(snapshot.fetchedAt) : null,
374
+ };
375
+ } catch (err) {
376
+ if (!json) p.log.warn(`Etapas do board não consultáveis agora: ${err.message}`);
302
377
  }
303
378
  }
304
379
 
@@ -307,17 +382,34 @@ export async function order({ feature: featureArg } = {}) {
307
382
  const { order: sorted, cycle, external } = orderStories(
308
383
  enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
309
384
 
310
- // Uma leitura por issue externa DISTINTA — o estado dela é o que diz se o
311
- // bloqueio ainda vale.
385
+ // Uma leitura por issue externa DISTINTA — cacheada (`issue-<n>`): é exibição,
386
+ // não decisão de escrita.
312
387
  const foraDoConjunto = [...new Set([...external.values()].flat())];
313
388
  const externasInfo = new Map();
314
389
  await Promise.all(foraDoConjunto.map(async (n) => {
315
- const issue = await getIssue(token, owner, repo, n).catch(() => null);
316
- externasInfo.set(n, issue
317
- ? { title: issue.title, state: issue.state, aberta: issue.state === 'open' }
318
- : { title: '(não foi possível ler)', state: null, aberta: true });
390
+ externasInfo.set(n, await externalIssueInfo({ token, owner, repo, root, number: n, refresh, ttlSec }));
319
391
  }));
320
392
 
393
+ if (opts.json) {
394
+ console.log(JSON.stringify(renderOrderJson({
395
+ sorted,
396
+ byNumber,
397
+ featureOf: new Map(enriched.map(s => [s.number, { number: featureNumber, title: featureIssue.title }])),
398
+ stageOf, external, cycle,
399
+ meta: {
400
+ edges: carga.origin.edges,
401
+ stages: boardMeta.stages,
402
+ fetchedAt: boardMeta.fetchedAt,
403
+ warnings: [
404
+ ...carga.warnings,
405
+ ...milestoneMismatches(featureIssue.milestone, stories),
406
+ ...(boardMeta.stale ? [boardMeta.stale] : []),
407
+ ],
408
+ },
409
+ }), null, 2));
410
+ return;
411
+ }
412
+
321
413
  if (cycle.length > 0) {
322
414
  p.log.warn(
323
415
  chalk.yellow.bold('⚠ CICLO DE DEPENDÊNCIAS detectado!') + '\n' +
@@ -341,7 +433,8 @@ export async function order({ feature: featureArg } = {}) {
341
433
  const detalhe = deps.map(d => {
342
434
  const info = externasInfo.get(d);
343
435
  const marca = info?.aberta ? chalk.yellow('aberta') : chalk.green('fechada');
344
- return `#${d} (${marca}) ${info?.title || ''}`.trim();
436
+ const cacheTag = info?.fromCache ? chalk.dim(' (cache)') : '';
437
+ return `#${d} (${marca}${cacheTag}) ${info?.title || ''}`.trim();
345
438
  }).join('\n ');
346
439
  return ` #${n} ${byNumber.get(n)?.title || ''}\n ← ${detalhe}`;
347
440
  });
@@ -392,5 +485,166 @@ export async function order({ feature: featureArg } = {}) {
392
485
  );
393
486
  }
394
487
 
488
+ for (const w of carga.warnings) p.log.warn(w);
489
+ p.log.info(chalk.dim(edgesNote([carga.origin.edges], opts)));
490
+ if (boardMeta.stale) p.log.warn(chalk.yellow(boardMeta.stale));
395
491
  p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
396
492
  }
493
+
494
+ // ── `order --sync`: API → decomposition.md + dependency-map.json ────────────
495
+
496
+ // Commit LOCAL escopado aos artefatos reescritos + push com retry — mesmo
497
+ // padrão do bug.md do qa (git-retry). Falha de push degrada para aviso: os
498
+ // arquivos já estão certos no disco.
499
+ function commitSyncedFiles(root, filesRel, featureNumber) {
500
+ try {
501
+ for (const rel of filesRel) {
502
+ execSync(`git add -- ${JSON.stringify(rel)}`, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
503
+ }
504
+ execSync(
505
+ `git commit -m ${JSON.stringify(`docs: sync de dependências da Feature #${featureNumber} [spec-wave]`)} ` +
506
+ filesRel.map(r => `-- ${JSON.stringify(r)}`).join(' '),
507
+ { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
508
+ return true;
509
+ } catch (err) {
510
+ p.log.warn(`⚠️ Arquivos escritos, mas o commit falhou (${String(err.message).split('\n')[0]}) — commite-os você mesmo.`);
511
+ return false;
512
+ }
513
+ }
514
+
515
+ async function syncDependencies({ token, owner, repo, root, opts, featureNumber }) {
516
+ // Alvos: a Feature pedida, ou as Features abertas do board (--milestone filtra).
517
+ let targets = [];
518
+ if (featureNumber) {
519
+ const issue = await getIssue(token, owner, repo, featureNumber).catch((err) => {
520
+ emitError(opts, `Não foi possível ler a issue #${featureNumber}: ${err.message}`);
521
+ return null;
522
+ });
523
+ if (!issue) { process.exitCode = 1; return; }
524
+ if (detectIssueType(issue) !== 'Feature') {
525
+ emitError(opts, `--sync só aceita Feature. Issue #${featureNumber} é ${detectIssueType(issue) || 'de tipo desconhecido'}.`);
526
+ process.exitCode = 1;
527
+ return;
528
+ }
529
+ targets = [{ number: featureNumber, nodeId: issue.node_id, title: issue.title }];
530
+ } else {
531
+ const { project, error } = loadProjectConfig();
532
+ if (error) {
533
+ emitError(opts, `${error} — sem board não há como enumerar as Features. Use \`order <feature> --sync\`.`);
534
+ process.exitCode = 1;
535
+ return;
536
+ }
537
+ // Sync é reconciliação: sempre parte de um snapshot FRESCO.
538
+ const snapshot = await loadBoardSnapshot({ token, project, root, refresh: true });
539
+ let features = selectOpenFeatures(snapshot.items);
540
+ if (opts.milestone) features = filterItemsByMilestone(features, opts.milestone);
541
+ targets = features;
542
+ }
543
+
544
+ let synced = 0;
545
+ let untouched = 0;
546
+ for (const feature of targets) {
547
+ const resultado = await syncOneFeature({ token, owner, repo, root, feature });
548
+ if (resultado === 'synced') synced += 1;
549
+ else if (resultado === 'unchanged') untouched += 1;
550
+ }
551
+ p.outro(`${chalk.green('✓')} sync: ${synced} Feature(s) atualizada(s), ${untouched} já em dia.`);
552
+ }
553
+
554
+ async function syncOneFeature({ token, owner, repo, root, feature }) {
555
+ let paths;
556
+ try {
557
+ paths = featureDocPaths(root, { title: feature.title }, 'Feature');
558
+ } catch {
559
+ p.log.warn(`#${feature.number}: título sem slug utilizável — pulada.`);
560
+ return 'skipped';
561
+ }
562
+ const docAbs = path.join(root, paths.decomposition.rel);
563
+ if (!existsSync(docAbs)) {
564
+ p.log.info(chalk.dim(`#${feature.number}: sem ${paths.decomposition.rel} — nada a sincronizar.`));
565
+ return 'skipped';
566
+ }
567
+ let doc;
568
+ try {
569
+ doc = parseDecompositionDoc(readFileSync(docAbs, 'utf-8'));
570
+ } catch (err) {
571
+ p.log.warn(`#${feature.number}: ${paths.decomposition.rel} ilegível (${err.message}) — pulada.`);
572
+ return 'skipped';
573
+ }
574
+ if (!doc.appliedAt || doc.kind !== 'stories') {
575
+ p.log.info(chalk.dim(`#${feature.number}: decomposition.md ainda é proposta (ou é de RFC) — nada a sincronizar.`));
576
+ return 'skipped';
577
+ }
578
+
579
+ // Leitura VIVA: body ∪ blocked_by, sempre frescos — sync que lê cache
580
+ // reconciliaria o doc com ele mesmo.
581
+ const subs = (await listSubIssues(token, feature.nodeId || feature.node_id))
582
+ .filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
583
+ const remoteEdges = [];
584
+ for (const s of subs) {
585
+ const blockedBy = (await listBlockedBy(token, owner, repo, s.number).catch(() => []))
586
+ .map(b => b.number);
587
+ remoteEdges.push({ number: s.number, dependsOn: blockedBy });
588
+ }
589
+ const liveDeps = mergeDependencyEdges(
590
+ subs.map(s => ({ number: s.number, dependsOn: parseDependencies(s.body) })),
591
+ remoteEdges,
592
+ );
593
+
594
+ const { doc: updated, changed, changes } = syncDocDependencies(doc, liveDeps);
595
+
596
+ // O mapa é SEMPRE regenerado ("forçar update dos artefatos"): mesmo sem
597
+ // aresta nova, ele pode nem existir ainda (repo de antes desta versão).
598
+ const mapRel = paths['dependency-map'].rel;
599
+ const mapAbs = path.join(root, mapRel);
600
+ const generatedAt = new Date().toISOString();
601
+ const mapStories = (updated.stories || []).map((s, i) => ({
602
+ number: s.issue,
603
+ title: s.title,
604
+ dependsOn: [...new Set([
605
+ ...(s.dependsOn || []).map(idx => updated.stories[idx]?.issue).filter(Number.isInteger),
606
+ ...(s.dependsOnIssues || []),
607
+ ])],
608
+ tasks: (s.tasks || []).map(t => t.issue).filter(Number.isInteger),
609
+ }));
610
+ const mapAntes = existsSync(mapAbs) ? readFileSync(mapAbs, 'utf-8') : null;
611
+ const mapConteudo = `${JSON.stringify(buildDependencyMap({
612
+ featureNumber: feature.number, stories: mapStories, source: 'sync', generatedAt,
613
+ }), null, 2)}\n`;
614
+ // Compara sem o generatedAt: regravar SÓ por causa do timestamp criaria um
615
+ // commit de ruído por sync.
616
+ const semTimestamp = (txt) => String(txt ?? '').replace(/"generatedAt": "[^"]*"/, '"generatedAt": "-"')
617
+ .replace(/"source": "[^"]*"/, '"source": "-"');
618
+ const mapMudou = semTimestamp(mapAntes) !== semTimestamp(mapConteudo);
619
+
620
+ if (!changed && !mapMudou) {
621
+ p.log.info(chalk.dim(`#${feature.number}: dependências já em dia.`));
622
+ return 'unchanged';
623
+ }
624
+
625
+ const escritos = [];
626
+ if (changed) {
627
+ writeFileSync(docAbs, renderDecompositionDoc(updated));
628
+ escritos.push(paths.decomposition.rel);
629
+ for (const c of changes) {
630
+ p.log.info(
631
+ `#${feature.number} · Story #${c.story}: Depende de ` +
632
+ `${c.before.map(n => `#${n}`).join(', ') || '—'} → ${c.after.map(n => `#${n}`).join(', ') || '—'}`
633
+ );
634
+ }
635
+ }
636
+ if (mapMudou) {
637
+ writeFileSync(mapAbs, mapConteudo);
638
+ escritos.push(mapRel);
639
+ }
640
+
641
+ const committed = commitSyncedFiles(root, escritos, feature.number);
642
+ if (committed) {
643
+ const push = await pushWithRebase({ cwd: root });
644
+ if (!push.ok) {
645
+ p.log.warn(`⚠️ Sync commitado, mas o push falhou: ${String(push.error).split('\n')[0]} — pushe manualmente.`);
646
+ }
647
+ }
648
+ p.log.success(`#${feature.number}: ${escritos.join(' + ')} atualizado(s).`);
649
+ return 'synced';
650
+ }