@aksp/opencrew 1.6.0 → 1.6.2

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 (25) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +19 -5
  3. package/package.json +1 -1
  4. package/src/cli.js +2 -1
  5. package/src/commands/init.js +26 -18
  6. package/src/lib/migrations.js +40 -3
  7. package/templates/_opencrew/.opencrew-version +1 -1
  8. package/templates/_opencrew/core/prompts/build.prompt.md +2 -0
  9. package/templates/_opencrew/core/runner.pipeline.md +54 -26
  10. package/templates/_opencrew/core/scripts/comum.mjs +48 -0
  11. package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +82 -0
  12. package/templates/_opencrew/core/scripts/conferir-fontes/coleta.mjs +104 -0
  13. package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +52 -0
  14. package/templates/_opencrew/core/scripts/conferir-fontes.mjs +82 -136
  15. package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +33 -0
  16. package/templates/_opencrew/core/scripts/verificar/arquivos.mjs +50 -0
  17. package/templates/_opencrew/core/scripts/verificar/html.mjs +52 -0
  18. package/templates/_opencrew/core/scripts/verificar/leitura.mjs +138 -71
  19. package/templates/_opencrew/core/scripts/verificar/medicao.mjs +142 -0
  20. package/templates/_opencrew/core/scripts/verificar/pecas.mjs +190 -0
  21. package/templates/_opencrew/core/scripts/verificar/proibicoes.mjs +100 -0
  22. package/templates/_opencrew/core/scripts/verificar/regras.mjs +142 -92
  23. package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +43 -0
  24. package/templates/_opencrew/core/scripts/verificar/secoes.mjs +145 -0
  25. package/templates/_opencrew/core/scripts/verificar.mjs +162 -88
@@ -1,118 +1,192 @@
1
1
  #!/usr/bin/env node
2
2
  // Verificador automático do OpenCrew — mede o texto ANTES do revisor.
3
- // Uso: node _opencrew/core/scripts/verificar.mjs --crew crews/<nome> --arquivo <a.md>[,<b.md>] [--formato blog-post|blog-seo]
3
+ // Uso: node _opencrew/core/scripts/verificar.mjs --crew crews/<nome> --arquivo "<caminho=formato>[,<caminho=formato>…]" [--formato blog-post|blog-seo]
4
+ // caminho=formato: o formato declarado de cada arquivo (o `format:` do passo que o gerou); o
5
+ // "=formato" é opcional. --formato só escolhe os limites de blog do item sem "=formato" que tem
6
+ // título no frontmatter.
4
7
  // Os limites vêm do frontmatter `constraints:` dos best-practices (fonte única).
5
- // Última linha da saída: VERIFICACAO:OK ou VERIFICACAO:BLOQUEADA (o runner lê esta linha).
6
- // Spec: specs/fase-u1-revisor-com-dentes.md (repositório do OpenCrew).
7
- import { readFile } from 'node:fs/promises';
8
+ // Última linha da saída (o runner lê esta linha): VERIFICACAO:OK, VERIFICACAO:BLOQUEADA ou
9
+ // VERIFICACAO:AGUARDANDO_USUARIO (os únicos bloqueios são [PREENCHER], que só o usuário resolve).
10
+ // Código de saída: 0 = verificou (ao menos um caminho da lista existe) · 1 = erro de uso (opção
11
+ // obrigatória faltando, pasta sem `_opencrew/`, crew inexistente, crew ou caminho fora do
12
+ // projeto, nenhum caminho da lista existe) ou erro que impediu a verificação inteira; com
13
+ // código 1 não há linha VERIFICACAO:.
14
+ // Specs: fase-u1-revisor-com-dentes.md e fase-r1-reparos-1-6-1.md (repositório do OpenCrew).
8
15
  import { existsSync } from 'node:fs';
9
16
  import path from 'node:path';
