@aksp/opencrew 1.5.0 → 1.6.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +35 -14
  3. package/package.json +1 -1
  4. package/src/cli.js +2 -1
  5. package/src/commands/init.js +42 -30
  6. package/src/commands/update.js +58 -42
  7. package/src/lib/ides.js +15 -12
  8. package/src/lib/manifest.js +89 -0
  9. package/src/lib/migrations.js +147 -0
  10. package/templates/.mcp.json +1 -1
  11. package/templates/AGENTS.md +2 -1
  12. package/templates/_opencrew/.opencrew-version +1 -1
  13. package/templates/_opencrew/core/prompts/build.prompt.md +14 -0
  14. package/templates/_opencrew/core/prompts/discovery.prompt.md +15 -2
  15. package/templates/_opencrew/core/runner.pipeline.md +86 -20
  16. package/templates/_opencrew/core/scripts/comum.mjs +48 -0
  17. package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +82 -0
  18. package/templates/_opencrew/core/scripts/conferir-fontes/coleta.mjs +104 -0
  19. package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +52 -0
  20. package/templates/_opencrew/core/scripts/conferir-fontes.mjs +135 -0
  21. package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +33 -0
  22. package/templates/_opencrew/core/scripts/verificar/arquivos.mjs +50 -0
  23. package/templates/_opencrew/core/scripts/verificar/html.mjs +52 -0
  24. package/templates/_opencrew/core/scripts/verificar/leitura.mjs +138 -66
  25. package/templates/_opencrew/core/scripts/verificar/medicao.mjs +142 -0
  26. package/templates/_opencrew/core/scripts/verificar/pecas.mjs +190 -0
  27. package/templates/_opencrew/core/scripts/verificar/proibicoes.mjs +100 -0
  28. package/templates/_opencrew/core/scripts/verificar/regras.mjs +110 -91
  29. package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +43 -0
  30. package/templates/_opencrew/core/scripts/verificar/secoes.mjs +145 -0
  31. package/templates/_opencrew/core/scripts/verificar.mjs +162 -88
  32. package/templates/skills/opencrew-best-practice-creator/SKILL.md +4 -4
