@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.
@@ -33,8 +33,9 @@ import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
33
33
  import { parseQaPlanDoc, resolveTargetScenarios } from '../lib/qa-plan-doc.mjs';
34
34
  import {
35
35
  qaExecutionGate, greenTargetStage, storyCanAdvance, featureCanAdvanceQa,
36
- extractRegressionSection, renderQaCommand, buildQaContext,
36
+ extractRegressionSection, renderQaCommand, buildQaContext, qaProcessEnv,
37
37
  } from '../lib/qa-exec.mjs';
38
+ import { pushWithRebase } from '../lib/git-retry.mjs';
38
39
  import {
39
40
  aggregateVerdict, validateQaResults, combineWithPrevious, parseLastQaReport,
40
41
  renderQaReport, renderQaBugDoc, qaOriginMarker, matchesQaOrigin, shortSha,
@@ -426,7 +427,10 @@ export async function qaRun({ issue: issueArg, only: onlyArg, severity: severity
426
427
  execSync(command, {
427
428
  stdio: 'inherit',
428
429
  cwd: root || process.cwd(),
429
- env: { ...process.env, ...(config?.qa?.env || {}) },
430
+ // §3.1 da spec-qa-lead: a env do PROCESSO vence a `qa.env`, chave a chave
431
+ // — é assim que o `qa-lead` injeta o endereço real de cada container sem
432
+ // mexer no arquivo versionado.
433
+ env: qaProcessEnv(config?.qa?.env, process.env),
430
434
  });
431
435
  } catch (err) {
432
436
  executorFailed = err;
@@ -455,13 +459,33 @@ export async function qaRun({ issue: issueArg, only: onlyArg, severity: severity
455
459
  return { verdict: null, error: 'resultados-invalidos' };
456
460
  }
457
461
 
462
+ // D-QAL5: plano que MUDOU durante a execução invalida a corrida — o veredito
463
+ // seria de cenários que já não são os do arquivo. A checagem é da CLI (re-lê
464
+ // o plano do disco), não do executor: não depende de cooperação de agente.
465
+ {
466
+ const planAbs = path.join(root || process.cwd(), qaPlanRel);
467
+ let planNow = null;
468
+ try {
469
+ planNow = readFileSync(planAbs, 'utf-8');
470
+ } catch { /* ausente agora — cai no erro abaixo */ }
471
+ if (planNow === null || shortSha(planNow) !== shortSha(planContent)) {
472
+ process.exitCode = 1;
473
+ console.error(chalk.red(
474
+ `\n\`${qaPlanRel}\` mudou durante a execução (planSha ${shortSha(planContent)} → ` +
475
+ `${planNow === null ? 'arquivo ausente' : shortSha(planNow)}) — o veredito seria de um plano ` +
476
+ 'que não existe mais. Nada foi movido, nenhum Bug criado. Rode de novo com o plano estável.'
477
+ ));
478
+ return { verdict: null, error: 'plano-mudou' };
479
+ }
480
+ }
481
+
458
482
  // ── Estado acumulado e veredito ────────────────────────────────────────────
459
483
  const { combined, pendingNumbers } = combineWithPrevious({
460
484
  executed: results,
461
485
  previous: previous.results,
462
486
  allNumbers: scenarios.map(s => s.numero),
463
487
  });
464
- const verdict = aggregateVerdict(combined);
488
+ let verdict = aggregateVerdict(combined);
465
489
  const planSha = shortSha(planContent);
466
490
  const headSha = headShaOf(root);
467
491
  const byNumero = new Map(scenarios.map(s => [s.numero, s]));
@@ -473,11 +497,38 @@ export async function qaRun({ issue: issueArg, only: onlyArg, severity: severity
473
497
  featureStories,
474
498
  };
475
499
 
500
+ let bugs = [];
476
501
  if (verdict === 'fail') {
477
502
  // Reprovar o re-teste de um BUG não abre outro Bug: o vermelho significa
478
503
  // que o fix não segurou — o defeito é o mesmo, e o registro é o relatório.
479
504
  const failed = type === 'Bug' ? [] : results.filter(r => r.verdict === 'fail');
480
- const bugs = await openBugsForFailures({ ...outcome, failed, byNumero, severity, headSha, qaPlanRel });
505
+ const opened = await openBugsForFailures({ ...outcome, failed, byNumero, severity, headSha, qaPlanRel });
506
+ bugs = opened.bugs;
507
+ // §3.2 da spec-qa-lead: NUNCA reporte `fail` sem o Bug correspondente. O
508
+ // push do bug.md rejeitado após o teto de retries degrada o cenário para
509
+ // `blocked`/`outro` — o defeito pode existir, mas sem o registro publicado
510
+ // ele viraria um vermelho sem Bug, que a próxima execução recriaria.
511
+ if (opened.pushFailures.length > 0) {
512
+ for (const pf of opened.pushFailures) {
513
+ const idx = combined.findIndex(r => r.numero === pf.numero && !r.carried);
514
+ if (idx !== -1) {
515
+ combined[idx] = {
516
+ ...combined[idx],
517
+ verdict: 'blocked',
518
+ blockedReason: 'outro',
519
+ evidencia: `commit do bug.md rejeitado: ${pf.error}`.slice(0, 400),
520
+ };
521
+ }
522
+ }
523
+ verdict = aggregateVerdict(combined);
524
+ console.log(chalk.yellow(
525
+ `Cenário(s) ${opened.pushFailures.map(f => f.numero).join(', ')} degradado(s) para ` +
526
+ `blocked/outro (push do bug.md rejeitado) — veredito recalculado: ${chalk.bold(verdict)}.`
527
+ ));
528
+ }
529
+ }
530
+
531
+ if (verdict === 'fail') {
481
532
  await stayInQa(outcome);
482
533
  await postReport({
483
534
  ...outcome, run, verdict, planSha, headSha, combined, pendingNumbers, bugs,
@@ -551,13 +602,21 @@ async function stayInQa({ projectToken, board, issue }) {
551
602
  }
552
603
 
553
604
  // Vermelho: um Bug por cenário reprovado — filho da STORY dona do cenário
554
- // (nunca da Feature), com bug.md determinístico commitado e `bug-approved`
555
- // aplicada (exceção documentada — spec §2.1).
605
+ // (nunca da Feature), com bug.md determinístico commitado E PUBLICADO (push com
606
+ // retry — §3.2 da spec-qa-lead) e `bug-approved` aplicada (exceção documentada
607
+ // — spec §2.1). O bug.md vai para o remoto ANTES de a issue nascer: rodando num
608
+ // container do `qa-lead`, um commit que fica local morre com o container, e um
609
+ // Bug sem bug.md quebra o guard de idempotência da próxima corrida.
610
+ //
611
+ // Devolve também `pushFailures`: cenários cujo bug.md não pôde ser publicado —
612
+ // o chamador os degrada para `blocked`/`outro`, porque `fail` sem Bug é o
613
+ // estado que a spec proíbe.
556
614
  async function openBugsForFailures({
557
615
  token, projectToken, owner, repo, root, board,
558
616
  failed, byNumero, severity, headSha, qaPlanRel, feature, type, number,
559
617
  }) {
560
618
  const bugs = [];
619
+ const pushFailures = [];
561
620
  for (const r of failed) {
562
621
  const scenario = byNumero.get(r.numero);
563
622
  if (!scenario) continue;
@@ -568,6 +627,7 @@ async function openBugsForFailures({
568
627
  story = await getIssue(token, owner, repo, storyNumber);
569
628
  } catch (err) {
570
629
  console.warn(chalk.yellow(`⚠️ Não consegui ler a Story #${storyNumber} (${err.message}) — Bug do cenário ${r.numero} NÃO criado.`));
630
+ pushFailures.push({ numero: r.numero, error: `Story #${storyNumber} ilegível: ${err.message}` });
571
631
  continue;
572
632
  }
573
633
 
@@ -607,11 +667,43 @@ async function openBugsForFailures({
607
667
  const milestone = story.milestone?.number ?? undefined; // herda do pai (D5)
608
668
  const labels = ['[BUG]', severity, bugOriginLabel('qa')].filter(Boolean);
609
669
 
670
+ // bug.md determinístico, escrito, commitado e PUBLICADO antes de a issue
671
+ // nascer (§3.2): se o push não segura nem com retry, o Bug NÃO é criado e o
672
+ // cenário será degradado para blocked/outro pelo chamador.
673
+ const { fileRel, fileAbs, dirAbs } = bugDocPaths(title, root);
674
+ try {
675
+ mkdirSync(dirAbs, { recursive: true });
676
+ writeFileSync(fileAbs, renderQaBugDoc({
677
+ title: resumo, scenario, evidence: r.evidencia, severity, headSha,
678
+ featureNumber: feature?.number ?? null,
679
+ }));
680
+ } catch (err) {
681
+ console.warn(chalk.yellow(`⚠️ Não consegui escrever ${fileRel}: ${err.message} — Bug do cenário ${scenario.numero} NÃO criado.`));
682
+ pushFailures.push({ numero: r.numero, error: `escrita do ${fileRel} falhou: ${err.message}` });
683
+ continue;
684
+ }
685
+ const committed = commitLocalFile(root, fileRel,
686
+ `docs: gera ${fileRel} (reprovação de QA, cenário ${scenario.numero} da Story #${storyNumber}) [spec-wave]`);
687
+ if (!committed) {
688
+ pushFailures.push({ numero: r.numero, error: `commit do ${fileRel} falhou` });
689
+ continue;
690
+ }
691
+ const push = await pushWithRebase({ cwd: root || process.cwd() });
692
+ if (!push.ok) {
693
+ console.warn(chalk.yellow(
694
+ `⚠️ Push do ${fileRel} rejeitado após ${push.attempts} tentativa(s): ` +
695
+ `${String(push.error).split('\n')[0]} — Bug do cenário ${scenario.numero} NÃO criado.`
696
+ ));
697
+ pushFailures.push({ numero: r.numero, error: String(push.error).split('\n')[0] });
698
+ continue;
699
+ }
700
+
610
701
  let created;
611
702
  try {
612
703
  created = await createIssue(token, owner, repo, title, bodyLines.join('\n'), labels, { milestone });
613
704
  } catch (err) {
614
705
  console.warn(chalk.yellow(`⚠️ Falha ao criar o Bug do cenário ${scenario.numero}: ${err.message}`));
706
+ pushFailures.push({ numero: r.numero, error: `criação da issue falhou: ${err.message}` });
615
707
  continue;
616
708
  }
617
709
  console.log(`Bug #${created.number} criado para o cenário ${scenario.numero} (filho da Story #${storyNumber}).`);
@@ -638,34 +730,21 @@ async function openBugsForFailures({
638
730
  }
639
731
  }
640
732
 
641
- // bug.md determinístico, escrito e COMMITADO localmente (exceção §2.1) +
642
- // `bug-approved`: reprodução, esperado/obtido e regressão são a execução
643
- // observada — regerar por IA só introduziria alucinação.
644
- const { fileRel, fileAbs, dirAbs } = bugDocPaths(created.title || title, root);
645
- try {
646
- mkdirSync(dirAbs, { recursive: true });
647
- writeFileSync(fileAbs, renderQaBugDoc({
648
- title: resumo, scenario, evidence: r.evidencia, severity, headSha,
649
- featureNumber: feature?.number ?? null,
650
- }));
651
- commitLocalFile(root, fileRel,
652
- `docs: gera ${fileRel} (reprovação de QA, issue ${created.number}) [spec-wave]`);
653
- } catch (err) {
654
- console.warn(chalk.yellow(`⚠️ Não consegui escrever ${fileRel}: ${err.message}`));
655
- }
733
+ // `bug-approved` direto: reprodução, esperado/obtido e regressão são a
734
+ // execução observada regerar por IA introduziria alucinação.
656
735
  await addLabel(token, owner, repo, created.number, LABEL_BUG_APPROVED).catch(() => {});
657
736
 
658
737
  await commentOnIssue(token, owner, repo, created.number,
659
738
  `🧪 **Bug aberto pela reprovação de QA** — ${scenario.anchor} da Story #${storyNumber}.\n\n` +
660
- `📄 \`${fileRel}\` foi escrito e commitado com as seis seções preenchidas a partir do cenário ` +
661
- 'e da saída real da execução (exceção documentada à regra do `spec-wave:bug` — conteúdo ' +
662
- 'determinístico de uma execução observada, sem IA).\n\n' +
739
+ `📄 \`${fileRel}\` foi escrito, commitado e publicado com as seis seções preenchidas a partir ` +
740
+ 'do cenário e da saída real da execução (exceção documentada à regra do `spec-wave:bug` — ' +
741
+ 'conteúdo determinístico de uma execução observada, sem IA).\n\n' +
663
742
  `Após o fix, re-teste: \`npx @spec-wave/cli@latest qa ${storyNumber} --only ${scenario.numero}\``
664
743
  ).catch(() => {});
665
744
 
666
745
  bugs.push({ number: created.number, cenario: scenario.numero, existing: false });
667
746
  }
668
- return bugs;
747
+ return { bugs, pushFailures };
669
748
  }
670
749
 
671
750
  // Verde: aprova e move — Story → 📋 Homologação, Bug → 🚀 Deploy, Feature
package/src/config.mjs CHANGED
@@ -357,6 +357,21 @@ export const LABEL_QA = 'spec-wave:qa';
357
357
  export const LABEL_QA_READY = 'spec-wave:qa-ready';
358
358
  export const LABEL_QA_APPROVED = 'spec-wave:qa-approved';
359
359
 
360
+ // Motivos de bloqueio de um cenário de QA (rfc/spec-qa-lead.md, D-QAL6).
361
+ // Enum FECHADO: o executor escolhe um destes no `blockedReason` do arquivo de
362
+ // resultados; `outro` exige evidência em texto livre não vazia. O agregado por
363
+ // motivo (`blockedByReason` do relatório de trilha) é o que diz se o problema
364
+ // é ambiente, massa de dados ou dependência — por isso não aceita texto livre.
365
+ export const QA_BLOCKED_REASONS = [
366
+ 'ambiente',
367
+ 'setup-falhou',
368
+ 'massa-de-dados',
369
+ 'dependencia-nao-entregue',
370
+ 'bloqueado-por-bug',
371
+ 'credencial',
372
+ 'outro',
373
+ ];
374
+
360
375
  // Desfecho da triagem do PM (RFC-004 §4.1). São mutuamente exclusivas: um bug
361
376
  // triado foi aceito, rejeitado ou marcado como duplicata.
362
377
  export const LABEL_TRIAGED = 'spec-wave:triaged';
@@ -85,7 +85,7 @@ export function isStaleArtifactBranch(status, openPr) {
85
85
  */
86
86
  export async function publishArtifact({
87
87
  token, owner, repo, doc, issueNumber, issueTitle = '', issueUrl = '',
88
- pathRel, content, base = null, nextLabel = null, deps = {},
88
+ pathRel, content, base = null, nextLabel = null, extraFiles = [], deps = {},
89
89
  }) {
90
90
  const api = {
91
91
  getRepoDefaultBranch, compareBranches, findOpenPR, commitFilesToBranch,
@@ -123,7 +123,10 @@ export async function publishArtifact({
123
123
  commit = await api.commitFilesToBranch(token, owner, repo, {
124
124
  branch,
125
125
  base: alvo,
126
- files: [{ path: pathRel, content }],
126
+ // `extraFiles`: derivados que viajam no MESMO commit do documento (ex.: o
127
+ // dependency-map.json do apply) — dois commits/PRs para um só evento
128
+ // dariam dois estados intermediários para o mesmo fato.
129
+ files: [{ path: pathRel, content }, ...extraFiles],
127
130
  message: artifactCommitMessage({ doc, issueNumber, pathRel }),
128
131
  });
129
132
  } catch (err) {
package/src/lib/board.mjs CHANGED
@@ -4,6 +4,16 @@
4
4
  import { addProjectItem, setItemSingleSelect, getSingleSelectField, getItemSingleSelectValue } from '../api/github-graphql.mjs';
5
5
  import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS, WORK_ITEM_TYPES, STAGE_DONE } from '../config.mjs';
6
6
  import { loadConfig } from './project-root.mjs';
7
+ import { invalidateCache } from './net-cache.mjs';
8
+
9
+ // Toda ESCRITA de board passa por advanceToStage/setItemStage/setItemStatus —
10
+ // invalidar aqui cobre todos os comandos de uma vez (move, qa, code-review,
11
+ // merge, implement…): o snapshot cacheado (`board-items`, lib/net-cache.mjs)
12
+ // não pode sobreviver a uma Etapa que acabou de mudar. Best-effort por
13
+ // construção: o invalidate nunca lança.
14
+ function dropBoardCache() {
15
+ invalidateCache(loadConfig().root, 'board-items');
16
+ }
7
17
 
8
18
  /**
9
19
  * Carrega o bloco `project` do .spec-wave.json, procurando-o a partir de `cwd`
@@ -229,6 +239,7 @@ export async function advanceToStage(
229
239
  );
230
240
  }
231
241
  await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
242
+ dropBoardCache();
232
243
  }
233
244
  if (statusField?.id && targetStatus) {
234
245
  const optionId = statusField.options?.[targetStatus];
@@ -239,6 +250,7 @@ export async function advanceToStage(
239
250
  );
240
251
  }
241
252
  await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
253
+ dropBoardCache();
242
254
  }
243
255
  return true;
244
256
  }
@@ -288,6 +300,7 @@ export async function setItemStage(
288
300
  const statusOption = statusField.options?.[targetStatus];
289
301
  if (statusOption) await setItemSingleSelect(token, project.id, itemId, statusField.id, statusOption);
290
302
  }
303
+ dropBoardCache();
291
304
  return { from };
292
305
  }
293
306
 
@@ -308,5 +321,6 @@ export async function setItemStatus(token, project, statusField, nodeId, status)
308
321
  if (!optionId) return false;
309
322
  const itemId = await addProjectItem(token, project.id, nodeId);
310
323
  await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
324
+ dropBoardCache();
311
325
  return true;
312
326
  }
@@ -0,0 +1,300 @@
1
+ // Dependency map pré-computado de uma Feature (módulo PURO — sem I/O).
2
+ //
3
+ // Montar a ordem das Stories consultava a API por Story (`listBlockedBy` + o
4
+ // par addProjectItem/getItemSingleSelectValue) em QUATRO comandos diferentes —
5
+ // e estourava rate limit. O grafo, porém, já nasce pronto: o `decompose
6
+ // --apply` conhece todas as arestas quando cria as issues. Este módulo define o
7
+ // artefato COMMITADO `docs/features/<slug>/dependency-map.json` (escrito pelo
8
+ // apply, atualizado pelo `order --sync`) e as decisões puras de consumo:
9
+ // validade do artefato, união de fontes de aresta, frescor de cache e o JSON
10
+ // que o `order --json` entrega a agentes.
11
+ //
12
+ // O que o artefato NÃO carrega, de propósito: Etapa e estado open/closed — são
13
+ // do board, mudam a cada `move`, e commitá-los seria gravar mentira com hora
14
+ // marcada. Quem precisa deles paga UMA chamada paginada (`listProjectItems`),
15
+ // cacheada com TTL (ver lib/net-cache.mjs e lib/story-graph.mjs).
16
+
17
+ import { orderStories } from './dependencies.mjs';
18
+
19
+ /** Nome do artefato dentro do diretório da feature. */
20
+ export const DEPENDENCY_MAP_FILE = 'dependency-map.json';
21
+
22
+ // Versão do formato. Maior que a conhecida → parse recusa e o chamador cai no
23
+ // fallback (decomposition.md/body) — nunca interpretar errado em silêncio.
24
+ export const DEPENDENCY_MAP_VERSION = 1;
25
+
26
+ /**
27
+ * Monta o objeto do artefato (função PURA).
28
+ *
29
+ * `order`/`cycle` são conveniência derivada de `dependsOn` (a mesma
30
+ * `orderStories` do comando) — consumidores podem recalcular, mas um agente
31
+ * lendo o JSON não deveria precisar reimplementar Kahn.
32
+ *
33
+ * @param {object} params
34
+ * @param {number} params.featureNumber
35
+ * @param {Array<{number:number, title?:string, dependsOn?:number[], tasks?:number[]}>} params.stories
36
+ * @param {'apply'|'sync'} [params.source]
37
+ * @param {string} [params.generatedAt] ISO — injetável nos testes
38
+ * @returns {object} o dependency-map.json (v1)
39
+ */
40
+ export function buildDependencyMap({ featureNumber, stories = [], source = 'apply', generatedAt } = {}) {
41
+ const clean = stories
42
+ .filter(s => Number.isInteger(s?.number) && s.number > 0)
43
+ .map(s => ({
44
+ number: s.number,
45
+ title: String(s.title ?? ''),
46
+ dependsOn: [...new Set((s.dependsOn || [])
47
+ .filter(d => Number.isInteger(d) && d > 0 && d !== s.number))]
48
+ .sort((a, b) => a - b),
49
+ tasks: (s.tasks || []).filter(t => Number.isInteger(t) && t > 0),
50
+ }));
51
+ const { order, cycle } = orderStories(clean);
52
+ return {
53
+ version: DEPENDENCY_MAP_VERSION,
54
+ feature: featureNumber,
55
+ generatedAt: generatedAt || new Date().toISOString(),
56
+ source,
57
+ stories: clean,
58
+ order,
59
+ cycle,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Valida um dependency-map.json lido do disco (função PURA).
65
+ *
66
+ * Inválido nunca é erro do comando — é `{ ok: false, reason }` e o chamador
67
+ * cai no fallback (decomposition.md ∪ body).
68
+ *
69
+ * @param {*} value objeto já parseado do JSON
70
+ * @returns {{ ok: boolean, reason: string|null, map: object|null }}
71
+ */
72
+ export function parseDependencyMap(value) {
73
+ const bad = (reason) => ({ ok: false, reason, map: null });
74
+ if (!value || typeof value !== 'object') return bad('não é um objeto JSON');
75
+ if (value.version !== DEPENDENCY_MAP_VERSION) {
76
+ return bad(`version ${JSON.stringify(value.version)} (esta CLI entende v${DEPENDENCY_MAP_VERSION})`);
77
+ }
78
+ if (!Number.isInteger(value.feature) || value.feature <= 0) return bad('campo "feature" ausente/inválido');
79
+ if (!Array.isArray(value.stories)) return bad('campo "stories" ausente');
80
+ for (const [i, s] of value.stories.entries()) {
81
+ if (!Number.isInteger(s?.number) || s.number <= 0) return bad(`stories[${i}].number inválido`);
82
+ if (s.dependsOn != null && !Array.isArray(s.dependsOn)) return bad(`stories[${i}].dependsOn inválido`);
83
+ }
84
+ return { ok: true, reason: null, map: value };
85
+ }
86
+
87
+ /**
88
+ * Converte um decomposition.md APLICADO em Stories com arestas por número de
89
+ * issue (função PURA) — o fallback quando o dependency-map.json não existe.
90
+ *
91
+ * `ok: false` quando o doc não serve de fonte: proposta ainda não aplicada
92
+ * (números não existem), kind=tasks (RFC não tem Stories) ou apply parcial
93
+ * (Story sem `issue` — grafo não confiável).
94
+ *
95
+ * @param {object} doc saída de parseDecompositionDoc
96
+ * @returns {{ ok: boolean, reason: string|null,
97
+ * stories: Array<{number:number, title:string, dependsOn:number[], tasks:number[]}> }}
98
+ */
99
+ export function storiesFromAppliedDoc(doc) {
100
+ const bad = (reason) => ({ ok: false, reason, stories: [] });
101
+ if (!doc || typeof doc !== 'object') return bad('documento ilegível');
102
+ if (!doc.appliedAt) return bad('decomposition.md ainda é proposta (sem applied=) — os números não existem');
103
+ if (doc.kind !== 'stories') return bad(`kind=${doc.kind || '?'} não tem Stories`);
104
+ const stories = doc.stories || [];
105
+ const semIssue = stories.filter(s => !Number.isInteger(s?.issue) || s.issue <= 0);
106
+ if (semIssue.length > 0) {
107
+ return bad(`${semIssue.length} Story(ies) sem "**Issue:** #N" — apply parcial, grafo não confiável`);
108
+ }
109
+ return {
110
+ ok: true,
111
+ reason: null,
112
+ stories: stories.map((s, i) => ({
113
+ number: s.issue,
114
+ title: s.title || '',
115
+ dependsOn: [...new Set([
116
+ // irmãs por índice 0-based → número da issue da irmã
117
+ ...(s.dependsOn || []).map(idx => stories[idx]?.issue).filter(n => Number.isInteger(n)),
118
+ ...(s.dependsOnIssues || []),
119
+ ])].filter(n => n !== s.issue).sort((a, b) => a - b),
120
+ tasks: (s.tasks || []).map(t => t.issue).filter(n => Number.isInteger(n) && n > 0),
121
+ index: i,
122
+ })),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * União de arestas por Story, vindas de fontes diferentes (função PURA).
128
+ *
129
+ * Cada fonte é um array `[{number, dependsOn}]`; a união deduplica, remove
130
+ * self-loop e ordena — duas escritas do mesmo conteúdo dão o mesmo resultado.
131
+ *
132
+ * @param {...Array<{number:number, dependsOn?:number[]}>} sources
133
+ * @returns {Map<number, number[]>} number da Story → dependências
134
+ */
135
+ export function mergeDependencyEdges(...sources) {
136
+ const out = new Map();
137
+ for (const source of sources) {
138
+ for (const s of source || []) {
139
+ if (!Number.isInteger(s?.number)) continue;
140
+ if (!out.has(s.number)) out.set(s.number, new Set());
141
+ for (const d of s.dependsOn || []) {
142
+ if (Number.isInteger(d) && d > 0 && d !== s.number) out.get(s.number).add(d);
143
+ }
144
+ }
145
+ }
146
+ return new Map([...out.entries()].map(([n, deps]) => [n, [...deps].sort((a, b) => a - b)]));
147
+ }
148
+
149
+ // ── frescor de cache ─────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * A entrada de cache ainda vale? (função PURA)
153
+ *
154
+ * `ttlSec <= 0` desliga o cache (nunca fresco). `fetchedAt` no futuro conta
155
+ * como fresco — relógio torto não pode transformar cache válido em refetch em
156
+ * loop.
157
+ *
158
+ * @param {{fetchedAt?: string}|null} entry
159
+ * @param {number} ttlSec
160
+ * @param {number} [nowMs]
161
+ */
162
+ export function isFresh(entry, ttlSec, nowMs = Date.now()) {
163
+ if (!entry?.fetchedAt || !Number.isFinite(ttlSec) || ttlSec <= 0) return false;
164
+ const fetched = Date.parse(entry.fetchedAt);
165
+ if (!Number.isFinite(fetched)) return false;
166
+ return nowMs - fetched < ttlSec * 1000;
167
+ }
168
+
169
+ /**
170
+ * Aviso de staleness para a saída (função PURA).
171
+ *
172
+ * Dados com menos de 60s não geram aviso — poluir toda saída fresca ensinaria
173
+ * a ignorar a linha. `null` também para timestamp ilegível.
174
+ *
175
+ * @param {string} fetchedAt ISO
176
+ * @param {number} [nowMs]
177
+ * @returns {string|null}
178
+ */
179
+ export function stalenessNotice(fetchedAt, nowMs = Date.now()) {
180
+ const fetched = Date.parse(fetchedAt || '');
181
+ if (!Number.isFinite(fetched)) return null;
182
+ const ageSec = Math.floor((nowMs - fetched) / 1000);
183
+ if (ageSec < 60) return null;
184
+ const idade = ageSec < 3600
185
+ ? `${Math.floor(ageSec / 60)} min`
186
+ : `${Math.floor(ageSec / 3600)}h${String(Math.floor((ageSec % 3600) / 60)).padStart(2, '0')}`;
187
+ return `dados do board de ${idade} atrás — use --refresh para reconsultar`;
188
+ }
189
+
190
+ // ── filtros e saída ──────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Filtra itens do board por milestone (função PURA) — `order --milestone`.
194
+ *
195
+ * `ref` aceita número ("3"/"#3") ou título (case-insensitive, comparação
196
+ * exata). Exige o campo `milestone` no shape de listProjectItems.
197
+ *
198
+ * @param {Array<{milestone?: {number:number, title:string}|null}>} items
199
+ * @param {string|number} ref
200
+ * @returns {Array} os itens da milestone
201
+ */
202
+ export function filterItemsByMilestone(items = [], ref) {
203
+ const text = String(ref ?? '').trim();
204
+ if (/^#?\d+$/.test(text)) {
205
+ const number = parseInt(text.replace('#', ''), 10);
206
+ return items.filter(i => i?.milestone?.number === number);
207
+ }
208
+ const alvo = text.toLowerCase();
209
+ return items.filter(i => String(i?.milestone?.title || '').toLowerCase() === alvo);
210
+ }
211
+
212
+ /**
213
+ * A saída de `order --json` (função PURA) — contrato para agentes.
214
+ *
215
+ * Shape estável de propósito: é o que o dev-agent e outros consumidores
216
+ * programáticos leem no lugar de parsear o texto com ANSI.
217
+ *
218
+ * @param {object} params
219
+ * @param {number[]} params.sorted ordem topológica
220
+ * @param {Map<number, {title?:string, dependsOn?:number[]}>} params.byNumber
221
+ * @param {Map<number, object>} [params.featureOf] Story → Feature dona
222
+ * @param {Map<number, string|null>} [params.stageOf]
223
+ * @param {Map<number, number[]>} [params.external]
224
+ * @param {number[]} [params.cycle]
225
+ * @param {{edges?:string, stages?:string|null, fetchedAt?:string|null,
226
+ * generatedAt?:string, warnings?:string[]}} [params.meta]
227
+ * @returns {object}
228
+ */
229
+ export function renderOrderJson({
230
+ sorted = [], byNumber = new Map(), featureOf = new Map(), stageOf = new Map(),
231
+ external = new Map(), cycle = [], meta = {},
232
+ } = {}) {
233
+ return {
234
+ version: 1,
235
+ generatedAt: meta.generatedAt || new Date().toISOString(),
236
+ source: {
237
+ edges: meta.edges || 'remote',
238
+ stages: meta.stages ?? null,
239
+ fetchedAt: meta.fetchedAt ?? null,
240
+ },
241
+ order: sorted.map((n, i) => {
242
+ const s = byNumber.get(n) || {};
243
+ const feature = featureOf.get(n) || null;
244
+ return {
245
+ position: i + 1,
246
+ number: n,
247
+ title: s.title || '',
248
+ feature: feature ? { number: feature.number, title: feature.title || '' } : null,
249
+ stage: stageOf.get(n) ?? null,
250
+ dependsOn: (s.dependsOn || []).slice().sort((a, b) => a - b),
251
+ };
252
+ }),
253
+ external: [...external.entries()].map(([number, dependsOn]) => ({ number, dependsOn })),
254
+ cycle: cycle.slice(),
255
+ warnings: meta.warnings || [],
256
+ };
257
+ }
258
+
259
+ // ── sync API → doc (`order --sync`) ──────────────────────────────────────────
260
+
261
+ /**
262
+ * Reescreve as dependências de um decomposition.md APLICADO a partir do estado
263
+ * VIVO das issues (função PURA) — a metade de decisão do `order --sync`.
264
+ *
265
+ * `liveDeps` é a união body ∪ blocked_by por Story (a mesma que o `order`
266
+ * remoto usa). Cada Story do doc passa a declarar exatamente essas arestas:
267
+ * irmã ANTERIOR vira `Story N` (a forma legível, que a gramática exige "só
268
+ * para trás"); irmã posterior ou issue de fora vira `#N`. Dependência que
269
+ * sumiu do GitHub sai do doc — sync é espelho, não união.
270
+ *
271
+ * @param {object} doc saída de parseDecompositionDoc (aplicado, kind=stories)
272
+ * @param {Map<number, number[]>} liveDeps número da Story → deps vivas
273
+ * @returns {{ doc: object, changed: boolean,
274
+ * changes: Array<{story:number, before:number[], after:number[]}> }}
275
+ */
276
+ export function syncDocDependencies(doc, liveDeps = new Map()) {
277
+ const indexOfIssue = new Map((doc.stories || []).map((s, i) => [s.issue, i]));
278
+ const changes = [];
279
+ const stories = (doc.stories || []).map((story, i) => {
280
+ if (!liveDeps.has(story.issue)) return story; // sem leitura viva → não toca
281
+ const before = [...new Set([
282
+ ...(story.dependsOn || []).map(idx => doc.stories[idx]?.issue).filter(Number.isInteger),
283
+ ...(story.dependsOnIssues || []),
284
+ ])].sort((a, b) => a - b);
285
+ const after = [...new Set(liveDeps.get(story.issue) || [])]
286
+ .filter(n => Number.isInteger(n) && n > 0 && n !== story.issue)
287
+ .sort((a, b) => a - b);
288
+ if (before.join(',') === after.join(',')) return story;
289
+ changes.push({ story: story.issue, before, after });
290
+ const siblings = [];
291
+ const issues = [];
292
+ for (const dep of after) {
293
+ const idx = indexOfIssue.get(dep);
294
+ if (idx !== undefined && idx < i) siblings.push(idx);
295
+ else issues.push(dep);
296
+ }
297
+ return { ...story, dependsOn: siblings, dependsOnIssues: issues };
298
+ });
299
+ return { doc: { ...doc, stories }, changed: changes.length > 0, changes };
300
+ }
@@ -50,5 +50,9 @@ export function featureDocPaths(root, issue, type) {
50
50
  // A chave leva o hífen do nome do documento (ARTIFACT_DOCS) de propósito:
51
51
  // é o mesmo id que o resto do fluxo usa para este artefato.
52
52
  'qa-plan': doc('qa-plan.md'),
53
+ // Grafo de dependências pré-computado (lib/dependency-map.mjs) — escrito
54
+ // pelo decompose --apply, atualizado pelo `order --sync`, lido por
55
+ // order/implement/merge/qa-lead no lugar de N chamadas de API.
56
+ 'dependency-map': doc('dependency-map.json'),
53
57
  };
54
58
  }