@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.
- package/package.json +5 -3
- package/protocol/qa-result.v1.json +62 -0
- package/protocol/qa-trail-report.v1.json +113 -0
- package/src/api/github-graphql.mjs +6 -1
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +80 -5
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +102 -3
- package/src/commands/implement.mjs +56 -44
- package/src/commands/merge.mjs +43 -14
- package/src/commands/order.mjs +350 -96
- package/src/commands/qa-lead.mjs +748 -0
- package/src/commands/qa-run.mjs +104 -25
- package/src/config.mjs +15 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +4 -0
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/qa-exec.mjs +23 -2
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-report.mjs +65 -9
- package/src/lib/skill-compose.mjs +234 -0
- package/src/lib/story-graph.mjs +256 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/order/SKILL.md +21 -5
- package/src/plugin/skills/qa/SKILL.md +3 -1
- package/src/plugin/skills/qa-executor/SKILL.md +76 -0
- package/src/plugin/skills/qa-lead/SKILL.md +89 -0
- package/src/templates/skill/SKILL.md +953 -298
- package/src/templates/skill/core.md +584 -0
package/src/commands/qa-run.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
555
|
-
// aplicada (exceção documentada
|
|
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
|
|
642
|
-
//
|
|
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 só 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
|
|
661
|
-
'e da saída real da execução (exceção documentada à regra do `spec-wave:bug` —
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/doc-paths.mjs
CHANGED
|
@@ -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
|
}
|