10
- import { pathToFileURL } from 'node:url';
11
- import { lerLimites, lerSecoes, lerProibicoes, lerDominioDoSite } from './verificar/leitura.mjs';
12
- import { regrasBlog, regrasCanais, regrasGerais, FALTA_INFO } from './verificar/regras.mjs';
17
+ import { erroDeUso, ehPrincipal } from './comum.mjs';
18
+ import { USO, lerArgs, lerItemDaLista } from './verificar/argumentos.mjs';
19
+ import { lerItem } from './verificar/arquivos.mjs';
20
+ import { lerLimites, lerDominioDoSite, semFrontmatter } from './verificar/leitura.mjs';
21
+ import { lerProibicoes } from './verificar/proibicoes.mjs';
22
+ import { lerPecas } from './verificar/pecas.mjs';
23
+ import { medirPecas } from './verificar/medicao.mjs';
24
+ import { regrasGerais, temVariavel, item, FALTA_INFO, NAO_MEDIDO, NAO_VERIFICADO } from './verificar/regras.mjs';
25
+ import { formatarRelatorio } from './verificar/relatorio.mjs';
13
26
 
14
- const FORMATOS_DE_CANAL = ['instagram-feed', 'linkedin-post', 'twitter-post'];
27
+ export { formatarRelatorio };
15
28
 