@@ -0,0 +1,100 @@
1
+ // Proibições da memória da crew: os itens de `## Proibições Explícitas` do `memories.md`.
2
+ // Numa linha, vale como proibido o termo entre aspas até o primeiro marcador de troca; o que vem
3
+ // depois do marcador é termo preferido. Spec: fase-r1-reparos-1-6-1.md, regra 11.
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { lerTexto, semAcento } from './leitura.mjs';
7
+
8
+ // Entre aspas retas o termo não começa nem acaba por espaço ou aspa: `""` e a polegada de `15"`
9
+ // não abrem um par, e o termo de verdade, mais adiante na linha, continua sendo lido.
10
+ const ASPAS = /"([^\s"](?:[^"]{0,198}[^\s"])?)"|“([^”]{1,200})”|‘([^’]{1,200})’|`([^`]{1,200})`|(?<![\p{L}])'([^']{1,200})'(?![\p{L}])/gu;
11
+ const MARCADORES = new Set(['prefira', 'preferir', 'use', 'usar', 'utilize', 'utilizar', '→', '->']);
12
+ const NEGACOES = new Set('nao nunca nem jamais sem evite evitar proibido proibida proibidos proibidas vetado vetada parar pare deixar deixe'.split(' '));
13
+ // Com estas expressões a linha está escrita ao contrário: todos os termos continuam proibidos.
14
+ const SEM_TROCA = /(?<![\p{L}\p{N}])(?:em vez de|ao inves de|no lugar de)(?![\p{L}\p{N}])/u;
15
+
16
+ /** Fichas da linha, na ordem: termos entre aspas, e palavras e setas de fora das aspas. */
17
+ function fichas(linha) {
18
+ const saida = [];
19
+ let pos = 0;
20
+ const deFora = (ate) => {
21
+ for (const m of linha.slice(pos, ate).matchAll(/[\p{L}\p{N}]+|→|->/gu)) {
22
+ saida.push({ palavra: semAcento(m[0]), fim: pos + m.index + m[0].length });
23
+ }
24
+ };
25
+ for (const m of linha.matchAll(ASPAS)) {
26
+ deFora(m.index);
27
+ saida.push({ termo: m.slice(1).find(Boolean), inicio: m.index });
28
+ pos = m.index + m[0].length;
29
+ }
30
+ deFora(linha.length);
31
+ return saida;
32
+ }
33
+
34
+ /** `por` só é marcador logo antes de um termo entre aspas. */
35
+ function ehMarcador(f, i, linha) {
36
+ const { palavra, fim } = f[i];
37
+ if (MARCADORES.has(palavra)) return true;
38
+ const depois = f[i + 1];
39
+ return palavra === 'por' && depois?.termo != null && linha.slice(fim, depois.inicio).trim() === '';
40
+ }
41
+
42
+ /** Há negação entre as 3 palavras antes do marcador? A procura para no termo entre aspas anterior. */
43
+ function negado(f, i) {
44
+ for (let n = 1; n <= 3 && f[i - n]?.palavra; n++) {
45
+ if (NEGACOES.has(f[i - n].palavra)) return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ /** Posição do primeiro marcador de troca que vale: depois do primeiro termo e sem negação antes. */
51
+ function primeiroMarcador(f, linha) {
52
+ const primeiroTermo = f.findIndex((x) => x.termo != null);
53
+ const deFora = f.filter((x) => x.palavra).map((x) => x.palavra).join(' ');
54
+ if (primeiroTermo < 0 || SEM_TROCA.test(deFora)) return -1;
55
+ return f.findIndex((x, i) => i > primeiroTermo && x.palavra && ehMarcador(f, i, linha) && !negado(f, i));
56
+ }
57
+
58
+ /** Termos proibidos e termos preferidos de uma linha de proibição. */
59
+ export function lerLinha(linha) {
60
+ const f = fichas(linha);
61
+ const corte = primeiroMarcador(f, linha);
62
+ const termos = (de, ate) => f.slice(de, ate).filter((x) => x.termo != null).map((x) => x.termo);
63
+ return corte < 0 ? { proibidos: termos(0), preferidos: [] } : { proibidos: termos(0, corte), preferidos: termos(corte) };
64
+ }
65
+
66
+ /**
67
+ * Itens de lista (`-`, `*`, `+` ou numerada) da seção "Proibições Explícitas" — cabeçalho de
68
+ * nível 2 a 4, com ou sem símbolo antes do texto —, até o próximo cabeçalho do mesmo nível ou
69
+ * acima, ou até uma linha `---`.
70
+ */
71
+ function itensDaSecao(texto) {
72
+ const linhas = texto.split(/\r?\n/);
73
+ const titulos = linhas.map((l) => l.match(/^(#{2,4})\s+(.*)$/s));
74
+ const inicio = titulos.findIndex((t) => t && semAcento(t[2]).replace(/^[^\p{L}\p{N}]+/u, '').startsWith('proibicoes explicitas'));
75
+ if (inicio < 0) return [];
76
+ const acima = new RegExp(`^#{1,${titulos[inicio][1].length}}\\s`);
77
+ const resto = linhas.slice(inicio + 1);
78
+ const fim = resto.findIndex((l) => acima.test(l) || l.trim() === '---');
79
+ return resto.slice(0, fim < 0 ? resto.length : fim).filter((l) => /^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(l));
80
+ }
81
+
82
+ /**
83
+ * Proibições da crew. Itens sem aspas são contados, mas não verificados.
84
+ * @returns {Promise<{ existe: boolean, termos: string[], preferidos: string[], semAspas: number }>}
85
+ */
86
+ export async function lerProibicoes(raiz, crew) {
87
+ // `resolve`, não `join`: a crew pode vir em caminho absoluto de dentro do projeto.
88
+ const arquivo = path.resolve(raiz, crew, '_memory', 'memories.md');
89
+ const r = { existe: existsSync(arquivo), termos: [], preferidos: [], semAspas: 0 };
90
+ if (!r.existe) return r;
91
+ for (const linha of itensDaSecao(await lerTexto(arquivo))) {
92
+ const { proibidos, preferidos } = lerLinha(linha);
93
+ if (!proibidos.length && !preferidos.length) r.semAspas += 1;
94
+ r.termos.push(...proibidos);
95
+ r.preferidos.push(...preferidos);
96
+ }
97
+ const proibido = new Set(r.termos.map(semAcento));
98
+ r.preferidos = r.preferidos.filter((t) => !proibido.has(semAcento(t)));
99
+ return r;
100
+ }
@@ -1,95 +1,116 @@
1
- // Regras do verificador automático (specs/fase-u1-revisor-com-dentes.md §5).
2
- // Máximos bloqueiam (fácil de corrigir: encurtar); mínimos alertam (podem depender do usuário).
3
- import { lerFrontmatter, semFrontmatter, semAcento } from './leitura.mjs';
1
+ // Checagens gerais do verificador automático — valem para qualquer arquivo de texto:
2
+ // placeholders, [PREENCHER], termos proibidos e afirmações a confirmar.
3
+ // Specs: fase-u1-revisor-com-dentes.md §5 e fase-r1-reparos-1-6-1.md, regras 8 e 10.
4
+ // As expressões regulares daqui rodam sobre texto do usuário: nenhuma tem repetição sem teto
5
+ // que possa recomeçar em cada posição do texto.
6
+ import { semFrontmatter, semDiacritico } from './leitura.mjs';
4
7
 
5
8
  const segmentador = new Intl.Segmenter('pt', { granularity: 'grapheme' });
6
9
 
7
10
  /** Bloqueio que só o usuário resolve ([PREENCHER: …]). */
8
11
  export const FALTA_INFO = 'Falta informação sua';
12
+ /** Itens das linhas "Não medido — …" e "Não verificado — …" (nível: alerta ou nenhum). */
13
+ export const NAO_MEDIDO = 'Não medido';
14
+ export const NAO_VERIFICADO = 'Não verificado';
9
15
 
10
- /** Caracteres visíveis: sem marcadores de negrito/itálico; emoji conta 1. */
16
+ /** Caracteres visíveis: sem marcadores de negrito/itálico; emoji conta 1; quebra de linha conta. */
11
17
  export function contar(texto) {
12
18
  const limpo = String(texto).replace(/\*\*|__/g, '').replace(/\*([^*\n]+)\*/g, '$1').trim();
13
19
  return [...segmentador.segment(limpo)].length;
14
20
  }
15
21
 
16
- const contarHashtags = (texto) => (texto.match(/#[\p{L}\p{N}_]+/gu) ?? []).length;
22
+ export const item = (nome, medido, limite, nivel, detalhe = '') => ({ item: nome, medido, limite, nivel, detalhe });
17
23
 
18
- const item = (nome, medido, limite, nivel, detalhe = '') => ({ item: nome, medido, limite, nivel, detalhe });
24
+ const TRECHO = /[^\s()[\]<>"']+/g; // trecho sem espaço: um link, um número, uma palavra
25
+ const REPETIDO = /(\d)\1{5,}/; // 6 ou mais dígitos iguais seguidos
26
+ const COR_HEX = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3})(?![0-9a-z])/gi;
27
+ const EM_LINK = /http|wa\.me|tel:|\//i;
28
+ const VARIAVEL = /\{\{[^{}\n]{1,100}\}\}/g;
29
+ const EXPRESSOES = [
30
+ /\[(?:empresa|cliente|nome|feira|evento|produto|cidade|link|url|telefone|e-?mail|data)\b[^\]]{0,200}\](?!\()/giu,
31
+ /lorem ipsum/gi,
32
+ /\bX{3,}\b(?![ \t]+\p{Lu})/gu, // "XXX Congresso" é numeral romano, não placeholder
33
+ ];
34
+ const DOMINIOS = /\b(?:example\.com|seusite\.com(?:\.br)?|suaempresa\.com(?:\.br)?)\b/gi;
19
35
 
20
- function maximo(nome, medido, limite) {
21
- if (typeof limite !== 'number') return null;
22
- return item(nome, medido, limite, medido > limite ? 'bloqueio' : 'ok');
36
+ function semPontuacaoFinal(s) {
37
+ let fim = s.length;
38
+ while (fim > 0 && '.,;:!?'.includes(s[fim - 1])) fim--;
39
+ return s.slice(0, fim);
23
40
  }
24
41
 
25
- function minimo(nome, medido, limite) {
26
- if (typeof limite !== 'number') return null;
27
- return item(nome, medido, limite, medido < limite ? 'alerta' : 'ok');
42
+ /**
43
+ * Dígitos repetidos só contam em link ou telefone: trecho com `http`, `wa.me`, `tel:` ou `/`,
44
+ * ou sequência de 10 dígitos ou mais. Cor hexadecimal nunca conta.
45
+ */
46
+ function digitosRepetidos(texto) {
47
+ const achados = [];
48
+ for (const [trecho] of texto.matchAll(TRECHO)) {
49
+ const semCor = trecho.replace(COR_HEX, '');
50
+ if (!REPETIDO.test(semCor)) continue;
51
+ const telefone = (semCor.match(/\d{10,}/g) ?? []).some((seq) => REPETIDO.test(seq));
52
+ if (telefone || EM_LINK.test(semCor)) achados.push(trecho);
53
+ }
54
+ return achados;
28
55
  }
29
56
 
30
- const NAO_CONTA = /^(mailto:|tel:|https?:\/\/(wa\.me|api\.whatsapp\.com)\/)/i;
57
+ /** O texto tem `{{variável}}` de personalização? */
58
+ export const temVariavel = (texto) => texto.search(VARIAVEL) >= 0;
31
59
 
32
- function contarLinks(corpo, dominio) {
33
- const urls = [...corpo.matchAll(/\[[^\]]*\]\(([^)\s]+)[^)]*\)/g)].map((m) => m[1]).filter((u) => !NAO_CONTA.test(u));
34
- const interno = (u) => !/^https?:\/\//i.test(u) || (dominio && new URL(u).hostname.replace(/^www\./, '') === dominio);
35
- return { internos: urls.filter(interno).length, externos: urls.filter((u) => !interno(u)).length };
36
- }
60
+ /** O detalhe de um achado nunca cresce com o arquivo: acima do teto, é cortado com reticências. */
61
+ const cortar = (s, teto) => (s.length > teto ? `${s.slice(0, teto).trimEnd()}…` : s);
37
62
 
38
- /** Blog: frontmatter com título → título, meta description e links. */
39
- export function regrasBlog(texto, limites, dominio) {
40
- const fm = lerFrontmatter(texto);
41
- const titulo = fm?.title ?? fm?.titulo;
42
- if (titulo == null || !limites) return [];
43
- const meta = fm.meta_description ?? fm.meta_descricao;
44
- const { internos, externos } = contarLinks(semFrontmatter(texto), dominio);
45
- return [
46
- maximo('Título (SEO) — caracteres', contar(titulo), limites.title_max_chars),
47
- meta != null ? maximo('Meta description — caracteres', contar(meta), limites.meta_description_chars) : null,
48
- minimo('Links internos', internos, limites.min_internal_links),
49
- minimo('Links externos', externos, limites.min_external_links),
50
- ].filter(Boolean);
63
+ function placeholders(texto, variavelBloqueia) {
64
+ const expressoes = [...EXPRESSOES, ...(variavelBloqueia ? [VARIAVEL] : []), DOMINIOS];
65
+ const achados = [...digitosRepetidos(texto), ...expressoes.flatMap((rx) => [...texto.matchAll(rx)].map((m) => m[0]))];
66
+ return achados.map((achado) => item('Placeholder', null, null, 'bloqueio', cortar(semPontuacaoFinal(achado), 160)));
51
67
  }
52
68
 
53
- /** Seções por canal (cabeçalhos "Legenda Instagram", "Post LinkedIn", "Tweet"…). */
54
- export function regrasCanais(secoes, limitesPorFormato) {
55
- const itens = [];
56
- for (const s of secoes) {
57
- const t = semAcento(s.titulo);
58
- const ig = limitesPorFormato['instagram-feed'];
59
- if (/instagram/.test(t) && /legenda|caption/.test(t) && ig) {
60
- itens.push(maximo('Legenda Instagram — caracteres', contar(s.corpo), ig.caption_max_chars));
61
- itens.push(maximo('Legenda Instagram — hashtags', contarHashtags(s.corpo), ig.hashtags_max));
62
- } else if (/instagram/.test(t) && /carrossel|carousel/.test(t) && ig) {
63
- const slides = (s.corpo.match(/^\s*(?:#{3,6}\s*|\*\*\s*)slide\s*\d+/gim) ?? []).length;
64
- if (slides) itens.push(maximo('Carrossel Instagram — slides', slides, ig.carousel_max_slides));
65
- }
66
- const li = limitesPorFormato['linkedin-post'];
67
- if (/linkedin/.test(t) && !/carrossel|carousel/.test(t) && li) {
68
- itens.push(maximo('Post LinkedIn — caracteres', contar(s.corpo), li.post_max_chars));
69
- itens.push(maximo('Post LinkedIn — hashtags', contarHashtags(s.corpo), li.hashtags_max));
70
- }
71
- const tw = limitesPorFormato['twitter-post'];
72
- if (/tweet|twitter/.test(t) && tw) {
73
- s.corpo.split(/\n\s*\n/).filter((p) => p.trim()).forEach((p, i) => {
74
- const r = maximo(`Tweet ${i + 1} — caracteres`, contar(p), tw.tweet_max_chars);
75
- if (r?.nivel === 'bloqueio') itens.push(r);
76
- });
77
- }
69
+ /**
70
+ * `[PREENCHER: …]`, de qualquer tamanho, numa passada só: cada abertura vai até o primeiro `]`;
71
+ * a que cai dentro do pedido anterior é pulada; sem `]` adiante, a busca para.
72
+ */
73
+ function pedidosAoUsuario(texto) {
74
+ const pedidos = [];
75
+ let fecha = -1;
76
+ for (const m of texto.matchAll(/\[PREENCHER:?/giu)) {
77
+ if (m.index < fecha) continue;
78
+ fecha = texto.indexOf(']', m.index);
79
+ if (fecha < 0) break;
80
+ const dentro = texto.slice(m.index + m[0].length, fecha).trim();
81
+ pedidos.push(item(FALTA_INFO, null, null, 'bloqueio', cortar(dentro, 300) || texto.slice(m.index, fecha + 1)));
78
82
  }
79
- return itens.filter(Boolean);
83
+ return pedidos;
80
84
  }
81
85
 
82
- const PLACEHOLDERS = [
83
- /(?:https?:\/\/)?[^\s()[\]<>"']*?(\d)\1{5,}[^\s()[\]<>"']*/g, // 6+ dígitos repetidos (wa.me/5584999999999)
84
- /\[(?:empresa|cliente|nome|feira|evento|produto|cidade|link|url|telefone|e-?mail|data)\b[^\]]*\](?!\()/giu,
85
- /lorem ipsum/gi,
86
- /\bX{3,}\b/g,
87
- /\{\{[^}]+\}\}/g,
88
- /\b(?:example\.com|seusite\.com(?:\.br)?|suaempresa\.com(?:\.br)?)\b/gi,
89
- ];
86
+ const ESPECIAIS = /[.*+?^${}()|[\]\\]/g;
87
+ const BORDA = '[\\p{L}\\p{N}]';
88
+
89
+ /**
90
+ * Termo proibido é palavra inteira, com plural simples (`s`, `es`), sem diferenciar maiúsculas e
91
+ * acentos. Sigla (até 5 letras, todas maiúsculas) compara diferenciando maiúsculas. Entre as
92
+ * palavras do termo vale qualquer espaço: quebra de linha, dois espaços, espaço não separável.
93
+ */
94
+ function casaTermo(termo, exato, minusculo) {
95
+ const alvo = semDiacritico(termo.trim());
96
+ if (!alvo) return false;
97
+ const sigla = /^\p{Lu}{1,5}$/u.test(alvo);
98
+ const padrao = (sigla ? alvo : alvo.toLowerCase()).replace(ESPECIAIS, '\\$&').replace(/\s+/g, '\\s+');
99
+ const rx = new RegExp(`(?<!${BORDA})${padrao}(?:[sS]|[eE][sS])?(?!${BORDA})`, 'u');
100
+ return rx.test(sigla ? exato : minusculo);
101
+ }
102
+
103
+ function termosProibidos(texto, proibidos) {
104
+ const exato = semDiacritico(texto);
105
+ const minusculo = exato.toLowerCase();
106
+ return proibidos
107
+ .filter((termo) => casaTermo(termo, exato, minusculo))
108
+ .map((termo) => item('Termo proibido (memória da crew)', null, null, 'bloqueio', termo));
109
+ }
90
110
 
91
111
  const PRIMEIRA_PESSOA = /(?<!\p{L})(eu|nós|nosso|nossa|nossos|nossas|investimos|atendemos|fizemos|ajudamos|fundamos|começamos|criamos|entregamos|nossa equipe)(?!\p{L})/iu;
92
- const DADO_CONCRETO = /R\$\s?\d|US\$\s?\d|\d+([.,]\d+)?\s?%|\d+\s+(clientes|empresas|eventos|anos|projetos|pessoas)/iu;
112
+ // `(?<!\d)`: só o começo de cada número é tentado.
113
+ const DADO_CONCRETO = /R\$\s?\d|US\$\s?\d|(?<!\d)\d+(?:[.,]\d+)?\s?%|(?<!\d)\d+\s+(?:clientes|empresas|eventos|anos|projetos|pessoas)/iu;
93
114
 
94
115
  // Ano só conta como dado concreto se for passado: "Congresso 2026" (ano atual/futuro) é nome de
95
116
  // evento, não afirmação sobre a história da empresa (achado no uso real).
@@ -99,29 +120,27 @@ function temDadoConcreto(frase) {
99
120
  return [...frase.matchAll(/(?<!\d)((?:19|20)\d{2})(?!\d)/g)].some((m) => Number(m[1]) < atual);
100
121
  }
101
122
 
102
- const citar = (frase) => {
103
- const limpa = frase.replace(/\*\*|__/g, '').replace(/\*([^*\n]+)\*/g, '$1').trim();
104
- return limpa.length > 160 ? `${limpa.slice(0, 160).trimEnd()}…` : limpa;
105
- };
106
-
107
- /** Checagens que valem para qualquer texto. */
108
- export function regrasGerais(texto, proibidos) {
109
- const corpo = semFrontmatter(texto);
110
- const itens = [];
111
- for (const rx of PLACEHOLDERS) {
112
- for (const m of texto.matchAll(rx)) itens.push(item('Placeholder', null, null, 'bloqueio', m[0].replace(/[.,;:!?]+$/, '')));
113
- }
114
- for (const m of texto.matchAll(/\[PREENCHER:?\s*([^\]]*)\]/giu)) {
115
- itens.push(item(FALTA_INFO, null, null, 'bloqueio', m[1].trim() || m[0]));
116
- }
117
- const normal = semAcento(texto);
118
- for (const termo of proibidos) {
119
- if (normal.includes(semAcento(termo))) itens.push(item('Termo proibido (memória da crew)', null, null, 'bloqueio', termo));
120
- }
121
- for (const frase of corpo.split(/(?<=[.!?])\s+|\n+/)) {
122
- if (PRIMEIRA_PESSOA.test(frase) && temDadoConcreto(frase)) {
123
- itens.push(item('Afirmação a confirmar', null, null, 'alerta', citar(frase)));
124
- }
125
- }
126
- return itens;
123
+ const citar = (frase) => cortar(frase.replace(/\*\*|__/g, '').replace(/\*([^*\n]+)\*/g, '$1').trim(), 160);
124
+
125
+ function afirmacoes(corpo) {
126
+ return corpo
127
+ .split(/(?<=[.!?])\s+|\n+/)
128
+ .filter((frase) => PRIMEIRA_PESSOA.test(frase) && temDadoConcreto(frase))
129
+ .map((frase) => item('Afirmação a confirmar', null, null, 'alerta', citar(frase)));
130
+ }
131
+
132
+ /**
133
+ * Checagens que valem para qualquer texto.
134
+ * @param {string} texto
135
+ * @param {string[]} proibidos termos proibidos da memória da crew
136
+ * @param {{ variavelBloqueia?: boolean }} [opcoes] false em e-mail e WhatsApp: `{{variável}}`
137
+ * vira nota informativa (quem chama escreve a nota)
138
+ */
139
+ export function regrasGerais(texto, proibidos, { variavelBloqueia = true } = {}) {
140
+ return [
141
+ ...placeholders(texto, variavelBloqueia),
142
+ ...pedidosAoUsuario(texto),
143
+ ...termosProibidos(texto, proibidos),
144
+ ...afirmacoes(semFrontmatter(texto)),
145
+ ];
127
146
  }
@@ -0,0 +1,43 @@
1
+ // Relatório do verificador em markdown (PT-BR). A última linha é o status que o runner lê.
2
+ import { NAO_MEDIDO, NAO_VERIFICADO } from './regras.mjs';
3
+
4
+ const ROTULO = { bloqueio: '❌ Bloqueio', alerta: '⚠️ Alerta', ok: '✅ OK' };
5
+ const plural = (n, um, varios) => `${n} ${n === 1 ? um : varios}`;
6
+ const ehNaoMedido = (i) => i.item === NAO_MEDIDO || i.item === NAO_VERIFICADO;
7
+
8
+ function tabela(medidos) {
9
+ if (!medidos.length) return [];
10
+ const linhas = medidos.map((i) => `| ${i.item.replace(/\|/g, '\\|')} | ${i.medido} | ${i.minimo ? '≥' : '≤'} ${i.limite} | ${ROTULO[i.nivel]} |`);
11
+ return ['| Item | Medido | Limite | Resultado |', '|---|---|---|---|', ...linhas];
12
+ }
13
+
14
+ /** "Não medido" e "Não verificado" começam por ⚠️ quando são alerta e por ⚪ quando não têm nível. */
15
+ function linhaDoAchado(i) {
16
+ if (ehNaoMedido(i)) return `- ${i.nivel === 'alerta' ? '⚠️' : '⚪'} ${i.item} — ${i.detalhe}`;
17
+ return `- ${ROTULO[i.nivel]} — ${i.item}: "${i.detalhe}"`;
18
+ }
19
+
20
+ function secaoDoArquivo(a) {
21
+ const medidos = tabela(a.itens.filter((i) => i.medido != null));
22
+ const achados = a.itens.filter((i) => i.medido == null).map(linhaDoAchado);
23
+ if (a.fecho) achados.push(`- ${a.fecho}`);
24
+ const entre = medidos.length && achados.length ? [''] : [];
25
+ return [`### ${a.arquivo}`, '', ...medidos, ...entre, ...achados, ''];
26
+ }
27
+
28
+ /** @param {object} r resultado de `verificar()` */
29
+ export function formatarRelatorio(r) {
30
+ const naoTexto = r.naoTexto ?? [];
31
+ const notas = [...r.notas];
32
+ if (naoTexto.length) notas.push(`⚪ ${NAO_VERIFICADO} — não é texto (${naoTexto.length}): ${naoTexto.join(', ')}`);
33
+ const resumo = [plural(r.bloqueios, 'bloqueio', 'bloqueios'), plural(r.alertas, 'alerta', 'alertas'), plural(r.naoMedidos ?? 0, 'não medido', 'não medidos')];
34
+ return [
35
+ '## Verificação automática',
36
+ '',
37
+ ...r.arquivos.flatMap(secaoDoArquivo),
38
+ ...(notas.length ? ['**Notas:**', ...notas.map((n) => `- ${n}`), ''] : []),
39
+ `**Resumo: ${resumo.join(', ')}**`,
40
+ '',
41
+ `VERIFICACAO:${r.status}`,
42
+ ].join('\n');
43
+ }
@@ -0,0 +1,145 @@
1
+ // Seções de um texto nos dois jeitos de escrever: cabeçalho markdown e linha `=== RÓTULO ===`.
2
+ // Devolve os trechos que podem virar peça, cada um com o seu texto próprio (o corpo sem os
3
+ // trechos das candidatas de dentro). Nenhuma expressão regular daqui refaz a linha inteira a cada
4
+ // tentativa: a do cabeçalho leva a flag `s`, para o resto da linha nunca falhar.
5
+ import { semAcento } from './leitura.mjs';
6
+
7
+ export const BLOG = ['blog-post', 'blog-seo'];
8
+
9
+ // Palavra inteira (antes e depois não há letra nem dígito), com o plural escrito em cada forma.
10
+ const palavra = (formas) => new RegExp(`(?<![\\p{L}\\p{N}])(?:${formas})(?![\\p{L}\\p{N}])`, 'u');
11
+ const CANAL = { instagram: palavra('instagram'), linkedin: palavra('linkedin'), twitter: palavra('twitter|tweets?') };
12
+ // "Slide N" é um slide, não um carrossel: o singular fica de fora.
13
+ const PECA = {
14
+ legenda: palavra('legendas?|captions?'),
15
+ slides: palavra('carrossel|carrosseis|carousels?|slides'),
16
+ post: palavra('posts?'),
17
+ hashtags: palavra('hashtags?'),
18
+ };
19
+ // Linha de slide: "Slide N" ou "Slide #N", com até 12 caracteres que não são letra nem dígito
20
+ // antes (emoji, colchete, ">", "|", "#", negrito, espaço).
21
+ const SLIDE_N = /^[^\p{L}\p{N}]{0,12}slide\s{0,3}#?(\d{1,4})(?!\d)/iu;
22
+ // Cabeçalho que começa por "hashtags" é só hashtags, com qualquer palavra depois.
23
+ const SO_HASHTAGS = /^(?:\*\*|__|\*|_)?\s*hashtags?(?![\p{L}\p{N}])/u;
24
+ const FORMATO_DO_CANAL = { instagram: 'instagram-feed', linkedin: 'linkedin-post', twitter: 'twitter-post' };
25
+ // Só a cerca de código: três crases ou três tis, com ou sem o nome da linguagem.
26
+ const CERCA = /^(?:`{3,}|~{3,})[ \t]{0,3}[\p{L}\p{N}_+.#-]{0,30}$/u;
27
+
28
+ /** Número do slide de uma linha que começa por "Slide N" (cabeçalho, negrito, item ou texto). */
29
+ export function numeroDoSlide(linha) {
30
+ const m = linha.trim().replace(/^(?:#{1,6}|[-*+]|\d{1,3}[.)])\s+/, '').match(SLIDE_N);
31
+ return m ? Number(m[1]) : null;
32
+ }
33
+
34
+ /** `=== RÓTULO ===` → o rótulo inteiro, em maiúsculas; qualquer outra linha → null. */
35
+ export function rotuloDe(linha) {
36
+ const t = linha.trim();
37
+ let inicio = 0;
38
+ let fim = t.length;
39
+ while (t[inicio] === '=') inicio++;
40
+ while (fim > inicio && t[fim - 1] === '=') fim--;
41
+ const nome = t.slice(inicio, fim).trim();
42
+ return inicio >= 3 && t.length - fim >= 3 && nome ? nome.toUpperCase().replace(/\s+/g, ' ') : null;
43
+ }
44
+
45
+ /**
46
+ * Cada linha vira uma marca: rótulo, cabeçalho ou texto. A linha `---` e a que é só a cerca de
47
+ * código não contam como texto. Com `comRotulos` false (formato declarado fora da tabela), a
48
+ * linha `=== RÓTULO ===` é texto comum.
49
+ */
50
+ export function marcar(corpo, comRotulos = true) {
51
+ const marcas = [];
52
+ for (const linha of corpo.split(/\r?\n/)) {
53
+ if (linha.startsWith('---') && linha.trim() === '---') continue;
54
+ if (CERCA.test(linha.trim())) continue;
55
+ const rotulo = comRotulos ? rotuloDe(linha) : null;
56
+ const h = rotulo ? null : linha.match(/^(#{1,6})\s+(.*)$/s);
57
+ if (rotulo) marcas.push({ rotulo });
58
+ else if (h) marcas.push({ nivel: h[1].length, titulo: h[2].trim(), norm: semAcento(h[2]), linha });
59
+ else marcas.push({ linha });
60
+ }
61
+ return marcas;
62
+ }
63
+
64
+ /**
65
+ * A que peças o cabeçalho é candidato (regras 3 e 4): casa com a peça pelo próprio texto, ou tem
66
+ * a palavra da peça e o canal vem do formato declarado ou de um cabeçalho que o contém.
67
+ * @param {string} t cabeçalho sem acentos e em minúsculas
68
+ * @param {string[]} acima cabeçalhos que contêm este, na mesma forma
69
+ * @param {string|null} formato formato declarado da tabela, ou null
70
+ */
71
+ export function candidaturas(t, acima, formato) {
72
+ if (BLOG.includes(formato)) return []; // em blog, cabeçalho é conteúdo
73
+ if (numeroDoSlide(t) != null) return []; // "Slide N" é um slide, com qualquer palavra depois do número
74
+ if (SO_HASHTAGS.test(t)) return ['hashtags'];
75
+ const tem = (rx) => rx.test(t);
76
+ const sob = (rx) => acima.some((a) => rx.test(a));
77
+ const [ig, li, tw] = [tem(CANAL.instagram), tem(CANAL.linkedin), tem(CANAL.twitter)];
78
+ const noInstagram = ig || (!li && !tw && (formato === 'instagram-feed' || sob(CANAL.instagram)));
79
+ const noLinkedin = li || (!ig && !tw && tem(PECA.post) && (formato === 'linkedin-post' || sob(CANAL.linkedin)));
80
+ const tipos = [];
81
+ if (noInstagram && tem(PECA.legenda)) tipos.push('legenda');
82
+ if (noInstagram && tem(PECA.slides)) tipos.push('slides');
83
+ if (noLinkedin && !tem(PECA.slides)) tipos.push('post');
84
+ if (tw) tipos.push('tweet');
85
+ return tipos.length || !tem(PECA.hashtags) ? tipos : ['hashtags'];
86
+ }
87
+
88
+ /** Formato do canal que o cabeçalho cita (Instagram, LinkedIn ou Twitter, nesta ordem), ou null. */
89
+ const canalDe = (t) => FORMATO_DO_CANAL[Object.keys(CANAL).find((c) => CANAL[c].test(t))] ?? null;
90
+
91
+ /**
92
+ * Cabeçalhos de um corpo de blog que casam com outro canal pelos critérios da regra 3 (b), cada
93
+ * um uma vez: em blog declarado a seção não é medida, mas o relatório diz qual é.
94
+ */
95
+ export function secoesDeOutroCanal(corpo) {
96
+ const titulos = new Set();
97
+ let pilha = [];
98
+ for (const m of marcar(corpo)) {
99
+ if (m.rotulo) pilha = [];
100
+ if (!m.nivel) continue;
101
+ pilha = pilha.filter((c) => c.nivel < m.nivel);
102
+ if (candidaturas(m.norm, pilha.map((c) => c.norm), null).some((tipo) => tipo !== 'hashtags')) titulos.add(m.titulo);
103
+ pilha.push(m);
104
+ }
105
+ return [...titulos];
106
+ }
107
+
108
+ function abrir(trechos, trecho) {
109
+ trechos.push({ ...trecho, linhas: [] });
110
+ return trechos.at(-1);
111
+ }
112
+
113
+ /**
114
+ * Percorre as marcas e devolve os trechos, na ordem do texto: `{ rotulo, linhas }` para cada
115
+ * linha de rótulo e `{ titulo, tipos, canal, linhas }` para cada cabeçalho candidato a peça
116
+ * (`canal`: na seção de hashtags, o formato do canal que o cabeçalho cita; senão null).
117
+ * A seção de cabeçalho vai até o próximo cabeçalho do mesmo nível ou acima, ou até um rótulo; a
118
+ * de rótulo, até o próximo rótulo ou até um cabeçalho candidato (regra 2).
119
+ */
120
+ export function lerTrechos(marcas, formato) {
121
+ const trechos = [];
122
+ let pilha = []; // cabeçalhos abertos, do mais externo ao mais interno
123
+ let rotulo = null; // seção de rótulo aberta
124
+ for (const m of marcas) {
125
+ if (m.rotulo) {
126
+ pilha = [];
127
+ rotulo = abrir(trechos, { rotulo: m.rotulo });
128
+ continue;
129
+ }
130
+ if (m.nivel) {
131
+ pilha = pilha.filter((c) => c.nivel < m.nivel);
132
+ const tipos = candidaturas(m.norm, pilha.map((c) => c.norm), formato);
133
+ const canal = tipos[0] === 'hashtags' ? canalDe(m.norm) : null;
134
+ const trecho = tipos.length ? abrir(trechos, { titulo: m.titulo, tipos, canal }) : null;
135
+ pilha.push({ nivel: m.nivel, norm: m.norm, trecho });
136
+ if (trecho) {
137
+ rotulo = null;
138
+ continue;
139
+ }
140
+ }
141
+ const dono = rotulo ?? pilha.findLast((c) => c.trecho)?.trecho;
142
+ dono?.linhas.push(m.linha);
143
+ }
144
+ return trechos;
145
+ }