@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
@@ -0,0 +1,104 @@
1
+ // Coleta da conferência de fontes: os caminhos que a crew cita. Lê o crew.yaml, os passos
2
+ // (pipeline/steps/*.md) e todos os .md de agents/, em qualquer nível (agentes e tasks).
3
+ // Spec: specs/fase-r1-reparos-1-6-1.md, regras 15 e 16 (repositório do OpenCrew).
4
+ import { readFile, readdir } from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ // Marcador de modelo: nome a preencher, não arquivo (R1-06j). Pela ordem: ano (`AAAA-MM-DD`,
9
+ // `YYYY`); par de data ou hora sem letra nem dígito em volta (`DD-MM`, `MM-AA`, `HHMM`); número de
10
+ // ordem (`slide-NN.png`, só em maiúsculas); reticências no lugar de uma pasta. Ficam de fora, para
11
+ // não esconder fonte de verdade: `MM`, `DD` e `AA` sozinhos, `XX` e `[…]`.
12
+ const MARCADORES = [
13
+ /AAAA|YYYY/i,
14
+ /(?<![A-Za-z0-9])(?:DD[-_.]?MM|MM[-_.]?DD|MM[-_.]?(?:AA|YY)|(?:AA|YY)[-_.]?MM|HH[-_.h]?MM)(?![A-Za-z0-9])/i,
15
+ /(?<![A-Za-z0-9])NN(?![A-Za-z0-9])/,
16
+ /(^|[\\/])(\.\.\.|…)[\\/]/,
17
+ ];
18
+ // Comando que termina em nome de arquivo (`node scripts/render.js`) não é caminho.
19
+ const COMANDO = /^(?:node|npx|npm|pnpm|yarn|python3?|pip|git|bash|sh|ls|cd|cat|cp|mv|mkdir|curl)\s/;
20
+
21
+ function pareceCaminho(t) {
22
+ if (/[{}<>*$|]/.test(t) || /^https?:/i.test(t) || !/[\\/]/.test(t)) return false;
23
+ if (COMANDO.test(t) || MARCADORES.some((m) => m.test(t))) return false;
24
+ return /\.[A-Za-z0-9]{1,5}$/.test(t) || /[\\/]$/.test(t);
25
+ }
26
+ const saidaDeRun = (t) => /(^|[\\/])(output|_build)[\\/]/.test(t);
27
+
28
+ const ENTRE_CRASES = /`([^`\n]+)`/g;
29
+ // Só espaço e tab antes de `caminho:`: `\s` atravessa linhas e fica lento com muitas em branco.
30
+ const LINHA_DE_CAMINHO = /^([ \t]*(?:-[ \t]*)?caminho:[ \t]*)(.*)$/gm;
31
+ // Linha `- **Writes to**: …` do formato do agente: o que a crew grava (destino), não o que lê.
32
+ const LINHA_DE_DESTINO = /^[ \t]*[-*][ \t]*\*\*Writes to:?\*\*.*$/gim;
33
+
34
+ /** Caminhos de arquivo citados entre crases (saída de run não é fonte). */
35
+ function entreCrases(texto) {
36
+ const citados = [...texto.matchAll(ENTRE_CRASES)].map((m) => m[1].trim());
37
+ return citados.filter((t) => pareceCaminho(t) && !saidaDeRun(t));
38
+ }
39
+
40
+ /** Valor de `caminho:` — aceita aspas simples ou duplas e comentário `# …` no fim da linha. */
41
+ function valorDeCaminho(resto) {
42
+ const entreAspas = resto.match(/^(["'])(.*?)\1\s*(?:#.*)?$/);
43
+ if (entreAspas) return entreAspas[2].trim();
44
+ return resto.replace(/(^|\s)#.*$/, '').trim();
45
+ }
46
+
47
+ /** Os `caminho:` declarados em `fontes:` no crew.yaml. */
48
+ function fontesDeclaradas(texto) {
49
+ const linhas = [...texto.matchAll(LINHA_DE_CAMINHO)];
50
+ return linhas.map((m) => valorDeCaminho(m[2].trim())).filter(Boolean);
51
+ }
52
+
53
+ /**
54
+ * Troca `ref` por `sugestao` só onde a coleta o leu: o texto inteiro de um par de crases e, no
55
+ * crew.yaml, o valor de uma linha `caminho:` (aspas e comentário ficam). O resto do arquivo —
56
+ * outro caminho que contém o citado, frontmatter, texto fora de crases — não muda.
57
+ */
58
+ export function trocarCitacao(texto, { ref, sugestao }, noCrewYaml) {
59
+ const trocar = (trecho) => trecho.replace(ref, () => sugestao);
60
+ const novo = texto.replace(ENTRE_CRASES, (tudo, dentro) => (dentro.trim() === ref ? trocar(tudo) : tudo));
61
+ if (!noCrewYaml) return novo;
62
+ const naLinha = (tudo, chave, resto) => (valorDeCaminho(resto.trim()) === ref ? chave + trocar(resto) : tudo);
63
+ return novo.replace(LINHA_DE_CAMINHO, naLinha);
64
+ }
65
+
66
+ /** Os .md de uma pasta; com `fundo`, também os das subpastas (caminhada própria, Node 20.0). */
67
+ async function arquivosMd(dir, fundo) {
68
+ let entradas;
69
+ try { entradas = await readdir(dir, { withFileTypes: true }); } catch { return []; }
70
+ const achados = [];
71
+ for (const e of entradas) {
72
+ const abs = path.join(dir, e.name);
73
+ if (!e.isDirectory() && e.name.endsWith('.md')) achados.push(abs);
74
+ if (e.isDirectory() && fundo) achados.push(...await arquivosMd(abs, true));
75
+ }
76
+ return achados;
77
+ }
78
+
79
+ /**
80
+ * Caminho citado → { arquivos: os arquivos da crew em que ele aparece, destino: true quando só é
81
+ * citado em linha `Writes to` }.
82
+ */
83
+ export async function coletar(raiz, crew) {
84
+ const base = path.resolve(raiz, crew);
85
+ const crewYaml = path.join(base, 'crew.yaml');
86
+ const arquivos = [
87
+ ...(existsSync(crewYaml) ? [crewYaml] : []),
88
+ ...await arquivosMd(path.join(base, 'pipeline', 'steps'), false),
89
+ ...await arquivosMd(path.join(base, 'agents'), true),
90
+ ];
91
+ const refs = new Map();
92
+ const anotar = (ref, arquivo, destino) => {
93
+ if (!refs.has(ref)) refs.set(ref, { arquivos: new Set(), destino: true });
94
+ refs.get(ref).arquivos.add(arquivo);
95
+ refs.get(ref).destino &&= destino;
96
+ };
97
+ for (const arquivo of arquivos) {
98
+ const texto = await readFile(arquivo, 'utf8');
99
+ const lidos = new Set(entreCrases(texto.replace(LINHA_DE_DESTINO, '')));
100
+ for (const ref of entreCrases(texto)) anotar(ref, arquivo, !lidos.has(ref));
101
+ if (arquivo === crewYaml) for (const ref of fontesDeclaradas(texto)) anotar(ref, arquivo, false);
102
+ }
103
+ return refs;
104
+ }
@@ -0,0 +1,52 @@
1
+ // Relatório da conferência de fontes e as mensagens ao usuário (PT-BR).
2
+ // Spec: specs/fase-r1-reparos-1-6-1.md, regra 17 e seção 6 (repositório do OpenCrew).
3
+ import path from 'node:path';
4
+ import { barra } from './busca.mjs';
5
+
6
+ const plural = (n, um, varios) => `${n} ${n === 1 ? um : varios}`;
7
+
8
+ export const MSG = {
9
+ nadaACorrigir: 'Nada a corrigir.',
10
+ corrigidos: (n) => `${plural(n, 'caminho corrigido', 'caminhos corrigidos')} (cópia .bak ao lado de cada arquivo alterado).\n`,
11
+ semCorrecaoAutomatica: (n) => `Não há correção automática para ${n} pendência(s): escolha um candidato ou corrija o caminho na crew.`,
12
+ buscaParcial: (limite) => `Procurei só nos primeiros ${limite} itens do projeto; pode existir um arquivo com esse nome que eu não vi.`,
13
+ };
14
+
15
+ const MAX_NOMES = 20;
16
+ const comCrases = (lista) => lista.map((c) => `\`${c}\``);
17
+
18
+ /** Os primeiros nomes e, se sobram, quantos: uma pasta grande não pode encher a conversa. */
19
+ function resumida(nomes) {
20
+ if (nomes.length <= MAX_NOMES) return nomes.join(', ');
21
+ return [...nomes.slice(0, MAX_NOMES), `… e mais ${nomes.length - MAX_NOMES}`].join(', ');
22
+ }
23
+
24
+ /** Linha de um caminho que não existe. `aviso` é a frase de busca parcial, ou ''. */
25
+ function linhaDaPendencia(i, onde, aviso) {
26
+ const inicio = `- ❌ Não encontrei \`${i.ref}\` (${onde})`;
27
+ const fim = aviso ? `. ${aviso}` : '';
28
+ const achados = plural(i.candidatos.length, 'candidato', 'candidatos');
29
+ if (i.sugestao) return `${inicio}. Novo caminho sugerido: \`${i.sugestao}\`${fim}`;
30
+ if (i.candidatos.length) return `${inicio}. Encontrei ${achados}: ${resumida(comCrases(i.candidatos))}${fim}`;
31
+ if (i.pasta.length) return `${inicio}. Na pasta esperada existem: ${resumida(i.pasta)}${fim}`;
32
+ return aviso ? `${inicio}${fim}` : `${inicio} nem nada com esse nome no projeto.`;
33
+ }
34
+
35
+ function linhaDoAlerta(i, onde) {
36
+ const sugestao = i.sugestao ? ` Sugestão: \`${i.sugestao}\`` : '';
37
+ return `- ⚠️ \`${i.ref}\` é um caminho absoluto (não é portátil — quebra em outro computador).${sugestao} (${onde})`;
38
+ }
39
+
40
+ export function formatar(r) {
41
+ const aviso = r.buscaParcial ? MSG.buscaParcial(r.limite) : '';
42
+ const contar = (estado) => r.refs.filter((i) => i.estado === estado).length;
43
+ const apontados = r.refs.filter((x) => x.estado !== 'ok');
44
+ const linhas = [`## Conferência de fontes — ${r.crew}`, ''];
45
+ for (const i of apontados) {
46
+ const onde = `citado em ${i.citadoEm.map((a) => barra(path.relative(r.raiz, a))).join(', ')}`;
47
+ linhas.push(i.estado === 'nao-portatil' ? linhaDoAlerta(i, onde) : linhaDaPendencia(i, onde, aviso));
48
+ }
49
+ if (apontados.length) linhas.push(''); // sem pendência nem alerta, uma linha em branco só
50
+ linhas.push(`**Resumo: ${r.refs.length} fontes — ${contar('ok')} ok, ${contar('faltando')} pendentes, ${contar('nao-portatil')} alertas**`, '');
51
+ return linhas.join('\n');
52
+ }
@@ -3,111 +3,67 @@
3
3
  // existem. Se foram movidos, sugere o novo caminho (relativo à raiz do projeto); se o nome mudou,
4
4
  // lista o que existe na pasta esperada. Nunca apaga nada; só corrige com --corrigir (e .bak).
5
5
  // Uso: node _opencrew/core/scripts/conferir-fontes.mjs --crew crews/<nome> [--corrigir]
6
+ // Rode a partir da pasta do projeto (a que tem `_opencrew/`); a crew fica dentro dela.
6
7
  // Última linha da saída: FONTES:OK ou FONTES:PENDENTE (o runner lê esta linha).
7
- // Spec: specs/fase-u2-crew-que-conhece-o-projeto.md (repositório do OpenCrew).
8
- import { readFile, writeFile, readdir, copyFile } from 'node:fs/promises';
8
+ // Código de saída: 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso (opção faltando, pasta sem
9
+ // `_opencrew/`, crew fora do projeto ou inexistente), sem linha FONTES: e sem escrever nada.
10
+ // Specs: specs/fase-u2-crew-que-conhece-o-projeto.md e specs/fase-r1-reparos-1-6-1.md
11
+ // (repositório do OpenCrew). Módulos em conferir-fontes/: coleta, busca e relatório.
12
+ import { readFile, writeFile, copyFile } from 'node:fs/promises';
9
13
  import { existsSync } from 'node:fs';
10
14
  import path from 'node:path';
11
- import { pathToFileURL } from 'node:url';
15
+ import { erroDeUso, dentroDoProjeto, ehPrincipal } from './comum.mjs';
16
+ import { coletar, trocarCitacao } from './conferir-fontes/coleta.mjs';
17
+ import {
18
+ LIMITE_DA_BUSCA, barra, ehAbsoluto, temBarraFinal, resolver, indexar, candidatosPorNome, nomesDaPastaEsperada,
19
+ } from './conferir-fontes/busca.mjs';
20
+ import { formatar, MSG } from './conferir-fontes/relatorio.mjs';
12
21
 
13
- const IGNORAR = new Set(['node_modules', 'output', '_opencrew', '_build']);
14
- const LIMITE_ENTRADAS = 20000;
22
+ export { formatar };
15
23
 
16
- const barra = (p) => p.split(path.sep).join('/');
17
- const ehAbsoluto = (p) => /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('/');
18
-
19
- function pareceCaminho(t) {
20
- if (/[{}<>*$|]/.test(t) || /^https?:/i.test(t) || !/[\\/]/.test(t)) return false;
21
- return /\.[A-Za-z0-9]{1,5}$/.test(t) || /[\\/]$/.test(t);
22
- }
23
- const saidaDeRun = (t) => /(^|[\\/])(output|_build)[\\/]/.test(t);
24
-
25
- /** Caminhos citados entre crases no crew.yaml e nos passos, mais as `fontes:` do crew.yaml. */
26
- async function coletar(raiz, crew) {
27
- const refs = new Map();
28
- const add = (ref, arquivo) => {
29
- if (!refs.has(ref)) refs.set(ref, new Set());
30
- refs.get(ref).add(arquivo);
31
- };
32
- const crewYaml = path.join(raiz, crew, 'crew.yaml');
33
- const passos = path.join(raiz, crew, 'pipeline', 'steps');
34
- const arquivos = existsSync(crewYaml) ? [crewYaml] : [];
35
- if (existsSync(passos)) {
36
- for (const f of await readdir(passos)) if (f.endsWith('.md')) arquivos.push(path.join(passos, f));
37
- }
38
- for (const arquivo of arquivos) {
39
- const texto = await readFile(arquivo, 'utf8');
40
- for (const m of texto.matchAll(/`([^`\n]+)`/g)) {
41
- const t = m[1].trim();
42
- if (pareceCaminho(t) && !saidaDeRun(t)) add(t, arquivo);
43
- }
44
- if (arquivo === crewYaml) {
45
- for (const m of texto.matchAll(/^\s*-?\s*caminho:\s*["']?([^"'\n#]+?)["']?\s*$/gm)) add(m[1].trim(), arquivo);
46
- }
47
- }
48
- return refs;
49
- }
50
-
51
- function resolver(raiz, crew, ref) {
52
- if (ehAbsoluto(ref)) return existsSync(ref) ? path.resolve(ref) : null;
53
- for (const base of [path.join(raiz, crew), raiz]) {
54
- const p = path.resolve(base, ref);
55
- if (existsSync(p)) return p;
56
- }
57
- return null;
24
+ /**
25
+ * Caminho absoluto que existe dentro do projeto: o mesmo caminho, relativo à raiz. A própria raiz
26
+ * não tem caminho relativo a sugerir.
27
+ */
28
+ function sugestaoRelativa(raiz, ref, achado) {
29
+ const relativo = dentroDoProjeto(raiz, achado) ? barra(path.relative(raiz, achado)) : '';
30
+ return relativo ? relativo + (temBarraFinal(ref) ? '/' : '') : null;
58
31
  }
59
32
 
60
- /** Índice nome-do-arquivo → caminhos relativos, ignorando saídas, dependências e pastas ocultas. */
61
- async function indexar(raiz) {
62
- const porNome = new Map();
63
- let n = 0;
64
- async function walk(dir) {
65
- let entradas;
66
- try { entradas = await readdir(dir, { withFileTypes: true }); } catch { return; }
67
- for (const e of entradas) {
68
- if (++n > LIMITE_ENTRADAS) return;
69
- if (e.name.startsWith('.') || (e.isDirectory() && IGNORAR.has(e.name))) continue;
70
- const abs = path.join(dir, e.name);
71
- const chave = e.name.toLowerCase();
72
- if (!porNome.has(chave)) porNome.set(chave, []);
73
- porNome.get(chave).push(barra(path.relative(raiz, abs)) + (e.isDirectory() ? '/' : ''));
74
- if (e.isDirectory()) await walk(abs);
75
- }
76
- }
77
- await walk(raiz);
78
- return porNome;
33
+ /**
34
+ * Caminho que não existe: candidatos com o mesmo nome ou, sem nenhum, o que há na pasta esperada.
35
+ * Destino (só citado em linha `Writes to`) não ganha sugestão: com a troca, a crew gravaria por
36
+ * cima do arquivo achado. O candidato é listado e a escolha fica com o usuário. Busca que parou
37
+ * no limite também não sugere: o único candidato visto pode não ser o único que existe.
38
+ */
39
+ async function procurar(item, { raiz, crew, indice, destino }) {
40
+ item.estado = 'faltando';
41
+ item.candidatos = candidatosPorNome(indice, item.ref);
42
+ if (item.candidatos.length === 1 && !destino && !indice.parcial) item.sugestao = item.candidatos[0];
43
+ if (!item.candidatos.length) item.pasta = await nomesDaPastaEsperada(raiz, crew, item.ref);
79
44
  }
80
45
 
81
- export async function conferir({ raiz, crew }) {
82
- const refs = await coletar(raiz, crew);
46
+ /**
47
+ * Confere os caminhos que a crew cita. `limite` é o máximo de itens do projeto vistos na busca
48
+ * por nome; quando a busca para nele, `buscaParcial` é true.
49
+ */
50
+ export async function conferir({ raiz, crew, limite = LIMITE_DA_BUSCA }) {
83
51
  let indice = null;
84
- const lista = [];
85
- for (const [ref, citado] of refs) {
86
- const item = { ref, citadoEm: [...citado], estado: 'ok', sugestao: null, candidatos: [], pasta: [] };
87
- const achado = resolver(raiz, crew, ref);
88
- if (achado) {
89
- if (ehAbsoluto(ref)) {
90
- item.estado = 'nao-portatil';
91
- const rel = path.relative(raiz, achado);
92
- if (!rel.startsWith('..') && !path.isAbsolute(rel)) item.sugestao = barra(rel) + (/[\\/]$/.test(ref) ? '/' : '');
93
- }
94
- } else {
95
- item.estado = 'faltando';
96
- indice ??= await indexar(raiz);
97
- const nome = path.basename(ref.replace(/[\\/]+$/, '')).toLowerCase();
98
- item.candidatos = (indice.get(nome) ?? []).filter((c) => /[\\/]$/.test(ref) === c.endsWith('/'));
99
- if (item.candidatos.length === 1) item.sugestao = item.candidatos[0];
100
- if (!item.candidatos.length) {
101
- const pai = resolver(raiz, crew, path.dirname(ref.replace(/[\\/]+$/, '')));
102
- if (pai) {
103
- try { item.pasta = (await readdir(pai)).filter((f) => !f.startsWith('.')); } catch { /* não é pasta */ }
104
- }
105
- }
52
+ const refs = [];
53
+ for (const [ref, { arquivos, destino }] of await coletar(raiz, crew)) {
54
+ const item = { ref, citadoEm: [...arquivos], estado: 'ok', sugestao: null, candidatos: [], pasta: [] };
55
+ const achado = resolver(raiz, crew, ref, item.citadoEm);
56
+ if (!achado) {
57
+ indice ??= await indexar(raiz, limite);
58
+ await procurar(item, { raiz, crew, indice, destino });
59
+ } else if (ehAbsoluto(ref)) {
60
+ item.estado = 'nao-portatil';
61
+ item.sugestao = sugestaoRelativa(raiz, ref, achado);
106
62
  }
107
- lista.push(item);
63
+ refs.push(item);
108
64
  }
109
- const status = lista.some((i) => i.estado === 'faltando') ? 'PENDENTE' : 'OK';
110
- return { crew, raiz, refs: lista, status };
65
+ const status = refs.some((i) => i.estado === 'faltando') ? 'PENDENTE' : 'OK';
66
+ return { crew, raiz, refs, status, buscaParcial: Boolean(indice?.parcial), limite };
111
67
  }
112
68
 
113
69
  async function copiaDeSeguranca(arquivo) {
@@ -115,7 +71,10 @@ async function copiaDeSeguranca(arquivo) {
115
71
  await copyFile(arquivo, bak);
116
72
  }
117
73
 
118
- /** Troca, nos arquivos da crew, cada caminho com sugestão única. @returns quantos caminhos */
74
+ /**
75
+ * Troca, nos arquivos da crew, cada caminho com sugestão única — só a citação que a coleta leu,
76
+ * nunca um pedaço de outro texto. @returns quantos caminhos
77
+ */
119
78
  export async function corrigir({ resultado }) {
120
79
  const comSugestao = resultado.refs.filter((i) => i.sugestao && i.estado !== 'ok');
121
80
  const tocados = new Set();
@@ -126,64 +85,51 @@ export async function corrigir({ resultado }) {
126
85
  await copiaDeSeguranca(arquivo);
127
86
  tocados.add(arquivo);
128
87
  }
129
- await writeFile(arquivo, texto.split(item.ref).join(item.sugestao));
88
+ await writeFile(arquivo, trocarCitacao(texto, item, path.basename(arquivo) === 'crew.yaml'));
130
89
  }
131
90
  }
132
91
  return comSugestao.length;
133
92
  }
134
93
 
135
- export function formatar(r) {
136
- const rel = (a) => barra(path.relative(r.raiz, a));
137
- const linhas = [`## Conferência de fontes — ${r.crew}`, ''];
138
- for (const i of r.refs.filter((x) => x.estado !== 'ok')) {
139
- const onde = `citado em ${i.citadoEm.map(rel).join(', ')}`;
140
- if (i.estado === 'nao-portatil') {
141
- linhas.push(`- ⚠️ \`${i.ref}\` é um caminho absoluto (não é portátil — quebra em outro computador).${i.sugestao ? ` Sugestão: \`${i.sugestao}\`` : ''} (${onde})`);
142
- } else if (i.sugestao) {
143
- linhas.push(`- ❌ Não encontrei \`${i.ref}\` (${onde}). Novo caminho sugerido: \`${i.sugestao}\``);
144
- } else if (i.candidatos.length) {
145
- linhas.push(`- ❌ Não encontrei \`${i.ref}\` (${onde}). Encontrei ${i.candidatos.length} candidatos: ${i.candidatos.map((c) => `\`${c}\``).join(', ')}`);
146
- } else if (i.pasta.length) {
147
- linhas.push(`- ❌ Não encontrei \`${i.ref}\` (${onde}). Na pasta esperada existem: ${i.pasta.join(', ')}`);
148
- } else {
149
- linhas.push(`- ❌ Não encontrei \`${i.ref}\` (${onde}) nem nada com esse nome no projeto.`);
150
- }
94
+ /** --corrigir: troca o que tem sugestão única e diz quantas pendências ficam sem correção. */
95
+ async function corrigirEAvisar(r, escrever) {
96
+ const n = await corrigir({ resultado: r });
97
+ let atual = r;
98
+ if (n) {
99
+ escrever(MSG.corrigidos(n));
100
+ atual = await conferir({ raiz: r.raiz, crew: r.crew });
101
+ escrever(formatar(atual));
151
102
  }
152
- const ok = r.refs.filter((i) => i.estado === 'ok').length;
153
- const pend = r.refs.filter((i) => i.estado === 'faltando').length;
154
- const alertas = r.refs.filter((i) => i.estado === 'nao-portatil').length;
155
- linhas.push('', `**Resumo: ${r.refs.length} fontes — ${ok} ok, ${pend} pendentes, ${alertas} alertas**`, '');
156
- return linhas.join('\n');
103
+ const semSugestao = atual.refs.filter((i) => i.estado === 'faltando' && !i.sugestao).length;
104
+ if (semSugestao) escrever(MSG.semCorrecaoAutomatica(semSugestao));
105
+ else if (!n) escrever(MSG.nadaACorrigir);
106
+ return atual;
157
107
  }
158
108
 
159
- /** @returns {Promise<number>} 0 = conferiu · 1 = erro de uso */
160
- export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = {}) {
109
+ const USO = 'Uso: node _opencrew/core/scripts/conferir-fontes.mjs --crew crews/<nome> [--corrigir]';
110
+
111
+ function lerCrew(argv) {
161
112
  const i = argv.indexOf('--crew');
162
- const crew = i > -1 ? argv[i + 1] : null;
163
- if (!crew) {
164
- escrever('Uso: node _opencrew/core/scripts/conferir-fontes.mjs --crew crews/<nome> [--corrigir]');
165
- return 1;
166
- }
167
- if (!existsSync(path.join(cwd, crew))) {
168
- escrever(`Crew não encontrada: ${crew}`);
113
+ const valor = i > -1 ? argv[i + 1] : null;
114
+ return valor && !valor.startsWith('--') ? valor : null;
115
+ }
116
+
117
+ /** @returns {Promise<number>} 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso */
118
+ export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = {}) {
119
+ const crew = lerCrew(argv);
120
+ const erro = erroDeUso({ raiz: cwd, faltando: crew ? [] : ['--crew'], crew });
121
+ if (erro) {
122
+ escrever(erro);
123
+ if (!crew) escrever(USO);
169
124
  return 1;
170
125
  }
171
126
  let r = await conferir({ raiz: cwd, crew });
172
127
  escrever(formatar(r));
173
- if (argv.includes('--corrigir')) {
174
- const n = await corrigir({ resultado: r });
175
- if (!n) escrever('Nada a corrigir.');
176
- else {
177
- escrever(`${n} ${n === 1 ? 'caminho corrigido' : 'caminhos corrigidos'} (cópia .bak ao lado de cada arquivo alterado).\n`);
178
- r = await conferir({ raiz: cwd, crew });
179
- escrever(formatar(r));
180
- }
181
- }
128
+ if (argv.includes('--corrigir')) r = await corrigirEAvisar(r, escrever);
182
129
  escrever(`FONTES:${r.status}`);
183
130
  return 0;
184
131
  }
185
132
 
186
- const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
187
- if (isMain) {
133
+ if (ehPrincipal(import.meta.url)) {
188
134
  main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
189
135
  }
@@ -0,0 +1,33 @@
1
+ // Linha de comando do verificador: as opções, os itens `caminho=formato` e a linha de uso.
2
+ // Spec: fase-r1-reparos-1-6-1.md, §3.
3
+
4
+ // Uma linha só, e sem o texto do status: em erro de uso a saída não tem linha de status.
5
+ export const USO = 'Uso: node _opencrew/core/scripts/verificar.mjs --crew crews/<nome> --arquivo "<caminho=formato>[,<caminho=formato>…]" [--formato blog-post|blog-seo]'
6
+ + ' — o "=formato" é opcional; o relatório termina em OK, BLOQUEADA ou AGUARDANDO_USUARIO; código de saída: 0 = verificou, 1 = erro de uso';
7
+
8
+ /**
9
+ * Opções da linha de comando: `{ crew, formato, arquivos }`. `--arquivo` repetido soma à lista, e
10
+ * o que vem solto logo depois da lista (sem `--`) é mais um item dela: nenhum arquivo citado fica
11
+ * sem verificação.
12
+ */
13
+ export function lerArgs(argv) {
14
+ const args = { arquivos: [] };
15
+ let naLista = false;
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const opcao = argv[i].match(/^--(crew|arquivo|formato)$/)?.[1];
18
+ if (opcao && i + 1 < argv.length) {
19
+ if (opcao === 'arquivo') args.arquivos.push(argv[++i]);
20
+ else args[opcao] = argv[++i];
21
+ naLista = opcao === 'arquivo';
22
+ } else if (naLista && !argv[i].startsWith('--')) args.arquivos.push(argv[i]);
23
+ else naLista = false;
24
+ }
25
+ return args;
26
+ }
27
+
28
+ /** `caminho=formato` → `{ arquivo, formato }`; "=formato" só vale com minúsculas, dígitos e hífen. */
29
+ export function lerItemDaLista(texto) {
30
+ const igual = texto.lastIndexOf('=');
31
+ const formato = igual > 0 ? texto.slice(igual + 1) : '';
32
+ return /^[a-z0-9-]+$/.test(formato) ? { arquivo: texto.slice(0, igual), formato } : texto;
33
+ }
@@ -0,0 +1,50 @@
1
+ // O que é cada item da lista do verificador: texto, não-texto, pasta, ausente ou texto que não
2
+ // está em UTF-8 (o UTF-16 com a marca de início é lido).
3
+ // Só texto é verificado; em HTML, o texto visível e os links (fase-r1-reparos-1-6-1.md, regra 9).
4
+ import { readFile, stat } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { semBom } from './leitura.mjs';
7
+ import { textoDeHtml } from './html.mjs';
8
+
9
+ const NAO_TEXTO = new Set(
10
+ '.png .jpg .jpeg .gif .webp .bmp .ico .svg .pdf .doc .docx .xls .xlsx .ods .pptx .mp3 .wav .ogg .mp4 .mov .webm .zip .css .js .json'.split(' '),
11
+ );
12
+ const HTML = new Set(['.html', '.htm']);
13
+ const COM_PECAS = new Set(['.md', '.txt']);
14
+
15
+ async function tipoNoDisco(caminho) {
16
+ try {
17
+ return (await stat(caminho)).isDirectory() ? 'pasta' : 'arquivo';
18
+ } catch (erro) {
19
+ if (erro.code === 'ENOENT' || erro.code === 'ENOTDIR') return 'ausente';
20
+ throw erro;
21
+ }
22
+ }
23
+
24
+ /** Texto de um arquivo que começa pela marca de UTF-16 (FF FE ou FE FF); sem a marca, null. */
25
+ function deUtf16(bytes) {
26
+ const [a, b] = bytes;
27
+ if (!(a === 0xff && b === 0xfe) && !(a === 0xfe && b === 0xff)) return null;
28
+ const pares = bytes.subarray(0, bytes.length - (bytes.length % 2));
29
+ return (a === 0xfe ? pares.swap16() : pares).toString('utf16le');
30
+ }
31
+
32
+ /**
33
+ * Lê um item da lista.
34
+ * @returns {Promise<{ tipo: 'ausente'|'pasta'|'nao-texto'|'nao-utf8'|'texto', texto?: string, comPecas?: boolean }>}
35
+ * `comPecas`: a procura de peças só vale para `.md` e `.txt` · `nao-utf8`: `.md`, `.txt`,
36
+ * `.html` ou `.htm` com byte nulo e sem a marca de UTF-16
37
+ */
38
+ export async function lerItem(caminho) {
39
+ const tipo = await tipoNoDisco(caminho);
40
+ if (tipo !== 'arquivo') return { tipo };
41
+ const ext = path.extname(caminho).toLowerCase();
42
+ if (NAO_TEXTO.has(ext)) return { tipo: 'nao-texto' };
43
+ const bytes = await readFile(caminho);
44
+ const deTexto = COM_PECAS.has(ext) || HTML.has(ext);
45
+ const utf16 = deTexto ? deUtf16(bytes) : null;
46
+ // Byte nulo é conteúdo binário; em arquivo de texto, é outra codificação.
47
+ if (utf16 == null && bytes.includes(0)) return { tipo: deTexto ? 'nao-utf8' : 'nao-texto' };
48
+ const texto = semBom(utf16 ?? bytes.toString('utf8'));
49
+ return { tipo: 'texto', texto: HTML.has(ext) ? textoDeHtml(texto) : texto, comPecas: COM_PECAS.has(ext) };
50
+ }
@@ -0,0 +1,52 @@
1
+ // Texto de um HTML para as checagens gerais: o texto visível e os valores de `href`, `src` e
2
+ // `alt`. Ficam de fora `<style>`, `<script>`, os comentários e o resto das tags.
3
+ // Uma passada só pelo arquivo, de `<` em `<`: nada aqui recomeça a busca do início.
4
+
5
+ const ATRIBUTO = /(?:^|\s)(href|src|alt)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi;
6
+ const DE_BLOCO = /^\/?(?:p|div|br|hr|li|ul|ol|h[1-6]|tr|td|th|table|section|article|header|footer|main|nav|aside|title|blockquote|figure|figcaption|body|head|html)\b/i;
7
+ const ENTIDADES = { '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" };
8
+
9
+ /** Posição logo depois de `marca`, procurada a partir de `de`; o fim do texto se não houver. */
10
+ function depoisDe(html, marca, de) {
11
+ marca.lastIndex = de;
12
+ const m = marca.exec(html);
13
+ return m ? m.index + m[0].length : html.length;
14
+ }
15
+
16
+ /** Consome o que começa no `<` da posição `abre` e devolve a posição seguinte. */
17
+ function consumir(html, abre, saida) {
18
+ if (html.startsWith('<!--', abre)) return depoisDe(html, /-->/g, abre + 4);
19
+ const ehTag = /[a-z/!?]/i.test(html[abre + 1] ?? '');
20
+ const fecha = ehTag ? html.indexOf('>', abre) : -1;
21
+ if (fecha < 0) {
22
+ // "<" solto é texto; tag sem nenhum ">" adiante: o resto todo é texto.
23
+ const ate = ehTag ? html.length : abre + 1;
24
+ saida.visivel.push(html.slice(abre, ate));
25
+ return ate;
26
+ }
27
+ const tag = html.slice(abre + 1, fecha);
28
+ for (const m of tag.matchAll(ATRIBUTO)) {
29
+ const valor = m[2] ?? m[3] ?? m[4];
30
+ // `src="data:…"` é o conteúdo de um arquivo embutido, não um link: fica de fora.
31
+ if (m[1].toLowerCase() === 'alt' || !/^\s*data:/i.test(valor)) saida.valores.push(valor);
32
+ }
33
+ if (DE_BLOCO.test(tag)) saida.visivel.push('\n');
34
+ // Só `<script>` e `<style>`: `<script-x>` e `<style-guia>` são tags comuns.
35
+ const invisivel = tag.match(/^(script|style)(?=[\s/]|$)/i);
36
+ return invisivel ? depoisDe(html, new RegExp(`</${invisivel[1]}\\s*>`, 'gi'), fecha + 1) : fecha + 1;
37
+ }
38
+
39
+ /** Texto visível do HTML, seguido dos valores de `href`, `src` e `alt` (um por linha). */
40
+ export function textoDeHtml(html) {
41
+ const saida = { visivel: [], valores: [] };
42
+ let pos = 0;
43
+ while (pos < html.length) {
44
+ const abre = html.indexOf('<', pos);
45
+ if (abre < 0) break;
46
+ saida.visivel.push(html.slice(pos, abre));
47
+ pos = consumir(html, abre, saida);
48
+ }
49
+ saida.visivel.push(html.slice(pos));
50
+ const texto = [saida.visivel.join(''), ...saida.valores].join('\n');
51
+ return texto.replace(/&(?:nbsp|amp|lt|gt|quot|#39);/g, (entidade) => ENTIDADES[entidade]);
52
+ }