16
- export async function verificar({ raiz, crew, arquivos, formato = 'blog-post' }) {
17
- const notas = [];
18
- const limites = {};
19
- for (const id of [formato, ...FORMATOS_DE_CANAL]) {
20
- limites[id] = await lerLimites(raiz, id);
21
- if (!limites[id]) notas.push(`Formato "${id}" não encontrado em _opencrew/core/best-practices/ — sem limites para ele.`);
22
- }
29
+ const NAO_LIDO = { ausente: 'arquivo não encontrado', pasta: 'é uma pasta', 'nao-utf8': 'o arquivo não está em UTF-8' };
30
+ const NOTA_VARIAVEL = 'Variável de personalização {{…}}: confira se a sua ferramenta de envio troca pelo dado real.';
31
+ const SEM_PROBLEMA = '✅ Nada a apontar.';
32
+ const SO_GERAIS = (formato) => `⚪ Nada a apontar nas checagens gerais (${formato ? '' : 'formato não informado: '}limites não medidos)`;
33
+ const naoVerificado = (motivo) => item(NAO_VERIFICADO, null, null, 'alerta', motivo);
34
+ const plural = (n, um, varios) => (n === 1 ? um : varios);
35
+
36
+ /** O que vale para a execução inteira: notas, proibições, site e os limites já lidos. */
37
+ async function prepararContexto(raiz, crew, formatoDeBlog) {
38
+ const notas = new Set();
23
39
  const proibicoes = await lerProibicoes(raiz, crew);
24
- if (!proibicoes.existe) notas.push('Sem proibições registradas (a crew não tem memories.md).');
25
- if (proibicoes.semAspas) {
26
- const n = proibicoes.semAspas;
27
- notas.push(`${n} ${n === 1 ? 'proibição' : 'proibições'} sem termo entre aspas ${n === 1 ? 'não é verificada' : 'não são verificadas'} automaticamente — escreva o termo entre aspas na memória para virar trava.`);
28
- }
29
- const dominio = await lerDominioDoSite(raiz);
40
+ if (!proibicoes.existe) notas.add('Sem proibições registradas (a crew não tem memories.md).');
41
+ const n = proibicoes.semAspas;
42
+ if (n) notas.add(`${n} ${plural(n, 'proibição', 'proibições')} sem termo entre aspas ${plural(n, 'não é verificada', 'não são verificadas')} automaticamente — escreva o termo entre aspas na memória para virar trava.`);
43
+ if (proibicoes.preferidos.length) notas.add(`Termos lidos como preferidos (não bloqueiam): ${proibicoes.preferidos.map((t) => `"${t}"`).join(', ')}`);
44
+ return { raiz, formatoDeBlog, notas, proibidos: proibicoes.termos, dominio: await lerDominioDoSite(raiz), limites: {} };
45
+ }
30
46
 
31
- const resultado = [];
32
- for (const rel of arquivos) {
33
- const texto = await readFile(path.join(raiz, rel), 'utf8');
34
- const itens = [
35
- ...regrasBlog(texto, limites[formato], dominio),
36
- ...regrasCanais(lerSecoes(texto), limites),
37
- ...regrasGerais(texto, proibicoes.termos),
38
- ];
39
- resultado.push({ arquivo: rel, itens });
47
+ /** Limites dos formatos pedidos, lidos uma vez por execução; cada aviso vira nota. */
48
+ async function limitesDe(ctx, formatos) {
49
+ for (const id of formatos) {
50
+ if (id in ctx.limites) continue;
51
+ const { limites, nota } = await lerLimites(ctx.raiz, id);
52
+ ctx.limites[id] = limites;
53
+ if (nota) ctx.notas.add(nota);
40
54
  }
41
- const todos = resultado.flatMap((a) => a.itens);
55
+ return ctx.limites;
56
+ }
57
+
58
+ /** Procura e mede as peças de um `.md` ou `.txt` (regras 3 a 6). */
59
+ async function medir(texto, formato, ctx) {
60
+ const pecas = lerPecas(texto, formato, { formatoDeBlog: ctx.formatoDeBlog });
61
+ const formatos = new Set([...(formato ? [formato] : []), ...pecas.map((p) => p.formato)]);
62
+ const limites = await limitesDe(ctx, formatos);
63
+ return medirPecas({ pecas, corpo: semFrontmatter(texto), formato, limites, dominio: ctx.dominio });
64
+ }
65
+
66
+ /** Linha que fecha o arquivo sem bloqueio, alerta nem "Não medido"; null quando há o que apontar. */
67
+ function fechoDe(itens, medidas, formato) {
68
+ const temProblema = itens.some((i) => i.nivel === 'bloqueio' || i.nivel === 'alerta' || i.item === NAO_MEDIDO);
69
+ if (temProblema) return null;
70
+ return medidas ? SEM_PROBLEMA : SO_GERAIS(formato);
71
+ }
72
+
73
+ /** @returns {Promise<object|null>} o resultado do arquivo, ou null quando ele não é texto */
74
+ async function verificarArquivo({ arquivo, formato }, ctx, regraDeTeste) {
75
+ const lido = await lerItem(path.resolve(ctx.raiz, arquivo));
76
+ if (lido.tipo === 'nao-texto') return null;
77
+ if (lido.tipo !== 'texto') return { itens: [naoVerificado(NAO_LIDO[lido.tipo])], fecho: null };
78
+ const { itens: medidos, medidas } = lido.comPecas ? await medir(lido.texto, formato, ctx) : { itens: [], medidas: 0 };
79
+ // Sem procura de peças (.html, .csv…) o formato declarado ainda é lido, pelas notas dele.
80
+ if (formato && !lido.comPecas) await limitesDe(ctx, [formato]);
81
+ const envio = /^(email|whatsapp)-/.test(formato ?? '');
82
+ if (envio && temVariavel(lido.texto)) ctx.notas.add(NOTA_VARIAVEL);
83
+ const gerais = regrasGerais(lido.texto, ctx.proibidos, { variavelBloqueia: !envio });
84
+ const extras = regraDeTeste ? (await regraDeTeste({ arquivo, formato, texto: lido.texto })) ?? [] : [];
85
+ const naoMedido = (i) => i.item === NAO_MEDIDO;
86
+ const itens = [...medidos.filter((i) => !naoMedido(i)), ...gerais, ...extras, ...medidos.filter(naoMedido)];
87
+ return { itens, fecho: fechoDe(itens, medidas, formato) };
88
+ }
89
+
90
+ /** Item da lista → `{ arquivo, formato }`; só o objeto traz formato declarado. */
91
+ function normalizar(entrada) {
92
+ if (typeof entrada === 'string') return { arquivo: entrada, formato: null };
93
+ return { arquivo: entrada.arquivo, formato: entrada.formato || null };
94
+ }
95
+
96
+ /** O mesmo arquivo citado duas vezes, com o mesmo formato, é verificado (e contado) uma vez só. */
97
+ function semRepetidas(raiz, entradas) {
98
+ const vistas = new Set();
99
+ return entradas.filter((e) => {
100
+ const chave = `${path.resolve(raiz, e.arquivo)}|${e.formato ?? ''}`;
101
+ return !vistas.has(chave) && vistas.add(chave);
102
+ });
103
+ }
104
+
105
+ /** Caminho absoluto de dentro do projeto aparece no relatório como o relativo. */
106
+ function nomeNoRelatorio(raiz, arquivo) {
107
+ return path.isAbsolute(arquivo) ? path.relative(raiz, arquivo).split(path.sep).join('/') : arquivo;
108
+ }
109
+
110
+ function resumir(arquivos, naoTexto, notas) {
111
+ const todos = arquivos.flatMap((a) => a.itens);
42
112
  const bloqueios = todos.filter((i) => i.nivel === 'bloqueio');
43
- const alertas = todos.filter((i) => i.nivel === 'alerta').length;
44
113
  // [PREENCHER] só o usuário resolve: não força REJECT (o redator não tem o dado), mas a
45
114
  // aprovação final não fecha sem ele.
46
115
  const reais = bloqueios.filter((i) => i.item !== FALTA_INFO).length;
47
- const status = reais ? 'BLOQUEADA' : bloqueios.length ? 'AGUARDANDO_USUARIO' : 'OK';
48
- return { arquivos: resultado, notas, bloqueios: bloqueios.length, alertas, status };
116
+ return {
117
+ arquivos,
118
+ naoTexto,
119
+ notas: [...notas],
120
+ bloqueios: bloqueios.length,
121
+ alertas: todos.filter((i) => i.nivel === 'alerta').length,
122
+ naoMedidos: todos.filter((i) => i.item === NAO_MEDIDO || i.item === NAO_VERIFICADO).length,
123
+ status: reais ? 'BLOQUEADA' : bloqueios.length ? 'AGUARDANDO_USUARIO' : 'OK',
124
+ };
49
125
  }
50
126
 
51
- const ROTULO = { bloqueio: '❌ Bloqueio', alerta: '⚠️ Alerta', ok: '✅ OK' };
52
- const plural = (n, um, varios) => `${n} ${n === 1 ? um : varios}`;
53
-
54
- export function formatarRelatorio(r) {
55
- const linhas = ['## Verificação automática', ''];
56
- for (const a of r.arquivos) {
57
- linhas.push(`### ${a.arquivo}`, '');
58
- const medidos = a.itens.filter((i) => i.medido != null);
59
- if (medidos.length) {
60
- linhas.push('| Item | Medido | Limite | Resultado |', '|---|---|---|---|');
61
- for (const i of medidos) {
62
- const op = i.nivel === 'alerta' || /links/i.test(i.item) ? '≥' : '≤';
63
- linhas.push(`| ${i.item} | ${i.medido} | ${op} ${i.limite} | ${ROTULO[i.nivel]} |`);
64
- }
65
- linhas.push('');
66
- }
67
- for (const i of a.itens.filter((x) => x.medido == null)) {
68
- linhas.push(`- ${ROTULO[i.nivel]} — ${i.item}: "${i.detalhe}"`);
69
- }
70
- if (!a.itens.length) linhas.push('- ✅ Nada a apontar.');
71
- linhas.push('');
72
- }
73
- if (r.notas.length) linhas.push('**Notas:**', ...r.notas.map((n) => `- ${n}`), '');
74
- linhas.push(`**Resumo: ${plural(r.bloqueios, 'bloqueio', 'bloqueios')}, ${plural(r.alertas, 'alerta', 'alertas')}**`, '');
75
- linhas.push(`VERIFICACAO:${r.status}`);
76
- return linhas.join('\n');
77
- }
78
-
79
- function lerArgs(argv) {
80
- const args = {};
81
- for (let i = 0; i < argv.length; i++) {
82
- const m = argv[i].match(/^--(crew|arquivo|formato)$/);
83
- if (m && i + 1 < argv.length) args[m[1]] = argv[++i];
127
+ /**
128
+ * Verifica os arquivos de saída de uma crew.
129
+ * @param {object} o
130
+ * @param {string} o.raiz pasta do projeto (a que tem `_opencrew/`)
131
+ * @param {string} o.crew pasta da crew, dentro do projeto
132
+ * @param {Array<string|{ arquivo: string, formato?: string }>} o.arquivos caminho (sem formato
133
+ * declarado) ou `{ arquivo, formato }` (formato declarado)
134
+ * @param {string} [o.formato] limites de blog do item sem formato declarado que tem título no frontmatter
135
+ * @param {Function} [o.regraDeTeste] SÓ PARA TESTE: regra extra, chamada com `{ arquivo, formato,
136
+ * texto }` em cada arquivo de texto; serve para simular uma regra que lança erro
137
+ * @returns {Promise<object>} `{ arquivos, naoTexto, notas, bloqueios, alertas, naoMedidos, status }`
138
+ * · `arquivos`: `[{ arquivo, formato, itens, fecho }]`, só os de texto; cada item é
139
+ * `{ item, medido, limite, nivel, detalhe }`, com `nivel` bloqueio, alerta, ok ou null (linha
140
+ * "Não medido" sem nível); para a mesma entrada, arquivo + item + detalhe não mudam
141
+ * · `naoTexto`: caminhos que não são texto · `naoMedidos`: linhas "Não medido"/"Não verificado"
142
+ * · `status`: OK, BLOQUEADA ou AGUARDANDO_USUARIO
143
+ */
144
+ export async function verificar({ raiz, crew, arquivos, formato = 'blog-post', regraDeTeste }) {
145
+ const entradas = semRepetidas(raiz, arquivos.map(normalizar));
146
+ const erro = erroDeUso({ raiz, crew, caminhos: entradas.map((e) => e.arquivo) });
147
+ if (erro) throw new Error(erro);
148
+ const ctx = await prepararContexto(raiz, crew, formato);
149
+ const resultado = [];
150
+ const naoTexto = [];
151
+ for (const entrada of entradas) {
152
+ const arquivo = nomeNoRelatorio(raiz, entrada.arquivo);
153
+ const r = await verificarArquivo(entrada, ctx, regraDeTeste)
154
+ .catch((e) => ({ itens: [naoVerificado(`erro ao verificar: ${e.message}`)], fecho: null }));
155
+ if (r) resultado.push({ arquivo, formato: entrada.formato, ...r });
156
+ else naoTexto.push(arquivo);
84
157
  }
85
- return args;
158
+ return resumir(resultado, naoTexto, ctx.notas);
86
159
  }
87
160
 
88
- const USO = 'Uso: node _opencrew/core/scripts/verificar.mjs --crew crews/<nome> --arquivo <a.md>[,<b.md>] [--formato blog-post|blog-seo]';
89
-
90
- /** @returns {Promise<number>} 0 = verificou (OK ou BLOQUEADA) · 1 = erro de uso */
161
+ /**
162
+ * @returns {Promise<number>} 0 = verificou (ao menos um caminho da lista existe) · 1 = erro de uso,
163
+ * ou erro que impediu a verificação inteira (uma linha em PT-BR, sem linha de status)
164
+ */
91
165
  export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = {}) {
92
166
  const args = lerArgs(argv);
93
- if (!args.crew || !args.arquivo) {
94
- escrever(USO);
167
+ const arquivos = args.arquivos.join(',').split(',').map((s) => s.trim()).filter(Boolean).map(lerItemDaLista);
168
+ const caminhos = arquivos.map((a) => a.arquivo ?? a);
169
+ const faltando = [...(args.crew ? [] : ['--crew']), ...(arquivos.length ? [] : ['--arquivo'])];
170
+ const erro = erroDeUso({ raiz: cwd, faltando, crew: args.crew, caminhos });
171
+ if (erro) {
172
+ escrever(erro);
173
+ if (faltando.length) escrever(USO);
95
174
  return 1;
96
175
  }
97
- const arquivos = args.arquivo.split(',').map((s) => s.trim()).filter(Boolean);
98
- for (const rel of [args.crew, ...arquivos]) {
99
- const abs = path.resolve(cwd, rel);
100
- if (path.relative(cwd, abs).startsWith('..') || path.isAbsolute(path.relative(cwd, abs))) {
101
- escrever(`Caminho fora do projeto: ${rel}`);
102
- return 1;
103
- }
176
+ if (!caminhos.some((c) => existsSync(path.resolve(cwd, c)))) {
177
+ for (const c of new Set(caminhos)) escrever(`Arquivo não encontrado: ${c}`);
178
+ return 1;
104
179
  }
105
- const faltando = arquivos.find((rel) => !existsSync(path.join(cwd, rel)));
106
- if (faltando) {
107
- escrever(`Arquivo não encontrado: ${faltando}`);
180
+ try {
181
+ escrever(formatarRelatorio(await verificar({ raiz: cwd, crew: args.crew, arquivos, formato: args.formato || 'blog-post' })));
182
+ return 0;
183
+ } catch (erroGeral) {
184
+ // Fora da lista de arquivos (memória da crew ou `company.md` ilegível): nada foi verificado.
185
+ escrever(`Não consegui verificar: ${erroGeral.message}`);
108
186
  return 1;
109
187
  }
110
- const r = await verificar({ raiz: cwd, crew: args.crew, arquivos, formato: args.formato || 'blog-post' });
111
- escrever(formatarRelatorio(r));
112
- return 0;
113
188
  }
114
189
 
115
- const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
116
- if (isMain) {
190
+ if (ehPrincipal(import.meta.url)) {
117
191
  main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
118
192
  }