@spec-wave/cli 0.6.0 → 0.7.1
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/bin/spec-wave.mjs +37 -0
- package/package.json +4 -1
- package/src/api/github-rest.mjs +60 -1
- package/src/commands/code-review.mjs +13 -52
- package/src/commands/decompose.mjs +288 -61
- package/src/commands/doctor.mjs +415 -0
- package/src/commands/generate-plan.mjs +95 -29
- package/src/commands/generate-spec.mjs +71 -28
- package/src/commands/implement.mjs +118 -3
- package/src/commands/init.mjs +2 -2
- package/src/commands/order.mjs +172 -0
- package/src/commands/qa.mjs +8 -46
- package/src/commands/story.mjs +128 -0
- package/src/commands/task.mjs +183 -0
- package/src/commands/validate.mjs +20 -3
- package/src/config.mjs +37 -0
- package/src/lib/board.mjs +106 -0
- package/src/lib/claude.mjs +138 -13
- package/src/lib/code-digest.mjs +183 -0
- package/src/lib/critique.mjs +160 -0
- package/src/lib/dependencies.mjs +92 -0
- package/src/lib/output-lint.mjs +92 -0
- package/src/lib/usage-report.mjs +167 -0
- package/src/setup/files.mjs +8 -20
- package/src/templates/skill/SKILL.md +104 -12
- package/src/templates/workflows/code-review.yml +4 -0
- package/src/templates/workflows/decompose.yml +7 -0
- package/src/templates/workflows/generate-plan.yml +4 -0
- package/src/templates/workflows/generate-spec.yml +4 -0
- package/src/templates/workflows/qa.yml +4 -0
- package/src/templates/workflows/validate.yml +4 -0
- package/src/ui/wizard.mjs +2 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Digest do estado atual do código — anexado ao contexto do `implement` para o
|
|
2
|
+
// agente NÃO reimplementar o que já existe (caso real: módulo `landing` criado
|
|
3
|
+
// duplicando o módulo `waves`). Duas fontes, ambas best-effort:
|
|
4
|
+
// • commits recentes (git log desde a criação da Feature);
|
|
5
|
+
// • árvore rasa dos módulos citados no plan.md.
|
|
6
|
+
// Contrato: buildCodeDigest NUNCA lança — cada seção tem seu try/catch e, se
|
|
7
|
+
// tudo falhar, retorna null (o chamador simplesmente omite a seção).
|
|
8
|
+
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
// Limites — o digest é contexto auxiliar, não pode dominar o arquivo.
|
|
14
|
+
const MAX_PATHS = 30;
|
|
15
|
+
const MAX_LOG_LINES = 50;
|
|
16
|
+
const MAX_TREE_ENTRIES = 40;
|
|
17
|
+
const MAX_TREE_DEPTH = 2;
|
|
18
|
+
|
|
19
|
+
// Extensões comuns de código/config — usadas para aceitar nomes sem "/" como
|
|
20
|
+
// caminho (ex.: `config.mjs`).
|
|
21
|
+
const COMMON_EXT_RE = /\.(mjs|cjs|jsx?|tsx?|json|ya?ml|md|css|scss|html|vue|svelte|py|rb|go|rs|java|kt|sql|sh|prisma|toml)$/i;
|
|
22
|
+
|
|
23
|
+
// Prefixos de diretório que aparecem "soltos" no texto do plan (fora de backticks).
|
|
24
|
+
const LOOSE_PATH_RE = /(?:^|[\s(])((?:src|server|client|packages)\/[\w./@-]+)/gm;
|
|
25
|
+
|
|
26
|
+
// Caracteres que denunciam código inline, não caminho: chamadas, objetos, aspas…
|
|
27
|
+
const NON_PATH_CHARS_RE = /[(){}<>|"'`\\=,;!?*\s]/;
|
|
28
|
+
|
|
29
|
+
function normalizePath(raw) {
|
|
30
|
+
return String(raw)
|
|
31
|
+
.trim()
|
|
32
|
+
.replace(/^\.\//, '') // sem "./" inicial
|
|
33
|
+
.replace(/\/+$/, '') // sem "/" final
|
|
34
|
+
.replace(/\.+$/, ''); // pontuação de fim de frase colada ("src/lib.")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function looksLikePath(candidate) {
|
|
38
|
+
if (!candidate || candidate.startsWith('-') || candidate.includes('://')) return false;
|
|
39
|
+
if (NON_PATH_CHARS_RE.test(candidate)) return false;
|
|
40
|
+
return candidate.includes('/') || COMMON_EXT_RE.test(candidate);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extrai caminhos de arquivo/diretório citados no plan.md: conteúdo de
|
|
45
|
+
* backticks que pareça caminho (tem "/" ou extensão comum) e padrões soltos
|
|
46
|
+
* tipo `src/...`, `server/...`, `client/...`, `packages/...`.
|
|
47
|
+
*
|
|
48
|
+
* @param {string|null|undefined} planText conteúdo do plan.md
|
|
49
|
+
* @returns {string[]} caminhos normalizados, sem duplicatas, no máx. 30
|
|
50
|
+
*/
|
|
51
|
+
export function extractPathsFromPlan(planText) {
|
|
52
|
+
if (!planText) return [];
|
|
53
|
+
const paths = [];
|
|
54
|
+
const push = (raw) => {
|
|
55
|
+
const candidate = normalizePath(raw);
|
|
56
|
+
if (looksLikePath(candidate) && !paths.includes(candidate)) paths.push(candidate);
|
|
57
|
+
};
|
|
58
|
+
for (const m of planText.matchAll(/`([^`\n]+)`/g)) push(m[1]);
|
|
59
|
+
for (const m of planText.matchAll(LOOSE_PATH_RE)) push(m[1]);
|
|
60
|
+
return paths.slice(0, MAX_PATHS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// exec padrão: execSync capturando stdout (stderr descartado — falha vira
|
|
64
|
+
// exceção e a seção é omitida).
|
|
65
|
+
function defaultExec(command, options = {}) {
|
|
66
|
+
return execSync(command, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...options });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Listagem rasa de um diretório (profundidade máx. 2, orçamento de entradas
|
|
70
|
+
// compartilhado), formato indentado. Erros de leitura interrompem só o ramo.
|
|
71
|
+
function listDirTree(absDir, budget) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
const walk = (dir, depth) => {
|
|
74
|
+
if (depth > MAX_TREE_DEPTH) return;
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
78
|
+
} catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
84
|
+
if (budget.remaining <= 0) {
|
|
85
|
+
budget.truncated = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
budget.remaining--;
|
|
89
|
+
const indent = ' '.repeat(depth + 1);
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
lines.push(`${indent}${entry.name}/`);
|
|
92
|
+
walk(path.join(dir, entry.name), depth + 1);
|
|
93
|
+
} else {
|
|
94
|
+
lines.push(`${indent}${entry.name}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
walk(absDir, 0);
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Seção "Commits desde a criação da feature" — null se a seção falhar.
|
|
103
|
+
function buildCommitsSection({ sinceIso, cwd, exec }) {
|
|
104
|
+
if (!sinceIso) return null;
|
|
105
|
+
try {
|
|
106
|
+
// Sanitiza o ISO antes de interpolar no shell (só chars de data/hora).
|
|
107
|
+
const safeSince = String(sinceIso).replace(/[^\w:+.-]/g, '');
|
|
108
|
+
const out = exec(`git log --oneline --no-merges --since="${safeSince}"`, { cwd });
|
|
109
|
+
const commits = String(out).split('\n').filter(l => l.trim()).slice(0, MAX_LOG_LINES);
|
|
110
|
+
const lines = ['### Commits desde a criação da feature', ''];
|
|
111
|
+
if (commits.length === 0) {
|
|
112
|
+
lines.push('nenhum commit no período.');
|
|
113
|
+
} else {
|
|
114
|
+
lines.push('```', ...commits, '```');
|
|
115
|
+
}
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Seção "Árvore dos módulos citados no plan" — null se não houver paths ou se
|
|
123
|
+
// a seção falhar.
|
|
124
|
+
function buildTreeSection({ paths, cwd }) {
|
|
125
|
+
if (!paths || paths.length === 0) return null;
|
|
126
|
+
try {
|
|
127
|
+
const lines = ['### Árvore dos módulos citados no plan', ''];
|
|
128
|
+
const missing = [];
|
|
129
|
+
const budget = { remaining: MAX_TREE_ENTRIES, truncated: false };
|
|
130
|
+
for (const rel of paths) {
|
|
131
|
+
let stat;
|
|
132
|
+
try {
|
|
133
|
+
stat = statSync(path.join(cwd, rel));
|
|
134
|
+
} catch {
|
|
135
|
+
missing.push(rel);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (stat.isDirectory()) {
|
|
139
|
+
lines.push(`- \`${rel}/\` (diretório):`);
|
|
140
|
+
lines.push('```');
|
|
141
|
+
lines.push(...listDirTree(path.join(cwd, rel), budget));
|
|
142
|
+
lines.push('```');
|
|
143
|
+
} else {
|
|
144
|
+
let lineCount = null;
|
|
145
|
+
try {
|
|
146
|
+
const content = readFileSync(path.join(cwd, rel), 'utf-8');
|
|
147
|
+
lineCount = content.split('\n').length;
|
|
148
|
+
} catch { /* tamanho é opcional */ }
|
|
149
|
+
lines.push(`- \`${rel}\` — arquivo existente${lineCount != null ? ` (${lineCount} linhas)` : ''}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (budget.truncated) lines.push(`_(listagem truncada em ${MAX_TREE_ENTRIES} entradas)_`);
|
|
153
|
+
if (missing.length > 0) {
|
|
154
|
+
lines.push(`- não existem ainda (a criar): ${missing.map(m => `\`${m}\``).join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
return lines.join('\n');
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Monta o digest do estado do código em markdown. Best-effort: cada seção tem
|
|
164
|
+
* try/catch próprio; NUNCA lança — se nada puder ser gerado, retorna null.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} opts
|
|
167
|
+
* @param {string|null} [opts.sinceIso] ISO da criação da Feature (limite do git log)
|
|
168
|
+
* @param {string[]} [opts.paths] caminhos citados no plan (ver extractPathsFromPlan)
|
|
169
|
+
* @param {string} [opts.cwd] raiz do repo alvo
|
|
170
|
+
* @param {Function} [opts.exec] injetável para testes (default: execSync utf-8)
|
|
171
|
+
* @returns {Promise<string|null>} markdown do digest, ou null
|
|
172
|
+
*/
|
|
173
|
+
export async function buildCodeDigest({ sinceIso, paths = [], cwd = process.cwd(), exec = defaultExec } = {}) {
|
|
174
|
+
try {
|
|
175
|
+
const sections = [
|
|
176
|
+
buildCommitsSection({ sinceIso, cwd, exec }),
|
|
177
|
+
buildTreeSection({ paths, cwd }),
|
|
178
|
+
].filter(Boolean);
|
|
179
|
+
return sections.length > 0 ? sections.join('\n\n') : null;
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Crítica adversarial dos artefatos gerados por IA (plan e stories).
|
|
2
|
+
//
|
|
3
|
+
// Um segundo passe de IA, com papel de revisor cético, audita o documento
|
|
4
|
+
// recém-gerado contra a spec/regras de negócio/tech_context e classifica cada
|
|
5
|
+
// contradição como GRAVE ou MENOR. Findings graves aplicam a label
|
|
6
|
+
// `spec-wave:critique-failed`, que bloqueia o `spec-wave:ready` até correção.
|
|
7
|
+
//
|
|
8
|
+
// Contrato: a crítica NUNCA deve derrubar o fluxo principal — parse tolerante
|
|
9
|
+
// a JSON sujo, e falhas de API são tratadas pelo chamador como não-fatais.
|
|
10
|
+
|
|
11
|
+
import { generateDocument } from './claude.mjs';
|
|
12
|
+
import { LABEL_CRITIQUE_FAILED } from '../config.mjs';
|
|
13
|
+
|
|
14
|
+
// Tamanho máximo da resposta bruta preservada no fallback de parse.
|
|
15
|
+
const RAW_FALLBACK_MAX = 400;
|
|
16
|
+
|
|
17
|
+
// Redação específica por tipo de auditoria. 'plan' audita o plan.md contra a
|
|
18
|
+
// spec; 'stories' audita as stories propostas contra spec + plan.
|
|
19
|
+
const KIND_FOCUS = {
|
|
20
|
+
plan:
|
|
21
|
+
'Audite o plan.md contra o spec.md, as regras de negócio e o tech_context fornecidos. ' +
|
|
22
|
+
'Procure decisões técnicas que contradizem ou ignoram requisitos da spec e ' +
|
|
23
|
+
'tecnologias/serviços fora do tech_context.',
|
|
24
|
+
stories:
|
|
25
|
+
'Audite as Stories propostas (JSON) contra o spec.md e o plan.md fornecidos. ' +
|
|
26
|
+
'Procure stories que contradizem, invertem ou ignoram requisitos da spec ou ' +
|
|
27
|
+
'decisões do plan, e critérios de aceite incompatíveis com as regras de negócio.',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function buildSystemPrompt(kind) {
|
|
31
|
+
const focus = KIND_FOCUS[kind] || KIND_FOCUS.plan;
|
|
32
|
+
return `Você é um revisor técnico CÉTICO e adversarial. Seu papel é encontrar problemas, não elogiar.
|
|
33
|
+
|
|
34
|
+
${focus}
|
|
35
|
+
|
|
36
|
+
Liste:
|
|
37
|
+
- contradições diretas entre os documentos;
|
|
38
|
+
- inversões de requisito (ex.: consentimento→persistência invertidos: a spec exige consentimento ANTES de persistir e o documento persiste antes de pedir consentimento);
|
|
39
|
+
- violações de restrições explícitas (ex.: minimização de dados LGPD, limites de retenção, campos proibidos);
|
|
40
|
+
- itens que contradizem ou ignoram a spec.
|
|
41
|
+
|
|
42
|
+
Classifique cada finding:
|
|
43
|
+
- "grave": contradiz um requisito ou regra explícita — causaria implementação errada;
|
|
44
|
+
- "menor": inconsistência, omissão ou ambiguidade que merece atenção mas não inverte requisito.
|
|
45
|
+
|
|
46
|
+
NÃO invente problemas: se os documentos estiverem consistentes, retorne a lista vazia.
|
|
47
|
+
Escreva os findings em português (pt-BR).
|
|
48
|
+
|
|
49
|
+
Responda APENAS com JSON neste formato, sem texto adicional:
|
|
50
|
+
{"findings": [{"severity": "grave"|"menor", "text": "..."}]}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Interpreta a resposta do modelo crítico (função PURA — testável).
|
|
55
|
+
*
|
|
56
|
+
* Tolerante a JSON sujo: fences de código, texto ao redor do objeto e
|
|
57
|
+
* severities em maiúsculas/variantes ("GRAVE", "Grave"). Qualquer severity que
|
|
58
|
+
* não comece com "grave" vira "menor". Se nada parseável for encontrado,
|
|
59
|
+
* retorna a resposta bruta (truncada) como um único finding menor — a crítica
|
|
60
|
+
* nunca deve explodir.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} text resposta bruta do modelo
|
|
63
|
+
* @returns {{ grave: boolean, findings: Array<{ severity: 'grave'|'menor', text: string }> }}
|
|
64
|
+
*/
|
|
65
|
+
export function parseCritiqueResponse(text) {
|
|
66
|
+
const raw = (text || '').trim();
|
|
67
|
+
|
|
68
|
+
// Candidatos a JSON, do mais provável ao mais permissivo: conteúdo de fence
|
|
69
|
+
// de código, resposta inteira, primeiro objeto {...} encontrado no texto.
|
|
70
|
+
const candidates = [];
|
|
71
|
+
const fence = raw.match(/```[a-zA-Z]*\s*\n?([\s\S]*?)```/);
|
|
72
|
+
if (fence) candidates.push(fence[1]);
|
|
73
|
+
candidates.push(raw);
|
|
74
|
+
const obj = raw.match(/\{[\s\S]*\}/);
|
|
75
|
+
if (obj) candidates.push(obj[0]);
|
|
76
|
+
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
let parsed;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(candidate.trim());
|
|
81
|
+
} catch {
|
|
82
|
+
continue; // tenta o próximo candidato
|
|
83
|
+
}
|
|
84
|
+
const list = Array.isArray(parsed?.findings) ? parsed.findings
|
|
85
|
+
: Array.isArray(parsed) ? parsed
|
|
86
|
+
: null;
|
|
87
|
+
if (!list) continue;
|
|
88
|
+
const findings = list
|
|
89
|
+
.filter(f => f && typeof f.text === 'string' && f.text.trim())
|
|
90
|
+
.map(f => ({
|
|
91
|
+
severity: /^grave/i.test(String(f.severity || '').trim()) ? 'grave' : 'menor',
|
|
92
|
+
text: f.text.trim(),
|
|
93
|
+
}));
|
|
94
|
+
return { grave: findings.some(f => f.severity === 'grave'), findings };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fallback: resposta não-parseável → finding menor com a resposta bruta.
|
|
98
|
+
if (!raw) return { grave: false, findings: [] };
|
|
99
|
+
const truncated = raw.length > RAW_FALLBACK_MAX ? `${raw.slice(0, RAW_FALLBACK_MAX)}…` : raw;
|
|
100
|
+
return { grave: false, findings: [{ severity: 'menor', text: truncated }] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Monta o comentário de issue a partir dos findings classificados.
|
|
104
|
+
function renderMarkdown(findings) {
|
|
105
|
+
const header = '🔎 **Crítica adversarial (spec-wave)**';
|
|
106
|
+
if (findings.length === 0) {
|
|
107
|
+
return `${header}\n\n✅ crítica não encontrou contradições.`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const graves = findings.filter(f => f.severity === 'grave');
|
|
111
|
+
const menores = findings.filter(f => f.severity === 'menor');
|
|
112
|
+
const parts = [header];
|
|
113
|
+
if (graves.length > 0) {
|
|
114
|
+
parts.push(`### ❌ Graves\n\n${graves.map(f => `- ${f.text}`).join('\n')}`);
|
|
115
|
+
}
|
|
116
|
+
if (menores.length > 0) {
|
|
117
|
+
parts.push(`### ⚠️ Menores\n\n${menores.map(f => `- ${f.text}`).join('\n')}`);
|
|
118
|
+
}
|
|
119
|
+
parts.push(graves.length > 0
|
|
120
|
+
? `⛔ Há findings **graves**: a label \`${LABEL_CRITIQUE_FAILED}\` bloqueia o ` +
|
|
121
|
+
`\`spec-wave:ready\` até ser removida após a correção dos pontos acima.`
|
|
122
|
+
: `_Findings menores não bloqueiam o fluxo. Se houvesse graves, a label ` +
|
|
123
|
+
`\`${LABEL_CRITIQUE_FAILED}\` bloquearia o \`spec-wave:ready\` até ser removida após correção._`);
|
|
124
|
+
return parts.join('\n\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Roda a crítica adversarial sobre os artefatos fornecidos.
|
|
129
|
+
*
|
|
130
|
+
* Seções ausentes (spec/plan/tech_context/stories) são simplesmente omitidas
|
|
131
|
+
* do prompt. Erros de API PROPAGAM — o chamador deve tratar com try/catch e
|
|
132
|
+
* seguir o fluxo principal (crítica indisponível não é fatal).
|
|
133
|
+
*
|
|
134
|
+
* @param {object} params
|
|
135
|
+
* @param {'plan'|'stories'} params.kind o que está sendo auditado
|
|
136
|
+
* @param {string} [params.spec] conteúdo do spec.md
|
|
137
|
+
* @param {string} [params.plan] conteúdo do plan.md
|
|
138
|
+
* @param {string} [params.techContextYaml] tech_context serializado em YAML
|
|
139
|
+
* @param {object[]} [params.stories] stories propostas (antes da criação)
|
|
140
|
+
* @param {object[]} [params.usage] coletor de uso de IA (repassado ao generateDocument)
|
|
141
|
+
* @returns {Promise<{ grave: boolean, findings: Array<{ severity: string, text: string }>, markdown: string }>}
|
|
142
|
+
*/
|
|
143
|
+
export async function runCritique({ kind, spec, plan, techContextYaml, stories, usage } = {}) {
|
|
144
|
+
const sections = [];
|
|
145
|
+
if (spec) sections.push(`## spec.md\n\n${spec}`);
|
|
146
|
+
if (plan) sections.push(`## plan.md\n\n${plan}`);
|
|
147
|
+
if (techContextYaml) sections.push(`## tech_context\n\n\`\`\`yaml\n${techContextYaml}\n\`\`\``);
|
|
148
|
+
if (stories) sections.push(`## Stories propostas (JSON)\n\n\`\`\`json\n${JSON.stringify(stories, null, 2)}\n\`\`\``);
|
|
149
|
+
const userContent = sections.join('\n\n') || '(nenhum documento fornecido)';
|
|
150
|
+
|
|
151
|
+
const raw = await generateDocument(buildSystemPrompt(kind), userContent, {
|
|
152
|
+
action: 'critique',
|
|
153
|
+
temperature: 0,
|
|
154
|
+
maxTokens: 4096,
|
|
155
|
+
usage,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const { grave, findings } = parseCritiqueResponse(raw);
|
|
159
|
+
return { grave, findings, markdown: renderMarkdown(findings) };
|
|
160
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Dependências entre Stories (módulo puro — sem I/O, sem dependências).
|
|
2
|
+
//
|
|
3
|
+
// O decompose grava uma linha "Depende de: #4, #5" no corpo das Stories; os
|
|
4
|
+
// comandos `order`/`task`/`story` (Fase 2) leem essa linha para ordenar o
|
|
5
|
+
// trabalho e criar as relações blocked_by no GitHub.
|
|
6
|
+
|
|
7
|
+
// Linha de dependência: começo de linha, opcionalmente com marcador de lista
|
|
8
|
+
// (-, *, >) e/ou ênfase (_itálico_, **negrito**), "Depende de" case-insensitive,
|
|
9
|
+
// com ou sem dois-pontos. Os números são capturados depois via /#\d+/g.
|
|
10
|
+
const DEP_LINE_RE = /^[\s>*-]*[*_]{0,2}depende\s+de[*_]{0,2}\s*:?/i;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Formata a linha de dependências gravada no corpo de uma Story.
|
|
14
|
+
*
|
|
15
|
+
* @param {number[]} numbers números das issues das quais a Story depende
|
|
16
|
+
* @returns {string} ex.: "Depende de: #4, #5" — string vazia para lista vazia
|
|
17
|
+
*/
|
|
18
|
+
export function formatDependencyLine(numbers) {
|
|
19
|
+
if (!numbers || numbers.length === 0) return '';
|
|
20
|
+
return `Depende de: ${numbers.map(n => `#${n}`).join(', ')}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extrai os números de dependência do corpo de uma issue. Tolerante a
|
|
25
|
+
* variações de formatação: "Depende de: #4", "**Depende de** #4 e #5",
|
|
26
|
+
* "- _depende de_ #4, #5".
|
|
27
|
+
*
|
|
28
|
+
* @param {string|null|undefined} body corpo da issue
|
|
29
|
+
* @returns {number[]} números encontrados (sem duplicatas, na ordem do texto);
|
|
30
|
+
* [] para body nulo ou sem linha de dependência
|
|
31
|
+
*/
|
|
32
|
+
export function parseDependencies(body) {
|
|
33
|
+
if (!body) return [];
|
|
34
|
+
const numbers = [];
|
|
35
|
+
for (const line of body.split(/\r?\n/)) {
|
|
36
|
+
if (!DEP_LINE_RE.test(line)) continue;
|
|
37
|
+
for (const m of line.matchAll(/#(\d+)/g)) {
|
|
38
|
+
const n = parseInt(m[1], 10);
|
|
39
|
+
if (n && !numbers.includes(n)) numbers.push(n);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return numbers;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ordena Stories topologicamente pelas dependências (Kahn). Estável: entre as
|
|
47
|
+
* Stories liberadas ao mesmo tempo, vence a de menor number. Dependências que
|
|
48
|
+
* apontam para fora do conjunto (ex.: issue externa) são ignoradas.
|
|
49
|
+
*
|
|
50
|
+
* Contrato: NUNCA lança. Retorna sempre `{ order, cycle }`:
|
|
51
|
+
* • sem ciclo → order = todos os numbers em ordem de execução, cycle = [];
|
|
52
|
+
* • com ciclo → order = o que pôde ser ordenado, cycle = numbers restantes
|
|
53
|
+
* (envolvidos no ciclo ou bloqueados por ele), ambos em ordem crescente.
|
|
54
|
+
* O chamador decide se trata cycle.length > 0 como erro.
|
|
55
|
+
*
|
|
56
|
+
* @param {Array<{ number: number, dependsOn: number[] }>} stories
|
|
57
|
+
* @returns {{ order: number[], cycle: number[] }}
|
|
58
|
+
*/
|
|
59
|
+
export function orderStories(stories) {
|
|
60
|
+
const known = new Set(stories.map(s => s.number));
|
|
61
|
+
// indegree = quantas dependências INTERNAS ainda não resolvidas.
|
|
62
|
+
const indegree = new Map();
|
|
63
|
+
const dependents = new Map(); // number → numbers que dependem dele
|
|
64
|
+
for (const s of stories) {
|
|
65
|
+
const deps = (s.dependsOn || []).filter(d => known.has(d) && d !== s.number);
|
|
66
|
+
indegree.set(s.number, deps.length);
|
|
67
|
+
for (const d of deps) {
|
|
68
|
+
if (!dependents.has(d)) dependents.set(d, []);
|
|
69
|
+
dependents.get(d).push(s.number);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const order = [];
|
|
74
|
+
// Fila de liberados; o menor number sai primeiro (ordenação estável).
|
|
75
|
+
const ready = [...indegree.entries()].filter(([, deg]) => deg === 0).map(([n]) => n);
|
|
76
|
+
while (ready.length > 0) {
|
|
77
|
+
ready.sort((a, b) => a - b);
|
|
78
|
+
const n = ready.shift();
|
|
79
|
+
order.push(n);
|
|
80
|
+
for (const dep of dependents.get(n) || []) {
|
|
81
|
+
const deg = indegree.get(dep) - 1;
|
|
82
|
+
indegree.set(dep, deg);
|
|
83
|
+
if (deg === 0) ready.push(dep);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const cycle = [...indegree.entries()]
|
|
88
|
+
.filter(([, deg]) => deg > 0)
|
|
89
|
+
.map(([n]) => n)
|
|
90
|
+
.sort((a, b) => a - b);
|
|
91
|
+
return { order, cycle };
|
|
92
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Lint de idioma da saída da IA (módulo puro — sem I/O, sem dependências).
|
|
2
|
+
//
|
|
3
|
+
// Modelos ocasionalmente "vazam" caracteres de outros alfabetos no meio do
|
|
4
|
+
// texto (caso real: "Registration成功率" num spec pt-BR). Este lint detecta
|
|
5
|
+
// scripts Unicode incompatíveis com idiomas latinos via property escapes.
|
|
6
|
+
|
|
7
|
+
// Scripts que nunca aparecem em texto legítimo de idiomas latinos (pt-BR, en,
|
|
8
|
+
// es...). Acentos, emoji, pontuação e code fences NÃO casam com nenhum deles.
|
|
9
|
+
const FOREIGN_SCRIPTS = [
|
|
10
|
+
{ script: 'Han', re: /\p{Script=Han}/u },
|
|
11
|
+
{ script: 'Hiragana', re: /\p{Script=Hiragana}/u },
|
|
12
|
+
{ script: 'Katakana', re: /\p{Script=Katakana}/u },
|
|
13
|
+
{ script: 'Hangul', re: /\p{Script=Hangul}/u },
|
|
14
|
+
{ script: 'Cyrillic', re: /\p{Script=Cyrillic}/u },
|
|
15
|
+
{ script: 'Arabic', re: /\p{Script=Arabic}/u },
|
|
16
|
+
{ script: 'Thai', re: /\p{Script=Thai}/u },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Distância máxima (em caracteres) entre dois achados para agrupá-los num
|
|
20
|
+
// mesmo finding — evita explodir a lista quando um parágrafo inteiro vaza.
|
|
21
|
+
const GROUP_GAP = 20;
|
|
22
|
+
|
|
23
|
+
// Identifica o script de um caractere (ou null se não for estrangeiro).
|
|
24
|
+
function scriptOf(char) {
|
|
25
|
+
for (const s of FOREIGN_SCRIPTS) {
|
|
26
|
+
if (s.re.test(char)) return s.script;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Verifica se `text` contém caracteres de scripts incompatíveis com o
|
|
33
|
+
* idioma-alvo. Para pt-BR (e idiomas latinos em geral), qualquer caractere
|
|
34
|
+
* CJK, Hangul, cirílico, árabe ou tailandês é um finding.
|
|
35
|
+
*
|
|
36
|
+
* Findings contíguos (distância ≤ ~20 chars) são agrupados num único item
|
|
37
|
+
* para runs longas não gerarem um finding por caractere.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} text texto a verificar
|
|
40
|
+
* @param {object} [opts]
|
|
41
|
+
* @param {string} [opts.lang='pt-BR'] idioma-alvo (informativo; todos os
|
|
42
|
+
* idiomas suportados hoje são latinos)
|
|
43
|
+
* @param {string[]} [opts.allowlist=[]] substrings a ignorar (ex.: nomes
|
|
44
|
+
* próprios ou termos técnicos legítimos em outro alfabeto)
|
|
45
|
+
* @returns {{ ok: boolean, findings: Array<{ index: number, char: string, script: string, excerpt: string }> }}
|
|
46
|
+
* index = posição do primeiro caractere do grupo; char = trecho do
|
|
47
|
+
* grupo (do primeiro ao último caractere estrangeiro); script = script
|
|
48
|
+
* do primeiro caractere; excerpt = ~40 chars ao redor do grupo.
|
|
49
|
+
*/
|
|
50
|
+
export function lintLanguage(text, { lang = 'pt-BR', allowlist = [] } = {}) {
|
|
51
|
+
const source = text || '';
|
|
52
|
+
// Substitui trechos da allowlist por espaços (mesmo comprimento) para
|
|
53
|
+
// preservar os índices/excerpts do texto original.
|
|
54
|
+
let scanned = source;
|
|
55
|
+
for (const allowed of allowlist) {
|
|
56
|
+
if (!allowed) continue;
|
|
57
|
+
scanned = scanned.split(allowed).join(' '.repeat(allowed.length));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Coleta cada caractere estrangeiro com seu índice e script.
|
|
61
|
+
const hits = [];
|
|
62
|
+
for (const m of scanned.matchAll(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Cyrillic}\p{Script=Arabic}\p{Script=Thai}]/gu)) {
|
|
63
|
+
hits.push({ index: m.index, char: m[0], script: scriptOf(m[0]) });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Agrupa hits próximos num único finding.
|
|
67
|
+
const findings = [];
|
|
68
|
+
let group = null;
|
|
69
|
+
const flush = () => {
|
|
70
|
+
if (!group) return;
|
|
71
|
+
const start = Math.max(0, group.start - 20);
|
|
72
|
+
const end = Math.min(source.length, group.end + 20);
|
|
73
|
+
findings.push({
|
|
74
|
+
index: group.start,
|
|
75
|
+
char: source.slice(group.start, group.end),
|
|
76
|
+
script: group.script,
|
|
77
|
+
excerpt: source.slice(start, end),
|
|
78
|
+
});
|
|
79
|
+
group = null;
|
|
80
|
+
};
|
|
81
|
+
for (const hit of hits) {
|
|
82
|
+
if (group && hit.index - group.end <= GROUP_GAP) {
|
|
83
|
+
group.end = hit.index + hit.char.length;
|
|
84
|
+
} else {
|
|
85
|
+
flush();
|
|
86
|
+
group = { start: hit.index, end: hit.index + hit.char.length, script: hit.script };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
flush();
|
|
90
|
+
|
|
91
|
+
return { ok: findings.length === 0, findings };
|
|
92
|
+
}
|