@spec-wave/cli 0.5.11 → 0.7.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.
@@ -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,158 @@
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
+ * @returns {Promise<{ grave: boolean, findings: Array<{ severity: string, text: string }>, markdown: string }>}
141
+ */
142
+ export async function runCritique({ kind, spec, plan, techContextYaml, stories } = {}) {
143
+ const sections = [];
144
+ if (spec) sections.push(`## spec.md\n\n${spec}`);
145
+ if (plan) sections.push(`## plan.md\n\n${plan}`);
146
+ if (techContextYaml) sections.push(`## tech_context\n\n\`\`\`yaml\n${techContextYaml}\n\`\`\``);
147
+ if (stories) sections.push(`## Stories propostas (JSON)\n\n\`\`\`json\n${JSON.stringify(stories, null, 2)}\n\`\`\``);
148
+ const userContent = sections.join('\n\n') || '(nenhum documento fornecido)';
149
+
150
+ const raw = await generateDocument(buildSystemPrompt(kind), userContent, {
151
+ action: 'critique',
152
+ temperature: 0,
153
+ maxTokens: 4096,
154
+ });
155
+
156
+ const { grave, findings } = parseCritiqueResponse(raw);
157
+ return { grave, findings, markdown: renderMarkdown(findings) };
158
+ }
@@ -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
+ }
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
4
  import { upsertFile, getFileContent, isRepoInitialized } from '../api/github-rest.mjs';
5
+ import { WORKFLOW_FILES } from '../config.mjs';
5
6
 
6
7
  const __dir = path.dirname(fileURLToPath(import.meta.url));
7
8
  const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
@@ -22,6 +23,8 @@ export async function setupFiles(token, owner, repo, spinner) {
22
23
  );
23
24
  }
24
25
 
26
+ // Workflows vêm de WORKFLOW_FILES (config.mjs, fonte única) — adicionar um
27
+ // workflow novo lá basta para o init (e o update) instalarem.
25
28
  const filesToCreate = [
26
29
  {
27
30
  path: '.github/ISSUE_TEMPLATE/plan-template.md',
@@ -33,26 +36,11 @@ export async function setupFiles(token, owner, repo, spinner) {
33
36
  content: readTemplate('issue', 'spec-template.md'),
34
37
  message: 'chore: add spec.md issue template [spec-wave]',
35
38
  },
36
- {
37
- path: '.github/workflows/generate-plan.yml',
38
- content: readTemplate('workflows', 'generate-plan.yml'),
39
- message: 'chore: add generate-plan workflow [spec-wave]',
40
- },
41
- {
42
- path: '.github/workflows/generate-spec.yml',
43
- content: readTemplate('workflows', 'generate-spec.yml'),
44
- message: 'chore: add generate-spec workflow [spec-wave]',
45
- },
46
- {
47
- path: '.github/workflows/validate.yml',
48
- content: readTemplate('workflows', 'validate.yml'),
49
- message: 'chore: add validate workflow [spec-wave]',
50
- },
51
- {
52
- path: '.github/workflows/decompose.yml',
53
- content: readTemplate('workflows', 'decompose.yml'),
54
- message: 'chore: add decompose workflow [spec-wave]',
55
- },
39
+ ...WORKFLOW_FILES.map((file) => ({
40
+ path: `.github/workflows/${file}`,
41
+ content: readTemplate('workflows', file),
42
+ message: `chore: add ${file.replace(/\.yml$/, '')} workflow [spec-wave]`,
43
+ })),
56
44
  ];
57
45
 
58
46
  for (let i = 0; i < filesToCreate.length; i++) {