@spec-wave/cli 0.29.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 +114 -9
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +183 -3
- package/src/commands/generate-qa-plan.mjs +421 -0
- 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 +892 -0
- package/src/commands/run.mjs +5 -1
- package/src/config.mjs +32 -1
- package/src/lib/artifact-pr.mjs +2 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/critique.mjs +38 -9
- package/src/lib/decomposition-doc.mjs +5 -1
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +9 -2
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/next-step.mjs +15 -3
- package/src/lib/qa-exec.mjs +335 -0
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-plan-doc.mjs +340 -0
- package/src/lib/qa-report.mjs +396 -0
- 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 +107 -0
- package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
- package/src/plugin/skills/qa/model-prompt.md +68 -0
- 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 +981 -279
- package/src/templates/skill/core.md +584 -0
- package/src/templates/workflows/generate-qa-plan.yml +64 -0
package/src/commands/run.mjs
CHANGED
|
@@ -137,7 +137,7 @@ function localDocStates(issue, type, root) {
|
|
|
137
137
|
const paths = featureDocPaths(root, issue, type);
|
|
138
138
|
const docs = {};
|
|
139
139
|
const docPaths = {};
|
|
140
|
-
for (const nome of ['spec', 'plan', 'decomposition']) {
|
|
140
|
+
for (const nome of ['spec', 'plan', 'decomposition', 'qa-plan']) {
|
|
141
141
|
docs[nome] = existsSync(paths[nome].abs) ? 'local' : 'missing';
|
|
142
142
|
docPaths[nome] = paths[nome].rel;
|
|
143
143
|
}
|
|
@@ -305,6 +305,10 @@ async function dispatch(action, { issueNumber }) {
|
|
|
305
305
|
const { generateBug } = await import('./generate-bug.mjs');
|
|
306
306
|
return await generateBug({ issueNumber });
|
|
307
307
|
}
|
|
308
|
+
case 'generate-qa-plan': {
|
|
309
|
+
const { generateQaPlan } = await import('./generate-qa-plan.mjs');
|
|
310
|
+
return await generateQaPlan({ issueNumber });
|
|
311
|
+
}
|
|
308
312
|
default:
|
|
309
313
|
throw new Error(`Passo sem despacho: ${action}`);
|
|
310
314
|
}
|
package/src/config.mjs
CHANGED
|
@@ -54,7 +54,7 @@ export const DEFAULT_PROVIDER = 'anthropic';
|
|
|
54
54
|
// Ações de IA que podem ter modelo próprio no .spec-wave.json (bloco
|
|
55
55
|
// `ai.models`, ex.: { "critique": "claude-opus-4-1" }). Resolvidas em runtime
|
|
56
56
|
// por resolveAiConfig() em src/lib/claude.mjs.
|
|
57
|
-
export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug'];
|
|
57
|
+
export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug', 'qa'];
|
|
58
58
|
|
|
59
59
|
// Override de modelo POR EXECUÇÃO: a label `spec-wave:model:<apelido>` na issue
|
|
60
60
|
// aponta para uma entrada de `ai.modelAliases` do .spec-wave.json. Serve para
|
|
@@ -346,6 +346,32 @@ export const LABEL_DEV_AGENT = 'spec-wave:dev-agent';
|
|
|
346
346
|
export const LABEL_BUG = 'spec-wave:bug';
|
|
347
347
|
export const LABEL_BUG_APPROVED = 'spec-wave:bug-approved';
|
|
348
348
|
|
|
349
|
+
// Fluxo de QA (rfc/spec-qa-skill.md). O gatilho gera (ou re-critica) o
|
|
350
|
+
// qa-plan.md; as duas de estado são gravadas pelas automações, nunca à mão.
|
|
351
|
+
//
|
|
352
|
+
// ⚠️ `spec-wave:qa-ready` NÃO é `spec-wave:ready`: a primeira é ESTADO do plano
|
|
353
|
+
// de QA (passou na crítica, espera revisão humana — o portão do D-QA4); a
|
|
354
|
+
// segunda é o GATILHO da validação de spec/plan. Nenhuma checagem pode usar
|
|
355
|
+
// prefixo (`startsWith('spec-wave:ready')`) para distinguir as duas.
|
|
356
|
+
export const LABEL_QA = 'spec-wave:qa';
|
|
357
|
+
export const LABEL_QA_READY = 'spec-wave:qa-ready';
|
|
358
|
+
export const LABEL_QA_APPROVED = 'spec-wave:qa-approved';
|
|
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
|
+
|
|
349
375
|
// Desfecho da triagem do PM (RFC-004 §4.1). São mutuamente exclusivas: um bug
|
|
350
376
|
// triado foi aceito, rejeitado ou marcado como duplicata.
|
|
351
377
|
export const LABEL_TRIAGED = 'spec-wave:triaged';
|
|
@@ -432,6 +458,9 @@ export const TRIGGER_LABELS = [
|
|
|
432
458
|
{ name: LABEL_DEV_AGENT, color: '5319E7', description: 'Enfileira a issue para o dev-agent autônomo' },
|
|
433
459
|
{ name: LABEL_BUG, color: 'BFD4F2', description: 'Gerar bug.md via GitHub Action' },
|
|
434
460
|
{ name: LABEL_BUG_APPROVED, color: '0E8A16', description: 'bug.md validado (reprodução, causa raiz e teste de regressão)' },
|
|
461
|
+
{ name: LABEL_QA, color: 'BFD4F2', description: 'Gerar/re-criticar o plano de QA (qa-plan.md) via GitHub Action' },
|
|
462
|
+
{ name: LABEL_QA_READY, color: '0E8A16', description: 'Plano de QA aprovado pela crítica — revise-o e rode `spec-wave qa <n>`' },
|
|
463
|
+
{ name: LABEL_QA_APPROVED, color: '0E8A16', description: 'Execução do QA passou em todos os cenários' },
|
|
435
464
|
{ name: LABEL_TRIAGED, color: '0E8A16', description: 'Bug triado pelo PM (severidade, origem e pai definidos)' },
|
|
436
465
|
{ name: LABEL_DUPLICATE, color: 'EDEDED', description: 'Duplicata de outra issue (o corpo aponta qual)' },
|
|
437
466
|
{ name: LABEL_WONT_FIX, color: 'EDEDED', description: 'Rejeitado na triagem — não será corrigido' },
|
|
@@ -515,6 +544,7 @@ export const WORKFLOW_FILES = [
|
|
|
515
544
|
'generate-spec.yml',
|
|
516
545
|
'validate.yml',
|
|
517
546
|
'decompose.yml',
|
|
547
|
+
'generate-qa-plan.yml',
|
|
518
548
|
'code-review.yml',
|
|
519
549
|
'qa.yml',
|
|
520
550
|
];
|
|
@@ -535,6 +565,7 @@ export const ARTIFACT_WORKFLOW_FILES = [
|
|
|
535
565
|
'generate-plan.yml',
|
|
536
566
|
'generate-bug.yml',
|
|
537
567
|
'decompose.yml',
|
|
568
|
+
'generate-qa-plan.yml',
|
|
538
569
|
];
|
|
539
570
|
|
|
540
571
|
export const ISSUE_TEMPLATE_FILES = [
|
package/src/lib/artifact-pr.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export const ARTIFACT_DOCS = {
|
|
|
34
34
|
bug: { suffix: 'bug', file: 'bug.md' },
|
|
35
35
|
decomposition: { suffix: 'decompose', file: 'decomposition.md' },
|
|
36
36
|
'decomposition-apply': { suffix: 'decompose-apply', file: 'decomposition.md' },
|
|
37
|
+
'qa-plan': { suffix: 'qa-plan', file: 'qa-plan.md' },
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
/** Nomes aceitos em `doc`, na ordem do fluxo. */
|
|
@@ -102,6 +103,7 @@ const DOC_LABEL = {
|
|
|
102
103
|
bug: 'documento de bug',
|
|
103
104
|
decomposition: 'rascunho de decomposição',
|
|
104
105
|
'decomposition-apply': 'registro das issues criadas',
|
|
106
|
+
'qa-plan': 'plano de QA',
|
|
105
107
|
};
|
|
106
108
|
|
|
107
109
|
/**
|
|
@@ -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
|
}
|
package/src/lib/critique.mjs
CHANGED
|
@@ -44,7 +44,7 @@ export const CRITIQUE_TOOL_NAME = 'registrar_findings';
|
|
|
44
44
|
// Rótulo do artefato auditado, por contexto — usado no cabeçalho do comentário.
|
|
45
45
|
const KIND_LABEL = {
|
|
46
46
|
plan: 'plan.md', stories: 'decomposition.md', bug: 'bug.md', spec: 'spec.md',
|
|
47
|
-
conjunto: 'specs da milestone (conjunto)',
|
|
47
|
+
conjunto: 'specs da milestone (conjunto)', qa: 'qa-plan.md',
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
// Prompt por tipo de auditoria. 'plan' audita o plan.md contra a spec;
|
|
@@ -53,7 +53,7 @@ const KIND_LABEL = {
|
|
|
53
53
|
// relação entre documentos, não um documento.
|
|
54
54
|
const KIND_PROMPT = {
|
|
55
55
|
plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique', spec: 'spec/critique',
|
|
56
|
-
conjunto: 'audit/critique',
|
|
56
|
+
conjunto: 'audit/critique', qa: 'qa/critique',
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
// A decomposição virou arquivo revisável (decomposition.md): um finding só é
|
|
@@ -66,6 +66,13 @@ const ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho audita
|
|
|
66
66
|
- "geral" quando o problema for da decomposição como um todo (ex.: requisito da spec que nenhuma Story cobre).
|
|
67
67
|
Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "### Task N.M — ..." do documento.`;
|
|
68
68
|
|
|
69
|
+
// O qa-plan.md tem a mesma necessidade da decomposição: o finding precisa dizer
|
|
70
|
+
// QUAL cenário está errado, e o título "## Cenário N" é a âncora estável.
|
|
71
|
+
const QA_ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho auditado:
|
|
72
|
+
- "Cenário N" para um problema no Cenário N (ex.: "Cenário 3");
|
|
73
|
+
- "geral" quando o problema for do plano como um todo (ex.: critério de aceite da spec que nenhum cenário cobre).
|
|
74
|
+
Use EXATAMENTE os números que aparecem nos títulos "## Cenário N — Story #X" do documento.`;
|
|
75
|
+
|
|
69
76
|
// No conjunto o achado vive ENTRE documentos: sem dizer quais, o leitor relê a
|
|
70
77
|
// milestone inteira procurando o par. O campo é validado como array de números
|
|
71
78
|
// de issue — é o equivalente da âncora "Story N" para este contexto.
|
|
@@ -91,6 +98,7 @@ visto à luz das outras). Use EXATAMENTE os números que aparecem nos títulos "
|
|
|
91
98
|
function buildSystemPrompt(kind, cwd) {
|
|
92
99
|
const prompt = loadPrompt(KIND_PROMPT[kind] || KIND_PROMPT.plan, ...(cwd ? [{ cwd }] : []));
|
|
93
100
|
const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}`
|
|
101
|
+
: kind === 'qa' ? `\n\n${QA_ANCHOR_RULE}`
|
|
94
102
|
: kind === 'conjunto' ? `\n\n${FEATURES_RULE}` : '';
|
|
95
103
|
|
|
96
104
|
const contract = `## Contrato de saída
|
|
@@ -146,6 +154,13 @@ function critiqueJsonSchema(kind) {
|
|
|
146
154
|
};
|
|
147
155
|
required.push('anchor');
|
|
148
156
|
}
|
|
157
|
+
if (kind === 'qa') {
|
|
158
|
+
properties.anchor = {
|
|
159
|
+
type: 'string',
|
|
160
|
+
description: 'Âncora do trecho auditado: "Cenário N" ou "geral".',
|
|
161
|
+
};
|
|
162
|
+
required.push('anchor');
|
|
163
|
+
}
|
|
149
164
|
if (kind === 'conjunto') {
|
|
150
165
|
properties.features = {
|
|
151
166
|
type: 'array',
|
|
@@ -195,14 +210,14 @@ function describe(value) {
|
|
|
195
210
|
return typeof value === 'object' ? 'um objeto' : `${typeof value} (${JSON.stringify(value).slice(0, 60)})`;
|
|
196
211
|
}
|
|
197
212
|
|
|
198
|
-
const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
|
|
213
|
+
const ANCHOR_RE = /^(story|task|cen[aá]rio)[ \t]*(\d+(?:\.\d+)?)$/i;
|
|
199
214
|
|
|
200
215
|
/**
|
|
201
216
|
* Normaliza a âncora de um finding (função PURA).
|
|
202
217
|
*
|
|
203
|
-
* Aceita "Story 3", "story3", "TASK 3.2"
|
|
204
|
-
* "geral" — vira ausência de âncora: melhor
|
|
205
|
-
* mandar o humano para o trecho errado
|
|
218
|
+
* Aceita "Story 3", "story3", "TASK 3.2", "Cenário 2" (com ou sem acento).
|
|
219
|
+
* Qualquer outra coisa — inclusive "geral" — vira ausência de âncora: melhor
|
|
220
|
+
* não citar do que citar errado e mandar o humano para o trecho errado.
|
|
206
221
|
*
|
|
207
222
|
* @param {*} value valor cru do campo `anchor`
|
|
208
223
|
* @returns {string} âncora normalizada, ou '' quando não há
|
|
@@ -210,7 +225,9 @@ const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
|
|
|
210
225
|
export function normalizeAnchor(value) {
|
|
211
226
|
const m = ANCHOR_RE.exec(String(value ?? '').trim());
|
|
212
227
|
if (!m) return '';
|
|
213
|
-
|
|
228
|
+
const kind = m[1].toLowerCase();
|
|
229
|
+
const nome = kind === 'task' ? 'Task' : kind === 'story' ? 'Story' : 'Cenário';
|
|
230
|
+
return `${nome} ${m[2]}`;
|
|
214
231
|
}
|
|
215
232
|
|
|
216
233
|
/**
|
|
@@ -758,6 +775,11 @@ const KIND_TRAILER = {
|
|
|
758
775
|
'até ser removida. Um bug com causa raiz errada produz correção errada — corrija o ' +
|
|
759
776
|
'`bug.md` e reaplique `spec-wave:bug`.'
|
|
760
777
|
: '_Findings menores não bloqueiam a triagem._'),
|
|
778
|
+
qa: (graves) => (graves
|
|
779
|
+
? '⛔ Há findings **graves**: a label `spec-wave:critique-failed` impede o `qa run` ' +
|
|
780
|
+
'até ser removida. Corrija o `qa-plan.md` (as âncoras acima apontam para ele), ' +
|
|
781
|
+
'remova a label e reaplique `spec-wave:qa` para uma nova crítica.'
|
|
782
|
+
: '_Findings menores não bloqueiam a execução do QA._'),
|
|
761
783
|
// O conjunto roda fora do ciclo de tentativas e não aplica label: o achado é
|
|
762
784
|
// decisão de PO entre duas specs, e o destino dele é a issue das duas pontas.
|
|
763
785
|
conjunto: (graves) => (graves
|
|
@@ -897,7 +919,7 @@ export function renderCritiqueMarkdown({
|
|
|
897
919
|
* @returns {Promise<{grave, findings, markdown, attempt, model}>}
|
|
898
920
|
*/
|
|
899
921
|
export async function runCritique({
|
|
900
|
-
kind, spec, specs, plan, techContextYaml, decomposition, bugDoc, bugReport,
|
|
922
|
+
kind, spec, specs, plan, techContextYaml, decomposition, qaPlan, bugDoc, bugReport,
|
|
901
923
|
attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
902
924
|
model, labels = [], usage, cwd, decisions = null, standalone = false,
|
|
903
925
|
} = {}) {
|
|
@@ -917,6 +939,13 @@ export async function runCritique({
|
|
|
917
939
|
`## Decomposição proposta (decomposition.md)\n\n\`\`\`\`markdown\n${decomposition}\n\`\`\`\``
|
|
918
940
|
);
|
|
919
941
|
}
|
|
942
|
+
// Mesma cerca de quatro crases da decomposição, pelo mesmo motivo: o plano
|
|
943
|
+
// pode conter blocos de código de três.
|
|
944
|
+
if (qaPlan) {
|
|
945
|
+
sections.push(
|
|
946
|
+
`## Plano de QA auditado (qa-plan.md)\n\n\`\`\`\`markdown\n${qaPlan}\n\`\`\`\``
|
|
947
|
+
);
|
|
948
|
+
}
|
|
920
949
|
// O relato é a fonte contra a qual o bug.md é auditado: a causa raiz proposta
|
|
921
950
|
// tem que explicar OS SINTOMAS RELATADOS, não sintomas plausíveis quaisquer.
|
|
922
951
|
if (bugReport) sections.push(`## Relato original (issue e comentários)\n\n${bugReport}`);
|
|
@@ -953,7 +982,7 @@ export async function runCritique({
|
|
|
953
982
|
// sustenta não deve entrar no contador de tentativas nem bloquear o Action.
|
|
954
983
|
const findings = downgradeUnsupportedFindings(
|
|
955
984
|
report.value.findings,
|
|
956
|
-
[spec, ...(specs || []).map(s => s.content), plan, decomposition, bugDoc, techContextYaml]
|
|
985
|
+
[spec, ...(specs || []).map(s => s.content), plan, decomposition, qaPlan, bugDoc, techContextYaml]
|
|
957
986
|
.filter(Boolean),
|
|
958
987
|
);
|
|
959
988
|
const grave = findings.some(f => f.severity === 'grave');
|
|
@@ -69,8 +69,12 @@ function invalid(reason) {
|
|
|
69
69
|
* Regras do CommonMark que importam aqui: a cerca tem 3+ caracteres, o
|
|
70
70
|
* fechamento usa o mesmo caractere e comprimento >= o da abertura, e uma cerca
|
|
71
71
|
* de crase não aceita crase na info string.
|
|
72
|
+
*
|
|
73
|
+
* Exportado porque o qa-plan.md (lib/qa-plan-doc.mjs) espelha as MESMAS regras
|
|
74
|
+
* de parsing deste documento — duplicar o rastreador seria duas cópias para
|
|
75
|
+
* divergir.
|
|
72
76
|
*/
|
|
73
|
-
function fenceScanner() {
|
|
77
|
+
export function fenceScanner() {
|
|
74
78
|
let open = null;
|
|
75
79
|
return {
|
|
76
80
|
// true quando a linha pertence a um bloco de código (abertura, conteúdo ou
|
|
@@ -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
|
@@ -30,12 +30,12 @@ export function resolveDocDir(root, issue, type) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Caminhos dos
|
|
33
|
+
* Caminhos dos documentos de uma Feature/RFC (função PURA).
|
|
34
34
|
*
|
|
35
35
|
* @param {string|null} root
|
|
36
36
|
* @param {{title?: string}} issue
|
|
37
37
|
* @param {string|null} [type]
|
|
38
|
-
* @returns {{slug, dirRel, dirAbs, spec, plan, decomposition}} cada documento com {rel, abs}
|
|
38
|
+
* @returns {{slug, dirRel, dirAbs, spec, plan, decomposition, 'qa-plan'}} cada documento com {rel, abs}
|
|
39
39
|
*/
|
|
40
40
|
export function featureDocPaths(root, issue, type) {
|
|
41
41
|
const { slug, rel, dir } = resolveDocDir(root, issue, type);
|
|
@@ -47,5 +47,12 @@ export function featureDocPaths(root, issue, type) {
|
|
|
47
47
|
spec: doc('spec.md'),
|
|
48
48
|
plan: doc('plan.md'),
|
|
49
49
|
decomposition: doc('decomposition.md'),
|
|
50
|
+
// A chave leva o hífen do nome do documento (ARTIFACT_DOCS) de propósito:
|
|
51
|
+
// é o mesmo id que o resto do fluxo usa para este artefato.
|
|
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'),
|
|
50
57
|
};
|
|
51
58
|
}
|